前言
上文提到,webpack5源码导读:我们如何调试源码,插件是 webpack 的支柱功能,webpack 自身也是构建于我们开发者在 webpack 配置中用到的相同的插件系统之中。说的大白话点,就是它自身也是基于这套插件构建的,这种基于事件流的插件机制是 webpack 的骨架,而控制这些插件在 webpack 事件流上的运行就是基于一个库(Tapable),因此想要深入的了解 webpack,插件是绕不过的一道槛。本文将会详细的探究如何实现一个 webpack 插件,以点带面,让小伙伴们也能了解到插件的奥秘。
Tapable 指南
什么是 Tapable
tapable 有些类似于 Node.js 中的 Events 库。
const EventEmitter = require('events');
const emitter = new EventEmitter();
emitter.on('event', () => {
console.log('触发了一个事件');
});
emitter.emit('event');
简单的来说,是一种实现了发布订阅模式的库,通过 tapable 我们可以注册自定义事件,在合适的时机去触发注册的事件。
Tapable 的用法
tapable 提供了一系列事件的发布订阅 api,这些 api 就是各种类型的钩子。官方文档提供了以下九种钩子。
const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
AsyncParallelHook,
AsyncParallelBailHook,
AsyncSeriesHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook
} = require("tapable");
这里可以简单的就 SyncHook 钩子函数做一个示例。
const myHook = new SyncHook(["arg1", "arg2", "arg3"]);
myHook.tap('myHook1', (arg1, arg2, arg3) => {
console.log('myHook1', arg1, arg2, arg3);
});
myHook.tap('myHook2', (arg1, arg2, arg3) => {
console.log('myHook2', arg1, arg2, arg3);
});
myHook.call('Y', 'L', 'G');
//打印结果为:
// myHook1 Y L G
// myHook2 Y L G
这里简单理一下执行的逻辑,大概可以分为三步。
-
根据需求实例化不同种类的 Hook,在实例化的过程中接受一个字符串数组为参数,对字符串的值没有要求,但要尽量满足语义化。要注意数组中的字符串个数要与实际传参的个数相对应。
-
通过 tap 函数来注册事件要接受两个参数,第一个是起到占位符函数的字符串,如在 webpack 插件中,这个字符串的值一般是插件的名字,第二个参数是注册的回调函数,如下例:
compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {
compilation.addEntry(context, dep, options, err => {
callback(err);
});
});
- 通过 call 函数传入对应的参数,在执行的过程中,传入的参数会传递给所有的注册事件进行使用。
感兴趣的读者,可以引入 tapable 包自行尝试一下,这里简单的说一下,对于同步钩子而言,tap 是唯一注册事件的方法,通过 call 来进行触发。异步钩子则可以通过 tap,tapAsync,tapPromise进行注册,这里要注意,异步钩子也可以通过 tap 进行注册。
按照同步和异步进行分类。
同步表示注册的事件函数会同步的执行
异步则表示注册的事件会异步的执行
这里要提一下异步钩子,被分成了两类,串行和并行,我们还可以以单词的词义来进行区分,
series: 一连串的,系列
parallel:平行的,同时发生的
具体点来讲
-
AsyncSeriesHook: 可被串联执行的异步钩子函数。
-
AsyncParallelHook: 可被并联调用的异步钩子函数。
按照返回值进行分类。
Basic Hook: 基本类型钩子,它仅仅按顺序连续执行每个注册的事件函数,并不关心调用事件的返回值如何。
Waterfall Hook: 瀑布钩子,和基本类型钩子一样,也会按顺序连续执行注册的事件函数,区别在于,它将上一个事件函数的返回值传递到下一个事件函数为参数,如其中某个函数没有返回值,则将上一个存在的返回值传递下去,另一个注意的点是下一个事件函数存在多个参数时,返回值仅仅能修改第一个参数。
const { SyncWaterfallHook } = require('tapable');
const myHook = new SyncWaterfallHook(['arg1', 'arg2', 'arg3']);
myHook.tap('myHook1', (arg1, arg2, arg3) => {
console.log('myHook1:', arg1, arg2, arg3);
return 'cool';
});
myHook.tap('myHook2', (arg1, arg2, arg3) => {
console.log('myHook2:', arg1, arg2, arg3);
});
myHook.tap('myHook3', (arg1, arg2, arg3) => {
console.log('myHook3:', arg1, arg2, arg3);
});
hook.call('Y', 'L', 'G');
//打印结果为:
// myHook1 Y L G
// myHook2 cool L G
// myHook3 cool L G
Bail Hook: 保险钩子,如果任意一个注册事件函数返回非 underfined 的值,钩子执行的过程将会立即中断。
const { SyncBailHook } = require('tapable');
const myHook = new SyncBailHook(['arg1', 'arg2', 'arg3']);
myHook.tap('myHook1', (arg1, arg2, arg3) => {
console.log('myHook1:', arg1, arg2, arg3);
// 存在返回值,钩子的执行过程被中断
return true
});
myHook.tap('myHook2', (arg1, arg2, arg3) => {
console.log('myHook2:', arg1, arg2, arg3);
});
hook.call('Y', 'L', 'G');
//打印结果为:
// myHook1 Y L G
Loop Hook: 循环钩子,执行顺序与基本类型钩子一致,不同的是,如果任何一个注册的事件函数返回的值为非 underfined,则将会重头执行所有注册的钩子函数。直至所有事件函数的返回值都为 underfined。
如何实现一个 Webpack Plugin
什么是插件
在 webpack 编译时期,会为不同的编译对象初始化很多不同的 Hook,开发者们可以在编写的插件中监听,也就是用(tap,tapAsync,tapPromise)注册这些钩子,在打包的不同时期,触发(call)这些钩子,就可以在编译的过程中注入特定的逻辑,修改编译的结果来满足开发的需要。
如这里可以举个例子,描述一下 emit 钩子的作用,以及在这个钩子注册的事件被触发时,我们可以做些什么。正如官方文档所描述,这个钩子的触发时机是在输出 asset 到 output 目录之前执行。说明此时源文件的转换和组装已经完成,我们可以通过 emit 钩子此时的回调函数中的参数,compilation,来读取输出的资源,模块,以及依赖。
class myPlugin {
apply(compiler) {
// 注册 "emit" 钩子,
compiler.hooks.emit.tap('myPlugin', (compilation) => {
// 自定义逻辑,如打印存放当前模块所有依赖的文件路径
compilation.chunks.forEach((chunk) => {
chunk.forEachModule((module) => {
module.fileDependencies.forEach((filepath) => {
console.log(filepath);
});
});
})
}
}
}
结合上面的例子以及描述,我们再综合一下官网的创建插件说明来看,
-
一个 JavaScript 命名函数或 JavaScript 类。
-
在插件函数的 prototype 上定义一个
apply
方法。 -
指定一个绑定到 webpack 自身的事件钩子。
-
处理 webpack 内部实例的特定数据。
-
功能完成后调用 webpack 提供的回调。
让我们说的更方便理解一些,如果插件是一个函数,需要在原型链上指定 apply 方法,如果是一个 class 类,则一定要在类的属性上,添加 apply 方法,方法名必须是 apply,少一个字母多一个字母都不行,这在源码中是写死的,在 apply 方法中,通过 compiler 注册指定的事件钩子,在回调函数中拿到 compilation 对象,使用 compilation 修改编译后的数据,从而影响打包结果来达到我们的目的。
这里有一点需要注意,如果是异步钩子,在完成自定义逻辑后要执行 callback() 函数,来通知 webpack 继续编译。
常用的插件钩子介绍
这里用我的语言简单说一下,在开发插件时,Compiler 和 Compilation 的不同。
Compiler 对象
Compiler 对象在 webpack 启动时就已经被实例化,它和 compilation 实例不同,它是全局唯一的,在它的实例对象中,可以得到所有的配置信息,包括所有注册的 plugins 和 loaders。
Compilation 对象
每当文件发生变动时,都会有新的 compilation 实例被创建,它能够访问到所有的模块和依赖,我们可以通过一系列的钩子来访问或者修改打包的 module,assets,chunks。
下面是一些常用钩子的介绍
钩子 | 调用时机 | 参数 | 类型 |
---|---|---|---|
afterPlugins | 在初始化内部插件集合完成设置之后调用 | compiler | SyncHook |
run | 在开始读取 records 之前调用 | compiler | AsyncSeriesHook |
compile | 在创建一个新的 compilation 创建之前 | compilationParams | SyncHook |
compilation | compilation 创建之后执行 | compilation, compilationParams | SyncHook |
emit | 输出 asset 到 output 目录之前执行 | compilation | AsyncSeriesHook |
afterEmit | 输出 asset 到 output 目录之后执行 | compilation | AsyncSeriesHook |
done | 在 compilation 完成时执行 | stats | AsyncSeriesHook |
这里有一张 webpack 基于不同模块钩子执行的运行图。
如何实现插件
这里我们创建一个项目
mkdir webpack-plugins
npm init -y
npm install webpack webpack-cli --save-dev
创建好依赖后,我们来补充一下项目结构
├── dist
├── plugins
│ └── log-webpack-plugin.js
│ └── copy-rename-webpack-plugin.js
├── node_modules
├── package-lock.json
├── package.json
├── src
│ └── foo.js
│ └── index.js
└── webpack.config.js
webpack.config.js
/** *
* @type {import('webpack').Configuration}
*
*/
const path = require('path');
const webpack = require('webpack');
const LogWebpackPlugin = require('./plugins/log-webpack-plugin');
module.exports = {
mode: 'development',
entry: path.resolve(__dirname, 'src/index.js'),
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
plugins: [
new LogWebpackPlugin({
emitCallback: () => {
console.log('emit 事件发生啦')
},
compilationCallback: () => {
console.log('compilation 事件发生啦')
},
doneCallback: () => {
console.log('done 事件发生啦')
},
})
]
}
我们这里将会以两个插件举例说明,争取让读者也明明白白的。
logWebpackPlugin
log-webpack-plugin.js
class LogWebpackPlugin {
constructor({ emitCallback, compilationCallback, doneCallback }) {
this.emitCallback = emitCallback
this.compilationCallback = compilationCallback;
this.doneCallback = doneCallback
}
apply(compiler) {
compiler.hooks.emit.tap('LogWebpackPlugin', () => {
this.emitCallback();
});
compiler.hooks.compilation.tap('LogWebpackPlugin', (err) => {
this.compilationCallback();
});
compiler.hooks.done.tap('LogWebpackPlugin', (err) => {
this.doneCallback();
});
}
}
module.exports = LogWebpackPlugin;
执行 webpack 打包命令,看看 console.log 在不同的编译时期打印的信息。
上述的插件,可以传入自定义的函数,在 webpack 不同的编译时期,去触发那个函数,这个插件很简单,但也清晰的展现了插件的结构和原理。再一次重申,所谓插件,就是 webpack 依托事件流的机制,在打包的不同时期,暴露出钩子函数,使开发者能拿到不同编译时期的 compilation 实例,来访问或改变实例上的 module,assets,chunks,来实现所需的功能。
CopyRenameWebpackPlugin
让我们来模拟一个需求,我想让 /dist 目录下的指定文件复制到另一个指定的文件夹且重命名,让我们来思考一下应该怎么做。先展示一下代码。
首先在 webpack.config.js 中加一些代码
const CopyRenameWebpackPlugin = require('./plugins/copy-rename-webpack-plugin');
plugins: [
......
new CopyRenameWebpackPlugin({
entry: 'main.js',
output: [
'../copy/main1.js',
'../copy/main2.js'
],
})
]
copy-rename-webpack-plugin.js
class CopyRenameWebpackPlugin {
constructor(options) {
this.options = options || {};
}
apply(compiler) {
const pluginName = CopyRenameWebpackPlugin.name;
const { entry, output } = this.options;
let fileContent = null;
compiler.hooks.emit.tapAsync(pluginName, (compilation, callback) => {
const assets = compilation.getAssets();
assets.forEach(({ name, source }) => {
if(entry !== name) return;
fileContent = source;
});
output.forEach((dir) => {
compilation.emitAsset(dir, fileContent);
})
callback();
});
}
}
module.exports = CopyRenameWebpackPlugin;
让我们再来看一下输出的结果。
事实证明,这个插件是满足我们要求的,大概说一下这个插件的思路。
-
传入想要复制的文件名字,以及输出的目录,进行配置化和灵活化。
-
想一下用什么钩子,我们要复制打包目录下的文件,此时我们需要的资源是已经处理好的,emit 的时机是输出 assets 到打包目录之前,此时的 compilation 实例中的 assets 是编译处理后的。
-
我们通过 getAssets 来获取当前编译下所有资源的数组,进行遍历,取得 source 和 name,这个 name 可以理解为文件名字,source 为资源信息,我们通过 name 来比对 传入的 entry,如果一致,这个 source 就是我们需要的资源信息。
-
发射文件,同样使用 compilation 实例的 emitAsset 方法来写入文件。
现在可能会有读者疑惑,我不知道 compilation 上有 getAssets 这个方法,我也不知道使用这个方法可以获取什么值,我这里有两个法子,相辅相成,首先我们在官方文档中可以了解到一部分。
- 在 webpack 源码的根目录下,有 type.d.ts 这个文件,如我们可以查询进入文件,Ctrl + F,直接搜寻 getAssets,如下图。
我们可以知道调用这个方法会返回一个类型是 Asset,仅仅只读的数组。
让我们再去剖析 Asset 里都有什么。
我们找到了,它里面含有 name 和 source,和我们解构出来的值是一样的。
重写 CopyRenameWebpackPlugin
如果我们使用的版本是 webpack4,那这篇文章就已经结束了,先看一张图。
这个提示的大概意思就是,在 webpack5 之前,我们常在 compiler.hooks.emit 钩子注册的事件中对资源进行处理,如删除注释等,现在官方不建议这样用了,人家建议用 compilation.hooks.processAssets这个钩子对 assets 进行处理。于是我们要改造一下我们的代码。
class CopyRenameWebpackPlugin {
constructor(options) {
this.options = options || {};
}
apply(compiler) {
const pluginName = CopyRenameWebpackPlugin.name;
const { entry, output } = this.options;
let fileContent = null;
const { webpack } = compiler;
const { Compilation } = webpack;
compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
compilation.hooks.processAssets.tap(
{
name: pluginName,
stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,
},
(assets) => {
Object.entries(assets).forEach(([name, source]) => {
if(entry !== name) return;
fileContent = source;
})
output.forEach((dir) => {
compilation.emitAsset(dir, fileContent);
})
}
)
})
}
}
module.exports = CopyRenameWebpackPlugin;
此时已经不报这个警告了。
其实整体的流程差不多,这边解释一下 compilation.hooks.processAssets 这个钩子。正如官网所描述,它是一个专门处理 assets 的钩子。
这边要注意的是 stage 这个参数。可以用常量来赋值,也可以使用数字,如 stage: 1000。
尽管这个钩子的 Hook 参数以及回调参数给的比较清晰,此时我们也可以用 type.d.ts 来查询一下这个钩子的信息。
这里可以看出 CompilationAssets 是一个对象,它的值的类型依旧是 Source。
最后再说一点,也是我在学习插件的过程比较在意的一点,webpack 的钩子众多,有很多时期很接近,但官网说的又不清晰,我怎么知道我要用哪个,这一点,说说我的理解。
如在上述例子中,我使用了 compiler.hooks.thisCompilation 这个钩子,我为什么要使用这个,我可以使用 compiler.hooks.compilation 吗,答案是可以的,我之所以使用 thisCompilation 这个钩子,是因为此时 compilation 实例已经创建完毕,使用这个钩子,我可以最早拿到 compilation实例。
以下三个钩子都是满足条件的,因为这三个钩子触发的时机都在 compilation 创建之后,结束之前执行。
总结
写到这里,也很感谢每一位读到这里的小伙伴,webpack 插件的内容相信对于大部分开发者来说都是陌生的,但相信,读到这里的同学,对待插件也有一个基本的认识了,学习,我认为最重要的是兴趣为主,无论学习的目的是为了面试,还是因为有开发任务,希望我们都能树立一个心态,那就是我变强了,形成一个正向循环。也希望这篇文章能给读者们带来启迪,打开 webpack 插件的大门。