深入原始碼解析 tapable 實現原理

霧豹發表於2019-11-05

引子

如果你瞭解過 webpack,他們會告訴你,webpack 底層是基於 tapable

如果你好奇 tapable 是什麼,你可能會看到其他地方的部落格:『Tapble是webpack在打包過程中,控制打包在什麼階段呼叫Plugin的庫,是一個典型的觀察者模式的實現』。

可能,他們還會告訴你,Tapable的核心功能就是控制一系列註冊事件之間的執行流控制,對吧?

如果你瞭解的繼續深一些,可能還會看到下面的表格,見到如此多的鉤子:

名稱 鉤入的方式 作用
Hook taptapAsynctapPromise 鉤子基類
SyncHook tap 同步鉤子
SyncBailHook tap 同步鉤子,只要執行的 handler 有返回值,剩餘 handler 不執行
SyncLoopHook tap 同步鉤子,只要執行的 handler 有返回值,一直迴圈執行此 handler
SyncWaterfallHook tap 同步鉤子,上一個 handler 的返回值作為下一個 handler 的輸入值
AsyncParallelBailHook taptapAsynctapPromise 非同步鉤子,handler 並行觸發,但是跟 handler 內部呼叫回撥函式的邏輯有關
AsyncParallelHook taptapAsynctapPromise 非同步鉤子,handler 並行觸發
AsyncSeriesBailHook taptapAsynctapPromise 非同步鉤子,handler 序列觸發,但是跟 handler 內部呼叫回撥函式的邏輯有關
AsyncSeriesHook taptapAsynctapPromise 非同步鉤子,handler 序列觸發
AsyncSeriesLoopHook taptapAsynctapPromise 非同步鉤子,可以觸發 handler 迴圈呼叫
AsyncSeriesWaterfallHook taptapAsynctapPromise 非同步鉤子,上一個 handler 可以根據內部的回撥函式傳值給下一個 handler
Hook Helper 與 Tapable 類
名稱 作用
HookCodeFactory 編譯生成可執行 fn 的工廠類
HookMap Map 結構,儲存多個 Hook 例項
MultiHook 組合多個 Hook 例項
Tapable 向前相容老版本,例項必須擁有 hooks 屬性

那麼,問題來了,這些鉤子的內部是如何實現的?它們之間有什麼樣的繼承關係? 原始碼設計上有什麼優化地方?

本文接下來,將從 tapable 原始碼出發,解開 tapable 神祕的面紗。

Tapable 原始碼核心

先上一張大圖,涵蓋了 80% 的 tapable 核心流程

深入原始碼解析 tapable 實現原理

上圖中,我們看到, tapable 這個框架,最底層的有兩個類: 基礎類 Hook, 工廠類 HookCodeFactory

上面列表中 tapable 提供的鉤子,比如說 SyncHookSyncWaterHooks等,都是繼承自基礎類 Hook

圖中可見,這些鉤子,有兩個最關鍵的方法: tap方法、 call 方法。

這兩個方法是tapable 暴露給使用者的api, 簡單且好用。 webpack 是基於這兩個api 建構出來的一套複雜的工作流。

我們再來看工廠類 HookCodeFactory,它也衍生出SyncHookCodeFactorySyncWaterCodeFactory 等不同的工廠建構函式,例項化出來不同工廠例項factory

工廠例項factory的作用是,拼接生產出不同的 compile 函式,生產 compile 函式的過程,本質上就是拼接字串,沒有什麼魔法,下文中會介紹到。

這些不同的 compile 函式,最終會在 call() 方法被呼叫。

呼,剛才介紹了一大堆概念,希望沒有把讀者弄暈

我們首先看一下,call 方法和 tap 方法是如何使用的。

基本用法

下面是簡單的一個例子:

let hook = new SyncHook(['foo']);

hook.tap({
    name: 'dudu',
    before: '',
}, (params) => {
    console.log('11')
})

hook.tap({
    name: 'lala',
    before: 'dudu',
}, (params) => {
    console.log('22')
})

hook.tap({
 name: 'xixi',
 stage: -1
}, (params) => {
 console.log('22')
})

hook.call('tapable', 'learn')
複製程式碼

上面程式碼的輸出結果:

// 22
// 11
複製程式碼

我們使用 tap()方法用於註冊事件,使用 call() 來觸發所有回撥函式執行。

注意點:

  • 在例項化 SyncHook 時,我們傳入字串陣列。陣列的長度很重要,會影響你通過 call 方法呼叫 handler 時入參個數。就像例子所示,呼叫 call 方法傳入的是兩個引數,實際上 handler 只能接收到一個引數,因為你在new SyncHook 的時候傳入的字串陣列長度是1。

  • 通過 tap 方法去註冊 handler 時,第一個引數必須有,格式如下:

    interface Tap {
            name: string,  // 標記每個 handler,必須有,
            before: string | array, // 插入到指定的 handler 之前
            type: string,   // 型別:'sync', 'async', 'promise'
            fn: Function,   // handler
            stage: number,  // handler 順序的優先順序,預設為 0,越小的排在越前面執行
            context: boolean // 內部是否維護 context 物件,這樣在不同的 handler 就能共享這個物件
    }
    複製程式碼

    上面引數,我們重點關注 beforestage,這兩個引數影響了回撥函式的執行順序 。上文例子中, name'lala'handler 註冊的時候,是傳了一個物件,它的 before 屬性為 dudu,說明這個 handler 要插到 nameduduhandler 之前執行。但是又因為 namexixihandler 註冊的時候,stage 屬性為 -1,比其他的 handlerstage 要小,所以它會被移到最前面執行。

那麼,tapcall是如何實現的呢? 被呼叫的時候,背後發生了什麼?

我們接下來,深入到原始碼分析 tapable 機制。

下文中分析的原始碼是 tapable v1.1.3 版本

tap 方法的實現

上文中,我們在註冊事件時候,用了 hook.tap() 方法。

tap 方法核心是,把註冊的回撥函式,維護在這個鉤子的一個陣列中。

tap 方法實現在哪裡呢?

程式碼裡面,hookSyncHook 的例項,SyncHook又繼承了 Hook 基類,在 Hook 基類中,具體程式碼如下:

class Hook {
    tap(options, fn) {
        options = this._runRegisterInterceptors(options);
        this._insert(options);
    }
}
複製程式碼

我們發現,tap 方法最終呼叫了_insert方法,

_insert(item) {
        this._resetCompilation();

        let before;
        if (typeof item.before === "string") before = new Set([item.before]);
        else if (Array.isArray(item.before)) {
            before = new Set(item.before);
        }
        // 預設 stage是0
        // stage 值越大,
        let stage = 0;
        if (typeof item.stage === "number") stage = item.stage;
        let i = this.taps.length;
        while (i > 0) {
            i--;
            const x = this.taps[i];
            this.taps[i + 1] = x;
            const xStage = x.stage || 0;
            if (before) {
                if (before.has(x.name)) {
                    before.delete(x.name);
                    continue;
                }
                if (before.size > 0) {
                    continue;
                }
            }
            if (xStage > stage) {
                continue;
            }
            i++;
            break;
        }
        this.taps[i] = item;
    }
複製程式碼

把註冊的方法,都 push 到一個 taps 陣列上面。這裡對 beforestage 做了處理,使得 push 到 taps 陣列的順序不同,從而決定了 回撥函式的執行順序不同。

call 方法的實現

SyncHook.js 中,我們沒有找到 call 方法的定義。再去 Hook 基類上找,發現有這樣一句, call 方法 是 _call 方法

this.call = this._call;
複製程式碼
class Hook {
    construcotr {
        // 這裡發現,call 方法就是 this._call 方法
        this.call = this._call;
    }
    compile(options) {
        throw new Error("Abstract: should be overriden");
    }

    _createCall(type) {
        return this.compile({
            taps: this.taps,
            interceptors: this.interceptors,
            args: this._args,
            type: type
        });
    }
}
複製程式碼

那麼, _call 方法是在哪裡定義的呢?看下面, this._callcreateCompileDelegate("call", "sync")的返回值。

Object.defineProperties(Hook.prototype, {
    // this._call 是 createCompileDelegate("call", "sync") 的值, 為函式 lazyCompileHook
    _call: {
        value: createCompileDelegate("call", "sync"),
        configurable: true,
        writable: true
    },
    _promise: {
        value: createCompileDelegate("promise", "promise"),
        configurable: true,
        writable: true
    },
    _callAsync: {
        value: createCompileDelegate("callAsync", "async"),
        configurable: true,
        writable: true
    }
});
複製程式碼

接著往下看 createCompileDelegate 方法裡面做了什麼?

// 下面的createCompileDelegate 方法 返回了一個新的方法,
// 引數 name 是閉包儲存的字串 'call'
function createCompileDelegate(name, type) {
    return function lazyCompileHook(...args) {
        // 實際上
        // this.call = this._creteCall(type)
        // return this.call()
        this[name] = this._createCall(type);
        return this[name](...args);
    };
}
複製程式碼

上面的程式碼,createCompileDelegate 先呼叫 this._createCall() 方法,把返回值賦值給 this[name]

this._createCall() 裡面本質是呼叫了this.compiler 方法,但是基類Hook上的compiler() 方法是一個空實現,順著這條線索找下來,這是一條死衚衕。

this.compiler 方法,真正是定義在衍生類 SyncHook上,也就是在 SyncHook.js 中,SyncHook 類重新定義了 compiler 方法來覆蓋:

const factory = new SyncHookCodeFactory();
class SyncHook extends Hook {
    compile(options) {
        factory.setup(this, options);
        return factory.create(options);
    }
}
複製程式碼

這裡的 factory ,就是本文開頭提到的工廠例項。factory.create 的產物如下:

ƒ anonymous() {
  "use strict";
  var _context;
  var _x = this._x;
  var _fn0 = _x[0];
  _fn0();  
  var _fn1 = _x[1];
  _fn1();
}
複製程式碼

this._x 是一個陣列,裡面存放的就是我們註冊的 taps 方法。上面程式碼的核心就是,遍歷我們註冊的 taps 方法,並去執行。

factory.create 的核心是,根據傳入的type 型別,拼接對應的字串,程式碼如下:

fn = new Function(
    this.args(),
    '"use strict";\n' +
    this.header() +
    this.content({
        onError: err => `throw ${err};\n`,
        onResult: result => `return ${result};\n`,
        resultReturns: true,
        onDone: () => "",
        rethrowIfPossible: true
    })
);
複製程式碼

上面程式碼中, content 方法是定義在 SyncHook 的衍生類上的,

class SyncHookCodeFactory extends HookCodeFactory {
    // 區分不同的型別的 工程
    // content 方法用於拼接字串
    // HookCodeFactory 裡面會呼叫 this.content(), 訪問到的是這裡的 content
    content({ onError, onDone, rethrowIfPossible }) {
        return this.callTapsSeries({
            onError: (i, err) => onError(err),
            onDone,
            rethrowIfPossible
        });
    }
}
複製程式碼

到這裡為止一目瞭然,我們可以看到我們的註冊回撥是怎樣在this.call方法中一步步執行的。

在這裡的優化, tapable 用到了《javascript 高階程式設計》中的『惰性函式』,快取下來 this.__createCall call,從而提升效能

惰性函式

什麼是惰性函式? 惰性函式有什麼作用?

比如說,我們定義一個函式,我們需要在這個函式裡面判斷不同的瀏覽器環境,走不同的邏輯。

function addEvent (type, element, fun) {
 if (element.addEventListener) {
 element.addEventListener(type, fun, false);
 } else if(element.attachEvent){
 element.attachEvent('on' + type, fun);
 } else{
 element['on' + type] = fun;
 }
}
複製程式碼

上面 addEvent 方法,每執行一遍,都需要判斷一次,有沒有什麼優化方法呢? 答案就是惰性函式。

function addEvent (type, element, fun) {
	if (element.addEventListener) {
		addEvent = function (type, element, fun) {
			element.addEventListener(type, fun, false);
		}
	} else if(element.attachEvent){
		addEvent = function (type, element, fun) {
			element.attachEvent('on' + type, fun);
		}
	} else{
		addEvent = function (type, element, fun) {
			element['on' + type] = fun;
		}
	}
	return addEvent(type, element, fun);
}
複製程式碼

上面的惰性函式,只會在第一次執行的時候判斷環境,之後每次執行,其實是執行被重新賦值的 addEvent 方法,判斷的結果被快取了下來。

tapable 裡用到惰性函式的地方,大概可以簡化成如下:

this._call = function(){
    this.call = this._creteCall(type)
    return this.call()
}
複製程式碼

this._call 只有在第一次執行的時候,才會拼接生產出字串方法,之後再執行時,就會執行被快取下來的字串方法,從而優化了效能。

factory工廠的產物

Tapable有一系列Hook方法,但是這麼多的Hook方法都是無非是為了控制註冊事件的執行順序以及異常處理

最簡單的SyncHook 的factory 的工廠產物,前面已經講過,我們從SyncBailHook開始看。

SyncBailHook

這類鉤子的特點是,判斷 handler 的返回值,是否===undefined, 如果是 undefined , 則執行,如果有返回值,則 return 返回值

// fn, 呼叫 call 時,實際執行的程式碼
function anonymous(/*``*/) {
    "use strict";
    var _context;
    var _x = this._x;
    var _fn0 = _x[0];
    var _result0 = _fn0();
    if (_result0 !== undefined) {
        return _result0;
    } else {
        var _fn1 = _x[1];
        var _result1 = _fn1();
        if (_result1 !== undefined) {
            return _result1;
        } else {
        }
    }
}
複製程式碼

通過列印fn,我們可以輕易的看出,SyncBailHook提供了中止註冊函式執行的機制,只要在某個註冊回撥中返回一個非undefined的值,執行就會中止。

SyncWaterfallHook

function anonymous(arg1) {
    "use strict";
    var _context;
    var _x = this._x;
    var _fn0 = _x[0];
    var _result0 = _fn0(arg1);
    if (_result0 !== undefined) {
        arg1 = _result0;
    }
    var _fn1 = _x[1];
    var _result1 = _fn1(arg1);
    if (_result1 !== undefined) {
        arg1 = _result1;
    }
    return arg1;
}
複製程式碼

可以看出SyncWaterfallHook就是將上一個事件註冊回撥的返回值作為下一個註冊函式的引數,這就要求在new SyncWaterfallHook(['arg1']);需要且只能傳入一個形參。

SyncLoopHook

// 列印fn
function anonymous(arg1) {
    "use strict";
    var _context;
    var _x = this._x;
    var _loop;
    do {
        _loop = false;
        var _fn0 = _x[0];
        var _result0 = _fn0(arg1);
        if (_result0 !== undefined) {
            _loop = true;
        } else {
            var _fn1 = _x[1];
            var _result1 = _fn1(arg1);
            if (_result1 !== undefined) {
                _loop = true;
            } else {
                if (!_loop) {
                }
            }
        }
    } while (_loop);
}
複製程式碼

SyncLoopHook只有當上一個註冊事件函式返回undefined的時候才會執行下一個註冊函式,否則就不斷重複呼叫。

AsyncSeriesHook

Series有順序的意思,這個Hook用於按順序執行非同步函式。

function anonymous(_callback) {
    "use strict";
    var _context;
    var _x = this._x;
    var _fn0 = _x[0];
    _fn0(_err0 => {
        if (_err0) {
            _callback(_err0);
        } else {
            var _fn1 = _x[1];
            _fn1(_err1 => {
                if (_err1) {
                    _callback(_err1);
                } else {
                    _callback();
                }
            });
        }
    });
}
複製程式碼

從列印結果可以發現,兩個事件之前是序列的,並且next中可以傳入err引數,當傳入err,直接中斷非同步,並且將err傳入我們在call方法傳入的完成回撥函式中。

AsyncParallelHook

asyncParallelHook 是非同步併發的鉤子,適用場景:一些情況下,我們去併發的請求不相關的介面,比如說請求使用者的頭像介面、地址介面。

factory.create 的產物是下面的字串

function anonymous(_callback) {
    "use strict";
    var _context;
    var _x = this._x;
    do {
        // _counter 是 註冊事件的數量
        var _counter = 2;
        var _done = () => {
            _callback();
        };

        if (_counter <= 0) break;

        var _fn0 = _x[0];

        _fn0(_err0 => {
            // 這個函式是 next 函式
            // 呼叫這個函式的時間不能確定,有可能已經執行了接下來的幾個註冊函式
            if (_err0) {
                // 如果還沒執行所有註冊函式,終止
                if (_counter > 0) {
                    _callback(_err0);
                    _counter = 0;
                }
            } else {
                // 檢查 _counter 的值,如果是 0 的話,則結束
                // 同樣,由於函式實際呼叫時間無法確定,需要檢查是否已經執行完畢,
                if (--_counter === 0) {
                    _done()
                };
            }
        });

        // 執行下一個註冊回撥之前,檢查_counter是否被重置等,如果重置說明某些地方返回err,直接終止。
        if (_counter <= 0) break;

        var _fn1 = _x[1];

        _fn1(_err1 => {
            if (_err1) {
                if (_counter > 0) {
                    _callback(_err1);
                    _counter = 0;
                }
            } else {
                if (--_counter === 0) _done();
            }
        });

    } while (false);
}
複製程式碼

從列印結果看出Event2的呼叫在AsyncCall in Event1之前,說明非同步事件是併發的。

位元組跳動大大大大量量量量招人了

位元組跳動(杭州|北京|上海)大量招人,福利超級棒,薪資水平秒殺 BAT,上班不打卡、每天下午茶、免費零食無限供應、免費三餐(我念下選單,大閘蟹鮑魚扇貝海鮮烤魚片黑椒牛柳咖哩牛肉麻辣小龍蝦)、免費健身房、入職配touch bar15寸頂配全新mbp、每月還有租房房補。 這次真的機會多多,年後研發人數要擴招n倍,技術氛圍好,大牛多,加班少,還猶豫什麼?快發簡歷到下方郵箱,就現在!

僅僅是一小部分的jd連結如下, 更多的歡迎加微信~

前端jd: job.toutiao.com/s/bJM4Anjob…

後端jd: job.toutiao.com/s/bJjjTsjob…

測試jd: job.toutiao.com/s/bJFv9bjob…

產品jd: job.toutiao.com/s/bJBgV8job…

前端實習生: job.toutiao.com/s/bJ6NjAjob…

後端實習生: job.toutiao.com/s/bJrjrkjob…

持續招聘大量前端、服務端、客戶端、測試、產品,實習社招都闊以

簡歷發 dujuncheng@bytedance.com,建議加微信 dujuncheng1,可以聊天聊地聊人生,請註明來自掘金以及要投遞哪裡的崗位

深入原始碼解析 tapable 實現原理

相關文章