上一篇文章我寫了tapable
的基本用法,我們知道他是一個增強版版的釋出訂閱模式
,本文想來學習下他的原始碼。tapable
的原始碼我讀了一下,發現他的抽象程度比較高,直接扎進去反而會讓人云裡霧裡的,所以本文會從最簡單的SyncHook
和釋出訂閱模式
入手,再一步一步抽象,慢慢變成他原始碼的樣子。
本文可執行示例程式碼已經上傳GitHub,大家拿下來一邊玩一邊看文章效果更佳:https://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,
});
}
}
官方對應的原始碼看這裡:https://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,大家拿下來一邊玩一邊看文章效果更佳:https://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小星星,你的支援是作者持續創作的動力。
歡迎關注我的公眾號進擊的大前端第一時間獲取高質量原創~
“前端進階知識”系列文章:https://juejin.im/post/5e3ffc85518825494e2772fd
“前端進階知識”系列文章原始碼GitHub地址: https://github.com/dennis-jiang/Front-End-Knowledges
參考資料
tapable
用法介紹:https://www.cnblogs.com/dennisj/p/14538668.html
tapable
原始碼地址:https://github.com/webpack/tapable