webpack核心模組tapable源碼解析
- 2021 年 4 月 1 日
- 筆記
- javascript, webpack, 前端, 工程化
上一篇文章我寫了tapable
的基本用法,我們知道他是一個增強版版的發布訂閱模式
,本文想來學習下他的源碼。tapable
的源碼我讀了一下,發現他的抽象程度比較高,直接扎進去反而會讓人云里霧裡的,所以本文會從最簡單的SyncHook
和發布訂閱模式
入手,再一步一步抽象,慢慢變成他源碼的樣子。
本文可運行示例程式碼已經上傳GitHub,大家拿下來一邊玩一邊看文章效果更佳://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Engineering/tapable-source-code。
SyncHook
的基本實現
上一篇文章已經講過SyncHook
的用法了,我這裡就不再展開了,他使用的例子就是這樣子:
const { SyncHook } = require("tapable");
// 實例化一個加速的hook
const accelerate = new SyncHook(["newSpeed"]);
// 註冊第一個回調,加速時記錄下當前速度
accelerate.tap("LoggerPlugin", (newSpeed) =>
console.log("LoggerPlugin", `加速到${newSpeed}`)
);
// 再註冊一個回調,用來檢測是否超速
accelerate.tap("OverspeedPlugin", (newSpeed) => {
if (newSpeed > 120) {
console.log("OverspeedPlugin", "您已超速!!");
}
});
// 觸發一下加速事件,看看效果吧
accelerate.call(500);
其實這種用法就是一個最基本的發布訂閱模式
,我之前講發布訂閱模式的文章講過,我們可以仿照那個很快實現一個SyncHook
:
class SyncHook {
constructor(args = []) {
this._args = args; // 接收的參數存下來
this.taps = []; // 一個存回調的數組
}
// tap實例方法用來註冊回調
tap(name, fn) {
// 邏輯很簡單,直接保存下傳入的回調參數就行
this.taps.push(fn);
}
// call實例方法用來觸發事件,執行所有回調
call(...args) {
// 邏輯也很簡單,將註冊的回調一個一個拿出來執行就行
const tapsLength = this.taps.length;
for(let i = 0; i < tapsLength; i++) {
const fn = this.taps[i];
fn(...args);
}
}
}
這段程式碼非常簡單,是一個最基礎的發布訂閱模式
,使用方法跟上面是一樣的,將SyncHook
從tapable
導出改為使用我們自己的:
// const { SyncHook } = require("tapable");
const { SyncHook } = require("./SyncHook");
運行效果是一樣的:
注意: 我們構造函數裡面傳入的args
並沒有用上,tapable
主要是用它來動態生成call
的函數體的,在後面講程式碼工廠的時候會看到。
SyncBailHook
的基本實現
再來一個SyncBailHook
的基本實現吧,SyncBailHook
的作用是當前一個回調返回不為undefined
的值的時候,阻止後面的回調執行。基本使用是這樣的:
const { SyncBailHook } = require("tapable"); // 使用的是SyncBailHook
const accelerate = new SyncBailHook(["newSpeed"]);
accelerate.tap("LoggerPlugin", (newSpeed) =>
console.log("LoggerPlugin", `加速到${newSpeed}`)
);
// 再註冊一個回調,用來檢測是否超速
// 如果超速就返回一個錯誤
accelerate.tap("OverspeedPlugin", (newSpeed) => {
if (newSpeed > 120) {
console.log("OverspeedPlugin", "您已超速!!");
return new Error('您已超速!!');
}
});
// 由於上一個回調返回了一個不為undefined的值
// 這個回調不會再運行了
accelerate.tap("DamagePlugin", (newSpeed) => {
if (newSpeed > 300) {
console.log("DamagePlugin", "速度實在太快,車子快散架了。。。");
}
});
accelerate.call(500);
他的實現跟上面的SyncHook
也非常像,只是call
在執行的時候不一樣而已,SyncBailHook
需要檢測每個回調的返回值,如果不為undefined
就終止執行後面的回調,所以程式碼實現如下:
class SyncBailHook {
constructor(args = []) {
this._args = args;
this.taps = [];
}
tap(name, fn) {
this.taps.push(fn);
}
// 其他程式碼跟SyncHook是一樣的,就是call的實現不一樣
// 需要檢測每個返回值,如果不為undefined就終止執行
call(...args) {
const tapsLength = this.taps.length;
for(let i = 0; i < tapsLength; i++) {
const fn = this.taps[i];
const res = fn(...args);
if( res !== undefined) return res;
}
}
}
然後改下SyncBailHook
從我們自己的引入就行:
// const { SyncBailHook } = require("tapable");
const { SyncBailHook } = require("./SyncBailHook");
運行效果是一樣的:
抽象重複程式碼
現在我們只實現了SyncHook
和SyncBailHook
兩個Hook
而已,上一篇講用法的文章裡面總共有9個Hook
,如果每個Hook
都像前面這樣實現也是可以的。但是我們再仔細看下SyncHook
和SyncBailHook
兩個類的程式碼,發現他們除了call
的實現不一樣,其他程式碼一模一樣,所以作為一個有追求的工程師,我們可以把這部分重複的程式碼提出來作為一個基類:Hook
類。
Hook
類需要包含一些公共的程式碼,call
這種不一樣的部分由各個子類自己實現。所以Hook
類就長這樣:
const CALL_DELEGATE = function(...args) {
this.call = this._createCall();
return this.call(...args);
};
// Hook是SyncHook和SyncBailHook的基類
// 大體結構是一樣的,不一樣的地方是call
// 不同子類的call是不一樣的
// tapable的Hook基類提供了一個抽象介面compile來動態生成call函數
class Hook {
constructor(args = []) {
this._args = args;
this.taps = [];
// 基類的call初始化為CALL_DELEGATE
// 為什麼這裡需要這樣一個代理,而不是直接this.call = _createCall()
// 等我們後面子類實現了再一起講
this.call = CALL_DELEGATE;
}
// 一個抽象介面compile
// 由子類實現,基類compile不能直接調用
compile(options) {
throw new Error("Abstract: should be overridden");
}
tap(name, fn) {
this.taps.push(fn);
}
// _createCall調用子類實現的compile來生成call方法
_createCall() {
return this.compile({
taps: this.taps,
args: this._args,
});
}
}
官方對應的源碼看這裡://github.com/webpack/tapable/blob/master/lib/Hook.js
子類SyncHook實現
現在有了Hook
基類,我們的SyncHook
就需要繼承這個基類重寫,tapable
在這裡繼承的時候並沒有使用class extends
,而是手動繼承的:
const Hook = require('./Hook');
function SyncHook(args = []) {
// 先手動繼承Hook
const hook = new Hook(args);
hook.constructor = SyncHook;
// 然後實現自己的compile函數
// compile的作用應該是創建一個call函數並返回
hook.compile = function(options) {
// 這裡call函數的實現跟前面實現是一樣的
const { taps } = options;
const call = function(...args) {
const tapsLength = taps.length;
for(let i = 0; i < tapsLength; i++) {
const fn = this.taps[i];
fn(...args);
}
}
return call;
};
return hook;
}
SyncHook.prototype = null;
注意:我們在基類Hook
構造函數中初始化this.call
為CALL_DELEGATE
這個函數,這是有原因的,最主要的原因是確保this
的正確指向。思考一下假如我們不用CALL_DELEGATE
,而是直接this.call = this._createCall()
會發生什麼?我們來分析下這個執行流程:
- 用戶使用時,肯定是使用
new SyncHook()
,這時候會執行const hook = new Hook(args);
new Hook(args)
會去執行Hook
的構造函數,也就是會運行this.call = this._createCall()
- 這時候的
this
指向的是基類Hook
的實例,this._createCall()
會調用基類的this.compile()
- 由於基類的
complie
函數是一個抽象介面,直接調用會報錯Abstract: should be overridden
。
那我們採用this.call = CALL_DELEGATE
是怎麼解決這個問題的呢?
- 採用
this.call = CALL_DELEGATE
後,基類Hook
上的call
就只是被賦值為一個代理函數而已,這個函數不會立馬調用。 - 用戶使用時,同樣是
new SyncHook()
,裡面會執行Hook
的構造函數 Hook
構造函數會給this.call
賦值為CALL_DELEGATE
,但是不會立即執行。new SyncHook()
繼續執行,新建的實例上的方法hook.complie
被覆寫為正確方法。- 當用戶調用
hook.call
的時候才會真正執行this._createCall()
,這裡面會去調用this.complie()
- 這時候調用的
complie
已經是被正確覆寫過的了,所以得到正確的結果。
子類SyncBailHook的實現
子類SyncBailHook
的實現跟上面SyncHook
的也是非常像,只是hook.compile
實現不一樣而已:
const Hook = require('./Hook');
function SyncBailHook(args = []) {
// 基本結構跟SyncHook都是一樣的
const hook = new Hook(args);
hook.constructor = SyncBailHook;
// 只是compile的實現是Bail版的
hook.compile = function(options) {
const { taps } = options;
const call = function(...args) {
const tapsLength = taps.length;
for(let i = 0; i < tapsLength; i++) {
const fn = this.taps[i];
const res = fn(...args);
if( res !== undefined) break;
}
}
return call;
};
return hook;
}
SyncBailHook.prototype = null;
抽象程式碼工廠
上面我們通過對SyncHook
和SyncBailHook
的抽象提煉出了一個基類Hook
,減少了重複程式碼。基於這種結構子類需要實現的就是complie
方法,但是如果我們將SyncHook
和SyncBailHook
的complie
方法拿出來對比下:
SyncHook:
hook.compile = function(options) {
const { taps } = options;
const call = function(...args) {
const tapsLength = taps.length;
for(let i = 0; i < tapsLength; i++) {
const fn = this.taps[i];
fn(...args);
}
}
return call;
};
SyncBailHook:
hook.compile = function(options) {
const { taps } = options;
const call = function(...args) {
const tapsLength = taps.length;
for(let i = 0; i < tapsLength; i++) {
const fn = this.taps[i];
const res = fn(...args);
if( res !== undefined) return res;
}
}
return call;
};
我們發現這兩個complie
也非常像,有大量重複程式碼,所以tapable
為了解決這些重複程式碼,又進行了一次抽象,也就是程式碼工廠HookCodeFactory
。HookCodeFactory
的作用就是用來生成complie
返回的call
函數體,而HookCodeFactory
在實現時也採用了Hook
類似的思路,也是先實現了一個基類HookCodeFactory
,然後不同的Hook
再繼承這個類來實現自己的程式碼工廠,比如SyncHookCodeFactory
。
創建函數的方法
在繼續深入程式碼工廠前,我們先來回顧下JS裡面創建函數的方法。一般我們會有這幾種方法:
-
函數申明
function add(a, b) { return a + b; }
-
函數表達式
const add = function(a, b) { return a + b; }
但是除了這兩種方法外,還有種不常用的方法:使用Function構造函數。比如上面這個函數使用構造函數創建就是這樣的:
const add = new Function('a', 'b', 'return a + b;');
上面的調用形式里,最後一個參數是函數的函數體,前面的參數都是函數的形參,最終生成的函數跟用函數表達式的效果是一樣的,可以這樣調用:
add(1, 2); // 結果是3
注意:上面的a
和b
形參放在一起用逗號隔開也是可以的:
const add = new Function('a, b', 'return a + b;'); // 這樣跟上面的效果是一樣的
當然函數並不是一定要有參數,沒有參數的函數也可以這樣創建:
const sayHi = new Function('alert("Hello")');
sayHi(); // Hello
這樣創建函數和前面的函數申明和函數表達式有什麼區別呢?使用Function構造函數來創建函數最大的一個特徵就是,函數體是一個字元串,也就是說我們可以動態生成這個字元串,從而動態生成函數體。因為SyncHook
和SyncBailHook
的call
函數很像,我們可以像拼一個字元串那樣拼出他們的函數體,為了更簡單的拼湊,tapable
最終生成的call
函數裡面並沒有循環,而是在拼函數體的時候就將循環展開了,比如SyncHook
拼出來的call
函數的函數體就是這樣的:
"use strict";
var _x = this._x;
var _fn0 = _x[0];
_fn0(newSpeed);
var _fn1 = _x[1];
_fn1(newSpeed);
上面程式碼的_x
其實就是保存回調的數組taps
,這裡重命名為_x
,我想是為了節省程式碼大小吧。這段程式碼可以看到,_x
,也就是taps
裡面的內容已經被展開了,是一個一個取出來執行的。
而SyncBailHook
最終生成的call
函數體是這樣的:
"use strict";
var _x = this._x;
var _fn0 = _x[0];
var _result0 = _fn0(newSpeed);
if (_result0 !== undefined) {
return _result0;
;
} else {
var _fn1 = _x[1];
var _result1 = _fn1(newSpeed);
if (_result1 !== undefined) {
return _result1;
;
} else {
}
}
這段生成的程式碼主體邏輯其實跟SyncHook
是一樣的,都是將_x
展開執行了,他們的區別是SyncBailHook
會對每次執行的結果進行檢測,如果結果不是undefined
就直接return
了,後面的回調函數就沒有機會執行了。
創建程式碼工廠基類
基於這個目的,我們的程式碼工廠基類應該可以生成最基本的call
函數體。我們來寫個最基本的HookCodeFactory
吧,目前他只能生成SyncHook
的call
函數體:
class HookCodeFactory {
constructor() {
// 構造函數定義兩個變數
this.options = undefined;
this._args = undefined;
}
// init函數初始化變數
init(options) {
this.options = options;
this._args = options.args.slice();
}
// deinit重置變數
deinit() {
this.options = undefined;
this._args = undefined;
}
// args用來將傳入的數組args轉換為New Function接收的逗號分隔的形式
// ['arg1', 'args'] ---> 'arg1, arg2'
args() {
return this._args.join(", ");
}
// setup其實就是給生成程式碼的_x賦值
setup(instance, options) {
instance._x = options.taps.map(t => t);
}
// create創建最終的call函數
create(options) {
this.init(options);
let fn;
// 直接將taps展開為平鋪的函數調用
const { taps } = options;
let code = '';
for (let i = 0; i < taps.length; i++) {
code += `
var _fn${i} = _x[${i}];
_fn${i}(${this.args()});
`
}
// 將展開的循環和頭部連接起來
const allCodes = `
"use strict";
var _x = this._x;
` + code;
// 用傳進來的參數和生成的函數體創建一個函數出來
fn = new Function(this.args(), allCodes);
this.deinit(); // 重置變數
return fn; // 返回生成的函數
}
}
上面程式碼最核心的其實就是create
函數,這個函數會動態創建一個call
函數並返回,所以SyncHook
可以直接使用這個factory
創建程式碼了:
// SyncHook.js
const Hook = require('./Hook');
const HookCodeFactory = require("./HookCodeFactory");
const factory = new HookCodeFactory();
// COMPILE函數會去調用factory來生成call函數
const COMPILE = function(options) {
factory.setup(this, options);
return factory.create(options);
};
function SyncHook(args = []) {
const hook = new Hook(args);
hook.constructor = SyncHook;
// 使用HookCodeFactory來創建最終的call函數
hook.compile = COMPILE;
return hook;
}
SyncHook.prototype = null;
讓程式碼工廠支援SyncBailHook
現在我們的HookCodeFactory
只能生成最簡單的SyncHook
程式碼,我們需要對他進行一些改進,讓他能夠也生成SyncBailHook
的call
函數體。你可以拉回前面再仔細觀察下這兩個最終生成程式碼的區別:
SyncBailHook
需要對每次執行的result
進行處理,如果不為undefined
就返回SyncBailHook
生成的程式碼其實是if...else
嵌套的,我們生成的時候可以考慮使用一個遞歸函數
為了讓SyncHook
和SyncBailHook
的子類程式碼工廠能夠傳入差異化的result
處理,我們先將HookCodeFactory
基類的create
拆成兩部分,將程式碼拼裝的邏輯單獨拆成一個函數:
class HookCodeFactory {
// ...
// 省略其他一樣的程式碼
// ...
// create創建最終的call函數
create(options) {
this.init(options);
let fn;
// 拼裝程式碼頭部
const header = `
"use strict";
var _x = this._x;
`;
// 用傳進來的參數和函數體創建一個函數出來
fn = new Function(this.args(),
header +
this.content()); // 注意這裡的content函數並沒有在基類HookCodeFactory實現,而是子類實現的
this.deinit();
return fn;
}
// 拼裝函數體
// callTapsSeries也沒在基類調用,而是子類調用的
callTapsSeries() {
const { taps } = this.options;
let code = '';
for (let i = 0; i < taps.length; i++) {
code += `
var _fn${i} = _x[${i}];
_fn${i}(${this.args()});
`
}
return code;
}
}
上面程式碼裡面要特別注意create
函數裡面生成函數體的時候調用的是this.content
,但是this.content
並沒與在基類實現,這要求子類在使用HookCodeFactory
的時候都需要繼承他並實現自己的content
函數,所以這裡的content
函數也是一個抽象介面。那SyncHook
的程式碼就應該改成這樣:
// SyncHook.js
// ... 省略其他一樣的程式碼 ...
// SyncHookCodeFactory繼承HookCodeFactory並實現content函數
class SyncHookCodeFactory extends HookCodeFactory {
content() {
return this.callTapsSeries(); // 這裡的callTapsSeries是基類的
}
}
// 使用SyncHookCodeFactory來創建factory
const factory = new SyncHookCodeFactory();
const COMPILE = function (options) {
factory.setup(this, options);
return factory.create(options);
};
注意這裡:子類實現的content
其實又調用了基類的callTapsSeries
來生成最終的函數體。所以這裡這幾個函數的調用關係其實是這樣的:
那這樣設計的目的是什麼呢?為了讓子類content
能夠傳遞參數給基類callTapsSeries
,從而生成不一樣的函數體。我們馬上就能在SyncBailHook
的程式碼工廠上看到了。
為了能夠生成SyncBailHook
的函數體,我們需要讓callTapsSeries
支援一個onResult
參數,就是這樣:
class HookCodeFactory {
// ... 省略其他相同的程式碼 ...
// 拼裝函數體,需要支援options.onResult參數
callTapsSeries(options) {
const { taps } = this.options;
let code = '';
let i = 0;
const onResult = options && options.onResult;
// 寫一個next函數來開啟有onResult回調的函數體生成
// next和onResult相互遞歸調用來生成最終的函數體
const next = () => {
if(i >= taps.length) return '';
const result = `_result${i}`;
const code = `
var _fn${i} = _x[${i}];
var ${result} = _fn${i}(${this.args()});
${onResult(i++, result, next)}
`;
return code;
}
// 支援onResult參數
if(onResult) {
code = next();
} else {
// 沒有onResult參數的時候,即SyncHook跟之前保持一樣
for(; i< taps.length; i++) {
code += `
var _fn${i} = _x[${i}];
_fn${i}(${this.args()});
`
}
}
return code;
}
}
然後我們的SyncBailHook
的程式碼工廠在繼承工廠基類的時候需要傳一個onResult
參數,就是這樣:
const Hook = require('./Hook');
const HookCodeFactory = require("./HookCodeFactory");
// SyncBailHookCodeFactory繼承HookCodeFactory並實現content函數
// content裡面傳入訂製的onResult函數,onResult回去調用next遞歸生成嵌套的if...else...
class SyncBailHookCodeFactory extends HookCodeFactory {
content() {
return this.callTapsSeries({
onResult: (i, result, next) =>
`if(${result} !== undefined) {\nreturn ${result};\n} else {\n${next()}}\n`,
});
}
}
// 使用SyncHookCodeFactory來創建factory
const factory = new SyncBailHookCodeFactory();
const COMPILE = function (options) {
factory.setup(this, options);
return factory.create(options);
};
function SyncBailHook(args = []) {
// 基本結構跟SyncHook都是一樣的
const hook = new Hook(args);
hook.constructor = SyncBailHook;
// 使用HookCodeFactory來創建最終的call函數
hook.compile = COMPILE;
return hook;
}
現在運行下程式碼,效果跟之前一樣的,大功告成~
其他Hook的實現
到這裡,tapable
的源碼架構和基本實現我們已經弄清楚了,但是本文只用了SyncHook
和SyncBailHook
做例子,其他的,比如AsyncParallelHook
並沒有展開講。因為AsyncParallelHook
之類的其他Hook
的實現思路跟本文是一樣的,比如我們可以先實現一個獨立的AsyncParallelHook
類:
class AsyncParallelHook {
constructor(args = []) {
this._args = args;
this.taps = [];
}
tapAsync(name, task) {
this.taps.push(task);
}
callAsync(...args) {
// 先取出最後傳入的回調函數
let finalCallback = args.pop();
// 定義一個 i 變數和 done 函數,每次執行檢測 i 值和隊列長度,決定是否執行 callAsync 的最終回調函數
let i = 0;
let done = () => {
if (++i === this.taps.length) {
finalCallback();
}
};
// 依次執行事件處理函數
this.taps.forEach(task => task(...args, done));
}
}
然後對他的callAsync
函數進行抽象,將其抽象到程式碼工廠類裡面,使用字元串拼接的方式動態構造出來就行了,整體思路跟前面是一樣的。具體實現過程可以參考tapable
源碼:
總結
本文可運行示例程式碼已經上傳GitHub,大家拿下來一邊玩一邊看文章效果更佳://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Engineering/tapable-source-code。
下面再對本文的思路進行一個總結:
tapable
的各種Hook
其實都是基於發布訂閱模式。- 各個
Hook
自己獨立實現其實也沒有問題,但是因為都是發布訂閱模式,會有大量重複程式碼,所以tapable
進行了幾次抽象。 - 第一次抽象是提取一個
Hook
基類,這個基類實現了初始化和事件註冊等公共部分,至於每個Hook
的call
都不一樣,需要自己實現。 - 第二次抽象是每個
Hook
在實現自己的call
的時候,發現程式碼也有很多相似之處,所以提取了一個程式碼工廠,用來動態生成call
的函數體。 - 總體來說,
tapable
的程式碼並不難,但是因為有兩次抽象,整個程式碼架構顯得不那麼好讀,經過本文的梳理後,應該會好很多了。
文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。
歡迎關注我的公眾號進擊的大前端第一時間獲取高品質原創~
「前端進階知識」系列文章://juejin.im/post/5e3ffc85518825494e2772fd
「前端進階知識」系列文章源碼GitHub地址: //github.com/dennis-jiang/Front-End-Knowledges
參考資料
tapable
用法介紹://www.cnblogs.com/dennisj/p/14538668.html
tapable
源碼地址://github.com/webpack/tapable