編寫自定義webpack插件從理解Tapable開始

在上篇文章《Webpack源碼解讀:理清編譯主流程》中,大致瞭解了webpack的編譯主流程,其中咱們跳過了一個重要內容Tapable。webpack 插件向第三方開發者提供了鉤入webpack引擎中的編譯流程的方式,而Tapable是插件的最核心基礎。javascript

本文首先分析Tapable的基本原理,在此基礎上編寫一個自定義插件。前端

Tapable

若是你閱讀了 webpack 的源碼,必定不會對 tapable 不陌生。絕不誇張的說, tapable是webpack控制事件流的超級管家。java

Tapable的核心功能就是依據不一樣的鉤子將註冊的事件在被觸發時按序執行。它是典型的」發佈訂閱模式「。Tapable提供了兩大類共九種鉤子類型,詳細類型以下思惟導圖:webpack

除了SyncAsync分類外,你應該也注意到了BailWaterfallLoop等關鍵詞,它們指定了註冊的事件回調handler觸發的順序。web

  • Basic hook:按照事件註冊順序,依次執行handlerhandler之間互不干擾;
  • Bail hook:按照事件註冊順序,依次執行handler,若其中任一handler返回值不爲undefined,則剩餘的handler均不會執行;
  • Waterfall hook:按照事件註冊順序,依次執行handler,前一個handler的返回值將做爲下一個handler的入參;
  • Loop hook:按照事件註冊順序,依次執行handler,若任一handler的返回值不爲undefined,則該事件鏈再次從頭開始執行,直到全部handler均返回undefined

基本用法

咱們以SyncHook爲例:api

const {
    SyncHook
} = require("../lib/index");
let sh = new SyncHook(["name"])
sh.tap('A', () => {
    console.log('A:', name)
})
sh.tap({
    name: 'B',
    before: 'A'  // 影響該回調的執行順序, 回調B比回調A先執行
}, () => {
    console.log('B:', name)
})
sh.call('Tapable')

// output:
B:Tapable
A:Tapable
複製代碼

這裏咱們定義了一個同步鉤子sh,注意到它的構造函數接收一個數組類型入參["name"],表明了它的註冊事件將接收到的參數列表,以此來告知調用方在編寫回調handler時將會接收到哪些參數。示例中,每一個事件回調都會接收name的參數。數組

經過鉤子的tap方法能夠註冊回調handler,調用call方法來觸發鉤子,依次執行註冊的回調函數。promise

在註冊回調B時,傳入了before參數,before: 'A',它直接影響了該回調的執行順序,即回調B會在回調A以前觸發。此外,你也能夠指定回調的stage來給回調排序。服務器

源碼解讀

Hook基類

從上面的例子中,咱們看到鉤子上有兩個對外的接口:tapcalltap負責註冊事件回調,call負責觸發事件。閉包

雖然Tapable提供多個類型的鉤子,但全部鉤子都是繼承於一個基類Hook,且它們的初始化過程都是類似的。這裏咱們仍以SyncHook爲例:

// 工廠類的做用是生成不一樣的compile方法,compile本質根據事件註冊順序返回控制流代碼的字符串。最後由`new Function`生成真實函數賦值到各個鉤子對象上。
class SyncHookCodeFactory extends HookCodeFactory {
    content({ onError, onDone, rethrowIfPossible }) {
        return this.callTapsSeries({
            onError: (i, err) => onError(err),
            onDone,
            rethrowIfPossible
        });
    }
}
const factory = new SyncHookCodeFactory();
// 覆蓋Hook基類中的tapAsync方法,由於`Sync`同步鉤子禁止以tapAsync的方式調用
const TAP_ASYNC = () => {
    throw new Error("tapAsync is not supported on a SyncHook");
};
// 覆蓋Hook基類中的tapPromise方法,由於`Sync`同步鉤子禁止以tapPromise的方式調用
const TAP_PROMISE = () => {
    throw new Error("tapPromise is not supported on a SyncHook");
};
// compile是每一個類型hook都須要實現的,須要調用各自的工廠函數來生成鉤子的call方法。
const COMPILE = function(options) {
    factory.setup(this, options);
    return factory.create(options);
};
function SyncHook(args = [], name = undefined) {
    const hook = new Hook(args, name);  // 實例化父類Hook,並修飾hook
    hook.constructor = SyncHook;
    hook.tapAsync = TAP_ASYNC;
    hook.tapPromise = TAP_PROMISE;
    hook.compile = COMPILE;
    return hook;
}
複製代碼

tap方法

當執行tap方法註冊回調時,又如何執行的呢? 在Hook基類中,關於tap的代碼以下:

class Hook{
    constructor(args = [], name = undefined){
        this.taps = []
    }
    tap(options, fn) {
        this._tap("sync", options, fn);
    }
    _tap(type, options, fn) {
        // 這裏省略入參預處理部分代碼
        this._insert(options);
    }
}
複製代碼

咱們看到最終會執行到this._insert方法中,而this._insert的工做就是將回調fn插入到內部的taps數組中,並依據beforestage參數來調整taps數組的排序。具體代碼以下:

_insert(item) {
	// 每次註冊事件時,將call重置,須要從新編譯生成call方法
  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);
  }
  let stage = 0;
  if (typeof item.stage === "number") {
    stage = item.stage;
  }
  let i = this.taps.length;
  // while循環體中,依據before和stage調整回調順序
  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;  // taps暫存全部註冊的回調函數
}
複製代碼

不管是調用taptapAsync或者tapPromise,都會將回調handler暫存至taps數組中,清空以前已經生成的call方法(this.call = this._call)。

call方法

註冊好事件回調後,接下來該如何觸發事件了。一樣的,call也存在三種調用方式:callcallAsyncpromise,分別對應三種tap註冊方式。觸發同步Sync鉤子事件時直接使用call方法,觸發異步Async鉤子事件時須要使用callAsyncpromise方法,繼續看看在Hook基類中call是如何定義的:

const CALL_DELEGATE = function(...args) {
    // 在第一次執行call時,會依據鉤子類型和回調數組生成真實執行的函數fn。並從新賦值給this.call
    // 在第二次執行call時,直接運行fn,再也不重複調用_createCall
    this.call = this._createCall("sync");
    return this.call(...args);
};
class Hoook {
    constructor(args = [], name = undefined){
        this.call = CALL_DELEGATE
        this._call = CALL_DELEGATE
    }
	
    compile(options) {
        throw new Error("Abstract: should be overridden");
    }
	
    _createCall(type) {
        // 進入該函數體意味是第一次執行call或call被重置,此時須要調用compile去生成call方法
        return this.compile({
            taps: this.taps,
            interceptors: this.interceptors,
            args: this._args,
            type: type
        });
    }
}
複製代碼

_createCall會調用this.compile方法來編譯生成真實調用的call方法,但在Hook基類中compile是空實現。它要求繼承Hook父類的子類必須實現這個方法(即抽象方法)。回到SyncHook中查看compiler的實現:

const HookCodeFactory = require("./HookCodeFactory");
class SyncHookCodeFactory extends HookCodeFactory {
    content({ onError, onDone, rethrowIfPossible }) {
        return this.callTapsSeries({
            onError: (i, err) => onError(err),
            onDone,
            rethrowIfPossible
        });
    }
}
const factory = new SyncHookCodeFactory();
const COMPILE = function(options) {
    // 調用工廠類中的setup和create方法拼接字符串,以後實例化 new Function 獲得函數fn
    factory.setup(this, options);
    return factory.create(options);
};
function SyncHook(args = [], name = undefined) {
    const hook = new Hook(args, name);
    hook.compile = COMPILE;
    return hook;
}
複製代碼

SyncHook類中compile會調用工廠類HookCodeFactorycreate方法,這裏對create的內部暫時不表,factory.create返回編譯好的function,最終賦值給this.call方法。

這裏Hook使用了一個技巧——惰性函數,當第一次指定this.call方法時,此時會運行到CALL_DELEGATE函數體中,CALL_DELEGATE會從新賦值this.call,這樣在下一次執行時,直接執行賦值後的this.call方法,而不用再次進行生成call的過程,從而優化了性能。

惰性函數有兩個主要優勢:

  1. 效率高:惰性函數僅在第一次運行時執行計算邏輯,以後函數再次運行時都返回第一次執行的結果,節約了不少執行時間;
  2. 延遲執行:在某些場景下,須要判斷一些環境信息,一旦肯定後就再也不須要從新判斷。能夠理解爲嗅探程序。好比能夠用下面的方式使用惰性載入重寫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);
}
複製代碼

HookCodeFactory工廠類

在上節提到,factory.create返回編譯好的function賦值給call方法。 每一個類型的鉤子都會構造一個工廠類負責拼接調度回調handler時序的函數字符串,經過new Function()的實例化方式來生成執行函數。

延伸:new Function

在 JavaScript 中有三種函數定義的方式:

// 定義1. 函數聲明
function add(a, b){
    return a + b
}

// 定義2. 函數表達式
const add = function(a, b){
    return a + b
}

// 定義3. new Function
const add = new Function('a', 'b', 'return a + b')
複製代碼

前兩種函數定義方式是」靜態「的,之所謂是」靜態「的是函數定義之時,它的功能就肯定下來了。而第三種函數定義方式則是」動態「,所謂」動態「是函數功能能夠在程序運行過程當中變化。

定義1 與 定義2也是有區別的哦,最關鍵的區別在於 JavaScript 函數和變量聲明的「提早」(hoist)行爲。這裏就不作展開了。

好比,我須要動態構造一個 n 個數相加的函數:

let nums = [1,2,3,4]
let len = nums.length
let params = Array(len).fill('x').map((item, idx)=>{
    return '' + item + idx
})
const add = new Function(params.join(','), ` return ${params.join('+')}; `)
console.log(add.toString())
console.log(add.apply(null, nums))
複製代碼

打印函數字符串add.toString(),能夠獲得:

function anonymous(x0,x1,x2,x3) {
    return x0+x1+x2+x3;
}
複製代碼

函數add的函數入參和函數體會根據nums的長度而動態生成,這樣你能夠根據實際狀況來控制傳入參數的個數,而且函數也只處理這幾個入參。

new Function的函數聲明方式較前二者首先性能上會有點吃虧,每次實例化都會消耗性能。其次,new Function聲明的函數不支持」閉包「,對好比下代碼:

function bar(){
    let name = 'bar'
    let func = function(){return name}
    return func
}
bar()()  // "bar", func中name讀取到bar詞法做用域中的name變量

function foo(){
    let name = 'foo'
    let func = new Function('return name')
    return func
}
foo()()  // ReferenceError: name is not defined
複製代碼

究其緣由是由於new Function的詞法做用域指向的是全局做用域。

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
    })
);
複製代碼

咱們以SyncHook爲例:

let sh = new SyncHook(["name"]);
sh.tap("A", (name) => {
    console.log("A");
});
sh.tap('B', (name) => {
    console.log("B");
});
sh.tap("C", (name) => {
    console.log("C");
});
sh.call();
複製代碼

能夠獲得以下的函數字符串:

function anonymous(name) {
 "use strict";
    var _context;
    var _x = this._x;
    var _fn0 = _x[0];
    _fn0(name);
    var _fn1 = _x[1];
    _fn1(name);
    var _fn2 = _x[2];
    _fn2(name);
}
複製代碼

其中_x則指向this.taps數組,按序訪問到每一個handler,並執行handler

更多Hook示例,能夠查看RunKit

自定義 webpack plugin

一個插件的自我修養

一個合乎規範的插件應知足如下條件:

  1. 它是一個具名的函數或者JS類;
  2. 在原型鏈上指定apply方法;
  3. 指定一個明確的事件鉤子並註冊回調;
  4. 處理 webpack 內部實例的特定數據(CompilerCompilation);
  5. 完成功能後調用webpack傳入的回調等;

其中條件四、5並非必需的,只有功能複雜的插件會同時知足以上五個條件。

在文章《Webpack源碼解讀:理清編譯主流程》中咱們知道 webpack 中有兩個很是重要的內部對象,compilercompilation對象,在二者的hooks上都事先定義好了不一樣類型的鉤子,這些鉤子會在編譯的整個過程當中在相應時間點時觸發。而自定義插件就是「鉤住」這個時間點,並執行相關邏輯。

compiler鉤子列表 compilation鉤子列表

自動上傳資源的插件

使用webpack打包資源後都會在本地項目中生成一個dist文件夾用於存放打包後的靜態資源,此時能夠寫一個自動上傳資源文件到CDN的webpack插件,每次打包成功後及時的上傳至CDN。

當你明確插件的功能時,你須要在合適的鉤子上去註冊你的回調。在本例中,咱們須要將已經打包輸出後的靜態文件上傳至CDN,經過在compiler鉤子列表中查詢知道compiler.hooks.afterEmit是符合要求的鉤子,它是一個AsyncSeriesHook類型。

按照五個基本條件來實現這個插件:

const assert = require("assert");
const fs = require("fs");
const glob = require("util").promisify(require("glob"));

// 1. 它是一個具名的函數或者JS類
class AssetUploadPlugin {
    constructor(options) {
        // 這裏能夠校驗傳入的參數是否合法等初始化操做
        assert(
            options,
            "check options ..."
        );
    }
    // 2. 在原型鏈上指定`apply`方法
    // apply方法接收 webpack compiler 對象入參
    apply(compiler) {
        // 3. 指定一個明確的事件鉤子並註冊回調
        compiler.hooks.afterEmit.tapAsync(  // 由於afterEmit是AsyncSeriesHook類型的鉤子,須要使用tapAsync或tapPromise鉤入回調
            "AssetUploadPlugin",
            (compilation, callback) => {
                const {
                    outputOptions: { path: outputPath }
                } = compilation;  // 4. 處理 webpack 內部實例的特定數據
                uploadDir(
                    outputPath,
                    this.options.ignore ? { ignore: this.options.ignore } : null
                )
                .then(() => {
                    callback();  // 5. 完成功能後調用webpack傳入的回調等;
                })
                .catch(err => {
                    callback(err);
                });
            });
    }
};
// uploadDir就是這個插件的功能性描述
function uploadDir(dir, options) {
    if (!dir) {
        throw new Error("dir is required for uploadDir");
    }
    if (!fs.existsSync(dir)) {
        throw new Error(`dir ${dir} is not exist`);
    }
    return fs
        .statAsync(dir)
        .then(stat => {
            if (!stat.isDirectory()) {
                throw new Error(`dir ${dir} is not directory`);
            }
        })
        .then(() => {
            return glob(
                "**/*",
                Object.assign(
                    {
                        cwd: dir,
                        dot: false,
                        nodir: true
                    },
                    options
                )
            );
        })
        .then(files => {
            if (!files || !files.length) {
                return "未找到須要上傳的文件";
            }
            // TODO: 這裏將資源上傳至你的靜態雲服務器中,如京東雲、騰訊雲等
            // ...
        });
}

module.exports = AssetUploadPlugin
複製代碼

webpack.config.js中能夠引入這個插件並實例化:

const AssetUploadPlugin = require('./AssetUploadPlugin')
const config = {
    //...
    plugins: [
        new AssetUploadPlugin({
            ignore: []
        })
    ]
}
複製代碼

總結

webpack的靈活配置得益於 Tapable 提供強大的鉤子體系,讓編譯的每一個過程均可以「鉤入」,如虎添翼。正所謂「三人成衆」,將一個系統作到插件化時,它的可擴展性將大大提升。 Tapable也能夠應用到具體的業務場景中,好比流程監控日誌記錄埋點上報等,凡是須要「鉤入」到具體流程中時,Tapable就有它的應用場景。

最後

碼字不易,若是:

  • 這篇文章對你有用,請不要吝嗇你的小手爲我點贊;
  • 有不懂或者不正確的地方,請評論,我會積極回覆或勘誤;
  • 指望與我一同持續學習前端技術知識,請關注我吧;
  • 轉載請註明出處;

您的支持與關注,是我持續創做的最大動力!

參考

相關文章
相關標籤/搜索