最近在尝试学习webpack的源码,其实很早前就知道webpack的plugin体系核心是tapable,然后在webpack的入口代码里就看到了它的身影,索性先来研读下tapable的内部原理,这篇文章就是用来帮助大家理解它。
直接看仓库的readme其实还是挺抽象的,容易被那些钩子概念弄晕,所以这篇文章会针对每个钩子举一些例子,并将tapable在内部的相应运行代码贴出来,个人经验觉得这样对于理解各个钩子帮助很大。
个人在学习源码时参考了掘金上的这篇博客
概念
tapable可以理解为一个高级的事件发布订阅系统。相信很多人或多或少知道观察者模式实现的事件监听模型,例如window.addEventListeners或vue里的$emit、$on,他们的共同特点是所有的事件回调之间完全独立,针对一个特定event的回调函数列表在调用时是顺序同步执行的。
tapable打破了这一限制,扩展了事件订阅和发布的各种执行时机,包括同步顺序执行、异步顺序执行、异步并行执行等等,同时回调函数之间也可以进行一定程度关联,例如BailHook可以将前一个回调函数的返回值当做后一个回调函数的入参。
tapable包含的钩子类型有:
SyncHookSyncBailHookSyncWaterfallHookSyncLoopHookAsyncParallelHookAsyncParallelBailHookAsyncSeriesHookAsyncSeriesBailHookAsyncSeriesWaterfallHook
上述以Async开头的是异步钩子,异步又分为并发执行和串行执行,Sync开头的是同步钩子,用一个图来分类:
另外先简要总结下各个钩子的用法:
| 钩子名称 | 执行方式 | 作用顺序 |
|---|---|---|
SyncHook |
同步串行 | 监听函数之间完全独立 |
SyncBailHook |
同步串行 | 上一个回调函数的返回值如果不为空,后面的回调就再也不会执行 |
SyncWaterfallHook |
同步串行 | 上一个回调函数的返回值如果不为空,就会传给下一个回调函数当做参数 |
SyncLoopHook |
同步循环 | 只要某个监听的回调返回值不为空就会一直循环执行这个回调,直到返回空才会执行下一个回调 |
AsyncParallelHook |
异步并行 | 只要前一个回调函数不抛异常,在执行完后就会顺序执行后一个回调。若抛异常,会直接执行callAsync等触发函数绑定的回调,并将异常当做参数 |
AsyncParallelBailHook |
异步并行 | 只要前一个回调的返回值不为空或者抛异常,就会直接执行callAsync等触发函数绑定的回调,后续的tap回调不会被执行 |
AsyncSeriesHook |
异步串行 | 不关心每个tap回调参数的返回值,除非抛出异常会直接调用callAsync等触发函数绑定的回调,此时后续tap回调均不会执行 |
AsyncSeriesBailHook |
异步串行 | 回调的返回值不为空,或者回调抛出异常,就会直接执行callAsync等触发函数绑定的回调函数 |
AsyncSeriesWaterfallHook |
异步串行 | 上一个监听函数的返回值, 可以作为下一个监听函数的参数。 如果监听函数报错,直接执行callAsync等触发函数绑定的回调,后续tap回调不会被执行 |
上述的异步钩子在注册监听和触发时有多种组合,这里会讲述3种:
tap<-->callAsync-->tapAsync<--->callAsync--->tapPromise<--->promise--->
主体逻辑
各个钩子最后都是生成一段匿名函数来执行的,生成这段函数的代码视钩子不同而不同,这里会以最简单的SyncHook为例,其他的钩子都类似,大家自己去看就好。
我们所用的测试代码为:
1 | const { SyncHook } = require("tapable"); |
上述代码的执行结果为:
1 | webpack 1 |
初始化
我们从入口SyncHook着手:
1 | class SyncHook extends Hook { |
看到继承了Hook父类:
1 | class Hook { |
省略了一些无关代码,可以看到new SyncHook并没有做什么事,只是一些变量的初始化。SyncHook.prototype.tap会开始注册事件监听:
注册监听
1 | tap(options, fn) { |
tap的第一个参数可以是字符串或对象,最后我们的options通常包含3个属性:
1 | options = { |
有了这3个属性,我们就知道每个事件监听的基本信息。之后的_insert会将这个事件监听插入到内部的taps数组中:
1 | _insert(item) { |
_insert会将taps数组按照stage字段升序,这样我们的注册步骤就完成了,其他的两种注册方式tapAsync、tapPromise类似就不说了。
触发回调
Hook.prototype.call用于触发回调,类似的还有callAsync、promise,这里我们只说call。
1 | function createCompileDelegate(name, type) { |
_createCall用于生成每种钩子的匿名调用函数,然后我们调用call时传入的参数会透传给这个匿名函数。
1 | _createCall(type) { |
此处compile就是各种钩子的差别了,这里我们还是以SyncHook为例:
1 | class SyncHookCodeFactory extends HookCodeFactory { |
看到内部调用了HookCodeFactory的setup和create方法,只能继续往下看了。。。
1 | class HookCodeFactory { |
最后返回的fn就是我们真正会调用的匿名函数,它是先用字符串拼接然后new Function构造而成。
匿名函数由3个部分组成,args()用于生成参数列表,header()用于生成一些初始化语句,content()是函数的主体部分。
args:
1 | // 构造生成函数的参数 |
在我们的测试代码中,最后会返回"name"这个字符串。
header:
1 | // 构造生成函数的一些初始化语句 |
嗯,args和header都很简单,随后就是最复杂的content部分了:
1 | class SyncHookCodeFactory extends HookCodeFactory { |
内部调用了HookCodeFactory.prototype.callTapsSeries:
1 | callTapsSeries({ onError, onResult, onDone, rethrowIfPossible }) { |
以上代码很容易看晕,基本上是不断的调用next函数,从next(0)开始生成执行第一个事件监听的代码,随后next(1)生成第二个。。。
最后我们生成的fn匿名函数为:
1 | function anonymous ( name) { |
也就是说我们示范代码中的queue.call("webpack")最终是执行anonymous('webpack'),_x是每个具体的监听函数数组,如第一次调用tap时传入的:
1 | function( name) { |
所以SyncHook生成的匿名函数逻辑就是同步顺序执行各个tap回调函数。
interceptor
可以监听钩子的各个生命周期,按照官网的解释,一个interceptor是一个对象,可以包含的成员有:
call: (...args) => void在事件监听函数执行前被调用,获得的args参数与监听函数相同,每个interceptor只会调用一次calltap: (tap: Tap) => void在HookCodeFactory.prototype.callTap中生成,针对每个回调均会调用一次。loop: (...args) => void用于LoopHookregister: (tap: Tap) => Tap | undefined在注册事件监听回调时被调用。
上面的tapInfo对象是Hook.tapXXX时构造的,通常包含name、type、fn 3个成员。
我们先看看添加了interceptor后的匿名函数变成什么样,需要修改测试代码
1 | queue.tap( "tap1", function( name) { |
运行结果为:
1 | ` |
可以看到先是触发了interceptor的register回调,随后执行了一次call回调,最后针对每个事件监听都执行了一次tap回调。接下来看看tapable内部分别是在什么时候处理了interceptor。
Hook.prototype.intercept 添加新的interceptor
在Hook.prototype.intercept添加每一个interceptor,在此时会立即执行一次register:
1 | /** |
Hook.prototype.tapXXX新注册的事件监听接受老的interceptor洗礼
在Hook.prototype.tapXXX中除了_insert外还执行了_runRegisterInterceptors,在这里会执行register回调:
1 | _runRegisterInterceptors(options) { |
HookCodeFactory.prototype.header执行interceptor.call
1 | // 构造生成函数的一些初始化语句 |
很容易可以看出来,每个interceptor会执行一次call钩子。
HookCodeFactory.prototype.callTap执行interceptor.tap
1 | callTap(tapIndex, { onError, onResult, onDone, rethrowIfPossible }) { |
每一个事件监听都会执行一次callTap,每一次callTap会挨个执行所有的interceptors的tap钩子。
小结
以上我们通过分析tapable的内部代码初步了解了其内部逻辑,虽然使用的是最简单的SyncHook作为示范,不过其他钩子也是遵照类似的逻辑,大家可以像上面那样将最终生成的匿名函数打印出来,这样就对每种钩子的运行逻辑非常清楚了。我会在下一篇文章挨个讲解每种钩子,会直接对照例子和生成的代码来帮助大家理解。