配置文件里的 plugins 数组我抄了好几年,塞进去七八个插件都能跑,可一旦某个插件行为不对,我完全不知道该从哪儿开始查。真正逼我把这块弄明白的是个很小的需求,构建完想自动生成一份「这次打出来哪些文件、各多大」的清单。现成的方案不是没有,只是我想顺手把 plugin 的写法练一遍。
几十行代码写完,事件流、Compiler 和 Compilation 的分工、emit 到底卡在哪个时机,一下子全串起来了。这篇把原理和四个能直接抄走的插件例子记在一起,末尾单独补一段 webpack 4 和 5 之后哪些写法已经不能用了。读完你至少能自己判断一个功能该挂在哪个钩子上。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 一个插件挂到 webpack 上的完整过程,
apply和compiler是怎么传进来的 Compiler和Compilation的区别,什么时候该用哪个- Tapable 的九种钩子,同步、异步串行、异步并行分别怎么注册和触发
- 常用 API,读产物、监听额外文件变化、改写输出、判断当前用了哪些插件
- 四个可以直接抄走的插件实现,构建收尾、文件清单、版权信息、打包 zip
- webpack 4 到 5 之后,文中这些写法哪些废弃了,替代品是什么
# 一、一个插件是怎么挂上去的
webpack 跑起来之后,整个生命周期会广播出一大堆事件。plugin 干的事就一件,监听它关心的那个事件,在那个时机用 webpack 给的 API 改变输出结果。
先看最小的一个插件长什么样。
class BasicPlugin{
// 在构造函数中获取用户给该插件传入的配置
constructor(options){
}
// Webpack 会调用 BasicPlugin 实例的 apply 方法给插件实例传入 compiler 对象
apply(compiler){
compiler.plugin('compilation',function(compilation,callback) {
})
}
}
// 导出 Plugin
module.exports = BasicPlugin;
这段代码里其实只有一个约定,插件就是一个带 apply 方法的类。构造函数拿用户传进来的配置,apply 拿 webpack 给的 compiler,别的都是你自己的事。
在配置里用它的时候是这样:
const BasicPlugin = require('./BasicPlugin.js');
module.exports = {
plugins:[
new BasicPlugin(options),
]
}
顺着这两段代码把流程串一遍。webpack 启动后读配置,先执行 new BasicPlugin(options) 拿到实例,这一步只是把你的配置存下来,还没有任何 webpack 的东西可用。等 compiler 对象初始化完,webpack 才回过头调用 basicPlugin.apply(compiler),把 compiler 交到插件手里。从这一刻起插件才算真正接入了构建流程,可以通过 compiler.plugin(事件名称, 回调函数) 监听广播出来的事件,也可以直接操作 compiler 上的配置。
所以插件的构造函数里不要写任何依赖 webpack 状态的逻辑,那时候什么都还没有。
原文这里有个笔误我顺手改了,配置导出应该是 module.exports 而不是 module.export,后者 webpack 读不到,表现是插件像不存在一样,一点报错都没有。这种错最难查,因为你会一直怀疑是钩子挂错了。
到这里最简的模型就跑通了,但实际开发绕不开两个对象,下面单独说。
# 二、Compiler 和 Compilation 到底该用哪个
开发 plugin 时最常打交道的就是这两个对象,它们是插件和 webpack 之间的桥梁。
Compiler 对象包含了 webpack 环境所有的配置信息,options、loaders、plugins 这些都挂在上面。它在 webpack 启动时被实例化,全局唯一,可以简单地把它理解为 webpack 实例本身。
Compilation 对象包含的是当前这一次编译的模块资源、编译生成资源、变化的文件等等。webpack 以开发模式运行时,每检测到一个文件变化就会创建一次新的 Compilation。它同样提供了很多事件回调供插件扩展,并且通过它也能反过来读到 Compiler。
两者的区别就一句话,Compiler 代表整个 webpack 从启动到关闭的生命周期,Compilation 只代表一次编译。
判断该用哪个有个很好用的经验:跟「这一次打包出来什么」有关的,找 Compilation;跟「整个进程什么时候开始、什么时候结束」有关的,找 Compiler。watch 模式下你会更容易理解这层关系,进程只启动一次,Compiler 就一个,但你改一次文件就多一个 Compilation。如果你把状态缓存在了 Compiler 上,watch 时它会一直累积,这个坑我踩过,插件在单次构建时正常,开着 dev server 改几次文件之后输出就重复了。
那这些钩子是哪来的?webpack 源码里 compiler 的钩子函数是借助 tapable 库实现的。
const {
Tapable,
SyncHook,
SyncBailHook,
AsyncParallelHook,
AsyncSeriesHook
} = require("tapable");
class Compiler extends Tapable {
constructor(context) {
super();
this.hooks = {
/** @type {SyncBailHook<Compilation>} */
shouldEmit: new SyncBailHook(["compilation"]),
/** @type {AsyncSeriesHook<Stats>} */
done: new AsyncSeriesHook(["stats"]),
/** @type {AsyncSeriesHook<>} */
additionalPass: new AsyncSeriesHook([]),
/** @type {AsyncSeriesHook<Compiler>} */
beforeRun: new AsyncSeriesHook(["compiler"]),
/** @type {AsyncSeriesHook<Compiler>} */
run: new AsyncSeriesHook(["compiler"]),
/** @type {AsyncSeriesHook<Compilation>} */
emit: new AsyncSeriesHook(["compilation"]),
/** @type {AsyncSeriesHook<string, Buffer>} */
assetEmitted: new AsyncSeriesHook(["file", "content"]),
/** @type {AsyncSeriesHook<Compilation>} */
afterEmit: new AsyncSeriesHook(["compilation"]),
/** @type {SyncHook<Compilation, CompilationParams>} */
thisCompilation: new SyncHook(["compilation", "params"]),
/** @type {SyncHook<Compilation, CompilationParams>} */
compilation: new SyncHook(["compilation", "params"]),
/** @type {SyncHook<NormalModuleFactory>} */
normalModuleFactory: new SyncHook(["normalModuleFactory"]),
/** @type {SyncHook<ContextModuleFactory>} */
contextModuleFactory: new SyncHook(["contextModulefactory"]),
/** @type {AsyncSeriesHook<CompilationParams>} */
beforeCompile: new AsyncSeriesHook(["params"]),
/** @type {SyncHook<CompilationParams>} */
compile: new SyncHook(["params"]),
/** @type {AsyncParallelHook<Compilation>} */
make: new AsyncParallelHook(["compilation"]),
/** @type {AsyncSeriesHook<Compilation>} */
afterCompile: new AsyncSeriesHook(["compilation"]),
/** @type {AsyncSeriesHook<Compiler>} */
watchRun: new AsyncSeriesHook(["compiler"]),
/** @type {SyncHook<Error>} */
failed: new SyncHook(["error"]),
/** @type {SyncHook<string, string>} */
invalid: new SyncHook(["filename", "changeTime"]),
/** @type {SyncHook} */
watchClose: new SyncHook([]),
/** @type {SyncBailHook<string, string, any[]>} */
infrastructureLog: new SyncBailHook(["origin", "type", "args"]),
// TODO the following hooks are weirdly located here
// TODO move them for webpack 5
/** @type {SyncHook} */
environment: new SyncHook([]),
/** @type {SyncHook} */
afterEnvironment: new SyncHook([]),
/** @type {SyncHook<Compiler>} */
afterPlugins: new SyncHook(["compiler"]),
/** @type {SyncHook<Compiler>} */
afterResolvers: new SyncHook(["compiler"]),
/** @type {SyncBailHook<string, Entry>} */
entryOption: new SyncBailHook(["context", "entry"])
};
}}
这一大段构造函数看着吓人,其实信息量很集中。this.hooks 就是这个 Compiler 能广播的全部事件,每个事件都被声明成了某一种钩子类型,注释里的 SyncHook<Compilation> 这类泛型标注说明了回调能拿到什么参数。你要写插件,第一件事就是来这里翻,看看有没有一个钩子的时机正好是你要的。