解密 Tapable:Webpack插件机制
在 webpack
的生态中围绕着 loader
和 plugin
两种机制展开。如果你想学习webpack plugins
那么 tapable
是你必须掌握的知识。
本文内容主要来源于风佬的Tapable,看这一篇就够了,这里这是为了加强自己的记忆和理解。
tapable 基本使用
tapable
提供了一系列事件的发布订阅 API
,通过 tapable
可以注册事件,从而在不同时机去触发注册的事件进行执行。
webpack
中的 plugin
机制正是基于这种机制实现了在不同编译阶段调用不同的插件,从而影响编译结果。
在 tapable
中所有注册的事件可以分为同步、异步两种执行方式:
-
针对同步钩子来
tap
方法是唯一的注册事件的方法,通过call
方法触发同步钩子的执行。 -
异步钩子可以通过
tap
、tapAsync
、tapPromise
三种方式来注册,同时可以通过对应的call
、callAsync
、promise
三种方式来触发注册的函数。 -
异步串行钩子(
AsyncSeries
):可以被串联(连续按照顺序调用)执行的异步钩子函数。 -
异步并行钩子(
AsyncParallel
):可以被并联(并发调用)执行的异步钩子函数。
按照执行机制分类
tapable
也可以按照执行机制进行分类,比如:
Basic Hook
基本类型的钩子,它仅仅执行钩子注册的事件,并不关心每个被调用的事件函数返回值如何。
const {SyncHook} = require('tapable');
// 所有的构造函数都接收一个可选的参数,这个参数是一个参数名的字符串数组
// 1. 这里array的字符串随便填写,但是array的长度必须与实际要接受参数个数保持一致;
// 2. 如果回调不接受参数,可以传入空数组。
// 后面类型都是这个规则,不再做过多说明
const hook = new SyncHook(['name']);
// 添加监听
hook.tap('1', (arg0, arg1) => {
// tap 的第一个参数是用来标识`call`传入的参数
// 因为new的时候值的array长度为1
// 所以这里只得到了`call`传入的第一个参数,即Webpack
// arg1 为 undefined
console.log(arg0, arg1);
return '1';
});
hook.tap('2', arg0 => {
console.log(arg0);
});
hook.tap('3', arg0 => {
console.log(arg0);
});
// 传入参数,触发监听的函数回调
// 这里传入两个参数,但是实际回调函数只得到一个
hook.call('Webpack', 'Tapable');
// 执行结果:
Webpack undefined // 传入的参数需要和new实例的时候保持一致,否则获取不到多传的参数
Webpack
Webpack
通过上面的代码可以得出结论:
- 在实例化
SyncHook
传入的数组参数实际是只用了长度,跟实际内容没有关系; - 执行
call
时,入参个数跟实例化时数组长度相关; - 回调栈是按照「先入先出」顺序执行的(这里叫回调队列更合适,队列是先入先出);
- 功能跟 EventEmitter 类似。
Bail Hook(保险类型钩子)
Bail
类型的 Hook
也是按回调栈顺序依次执行回调,但是如果其中一个回调函数返回结果result !== undefined
则退出回调栈调。
const { SyncBailHook } = require('tapable')
const hook = new SyncBailHook(['name'])
hook.tap('1', name => {
console.log(name, 1)
})
hook.tap('2', name => {
console.log(name, 2)
return 'stop'
})
hook.tap('3', name => {
console.log(name, 3)
})
hook.call('hello')
// 打印
hello 1
hello 2
通过上面的代码可以得出结论:
BailHook
中的回调是顺序执行的;- 调用
call
传入的参数会被每个回调函数都获取; - 当回调函数返回非
undefined
才会停止回调栈的调用。
SyncBailHook
类似Array.find
,找到(或者发生)一件事情就停止执行;AsyncParallelBailHook
类似Promise.race
这里竞速场景,只要有一个回调解决了一个问题,全部都解决了。
Waterfall Hook
类似Array.reduce
效果,如果上一个回调函数的结果 result !== undefined
,则会被作为下一个回调函数的第一个参数。代码示例如下:
const { SyncWaterfallHook } = require('tapable')
const hook = new SyncWaterfallHook(['arg0', 'arg1'])
hook.tap('1', (arg0, arg1) => {
console.log(arg0, arg1, 1)
return 1
})
hook.tap('2', (arg0, arg1) => {
// 这里 arg0 = 1
console.log(arg0, arg1, 2)
return 2
})
hook.tap('3', (arg0, arg1) => {
// 这里 arg0 = 2
console.log(arg0, arg1, 3)
// 等同于 return undefined
})
hook.tap('4', (arg0, arg1) => {
// 这里 arg0 = 2 还是2
console.log(arg0, arg1, 4)
})
hook.call('Webpack', 'Tapable')
// 打印结果
Webpack Tapable 1
1 'Tapable' 2
2 'Tapable' 3
2 'Tapable' 4
通过上面的代码可以得出结论:
WaterfallHook
的回调函数接受的参数来自于上一个函数结果;- 调用
call
传入的第一个参数会被上一个函数的非undefined
结果给替换; - 当回调函数返回非
undefined
不会停止回调栈的调用。
Loop Hook
LoopHook
执行特点是不停地循环执行回调函数,直到所有函数结果 result === undefined
。为了更加直观地展现 LoopHook 的执行过程,我对示例代码做了一下丰富:
const { SyncLoopHook } = require('tapable')
const hook = new SyncLoopHook(['name'])
let callbackCalledCount1 = 0
let callbackCalledCount2 = 0
let callbackCalledCount3 = 0
let intent = 0
hook.tap('callback 1', arg => {
callbackCalledCount1++
if (callbackCalledCount1 === 2) {
callbackCalledCount1 = 0
intent -= 4
intentLog('</callback-1>')
return
} else {
intentLog('<callback-1>')
intent += 4
return 'callback-1'
}
})
hook.tap('callback 2', arg => {
callbackCalledCount2++
if (callbackCalledCount2 === 2) {
callbackCalledCount2 = 0
intent -= 4
intentLog('</callback-2>')
return
} else {
intentLog('<callback-2>')
intent += 4
return 'callback-2'
}
})
hook.tap('callback 3', arg => {
callbackCalledCount3++
if (callbackCalledCount3 === 2) {
callbackCalledCount3 = 0
intent -= 4
intentLog('</callback-3>')
return
} else {
intentLog('<callback-3>')
intent += 4
return 'callback-3'
}
})
hook.call('args')
function intentLog(...text) {
console.log(new Array(intent).join(' '), ...text)
}
打印结果如下:
<callback-1>
</callback-1>
<callback-2>
<callback-1>
</callback-1>
</callback-2>
<callback-3>
<callback-1>
</callback-1>
<callback-2>
<callback-1>
</callback-1>
</callback-2>
</callback-3>
AsyncSeriesHook
表示异步串联执行:
const { AsyncSeriesHook } = require('tapable');
// 初始化同步钩子
const hook = new AsyncSeriesHook(['arg1', 'arg2', 'arg3']);
console.time('timer');
// 注册事件
hook.tapAsync('flag1', (arg1, arg2, arg3, callback) => {
console.log('flag1:', arg1, arg2, arg3);
setTimeout(() => {
// 1s后调用callback,表示flag1执行完成
callback();
}, 1000);
});
hook.tapPromise('flag2', (arg1, arg2, arg3) => {
console.log('flag2:', arg1, arg2, arg3);
// tapPromise返回Promise
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 1000);
});
});
// 调用事件并传递执行参数
hook.callAsync('1', 'wang', 'haoyu', () => {
console.log('全部执行完毕 done');
console.timeEnd('timer');
});
// 打印结果
flag1: 1 wang haoyu
flag2: 1 wang haoyu
全部执行完毕 done
timer: 2.012s
-
首先打印
flag1: 1 wang haoyu
,等待1s后,打印flag2: 1 wang haoyu
,所以是一个串行的过程。所以总的用时是2s多一点。 -
tapAsync
注册时实参结尾额外接受一个callback
,调用callback
表示本次事件执行完毕。
AsyncParallelHook
表示异步并行钩子:
const { AsyncParallelHook } = require('tapable')
// 初始化同步钩子
const hook = new AsyncParallelHook(['arg1', 'arg2', 'arg3'])
console.time('timer')
// 注册事件
hook.tapAsync('flag1', (arg1, arg2, arg3, callback) => {
console.log('flag1:', arg1, arg2, arg3)
setTimeout(() => {
// 1s后调用callback表示 flag1执行完成
console.log('flag1----')
callback()
}, 1000)
})
hook.tapPromise('flag2', (arg1, arg2, arg3) => {
console.log('flag2:', arg1, arg2, arg3)
// tapPromise返回Promise
return new Promise(resolve => {
setTimeout(() => {
console.log('flag2----')
resolve()
}, 1000)
})
})
// 调用事件并传递执行参数
hook.callAsync('19Qingfeng', 'wang', 'haoyu', () => {
console.log('全部执行完毕 done')
console.timeEnd('timer')
})
首先打印flag1: 1 wang haoyu flag2: 1 wang haoyu
,然后等待1s后打印flag1---- flag2----
,总用时是1s多,可以看出这两个函数是并行执行的。
Tapable 的原理解析
首先看下这段简单的代码:
看起来很简单对吧,这段代码通过 SyncHook
创建了一个同步 Hook
的实例之后,然后通过 tap
方法注册了两个事件,最后通过 call
方法来调用。
实质上这段代码在调用 hook.call('arg1','agr2')
时, Tapable
会动态编译出来这样一个函数:
function fn(arg1, arg2) {
"use strict";
var _context;
var _x = this._x;
var _fn0 = _x[0];
_fn0(arg1, arg2);
var _fn1 = _x[1];
_fn1(arg1, arg2);
}
通过 this._x
获取调用者的 _x
属性,之后从 _x
属性中获取到对应下标元素。
这里的_x[0]
正是我们监听的第一个 flag1
对应的事件函数体。
同理 _x[1]
正是通过 tap 方法监听的 flag2
函数体内容。
同时会生成一个 Hook 对象,它具有如下属性:
const hook = {
_args: [ 'arg1', 'arg2' ],
name: undefined,
taps: [
{ type: 'sync', fn: [Function (anonymous)], name: 'flag1' },
{ type: 'sync', fn: [Function (anonymous)], name: 'flag2' }
],
interceptors: [],
_call: [Function: CALL_DELEGATE],
call: [Function: anonymous],
_callAsync: [Function: CALL_ASYNC_DELEGATE],
callAsync: [Function: CALL_ASYNC_DELEGATE],
_promise: [Function: PROMISE_DELEGATE],
promise: [Function: PROMISE_DELEGATE],
_x: [ [Function (anonymous)], [Function (anonymous)] ],
compile: [Function: COMPILE],
tap: [Function: tap],
tapAsync: [Function: TAP_ASYNC],
tapPromise: [Function: TAP_PROMISE],
constructor: [Function: SyncHook]
}
tapable
所做的事件就是根据 Hook
中对应的内容动态编译上述的函数体以及创建 Hook
实例对象。
最终在我们通过 Call
调用时,相当于执行这段代码:
// fn 为我们上述动态生成最终需要执行的fn函数
// hook 为我们上边 tapable 内部创建的hook实例对象
hook.call = fn
hook.call(arg1, arg2)
Tapable
源码中的核心正是围绕生成这两部分内容: 一个是动态生成的 fn
,二是生成调用fn的 hook 实例对象。
源码中分别存在两个 class
去管理这两块的内容:
Hook
类,负责创建管理上边的hook
实例对象。下文简称这个对象为核心hook实例对象。HookCodeFactory
类,负责根据内容编译最终通过hook
调用的函数fn
。下文简称这个函数为最终生成的执行函数。
实现一个简易版的 Tapable
我们先从最基础的 SyncHook
出发来一步一步尝试实现 Tapable
。
首先创建基本目录结构:
- 创建了一个
index.js
作为项目入口文件
exports.SyncHook = require('./SyncHook')
- 创建了一个 SyncHook.js 保存同步基本钩子相关逻辑。
function SyncHook () {
}
module.exports = SyncHook
-
创建 Hook.js ,该文件是所有类型 Hook 的父类,所有 Hook 都是基于该类派生而来的。
-
创建一个 HookCodeFactory.js 作为生成最终需要执行的函数的文件。
实现 SyncHook.js
const Hook = require("./Hook");
function SyncHook(args = [], name = undefined) {
const hook = new Hook(args, name);
hook.constructor = SyncHook;
// COMPILE 方法你可以暂时忽略它
hook.compile = COMPILE;
return hook;
}
SyncHook.prototype = null;
module.exports = SyncHook;
当我们进行 new SyncHook
时
-
首先通过
new SyncHook(args, name)
创建了基础的 hook 实例对象。 -
所有类型的 Hook 都是基于这个
Hook
类去继承而来的,同时这个基础的 Hook 类的实例也就是所谓的核心hook实例对象。 -
返回
hook
实例对象,并且将SyncHook
的原型设置为 null。
实现 Hook.js
class Hook {
constructor(args = [], name = undefined) {
// 保存初始化Hook时传递的参数
this._args = args;
// name参数没什么用可以忽略掉
this.name = name;
// 保存通过tap注册的内容
this.taps = [];
// hook.call 调用方法
this._call = CALL_DELEGATE;
this.call = CALL_DELEGATE;
// _x存放hook中所有通过tap注册的函数
this._x = undefined;
// 动态编译方法
this.compile = this.compile;
// 相关注册方法
this.tap = this.tap;
}
compile(options) {
throw new Error('Abstract: should be overridden');
}
}
module.exports = Hook;
所谓 compile
方法正是编译我们最终生成的执行函数的入口方法,同时我们可以看到在 Hook
类中并没有实现 compile
方法,这是因为不同类型的 Hook
最终编译出的执行函数是不同的形式,所以这里以一种抽象方法的方式将 compile
方法交给了子类进行实现。
实现 tap 注册方法
class Hook {
...
tap(options, fn) {
// 这里额外多做了一层封装 是因为this._tap是一个通用方法
// 这里我们使用的是同步 所以第一参数表示类型传入 sync
this._tap('sync', options, fn);
}
_tap(type, options, fn) {
if (typeof options === 'string') {
options = {
name: options.trim(),
};
} else if (typeof options !== 'object' || options === null) {
// 如果非对象或者传入null
throw new Error('Invalid tap options');
}
// 那么此时剩下的options类型仅仅就只有object类型了
if (typeof options.name !== 'string' || options.name === '') {
// 如果传入的options.name 不是字符串 或者是 空串
throw new Error('Missing name for tap');
}
// 合并参数 { type, fn, name:'xxx' }
options = Object.assign({ type, fn }, options);
// 将合并后的参数插入
this._insert(options)
}
_insert(item) {
this.taps.push(item)
}
}
当我们调用 hook.tap
方法注册事件时,最终会在 this.taps
中插入一个 { type:'sync',name:string, fn: Function}
的对象。
实现 call 调用方法
真实的 call
方法的内部核心就是通过调用 hook.call
时动态生成最终生成的执行函数,从而通过 hook
实例对象调用这个最终生成的执行函数。
const CALL_DELEGATE = function(...args) {
this.call = this._createCall("sync");
return this.call(...args);
};
class Hook {
constructor(args = [], name = undefined) {
// ...
this._call = CALL_DELEGATE;
this.call = CALL_DELEGATE;
// ...
}
...
// 编译最终生成的执行函数的方法
// compile是一个抽象方法 需要在继承Hook类的子类方法中进行实现
_createCall(type) {
return this.compile({
taps: this.taps,
args: this._args
type: type,
});
}
}
这里的 CALL_DELEGATE
只有在 this.call
被调用的时才会执行,换句话说每次调用 hook.call
方法时才会进行一次编译,根据 hook
内部注册的事件函数编译称为最终生成的执行函数从而调用它。
你可以将它理解成为一种懒(动态)编译的方式。
实现 HookCodeFactory.js
接下来走进 HookCodeFactory.js
开始探索 Tapable
是如何编译生成最终生成的执行函数。
Hook.js Compile 方法
在 Hook.js 的父类中,我们并没有实现 compile 方法,我们说过每个 compile 方法不同类型的 Hook 编译的结果函数都是不尽相同的。
// SyncHook.js
const Hook = require('./Hook');
const HookCodeFactory = require('./HookCodeFactory');
class SyncHookCodeFactory extends HookCodeFactory {
// 关于 content 方法 你可以先忽略它
content({ onError, onDone, rethrowIfPossible }) {
return this.callTapsSeries({
onError: (i, err) => onError(err),
onDone,
rethrowIfPossible,
});
}
}
const factory = new SyncHookCodeFactory();
/**
* 调用栈 this.call() -> CALL_DELEGATE() -> this._createCall() -> this.compile() -> COMPILE()
* @param {*} options
* @returns
*/
function COMPILE(options) {
factory.setup(this, options);
return factory.create(options);
}
function SyncHook(args = [], name = undefined) {
const hook = new Hook(args);
hook.constructor = SyncHook;
hook.tapAsync = TAP_ASYNC;
hook.tapPromise = TAP_PROMISE;
hook.compile = COMPILE;
return hook;
}
SyncHook.prototype = null;
module.exports = SyncHook;
hook.compile
方法在hook.call
调用时会被调用,接受的 options 类型的参数如下:
{
taps: this.taps,
args: this._args,
type: type,
}
-
HookCodeFactory
这个类即是编译生成最终生成的执行函数的方法类,这是一个基础类。Tapable
将不同种类Hook
编译生成最终方法相同逻辑抽离到了这个类上。 -
SyncHookCodeFactory
它是HookCodeFactory
的子类,它用来存放不同类型的Hook
中差异化的content
方法实现。 -
COMPILE
方法内部SyncHookCodeFactory
的实例对象factory
调用了初始化factory.setup(this, options)
以及通过factory.create(options)
创建最终生成的执行函数并且返回这个函数。
其实 Tapable
中的代码思路还是非常清晰的,不同的类负责不同的逻辑处理。
抽离公用的逻辑在基类中进行实现,同时对于差异化的逻辑基于抽象类的方式在不同的子类中进行实现。
HookCodeFactory.js 基础骨架
class HookCodeFactory {
constructor(config) {
this.config = config;
this.options = undefined;
this._args = undefined;
}
// 初始化参数
setup(instance, options) {}
// 编译最终需要生成的函数
create(options) {}
}
module.exports = HookCodeFactory;
setup 方法
setup 方法的实现非常简单,它的作用是用来初始化当前事件组成的集合。
class HookCodeFactory {
...
// 初始化参数
setup(instance, options) {
instance._x = options.taps.map(i => i.fn)
}
...
}
- 第一个参数是
COMPILE
方法中的this
对象,也就是我们通过new Hook
生成的hook
实例对象。 - 第二个参数是调用
COMPILE
方法时Hook
类上_createCall
传递的options
对象,它的内容是:
{
taps: this.taps,
args: this._args,
type: type,
}
每次调用 hook.call
时会首先通过 setup
方法为 hook
实例对象上的 _x
赋值为所有被 tap
注册的事件函数 [fn1,fn2 ...]
。
create 方法
Tapable
中正是通过 HookCodeFactory
类上的 create
方法正是实现了编译出最终需要执行函数的核心逻辑。正是通过 HookCodeFactory
类上的 create
方法编译出的这段函数:
function fn(arg1, arg2) {
"use strict";
var _context;
var _x = this._x;
var _fn0 = _x[0];
_fn0(arg1, arg2);
var _fn1 = _x[1];
_fn1(arg1, arg2);
}
让我们一步一步先来实现 create 方法:
class HookCodeFactory {
constructor(config) {
this.config = config
this.options = undefined
this._args = undefined
}
// 参数初始化
setup(instance, options) {
instance._x = options.taps.map(i => i.fn)
}
// 编译最终需要生成的函数
create(options) {
this.init(options)
// 生成最终的编译方法fn
let fn
switch (this.options.type) {
case 'sync':
fn = new Function(
this.args(),
'"use strict";\n' +
this.header() +
this.contentWithInterceptors({
onError: err => `throw ${err};\n`,
onResult: result => `return ${result};\n`,
resultReturns: true,
onDone: () => '',
rethrowIfPossible: true
})
)
break
default:
break
}
this.deinit()
return fn
}
callTapsSeries({ onDone }) {
let code = ''
let current = onDone
// 没有注册的事件则直接返回
if (this.options.taps.length === 0) {
return onDone()
}
// 遍历taps注册的函数 编译生成需要执行的函数
for (let i = this.options.taps.length - 1; i >= 0; i--) {
const done = current
// 一个一个创建对应的函数调用
const content = this.callTap(i, {
onDone: done
})
current = () => content
}
code += current()
return code
}
// 编译生成单个的事件函数并且调用 比如 fn1 = this._x[0]; fn1(...args)
callTap(tapIndex, { onDone }) {
let code = ''
// 无论什么类型的都要通过下标先获得内容
// 比如这一步生成 var _fn[1] = this._x[1]
code += `var _fn${tapIndex} = ${this.getTapFn(tapIndex)};\n`
// 不同类型的调用方式不同
// 生成调用代码 fn1(arg1,arg2,...)
const tap = this.options.taps[tapIndex]
switch (tap.type) {
case 'sync':
code += `_fn${tapIndex}(${this.args()});\n`
break
default:
break
}
if (onDone) {
code += onDone()
}
return code
}
// 从this._x中获取函数内容 this._x[index]
getTapFn(idx) {
return `_x[${idx}]`
}
contentWithInterceptors(options) {
return this.content(options)
}
header() {
let code = ''
code += 'var _context;\n'
code += 'var _x = this._x;\n'
return code
}
args({ before, after } = {}) {
let allArgs = this._args
if (before) {
allArgs = [before].concat(allArgs)
}
if (after) {
allArgs = [after].concat(allArgs)
}
if (allArgs.length === 0) {
return ''
} else {
return allArgs.join(',')
}
}
init(options) {
this.options = options
// 保存初始化Hook时的参数,即占位符参数
this._args = options.args.slice()
}
deinit() {
this.options = undefined
this._args = undefined
}
}
module.exports = HookCodeFactory
动态创建函数使用了new Function
进行创建,它的用法如下:
new Function ([arg1[, arg2[, ...argN]],] functionBody)
const adder = new Function("a, b", "return a + b");
// 调用函数
adder(2, 6);//8
通过一个 SyncHook
应该已经说明了 Tapable 中基础的工作流,如下图所示:
本质上 Tapable
就是通过 Hook
这个类来保存相应的监听属性和方法,同时在调用 call
方法触发事件时通过 HookCodeFactory
动态编译生成的一个 Function
,从而执行达到相应的效果。
Tapable 与 Webpack
纵观 Webapck
编译阶段存在两个核心对象 Compiler
、 Compilation
。Webpack
在初始化 Compiler
、 Compilation
对象时会创建一系列相应的 Hook
作为属性保存各自实例对象中。
在进行 Webapck Plugin
开发时,正是基于这一系列 Hook 在不同时机发布对应事件。执行相应的事件从而影响最终的编译结果。
转载自:https://juejin.cn/post/7174624276606255135