什么是tapable
Tapable是一个任务调度库,它的核心思基于发布订阅模式是将任务执行逻辑和调度逻辑分离,tapable在webpack中用于plugin的管理,在可以实现复杂调度逻辑的同时尽可能保证可维护性。
基本使用
按照执行流程的差异可以将tappable中的常用hook分为以下五个类别,SeriesHook(普通结果串行Hook),SeriesBailHook(带保险的串行Hook),SeriesWaterfallHook(串行瀑布Hook)还有并行Hook(ParallelHook && ParallelBailHook)。从名字中大概可以看出这些Hook的差异,SeriesHook还有同步和异步的区别,不过因为执行流程没有差异,这里不做区分。
SeriesHook
SeriesHook(SyncHook和AsyncSeriesHook)会按照数组内callback的顺序依次执行
const hook = new SyncHook(['arg1', 'arg2']);
hook.tap('callback1', (arg1, arg2) => {
console.log('callback1:', arg1, arg2);
});
hook.tap('callback2', (arg1, arg2) => {
console.log('callback2:', arg1, arg2);
});
hook.call('arg1', 'arg2');
// callback1: arg1 arg2
// callback2: arg1 arg2
SeriesBailHook
串行保险Hook经常用于需要中断的任务调度,当callback存在非undefined的返回值时会停止后续callback的执行
const hook = new SyncBailHook(['arg1', 'arg2']);
hook.tap('callback1', (arg1, arg2) => {
console.log('callback1:', arg1, arg2);
return 'top'
});
hook.tap('callback2', (arg1, arg2) => {
console.log('callback2:', arg1, arg2);
});
hook.call('arg1', 'arg2');
// callback1: arg1 arg2
SeriesWaterfallHook
瀑布Hook会将自己的返回值传递给下一个callback的第一个参数,如果callback直接存在返回值依赖可以使用WaterfallHook
const hook = new SyncWaterfallHook(['arg1', 'arg2']);
// 注册事件
hook.tap('callback1'', (arg1, arg2) => {
console.log('callback1:', arg1, arg2);
return 'foo';
});
hook.tap('callback2', (arg1, arg2) => {
console.log('callback2:', arg1, arg2);
});
hook.call('arg1', 'arg2');
// callback1: arg1 arg2
// callback2: foo arg2
ParallelHook
ParallelHook类似于Promise.all,在并发执行所有异步任务后等待任务全部结束并执行callback
const hook = new AsyncParallelHook(['arg1', 'arg2']);
hook.tapPromise('promise1', (arg1, arg2) => {
console.log('promise1:', arg1, arg2);
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(true);
}, 1000);
});
});
hook.tapPromise('promise2', (arg1, arg2) => {
console.log('promise2:', arg1, arg2);
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(true);
}, 1000);
});
});
hook.callAsync('arg1', 'arg2', () => {
console.log('finish');
});
// promise1: arg1 arg2
// promise2: arg1 arg2
// finish
ParallelBailHook
同时结合了Parallel和Bail,这个Hook感觉用处不是特别大,当地一个异步函数执行结束并带有返回值时会触发callback,但是因为其他函数也已经启动了,所以还是会被执行。
const hook = new AsyncParallelBailHook(['arg1', 'arg2']);
hook.tapPromise('promise1', (arg1, arg2) => {
return new Promise((resolve, reject) => {
console.log('promsie1:', arg1, arg2);
setTimeout(() => {
resolve(true);
}, 1000);
});
});
hook.tapAsync('async1', (arg1, arg2, callback) => {
setTimeout(() => {
console.log('async1:', arg1, arg2);
callback();
}, 3000);
});
hook.callAsync('arg1', 'arg2', () => {
console.log('done');
});
// promise1: arg1 arg2
// done
// async1: arg1 arg2
源码浅析
在看源码之前可以熟悉一下tapable的文件结构:
- HookCodeFactory.js 包含hook无关的逻辑,比如串行执行hook,动态生成call函数代码等
- 各种Hook.js就是对前端提到的不同种Hook的具体实现 下面以SyncHook为例,一起分析以下tapable的执行流程
测试代码如下
const hook = new SyncHook(['arg1', 'arg2']);
hook.tap('flag1', (arg1, arg2) => {
console.log('flag1:', arg1, arg2);
});
hook.tap('flag2', (arg1, arg2) => {
console.log('flag2:', arg1, arg2);
});
hook.call('arg1', 'arg2');
HookCodeFactory初始化
class SyncHookCodeFactory extends HookCodeFactory {
content({ onError, onDone, rethrowIfPossible }) {
return this.callTapsSeries({
onError: (i, err) => onError(err),
onDone,
rethrowIfPossible
});
}
}
每个Hook都需要重写HookCodeFactory中的content方法,该方法决定taps的执行流程,串行执行的Hook需要调用callTapsSeries,并行的Hook则需要调用callTapsParallel。
也就是说Hook的执行流程在初始化的时候已经决定了,后面Factory直接调用content方法即可,并不需要感知具体是什么Hook,这样就做到了Hook和CodeFactory的解耦。
插入callback
_insert(item) {
this._resetCompilation();
// 处理before
let before;
if (typeof item.before === "string") {
before = new Set([item.before]);
} else if (Array.isArray(item.before)) {
before = new Set(item.before);
}
// stage
let stage = 0;
if (typeof item.stage === "number") {
stage = item.stage;
}
// 反向遍历insert
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内部会维护一个数组用于保存callback顺序,在调用Hook.tap方法时将callback按照参数的要求插入的数组中,后面call方法就可以保证执行顺序正确(在insert时callback之间的顺序已经确定了)。
Compile
callTapsSeries({
onError,
onResult,
resultReturns,
onDone,
doneReturns,
rethrowIfPossible
}) {
// some code
for (let j = this.options.taps.length - 1; j >= 0; j--) {
const i = j;
const unroll =
current !== onDone &&
(this.options.taps[i].type !== "sync" || unrollCounter++ > 20);
if (unroll) {
unrollCounter = 0;
code += `function _next${i}() {\n`;
code += current();
code += `}\n`;
current = () => `${somethingReturns ? "return " : ""}_next${i}();\n`;
}
const done = current;
const doneBreak = skipDone => {
if (skipDone) return "";
return onDone();
};
const content = this.callTap(i, {
// some code
});
current = () => content;
}
code += current();
return code;
}
create(options) {
this.init(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;
case "async":
// some code
case "promise":
// some code
}
this.deinit();
return fn;
}
Compile过程是tapable的核心流程,通过前面传入的callback动态生成call方法。create方法中的contentWithInterceptors最终会调用callTapsSeries方法(根据Hook不同调用的方法不同),通过循环的方式将函数的执行流程“铺平”。
下面可以看一下Compile后返回的代码
function anonymous(arg1, arg2
) {
"use strict";
var _context;
var _x = this._x;
var _fn0 = _x[0];
_fn0(arg1, arg2);
var _fn1 = _x[1];
_fn1(arg1, arg2);
}
这就是Demo经过Compile后生成的call代码,原本的for循环被生成为线性执行流程(如果是AsyncSeriesHook会更复杂一点)。
- _x是callback构成的array
- _fn是每一个callback
核心流程总结
几个比较有趣的点
tapable中有很多有趣的流程值得一看
如何动态生成call方法
这里可以从HookCodeFactory.create开始看,具体的调用流程是HookCodeFactory.create -> HookCodeFactory.contentWithInterceptors -> callTapSeries
- contentWithInterceptors用来处理拦截器,如果传入了拦截器需要将interceptors插入到callback的执行流程中
- 对于没有拦截器的情况直接到callTapSeries动态生成call代码
在处理taps数组时tapable通过循环将每个callback拆出来形成_fn{index}形式的调用,接下来按顺序执行即可
SeriesHook如何保证异步任务按顺序连续调用
先贴一下测试代码
const hook = new AsyncSeriesHook(['arg1', 'arg2']);
hook.tapAsync('callback1', (arg1, arg2, callback) => {
setTimeout(() => {
callback();
}, 1000);
});
hook.tapAsync('callback2', (arg1, arg2, callback) => {
setTimeout(() => {
callback();
}, 1500);
});
hook.callAsync('arg1', 'arg2', () => {
console.log('全部执行完毕 done');
});
这里主要涉及两块,首先是Hook._insert方法,tapable会把callback放在数组内,在插入新的callback时会使用一种类似插入排序的机制对比stage和before,保证callback插入顺序正确,具体可以看下面的代码。
_insert(item) {
// 创建包含before的set
let before;
if (typeof item.before === "string") {
before = new Set([item.before]);
} else if (Array.isArray(item.before)) {
before = new Set(item.before);
}
// 处理stage
let stage = 0;
if (typeof item.stage === "number") {
stage = item.stage;
}
// 反向遍历insert
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;
}
tapable将before转换为set,然后反向遍历taps数组,如果碰到before包含的string则将该string移出set,直到满足条件。
另一部分就是如何保证Async方法连续执行(其实很容易猜到通过callback完成此功能),核心逻辑在callTapSeries内
if (unroll) {
unrollCounter = 0;
code += `function _next${i}() {\n`;
code += current();
code += `}\n`;
current = () => `${somethingReturns ? "return " : ""}_next${i}();\n`;
}
通过unroll变量来标记下一个该执行的函数 _next${i},在当前异步函数结束时需要调用最后一个入参作为结束信号,该函数内就是 _next。
可以在看一下生成的call方法验证以下结论
function anonymous(arg1, arg2, _callback) {
"use strict";
var _context;
var _x = this._x;
function _next0() {
var _fn1 = _x[1];
_fn1(arg1, arg2, (function(_err1) {
if(_err1) {
_callback(_err1);
} else {
_callback();
}
}));
}
var _fn0 = _x[0];
_fn0(arg1, arg2, (function(_err0) {
if(_err0) {
_callback(_err0);
} else {
_next0();
}
}));
}
interceptor工作流程
tapbable中的interceptor可以分为五种
- rigister 在执行intercept时调用
- call 执行call方法时调用
- tap callback执行时调用
- loop 循环Hook触发loop时调用
在执行Hook.intercept时会将interceptor放入数组内,变遍历taps执行register
intercept(interceptor) {
this._resetCompilation();
this.interceptors.push(Object.assign({}, interceptor));
if (interceptor.register) {
for (let i = 0; i < this.taps.length; i++) {
this.taps[i] = interceptor.register(this.taps[i]);
}
}
}
在contentWithInterceptor内会判断当前hook是否有interceptor,如果有则在生成的代码中插入interceptor,如果没有直接走content逻辑
下面看一下create方法最终生成的代码
function anonymous(arg1, arg2) {
"use strict";
var _context;
var _x = this._x;
var _taps = this.taps;
var _interceptors = this.interceptors;
var _tap0 = _taps[0];
_interceptors[0].tap(_tap0);
var _fn0 = _x[0];
_fn0(arg1, arg2);
}
在每个_fn执行结束之前会执行_interceptors[index].tap方法,具体数量取决于interceptor数量,其他的逻辑与单纯执行content方法没有区别
如何监听并发Hook执行结束
tapable监听异步并发任务结束的方案类似于Promise.all,都是在内部保存一个计数器(_counter)。
这里可以看callTapsParallel(不同种类的hook都会重写HookCodeFactory的content方法,该方法决定执行callback的流程,前面的SyncHook会执行callTapsSeries,这里是callTapsParallel)
直接看一下生成的call代码会比较清晰,内部维护了_counter,每个异步任务结束则_coutern - 1,_counter为0是异步任务全部结束。
function anonymous(arg1, arg2, _callback) {
"use strict";
var _context;
var _x = this._x;
do {
var _counter = 2;
var _done = (function() {_callback();});
if(_counter <= 0)
break;
var _fn0 = _x[0];
var _hasResult0 = false;
var _promise0 = _fn0(arg1, arg2);
_promise0.then((function(_result0) {
_hasResult0 = true;
if(--_counter === 0) _done();
}), function(_err0) {
if(_counter > 0) {
_callback(_err0);
_counter = 0;
}});
if(_counter <= 0)
break;
var _fn1 = _x[1];
var _hasResult1 = false;
var _promise1 = _fn1(arg1, arg2);
_promise1.then((function(_result1) {
_hasResult1 = true;
if(--_counter === 0)
_done();
}), function(_err1) {
if(_counter > 0) {
_callback(_err1);
_counter = 0;
}});
} while(false);
}
结尾
tapable源码相对比较古老,部分细节可能比较难懂,但是对于异步任务的执行流程控制还是很值得学习的。后面有时间可以再分析一下BailHook和Waterfall的执行流程