研究tapable源码的理由
- 大佬写的代码,当然值得一看了。
- tapable源码的代码量够少,可以让我们花少量时间就能研究的明白,还能有所收获。
上一篇文章《Webpack tapable 使用研究》研究了tapable的用法,了解用法有助于我们理解源码。感兴趣可以看看。
查看SyncHook.js文件
看源码,第一感觉肯定是充满疑惑的。
先从用法最简单的SyncHook来看吧。我想象的SyncHook大致是这样:
export default class SyncHook {
constructor() {
this.taps = [];
}
tap(name, fn) {
this.taps.push({
name,
fn,
});
}
call() {
this.taps.forEach(tap => tap.fn());
}
}
有个tap方法,有个call方法,有个变量存储注册的插件,可是实际上不是:
const Hook = require("./Hook");
const HookCodeFactory = require("./HookCodeFactory");
class SyncHookCodeFactory extends HookCodeFactory {
...
}
const factory = new SyncHookCodeFactory();
class SyncHook extends Hook {
tapAsync() {
throw new Error("tapAsync is not supported on a SyncHook");
}
tapPromise() {
throw new Error("tapPromise is not supported on a SyncHook");
}
compile(options) {
factory.setup(this, options);
return factory.create(options);
}
}
module.exports = SyncHook;
没有tap也没有call,反而有tapAsync和tapPromise。还有个不知干啥的compile方法,里面还用了工厂。SyncHook继承自Hook。
分析:tap和call方法肯定是要有的,不在这里,那就在它的基类Hook里。这里使用到了继承和工厂模式,我们可以通过源码学习它们的实践了。
查看SyncBailHook.js、SyncLoopHook.js、SyncWaterfallHook.js文件
我们不急着看Hook.js,既然它用到继承,就是将公共的、可复用的逻辑抽象到父类中了。如果直接看父类,我们可能不容易发现作者抽象的思路,为什么要将这些点抽象到父类中。
我们先看看这些继承了Hook的子类,看看它们有那些公共的地方,再去看父类Hook.js。
// SyncBailHook.js
class SyncBailHookCodeFactory extends HookCodeFactory {
...
}
const factory = new SyncBailHookCodeFactory();
class SyncBailHook extends Hook {
tapAsync() {
throw new Error("tapAsync is not supported on a SyncBailHook");
}
tapPromise() {
throw new Error("tapPromise is not supported on a SyncBailHook");
}
compile(options) {
factory.setup(this, options);
return factory.create(options);
}
}
module.exports = SyncBailHook;
SyncBailHook与SyncHook的区别就是换了个工厂给compile方法。其他没有什么不同。SyncLoopHook.js、SyncWaterfallHook.js全都类似,只是使用的工厂不同。
分析:还是分析不出什么,同步的钩子看完了,接着在看异步钩子类。
查看AsyncParallelHook.js文件
const Hook = require("./Hook");
const HookCodeFactory = require("./HookCodeFactory");
class AsyncParallelHookCodeFactory extends HookCodeFactory {
...
}
const factory = new AsyncParallelHookCodeFactory();
class AsyncParallelHook extends Hook {
compile(options) {
factory.setup(this, options);
return factory.create(options);
}
}
Object.defineProperties(AsyncParallelHook.prototype, {
_call: { value: undefined, configurable: true, writable: true }
});
module.exports = AsyncParallelHook;
连tapAsync和tapPromise的异常抛出都没有了,只剩compile方法了。下面还用Object.defineProperties给还AsyncParallelHook定义了一个_call方法。其他的异步钩子类,也跟AsyncParallelHook文件很类似,就是compile中使用的工厂不同。将_call的value定义为null。
分析:这里用Object.defineProperties定义类方法是个疑惑点,为什么不直接写在类中,而是用这种方式呢?
再就是说明各个Hook之间的主要区别,在于compile方法,compile方法里使用的不同工厂类,也是主要的区别点。其他所有逻辑,都抽象到Hook.js里了。
我们现在的疑惑,compile方法到底是干啥的?
查看Hook.js
带着疑惑,我们来看tapable有着最核心的逻辑的Hook.js文件,先省略一些部分,先看关键的api:
class Hook {
constructor(args) {
if (!Array.isArray(args)) args = [];
this._args = args;
this.taps = [];
this.interceptors = [];
this.call = this._call;
this.promise = this._promise;
this.callAsync = this._callAsync;
}
compile(options) {
throw new Error("Abstract: should be overriden");
}
tap(options, fn) {
...
}
tapAsync(options, fn) {
...
}
tapPromise(options, fn) {
...
}
intercept(interceptor) {
...
}
}
Object.defineProperties(Hook.prototype, {
_call: {
value: createCompileDelegate("call", "sync"),
configurable: true,
writable: true
},
_promise: {
value: createCompileDelegate("promise", "promise"),
configurable: true,
writable: true
},
_callAsync: {
value: createCompileDelegate("callAsync", "async"),
configurable: true,
writable: true
}
});
module.exports = Hook;
先看构造函数,接收args的数组,作为插件的参数标识。taps变量存储插件,interceptors变量存储拦截器。
再看方法,compile方法在这,标识是个抽象方法,由子类重写,也符合我们查看子类的预期。
tap、tapAsync、tapPromise、intercept在子类中都会被继承下来,但是在同步的钩子中,tapAsync、tapPromise被抛了异常了,不能用,也符合使用时的预期。
这里比较疑惑的是call、promise、callAsync这三个调用方法,为啥不像tap这样写在类里,而是写在构造函数的变量里,而且下面Object.defineProperties定义了三个_call、_promise、_callAsync三个私有方法,它们和call、promise、callAsync是什么关系?
我们接着深入的看。
注册过程:tap、tapAsync、tapPromise方法
既然调用方法call、promise、callAsync的实现比较复杂,我们就先看tap、tapAsync、tapPromise这些注册方法,实现比较简单:
tap(options, fn) {
if (typeof options === "string") options = { name: options };
if (typeof options !== "object" || options === null)
throw new Error(
"Invalid arguments to tap(options: Object, fn: function)"
);
options = Object.assign({ type: "sync", fn: fn }, options);
if (typeof options.name !== "string" || options.name === "")
throw new Error("Missing name for tap");
options = this._runRegisterInterceptors(options);
this._insert(options);
}
tapAsync(options, fn) {
if (typeof options === "string") options = { name: options };
if (typeof options !== "object" || options === null)
throw new Error(
"Invalid arguments to tapAsync(options: Object, fn: function)"
);
options = Object.assign({ type: "async", fn: fn }, options);
if (typeof options.name !== "string" || options.name === "")
throw new Error("Missing name for tapAsync");
options = this._runRegisterInterceptors(options);
this._insert(options);
}
tapPromise(options, fn) {
if (typeof options === "string") options = { name: options };
if (typeof options !== "object" || options === null)
throw new Error(
"Invalid arguments to tapPromise(options: Object, fn: function)"
);
options = Object.assign({ type: "promise", fn: fn }, options);
if (typeof options.name !== "string" || options.name === "")
throw new Error("Missing name for tapPromise");
options = this._runRegisterInterceptors(options);
this._insert(options);
}
它们三个的实现非常类似。核心功能是拼起一个options对象,options的内容如下:
options:{
name, // 插件名称
type: "sync" | "async" | "promise", // 插件注册的类型
fn, // 插件的回调函数,被call时的响应函数
stage, // 插件调用的顺序值
before,// 插件在哪个插件之前调用
}
拼好了options,就利用_insert方法将其放到taps变量里,以供后续调用。_insert方法内部就是实现了根据stage和before两个值,对options的插入到taps中的顺序做了调整并插入。
intercept方法
intercept方法将拦截器的相应回调放到interceptors里,以供对应的时机调用。
调用过程: call方法、callAsync方法、promise方法
注册过程机会没什么区别,区别在于调用过程,最终影响插件的执行顺序和逻辑。
首先先解决为什么_call方法要写成Object.defineProperties中定义,而不是类中定义,这样的好处是,方便我们为_call方法赋值为另一个函数,代码中将_call的value赋值成了createCompileDelegate方法的返回值,而如果将_call直接声明到类中,不好做到。再就是可以直接在子类(如AsyncParallelHook)中,再利用Object.defineProperties将_call的vale赋值为null。就可以得到一个没有_call方法的子类了。
再看一个私有方法:
_resetCompilation() {
this.call = this._call;
this.callAsync = this._callAsync;
this.promise = this._promise;
}
此方法在_insert和intercept中调用,也就是在每次的注册新插件或注册新的拦截器,会触发一次私有调用方法到call等变量的一次赋值。
为什么每次都要重新赋值呢?每次的_call方法不一样了吗?我先给出答案,确实,每次赋值都是一个全新的new出来的_call方法。因为注册新插件或注册新的拦截器会形成一个新的_call方法,所以每次都要重新赋值一次。
那为什么要每次生成一个新的_call方法呢?直接写死不好吗,不就是调用taps变量里的插件和拦截器吗?
原因是因为我们的插件彼此有着联系,所以我们用了这么多类型的钩子来控制这些联系,每次注册了新的插件或拦截器,我们就要重新排布插件和拦截器的调用顺序,所以每次都要生成新的_call方法。接下来我们来看代码:
function createCompileDelegate(name, type) {
return function lazyCompileHook(...args) {
this[name] = this._createCall(type);
return this[name](...args);
};
}
生成_call方法的是createCompileDelegate方法,这里用到了闭包,存储了name和type。然后返回了一个lazyCompileHook方法给_call变量。当_call方法被调用时,_createCall方法也立即被调用。
_createCall(type) {
return this.compile({
taps: this.taps,
interceptors: this.interceptors,
args: this._args,
type: type
});
}
这里调用了compile方法,也就是说我们的调用方法(call方法、callAsync方法、promise方法)和compile是息息相关的。看SyncHook中的compile
class SyncHookCodeFactory extends HookCodeFactory {
...
}
const factory = new SyncHookCodeFactory();
export default class SyncHook {
...
compile(options) {
factory.setup(this, options);
return factory.create(options);
}
}
compile关联了HookCodeFactory,我们来看HookCodeFactory的setup和create方法都干了什么:
setup(instance, options) {
instance._x = options.taps.map(t => t.fn);
}
setup就是将插件的回调函数,都存在钩子实例的_x变量上。
create(options) {
this.init(options);
let fn;
switch (this.options.type) {
case "sync":
fn = new Function(
this.args(),
'"use strict";\n' +
this.header() +
this.content({
onError: err => `throw ${err};\n`,
onResult: result => `return ${result};\n`,
resultReturns: true,
onDone: () => "",
rethrowIfPossible: true
})
);
break;
...
}
create方法我们只关注跟Sync相关的,这里的变量fn就是最终在调用的时刻,生成了一个call方法的执行体。我们来看一下这个生成的call方法什么样:
实验代码:
import { SyncHook } from 'tapable';
const hook = new SyncHook(['options']);
hook.tap('A', function (arg) {
console.log('A', arg);
})
hook.tap('B', function () {
console.log('b')
})
hook.call(6);
console.log(hook.call);
console.log(hook);
打印结果如下:
可以看到我们的call方法中的x就是setup方法中设置的我们插件的回调函数啊,call方法生成的代码,就是根据我们使用不同的钩子,根据我们设计的逻辑,调用这些回调。
在看一下hook对象下的call和callAsync有何不同,callAsync没有被调用,所以它还是lazyCompileHook函数,也验证了我们的思考,call方法是在调用时,才被生成了上面那样的执行函数。
结束语
tapable的核心逻辑,就研究完毕了,感兴趣的小伙伴可以继续再看看。可以看到源码中对于面向对象继承的使用,工厂模式的使用,调用时才生成执行逻辑这种操作。都是值得我们学习的。