webpack 的构建流程是怎样的?Tapable 钩子有哪些类型?
webpack 启动时创建一个 compiler,每次构建创建一个 compilation。大致顺序是 run → compile → compilation → make(从入口构建模块图)→ finishMake → seal(封存模块、生成 chunk 和产物)→ afterCompile → emit(写文件前)→ afterEmit → done。每个阶段都是一个 Tapable 钩子,插件用 tap、tapAsync、tapPromise 注册。钩子按执行方式分为 Sync、AsyncSeries、AsyncParallel,按回调返回值的作用分为基础、Bail、Waterfall、Loop。
简答
执行一次
webpack构建,内部会先创建一个compiler对象,它代表整个 webpack 实例,从启动到结束只有一个。每开始一次构建,compiler再创建一个compilation,它代表这一次构建,watch 模式下每次重新构建都会有新的compilation。流程按 compiler 上的钩子走:
run开始,compile准备创建 compilation,thisCompilation和compilation在它创建后触发;接着是make,从入口出发,用 loader 转换每个模块、解析依赖,建出模块图;finishMake表示所有入口和依赖都加进来了;然后 compilation 执行seal,不再接受新模块,开始优化、分 chunk、生成产物,processAssets就在这个阶段;之后是afterCompile、emit(写入输出目录之前)、afterEmit和done。这些钩子都来自 Tapable 库。插件通过
tap注册同步回调,异步钩子还能用tapAsync(最后一个参数是 callback)和tapPromise(返回 Promise)。Tapable 的钩子类型从两个角度分:执行方式有 Sync、AsyncSeries(一个接一个)、AsyncParallel(同时跑);回调返回值的作用有基础钩子(忽略返回值)、Bail(有返回值就停)、Waterfall(返回值传给下一个)、Loop(有返回值就从头再来)。写插件时要注意选对钩子。webpack 文档在
emit里写明,不要在这里新增或修改产物,因为它在 compilation 封存之后才执行,此时processAssets的各个阶段都已经跑完了,要改产物应该在processAssets里改。
构建流程和钩子
下面按顺序列出 webpack 文档里 compiler 上的主要钩子和类型。类型决定了能用哪种 tap 方法:SyncHook 只能 tap,AsyncSeriesHook 和 AsyncParallelHook 三种都可以。
| 钩子 | 类型 | 文档说明 |
|---|---|---|
run | AsyncSeriesHook | 开始读取 records 之前 |
compile | SyncHook | 创建新的 compilation 之前 |
thisCompilation / compilation | SyncHook | compilation 创建时与创建后 |
make | AsyncParallelHook | 完成 compilation 之前 |
finishMake | AsyncSeriesHook | 所有入口和依赖都已加入 compilation |
afterCompile | AsyncSeriesHook | compilation 完成并封存之后 |
emit | AsyncSeriesHook | 把产物写入输出目录之前 |
afterEmit | AsyncSeriesHook | 产物写入输出目录之后 |
done | AsyncSeriesHook | 本次构建结束,参数是 stats |
seal 是 compilation 上的钩子,文档说明它在 compilation 停止接受新模块时触发。从这里开始,webpack 不再找新模块,而是把已有模块分成 chunk、生成代码、计算哈希,产物在这个阶段形成,processAssets 也在这里执行。
为什么 make 是 AsyncParallelHook?因为多个入口可以同时开始构建,互不依赖。而 emit、done 是 AsyncSeriesHook,插件要按顺序一个个执行,前一个做完后一个才开始。
插件的写法和 compiler、compilation 的分工,见《webpack 的 loader 和 plugin 有什么区别?怎样手写一个?》。webpack 的基本配置见《webpack 的 entry、output、loader 和 plugin 分别负责什么?》。
手写简化版 SyncHook 和 AsyncSeriesHook
真正的 Tapable 会根据注册的回调数量和类型生成执行代码,还支持拦截器、stage 和 before 等选项。下面只写出最核心的行为:注册回调,按注册顺序调用。
class SyncHook {
constructor(args = []) {
this.args = args;
this.taps = [];
}
tap(name, fn) {
this.taps.push({ name, fn });
}
call(...args) {
// 基础钩子忽略回调的返回值
for (const { fn } of this.taps) fn(...args);
}
}
class AsyncSeriesHook {
constructor(args = []) {
this.args = args;
this.taps = [];
}
tap(name, fn) {
this.taps.push({ name, run: (args) => Promise.resolve(fn(...args)) });
}
tapPromise(name, fn) {
this.taps.push({ name, run: (args) => fn(...args) });
}
tapAsync(name, fn) {
this.taps.push({
name,
run: (args) =>
new Promise((resolve, reject) => {
fn(...args, (err) => (err ? reject(err) : resolve()));
}),
});
}
async promise(...args) {
// 上一个回调完成后再执行下一个
for (const { run } of this.taps) await run(args);
}
}
const logs = [];
const compile = new SyncHook(['params']);
compile.tap('A', () => logs.push('compile A'));
compile.tap('B', () => logs.push('compile B'));
compile.call({});
const emit = new AsyncSeriesHook(['compilation']);
emit.tapPromise('Slow', () => Promise.resolve().then(() => logs.push('emit Slow')));
emit.tapAsync('Callback', (compilation, callback) => {
logs.push('emit Callback');
callback();
});
emit.tap('Sync', () => logs.push('emit Sync'));
emit.promise({}).then(() => {
console.log(logs.join(' / ')); // compile A / compile B / emit Slow / emit Callback / emit Sync
});
Slow 的回调要等一个微任务才写日志,可 Callback 和 Sync 仍然排在它后面。这就是 AsyncSeries 的含义:前一个回调的 Promise 完成、或者调用了 callback,才会开始下一个。如果换成 AsyncParallel,三个回调会同时开始,Callback 和 Sync 就可能先写日志。
为什么同步钩子只有 tap?Tapable README 说明,同步钩子只能用 tap 注册,因为 call 是同步返回的,没法等异步回调完成。异步钩子三种方法都支持,同步的 tap 回调在里面也照样能用。
Bail、Waterfall 和 Loop
这三种只改变“回调的返回值有什么用”,可以和 Sync、AsyncSeries 组合。Bail 是有一个回调返回了非 undefined 的值,就停下来不再执行后面的;webpack 的 shouldEmit 就是 SyncBailHook,返回 false 可以阻止输出文件。Waterfall 把每个回调的返回值传给下一个回调,适合层层加工同一个值。Loop 是某个回调返回非 undefined 时,从第一个回调重新开始,直到所有回调都返回 undefined。
常见追问
compiler 和 compilation 的钩子怎么区分该用哪个?
跟整个构建生命周期有关的,比如开始、结束、写文件,在 compiler 上;跟这一次构建的模块、chunk、产物有关的,在 compilation 上。要用 compilation 的钩子,通常先在 compiler 的
thisCompilation或compilation钩子里拿到 compilation 对象,再在它上面注册。
tap 的第一个参数为什么必须传名字?
Tapable README 写明,注册时必须传入一个名字来标识插件。它用于调试和排序,比如别的插件可以通过
before指定排在某个名字之前。