webpack核心模組tapable原始碼解析

_蔣鵬飛發表於2021-04-01

上一篇文章我寫了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);
        }
    }
}

這段程式碼非常簡單,是一個最基礎的釋出訂閱模式,使用方法跟上面是一樣的,將SyncHooktapable匯出改為使用我們自己的:

// const { SyncHook } = require("tapable");
const { SyncHook } = require("./SyncHook");

執行效果是一樣的:

image-20210323153234354

注意: 我們建構函式裡面傳入的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"); 

執行效果是一樣的:

image-20210323155857678

抽象重複程式碼

現在我們只實現了SyncHookSyncBailHook兩個Hook而已,上一篇講用法的文章裡面總共有9個Hook,如果每個Hook都像前面這樣實現也是可以的。但是我們再仔細看下SyncHookSyncBailHook兩個類的程式碼,發現他們除了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.callCALL_DELEGATE這個函式,這是有原因的,最主要的原因是確保this的正確指向。思考一下假如我們不用CALL_DELEGATE,而是直接this.call = this._createCall()會發生什麼?我們來分析下這個執行流程:

  1. 使用者使用時,肯定是使用new SyncHook(),這時候會執行const hook = new Hook(args);
  2. new Hook(args)會去執行Hook的建構函式,也就是會執行this.call = this._createCall()
  3. 這時候的this指向的是基類Hook的例項,this._createCall()會呼叫基類的this.compile()
  4. 由於基類的complie函式是一個抽象介面,直接呼叫會報錯Abstract: should be overridden

那我們採用this.call = CALL_DELEGATE是怎麼解決這個問題的呢

  1. 採用this.call = CALL_DELEGATE後,基類Hook上的call就只是被賦值為一個代理函式而已,這個函式不會立馬呼叫。
  2. 使用者使用時,同樣是new SyncHook(),裡面會執行Hook的建構函式
  3. Hook建構函式會給this.call賦值為CALL_DELEGATE,但是不會立即執行。
  4. new SyncHook()繼續執行,新建的例項上的方法hook.complie被覆寫為正確方法。
  5. 當使用者呼叫hook.call的時候才會真正執行this._createCall(),這裡面會去呼叫this.complie()
  6. 這時候呼叫的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;

抽象程式碼工廠

上面我們通過對SyncHookSyncBailHook的抽象提煉出了一個基類Hook,減少了重複程式碼。基於這種結構子類需要實現的就是complie方法,但是如果我們將SyncHookSyncBailHookcomplie方法拿出來對比下:

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為了解決這些重複程式碼,又進行了一次抽象,也就是程式碼工廠HookCodeFactoryHookCodeFactory的作用就是用來生成complie返回的call函式體,而HookCodeFactory在實現時也採用了Hook類似的思路,也是先實現了一個基類HookCodeFactory,然後不同的Hook再繼承這個類來實現自己的程式碼工廠,比如SyncHookCodeFactory

建立函式的方法

在繼續深入程式碼工廠前,我們先來回顧下JS裡面建立函式的方法。一般我們會有這幾種方法:

  1. 函式申明

    function add(a, b) {
      return a + b;
    }
    
  2. 函式表示式

    const add = function(a, b) {
      return a + b;
    }
    

但是除了這兩種方法外,還有種不常用的方法:使用Function建構函式。比如上面這個函式使用建構函式建立就是這樣的:

const add = new Function('a', 'b', 'return a + b;');

上面的呼叫形式裡,最後一個引數是函式的函式體,前面的引數都是函式的形參,最終生成的函式跟用函式表示式的效果是一樣的,可以這樣呼叫:

add(1, 2);    // 結果是3

注意:上面的ab形參放在一起用逗號隔開也是可以的:

const add = new Function('a, b', 'return a + b;');    // 這樣跟上面的效果是一樣的

當然函式並不是一定要有引數,沒有引數的函式也可以這樣建立:

const sayHi = new Function('alert("Hello")');

sayHi(); // Hello

這樣建立函式和前面的函式申明和函式表示式有什麼區別呢?使用Function建構函式來建立函式最大的一個特徵就是,函式體是一個字串,也就是說我們可以動態生成這個字串,從而動態生成函式體。因為SyncHookSyncBailHookcall函式很像,我們可以像拼一個字串那樣拼出他們的函式體,為了更簡單的拼湊,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吧,目前他只能生成SyncHookcall函式體:

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程式碼,我們需要對他進行一些改進,讓他能夠也生成SyncBailHookcall函式體。你可以拉回前面再仔細觀察下這兩個最終生成程式碼的區別:

  1. SyncBailHook需要對每次執行的result進行處理,如果不為undefined就返回
  2. SyncBailHook生成的程式碼其實是if...else巢狀的,我們生成的時候可以考慮使用一個遞迴函式

為了讓SyncHookSyncBailHook的子類程式碼工廠能夠傳入差異化的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來生成最終的函式體。所以這裡這幾個函式的呼叫關係其實是這樣的:

image-20210401111739814

那這樣設計的目的是什麼呢為了讓子類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的原始碼架構和基本實現我們已經弄清楚了,但是本文只用了SyncHookSyncBailHook做例子,其他的,比如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原始碼:

Hook類原始碼

SyncHook類原始碼

SyncBailHook類原始碼

HookCodeFactory類原始碼

總結

本文可執行示例程式碼已經上傳GitHub,大家拿下來一邊玩一邊看文章效果更佳:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Engineering/tapable-source-code

下面再對本文的思路進行一個總結:

  1. tapable的各種Hook其實都是基於釋出訂閱模式。
  2. 各個Hook自己獨立實現其實也沒有問題,但是因為都是釋出訂閱模式,會有大量重複程式碼,所以tapable進行了幾次抽象。
  3. 第一次抽象是提取一個Hook基類,這個基類實現了初始化和事件註冊等公共部分,至於每個Hookcall都不一樣,需要自己實現。
  4. 第二次抽象是每個Hook在實現自己的call的時候,發現程式碼也有很多相似之處,所以提取了一個程式碼工廠,用來動態生成call的函式體。
  5. 總體來說,tapable的程式碼並不難,但是因為有兩次抽象,整個程式碼架構顯得不那麼好讀,經過本文的梳理後,應該會好很多了。

文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。

歡迎關注我的公眾號進擊的大前端第一時間獲取高質量原創~

“前端進階知識”系列文章:https://juejin.im/post/5e3ffc85518825494e2772fd

“前端進階知識”系列文章原始碼GitHub地址: https://github.com/dennis-jiang/Front-End-Knowledges

QR1270

參考資料

tapable用法介紹:https://www.cnblogs.com/dennisj/p/14538668.html

tapable原始碼地址:https://github.com/webpack/tapable

相關文章