一文詳解Webpack中Tapable事件機(jī)制
引言
Webpack 是前端工程化常用的靜態(tài)模塊打包工具。在合適的時機(jī)通過 Webpack 提供的 API 改變輸出結(jié)果,使 Webpack 可以執(zhí)行更廣泛的任務(wù),擁有更強(qiáng)的構(gòu)建能力。
Webpack 的插件機(jī)制本質(zhì)上是一種事件流的機(jī)制,它的工作流程就是將各個插件串聯(lián)起來,而實現(xiàn)這一切的核心就是Tapable,Webpack中最核心的負(fù)責(zé)編譯的Compiler和負(fù)責(zé)創(chuàng)建bundles的Compilation都是Tapable的實例。
本文將介紹 Tapable 的基本使用以及底層實現(xiàn)。
Tapable
Tapable 是一個類似于 Node.js 中的 EventEmitter 的庫,但它更專注于自定義事件的觸發(fā)和處理。通過 Tapable 我們可以注冊自定義事件,然后在適當(dāng)?shù)臅r機(jī)去執(zhí)行自定義事件。這個和我們所熟知的生命周期函數(shù)類似,在特定的時機(jī)去觸發(fā)。
我們先看一個 簡單 Tapable 的 例子:
const { SyncHook } = require("tapable");
// 實例化 鉤子函數(shù) 定義形參
const syncHook = new SyncHook(["name"]);
//通過tap函數(shù)注冊事件
syncHook.tap("同步鉤子1", (name) => {
console.log("同步鉤子1", name);
});
//同步鉤子 通過call 發(fā)布事件
syncHook.call("古茗前端");
通過上面的例子,我們大致可以將 Tapable 的使用分為以下三步:
- 實例化鉤子函數(shù)
- 事件注冊
- 事件觸發(fā)
事件注冊
- 同步的鉤子要用
tap方法來注冊事件 - 異步的鉤子可以像同步方式一樣用
tap方法來注冊,也可以用tapAsync或tapPromise異步方法來注冊。tapAsync: 使用用tapAsync方法來注冊hook時,必須調(diào)用callback 回調(diào)函數(shù)。tapPromise:使用tapPromise方法來注冊hook時,必須返回一個pormise,異步任務(wù)完成后resolve。
事件觸發(fā)
- 同步的鉤子要用
call方法來觸發(fā) - 異步的鉤子需要用
callAsync或promise異步方法來觸發(fā)。callAsync:當(dāng)我們用callAsync方法來調(diào)用hook時,第二個參數(shù)是一個回調(diào)函數(shù),回調(diào)函數(shù)的參數(shù)是執(zhí)行任務(wù)的最后一個返回值promise:當(dāng)我們用promise方法來調(diào)用hook時,需要使用then來處理執(zhí)行結(jié)果,參數(shù)是執(zhí)行任務(wù)的最后一個返回值。
Tapable Hook 鉤子
tapable 內(nèi)置了 9 種 hook 。分為 同步、異步 兩種執(zhí)行方式。異步執(zhí)行 Hook 又可以分為 串行 執(zhí)行和 并行 執(zhí)行。除此之外,hook 可以根據(jù)執(zhí)行機(jī)制分為 常規(guī) 瀑布模式 熔斷模式 循環(huán)模式 四種執(zhí)行機(jī)制。

同步鉤子
同步鉤子顧名思義:同步執(zhí)行,上一個鉤子執(zhí)行完才會執(zhí)行下一個鉤子。
示例代碼如下:
const { SyncHook } = require("tapable");
// 實例化 鉤子函數(shù) 定義形參
const syncHook = new SyncHook(["name"]);
//通過tap函數(shù)注冊事件
syncHook.tap("同步鉤子1", (name) => {
console.log("同步鉤子1", name);
});
//該監(jiān)聽函數(shù)有返回值
syncHook.tap("同步鉤子2", (name) => {
console.log("同步鉤子2", name);
});
//同步鉤子 通過call 發(fā)布事件
syncHook.call("古茗前端");
執(zhí)行結(jié)果如下所示:

異步鉤子
異步鉤子分為: 串行執(zhí)行和并行執(zhí)行。在串行執(zhí)行中,如果上一個鉤子沒有調(diào)用callback 回調(diào)函數(shù),下一個鉤子將不會觸發(fā)對應(yīng)的事件監(jiān)聽。
示例代碼如下:
const { AsyncParallelHook, AsyncSeriesHook } = require("tapable");
const asyncParallelHook = new AsyncParallelHook(["name"]);
const asyncSeriesHook = new AsyncSeriesHook(["name"]);
//通過tap函數(shù)注冊事件
asyncParallelHook.tapAsync("異步并行鉤子1", (name, callback) => {
setTimeout(() => {
console.log("異步并行鉤子1", name);
}, 3000);
});
//該監(jiān)聽函數(shù)有返回值
asyncParallelHook.tapAsync("異步并行鉤子2", (name, callback) => {
setTimeout(() => {
console.log("異步并行鉤子2", name);
}, 1500);
});
//通過tap函數(shù)注冊事件
asyncSeriesHook.tapAsync("異步串行鉤子1", (name, callback) => {
setTimeout(() => {
console.log("異步串行鉤子1", name);
}, 3000);
});
//該監(jiān)聽函數(shù)有返回值
asyncSeriesHook.tapAsync("異步串行鉤子2", (name, callback) => {
setTimeout(() => {
console.log("異步串行鉤子2", name);
}, 1500);
});
// 異步并行鉤子 通過 callAsync 發(fā)布事件
asyncParallelHook.callAsync("古茗前端", () => {
console.log("1111");
return "1122";
});
// 異步串行鉤子 通過 callAsync 發(fā)布事件
asyncSeriesHook.callAsync("古茗前端", () => {
console.log("1111");
return "1122";
});
控制臺輸出結(jié)果如下圖所示:

串行鉤子1沒有調(diào)用callback, 所以串行鉤子2沒有觸發(fā)。添加callback后,控制臺輸出結(jié)果:

熔斷類
AsyncSeriesBailHook是一個異步串行、熔斷類型的Hook。在串行的執(zhí)行過程中,只要其中一個有返回值,后面的就不會執(zhí)行了。
示例代碼如下:
const { SyncBailHook, AsyncSeriesBailHook } = require("tapable");
const syncBailHook = new SyncBailHook(["name"]);
const asyncSeriesBailHook = new AsyncSeriesBailHook(["name"]);
syncBailHook.tap("保險類同步鉤子1", (name) => {
console.log("保險類同步鉤子1", name);
});
syncBailHook.tap("保險類同步鉤子2", (name) => {
console.log("保險類同步鉤子2", name);
return "有返回值";
});
syncBailHook.tap("保險類同步鉤子3", (name) => {
console.log("保險類同步鉤子3", name);
});
asyncSeriesBailHook.tapAsync("保險類異步串行鉤子1", (name, callback) => {
setTimeout(() => {
console.log("保險類異步串行鉤子1", name);
callback();
}, 3000);
});
asyncSeriesBailHook.tapAsync("保險類2步串行鉤子1", (name, callback) => {
setTimeout(() => {
console.log("保險類異步串行鉤子2", name);
callback("有返回值");
}, 2000);
});
asyncSeriesBailHook.tapAsync("保險類異步串行鉤子3", (name) => {
setTimeout(() => {
console.log("保險類異步串行鉤子3", name);
}, 1000);
});
syncBailHook.call("古茗前端");
asyncSeriesBailHook.callAsync("古茗前端", (result) => {
console.log("result", result);
});
控制臺輸出結(jié)果如下圖所示:

循環(huán)類
SyncLoopHook 是一個同步、循環(huán)類型的 Hook。循環(huán)類型的含義是不停的循環(huán)執(zhí)行事件函數(shù),直到所有函數(shù)結(jié)果 result === undefined,不符合條件就調(diào)頭重新開始執(zhí)行。
示例代碼:
const { SyncLoopHook } = require("tapable");
const syncLoopHook = new SyncLoopHook(["name"]);
let count = 4;
syncLoopHook.tap("循環(huán)鉤子1", (name) => {
console.log("循環(huán)鉤子1", count);
return count <= 3 ? undefined : count--;
});
syncLoopHook.tap("循環(huán)鉤子2", (name) => {
console.log("循環(huán)鉤子2", count);
return count <= 2 ? undefined : count--;
});
syncLoopHook.tap("循環(huán)鉤子3", (name) => {
console.log("循環(huán)鉤子3", count);
return count <= 1 ? undefined : count--;
});
syncLoopHook.call();
控制臺輸出結(jié)果:

瀑布類
AsyncSeriesWaterfallHook 是一個異步串行、瀑布類型的 Hook。如果前一個事件函數(shù)的結(jié)果 result !== undefined,則 result 會作為后一個事件函數(shù)的第一個參數(shù)(也就是上一個函數(shù)的執(zhí)行結(jié)果會成為下一個函數(shù)的參數(shù))。
示例代碼:
const { AsyncSeriesWaterfallHook, SyncWaterfallHook } = require("tapable");
const syncWaterfallHook = new SyncWaterfallHook(["name"]);
const asyncSeriesWaterfallHook = new AsyncSeriesWaterfallHook(["name"]);
syncWaterfallHook.tap("瀑布式同步鉤子1", (name) => {
console.log("瀑布式同步鉤子1", name);
return "古茗前端1";
});
syncWaterfallHook.tap("瀑布式同步鉤子2", (name) => {
console.log("瀑布式同步鉤子2", name);
});
syncWaterfallHook.tap("瀑布式同步鉤子3", (name) => {
console.log("瀑布式同步鉤子3", name);
return "古茗前端3";
});
asyncSeriesWaterfallHook.tapAsync("瀑布式異步串行鉤子1", (name, callback) => {
setTimeout(() => {
console.log("瀑布式異步串行鉤子1", name);
callback();
}, 1000);
});
asyncSeriesWaterfallHook.tapAsync("瀑布式異步串行鉤子2", (name, callback) => {
console.log("瀑布式異步串行鉤子2", name);
setTimeout(() => {
callback();
}, 2000);
});
asyncSeriesWaterfallHook.tapAsync("瀑布式異步串行鉤子3", (name, callback) => {
console.log("瀑布式異步串行鉤子3", name);
setTimeout(() => {
callback("古茗前端3");
}, 3000);
});
syncWaterfallHook.call("古茗前端");
asyncSeriesWaterfallHook.callAsync("古茗前端", (result) => {
console.log("result", result);
});
控制臺輸出結(jié)果:

Tapable 高級特性
Intercept
除了通常的 tap/call 之外,所有 hook 鉤子都提供額外的攔截API。— intercept 接口。
intercept 支持的中間件如下圖所示:
| intercept | 類型 | 描述 |
|---|---|---|
| call | (...args) => void | 當(dāng)鉤子被觸發(fā)時,向攔截器添加調(diào)用將被觸發(fā)。您可以訪問hooks參數(shù) |
| tap | (tap: Tap) | 將tap添加到攔截器將在插件點擊鉤子時觸發(fā)。提供的是Tap對象。無法更改Tap對象 |
| loop | (...args) => void | 向攔截器添加循環(huán)將觸發(fā)循環(huán)鉤子的每個循環(huán)。 |
| register | (tap: Tap) => Tap 或 undefined | 將注冊添加到攔截器將觸發(fā)每個添加的Tap,并允許對其進(jìn)行修改。 |
context
插件和攔截器可以選擇訪問可選的上下文對象,該對象可用于向后續(xù)插件和攔截器傳遞任意值。
我們通過下面的小案例,來幫助我們理解。示例代碼如下:
const { SyncHook } = require("tapable");
// 實例化 鉤子函數(shù) 定義形參
const syncHook = new SyncHook(["name"]);
syncHook.intercept({
context: true,
register(context, name) {
console.log("every time tap", context, name);
},
call(context, name) {
console.log("before call", context, name);
},
loop(context, name) {
console.log("before loop", context, name);
},
tap(context, name) {
console.log("before tap", context, name);
},
});
//通過tap函數(shù)注冊事件
syncHook.tap("同步鉤子1", (name) => {
console.log("同步鉤子1", name);
});
//通過tap函數(shù)注冊事件
syncHook.tap("同步鉤子2", (name) => {
console.log("同步鉤子2", name);
});
//同步鉤子 通過call 發(fā)布事件
syncHook.call("古茗前端");
syncHook.call("古茗前端 call2");
控制臺輸入結(jié)果如圖所示:

由上面的案例結(jié)果,我們可以知道。intercept 中的 register 會在每一次的 tap 觸發(fā)。 有幾個 tap 就會觸發(fā)幾次 register。然后依次執(zhí)行鉤子里面的 call、tap.
intercept 特性在 webpack 內(nèi)主要被用作進(jìn)度提示,如 webpack/lib/ProgressPlugin 插件中,分別對 compiler.hooks.emit 、compiler.hooks.afterEmit 鉤子應(yīng)用了記錄進(jìn)度的中間件函數(shù)。
HookMap
HookMap HookMap是具有Hooks的Map的輔助類.提供了一種集合操作能力,能夠降低創(chuàng)建與使用的復(fù)雜度,用法比較簡單:
const { SyncHook, HookMap } = require("tapable");
const syncMap = new HookMap(() => new SyncHook());
// 通過 for 函數(shù)過濾集合中的特定鉤子
syncMap.for("some-key").tap("MyPlugin", (arg) => { /* ... */ });
syncMap.for("some-key").tapAsync("MyPlugin", (arg, callback) => { /* ... */ });
syncMap.for("some-key").tapPromise("MyPlugin", (arg) => { /* ... */ });
// 觸發(fā) guming-test 類型的鉤子
syncMap.get("guming-test").call();
Tapable 底層原理
我們先將 Tapable 工程源碼克隆到本地, 執(zhí)行如下指令:
$ git clone https://github.com/webpack/tapable.git
Tapable 源碼的 lib 目錄結(jié)構(gòu), 如下所示:
lib ├─ AsyncParallelBailHook.js ├─ AsyncParallelHook.js ├─ AsyncSeriesBailHook.js ├─ AsyncSeriesHook.js ├─ AsyncSeriesLoopHook.js ├─ AsyncSeriesWaterfallHook.js ├─ Hook.js ├─ HookCodeFactory.js ├─ HookMap.js ├─ MultiHook.js ├─ SyncBailHook.js ├─ SyncHook.js ├─ SyncLoopHook.js ├─ SyncWaterfallHook.js ├─ index.js └─ util-browser.js
除了上面我們所提及的基本 hooks 函數(shù)、HookMap高級特性,還會有一些 HookCodeFactory、Hook 這些文件。我們簡單過一下 hooks 函數(shù)內(nèi)的內(nèi)容, 會發(fā)現(xiàn)所有的 hooks 函數(shù)都會引用 HookCodeFactory 和 Hook 這兩個文件所導(dǎo)出的對象實例。
我們以 syncHook 鉤子函數(shù)為例, 如下圖所示:

我們大致能夠知道 一個 hooks 函數(shù) 會由一個 CodeFactory 代碼工廠 以及 Hook 實例組成。 Hook 實例會針對不同場景的 hooks 函數(shù), 更改其對應(yīng)的 注冊鉤子(tapAsync ,tap, tapPromise ),事件觸發(fā)鉤子( call , callAsync ), 編譯函數(shù)(complier)。 Complier 函數(shù)會由我們 HookCodeFactory 實現(xiàn)。
接下來,我們將通過分析 HookCodeFactory 及 Hook 的內(nèi)部實現(xiàn)來了解 Tapable 的內(nèi)部實現(xiàn)機(jī)制。
Hook 實例
Hook 實例會生成我們 hooks 鉤子函數(shù)通用的 事件注冊,事件觸發(fā)。核心邏輯,我們大致可以分為以下三個部分:
- 實例初始化構(gòu)造函數(shù)
- 事件注冊 的實現(xiàn)
- 事件觸發(fā)的實現(xiàn)
構(gòu)造函數(shù)
構(gòu)造函數(shù)會對實例屬性初始化賦值。代碼如下圖所示:

注冊事件
注冊事件主要分為兩塊,一塊是 適配器注冊調(diào)用, 第二塊是 觸發(fā)事件注冊。核心邏輯在 _tap 函數(shù)內(nèi)部實現(xiàn),代碼如下圖所示:

適配器調(diào)用
在這里會對攜帶 register 函數(shù)的適配器進(jìn)行調(diào)用,更改 options 配置,返回新的 options 配置。代碼如下圖所示:

觸發(fā)事件注冊
Hook 實例的 taps 會存儲我們的注冊事件, 同時會根據(jù),注冊事件配置的執(zhí)行順序去存儲對應(yīng)的注冊事件。

觸發(fā)事件
觸發(fā)事件會通過調(diào)用內(nèi)部 的 _createCall 函數(shù),函數(shù)內(nèi)部會調(diào)用實例的 compile 函數(shù)。我們會發(fā)現(xiàn):Hook 實例內(nèi)部不會去實現(xiàn) complier的邏輯, 不同鉤子的 complier 函數(shù)會通過通過對應(yīng)的 繼承 HookCodeFactory 的實例去實現(xiàn)。代碼如下圖所示:


接下來,我們繼續(xù) 探究 HookCodeFactory 實例,了解 Tapable 事件觸發(fā)的邏輯。
HookCodeFactory 實例
HookCodeFactory 實例會根據(jù)我們傳入的事件觸發(fā)類型 (sync, async, promise)以及我們的觸發(fā)機(jī)制類型 (常規(guī) 瀑布模式 保險模式 循環(huán)模式), 生成事件觸發(fā)函數(shù)的函數(shù)頭, 函數(shù)體。通過 new Function 構(gòu)造出事件觸發(fā)函數(shù)。
Tapable事件觸發(fā)的執(zhí)行,是動態(tài)生成執(zhí)行代碼, 包含我們的參數(shù),函數(shù)頭,函數(shù)體,然后通過new Function來執(zhí)行。相較于我們通常的遍歷/遞歸調(diào)用事件,這無疑讓webpack的整個事件機(jī)制的執(zhí)行有了一個更高的性能優(yōu)勢。
由上面我們可知, Hook 實例 的 complier 函數(shù)是 HookCodeFactory 實例 create 函數(shù) 的返回。
接下來,我們就從 create 函數(shù) 一步步揭秘 Tapable 的動態(tài)生成執(zhí)行函數(shù)的核心實現(xiàn)。
create
create 函數(shù)通過對應(yīng)的 函數(shù)參數(shù), 函數(shù) header, 函數(shù) content方法構(gòu)造出我們事件觸發(fā)的函數(shù)的內(nèi)容, 通過 new Function 創(chuàng)建出我們的觸發(fā)函數(shù)。
create 函數(shù)會根據(jù)事件的觸發(fā)類型 ( sync、async、promise),進(jìn)行不同的邏輯處理。代碼如下圖所示:

每一種觸發(fā)機(jī)制,都會由 this.args, this.header, this.contentWithInterceptors 三個函數(shù)去實現(xiàn) 動態(tài)函數(shù)的 code。代碼如下圖所示:

接下來我們看一看 this.contentWithInterceptors 函數(shù)如何生成我們事件觸發(fā)函數(shù)的函數(shù)體。
contentWithInterceptors
contentWithInterceptors 函數(shù)里包含兩個模塊, 一個是適配器 (interceptor), 一個 content 生成函數(shù)。 同時,HookCodeFactory 實例本身不會去實現(xiàn) content 函數(shù)的邏輯,會由繼承的實例去實現(xiàn)。整體結(jié)構(gòu)代碼如下圖所示:

每個 hooks 鉤子函數(shù)的 CodeFactory 實例會去實現(xiàn) content 函數(shù)。 content 函數(shù)會調(diào)用 HookCodeFactory 實現(xiàn)的不同運行機(jī)制的方法( callTap、callTapsSeries、callTapsLooping、callTapsParallel), 構(gòu)造出最終的函數(shù)體。實現(xiàn)代碼如下圖所示:

接下來,就是不同運行機(jī)制,根據(jù)不同的調(diào)用方式 ( sync, async, promise ) 生成對應(yīng)的執(zhí)行代碼。
動態(tài)函數(shù)
我們通過下面一個簡單案例,看 New Function 輸入的內(nèi)容是什么?
實例代碼如下:
const { SyncHook, AsyncSeriesHook } = require("tapable");
// 實例化 鉤子函數(shù) 定義形參
const syncHook = new SyncHook(["name"]);
const asyncSeriesHook = new AsyncSeriesHook(["name"])
//通過tap函數(shù)注冊事件
syncHook.tap("同步鉤子1", (name) => {
console.log("同步鉤子1", name);
});
//通過tap函數(shù)注冊事件
asyncSeriesHook.tapAsync("同步鉤子1", (name) => {
console.log("同步鉤子1", name);
});
//同步鉤子 通過call 發(fā)布事件
syncHook.call("古茗前端sync");
asyncSeriesHook.callAsync("古茗前端async");
asyncSeriesHook.promise("古茗前端promise")
在 HookCodeFactory 的 create 打印 fn, 實例代碼如圖所示:

sync 同步調(diào)用的輸出結(jié)果如下:
function anonymous(name
) {
"use strict";
var _context;
var _x = this._x;
var _fn0 = _x[0];
_fn0(name);
}
async 異步調(diào)用的輸出結(jié)果如下:
function anonymous(name, _callback
) {
"use strict";
var _context;
var _x = this._x;
var _fn0 = _x[0];
_fn0(name, (function(_err0) {
if(_err0) {
_callback(_err0);
} else {
_callback();
}
}));
}
promise 調(diào)用的輸出結(jié)果如下
function anonymous(name
) {
"use strict";
var _context;
var _x = this._x;
return new Promise((function(_resolve, _reject) {
var _sync = true;
function _error(_err) {
if(_sync)
_resolve(Promise.resolve().then((function() { throw _err; })));
else
_reject(_err);
};
var _fn0 = _x[0];
_fn0(name, (function(_err0) {
if(_err0) {
_error(_err0);
} else {
_resolve();
}
}));
_sync = false;
}));
}
三種不同方式調(diào)用,內(nèi)部代碼實現(xiàn)差異還是比較清晰的。async 調(diào)用相較于 sync 多了回調(diào)函數(shù)。 async 和 promise 的區(qū)別再去返回 promise 還是回調(diào)函數(shù)。
最后,我們來用一些結(jié)構(gòu)圖來總結(jié)一下 Tapable 中事件觸發(fā)的邏輯。
HookCodeFactory 會根據(jù)我們觸發(fā)的方式,生成我們對應(yīng) new Function 里面的 content, args, header。

content 最終會由 callTapsSeries、callTapsLooping、callTapsParallel 生成。每種生成方式都會包含 Done 處理、Error 處理以及 Result 處理。

總結(jié)
其他機(jī)制的 Hooks 鉤子實現(xiàn)原理大致是相同的, 這里就不一一贅述了。Tapable 是一個非常優(yōu)秀的庫,靈活擴(kuò)展性高,許多優(yōu)秀的開源項目的插件化設(shè)計都借鑒或采納了 Tapable 的設(shè)計思想,是一個非常值得推薦學(xué)習(xí)的一個開源工具庫。
以上就是一文詳解Webpack中Tapable事件機(jī)制的詳細(xì)內(nèi)容,更多關(guān)于Webpack Tapable事件機(jī)制的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
TypeScript中的interface與type實戰(zhàn)
這篇文章主要介紹了TypeScript中的interface與type詳解,它們都是用來定義類型的強(qiáng)大工具,在實際開發(fā)中,你可以根據(jù)具體情況選擇使用 interface 或 type,或者甚至將它們結(jié)合起來使用,需要的朋友可以參考下2023-06-06
前端技巧之HTTP中POST提交數(shù)據(jù)四種方式
這篇文章主要為大家介紹了前端技巧之HTTP中POST提交數(shù)據(jù)四種方式詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-06-06
異步j(luò)avascript的原理和實現(xiàn)技巧介紹
因為工作的需要,我要在網(wǎng)頁端編寫一段腳本,把數(shù)據(jù)通過網(wǎng)頁批量提交到系統(tǒng)中去。所以我就想到了Greasemonkey插件,于是就開始動手寫,發(fā)現(xiàn)問題解決得很順利2012-11-11

