webpack核心模塊tapable源碼解析

上一篇文章我寫了tapable的基本用法,咱們知道他是一個加強版版的發佈訂閱模式,本文想來學習下他的源碼。tapable的源碼我讀了一下,發現他的抽象程度比較高,直接扎進去反而會讓人云裏霧裏的,因此本文會從最簡單的SyncHook發佈訂閱模式入手,再一步一步抽象,慢慢變成他源碼的樣子。javascript

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

SyncHook的基本實現

上一篇文章已經講過SyncHook的用法了,我這裏就再也不展開了,他使用的例子就是這樣子:java

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);

其實這種用法就是一個最基本的發佈訂閱模式,我以前講發佈訂閱模式的文章講過,咱們能夠仿照那個很快實現一個SyncHookwebpack

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導出改成使用咱們本身的:git

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

運行效果是同樣的:github

image-20210323153234354

注意: 咱們構造函數裏面傳入的args並無用上,tapable主要是用它來動態生成call的函數體的,在後面講代碼工廠的時候會看到。web

SyncBailHook的基本實現

再來一個SyncBailHook的基本實現吧,SyncBailHook的做用是當前一個回調返回不爲undefined的值的時候,阻止後面的回調執行。基本使用是這樣的:segmentfault

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://github.com/dennis-jiang/Front-End-Knowledges

1270_300二維碼_2.png

參考資料

tapable用法介紹:http://www.javashuo.com/article/p-rgjasasi-vg.html

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

相關文章
相關標籤/搜索