webpack核心模塊tapable源碼解析

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

這段代碼非常簡單,是一個最基礎的發佈訂閱模式,使用方法跟上面是一樣的,將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,
      });
	}
}

官方對應的源碼看這裡://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,大家拿下來一邊玩一邊看文章效果更佳://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小星星,你的支持是作者持續創作的動力。

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

「前端進階知識」系列文章://juejin.im/post/5e3ffc85518825494e2772fd

「前端進階知識」系列文章源碼GitHub地址: //github.com/dennis-jiang/Front-End-Knowledges

QR1270

參考資料

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

tapable源碼地址://github.com/webpack/tapable