首發於個人博客: www.ahonn.me/post/58webpack
在閱讀 webpack 前若是不瞭解 tapable 的話,頗有可能會看得雲裏霧裏,那麼 tapable 究竟是什麼,又有什麼用呢?本文主要介紹 tapable 的使用以及相關實現,經過學習 tapable 可以進一步的瞭解 webpack 的插件機制。如下內容皆基於 tapable v1.1.3 版本。git
tapable 是一個相似於 Node.js 中的 EventEmitter的庫,但更專一於自定義事件的觸發和處理。webpack 經過 tapable 將實現與流程解耦,全部具體實現經過插件的形式存在。github
想要了解 tapable 的實現,那就必然得知道 tapable 的用法以及有哪些使用姿式。tapable 中主要提供了同步與異步兩種鉤子。咱們先從簡單的同步鉤子開始提及。web
以最簡單的 SyncHook 爲例:編程
const { SyncHook } = require('tapable');
const hook = new SyncHook(['name']);
hook.tap('hello', (name) => {
console.log(`hello ${name}`);
});
hook.tap('hello again', (name) => {
console.log(`hello ${name}, again`);
});
hook.call('ahonn');
// hello ahonn
// hello ahonn, again
複製代碼
能夠看到當咱們執行 hook.call('ahonn')
時會依次執行前面 hook.tap(name, callback)
中的回調函數。經過 SyncHook
建立同步鉤子,使用 tap
註冊回調,再調用 call
來觸發。這是 tapable 提供的多種鉤子中比較簡單的一種,經過 EventEmitter 也能輕鬆的實現這種效果。json
此外,tapable 還提供了不少有用的同步鉤子:promise
除了同步執行的鉤子以外,tapable 中還有一些異步鉤子,最基本的兩個異步鉤子分別是 AsyncParallelHook 和 AsyncSeriesHook 。其餘的異步鉤子都是在這兩個鉤子的基礎上添加了一些流程控制,相似於 SyncBailHook 之於 SyncHook 的關係。緩存
AsyncParallelHook 顧名思義是並行執行的異步鉤子,當註冊的全部異步回調都並行執行完畢以後再執行 callAsync 或者 promise 中的函數。異步
const { AsyncParallelHook } = require('tapable');
const hook = new AsyncParallelHook(['name']);
console.time('cost');
hook.tapAsync('hello', (name, cb) => {
setTimeout(() => {
console.log(`hello ${name}`);
cb();
}, 2000);
});
hook.tapPromise('hello again', (name) => {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`hello ${name}, again`);
resolve();
}, 1000);
});
});
hook.callAsync('ahonn', () => {
console.log('done');
console.timeEnd('cost');
});
// hello ahonn, again
// hello ahonn
// done
// cost: 2008.609ms
// 或者經過 hook.promise() 調用
// hook.promise('ahonn').then(() => {
// console.log('done');
// console.timeEnd('cost');
// });
複製代碼
能夠看到 AsyncParallelHook 比 SyncHook 複雜不少,SyncHook 之類的同步鉤子只能經過 tap 來註冊, 而異步鉤子還可以經過 tapAsync 或者 tapPromise 來註冊回調,前者以 callback 的方式執行,然後者則經過 Promise 的方式來執行。異步鉤子沒有 call 方法,執行註冊的回調經過 callAsync 與 promise 方法進行觸發。二者間的不一樣如上代碼所示。async
若是你想要順序的執行異步函數的話,顯然 AsyncParallelHook 是不適合的。因此 tapable 提供了另一個基礎的異步鉤子:AsyncSeriesHook。
const { AsyncSeriesHook } = require('tapable');
const hook = new AsyncSeriesHook(['name']);
console.time('cost');
hook.tapAsync('hello', (name, cb) => {
setTimeout(() => {
console.log(`hello ${name}`);
cb();
}, 2000);
});
hook.tapPromise('hello again', (name) => {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`hello ${name}, again`);
resolve();
}, 1000);
});
});
hook.callAsync('ahonn', () => {
console.log('done');
console.timeEnd('cost');
});
// hello ahonn
// hello ahonn, again
// done
// cost: 3011.162ms
複製代碼
上面的示例代碼與 AsyncParallelHook 的示例代碼幾乎相同,不一樣的是 hook 是經過 new AsyncSeriesHook()
實例化的。經過 AsyncSeriesHook 就可以順序的執行註冊的回調,除此以外註冊與觸發的用法都是相同的。
一樣的,異步鉤子也有一些帶流程控制的鉤子:
tapable 中除了這一些核心的鉤子以外還提供了一些功能,例如 HookMap,MultiHook 等。這裏就不詳細描述它們了,有興趣的能夠自行前往遊覽。
想知道 tapable 的具體實現就必須去閱讀相關的源碼。因爲篇幅有限,這裏咱們就經過閱讀 SyncHook 相關的代碼來看看相關實現,其餘的鉤子思路上大致一致。咱們經過如下代碼來慢慢深刻 tapable 的實現:
const { SyncHook } = require('tapable');
const hook = new SyncHook(['name']);
hook.tap('hello', (name) => {
console.log(`hello ${name}`);
});
hook.call('ahonn');
複製代碼
首先,咱們實例化了 SyncHook
,經過 package.json 能夠知道 tapable 的入口在 /lib/index.js ,這裏導出了上面提到的那些同步/異步的鉤子。SyncHook 對應的實如今 /lib/SyncHook.js 。
在這個文件中,咱們能夠看到 SyncHook 類的結構以下:
class SyncHook exntends Hook {
tapAsync() { ... }
tapPromise() { ... }
compile(options) { ... }
}
複製代碼
在 new SyncHook()
以後,咱們會調用對應實例的 tap
方法進行註冊回調。很明顯,tap
不是在 SyncHook 中實現的,而是在父類中。
能夠看到 /lib/Hook.js 文件中 Hook 類中實現了 tapable 鉤子的絕大多數方法,包括 tap
,tapAsync
,tapPromise
,call
,callAsync
等方法。
咱們主要關注 tap
方法,能夠看到該方法除了作了一些參數的檢查以外還調用了另外的兩個內部方法:_runRegisterInterceptors
和 _insert
。_runRegisterInterceptors()
是運行 register 攔截器,咱們暫且忽略它(有關攔截器能夠查看 tapable#interception )。
重點關注一下 _insert
方法:
_insert(item) {
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 (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;
}
複製代碼
這裏分紅三個部分看,第一部分是 this. _resetCompilation()
,這裏主要是重置一下 call
,callAsync
, promise
這三個函數。至於爲何要這麼作,咱們後面再講,這裏先插個眼。
第二部分是一堆複雜的邏輯,主要是經過 options 中的 before 與 stage 來肯定當前 tap
註冊的回調在什麼位置,也就是提供了優先級的配置,默認的話是添加在當前現有的 this.taps
後。將 before 與 stage 相關代碼去除後 _insert
就變成了這樣:
_insert(item) {
this._resetCompilation();
let i = this.taps.length;
this.taps[i] = item;
}
複製代碼
到目前爲止尚未什麼特別的騷操做,咱們繼續看。當咱們註冊了回調以後就能夠經過 call
來進行觸發了。經過 Hook 類的構造函數咱們能夠看到。
constructor(args) {
if (!Array.isArray(args)) args = [];
this._args = args;
this.taps = [];
this.interceptors = [];
this.call = this._call;
this.promise = this._promise;
this.callAsync = this._callAsync;
this._x = undefined;
}
複製代碼
這時候能夠發現 call
,callAsync
,promise
都指向了下劃線開頭的同名函數,在文件底部咱們看到了以下代碼:
function createCompileDelegate(name, type) {
return function lazyCompileHook(...args) {
this[name] = this._createCall(type);
return this[name](...args);
};
}
Object.defineProperties(Hook.prototype, {
_call: {
value: createCompileDelegate("call", "sync"),
configurable: true,
writable: true
},
_promise: {
value: createCompileDelegate("promise", "promise"),
configurable: true,
writable: true
},
_callAsync: {
value: createCompileDelegate("callAsync", "async"),
configurable: true,
writable: true
}
});
複製代碼
這裏能夠看到第一次執行 call
的時候實際上跑的是 lazyCompileHook
這個函數,這個函數會調用 this._createCall('sync')
來生成新函數執行,後面再次調用 call
時其實也是執行的生成的函數。
到這裏其實咱們就能夠明白前面在調用 tap
時執行的 this. _resetCompilation()
的做用了。也就是說,只要沒有新的 tap
來註冊回調,call
調用的就都會是同一個函數(第一次調用 call
生成的)。 執行新的 tap
來註冊回調後的第一次 call
方法調用都會從新生成函數。
這裏其實我不太明白爲何要經過
Object.defineProperties
在原型鏈上添加方法,直接寫在 Hook class 中的效果應該是同樣的。tapable 目前的 v2.0.0 beta 版本中已經不這樣實現了,若是有人知道爲何。請評論告訴我吧。
爲何須要從新生成函數呢?祕密就在 this._createCall('sync')
中的 this.complie()
裏。
_createCall(type) {
return this.compile({
taps: this.taps,
interceptors: this.interceptors,
args: this._args,
type: type
});
}
複製代碼
this.complie()
不是在 Hook 中實現的,咱們跳回到 SyncHook 中能夠看到:
compile(options) {
factory.setup(this, options);
return factory.create(options);
}
複製代碼
這裏出現了一個 factory
,能夠看到 factory 是上面的 SyncHookCodeFactory
類的實例,SyncHookCodeFactory
中只實現了 content
。因此咱們往上繼續看父類 HookCodeFactory
(lib/HookCodeFactory.js)中的 setup
與 create
。
這裏 setup 函數把 Hook 類中傳過來的 options.taps
中的回調函數(調用 tap 時傳入的函數)賦值給了 SyncHook 裏的 this._x
:
setup(instance, options) {
instance._x = options.taps.map(t => t.fn);
}
複製代碼
而後 factory.create()
執行以後返回,這裏咱們能夠知道 create()
返回的返回值必然是一個函數(供 call 來調用)。看到對應的源碼,create()
方法的實現有一個 switch,咱們着重關注 case 'sync'
。將多餘的代碼刪掉以後咱們能夠看到 create()
方法是這樣的:
create(options) {
this.init(options);
let fn;
switch (this.options.type) {
case "sync":
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
})
);
break;
}
this.deinit();
return fn;
}
複製代碼
能夠看到這裏用到了 new Function()
來生成函數並返回 ,這是 tapable 的關鍵。經過實例化 SyncHook 時傳入的參數名列表與後面註冊的回調信息,生成一個函數來執行它們。對於不一樣的 tapable 鉤子,最大的不一樣就是這裏生成的函數不同,若是是帶有流程控制的鉤子的話,生成的代碼中也會有對應的邏輯。
這裏咱們在 return fn
以前加一句 fn.toString()
來看看生成出來的函數是什麼樣的:
function anonymous(name) {
'use strict';
var _context;
var _x = this._x;
var _fn0 = _x[0];
_fn0(name);
}
複製代碼
因爲咱們的代碼比較簡單,生成出來的代碼就很是簡單了。主要的邏輯就是獲取 this._x
裏的第一個函數並傳入參數執行。若是咱們在 call
以前再經過 tap
註冊一個回調。那麼生成的代碼中也會對應的獲取 _x[1]
來執行第二個註冊的回調函數。
到這裏整一個 new SyncHook()
-> tap
-> call
的流程就結束了。主要的兩個比較有趣的點在執行 call
的時候會進行緩存,以及經過已知的信息來生成不一樣的函數給 call
執行。基本上其餘的鉤子的運行流程都差很少,具體的生成不一樣流程控制的細節這裏就不詳細說了,各位看官自行看源碼吧(具體邏輯在 SyncHookCodeFactory 類的 create 方法中)。
webpack 經過 tapable 這種巧妙的鉤子設計很好的將實現與流程解耦開來,值得學習。或許下一次寫相似須要插件機制的輪子的時候能夠借鑑一些 webpack 的作法。不過 tapable 生成函數的部分看起來不是很優雅,或許 JavaScript 可以支持元編程的話或許可以實現得更好?
若是本文有理解或者表述錯誤,請評論告訴我。感謝閱讀。