深入浅出tapable?


#深入浅出tapable

什么是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();
		}
	}));
	}

intercepter工作流程

如何监听并发Hook执行结束

结尾


Author: Maple
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source Maple !
  TOC