深入浅出tapable

1,002 阅读4分钟

什么是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

核心流程总结

juejin.png

几个比较有趣的点

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的执行流程