從使用到原理,喫透 Tapable

胡寧:微醫前端技術部平臺支撐組,最近是一陣信奉快樂的風~

tapable 是一個類似於 Node.js 中的 EventEmitter 的庫,但更專注於自定義事件的觸發和處理。webpack 通過 tapable 將實現與流程解耦,所有具體實現通過插件的形式存在。

Tapable 和 webpack 的關係

  1. webpack 是什麼?

本質上,webpack 是一個用於現代 JavaScript 應用程序的 靜態模塊打包工具。當 webpack 處理應用程序時,它會在內部構建一個 依賴圖 (dependency graph),此依賴圖對應映射到項目所需的每個模塊,並生成一個或多個 bundle。

  1. webpack 的重要模塊

插件 (plugin) 是 webpack 的支柱功能。webpack 自身也是構建於你在 webpack 配置中用到的相同的插件系統之上。

  1. webpack 的構建流程

webpack 本質上是一種事件流的機制,它的工作流程就是將各個插件串聯起來,而實現這一切的核心就是 Tapable。webpack 中最核心的負責編譯的 Compiler 和負責創建 bundle 的 Compilation 都是 Tapable 的實例 (webpack5 前)。webpack5 之後是通過定義屬性名爲 hooks 來調度觸發時機。Tapable 充當的就是一個複雜的發佈訂閱者模式

以 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 類的作用就是通過實例化來創建一個執行流程,並提供註冊和執行方法,Hook 類的不同會導致執行流程的不同。

const {
 SyncHook,
 SyncBailHook,
 SyncWaterfallHook,
 SyncLoopHook,
 AsyncParallelHook,
 AsyncParallelBailHook,
 AsyncSeriesHook,
 AsyncSeriesBailHook,
 AsyncSeriesWaterfallHook
 } = require("tapable");

每個 hook 都能被註冊多次,如何被觸發取決於 hook 的類型

按同步、異步(串行、並行)分類

按執行模式分類

使用方式

Hook 類使用

簡單來說就是下面步驟

  1. 實例化構造函數 Hook

  2. 註冊(一次或者多次)

  3. 執行(傳入參數)

  4. 如果有需要還可以增加對整個流程(包括註冊和執行)的監聽 - 攔截器

以最簡單的 SyncHook 爲例:

// 簡單來說就是實例化 Hooks 類
// 接收一個可選參數,參數是一個參數名的字符串數組
const hook = new SyncHook(["arg1""arg2""arg3"]);
// 註冊
// 第一個入參爲註冊名
// 第二個爲註冊回調方法
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;
});
// 執行
// 執行順序則是根據這個實例類型來決定的
hook.call("a""b""c");

//------輸出------
// 先註冊先觸發
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);
 }
}

註冊 & 執行:

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 來進行註冊的回調存儲和觸發,通過 HookCodeFactory 來控制註冊的執行流程。

首先來觀察一下 tapable 的 lib 文件結構,核心的代碼都是存放在 lib 文件夾中。其中 index.js 爲所有可使用類的入口。Hook 和 HookCodeFactory 則是核心類,主要的作用就是註冊和觸發流程。還有兩個輔助類 HookMap 和 MultiHook 以及一個工具類 util-browser。其餘均是以 Hook 和 HookCodeFactory 爲基礎類衍生的以上分類所提及的 9 種 Hooks。整個結構是非常簡單清楚的。如圖所示:

接下來講一下最重要的兩個類,也是 tapable 的源碼核心。

Hook

首先看 Hook 的屬性,可以看到屬性中有熟悉的註冊的方法:tap、tapAsync、tapPromise。執行方法:call、promise、callAsync。以及存放所有的註冊項 taps。constructor 的入參就是每個鉤子實例化時的入參。從屬性上就能夠知道是 Hook 類爲繼承它的子類提供了最基礎的註冊和執行的方法

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();
  }
  // 合併參數
  options = Object.assign({ type, fn }, options);
  // 執行註冊的 interceptors 的 register 監聽,並返回執行後的 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 來實現的,只是傳入的 type 不同。_tap 主要做了兩件事。

  1. 執行 interceptor.register,並返回 options

  2. 收集註冊項到 this.taps 列表中,同時根據 stage 和 before 排序。(stage 和 before 是註冊時的可選參數)

收集完註冊項,接下來就是執行這個流程:

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
  });
 }
}

執行流程可以說是殊途同歸,最後都是通過_createCall 來返回一個 compile 執行後的值。從上文可知,tapable 的執行流程有同步,異步串行,異步並行、循環等,因此 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 的職責就是將執行代碼賦值給 hook.compile,從而使 hook 得到執行能力。來看看該類內部運轉邏輯是這樣的:

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() 進行創建的。那麼重點就是這個 new Function 中了。

先了解一下 new Function 的語法

new Function ([arg1[, arg2[, ...argN]],] functionBody)

基本用法:

const sum = new Function('a''b''return a + b');
console.log(sum(2, 6));
// expected output: 8

使用 Function 構造函數的方法:

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() 就是返回執行時傳入參數名,爲後面 code 提供了對應參數值。

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. 生成監聽的回調對象如:
 // {
 //  onError,
 //  onResult,
 //  resultReturns,
 //  onDone,
 //  rethrowIfPossible
 // }
  // 2. 執行 this.content({...}),入參爲第一步返回的對象
 ...
}

而對應的 functionBody 則是通過 header 和 contentWithInterceptors 共同生成的。this.content 則是根據鉤子類型的不同調用不同的方法如下面代碼則調用的是 callTapsSeries:

class SyncHookCodeFactory extends HookCodeFactory {
 content({ onError, onDone, rethrowIfPossible }) {
  return this.callTapsSeries({
   onError: (i, err) => onError(err),
   onDone,
   rethrowIfPossible
  });
 }
}

HookCodeFactory 有三種生成 code 的方法:

// 串行
callTapsSeries() {...}
// 循環
callTapsLooping() {...}
// 並行
callTapsParallel() {...}
// 執行單個註冊回調,通過判斷 sync、async、promise 返回對應 code
callTap() {...}
  1. 並行(Parallel)原理:並行的情況只有在異步的時候才發生,因此執行所有的 taps 後,判斷計數器是否爲 0,爲 0 則執行結束回調(計數器爲 0 有可能是因爲 taps 全部執行完畢,有可能是因爲返回值不爲 undefined,手動設置爲 0)

  2. 循環(Loop)原理:生成 do{}while(__loop) 的代碼,將執行後的值是否爲 undefined 賦值給_loop,從而來控制循環

  3. 串行:就是按照 taps 的順序來生成執行的代碼

  4. callTap: 執行單個註冊回調

var _fn0 = _x[0];
_fn0(arg1, arg2, arg3);
var _fn1 = _x[1];
_fn1(arg1, arg2, arg3);
var _fn2 = _x[2];
_fn2(arg1, arg2, arg3);
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();
  }
}));
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);
});

將以上的執行順序以及執行方式來進行組合,就得到了現在的 9 種 Hook 類。若後續需要更多的模式只需要增加執行順序或者執行方式就能夠完成拓展。

如圖所示:

如何助力 webpack

插件可以使用 tapable 對外暴露的方法向 webpack 中注入自定義構建的步驟,這些步驟將在構建過程中觸發。

webpack 將整個構建的步驟生成一個一個 hook 鉤子(即 tapable 的 9 種 hook 類型的實例),存儲在 hooks 的對象裏。插件可以通過 Compiler 或者 Compilation 訪問到對應的 hook 鉤子的實例,進行註冊(tap,tapAsync,tapPromise)。當 webpack 執行到相應步驟時就會通過 hook 來進行執行(call, callAsync,promise),從而執行註冊的回調。以 ConsoleLogOnBuildWebpackPlugin 自定義插件爲例:

const pluginName = 'ConsoleLogOnBuildWebpackPlugin';

class ConsoleLogOnBuildWebpackPlugin {
  apply(compiler) {
    compiler.hooks.run.tap(pluginName, (compilation) ={
      console.log('webpack 構建過程開始!');
    });
  }
}

module.exports = ConsoleLogOnBuildWebpackPlugin;

可以看到在 apply 中通過 compiler 的 hooks 註冊(tap)了在 run 階段時的回調。從 Compiler 類中可以瞭解到在 hooks 對象中對 run 屬性賦值 AsyncSeriesHook 的實例,並在執行的時候通過 this.hooks.run.callAsync 觸發了已註冊的對應回調:

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);
     });
    });
   });
  };
  ...
 }
}

如圖所示,爲該自定義插件的執行過程:

總結

  1. tapable 對外暴露 9 種 hook 鉤子,核心方法是註冊、執行、攔截器

  2. tapable 實現方式就是根據鉤子類型以及註冊類型來拼接字符串傳入 Function 構造函數創建一個新的 Function 對象

  3. webpack 通過 tapable 來對整個構建步驟進行了流程化的管理。實現了對每個構建步驟都能進行靈活定製化需求。

如有意見,歡迎一鍵素質三連,寶~。

參考資料

[1]webpack 官方文檔中對於 plugin 的介紹: https://webpack.docschina.org/concepts/plugins/

[2]tapable 相關介紹: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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/3huqZYQBNN0gfMsS0xc0KA