最近在尝试学习webpack
的源码,其实很早前就知道webpack
的plugin
体系核心是tapable
,然后在webpack
的入口代码里就看到了它的身影,索性先来研读下tapable
的内部原理,这篇文章就是用来帮助大家理解它。
直接看仓库的readme
其实还是挺抽象的,容易被那些钩子概念弄晕,所以这篇文章会针对每个钩子举一些例子,并将tapable
在内部的相应运行代码贴出来,个人经验觉得这样对于理解各个钩子帮助很大。
个人在学习源码时参考了掘金上的这篇博客
概念
tapable
可以理解为一个高级的事件发布订阅系统。相信很多人或多或少知道观察者模式实现的事件监听模型,例如window.addEventListeners
或vue
里的$emit、$on
,他们的共同特点是所有的事件回调之间完全独立,针对一个特定event
的回调函数列表在调用时是顺序同步执行的。
tapable
打破了这一限制,扩展了事件订阅和发布的各种执行时机,包括同步顺序执行、异步顺序执行、异步并行执行等等,同时回调函数之间也可以进行一定程度关联,例如BailHook
可以将前一个回调函数的返回值当做后一个回调函数的入参。
tapable
包含的钩子类型有:
SyncHook
SyncBailHook
SyncWaterfallHook
SyncLoopHook
AsyncParallelHook
AsyncParallelBailHook
AsyncSeriesHook
AsyncSeriesBailHook
AsyncSeriesWaterfallHook
上述以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
只会调用一次call
tap: (tap: Tap) => void
在HookCodeFactory.prototype.callTap
中生成,针对每个回调均会调用一次。loop: (...args) => void
用于LoopHook
register: (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
作为示范,不过其他钩子也是遵照类似的逻辑,大家可以像上面那样将最终生成的匿名函数打印出来,这样就对每种钩子的运行逻辑非常清楚了。我会在下一篇文章挨个讲解每种钩子,会直接对照例子和生成的代码来帮助大家理解。