揭开 Webpack 工作流程面纱:Tapable 模块
webpack 工程相当庞大,但是 Webpack 本质上是一种事件流机制,通过事件流将各种插件串联起来,最终完成 webpack 的全流程,而实现事件流机制的核心是今天要讲的Tapable 模块。Webpack 负责编译的 Compiler 和创建 Bundle 的 Compilation 都是继承自 Tapable。所以在讲 Webpack 工作流程之前,我们需要先掌握 Tapable。
事件监听和发射器
我们都知道 Node.js 特点提到事件驱动,这是因为 Node.js 本身利用 JavaScript 的语言特点实现了自定义的事件回调,Node.js 内部一个事件发射器 EventEmitter,通过这个类,可以进行事件监听与发射,这个也是 Node.js 的核心模块,很多 Node.js 内部模块都是继承自它,或者引用了它。
const EventEmitter = require('events').EventEmitter;
const event = new EventEmitter();
event.on('event_name', (arg) => {
console.log('event_name fire', arg);
});
setTimeout(function () {
event.emit('event_name', 'hello world');
}, 1000);
上面代码就是事件发射器的用法。
webpack 核心库 Tapable 的原理和 EventEmitter 类似,但是功能更强大,包括多种类型,通过事件的注册和监听,触发 webpack 生命周期中的函数方法,在 Webpack 中,tapable 都是放到对象的hooks上,所以我们叫他们钩子。翻阅 webpack 的源码时,会发现很多类似下面的代码:
// webpack 4.29.6
// lib/compiler
class Compiler extends Tapable {
constructor(context) {
super();
this.hooks = {
shouldEmit: new SyncBailHook(['compilation']),
done: new AsyncSeriesHook(['stats']),
additionalPass: new AsyncSeriesHook([]),
beforeRun: new AsyncSeriesHook(['compiler']),
run: new AsyncSeriesHook(['compiler']),
emit: new AsyncSeriesHook(['compilation']),
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']),
afterCompile: new AsyncSeriesHook(['compilation']),
watchRun: new AsyncSeriesHook(['compiler']),
failed: new SyncHook(['error']),
invalid: new SyncHook(['filename', 'changeTime']),
watchClose: new SyncHook([]),
environment: new SyncHook([]),
afterEnvironment: new SyncHook([]),
afterPlugins: new SyncHook(['compiler']),
entryOption: new SyncBailHook(['context', 'entry']),
};
}
}
这些代码就是一个类或者函数完整生命周期需要**「走过的路」**,所有 Webpack 代码虽然代码量很大,但是从hook找生命周期事件点,然后通过 hook 名称,基本就可以猜出大概流程。
Tapable 中 Hook 的类型
在Tapable 的文档中显示了,Tapable 分为以下类型:
// tapable 1.1.1
const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
AsyncParallelHook,
AsyncParallelBailHook,
AsyncSeriesHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook,
} = require('tapable');
Hook 类型可以分为同步(Sync)和异步(Async),异步又分为并行和串行。

根据使用方式来分,又可以分为Basic、Waterfal、Bail和Loop四类,每类 Hook 都有自己的使用要点:
| 类型 | 使用要点 |
|---|---|
| Basic | 基础类型,不关心监听函数的返回值,不根据返回值做事情 |
| Bail | 保险式,只要监听函数中有返回值(不为undefined),则跳过之后的监听函数 |
| Waterfal | 瀑布式,上一步的返回值继续交给下一步处理和使用 |
| Loop | 循环类型,如果该监听函数返回 true 则这个监听函数会反复执行,如果返回 undefined 则退出循环 |
Basic 类型 Hook
基础类型包括SyncHook、AsyncParallelHook和AsyncSeriesHook,这类 Hook 不关心函数的返回值,会一直执行到底。下面以SyncHook为例来说明下:
const {SyncHook} = require('tapable');
// 所有的构造函数都接收一个可选的参数,这个参数是一个参数名的字符串数组
// 1. 这里array的字符串随便填写,但是array的长度必须与实际要接受参数个数保持一致;
// 2. 如果回调不接受参数,可以传入空数组。
// 后面类型都是这个规则,不再做过多说明
const hook = new SyncHook(['name']);
// 添加监听
hook.tap('1', (arg0, arg1) => {
// tap 的第一个参数是用来标识`call`传入的参数
// 因为new的时候只的array长度为1
// 所以这里只得到了`call`传入的第一个参数,即Webpack
// arg1 为 undefined
console.log(arg0, arg1, 1);
return '1';
});
hook.tap('2', (arg0) => {
console.log(arg0, 2);
});
hook.tap('3', (arg0) => {
console.log(arg0, 3);
});
// 传入参数,触发监听的函数回调
// 这里传入两个参数,但是实际回调函数只得到一个
hook.call('Webpack', 'Tapable');
// 执行结果:
/*
Webpack undefined 1 // 传入的参数需要和new实例的时候保持一致,否则获取不到多传的参数
Webpack 2
Webpack 3
*/
通过上面的代码可以得出结论:
- 在实例化SyncHook传入的数组参数实际是只用了长度,跟实际内容没有关系
- 执行call时,入参个数跟实例化时数组长度相关
- 回调栈是按照「先入先出」顺序执行的(这里叫回调队列更合适,队列是先入先出)
- 功能跟 EventEmitter 类似
详细的流程图如下:

再来看下AsyncSeriesHook的示例:
const {AsyncSeriesHook} = require('tapable');
const hook = new AsyncSeriesHook(['name']);
hook.tapAsync('one', (name, cb) => {
console.log('one', name);
setTimeout(() => {
console.log('one timeout');
cb();
}, 100);
});
hook.tapAsync('two', (name, cb) => {
console.log('two', name);
cb();
});
hook.callAsync('asyncHook', (endArgs) => {
console.log('end');
console.log('endArgs', endArgs);
});
// 执行结果:
/*
one asyncHook
one timeout
two asyncHook
end
endArgs undefined
*/
如果对代码 setTimeout 部分进行修改:
setTimeout(() => {
console.log('one timeout');
cb(1); // <- 回调
}, 100);
// 执行结果
/*
one asyncHook
one timeout
end
err 1
*/
说明在AsyncSeriesHook流程中只要任何地方回调了cb传入参数,则直接跳过后续流程,直接进入callAsync的回调,从而结束流程。
Bail 类型 Hook
Bail类型的 Hook 包括:SyncBailHook、AsyncSeriesBailHook、AsyncParallelBailHook,Bail类型的 Hook 也是按回调栈顺序一次执行回调,但是如果其中一个回调函数返回结果result !== undefined 则退出回调栈调。代码示例如下:。
const {SyncBailHook} = require('tapable');
const hook = new SyncBailHook(['name']);
hook.tap('1', (name) => {
console.log(name, 1);
});
hook.tap('2', (name) => {
console.log(name, 2);
return 'stop';
});
hook.tap('3', (name) => {
console.log(name, 3);
});
hook.call('hello');
/* output
hello 1
hello 2
*/
通过上面的代码可以得出结论:
- BailHook 中的回调是顺序执行的
- 调用call传入的参数会被每个回调函数都获取
- 当回调函数返回非undefined 才会停止回调栈的调用
详细的流程图如下:

SyncBailHook类似Array.find,找到(或者发生)一件事情就停止执行;AsyncParallelBailHook类似Promise.race这里竞速场景,只要有一个回调解决了一个问题,全部都解决了。
Waterfall 类型 Hook
Waterfall类型 Hook 包括 SyncWaterfallHook、AsyncSeriesWaterfallHook。类似Array.reduce效果,如果上一个回调函数的结果 result !== undefined,则会被作为下一个回调函数的第一个参数。代码示例如下:
const {SyncWaterfallHook} = require('tapable');
const hook = new SyncWaterfallHook(['arg0', 'arg1']);
hook.tap('1', (arg0, arg1) => {
console.log(arg0, arg1, 1);
return 1;
});
hook.tap('2', (arg0, arg1) => {
console.log(arg0, arg1, 2);
return 2;
});
hook.tap('3', (arg0, arg1) => {
// 这里 arg0 = 2
console.log(arg0, arg1, 3);
// 等同于 return undefined
});
hook.tap('4', (arg0, arg1) => {
// 这里 arg0 = 2 还是2
console.log(arg0, arg1, 4);
});
hook.call('Webpack', 'Tapable');
/* console log output
Webpack Tapable 1
1 'Tapable' 2
2 'Tapable' 3
2 'Tapable' 4 */
通过上面的代码可以得出结论:
- WaterfallHook 的回调函数接受的参数来自于上一个函数结果
- 调用call传入的第一个参数会被上一个函数的非undefined结果给替换
- 当回调函数返回非undefined 不会停止回调栈的调用
详细的流程图如下:

Loop 类型 Hook
这类 Hook 只有一个SyncLoopHook(虽然 Tapable 1.1.1 版本中存在AsyncSeriesLoopHook,但是并没有将它 export 出来),LoopHook执行特点是不停的循环执行回调函数,直到所有函数结果 result === undefined。为了更加直观的展现 LoopHook 的执行过程,我对示例代码做了一下丰富:
const {SyncLoopHook} = require('tapable');
const hook = new SyncLoopHook(['name']);
let callbackCalledCount1 = 0;
let callbackCalledCount2 = 0;
let callbackCalledCount3 = 0;
let intent = 0;
hook.tap('callback 1', (arg) => {
callbackCalledCount1++;
if (callbackCalledCount1 === 2) {
callbackCalledCount1 = 0;
intent -= 4;
intentLog('</callback-1>');
return;
} else {
intentLog('<callback-1>');
intent += 4;
return 'callback-1';
}
});
hook.tap('callback 2', (arg) => {
callbackCalledCount2++;
if (callbackCalledCount2 === 2) {
callbackCalledCount2 = 0;
intent -= 4;
intentLog('</callback-2>');
return;
} else {
intentLog('<callback-2>');
intent += 4;
return 'callback-2';
}
});
hook.tap('callback 3', (arg) => {
callbackCalledCount3++;
if (callbackCalledCount3 === 2) {
callbackCalledCount3 = 0;
intent -= 4;
intentLog('</callback-3>');
return;
} else {
intentLog('<callback-3>');
intent += 4;
return 'callback-3';
}
});
hook.call('args');
function intentLog(...text) {
console.log(new Array(intent).join(' '), ...text);
}
/* output
<callback-1>
</callback-1>
<callback-2>
<callback-1>
</callback-1>
</callback-2>
<callback-3>
<callback-1>
</callback-1>
<callback-2>
<callback-1>
</callback-1>
</callback-2>
</callback-3>
*/
通过上面的代码可以得出结论:
- LoopHook 中的回调返回undefined(没有 return 其实就是undefined)才会跳出循环
- 所说的循环,起点是第一个回调栈的函数
详细的流程图如下:

Tapable 在 Webpack 中的应用
了解了 Tapable 的类型和基本使用方法,我们可能产生疑惑,这些类型在 Webpack 中是怎么被应用的,又是为什么要舍近求远的写个专门的库来实现异步,EventEmitter 和 Promise 不香吗?下面我们来看下 Webpack 内部的应用。
在 Webpack 中用的最多的是AsyncSeriesHook,我们摘录了 Webpack Compiler 一段代码:
// 为了好理解,对代码进行必要的精简
// 定义hooks
this.hooks = Object.freeze({
beforeRun: new AsyncSeriesHook(['compiler']),
run: new AsyncSeriesHook(['compiler']),
done: new AsyncSeriesHook(['stats']),
afterDone: new SyncHook(['stats']),
failed: new SyncHook(['error']),
});
const finalCallback = (err, stats) => {
// ...
if (err) {
this.hooks.failed.call(err);
}
if (callback !== undefined) callback(err, stats);
// 触发 afterDone
this.hooks.afterDone.call(stats);
};
const onCompiled = (err, compilation) => {
if (err) return finalCallback(err);
if (this.hooks.shouldEmit.call(compilation) === false) {
// ...
// 生成stats实例
const stats = new Stats(compilation);
this.hooks.done.callAsync(stats, (err) => {
if (err) return finalCallback(err);
return finalCallback(null, stats);
});
return;
}
// ...
this.hooks.done.callAsync(stats, (err) => {
if (err) return finalCallback(err);
this.cache.storeBuildDependencies(compilation.buildDependencies, (err) => {
if (err) return finalCallback(err);
return finalCallback(null, stats);
});
});
};
const run = () => {
this.hooks.beforeRun.callAsync(this, (err) => {
if (err) return finalCallback(err);
this.hooks.run.callAsync(this, (err) => {
if (err) return finalCallback(err);
this.readRecords((err) => {
if (err) return finalCallback(err);
this.compile(onCompiled);
});
});
});
};
// ==开始执行==
run();
在上面精简的代码中:
- 执行是从run开始,依次调用 Tapable 的 Hooks;
- 用的最多的是AsyncSeriesHook这个
- 我们看到整个代码出现最多的是if (err) return finalCallback(err);
Webpack 内部的 Tapable 的使用,实际是实现了一个异步操作流程,因为使用了AsyncSeriesHook,所以整个流程都是串行的异步,并且任何函数中遇见错误,回调Callback传回错误(finalCallback(err)),则流程立即停止。
这里实际是实现了一个符合 Node.js 的错误优先原则异步流程,这种用法和实现,保证了异步的同时,也很好的控制了流程中遇见的错误,即当错误在任何环节发生的时候,就可以调用回调函数直接结束整个流程。看到这里不得不说这种设计真的很赞。
Node.js 的错误优先原则: Node.js 核心 api 暴露的大多数异步方法都遵循称为错误优先回调的惯用模式。 使用这种模式,回调函数作为参数传给方法。 当操作完成或出现错误时,回调函数将使用 Error 对象(如果有)作为第一个参数传入。 如果没有出现错误,则第一个参数将作为 null 传入。
Tapable 的原理解析
Tapable 的执行流程可以分为四步:
- 使用tap*对事件进行注册绑定,根据类型不同,提供三种绑定的方式:tap、tapPromise、tapAsync,其中tapPromise、tapAsync为异步类 Hook 的绑定方法
- 使用call*对事件进行触发,根据类型不同,也提供了三种触发的方式:call、promise、callAsync
- 生成对应类型的代码片段(要执行的代码实际是拼字符串拼出来的)
- 生成第三步生成的代码片段
下面以SyncHook源码为例,分析下整个流程。先来看下lib/SyncHook.js 主要代码:
class SyncHook extends Hook {
// 错误处理,防止调用者调用异步钩子
tapAsync() {
throw new Error('tapAsync is not supported on a SyncHook');
}
tapPromise() {
throw new Error('tapPromise is not supported on a SyncHook');
}
// 实现入口
compile(options) {
factory.setup(this, options);
return factory.create(options);
}
}
首先所有的 Hook 都是继承自Hook类,针对同步 Hook 的事件绑定,如SyncHook、SyncBailHook、SyncLoopHook、SyncWaterfallHook, 会在子类中覆写基类Hook中 tapAsync 和 tapPromise 方法,这样做可以防止使用者在同步 Hook 中误用异步方法。
下面我按照执行流程的四个步骤来分析下源码,看一下一个完整的流程中,都是调用了什么方法和怎么实现的。
绑定事件
SyncHook中绑定事件是下面的代码。
hook.tap('evt1', (arg0) => {
console.log(arg0, 2);
});
hook.tap('evt2', (arg0) => {
console.log(arg0, 3);
});
下面我们来看下tap的实现,因为SyncHook是继承子Hook,所以我们找到lib/Hook.js中 tap 的实现代码:
tap(options, fn) {
// 实际调用了_tap
this._tap("sync", options, fn);
}
_tap(type, options, fn) {
// 这里主要进行了一些参数的类型判断
if (typeof options === "string") {
options = {
name: options
};
} else if (typeof options !== "object" || options === null) {
throw new Error("Invalid tap options");
}
if (typeof options.name !== "string" || options.name === "") {
throw new Error("Missing name for tap");
}
if (typeof options.context !== "undefined") {
deprecateContext();
}
options = Object.assign({ type, fn }, options);
// 这里是注册了Interceptors(拦截器)
options = this._runRegisterInterceptors(options);
// 参数处理完之后,调用了_insert,这是关键代码
this._insert(options);
}
通过查阅Hook.tap和Hook._tap的代码,发现主要是做一些参数处理的工作,而主要的实现是在Hook._insert实现的:
// tapable/lib/Hook.js
_insert(item) {
this._resetCompilation();
let before;
if (typeof item.before === "string") {
before = new Set([item.before]);
} else if (Array.isArray(item.before)) {
before = new Set(item.before);
}
let stage = 0;
if (typeof item.stage === "number") {
stage = item.stage;
}
// 这里根据 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;
}
_insert的代码主要目的是将传入的事件推入this.taps数组,等同于:
hook.tap('event', callback);
// → 即
this.taps.push({
type: 'sync',
name: 'event',
fn: callback,
});
在基类lib/Hook.js的constructor中,可以找到一些变量初始化的代码:
class Hook {
constructor(args = []) {
// 这里存入初始化的参数
this._args = args;
// 这里就是回调栈用到的数组
this.taps = [];
// 拦截器数组
this.interceptors = [];
this.call = this._call;
this.promise = this._promise;
this.callAsync = this._callAsync;
// 这个比较重要,后面拼代码会用
this._x = undefined;
}
}
这样绑定回调函数就完成了,下面看下触发回调的时候发生了什么。
事件触发
在事件触发,我们使用同syncHook的call方法触发一个事件:
hook.call(1, 2);
这里的call方法,实际是通过Object.defineProperties添加到Hook.prototype上面的:
// tapable/lib/Hook.js
function createCompileDelegate(name, type) {
return function lazyCompileHook(...args) {
this[name] = this._createCall(type);
return this[name](...args);
};
}
Object.defineProperties(Hook.prototype, {
_call: {
value: createCompileDelegate('call', 'sync'),
configurable: true,
writable: true,
},
_promise: {
value: createCompileDelegate('promise', 'promise'),
configurable: true,
writable: true,
},
_callAsync: {
value: createCompileDelegate('callAsync', 'async'),
configurable: true,
writable: true,
},
});
在上面的代码中,Hook.prototype通过对象定义属性方法Object.defineProperties定义了三个属性方法:_call、_promise、_callAsync,这三个属性的value都是通过 createCompileDelegate返回的一个名为lazyCompileHook的函数,从名字上面来猜测是「懒编译」,当我们真正调用call方法的时候,才会编译出真正的call函数。
call函数编译的用到的是_createCall方法,这个是在 Hook 类定义的时候就定义的方法,_createCall实际最终调用了compile方法,而通过Hook.js代码来看,compile是个需要子类重写实现的方法:
// tapable/lib/Hook.js
compile(options) {
throw new Error("Abstract: should be overriden");
}
_createCall(type) {
return this.compile({
taps: this.taps,
interceptors: this.interceptors,
args: this._args,
type: type
});
}
所以,在 Hook 中绕了一圈,我们又回到了SyncHook的类,我们再看下 SyncHook 的代码:
// lib/SyncHook.js
const HookCodeFactory = require('./HookCodeFactory');
class SyncHookCodeFactory extends HookCodeFactory {
content({onError, onDone, rethrowIfPossible}) {
return this.callTapsSeries({
onError: (i, err) => onError(err),
onDone,
rethrowIfPossible,
});
}
}
const factory = new SyncHookCodeFactory();
class SyncHook extends Hook {
tapAsync() {
throw new Error('tapAsync is not supported on a SyncHook');
}
tapPromise() {
throw new Error('tapPromise is not supported on a SyncHook');
}
compile(options) {
factory.setup(this, options);
return factory.create(options);
}
}
SyncHook 的compile来自是HookCodeFactory的子类SyncHookCodeFactory。在lib/HookCodeFactory.js找到setup方法:
// lib/HookCodeFactory
setup(instance, options) {
instance._x = options.taps.map(t => t.fn);
}
这里的instance实际就是SyncHook的实例,而_x就是我们之前绑定事件时候最后的_x。
最后factory.create(options)调用了HookCodeFactory的create方法,这个方法就是实际拼接可执行 JavaScript 代码片段的,具体看下实现:
// lib/HookCodeFactory.js
create(options) {
this.init(options);
let fn;
switch (this.options.type) {
case "sync":
fn = new Function(
this.args(),
'"use strict";\n' +
this.header() +
this.content({
onError: err => `throw ${err};\n`,
onResult: result => `return ${result};\n`,
resultReturns: true,
onDone: () => "",
rethrowIfPossible: true
})
);
break;
case "async":
fn = new Function(
this.args({
after: "_callback"
}),
'"use strict";\n' +
this.header() +
this.content({
onError: err => `_callback(${err});\n`,
onResult: result => `_callback(null, ${result});\n`,
onDone: () => "_callback();\n"
})
);
break;
case "promise":
let errorHelperUsed = false;
const content = this.content({
onError: err => {
errorHelperUsed = true;
return `_error(${err});\n`;
},
onResult: result => `_resolve(${result});\n`,
onDone: () => "_resolve();\n"
});
let code = "";
code += '"use strict";\n';
code += "return new Promise((_resolve, _reject) => {\n";
if (errorHelperUsed) {
code += "var _sync = true;\n";
code += "function _error(_err) {\n";
code += "if(_sync)\n";
code += "_resolve(Promise.resolve().then(() => { throw _err; }));\n";
code += "else\n";
code += "_reject(_err);\n";
code += "};\n";
}
code += this.header();
code += content;
if (errorHelperUsed) {
code += "_sync = false;\n";
}
code += "});\n";
fn = new Function(this.args(), code);
break;
}
this.deinit();
return fn;
}
上面create代码中的重要参数是type,而type是由 Hook 类在 createCompileDelegate("call", "sync")的时候传入进去的,所以调用 call方法,实际type为sync,在 create 中会进入到case 'sync'的分支,在switch中用到最重要的content实际是在class SyncHookCodeFactory extends HookCodeFactory的时候定义的。这里我们就不继续追踪代码生成的逻辑实现了,我们可以直接在最后将 fn的源码console.log出来:console.log(fn.toString()),大致可以得到下面的代码:
// 调用 call 的代码
const hook = new SyncHook(['argName0', 'argName1']);
hook.tap('evtName', (arg0) => {
console.log(arg0, 1);
});
hook.call('Webpack', 'Tapable');
// 最终得到的源码是:
function anonymous(argName0, argName1) {
'use strict';
var _context;
var _x = this._x;
var _fn0 = _x[0];
_fn0(argName0, argName1);
}
上面的_fn0实际就是我们tap绑定的回调函数,argName0和argsName1就是我们实例化SyncHook传入的形参,而我们实际只是在tap的回调中用了arg0一个参数,所以输出的结果是Webpack 1。
总结
Tapable 是 Webpack 的核心模块,Webpack 的所有工作流程都是通过 Tapable 来实现的。Tapable 本质上是提供了多种类型的事件绑定机制,根据不同的流程特点可以选择不同类型的 Hook 来使用。Tapable 的核心实现在绑定事件阶段跟我们平时的自定义 JavaScript 事件绑定(例如 EventEmitter)没有太大区别,但是在事件触发执行的时候,会临时生成可以执行的函数代码片段。通过这种实现方式,Tapable 实现了强大的事件流程控制能力,也增加了如 waterfall/parallel 系列方法,实现了异步/并行等事件流的控制能力。
作者:三水清
链接:https://juejin.cn/post/7039898741075083271
本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!