编写自定义webpack插件从理解Tapable开始
本文首先分析Tapable的基本原理,在此基础上编写一个自定义插件。
Tapable
如果你阅读了 webpack 的源码,一定不会对 tapable 不陌生。毫不夸张的说, tapable是webpack控制事件流的超级管家。
Tapable的核心功能就是依据不同的钩子将注册的事件在被触发时按序执行。它是典型的”发布订阅模式“。Tapable提供了两大类共九种钩子类型,详细类型如下思维导图:
除了Sync
和Async
分类外,你应该也注意到了Bail
、Waterfall
、Loop
等关键词,它们指定了注册的事件回调handler
触发的顺序。
Basic hook
:按照事件注册顺序,依次执行handler
,handler
之间互不干扰;Bail hook
:按照事件注册顺序,依次执行handler
,若其中任一handler
返回值不为undefined
,则剩余的handler
均不会执行;Waterfall hook
:按照事件注册顺序,依次执行handler
,前一个handler
的返回值将作为下一个handler
的入参;Loop hook
:按照事件注册顺序,依次执行handler
,若任一handler
的返回值不为undefined
,则该事件链再次从头开始执行,直到所有handler
均返回undefined
基本用法
我们以SyncHook
为例:
const {
SyncHook
} = require("../lib/index");
let sh = new SyncHook(["name"])
sh.tap('A', (name) => {
console.log('A:', name)
})
sh.tap({
name: 'B',
before: 'A' // 影响该回调的执行顺序, 回调B比回调A先执行
}, (name) => {
console.log('B:', name)
})
sh.call('Tapable')
// output:
B:Tapable
A:Tapable
这里我们定义了一个同步钩子sh
,注意到它的构造函数接收一个数组类型入参["name"]
,代表了它的注册事件将接收到的参数列表,以此来告知调用方在编写回调handler
时将会接收到哪些参数。示例中,每个事件回调都会接收name
的参数。
通过钩子的tap
方法可以注册回调handler
,调用call
方法来触发钩子,依次执行注册的回调函数。
在注册回调B
时,传入了before
参数,before: 'A'
,它直接影响了该回调的执行顺序,即回调B会在回调A之前触发。此外,你也可以指定回调的stage
来给回调排序。
源码解读
Hook基类
从上面的例子中,我们看到钩子上有两个对外的接口:tap
和 call
,tap
负责注册事件回调,call
负责触发事件。
虽然Tapable提供多个类型的钩子,但所有钩子都是继承于一个基类Hook
,且它们的初始化过程都是相似的。这里我们仍以SyncHook
为例:
// 工厂类的作用是生成不同的compile方法,compile本质根据事件注册顺序返回控制流代码的字符串。最后由`new Function`生成真实函数赋值到各个钩子对象上。
class SyncHookCodeFactory extends HookCodeFactory {
content({ onError, onDone, rethrowIfPossible }) {
return this.callTapsSeries({
onError: (i, err) => onError(err),
onDone,
rethrowIfPossible
});
}
}
const factory = new SyncHookCodeFactory();
// 覆盖Hook基类中的tapAsync方法,因为`Sync`同步钩子禁止以tapAsync的方式调用
const TAP_ASYNC = () => {
throw new Error("tapAsync is not supported on a SyncHook");
};
// 覆盖Hook基类中的tapPromise方法,因为`Sync`同步钩子禁止以tapPromise的方式调用
const TAP_PROMISE = () => {
throw new Error("tapPromise is not supported on a SyncHook");
};
// compile是每个类型hook都需要实现的,需要调用各自的工厂函数来生成钩子的call方法。
const COMPILE = function(options) {
factory.setup(this, options);
return factory.create(options);
};
function SyncHook(args = [], name = undefined) {
const hook = new Hook(args, name); // 实例化父类Hook,并修饰hook
hook.constructor = SyncHook;
hook.tapAsync = TAP_ASYNC;
hook.tapPromise = TAP_PROMISE;
hook.compile = COMPILE;
return hook;
}
tap方法
当执行tap
方法注册回调时,又如何执行的呢?
在Hook
基类中,关于tap
的代码如下:
class Hook{
constructor(args = [], name = undefined){
this.taps = []
}
tap(options, fn) {
this._tap("sync", options, fn);
}
_tap(type, options, fn) {
// 这里省略入参预处理部分代码
this._insert(options);
}
}
我们看到最终会执行到this._insert
方法中,而this._insert
的工作就是将回调fn
插入到内部的taps
数组中,并依据before
或stage
参数来调整taps
数组的排序。具体代码如下:
_insert(item) {
// 每次注册事件时,将call重置,需要重新编译生成call方法
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循环体中,依据before和stage调整回调顺序
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
,tapAsync
或者tapPromise
,都会将回调handler
暂存至taps
数组中,清空之前已经生成的call
方法(this.call = this._call
)。
call方法
注册好事件回调后,接下来该如何触发事件了。同样的,call
也存在三种调用方式:call
,callAsync
,promise
,分别对应三种tap
注册方式。触发同步Sync
钩子事件时直接使用call
方法,触发异步Async
钩子事件时需要使用callAsync
或promise
方法,继续看看在Hook
基类中call
是如何定义的:
const CALL_DELEGATE = function(...args) {
// 在第一次执行call时,会依据钩子类型和回调数组生成真实执行的函数fn。并重新赋值给this.call
// 在第二次执行call时,直接运行fn,不再重复调用_createCall
this.call = this._createCall("sync");
return this.call(...args);
};
class Hoook {
constructor(args = [], name = undefined){
this.call = CALL_DELEGATE
this._call = CALL_DELEGATE
}
compile(options) {
throw new Error("Abstract: should be overridden");
}
_createCall(type) {
// 进入该函数体意味是第一次执行call或call被重置,此时需要调用compile去生成call方法
return this.compile({
taps: this.taps,
interceptors: this.interceptors,
args: this._args,
type: type
});
}
}
_createCall
会调用this.compile
方法来编译生成真实调用的call
方法,但在Hook
基类中compile
是空实现。它要求继承Hook
父类的子类必须实现这个方法(即抽象方法)。回到SyncHook
中查看compiler
的实现:
const HookCodeFactory = require("./HookCodeFactory");
class SyncHookCodeFactory extends HookCodeFactory {
content({ onError, onDone, rethrowIfPossible }) {
return this.callTapsSeries({
onError: (i, err) => onError(err),
onDone,
rethrowIfPossible
});
}
}
const factory = new SyncHookCodeFactory();
const COMPILE = function(options) {
// 调用工厂类中的setup和create方法拼接字符串,之后实例化 new Function 得到函数fn
factory.setup(this, options);
return factory.create(options);
};
function SyncHook(args = [], name = undefined) {
const hook = new Hook(args, name);
hook.compile = COMPILE;
return hook;
}
在SyncHook
类中compile
会调用工厂类HookCodeFactory
的create
方法,这里对create
的内部暂时不表,factory.create
返回编译好的function
,最终赋值给this.call
方法。
这里Hook
使用了一个技巧——惰性函数,当第一次指定this.call
方法时,此时会运行到CALL_DELEGATE
函数体中,CALL_DELEGATE
会重新赋值this.call
,这样在下一次执行时,直接执行赋值后的this.call
方法,而不用再次进行生成call
的过程,从而优化了性能。
惰性函数有两个主要优点:
- 效率高:惰性函数仅在第一次运行时执行计算逻辑,之后函数再次运行时都返回第一次执行的结果,节约了很多执行时间;
- 延迟执行:在某些场景下,需要判断一些环境信息,一旦确定后就不再需要重新判断。可以理解为
嗅探程序
。比如可以用下面的方式使用惰性载入重写addEvent
:
function addEvent(type, element, fun) {
if (element.addEventListener) {
addEvent = function(type, element, fun) {
element.addEventListener(type, fun, false);
};
} else if (element.attachEvent) {
addEvent = function(type, element, fun) {
element.attachEvent("on" + type, fun);
};
} else {
addEvent = function(type, element, fun) {
element["on" + type] = fun;
};
}
return addEvent(type, element, fun);
}
HookCodeFactory工厂类
在上节提到,factory.create
返回编译好的function
赋值给call
方法。
每个类型的钩子都会构造一个工厂类负责拼接调度回调handler
时序的函数字符串,通过new Function()
的实例化方式来生成执行函数。
延伸:new Function
在 JavaScript 中有三种函数定义的方式:
// 定义1. 函数声明
function add(a, b){
return a + b
}
// 定义2. 函数表达式
const add = function(a, b){
return a + b
}
// 定义3. new Function
const add = new Function('a', 'b', 'return a + b')
前两种函数定义方式是”静态“的,之所谓是”静态“的是函数定义之时,它的功能就确定下来了。而第三种函数定义方式则是”动态“,所谓”动态“是函数功能可以在程序运行过程中变化。
定义1 与 定义2也是有区别的哦,最关键的区别在于 JavaScript 函数和变量声明的“提前”(hoist)行为。这里就不做展开了。
比如,我需要动态构造一个 n 个数相加的函数:
let nums = [1,2,3,4]
let len = nums.length
let params = Array(len).fill('x').map((item, idx)=>{
return '' + item + idx
})
const add = new Function(params.join(','), `
return ${params.join('+')};
`)
console.log(add.toString())
console.log(add.apply(null, nums))
打印函数字符串add.toString()
,可以得到:
function anonymous(x0,x1,x2,x3) {
return x0+x1+x2+x3;
}
函数add
的函数入参和函数体会根据nums
的长度而动态生成,这样你可以根据实际情况来控制传入参数的个数,并且函数也只处理这几个入参。
new Function
的函数声明方式较前两者首先性能上会有点吃亏,每次实例化都会消耗性能。其次,new Function
声明的函数不支持”闭包“,对比如下代码:
function bar(){
let name = 'bar'
let func = function(){return name}
return func
}
bar()() // "bar", func中name读取到bar词法作用域中的name变量
function foo(){
let name = 'foo'
let func = new Function('return name')
return func
}
foo()() // ReferenceError: name is not defined
究其原因是因为new Function
的词法作用域指向的是全局作用域。
factory.create
的主要逻辑是根据钩子类型type
,拼接回调时序控制字符串,如下:
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
})
);
我们以SyncHook
为例:
let sh = new SyncHook(["name"]);
sh.tap("A", (name) => {
console.log("A");
});
sh.tap('B', (name) => {
console.log("B");
});
sh.tap("C", (name) => {
console.log("C");
});
sh.call();
可以得到如下的函数字符串:
function anonymous(name) {
"use strict";
var _context;
var _x = this._x;
var _fn0 = _x[0];
_fn0(name);
var _fn1 = _x[1];
_fn1(name);
var _fn2 = _x[2];
_fn2(name);
}
其中_x
则指向this.taps
数组,按序访问到每个handler
,并执行handler
。
更多Hook示例,可以查看RunKit
Tapable为何要如此“费劲”的动态生成函数体呢?因为它是“执行效率最优化”的拥趸,尽可能的不产生新的调用堆栈的函数才是执行效率最优的。
自定义 webpack plugin
一个插件的自我修养
一个合乎规范的插件应满足以下条件:
- 它是一个具名的函数或者JS类;
- 在原型链上指定
apply
方法; - 指定一个明确的事件钩子并注册回调;
- 处理 webpack 内部实例的特定数据(
Compiler
或Compilation
); - 完成功能后调用webpack传入的回调等;
其中条件4、5
并不是必需的,只有功能复杂的插件会同时满足以上五个条件。
自动上传资源的插件
使用webpack打包资源后都会在本地项目中生成一个dist
文件夹用于存放打包后的静态资源,此时可以写一个自动上传资源文件到CDN的webpack插件,每次打包成功后及时的上传至CDN。
当你明确插件的功能时,你需要在合适的钩子上去注册你的回调。在本例中,我们需要将已经打包输出后的静态文件上传至CDN,通过在compiler钩子列表
中查询知道compiler.hooks.afterEmit
是符合要求的钩子,它是一个AsyncSeriesHook
类型。
按照五个基本条件来实现这个插件:
const assert = require("assert");
const fs = require("fs");
const glob = require("util").promisify(require("glob"));
// 1. 它是一个具名的函数或者JS类
class AssetUploadPlugin {
constructor(options) {
// 这里可以校验传入的参数是否合法等初始化操作
assert(
options,
"check options ..."
);
}
// 2. 在原型链上指定`apply`方法
// apply方法接收 webpack compiler 对象入参
apply(compiler) {
// 3. 指定一个明确的事件钩子并注册回调
compiler.hooks.afterEmit.tapAsync( // 因为afterEmit是AsyncSeriesHook类型的钩子,需要使用tapAsync或tapPromise钩入回调
"AssetUploadPlugin",
(compilation, callback) => {
const {
outputOptions: { path: outputPath }
} = compilation; // 4. 处理 webpack 内部实例的特定数据
uploadDir(
outputPath,
this.options.ignore ? { ignore: this.options.ignore } : null
)
.then(() => {
callback(); // 5. 完成功能后调用webpack传入的回调等;
})
.catch(err => {
callback(err);
});
});
}
};
// uploadDir就是这个插件的功能性描述
function uploadDir(dir, options) {
if (!dir) {
throw new Error("dir is required for uploadDir");
}
if (!fs.existsSync(dir)) {
throw new Error(`dir ${dir} is not exist`);
}
return fs
.statAsync(dir)
.then(stat => {
if (!stat.isDirectory()) {
throw new Error(`dir ${dir} is not directory`);
}
})
.then(() => {
return glob(
"**/*",
Object.assign(
{
cwd: dir,
dot: false,
nodir: true
},
options
)
);
})
.then(files => {
if (!files || !files.length) {
return "未找到需要上传的文件";
}
// TODO: 这里将资源上传至你的静态云服务器中,如京东云、腾讯云等
// ...
});
}
module.exports = AssetUploadPlugin
在webpack.config.js
中可以引入这个插件并实例化:
const AssetUploadPlugin = require('./AssetUploadPlugin')
const config = {
//...
plugins: [
new AssetUploadPlugin({
ignore: []
})
]
}
总结
webpack的灵活配置得益于 Tapable
提供强大的钩子体系,让编译的每个过程都可以“钩入”,如虎添翼。正所谓“三人成众”,将一个系统做到插件化时,它的可扩展性将大大提高。
Tapable
也可以应用到具体的业务场景中,比如流程监控
、日志记录
、埋点上报
等,凡是需要“钩入”到具体流程中时,Tapable
就有它的应用场景。
最后
码字不易,如果:
- 这篇文章对你有用,请不要吝啬你的小手为我点赞;
- 有不懂或者不正确的地方,请评论,我会积极回复或勘误;
- 期望与我一同持续学习前端技术知识,请关注我吧;
- 转载请注明出处;
您的支持与关注,是我持续创作的最大动力!
参考
转载自:https://juejin.cn/post/6844903993530023950