深入Webpack: Tapable
为什么 CleanWebpackPlugin 没有把 HtmlWebpackPlugin 生成的html给清掉
Tapable 是什么
Tapable 的官方描述为:
Just a little module for plugins.
Tapable 是一个发布订阅的事件系统,相对于node原生events,Tapable更关注于发布订阅中,订阅者的流程处理。
Tapable 是webpack中插件能运行的基石,是webpack与开发者交流的话筒,增强了webpack基础功能。
来看一下webpack官方示例
const pluginName = 'ConsoleLogOnBuildWebpackPlugin';
class ConsoleLogOnBuildWebpackPlugin {
apply(compiler) {
compiler.hooks.run.tap(pluginName, (compilation) => {
console.log('webpack 构建正在启动!');
});
}
}
module.exports = ConsoleLogOnBuildWebpackPlugin;
上面的示例代码中,我们订阅了compiler对象的run节点,在webpack流程运行到此处时,console.log会被打印出来。
Tapable 通过tap订阅,通过call来发布,对应于原生events中的on 和 emit。
Tapable的种类
Tapable钩子可以按照不同的方式分类,这也是Tapable与众不同的地方。
const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
AsyncParallelHook,
AsyncParallelBailHook,
AsyncSeriesHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook,
AsyncSeriesLoopHook,
} = require("tapable");
从Tapable的钩子的调用时机上,可以分为了两个大类,同步和异步钩子。 在异步钩子中,又分为串行和并行。
从Tapable的钩子的功能上,可以分为四种钩子类型,普通型,中断型,流水型和循环型。
而按照这些分类,在钩子的注册和调用上也有所不同。
钩子的订阅分为 tap、tapAsync、tapPromise。
钩子的发布分为call、callAsync、promise。
同步和异步钩子
同步钩子
SyncHook是一个普通的同步钩子,订阅后,在发布之后就会按顺序执行。
例子一
const sh = new SyncHook(["name"]);
sh.tap("one", name => {
console.log(name);
});
sh.tap("second", name => {
console.log(name);
});
sh.call("tapable");
// tapable
// tapable
同步的钩子只能使用tap订阅,如果使用tapAsync、tapPromise订阅,会报错, 钩子在继承的时候就重写了
const TAP_ASYNC = () => {
throw new Error("tapAsync is not supported on a SyncHook");
};
const TAP_PROMISE = () => {
throw new Error("tapPromise is not supported on a SyncHook");
};
function SyncHook(args = [], name = undefined) {
const hook = new Hook(args, name);
hook.constructor = SyncHook;
hook.tapAsync = TAP_ASYNC;
hook.tapPromise = TAP_PROMISE;
return hook;
}
同步的订阅可以使用异步的发布,比如callAsync和promise,和同步发布相比,异步发布是当订阅者全部执行完毕,发布者会得到通知。
sh.callAsync('tapable', () => {
console.log('all done')
})
// or
sh.promise().then(() => {
console.log('all done')
})
node原生的事件系统的需要自己扩展一些代码才能实现。
异步钩子
异步钩子分为两种, 串行和并行。
例子二, 异步并行
const sh = new AsyncParallelHook(["name"]);
console.time("AsyncParallelHook");
sh.tapAsync("one", (name, cb) => {
console.log("one start");
setTimeout(() => {
console.log("one done ", name);
cb(null);
}, 4000);
});
sh.tapPromise("two", name => {
return new Promise((resolve, reject) => {
console.log("two start");
setTimeout(() => {
console.log("two done ", name);
resolve();
}, 1000);
});
});
sh.promise("tapable").then(() => {
console.log("all done");
console.timeEnd("AsyncParallelHook");
});
//one start
//two start
//two done tapable
//one done tapable
//all done
//AsyncParallelHook: 4.015s
并行钩子依次订阅,当发布的时候,会同时进行,当有一个报错或者全部执行完毕后,会执行统一的回调。需要注意的是,这里cb函数的第一个参数是error,传入非真值将会直接终止,执行统一的回调,但不会影响其余钩子的执行,因为异步钩子一旦执行,就无法终止。
例子三, 异步串行
const sh = new AsyncSeriesHook(["name"]);
console.time("AsyncSeriesHook");
sh.tapAsync("one", (name, cb) => {
console.log("one start");
setTimeout(() => {
console.log("one done ", name);
cb(null);
}, 4000);
});
sh.tapPromise("two", name => {
return new Promise((resolve, reject) => {
console.log("two start");
setTimeout(() => {
console.log("two done ", name);
resolve();
}, 1000);
});
});
sh.callAsync("tapable", error => {
if (error) console.log(error);
console.log("all done");
console.timeEnd("AsyncSeriesHook");
});
//one start
//one done tapable
//two start
//two done tapable
//all done
//AsyncParallelHook: 5.013s
串行钩子和并行钩子差不多,区别在于,串行钩子会按照订阅顺序(如果不指定顺序)执行,下一个钩子需要等待前一个钩子执行完毕才会开始执行。
普通型,中断型,流水型和循环型
Tapable相比于其他的事件系统,最强大的地方是它各种的功能型钩子,功能型钩子各分为同步和异步方式,上面举的都是普通型钩子,接下来介绍下其余三种钩子。
中断型钩子
const sh = new SyncBailHook(["name"]);
sh.tap("one", (name) => {
console.log("one");
});
sh.tap("two", name => {
console.log("two");
return null;
});
sh.tap("three", name => {
console.log("three");
});
sh.callAsync("tapable", error => {
if (error) console.log(error);
console.log("all done");
});
//one
//two
//all done
在顺序执行的订阅者函数中,如果有一个订阅者函数返回了非undefined值, 则会中断后面的订阅者函数的执行, 直接到达call的回调函数。
上述是同步的例子,对于异步的中断钩子,同样会直接到达call的回调函数, 但无法中断后面订阅者的执行。
流水型钩子
const sh = new SyncWaterfallHook(["type"]);
sh.tap("createBody", type => {
return type === "add" ? `a + b` : "a - b";
});
sh.tap("createReturn", str => {
return `return ${str}`;
});
sh.tap("exFn", str => {
return new Function("a", "b", str);
});
sh.callAsync("add", (error, fn) => {
if (error) console.log(error);
const res = fn(3, 6);
console.log(res);
});
// 9
流水型钩子的订阅者执行的时候,依赖上一个订阅者的返回值, 如果上一个订阅者没有返回值的话,那它会一直往上寻找,直到找到了那个未返回undefined的返回值,上述代码通过参数来确认构造一个求和函数或者求差函数。
循环型钩子
const sh = new SyncLoopHook(["type"]);
let count = 0;
function random() {
return Math.floor(Math.random() * 10) + "";
}
sh.tap("one", res => {
count++;
console.log("I am trying one");
if (res[0] !== random()) {
return true;
}
});
sh.tap("two", res => {
count++;
console.log("I am trying two");
if (res[1] !== random()) {
return true;
}
});
sh.tap("three", res => {
count++;
console.log("I am trying three");
if (res[2] !== random()) {
return true;
}
});
sh.callAsync("777", error => {
if (error) console.log(error);
console.log(count);
});
循环型钩子是指任何一个订阅事件在未返回undefined的情况下,总会从订阅顺序上重新执行,直到所有的订阅事件都返回undefined。 上面函数计算了随机中到777的需要执行的次数。
思考与实现
基础型
实现一个简单的发布订阅很简单,下面是一个基础的发布订阅:
class Hook {
constructor() {
this.taps = [];
}
tap(fn) {
this.taps.push(fn);
}
call(done) {
for (let i = 0; i < this.taps.length; i++) {
const fn = this.taps[i];
fn();
}
done();
}
}
const hook = new Hook();
hook.tap(() => {
console.log("one");
});
hook.tap(() => {
console.log("two");
});
hook.call(() => {
console.log("done");
});
// one
// two
// done
思考下, 其他类型怎么实现呢
中断型
实现一个中断型的发布订阅:
...
callBail(done) {
for (let i = 0; i < this.taps.length; i++) {
const fn = this.taps[i];
const res = fn();
if (res !== undefined) {
done();
return;
}
}
done();
}
...
流水型
实现一个流水型发布订阅
callWaterfall(done) {
let res = "init value";
for (let i = 0; i < this.taps.length; i++) {
const fn = this.taps[i];
const r = fn(res);
if (r !== undefined) {
res = r;
}
}
done(res);
}
循环型
实现一个循环型发布订阅:
callLoop(done) {
for (let i = 0; i < this.taps.length; ) {
const fn = this.taps[i];
const res = fn();
i = res !== undefined ? 0 : i + 1;
}
done();
}
源码分析
可以发现, 上述的同步形式的功能型钩子实现起来看起来不难,不同的钩子不同的地方主要在于call函数。因此实现call函数是要解决的第一个问题。
另外,除了call函数外,各个Hooks的其余的代码都是相同的, 如果不同的Hooks都要实现这些逻辑的话,会显得非常的冗余, 因此tapable 声明了一个基类Hook,来处理一些通用逻辑。
Hook的实现
Hook定义了几个属性和方法, 这些属性和方法都是所有Hooks共用的:
属性:
-
_args:代表 订阅者的入参。
-
taps / _x: 订阅者列表。
-
...
方法:
-
_tap (tap、tapAsync、tapPromise): 订阅者注册方法, 通过调用_insert 将订阅者一个个加入taps
-
_insert: 添加订阅者,会按照订阅者的参数来决定调用的顺序。
-
_createCall: 创建不同的call函数,不同的类型钩子主要是call函数不同,_createCall函数通过complie函数来生成call函数。
-
compile: 是一个方法,但基类没有实现,会交给子类实现。如果子类没有实现,则会报错。
基类Hook定义了通用的方法和属性,各个Hooks通过继承它来复用它的功能。
function SyncHook(args = [], name = undefined) {
const hook = new Hook(args, name);
hook.constructor = SyncHook;
hook.tapAsync = TAP_ASYNC;
hook.tapPromise = TAP_PROMISE;
hook.compile = COMPILE;
return hook;
}
SyncHook.prototype = null;
module.exports = SyncHook;
HookCodeFactory的实现
从上面的Hook的可以知道,不同的Hooks功能主要通过子类重写compile方法实现,HookCodeFactory 主要来构造不同的call方法。
即使我们发现了各种钩子的不同主要在于call函数的不同,并且我们也把它的实现交给了各个钩子,但是我们会发现,即使是不同的call函数的实现, 也是有大部分重复代码的,比如for循环,每个同步的call函数都是有的,另外,call函数对于错误的处理、回调函数的处理,回调结果的处理,都是大同小异的,还是避免不了冗余。
另外一点,这么多钩子需要考虑很多种情况,比如我们在思考与实现还未考虑异步的情况,常规的编程实现起来难度很大。
因此tapable采用了用字符串拼接的方法来实现各种call函数。
转载自:https://juejin.cn/post/7018860885393276936