深入理解 Tapable
# 深入理解 Webpack 中的 Tapable
# 概览
其实稍微有了解过一些 Webpack 插件机制的同学应该会知道,Webpack 整个构建流程中,内部都是基于 tapable 做整体的事件流管控,通过 tapable 实现 Webpack 各个阶段之间的串联,同时也为开发者插件编写提供了十分丝滑的接入方案。那首先我们可以先简单了解下 tapable 包含了哪些 hooks。
# tapable hooks
# 返回值处理方式分类
从主要执行任务之后对待任务返回值的态度来看来看,可以这么区分:
# 普通
- SyncHook:同步串行
- AsyncSeriesHook:异步串行
- AsyncParallelHook:异步并行
按顺序执行一个个事件函数,不处理也不关心函数返回值。
# Bail - 熔断机制
- SyncBailHook:同步串行
- AsyncSeriesBailHook:异步串行
- AsyncParallelBailHook:异步并行
按顺序执行一个个事件函数,一旦有一个函数返回值不为 undefined 则直接结束。
# Water - 瀑布流机制
- SyncWaterfallHook:同步串行
- AsyncSeriesWaterHook:异步串行
瀑布流形式,将上一个函数的返回值作为当前函数的入参。
# Loop - 循环机制
- SyncLoopHook:同步循环执行
执行任务队列时候,只要其中一个返回非 undefined 就会重新开始从一个开始执行,直到所有任务都返回 undefined 。
# 执行任务机制分类
函数用途介绍如上
# 同步 hooks
- SyncHook
- SyncBailHook
- SyncWaterfallHook
- SyncLoopHook
# 异步串行 hooks
- AsyncSeriesHook
- AsyncSeriesBailHook
- AsyncSeriesWaterHook
# 异步并行 hooks
- AsyncParallelHook
- AsyncParallelBailHook
# 上手实践
# 如何使用 tapable
import { SyncHook } from 'tapable';
const instance = new SyncHook(['mxl']);
instance.tap('func1', (...args) => {
console.log('func1', ...args);
})
instance.tap('func2', (...args) => {
console.log('func2', ...args);
})
instance.call('first call');
// output:
// func1 first call
// func2 first call
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
使用方式上非常简单,选择你想要使用的 hook,然后实例化一下,就可以开始使用它的相关机制了。
其实上述用法来看,它很像我们常说的发布订阅模式,实例化出来的这个对象就类似于一个事件管理中心,你可以使用 tap 函数来订阅一个事件,在合适的时候,调用 call 就能按照你选的 hook 运行机制进行依次执行了。
# 实现一个简易 tapable
type ITask = (...args: unknown[]) => void;
export class SyncHook{
public taps: ITask[] = []
constructor(public args: unknown[]) {}
// 订阅
tap(name: string, fn: ITask) {
this.taps.push(fn);
}
// 执行
call(...args: unknown[]){
this.taps.forEach(tap => tap(...args));
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
一个简单的发布订阅模式其实就只需要一个函数用来收集,并提供另一个函数来进行批量执行就好了。
# Tapable 原理分析
这里就以 SyncHook 来进行解析。
# 入口函数
function SyncHook(args = [], name = undefined) {
const hook = new Hook(args, name);
hook.constructor = SyncHook;
hook.tapAsync = TAP_ASYNC;
hook.tapPromise = TAP_PROMISE;
hook.compile = COMPILE;
return hook;
}
2
3
4
5
6
7
8
第一步从入口看,这里实例化了一个 Hook 类,然后把我们刚刚传的参数透传进去,同时挂载了几个其他方法,如 tapAsync、tapPromise 等等。
这个时候机智的读者可能会问了,这不是同步 hook 吗,怎么还能调用 tapAsync?且看下面他们的实现你就知道了:
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");
};
2
3
4
5
6
7
这里是因为 new 出来的 hook 实例上有这些方法,但是 SyncHook 是不能调的,所以要覆盖成抛错的函数,避免被错误调用。
# Hook 类
这里简化一下,选取一部分核心逻辑。
const CALL_DELEGATE = function(...args) {
this.call = this._createCall("sync");
return this.call(...args);
};
class Hook {
constructor(args = [], name = undefined) {
this._args = args;
this.name = name;
this.taps = [];
this.call = CALL_DELEGATE;
this.tap = this.tap;
}
compile(options) {
throw new Error("Abstract: should be overridden");
}
_createCall(type) {
return this.compile({
taps: this.taps,
interceptors: this.interceptors,
args: this._args,
type: type
});
}
_tap(type, options, fn) {
// ...省略一些判断逻辑
options = Object.assign({ type, fn }, options);
this._insert(options);
}
_insert(item) {
// ...以上省略一大段代码
this.taps[i] = item;
}
tap(options, fn) {
this._tap("sync", options, fn);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
这里梳理一下其实也很简单,调用 tap 方法会把你注册的方法放到 taps 队列中,等待后面被 call 调用。逻辑复杂一点的还是这个 call 之后执行的逻辑,他首先调用 _createCall,_createCall 函数里面又调了这个 compile 方法,而这个方法是要求在实例化的时候被重写的,我们来看看这个 compile 函数调用之后做了什么事。
class SyncHookCodeFactory extends HookCodeFactory {
content({ onError, onDone, rethrowIfPossible }) {
return this.callTapsSeries({
onError: (i, err) => onError(err),
onDone,
rethrowIfPossible
});
}
}
const factory = new SyncHookCodeFactory();
const COMPILE = function(options) {
factory.setup(this, options);
return factory.create(options);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这里其实也不是核心的部分,我们可以直接跳过,来关注下核心的部分,这里的 create 方法会最终调到上面的 content 方法,也就是进到 callTapsSeries 方法里,这里其实 tapable 实现了一套拦截器机制,这里就不过多阐述了,感兴趣可以直接看这块的源码,内容不多。我们来继续看 callTapsSeries 方法做了什么。
callTapsSeries({
onError,
onResult,
resultReturns,
onDone,
doneReturns,
rethrowIfPossible
}) {
let code = "";
let current = onDone;
for (let j = this.options.taps.length - 1; j >= 0; j--) {
const done = current;
const content = this.callTap(i, {......});
current = () => content;
}
code += current();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
这里也是简化了一大段逻辑,主要是在遍历整个 taps 队列,然后进行字符串拼接,我们可以大致看下这个 callTap 函数做了什么
callTap() {
// .....
let code += `var _fn${tapIndex} = ${this.getTapFn(tapIndex)};\n`;
code += `_fn${tapIndex}(${params});\n`;
if (onDone) {
code += onDone();
}
// ......
}
2
3
4
5
6
7
8
9
10
这里也简化了下代码,我们可以差不多看到它就是组装了每一项 tap 的执行逻辑。同时我们可以看上面 callTapsSeries 函数遍历 taps 队列是从后往前遍历的,为啥这么做其实我们从它代码的组装逻辑可以看出来为啥,每次执行 callTap 都会把前面处理好的 code 拼到当前处理的 code 字符串的后面,也就是执行的时候越晚被遍历的函数执行的越早,和上面倒的遍历就对上了,保证了最终的执行顺序。
最后再看一眼它最终调用这段组装好的代码的地方,其实又来回归到前面调用的 create 函数里:
function create(options) {
let fn;
switch (this.options.type) {
case "sync":
fn = new Function(
this.args(),
'"use strict";\n' +
this.header() +
this.contentWithInterceptors({
onError: err => `throw ${err};\n`,
onResult: result => `return ${result};\n`,
resultReturns: true,
onDone: () => "",
rethrowIfPossible: true
})
);
break;
}
return fn;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
刚刚我们看的那几个函数就是从 contentWithInterceptors 这个函数一路调用过去的,最终拿到的结果会放到这个 new Function 中去执行。
至此大致的流程就差不多介绍到这了,如果有发现笔者表述不正确的地方,请帮忙指出,感激不尽。