前端知库

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 三种都可以。

钩子类型文档说明
runAsyncSeriesHook开始读取 records 之前
compileSyncHook创建新的 compilation 之前
thisCompilation / compilationSyncHookcompilation 创建时与创建后
makeAsyncParallelHook完成 compilation 之前
finishMakeAsyncSeriesHook所有入口和依赖都已加入 compilation
afterCompileAsyncSeriesHookcompilation 完成并封存之后
emitAsyncSeriesHook把产物写入输出目录之前
afterEmitAsyncSeriesHook产物写入输出目录之后
doneAsyncSeriesHook本次构建结束,参数是 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 等选项。下面只写出最核心的行为:注册回调,按注册顺序调用。

JavaScript
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 指定排在某个名字之前。