从零开始学 Webpack 4.0(九)完
原文本人写于 2022-05-26 22:26:48
一、前言
通过前面一系列文章的学习,我们对 Webpack 中的 Loader 以及 Plugin 已经不陌生了。我们使用 Loader 对特定的文件或者说模块去进行打包,使用 Plugin 在 Webpack 运行到某个时刻去进行一些操作。
这次我们来简单讲下 Loader 以及 Plugin 的实现机制。
二、如何编写一个 Loader?
基础结构
在 demo09 下创建 make-loader 作为项目文件夹,也就是 make-loader 目录是项目根路径。
进入 make-loader 目录,运行命令生成 package.json 文件。
npm init -y
安装 webpack 以及 webpack-cli
npm install webpack@4.29.0 webpack-cli@3.2.1 -D
创建 src 目录,在该目录下新增 index.js 进行业务代码编写。
index.js
console.log('hello world')
创建 loaders 目录,在该目录下新增 replaceLoader.js 进行 Loader 的编写。
replaceLoader.js
// 这就是一个最简单的 Loader。
// Loader 简单理解为就是一个函数,但是这里不能写成箭头函数的形式,
// 否则在调用 Loader 时 this 的指向会有问题,我们需要用到 Loader 内 this 的一些变量以及方法。
// 该函数接受参数 source 是需要 Loader 处理的模块内容,Loader 对源代码内容做变更再返回。
module.exports = function (source) {
return source.replace('world', 'phao')
}
编写 Webpack 打包配置文件 webpack.config.js,使用自己编写的 Loader 打包 js 文件。
webpack.config.js
const path = require('path')
module.exports = {
mode: 'development',
entry: {
main: './src/index.js'
},
module:{
rules: [{
test: /\.js/,
// 使用自己的 Loader
use: [
path.resolve(__dirname, './loaders/replaceLoader.js')
]
}]
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
}
}
package.json 去除默认 scripts 脚本命令 test,新增 build。
package.json
{
...
"scripts": {
"build": "webpack"
},
...
}
于是,当前项目结构为:

运行打包命令
npm run build
打包成功后,查看 dist 目录下 main.js 底部代码,可以看到原先 index.js 内写的 console.log('hello world')
给替换成 console.log('hello phao')
。

传递参数
在使用 Loader 打包模块时,可以进行传参。
webpack.config.js
...
module.exports = {
...
module:{
rules: [{
test: /\.js/,
use: [
{
loader: path.resolve(__dirname, './loaders/replaceLoader.js'),
// 传参给 Loader
options: {
name: 'phao'
}
}
]
}]
},
...
}
在编写 Loader 里的函数可以通过 this.query 获取到传过来的参数,但 this.query 有时候可能会遇到一些奇怪的问题,例如传过来的不是个对象而是字符串,官方建议是使用 loader-utils 工具类提供的 getOptions() 去获取携带参数。
安装 loader-utils
npm install loader-utils@1.2.3 -D
replaceLoader.js
const loaderUtils = require('loader-utils')
module.exports = function (source) {
// return source.replace('world', this.query.name)
// getOptions() 会自动去分析 this.query
const options = loaderUtils.getOptions(this)
return source.replace('world', options.name)
}
运行打包命令,打包成功后查看 dist 目录下 main.js 底部代码,原先 index.js 内写的 console.log('hello world')
正常替换成 console.log('hello phao')
。
返回值处理
当我们进行 Loader 的编写时,对传进来的代码处理完进行返回,通过 return 只能返回一个参数,这时候可以通过 this.callback() 把一些额外的信息返回出去。
replaceLoader.js
...
module.exports = function (source) {
const options = loaderUtils.getOptions(this)
const result = source.replace('world', options.name)
// this.callback(
// err: Error | null, // 没有错误就填 null
// content: string | Buffer, // 返回内容
// sourceMap?: SourceMap, // 可以配置一个 SourceMap
// meta?: any // 可以放置一些想要额外传递出去的信息
// )
// sourceMap 参数的具体意义
// 例如 css-loader,处理 css 之后,sourceMap 的映射关系会发生变化,
// 那么 css-loader 就需要调用 sourceMap 重新对代码的关系做处理。
// 没有需要额外传递的信息,这样写等价于 return。
this.callback(null, result)
}
当我们在编写 Loader 的时候加入了异步代码,需要声明一下这里面的代码有异步操作,不然外面调用时会因为没有及时拿到返回值而报错。
replaceLoader.js
...
module.exports = function (source) {
const options = loaderUtils.getOptions(this)
// 声明一下,这里面的代码有异步操作
const callback = this.async()
setTimeout(() => {
const result = source.replace('world', options.name)
callback(null, result)
}, 2000)
}
运行打包命令,打包被延迟两秒才完成。

案例实践
现在我们来做这样一件事情,通过调用两个自己编写的 Loader 来打包 index.js 的业务代码做打印内容的替换。
修改 replaceLoader.js
replaceLoader.js
module.exports = function (source) {
return source.replace('phao', 'phaode')
}
loaders 目录下新建 replaceLoaderAsync.js
replaceLoaderAsync.js
const loaderUtils = require('loader-utils')
module.exports = function (source) {
const options = loaderUtils.getOptions(this)
const callback = this.async()
setTimeout(() => {
const result = source.replace('world', options.name)
callback(null, result)
}, 2000)
}
使用 Loader
webpack.config.js
...
module.exports = {
...
resolveLoader: {
// 修改引入 Loader 的路径
// 在引入 Loader 时先去 node_modules 目录下找,没有找到就去 loaders 目录下找。
modules: ['node_modules', './loaders']
},
module: {
rules: [{
test: /\.js/,
use: [
{
// loader: path.resolve(__dirname, './loaders/replaceLoader.js')
loader: 'replaceLoader'
},
{
// loader: path.resolve(__dirname, './loaders/replaceLoaderAsync.js'),
loader: 'replaceLoaderAsync',
options: {
name: 'phao'
}
}
]
}]
},
...
}
进行了这些修改后,我们的 index.js 首先会被 replaceLoaderAsync 把 console.log('hello world')
替换成 console.log('hello phao')
,然后被 replaceLoader 替换成 console.log('hello phaode')
。
运行打包命令,打包成功后查看 dist 目录下 main.js 底部代码,内容为 console.log('hello phaode')
。
同步与异步
编写 Loader 的函数时,接受的参数是源代码,在源代码基础上返回想要的内容。
返回想要的内容,分为同步以及异步两种形式。
同步直接通过 return 以及 this.callback() 来返回结果。
异步需要先调用 this.async() 进行声明,然后通过 callback() 返回结果。
同步以及异步的 callback() 参数结构一致。
Loader 的使用场景
这里只是提供个思路,实际的实现会复杂一些。
- 异常捕获
// 在编写 Loader 的函数时拿到源代码 source 的 js 代码, // 通过抽象语法树的一些东西对代码进行一个分析,当代码里出现: function(){} // 就把它替换成: try{ function(){} } catch(e) { ... } // 通过 Loader 在 Webpack 里做异常捕获,业务代码不需要做任何改变。
- 网站语言切换
// 把网页显示内容放到一些占位符里面去写,例如: {{title}} , // loader 通过 node 传入的一些全局变量判断当前打包语言是哪一种, // 对源代码进行分析,把占位符替换为想要展示的文本内容。 // 伪代码 if (Node全局变量 === '中文') { source.replace('{{title}}', '中文标题') } else { source.replace('{{title}}', 'english title') } // 在编写业务代码时就不用考虑需要显示的是哪种语言,只需编写占位符。
小结
Loader 能做的东西非常多,当在编写业务代码时发现源代码需要做一些包装或者说替换,就可以考虑通过 Loader 来解决。
三、如何编写一个 Plugin?
案例实践
Plugin 就是插件,在打包过程中某个具体的时刻进行一些操作,例如打包后生成一个 html 文件,或者说打包前把 dist 目录下清空。
Webpack 的源码大多数都是基于 Plugin 的机制来编写的。
对于 Plugin 来说,它的核心机制是事件驱动,或者说是发布订阅这样的设计模式。在这个模式里,代码之间的执行都是通过事件来驱动。
现在我们来编写一个简单的 Plugin,要实现的效果是在整个项目打包结束,即将要把打包结果放入 dist 目录时,希望在 dist 目录下生成一个版权文件,例如叫 copyright.text,里面写一些版权信息。
在 demo09 下创建 make-plugin 作为项目文件夹,也就是 make-plugin 目录是项目根路径,各文件直接拿上面项目 make-loader 的,接下来做些修改。

package.json
{
"name": "make-plugin",
...
}
package-lock.json
{
"name": "make-plugin",
...
}
安装依赖
npm ci
创建 plugins 目录,在该目录下新增 copyright-webpack-plugin.js 进行 Plugin 的编写。
copyright-webpack-plugin.js
// 一个最基本的插件
class CopyrightWebpackPlugin {
constructor() {
console.log('插件被使用了')
}
// 调插件时会执行 apply 这个方法,compiler 可以理解为 Webpack 的一个实例。
// compiler 里存储着 Webpack 相关的各种各样配置文件,以及打包的过程等一系列内容。
apply(compiler) {
console.log('插件被调用了')
}
}
module.exports = CopyrightWebpackPlugin
修改 webpack.config.js,因为插件本质上是一个类,所以一般使用插件都需要去 new 一个实例。
webpack.config.js(原文件内容清空)
const path = require('path')
const CopyrightWebpackPlugin = require('./plugins/copyright-webpack-plugin')
module.exports = {
mode: 'development',
entry: {
main: './src/index.js'
},
plugins: [
new CopyrightWebpackPlugin()
],
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
}
}
运行打包命令
npm run build
观察控制台打印结果

通过简单的 Plugin 代码结构以及引入使用的方式,我们知道了 Plugin 怎么定义怎么使用。
还可以给 Plugin 传参
webpack.config.js
...
module.exports = {
...
plugins: [
new CopyrightWebpackPlugin({
name: 'phao'
})
],
...
}
copyright-webpack-plugin.js
class CopyrightWebpackPlugin {
// options 接收到外部传入参数
constructor(options) {
console.log('插件被使用了')
console.log(options)
}
...
}
...
运行打包命令,观察控制台打印结果。

传参仅做演示,我们这里不需要,配置还原。
webpack.config.js
...
module.exports = {
...
plugins: [
new CopyrightWebpackPlugin()
],
...
}
copyright-webpack-plugin.js
class CopyrightWebpackPlugin {
constructor(options) {
console.log('插件被使用了')
}
...
}
...
我们这个 Webpack 实例 compiler 在打包过程中某些时刻要去做一些事情,查阅官方文档,hooks(翻成中文就是钩子)里面就定义了一些具体的时刻值。
修改 copyright-webpack-plugin.js,编写 compile 以及 emit 这两个时刻。compile 是同步时刻,在一个新的编译创建之后执行。emit 是异步时刻,在生成资源到 output 目录之前执行。
copyright-webpack-plugin.js
class CopyrightWebpackPlugin {
// constructor(options) {
// console.log('插件被使用了')
// }
apply(compiler) {
// tap() 运行同步以及异步时刻
// tap() 第一个参数传入要使用的插件名,第二个参数传入函数编写逻辑,
// 第二参数传入函数接收的参数 compilation 存放着这次打包的相关内容。
compiler.hooks.compile.tap('CopyrightWebpackPlugin', (compilation) => {
console.log('compile')
})
// tapAsync() 运行异步时刻
// tapAsync() 第一个参数传入要使用的插件名,第二个参数传入函数编写逻辑,
// 第二参数传入函数接收两个参数,参数一 compilation 存放着这一次打包的相关内容,
// 参数二 callback 在函数末尾需要调用一下。
compiler.hooks.emit.tapAsync('CopyrightWebpackPlugin', (compilation, callback) => {
// compilation.assets 是本次打包生成的内容
console.log(compilation.assets)
// 往生成的文件里去新增一个
compilation.assets['copyright.txt'] = {
// source 表明文件的内容是什么
source: function() {
return 'copyright by phao'
},
// 告诉它这个文件大概有多长
size: function() {
// 17 个字符长度
return 17
}
}
callback()
})
}
}
module.exports = CopyrightWebpackPlugin
运行打包命令,观察控制台打印结果,实现了我们想要的效果,dist 目录下新增 copyright.text,完成这个简单的插件。


借助 node 调试工具进行 debugger
在本项目中我们提及 compilation 存放着这次打包的相关内容,那么想要查看里面具体包含什么,使用 console.log()
进行控制台的打印并不直观,我们可以借助 node 的调试工具去进行 debugger。
新增 package.json 的脚本命令 debug,node node_modules/webpack/bin/webpack.js
和 webpack
是一样的,只是这样显式地用 node 去执行文件可以传递一些 node 的参数进去,--inspect
表示要开启 node 的调试工具,--inspect-brk
表示在运行 webpack.js 做调试时,在 webpack 命令执行时的第一行打个断点。
package.json
{
...
"scripts": {
"debug": "node --inspect --inspect-brk node_modules/webpack/bin/webpack.js",
"build": "webpack"
},
...
}
在 copyright-webpack-plugin.js 打个断点
copyright-webpack-plugin.js
class CopyrightWebpackPlugin {
apply(compiler) {
...
compiler.hooks.emit.tapAsync('CopyrightWebpackPlugin', (compilation, callback) => {
console.log(compilation.assets)
// 打个断点
debugger
compilation.assets['copyright.txt'] = {
...
}
...
})
}
}
module.exports = CopyrightWebpackPlugin
运行 debug 命令
npm run debug

打开谷歌浏览器控制台,会多了个图标。

点击该图标会打开一个调试面板,再点击右上角按钮跳过第一个断点去到我们在 copyright-webpack-plugin.js 里打的断点,鼠标移动到 compilation 即可看到包含内容。


如果觉得不够方便可以在右侧上方的 watch 增加对变量 compilation 的监控,查看到里面具体内容。

这样,就可以通过 node 的调试工具去帮助我们看到打包的 Plugin 里面用到的一些变量是什么样的形式。
实际上,我们在编写 Webpack 的 Plugin 就是基于调试工具去进行编写的。
四、官方文档
学习完以上内容,可以前往 Webpack 4.0 官网 阅读相关中文文档。
建议阅读的内容为(内容比较多,感兴趣的小伙伴可以看看):
- (1)文档 > API,页面左侧的 loader API。
- (2)文档 > API,页面左侧的 PLUGINS。
英语比较好的小伙伴可以直接阅读 Webpack 4.0 官网 的英文文档。
五、总结
通过以上的学习,我们简单了解了 Loader 以及 Plugin 的实现机制,并知道了如何结合 node 调试工具去进行 debugger。
本篇文章是本系列的最后一篇,课程最后还涉及一些内容以文章的形式不是很好总结,所以就更新到这了。
Webpack 中涉及的配置项非常多,通过记忆把这门课的所有内容掌握是很困难的。
实际上,我们只需要把 Webpack 一些基础的知识点记住就可以了,例如 Loader 是什么,Plugin 是什么,entry 是什么,这些内容怎么配置等等。像官网 文档 > 概念 下的内容都要掌握,这些都是核心概念,通过掌握这些内容把 Webpack 的脉络捋清,知道哪块的作用是什么,当遇到不清楚的 Webpack 配置项再去官网进行查阅即可。
-
想解决某方面问题时,例如代码分割之类的,可以到官网 文档 > 指南 下去找。
-
像一些 devServer 的配置项,看到别人配不知道是什么作用时,可以到官网 文档 > 配置 下去找。
-
如果想自己编写 Loader 或 Plugin,可以查阅官网 文档 > API 下相关内容。
-
当在使用 Loader 解决一些问题,不知道这个 Loader 的具体作用时,可以在官网 文档 > LOADER 下查找,找不到的话去 GitHub 进行搜索查看相关文档。Plugin 同理,去 Webpack 官网以及 GitHub 搜索即可。
Webpack 的配置过程实际上不是记忆的过程,而是查阅文档的过程。
经过本系列文章的学习,我们对 Webpack 4.0 的配置有了一定的了解,接下来就是进行 Webpack 5.0 的学习并尝试进行一些项目的打包配置,在实战中去积累经验。
本文最后的代码我会上传到 码云(gitee.com/phao97)上,项目文件夹为 demo09。
如果觉得本篇文章对你有帮助,不妨点个赞或者给相关的 Git 仓库一个 Star,你的鼓励是我持续更新的动力!
转载自:https://juejin.cn/post/7218023140034527291