聊聊 Webpack 那些事 - 丑陋的 tapable

1,398 阅读6分钟

前言

我在上一篇文章中提到了, 对于骨灰级面试官可能会灵魂拷问下 Webpack 的核心依赖 tapable, 我作为面试官自然得去掌握下这个灵魂拷问的技巧, 毕竟我是个骨灰级面试官😀, 但是读完 tapable 的源码之后我的心情是这样的 ...这是什么鬼?

正文

光鲜亮丽的 Webpack 却有一颗其貌不扬甚至有点丑陋的心脏 tapable

tapable 是个很小的库, 但是却是 Webpack 插件机制的核心, tapable 的含义是类似一根自来水管那样, Webpack 将所有的构建过程封装成插件, 这些插件都被插在一个叫 Hook 的插槽上, 因为 JavaScript 的异步特征, Hook 分为 SyncHook 和 AsyncHook, 顾名思义就是看你的插件是个同步的还是异步的, 另外 tapable 也支持 Promise 风格的插件.

其实看 README.md 还好, 首先 tapable 并不是传统的 发布/订阅 模型, 这个可能和我们一般听到事件机制的预期不一致, 但现实就是如此, 如果你面试遇到这个问题, 首先得明确 tapable 就是 tapable, 如果说要定义是什么模式, 我觉得更像是管道模式.

通过 hook.tap → hook.taps[] → hook.call, 这种模式没有 event 模型中的事件名, 也没有 on, 或者监听之类的 api, 你可以看成是一种插件队列, 在 Webpack 里有各种各样的生命周期, 这些生命周期本质就是一个钩子管道, 看些源码感受下

			/** @type {SyncBailHook<[Compilation], boolean>} */
			shouldEmit: new SyncBailHook(["compilation"]),
			/** @type {AsyncSeriesHook<[Stats]>} */
			done: new AsyncSeriesHook(["stats"]),
			/** @type {SyncHook<[Stats]>} */
			afterDone: new SyncHook(["stats"]),
			/** @type {AsyncSeriesHook<[]>} */
			additionalPass: new AsyncSeriesHook([]),
			/** @type {AsyncSeriesHook<[Compiler]>} */
			beforeRun: new AsyncSeriesHook(["compiler"]),
			/** @type {AsyncSeriesHook<[Compiler]>} */
			run: new AsyncSeriesHook(["compiler"]),
			/** @type {AsyncSeriesHook<[Compilation]>} */
			emit: new AsyncSeriesHook(["compilation"]),
			/** @type {AsyncSeriesHook<[string, AssetEmittedInfo]>} */
			assetEmitted: new AsyncSeriesHook(["file", "info"]),
			/** @type {AsyncSeriesHook<[Compilation]>} */
			afterEmit: new AsyncSeriesHook(["compilation"]),

首先这些 Hook 之间并不共享状态, 对于 beforeRun 这个钩子来说, 可以有多个插件在这个钩子上执行, 而这些插件本质就是一个一个 tap, 就像水管中的水, 一滴一滴的流入, 那么在 tapable 内部, 插件运行的方式有这几种

  • 同步执行, 普通的队列
  • 并发执行, Parallel
  • 串行, Series

在此基础上还有几种模式

  • Bail
  • WaterFall
  • Loop (这个其实没实现)

至于实现机制, 其实没什么好说的, 很简单, 如果是同步的, 就是一个普通的函数, 如果是异步的, 那就加一个 callback, 你在 hook.call 的时候传入自己的 callback, 至于 Promise...就链式调用下就好了

当然这段我介绍 tapable 的详细设计是次要的....我主要是来吐槽的, 来看一段代码

_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;
		}
		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;
	}

这段代码干吗的? 就是往 taps 这个数组里插入一个 tap......, 如果这种方法看起来只是有点古怪, 那我给你看看当你执行 hook.call tapable 是怎么处理的, 你就知道我说的丑陋绝不是空穴来风

Weapack 内插件的每一次调用都是 tapable 这个老爷车在费力运作

对于 Webpack 来说, 任何一个插件都有一个 apply 方法, 然后你在插件内部可以使用 compiler 上的各种 hook 来构建插件的逻辑, 但是你知道你写的这些 hook 上的逻辑是怎么通过 tapable 来执行的么?

在没读 tapable 源码之前, 我是以一颗天真的心去猜测的, 直观感觉应该是

hook.call(x=>{某段插件的逻辑}), 然后 Webpack 在这个 hook 发生的阶段去调用我的这段逻辑, 可能采用类似 function.call(...args) 的方法, 这个链路基本就是

hook.call(function) → 代理函数(function.call(args))

然后当我头晕脑胀的读完 tapable 的逻辑, 他的链路是这样的

hook.call(function) → CALL_DELEGATEthis.call = this._createCallthis.compile() → factory.setup → factory.create() →
各种 code 生成逻辑 1,2,3new Functionthis.call(args)

这里面还有很多细节, 如果你比较闲, 对我说的就是那些老发沸点的摸鱼选手, 可以去试试读一下这段逻辑, 会让你了解到什么是 工厂模式, 什么是 原型继承, call 的用法, 以及深入理解 this, 并且对 JavaScript 面向对象编程有更高层次的理解, 高到你想放弃.

我都在思考可以在面试校招同学的时候, 拿 tapable 源码出来让他们读, 我就想知道年轻人的逻辑思考和耐心是否能经受住灵魂拷问

我不知道 Webpack 编译慢是不是跟 tapable 这种动态生成钩子的包裹代码有关系, 毕竟这么多 hook, 一堆的插件, 插件里一堆的逻辑, 一次编译怎么也得 new Function 个几百次? 再让你们感受下 tapable 的代码 "魅力"

				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";
				}

我不知道作者怎么修 bug, 反正让我修, 我肯定边骂边修, 这和祖传老代码有啥区别.

tapable 的总体设计还是值得赞扬的

吐槽完了实现, 撇开一言难尽的详细设计, 我想说 tapable 的总体设计还是值得夸夸的, 相比 发布/订阅 模式, tapable 的这种管道模式显然要可控的多, 插槽的完全独立, 也能提供更大的稳定性, 并且不依赖事件名称这种魔术字符串也避免了一些拼写意外, 如果你需要有一个代替 发布/订阅 的管道处理机制, 可以考虑参考 tapable 的总体设计, 为此我做了个总结

  • Hook 类负责管理 taps 和 interceptors 两个管道
  • HookCodeFactory 类负责处理将逻辑包裹成管道可执行的代码
  • 各种子类分别继承以上两个父类, 编写一些定制化的逻辑

但是我觉得真的应该重构下代码...无论从性能还是可读性来讲, 毕竟作为 Webpack 的核心库, tapable 怎么也算是个门面了, 并且动态包裹代码的方式对性能的影响也值得商榷, 管道模式可能有优雅更高性能的详细设计和实现, 无论是从数据结构还是算法上都有重构的空间.

后话

如果你是个精力旺盛的摸鱼闲人, 我真的建议你试着去重构下 tapable, 说不定提个 PR, Webpack 通过之后, 你就可以在自己的签名下这么写了

我是那个重写了 Webpack 心脏的男人