likes
comments
collection
share

走进熟悉又陌生的webpack

作者站长头像
站长
· 阅读数 12

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第3篇文章,点击查看活动详情

webpack对很多人来说都很熟悉,因为日常工作中我们都会用到,例如vue-cli这些脚手架,都是用webpack搭建出来的,极大的方便了我们的开发体验。但同时我们又对它感到很陌生,因为我们很少去真正的用到它,得益于社区的完善,大多数时候我们只需要下载一些插件再照着文档改改配置文件,就能满足我们日常的开发需要,这也是造就了webpack对很多开发者来说是个既熟悉又陌生的东西。尽管如此,我们仍有必要学习学习webpack的知识,毕竟说面试官也要考,谁会跟钱过不去呢。

接下来本文就带大家走进webpack的世界,虽不能让你成为webpack方面的高手,但也希望让你能对webpack有所了解,哪怕只是一小部分。

本文的示例及讲解基于webpack5.0

webpack是什么

在讲webpack是什么之前,让我们先把时间倒回到我们最初接触前端开发的时候,一开始我们还没有用上什么脚手架或打包工具之类的时候,都是一个html文件走天下,需要js就新建个js文件然后再来个script将其引入,css也是一样,总之就是在html里一把梭。

但随着需求越来越多,代码量也越来越多,一个html文件也越来越臃肿,各种引用的文件错综复杂,且要按照一定的顺序用script标签来引入,稍一不小心就报错了,例如a.js用到了jquery的API,不小心将a.js的引入放在jquery之前就报错了。

到后来,node.js出现了,node拥有对文件的操作能力,各类基于node的打包工具也随之出现,如本文所讲的webpack,除此之外还有rollupgulp等等。

回到webpack是什么的问题上,简而言之webpeck是一个用于现代 JavaScript 应用程序的静态模块打包工具,它从你的应用入口出发,递归查找你所用到的模块并构建出一个依赖关系图,最终将所有的模块打包成一个或多个bundles,他们都是浏览器所能识别的静态资源文件。

走进熟悉又陌生的webpack

webpack核心概念

前置准备

开始认识webpack的核心概念之前,这里我们先创建一个webpack的demo,方便后续的讲解。

先创建一个名为webpack-demo的文件夹,进入该文件夹:

// 初始化
npm inin -y

// 下载webpack和webpack-cli
npm i webpack webpack-cli --D

接着我们在项目下新建一个src/index.js,创建后的项目结构如下:

走进熟悉又陌生的webpack

入口entry

entry指明webpack使用哪个模块作为开始,来作为其构建内部依赖的起点。

随便在src/index.js里添加点东西:

console.log('hello world')

在package.json里增加一条命令:

"scripts": {
  "build": "webpack"
}

运行npm run build后可以看到,src/index.js下的内容已经被输出到dist/main.js下。

走进熟悉又陌生的webpack

webpack入口默认是./src/index.js,当然也可以自己指定入口文件,这时候需要创建一个webpack.config.js并设置entry。

走进熟悉又陌生的webpack

输出output

output是webpack构建后文件输出的位置,以及文件的命名。主要输出文件的默认值是./dist/main.js,其他生成文件默认在./dist下。当然也是可以通过在配置中指定output的。

const path = require('path')

module.exports = {
  entry: './src/index.js', // 指定入口文件
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'index.[hash].js'
  }
}

这里我们在配置里修改了输出的文件名规则,在后面加上了一串hash字符串。path是node自带的一个模块,这里可以不用install。

走进熟悉又陌生的webpack

模式mode

webpack的模式有三种:development、production、none,默认值为production。

  • development:一些没有用到的方法变量等会被保留,production则会自动移除
  • production:会进行代码压缩等优化操作
  • none:不做任何处理

使用命令行设置mode:

"scripts": {
  "build": "webpack --mode=development"
}

或者设置配置文件的mode字段:

module.exports = {
  mode: 'development'
}

模块转换器loader

webpack自身只理解js和json文件,loader的作用便是处理其他类型的文件,并将它们转换成webpack能识别的模块。例如css-loader用于转化css内容,ts-loader用于将ts转换为js。

我们试着新建一个样式文件并将其引入:

走进熟悉又陌生的webpack

走进熟悉又陌生的webpack

运行npm run build后会发现报错了:

走进熟悉又陌生的webpack

因为webpack只理解js和json文件,当我们没有指定loader去解析style.css这个文件时,webpack就当成js文件或json文件去解析了,遇到css的代码自然就解析出错了。

所以这时候如果我们修改style.css里的代码为js内容会发现是可以正常构建成功的,虽然这并不符合我们的预期。

// style.css
const name = '张三'

当然这并不是我们的初衷,我们需要的是可以正常解析css文件里的内容。webpack提供一个module对象,通过module下的rules属性可以使用特定的loader对特定的文件进行转换。

rules里包含两个必须的属性:

  • test:声明需要转换的文件类型,该值通常是一个正则表达式。
  • use:声明转换时所要使用的loader。use可以是一个字符串也可以是一个数组,use为数组时的执行顺序为从右向左
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: 'css-loader'
      }
    ]
  }
}

上面我们添加一个css-loader对css文件进行转换,重新npm run build构建后查看输出文件可以发现style.css文件可以被正常转换了。

走进熟悉又陌生的webpack

插件plugin

plugin可以在webpack构建流程中的某个时机注入扩展逻辑来改变构建结果。

插件通过plugins字段传入,plugins是一个数组。

const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
  plugins: [
    new CleanWebpackPlugin()
  ]
}

clean-webpack-plugin插件可以在每次构建输出的时候清空dist文件夹,避免上次打包的东西遗留下来形成堆积。

手写一个loader

之前我们已经使用css-loader对css文件做了转换,但我们还没有应用这个css文件的内容,接下来我们以编写一个style-loader为例,实现将css文件内容插入到html使其生效。

编写的loader需要是一个函数,因为函数里的this指向的是webpack的上下文,所以该函数不能是箭头函数,否则会导致不能使用this访问webpack内部的一些属性和方法。

// loader/style-loader.js
function styleLoader(source) {
  console.log('source===', source)
  
  return source
}

module.exports = styleLoader

loader函数接收的参数是源文件的内容,该函数必须返回处理后的结果。

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          {
            // 引入我们自定义的loader
            loader: path.resolve(__dirname, './loader/style-loader.js')
          },
          'css-loader'
        ]
      }
    ]
  }
}

使用该loader并进行构建,可以看到source的内容便是我们css文件里的内容。

走进熟悉又陌生的webpack

loader函数必须返回结果,除了使用return之外,也可以使用this上的callback方法。

function styleLoader(source) {
  console.log('source===', source)
  
  // return source
  this.callback(null, source)
}

callback接收4个参数:

  • error:必填,必须是一个Error或null。
  • content:必填,处理后要返回的结果。
  • map:选填,sourceMap相关内容。
  • meta:选填,传递给下一个loader的额外信息。

如果loader函数里有异步操作,可以使用this.async来获取callback再进行结果返回,async表明了loader将会异步的回调。

function styleLoader(source) {
  console.log('source===', source)
  let callback = this.async()
  setTimeout(() => {
    callback(null, source)
  }, 2000)
}

接下来我们创建一个html文件:

走进熟悉又陌生的webpack

然后使用html-webpack-plugin自动在dist目录下生成一个index.html并引入我们打包后的js文件。

const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      template: './public/index.html',
      filename: 'index.html' // 打包后的文件名
    })
  ]
}

重新构建后打开dist/index.html发现style.css的内容并没有生效,body的背景色并没有变成红色的。

走进熟悉又陌生的webpack

html, body {
  height: 100%;
}

body {
  background-color: red;
}

这是因为style.css文件只是被解析转换到js文件里去了,我们需要让他插入到index.html里的style标签里才能生效,所以接下来我们实现这个功能。

// src/loader/style-loader.js

function styleLoader() {}

styleLoader.pitch = function(remainingRequest) {
  // remainingRequest为:D:\webpack-demo\node_modules\css-loader\dist\cjs.js!D:\webpack-demo\src\style.css
  
  // 将绝对路径转为相对路径
  const relativeRequest =
        remainingRequest
        .split('!')
        .map((part) => this.utils.contextify(this.context, part))
        .join('!')
  
  // relativeRequest的结果为:../node_modules/css-loader/dist/cjs.js!./style.css
  
  return `
    import content from '!!${relativeRequest}';
    const style = document.createElement('style');
    style.innerHTML = content;
    document.head.appendChild(style);
  `
}

module.exports = styleLoader

这里我们使用了一个空的loader函数,处理逻辑写在了pitch里,这是为什么?pitch又是什么?

我们知道loader是从右往左执行的,在我们这个例子里是css-loader=>style-loader,如果我们按照这种正常的执行顺序,在style-loader里我们拿到的是已经经过css-loader处理过的js字符串,我们要的是css内容,所以这不符合我们的要求。

好在webpack提供了Pitch Loader,它与正常的loader不同,执行顺序是从左往右的,webpack在执行loader(从右到左)之前,会先从左到右的调用loader的pitch方法。拿我们这里的例子来说,其执行顺序为:

走进熟悉又陌生的webpack

如果pitch里有返回结果,则会跳过剩余的loader,只执行pitch对应loader的loader函数。如果style-loader的pitch有返回结果,上面的执行链路则变成:

走进熟悉又陌生的webpack

pitch函数的第一个参数remainingRequest可以用来获取loader链的剩余请求,将其转换为相对路径后是../node_modules/css-loader/dist/cjs.js!./style.css

随后我们在返回的可执行js字符串中将其进行import,中间的!是webpack的语法,表示使用css-loader来导入后面的css文件,最前面的两个!!表示禁用前置的所以loader,在这里也就是style-loader,避免重复执行style-loader。

最后我们的pitch函数里返回创建style标签并插入html的逻辑,打开dist/index.html,引入的js文件执行这段代码后便可以看到css文件的内容被插入到了html。

走进熟悉又陌生的webpack

以上就是style-loader简要的实现过程,相比官网的style-loader忽略了很多东西,感兴趣的同学阅读style-loader的源码。

手写一个plugin

接下来我们以编写一个clean-webpack-plugin插件为例,一步一步认识编写插件的知识。

webpack插件需要是一个函数或类,里面需要定义一个apply方法,webpack会通过apply来启动插件。

class CleanWebpackPlugin {
  constructor() {}

  apply(complier) {
    console.log('complier===', complier)
  }
}

module.exports = CleanWebpackPlugin
const CleanWebpackPlugin = require('./plugins/clean-webpack-plugin')

module.exports = {
  plugins: [
    new CleanWebpackPlugin()
  ]
}

这里让我们先了解一下apply参数里的complier,complier是webpack的核心模块,complier继承自webpack的一个核心工具Tapable,Tapable上有三个方法可以用于触发钩子:

  • tap():以同步的方式触发钩子。
  • tapAsync():以异步的方式触发钩子。
  • tapPromise():以异步的方式触发钩子,并返回Promise。

使用方式如下:

compiler.hooks.xxxHook.tap('CleanWebpackPlugin', (params) => {
  /* ... */
})

tap方法接收两个函数:插件名称和一个回调函数。

compiler的hooks的种类有:

environmentafterEnvironmententryOptionafterPluginsafterResolversinitializebeforeRunrunwatchRunnormalModuleFactorycontextModuleFactorybeforeCompilecompilethisCompilationcompilationmakeafterCompileshouldEmitemitafterEmitassetEmitteddoneadditionalPassfailedinvalidwatchCloseshutdowninfrastructureLoglog

回到我们要写的插件,我们想实现打包时自动清空dist目录,这时可以选择emit钩子进行触发,emit钩子表示会在输出asset到output之前执行,这符合我们的需求,当然你也可以选择更靠前的钩子。详细的钩子生命周期可以查看文档:compilier钩子

const fs = require('fs')

/**
 * 删除文件夹下的文件
 * @param {*} path
 */
async function deleteDir(path) {
  if (fs.existsSync(path)) {
    const dirs = []

    const files = await fs.readdirSync(path)

    files.forEach(async (file) => {
      const childPath = path + "/" + file
      if (fs.statSync(childPath).isDirectory()) {
        await deleteDir(childPath)
        dirs.push(childPath)
      } else {
        await fs.unlinkSync(childPath)
      }
    })

    dirs.forEach((fir) => fs.deleteDirSync(fir))
  }
}

class CleanWebpackPlugin {
  apply(complier) {
    const { hooks, options } = complier
    hooks.emit.tap('CleanWebpackPlugin', () => {
      // 清除目录下文件
      const dir = options.output.path
      deleteDir(dir)
    })
  }
}

module.exports = CleanWebpackPlugin

感谢

本次分享就到此结束了,感谢阅读!!!