前言
本文根据 tapable
工具库最新的 2.2.1 版本来做的源码解析。整体分为几个部分,目录结构描述、各个钩子函数介绍和实现代码流程,再深入探讨 tapable
如何在 webpack 中被用来创建强大的插件系统。
目录结构

钩子函数分类
在各个钩子函数中,按照执行方式又可以分为同步、异步:
同步钩子 包含SyncBailHook.js
、SyncHook.js
、SyncLoopHook.js
、SyncWaterfallHook.js
。
异步钩子 中包含分为串行(Series)钩子:AsyncSeriesBailHook.js
、AsyncSeriesHook.js
、AsyncSeriesLoopHook.js
、AsyncSeriesWaterfallHook.js
和并行(Parallel):AsyncParallelBailHook.js
、AsyncParallelHook.js
。
按执行机制分,可以分为基本类型、瀑布类型、熔断类型和循环类型:
基本类型 :SyncHook.js
、AsyncSeriesHook.js
、AsyncParallelHook.js
。
瀑布类型(Waterfall) :类似reduce
函数,插件按照注册的顺序被调用,每个插件接收前一个插件返回的值作为输入。包含SyncWaterfallHook.js
、AsyncSeriesWaterfallHook.js
。
熔断类型(Bail) :如果任何一个处理函数返回一个非 undefined
的值,整个钩子的执行将立即停止,并且这个返回值将成为整个钩子的最终结果。包含AsyncParallelBailHook.js
、AsyncSeriesBailHook.js
、SyncBailHook.js
。
循环类型(Loop) :允许插件或函数根据条件反复执行,如果某个插件返回非 undefined
值,则钩子从头开始再次执行,直到所有插件都返回 undefined
,这时循环结束。包含AsyncSeriesLoopHook.js
、SyncLoopHook.js
。
其他工具函数
除了SyncHook.js
等钩子函数外,Hook.js
是整个钩子的基类,定义了钩子的基础结构和公共接口;HookCodeFactory.js
是用于动态生成钩子的执行代码的工厂类;HookMap.js
是一个钩子容器,用于管理和维护多个钩子实例;MultiHook.js
用于组合多个钩子成一个单一的钩子。
执行流程解析
我们以一个最简单的 SyncHook.js
为例,对钩子函数执行流程中每一步做一个解析。
SyncHook.js
的使用如下:
javascript
const SyncHook = require("./lib/SyncHook");
// 实例化函数,定义形参
const syncHook = new SyncHook(["arguments1", "arguments2"]);
//第二步:注册事件1
syncHook.tap("注册事件1", (arguments1, arguments2) => { console.log("注册事件1:", arguments1, arguments2);});
//第二步:注册事件2
syncHook.tap("注册事件2", (arguments1, arguments2) => { console.log("注册事件2:", arguments1, arguments2);});
//第三步:注册事件3
syncHook.tap("注册事件3", (arguments1, arguments2) => { console.log("注册事件3:", arguments1, arguments2);});
//第三步:触发事件,传实参
syncHook.call("参数1", "参数2");
/**
* 执行结果如下:
* 注册事件1: 参数1 参数2
* 注册事件2: 参数1 参数2
* 注册事件3: 参数1 参数2
*/
可以看到,通过new一个实例,带入所需的形参,通过tab方法,收集各个需要执行的事件,最终通过call方法,传入实参去触发执行事件。
我们先看看源码 syncHook.js中做了什么:
javascript
// SyncHookCodeFactory 继承自 HookCodeFactory 类,用于创建特定于同步钩子的代码
class SyncHookCodeFactory extends HookCodeFactory {
// 重写了父类中的 content 方法。它定义了同步钩子的执行内容,特别是如何处理错误(onError)和完成(onDone)的情况
content({ onError, onDone, rethrowIfPossible }) {
// 用于顺序调用所有注册的 tap
return this.callTapsSeries({
onError: (i, err) => onError(err),
onDone,
rethrowIfPossible
});
}
}
const factory = new SyncHookCodeFactory();
// 定义以下两个方法,尝试以异步方式使用 SyncHook 时抛出错误
const TAP_ASYNC = () => {
throw new Error("tapAsync is not supported on a SyncHook");
};
const TAP_PROMISE = () => {
throw new Error("tapPromise is not supported on a SyncHook");
};
// 这个函数用于编译钩子。它使用 factory 的 setup 和 create 方法来设置和创建钩子的执行代码。
const COMPILE = function(options) {
factory.setup(this, options);
return factory.create(options);
};
function SyncHook(args = [], name = undefined) {
// 创建 Hook 实例
const hook = new Hook(args, name);
hook.constructor = SyncHook;
hook.tapAsync = TAP_ASYNC;
hook.tapPromise = TAP_PROMISE;
hook.compile = COMPILE;
return hook;
}
从源码可以看出,SyncHook
这个类主要是作为一种特定类型钩子的接口和配置器 ,它自身不直接处理收集 tap 事件(插件注册)和触发执行的逻辑,这些功能是在 Hook.js 和 HookCodeFactory.js
中实现的。
解析Hook类
Hook.js 类实现了钩子的基本功能,如存储 tap(即插件或事件监听器)的列表,以及定义通用的接口。将Hook.js源码中的拦截器和异步处理等代码去除后展示如下:
ini
const CALL_DELEGATE = function(...args) {
this.call = this._createCall("sync");
return this.call(...args);
};
class Hook {
constructor(args = []) {
this._args = args; // 存储传入的参数列表。
this.taps = []; // 存储所有的 tap(即插件或监听器)
this._call = CALL_DELEGATE; // _call 和 call 都是调用代理,初始时指向 CALL_DELEGATE
this.call = CALL_DELEGATE;
this._x = undefined; // 用于内部存储
this.compile = this.compile; // 是一个在子类中被重写的抽象方法,用于编译并创建钩子的具体调用逻辑
this.tap = this.tap;
}
compile(options) {
throw new Error("Abstract: should be overridden");
}
// 用于根据钩子的类型(如同步或异步)创建相应的调用函数
_createCall(type) {
return this.compile({
taps: this.taps,
args: this._args,
type: type
});
}
_tap(type, options, fn) {
if (typeof options === "string") {
options = {
name: options.trim()
};
}
options = Object.assign({ type, fn }, options);
this._insert(options);
}
tap(options, fn) {
this._tap("sync", options, fn);
}
// 重置编译
_resetCompilation() {
this.call = this._call;
}
// 将新的 tap 插入到正确的位置
_insert(item) {
this._resetCompilation();
let before;
if (typeof item.before === "string") { // 如果 item.before 是字符串,则创建一个只包含这个字符串的 Set。
before = new Set([item.before]);
} else if (Array.isArray(item.before)) { //如果 item.before 是数组,则将其转换为 Set。
before = new Set(item.before);
}
let stage = 0; // stage 参数用于定义 tap 的优先级。
if (typeof item.stage === "number") {
stage = item.stage;
}
let i = this.taps.length;
while (i > 0) {
i--;
const x = this.taps[i];
this.taps[i + 1] = x;
const xStage = x.stage || 0;
if (before) {
if (before.has(x.name)) {
before.delete(x.name);
continue;
}
if (before.size > 0) {
continue;
}
}
if (xStage > stage) {
continue;
}
i++;
break;
}
this.taps[i] = item;
}
}
由上可以看到,Hook 内部定义了一个 this.taps
数组做注册事件的维护,当调用 syncHook.tap
函数时,Hook内部执行流程为 this.tab -> this._tap -> this._insert ,通过 _insert 函数将新增的tap 插到正确的位置。
而在 _insert 方法中,before 和 stage 参数被用来控制新 tap 的插入位置。这个过程确保了 tap 能够按照指定的顺序和优先级被执行。下面是这两个参数如何控制插入位置的具体机制:
- 处理 before 参数
before 参数指定了一组 tap 的名称,新的 tap 应该被插入到这些指定的 tap 之前。如果 before 是字符串,就转换为只包含这个字符串的 Set。 如果 before 是数组,就转换为包含所有数组项的 Set。 在遍历现有的 taps 时,检查每个 tap 的名称是否在 before 集合中。 如果在集合中找到匹配的名称,从集合中移除该名称,并继续遍历,直到集合为空或遍历结束。
- 处理 stage 参数
stage 参数为每个 tap 定义了一个优先级,较低的数字表示较高的优先级。为新的 tap 定义一个 stage 值,如果未指定,则默认为 0。 在遍历现有的 taps 时,比较每个 tap 的 stage 值。 如果现有 tap 的 stage 值大于新 tap 的 stage 值,就找到了插入位置。
而当调用 syncHook.call
时,Hook内部的执行流程为this.call
-> CALL_DELEGATE
-> this._createCall
-> this.compile
。最后一步 this.compile
的在定义 SyncHook.js
时初始化,相关代码在 HookCodeFactory
中生成。
解析HookCodeFactory类
HookCodeFactory
是库中用于动态生成钩子的执行代码,同样,我们将其中的异步处理和拦截器部分先去除,只留下支持 syncHook
钩子的部分代码:
javascript
class HookCodeFactory {
// 构造函数,接收配置对象
constructor(config) {
this.config = config;
this.options = undefined;
this._args = undefined;
}
// 根据提供的选项创建函数
create(options) {
this.init(options);
let fn;
// 仅处理同步类型的钩子
if (this.options.type === "sync") {
// 用来动态创建一个新的函数
fn = new Function(
this.args(),
'"use strict";\n' +
this.header() +
this.content()
);
}
return fn;
}
// 设置实例的状态,主要是初始化_taps属性
setup(instance, options) {
instance._x = options.taps.map(t => t.fn);
}
// 初始化options和_args属性
init(options) {
this.options = options;
this._args = options.args.slice();
}
// 生成函数头部代码,定义_x变量
header() {
let code = "";
code += "var _x = this._x;\n";
return code;
}
// 生成函数参数列表
args() {
return this._args.join(", ");
}
// 生成执行所有tap函数的代码
content() {
let code = "";
this.options.taps.forEach((tap, i) => {
code += `var _fn${i} = _x[${i}];\n`; // 为每个tap定义变量
code += `_fn${i}(${this.args()});\n`; // 调用tap函数
});
return code;
}
}
从上面代码可以看出,当我们每次执行 syncHook.call
时,实际抛出的是 HookCodeFactory
中this.create
方法根据 new Function
所动态生成的 fn 。
- new Function的简单介绍
首先我们对 new Function
做一个简单介绍,它是 JavaScript 中的一种构造函数,用于动态创建一个新的 Function 对象。它允许你将字符串形式的代码转换成可执行的函数。这种方法在需要动态生成和执行代码的场景中特别有用。new Function
的基本语法如下:
javascript
new Function ([arg1[, arg2[, ...argN]],] functionBody)
使用示例:
javascript
const sum = new Function('a', 'b', 'return a + b');
console.log(sum(2, 6)); // 输出: 8
在 HookCodeFactory
类中,调用 this.create
返回对应函数去执行。其中根据 this.header()
+ this.content()
两部分去拼接代码。
this.header()
方法的作用是生成执行函数的头部代码,包括一些初始化和预备代码,为后续的 tap 调用做准备。比如 _x 存储所有的 tap 函数引用,以及一个上下文变量 _context 。
this.content()
方法负责生成函数体中的核心逻辑,即实际调用注册的 taps
的代码。就是去遍历 this.options.taps
,然后生成一个一个的 fn 。
根据上面我们调用 syncHook
的例子,在 create
方法最后 return
的 fn
前打印了 console.log(fn.toString())
,展示如下:
ini
function anonymous(arguments1, arguments2) {
"use strict";
var _context;
var _x = this._x;
var _fn0 = _x[0];
_fn0(arguments1, arguments2);
var _fn1 = _x[1];
_fn1(arguments1, arguments2);
var _fn2 = _x[2];
_fn2(arguments1, arguments2);
}
拦截器 interceptors
在 tapable 库中,拦截器(interceptors)提供了一种方式来拦截和修改钩子的行为,例如在每个 tap 执行之前或之后执行某些操作,或者修改 tap 的参数。首先我们先来看一下拦截器的使用例子:
javascript
const SyncHook = require("./lib/SyncHook");
const syncHook = new SyncHook(["arguments1", "arguments2"]);
// 定义一个拦截器
const interceptor = {
register(tapInfo) {
// 可以修改 tapInfo
console.log(`注册了一个 tap: ${tapInfo.name}`);
return tapInfo; // 一定要返回 tapInfo
},
call(arg1, arg2) {
// 在每个 tap 被调用之前执行
console.log('即将调用一个 tap');
},
tap(tapInfo) {
// 在每个 tap 被执行之前执行
console.log(`即将执行 tap: ${tapInfo.name}`);
}
};
// 将拦截器添加到钩子
syncHook.intercept(interceptor);
//第二步:注册事件1
syncHook.tap("注册事件1", (arguments1, arguments2) => {
console.log("注册事件1:", arguments1, arguments2);
});
//第三步:触发事件,这里传的是实参,会被每一个注册函数接收到
syncHook.call("参数1", "参数2");
/**
* 执行结果如下:
* 注册了一个 tap: 注册事件1
* 即将调用一个 tap
* 即将执行 tap: 注册事件1
* 注册事件1: 参数1 参数2
*/
在这个示例中,我们创建了一个 SyncHook
实例并定义了一个拦截器。这个拦截器通过 register
、call
和 tap
方法在钩子的不同阶段输出信息。当钩子被调用时,这些拦截器方法会按照预定的方式被触发。
我们将 Hook.js 代码做个简化,只留下拦截器相关的处理方法:
javascript
class Hook {
constructor() {
this.interceptors = [];
}
// 添加拦截器
intercept(interceptor) {
this.interceptors.push(Object.assign({}, interceptor));
}
// 运行拦截器的注册方法
_runRegisterInterceptors(options) {
for (const interceptor of this.interceptors) {
if (interceptor.register) {
const newOptions = interceptor.register(options);
if (newOptions !== undefined) {
options = newOptions;
}
}
}
return options;
}
// 模拟 tap 注册,展示拦截器的运行
tap(options) {
options = this._runRegisterInterceptors(options);
// 这里可以添加处理逻辑,例如将 tap 添加到内部存储
console.log('Tap registered:', options);
}
}
代码很简单,就是添加一个新的拦截器到拦截器数组中。
然后在 HookCodeFactory
类中,与拦截器相关的处理主要体现在以下几个方面:
contentWithInterceptors
方法,负责将拦截器的行为整合到钩子的执行代码中:
kotlin
contentWithInterceptors(options) {
if (this.options.interceptors.length > 0) {
// 对于每个拦截器,执行相关的拦截器方法
// 比如在调用每个 tap 前后执行 interceptor 的 call、error、result 等方法
...
} else {
return this.content(options);
}
}
当调用单个 tap 时,callTap
方法会考虑拦截器逻辑。如果拦截器定义了针对特定 tap 的行为(如 tap 方法),则这些行为将被集成到生成的执行代码中:
javascript
callTap(tapIndex, { onError, onResult, onDone, rethrowIfPossible }) {
...
// 对每个 tap,检查并执行拦截器的逻辑
for (let i = 0; i < this.options.interceptors.length; i++) {
const interceptor = this.options.interceptors[i];
if (interceptor.tap) {
...
// 执行拦截器的 tap 方法
}
}
...
}
在 header 方法中准备拦截器:
ini
header() {
...
if (this.options.interceptors.length > 0) {
// 定义与拦截器相关的变量
code += "var _taps = this.taps;\n";
code += "var _interceptors = this.interceptors;\n";
}
...
}
上面拦截器的使用示例,最后由new Function 动态拼接出来的代码如下:
ini
function anonymous(arguments1, arguments2) {
"use strict";
var _context;
var _x = this._x;
var _taps = this.taps;
var _interceptors = this.interceptors;
_interceptors[0].call(arguments1, arguments2);
var _tap0 = _taps[0];
_interceptors[0].tap(_tap0);
var _fn0 = _x[0];
_fn0(arguments1, arguments2);
}
在Webpack中的具体应用
webpack
的编译和构建过程中有多个阶段,例如解析、模块加载、打包等。tapable 允许 webpack
在这些不同阶段暴露出钩子,插件可以在这些钩子上执行特定的操作。例如,emit
钩子在输出阶段被调用,允许插件在文件写入磁盘之前修改它们。
Compiler
我们拿 webpack
中的 Compiler
类举例子,其中利用了 tapable
定义了一系列的钩子:
php
const {
SyncHook,
SyncBailHook,
AsyncParallelHook,
AsyncSeriesHook
} = require("tapable");
class Compiler {
constructor(context, options = ({})) {
this.hooks = Object.freeze({
initialize: new SyncHook([]),
shouldEmit: new SyncBailHook(["compilation"]),
done: new AsyncSeriesHook(["stats"]),
afterDone: new SyncHook(["stats"]),
additionalPass: new AsyncSeriesHook([]),
beforeRun: new AsyncSeriesHook(["compiler"]),
run: new AsyncSeriesHook(["compiler"]),
emit: new AsyncSeriesHook(["compilation"]),
assetEmitted: new AsyncSeriesHook(["file", "info"]),
afterEmit: new AsyncSeriesHook(["compilation"]),
thisCompilation: new SyncHook(["compilation", "params"]),
compilation: new SyncHook(["compilation", "params"]),
normalModuleFactory: new SyncHook(["normalModuleFactory"]),
contextModuleFactory: new SyncHook(["contextModuleFactory"]),
beforeCompile: new AsyncSeriesHook(["params"]),
compile: new SyncHook(["params"]),
make: new AsyncParallelHook(["compilation"]),
finishMake: new AsyncSeriesHook(["compilation"]),
afterCompile: new AsyncSeriesHook(["compilation"]),
readRecords: new AsyncSeriesHook([]),
emitRecords: new AsyncSeriesHook([]),
watchRun: new AsyncSeriesHook(["compiler"]),
failed: new SyncHook(["error"]),
invalid: new SyncHook(["filename", "changeTime"]),
watchClose: new SyncHook([]),
shutdown: new AsyncSeriesHook([]),
infrastructureLog: new SyncBailHook(["origin", "type", "args"]),
environment: new SyncHook([]),
afterEnvironment: new SyncHook([]),
afterPlugins: new SyncHook(["compiler"]),
afterResolvers: new SyncHook(["compiler"]),
entryOption: new SyncBailHook(["context", "entry"])
});
// ......
}
}
Compiler
方法管理着从开始到结束的整个编译过程。它负责设置编译环境,准备编译所需的所有配置和选项,并控制编译流程的各个阶段。在编译过程的关键时刻,Compiler
会触发相应的钩子。例如,在编译完成后,它会触发 done
钩子:
ini
this.hooks.done.callAsync(stats, err => {
// ...
});
Compiler
提供了 apply
方法,允许插件去订阅这些钩子。插件可以使用 tap
(用于同步钩子)或 tapAsync
/ tapPromise
(用于异步钩子)方法来订阅。比如一个常规的 Plugin 写法如下:
javascript
class MyPlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
// ... 插件逻辑 ...
callback();
});
}
}
下面我贴一下在 Compiler.hooks 中一些写插件时常用的钩子所代表的阶段,给有需要写 Plugin 的读者做参考,有不对的地方欢迎评论区指正:
-
initialize
: 在编译器初始化阶段触发。 -
entryOption
: 在处理入口选项后触发。这是一个起点,插件可以在此阶段修改入口配置 -
make
: 在构建模块时触发。这是一个核心阶段,插件在此处添加模块或改变构建流程。 -
compile
: 在编译流程开始前触发。 -
emit
: 在将编译后的资源输出到输出目录之前触发。 -
done
: 在编译过程完成后触发。 -
failed
: 当编译过程中发生错误时触发,我们可以拿来用于错误处理和日志记录。
举个例子,我们可以拿 compile 钩子和 done 钩子做一个构建时间的打印:
javascript
class BuildTimePlugin {
apply(compiler) {
compiler.hooks.compile.tap('BuildTimePlugin', params => {
this.startTime = Date.now();
console.log('构建开始...');
});
compiler.hooks.done.tap('BuildTimePlugin', stats => {
const endTime = Date.now();
console.log(`构建结束,耗时:${endTime - this.startTime}ms`);
});
}
}