談談Tapable的前世今生
tapable 是一個類似于 Node.js 中的 EventEmitter 的庫,但更專注于自定義事件的觸發(fā)和處理。webpack 通過 tapable 將實現(xiàn)與流程解耦,所有具體實現(xiàn)通過插件的形式存在。
Tapable 和 webpack 的關(guān)系
webpack 是什么?
本質(zhì)上,webpack 是一個用于現(xiàn)代 JavaScript 應用程序的 靜態(tài)模塊打包工具。當 webpack 處理應用程序時,它會在內(nèi)部構(gòu)建一個 依賴圖(dependency graph),此依賴圖對應映射到項目所需的每個模塊,并生成一個或多個 bundle。
webpack 的重要模塊
- 入口(entry)
- 輸出(output)
- loader(對模塊的源代碼進行轉(zhuǎn)換)
- plugin(webpack 構(gòu)建流程中的特定時機注入擴展邏輯來改變構(gòu)建結(jié)果或做你想要的事)
插件(plugin)是 webpack 的支柱功能。webpack 自身也是構(gòu)建于你在 webpack 配置中用到的相同的插件系統(tǒng)之上。
webpack 的構(gòu)建流程
webpack 本質(zhì)上是一種事件流的機制,它的工作流程就是將各個插件串聯(lián)起來,而實現(xiàn)這一切的核心就是 Tapable。webpack 中最核心的負責編譯的 Compiler 和負責創(chuàng)建 bundle 的 Compilation 都是 Tapable 的實例(webpack5 前)。webpack5 之后是通過定義屬性名為 hooks 來調(diào)度觸發(fā)時機。Tapable 充當?shù)木褪且粋€復雜的發(fā)布訂閱者模式
以 Compiler 為例:
- // webpack5 前,通過繼承
- ...
- const {
- Tapable,
- SyncHook,
- SyncBailHook,
- AsyncParallelHook,
- AsyncSeriesHook
- } = require("tapable");
- ...
- class Compiler extends Tapable {
- constructor(context) {
- super();
- ...
- }
- }
- // webpack5
- ...
- const {
- SyncHook,
- SyncBailHook,
- AsyncParallelHook,
- AsyncSeriesHook
- } = require("tapable");
- ...
- class Compiler {
- constructor(context) {
- this.hooks = Object.freeze({
- /** @type {SyncHook<[]>} */
- initialize: new SyncHook([]),
- /** @type {SyncBailHook<[Compilation], boolean>} */
- shouldEmit: new SyncBailHook(["compilation"]),
- ...
- })
- }
- ...
- }
Tapable 的使用姿勢
tapable 對外暴露了 9 種 Hooks 類。這些 Hooks 類的作用就是通過實例化來創(chuàng)建一個執(zhí)行流程,并提供注冊和執(zhí)行方法,Hook 類的不同會導致執(zhí)行流程的不同。
- const {
- SyncHook,
- SyncBailHook,
- SyncWaterfallHook,
- SyncLoopHook,
- AsyncParallelHook,
- AsyncParallelBailHook,
- AsyncSeriesHook,
- AsyncSeriesBailHook,
- AsyncSeriesWaterfallHook
- } = require("tapable");
每個 hook 都能被注冊多次,如何被觸發(fā)取決于 hook 的類型
按同步、異步(串行、并行)分類
- Sync:只能被同步函數(shù)注冊,如 myHook.tap()
- AsyncSeries:可以被同步的,基于回調(diào)的,基于 promise 的函數(shù)注冊,如 myHook.tap(),myHook.tapAsync() , myHook.tapPromise()。執(zhí)行順序為串行
- AsyncParallel:可以被同步的,基于回調(diào)的,基于 promise 的函數(shù)注冊,如 myHook.tap(),myHook.tapAsync() , myHook.tapPromise()。執(zhí)行順序為并行
按執(zhí)行模式分類
- Basic:執(zhí)行每一個事件函數(shù),不關(guān)心函數(shù)的返回值
- Bail:執(zhí)行每一個事件函數(shù),遇到第一個結(jié)果 result !== undefined 則返回,不再繼續(xù)執(zhí)行
- Waterfall:如果前一個事件函數(shù)的結(jié)果 result !== undefined,則 result 會作為后一個事件函數(shù)的第一個參數(shù)
- Loop:不停的循環(huán)執(zhí)行事件函數(shù),直到所有函數(shù)結(jié)果 result === undefined
使用方式
Hook 類
使用簡單來說就是下面步驟
- 實例化構(gòu)造函數(shù) Hook
- 注冊(一次或者多次)
- 執(zhí)行(傳入?yún)?shù))
- 如果有需要還可以增加對整個流程(包括注冊和執(zhí)行)的監(jiān)聽-攔截器
以最簡單的 SyncHook 為例:
- // 簡單來說就是實例化 Hooks 類
- // 接收一個可選參數(shù),參數(shù)是一個參數(shù)名的字符串數(shù)組
- const hook = new SyncHook(["arg1", "arg2", "arg3"]);
- // 注冊
- // 第一個入?yún)樽悦?nbsp;
- // 第二個為注冊回調(diào)方法
- hook.tap("1", (arg1, arg2, arg3) => {
- console.log(1, arg1, arg2, arg3);
- return 1;
- });
- hook.tap("2", (arg1, arg2, arg3) => {
- console.log(2, arg1, arg2, arg3);
- return 2;
- });
- hook.tap("3", (arg1, arg2, arg3) => {
- console.log(3, arg1, arg2, arg3);
- return 3;
- });
- // 執(zhí)行
- // 執(zhí)行順序則是根據(jù)這個實例類型來決定的
- hook.call("a", "b", "c");
- //------輸出------
- // 先注冊先觸發(fā)
- 1 a b c
- 2 a b c
- 3 a b c
上面的例子為同步的情況,若注冊異步則:
- let { AsyncSeriesHook } = require("tapable");
- let queue = new AsyncSeriesHook(["name"]);
- console.time("cost");
- queue.tapPromise("1", function (name) {
- return new Promise(function (resolve) {
- setTimeout(function () {
- console.log(1, name);
- resolve();
- }, 1000);
- });
- });
- queue.tapPromise("2", function (name) {
- return new Promise(function (resolve) {
- setTimeout(function () {
- console.log(2, name);
- resolve();
- }, 2000);
- });
- });
- queue.tapPromise("3", function (name) {
- return new Promise(function (resolve) {
- setTimeout(function () {
- console.log(3, name);
- resolve();
- }, 3000);
- });
- });
- queue.promise("weiyi").then((data) => {
- console.log(data);
- console.timeEnd("cost");
- });
HookMap 類使用
A HookMap is a helper class for a Map with Hooks
官方推薦將所有的鉤子實例化在一個類的屬性 hooks 上,如:
- class Car {
- constructor() {
- this.hooks = {
- accelerate: new SyncHook(["newSpeed"]),
- brake: new SyncHook(),
- calculateRoutes: new AsyncParallelHook(["source", "target", "routesList"])
- };
- }
- /* ... */
- setSpeed(newSpeed) {
- // following call returns undefined even when you returned values
- this.hooks.accelerate.call(newSpeed);
- }
- }
注冊&執(zhí)行:
- const myCar = new Car();
- myCar.hooks.accelerate.tap("LoggerPlugin", newSpeed => console.log(`Accelerating to ${newSpeed}`));
- myCar.setSpeed(1)
而 HookMap 正是這種推薦寫法的一個輔助類。具體使用方法:
- const keyedHook = new HookMap(key => new SyncHook(["arg"]))
- keyedHook.for("some-key").tap("MyPlugin", (arg) => { /* ... */ });
- keyedHook.for("some-key").tapAsync("MyPlugin", (arg, callback) => { /* ... */ });
- keyedHook.for("some-key").tapPromise("MyPlugin", (arg) => { /* ... */ });
- const hook = keyedHook.get("some-key");
- if(hook !== undefined) {
- hook.callAsync("arg", err => { /* ... */ });
- }
MultiHook 類使用
A helper Hook-like class to redirect taps to multiple other hooks
相當于提供一個存放一個 hooks 列表的輔助類:
- const { MultiHook } = require("tapable");
- this.hooks.allHooks = new MultiHook([this.hooks.hookA, this.hooks.hookB]);
Tapable 的原理
核心就是通過 Hook 來進行注冊的回調(diào)存儲和觸發(fā),通過 HookCodeFactory 來控制注冊的執(zhí)行流程。
首先來觀察一下 tapable 的 lib 文件結(jié)構(gòu),核心的代碼都是存放在 lib 文件夾中。其中 index.js 為所有可使用類的入口。Hook 和 HookCodeFactory 則是核心類,主要的作用就是注冊和觸發(fā)流程。還有兩個輔助類 HookMap 和 MultiHook 以及一個工具類 util-browser。其余均是以 Hook 和 HookCodeFactory 為基礎(chǔ)類衍生的以上分類所提及的 9 種 Hooks。整個結(jié)構(gòu)是非常簡單清楚的。如圖所示:
接下來講一下最重要的兩個類,也是 tapable 的源碼核心。
Hook
首先看 Hook 的屬性,可以看到屬性中有熟悉的注冊的方法:tap、tapAsync、tapPromise。執(zhí)行方法:call、promise、callAsync。以及存放所有的注冊項 taps。constructor 的入?yún)⒕褪敲總€鉤子實例化時的入?yún)ⅰ膶傩陨暇湍軌蛑朗?Hook 類為繼承它的子類提供了最基礎(chǔ)的注冊和執(zhí)行的方法
- class Hook {
- constructor(args = [], name = undefined) {
- this._args = args;
- this.name = name;
- this.taps = [];
- this.interceptors = [];
- this._call = CALL_DELEGATE;
- this.call = CALL_DELEGATE;
- this._callAsync = CALL_ASYNC_DELEGATE;
- this.callAsync = CALL_ASYNC_DELEGATE;
- this._promise = PROMISE_DELEGATE;
- this.promise = PROMISE_DELEGATE;
- this._x = undefined;
- this.compile = this.compile;
- this.tap = this.tap;
- this.tapAsync = this.tapAsync;
- this.tapPromise = this.tapPromise;
- }
- ...
- }
那么 Hook 類是如何收集注冊項的?如代碼所示:
- class Hook {
- ...
- tap(options, fn) {
- this._tap("sync", options, fn);
- }
- tapAsync(options, fn) {
- this._tap("async", options, fn);
- }
- tapPromise(options, fn) {
- this._tap("promise", options, fn);
- }
- _tap(type, options, fn) {
- if (typeof options === "string") {
- options = {
- name: options.trim()
- };
- } else if (typeof options !== "object" || options === null) {
- throw new Error("Invalid tap options");
- }
- if (typeof options.name !== "string" || options.name === "") {
- throw new Error("Missing name for tap");
- }
- if (typeof options.context !== "undefined") {
- deprecateContext();
- }
- // 合并參數(shù)
- options = Object.assign({ type, fn }, options);
- // 執(zhí)行注冊的 interceptors 的 register 監(jiān)聽,并返回執(zhí)行后的 options
- options = this._runRegisterInterceptors(options);
- // 收集到 taps 中
- this._insert(options);
- }
- _runRegisterInterceptors(options) {
- for (const interceptor of this.interceptors) {
- if (interceptor.register) {
- const newOptions = interceptor.register(options);
- if (newOptions !== undefined) {
- options = newOptions;
- }
- }
- }
- return options;
- }
- ...
- }
可以看到三種注冊的方法都是通過_tap 來實現(xiàn)的,只是傳入的 type 不同。_tap 主要做了兩件事。
- 執(zhí)行 interceptor.register,并返回 options
- 收集注冊項到 this.taps 列表中,同時根據(jù) stage 和 before 排序。(stage 和 before 是注冊時的可選參數(shù))
收集完注冊項,接下來就是執(zhí)行這個流程:
- const CALL_DELEGATE = function(...args) {
- this.call = this._createCall("sync");
- return this.call(...args);
- };
- const CALL_ASYNC_DELEGATE = function(...args) {
- this.callAsync = this._createCall("async");
- return this.callAsync(...args);
- };
- const PROMISE_DELEGATE = function(...args) {
- this.promise = this._createCall("promise");
- return this.promise(...args);
- };
- class Hook {
- constructor() {
- ...
- this._call = CALL_DELEGATE;
- this.call = CALL_DELEGATE;
- this._callAsync = CALL_ASYNC_DELEGATE;
- this.callAsync = CALL_ASYNC_DELEGATE;
- this._promise = PROMISE_DELEGATE;
- this.promise = PROMISE_DELEGATE;
- ...
- }
- compile(options) {
- throw new Error("Abstract: should be overridden");
- }
- _createCall(type) {
- return this.compile({
- taps: this.taps,
- interceptors: this.interceptors,
- args: this._args,
- type: type
- });
- }
- }
執(zhí)行流程可以說是殊途同歸,最后都是通過_createCall 來返回一個 compile 執(zhí)行后的值。從上文可知,tapable 的執(zhí)行流程有同步,異步串行,異步并行、循環(huán)等,因此 Hook 類只提供了一個抽象方法 compile,那么 compile 具體是怎么樣的呢。這就引出了下一個核心類 HookCodeFactory。
HookCodeFactory
見名知意,該類是一個返回 hookCode 的工廠。首先來看下這個工廠是如何被使用的。這是其中一種 hook 類 AsyncSeriesHook 使用方式:
- const HookCodeFactory = require("./HookCodeFactory");
- class AsyncSeriesHookCodeFactory extends HookCodeFactory {
- content({ onError, onDone }) {
- return this.callTapsSeries({
- onError: (i, err, next, doneBreak) => onError(err) + doneBreak(true),
- onDone
- });
- }
- }
- const factory = new AsyncSeriesHookCodeFactory();
- // options = {
- // taps: this.taps,
- // interceptors: this.interceptors,
- // args: this._args,
- // type: type
- // }
- const COMPILE = function(options) {
- factory.setup(this, options);
- return factory.create(options);
- };
- function AsyncSeriesHook(args = [], name = undefined) {
- const hook = new Hook(args, name);
- hook.constructor = AsyncSeriesHook;
- hook.compile = COMPILE;
- ...
- return hook;
- }
HookCodeFactory 的職責就是將執(zhí)行代碼賦值給 hook.compile,從而使 hook 得到執(zhí)行能力。來看看該類內(nèi)部運轉(zhuǎn)邏輯是這樣的:
- class HookCodeFactory {
- constructor(config) {
- this.config = config;
- this.options = undefined;
- this._args = undefined;
- }
- ...
- create(options) {
- ...
- this.init(options);
- // type
- switch (this.options.type) {
- case "sync": fn = new Function(省略...);break;
- case "async": fn = new Function(省略...);break;
- case "promise": fn = new Function(省略...);break;
- }
- this.deinit();
- return fn;
- }
- init(options) {
- this.options = options;
- this._args = options.args.slice();
- }
- deinit() {
- this.options = undefined;
- this._args = undefined;
- }
- }
最終返回給 compile 就是 create 返回的這個 fn,fn 則是通過 new Function()進行創(chuàng)建的。那么重點就是這個 new Function 中了。
先了解一下 new Function 的語法
new Function ([arg1[, arg2[, ...argN]],] functionBody)
- arg1, arg2, ... argN:被函數(shù)使用的參數(shù)的名稱必須是合法命名的。參數(shù)名稱是一個有效的 JavaScript 標識符的字符串,或者一個用逗號分隔的有效字符串的列表;例如“×”,“theValue”,或“a,b”。
- functionBody:一個含有包括函數(shù)定義的 JavaScript 語句的字符串。
基本用法:
- const sum = new Function('a', 'b', 'return a + b');
- console.log(sum(2, 6));
- // expected output: 8
使用 Function 構(gòu)造函數(shù)的方法:
- class HookCodeFactory {
- create() {
- ...
- fn = new Function(this.args({...}), code)
- ...
- return fn
- }
- args({ before, after } = {}) {
- let allArgs = this._args;
- if (before) allArgs = [before].concat(allArgs);
- if (after) allArgs = allArgs.concat(after);
- if (allArgs.length === 0) {
- return "";
- } else {
- return allArgs.join(", ");
- }
- }
- }
這個 this.args()就是返回執(zhí)行時傳入?yún)?shù)名,為后面 code 提供了對應參數(shù)值。
- 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
- })
- )
- header() {
- let code = "";
- if (this.needContext()) {
- code += "var _context = {};\n";
- } else {
- code += "var _context;\n";
- }
- code += "var _x = this._x;\n";
- if (this.options.interceptors.length > 0) {
- code += "var _taps = this.taps;\n";
- code += "var _interceptors = this.interceptors;\n";
- }
- return code;
- }
- contentWithInterceptors() {
- // 由于代碼過多這邊描述一下過程
- // 1. 生成監(jiān)聽的回調(diào)對象如:
- // {
- // onError,
- // onResult,
- // resultReturns,
- // onDone,
- // rethrowIfPossible
- // }
- // 2. 執(zhí)行 this.content({...}),入?yún)榈谝徊椒祷氐膶ο?nbsp;
- ...
- }
而對應的 functionBody 則是通過 header 和 contentWithInterceptors 共同生成的。this.content 則是根據(jù)鉤子類型的不同調(diào)用不同的方法如下面代碼則調(diào)用的是 callTapsSeries:
- class SyncHookCodeFactory extends HookCodeFactory {
- content({ onError, onDone, rethrowIfPossible }) {
- return this.callTapsSeries({
- onError: (i, err) => onError(err),
- onDone,
- rethrowIfPossible
- });
- }
- }
HookCodeFactory 有三種生成 code 的方法:
- // 串行
- callTapsSeries() {...}
- // 循環(huán)
- callTapsLooping() {...}
- // 并行
- callTapsParallel() {...}
- // 執(zhí)行單個注冊回調(diào),通過判斷 sync、async、promise 返回對應 code
- callTap() {...}
- 并行(Parallel)原理:并行的情況只有在異步的時候才發(fā)生,因此執(zhí)行所有的 taps 后,判斷計數(shù)器是否為 0,為 0 則執(zhí)行結(jié)束回調(diào)(計數(shù)器為 0 有可能是因為 taps 全部執(zhí)行完畢,有可能是因為返回值不為 undefined,手動設(shè)置為 0)
- 循環(huán)(Loop)原理:生成 do{}while(__loop)的代碼,將執(zhí)行后的值是否為 undefined 賦值給_loop,從而來控制循環(huán)
- 串行:就是按照 taps 的順序來生成執(zhí)行的代碼
- callTap:執(zhí)行單個注冊回調(diào)
- sync:按照順序執(zhí)行
- var _fn0 = _x[0];
- _fn0(arg1, arg2, arg3);
- var _fn1 = _x[1];
- _fn1(arg1, arg2, arg3);
- var _fn2 = _x[2];
- _fn2(arg1, arg2, arg3);
- async 原理:將單個 tap 封裝成一個_next[index]函數(shù),當前一個函數(shù)執(zhí)行完成即調(diào)用了 callback,則會繼續(xù)執(zhí)行下一個_next[index]函數(shù),如生成如下 code:
- function _next1() {
- var _fn2 = _x[2];
- _fn2(name, (function (_err2) {
- if (_err2) {
- _callback(_err2);
- } else {
- _callback();
- }
- }));
- }
- function _next0() {
- var _fn1 = _x[1];
- _fn1(name, (function (_err1) {
- if (_err1) {
- _callback(_err1);
- } else {
- _next1();
- }
- }));
- }
- var _fn0 = _x[0];
- _fn0(name, (function (_err0) {
- if (_err0) {
- _callback(_err0);
- } else {
- _next0();
- }
- }));
- promise:將單個 tap 封裝成一個_next[index]函數(shù),當前一個函數(shù)執(zhí)行完成即調(diào)用了 promise.then(),then 中則會繼續(xù)執(zhí)行下一個_next[index]函數(shù),如生成如下 code:
- function _next1() {
- var _fn2 = _x[2];
- var _hasResult2 = false;
- var _promise2 = _fn2(name);
- if (!_promise2 || !_promise2.then)
- throw new Error('Tap function (tapPromise) did not return promise (returned ' + _promise2 + ')');
- _promise2.then((function (_result2) {
- _hasResult2 = true;
- _resolve();
- }), function (_err2) {
- if (_hasResult2) throw _err2;
- _error(_err2);
- });
- }
- function _next0() {
- var _fn1 = _x[1];
- var _hasResult1 = false;
- var _promise1 = _fn1(name);
- if (!_promise1 || !_promise1.then)
- throw new Error('Tap function (tapPromise) did not return promise (returned ' + _promise1 + ')');
- _promise1.then((function (_result1) {
- _hasResult1 = true;
- _next1();
- }), function (_err1) {
- if (_hasResult1) throw _err1;
- _error(_err1);
- });
- }
- var _fn0 = _x[0];
- var _hasResult0 = false;
- var _promise0 = _fn0(name);
- if (!_promise0 || !_promise0.then)
- throw new Error('Tap function (tapPromise) did not return promise (returned ' + _promise0 + ')');
- _promise0.then((function (_result0) {
- _hasResult0 = true;
- _next0();
- }), function (_err0) {
- if (_hasResult0) throw _err0;
- _error(_err0);
- });
將以上的執(zhí)行順序以及執(zhí)行方式來進行組合,就得到了現(xiàn)在的 9 種 Hook 類。若后續(xù)需要更多的模式只需要增加執(zhí)行順序或者執(zhí)行方式就能夠完成拓展。
如圖所示:
如何助力 webpack
插件可以使用 tapable 對外暴露的方法向 webpack 中注入自定義構(gòu)建的步驟,這些步驟將在構(gòu)建過程中觸發(fā)。
webpack 將整個構(gòu)建的步驟生成一個一個 hook 鉤子(即 tapable 的 9 種 hook 類型的實例),存儲在 hooks 的對象里。插件可以通過 Compiler 或者 Compilation 訪問到對應的 hook 鉤子的實例,進行注冊(tap,tapAsync,tapPromise)。當 webpack 執(zhí)行到相應步驟時就會通過 hook 來進行執(zhí)行(call, callAsync,promise),從而執(zhí)行注冊的回調(diào)。以 ConsoleLogOnBuildWebpackPlugin 自定義插件為例:
- const pluginName = 'ConsoleLogOnBuildWebpackPlugin';
- class ConsoleLogOnBuildWebpackPlugin {
- apply(compiler) {
- compiler.hooks.run.tap(pluginName, (compilation) => {
- console.log('webpack 構(gòu)建過程開始!');
- });
- }
- }
- module.exports = ConsoleLogOnBuildWebpackPlugin;
可以看到在 apply 中通過 compiler 的 hooks 注冊(tap)了在 run 階段時的回調(diào)。從 Compiler 類中可以了解到在 hooks 對象中對 run 屬性賦值 AsyncSeriesHook 的實例,并在執(zhí)行的時候通過 this.hooks.run.callAsync 觸發(fā)了已注冊的對應回調(diào):
- class Compiler {
- constructor(context) {
- this.hooks = Object.freeze({
- ...
- run: new AsyncSeriesHook(["compiler"]),
- ...
- })
- }
- run() {
- ...
- const run = () => {
- this.hooks.beforeRun.callAsync(this, err => {
- if (err) return finalCallback(err);
- this.hooks.run.callAsync(this, err => {
- if (err) return finalCallback(err);
- this.readRecords(err => {
- if (err) return finalCallback(err);
- this.compile(onCompiled);
- });
- });
- });
- };
- ...
- }
- }
如圖所示,為該自定義插件的執(zhí)行過程:
總結(jié)
- tapable 對外暴露 9 種 hook 鉤子,核心方法是注冊、執(zhí)行、攔截器
- tapable 實現(xiàn)方式就是根據(jù)鉤子類型以及注冊類型來拼接字符串傳入 Function 構(gòu)造函數(shù)創(chuàng)建一個新的 Function 對象
- webpack 通過 tapable 來對整個構(gòu)建步驟進行了流程化的管理。實現(xiàn)了對每個構(gòu)建步驟都能進行靈活定制化需求。
參考資料
[1]webpack 官方文檔中對于 plugin 的介紹:
https://webpack.docschina.org/concepts/plugins/
[2]tapable 相關(guān)介紹:
http://www.zhufengpeixun.com/grow/html/103.7.webpack-tapable.html
[3]tabpable 源碼:
https://github.com/webpack/tapable
[4]webpack 源碼:
https://github.com/webpack/webpack