webpack 源碼從零開始 - tapable模型

前文

最近在看webpack的源碼,發現有個比較頭疼的點是:代碼看起來很是跳躍,每每看不到幾行就插入一段新內容,爲了理解又不得不先學習相關的前置知識。層層嵌套以後,發現最基礎的仍是tapable模型,所以先對這部分的內容作一個介紹。前端

引子-webpack的基本流程

Webpack的流程能夠分爲如下三大階段:node

初始化:啓動構建,讀取與合併配置參數,加載 Plugin,實例化 Compiler。這個compile對象會穿行在本次編譯的整個週期。
編譯:從 Entry 發出,針對每一個 Module 串行調用對應的 Loader 去翻譯文件內容,再找到該 Module 依賴的 Module,遞歸地進行編譯處理。
輸出:對編譯後的 Module 組合成 Chunk,把 Chunk 轉換成文件,輸出到文件系統。webpack

在這個過程當中,最核心的就是插件化的設計: 在不一樣的階段執行相應的一些插件,來執行某些功能。
而這裏的階段,指的就是hook。 理論太抽象,來看一段webpack的源碼(4.x版本):web

// webpack/lib/MultiCompiler.js
const { Tapable, SyncHook, MultiHook } = require("tapable");
class MultiCompiler extends Tapable {
    constructor(compilers) {
        super();
        this.hooks = {
            done: new SyncHook(["stats"]),
            invalid: new MultiHook(compilers.map(c => c.hooks.invalid)),
            run: new MultiHook(compilers.map(c => c.hooks.run)),
            watchClose: new SyncHook([]),
            watchRun: new MultiHook(compilers.map(c => c.hooks.watchRun)),
            infrastructureLog: new MultiHook(
                compilers.map(c => c.hooks.infrastructureLog)
            )
        };
    }
    /// 省略其餘代碼
}

這是compile的構造函數,有幾個注意點:數組

  1. 顯示繼承了Tapable,也就是本文的話題對象
  2. 注意看this.hooks部分的內容: done invalid, run, watchClose 等等都是內置的生命週期,具體的代碼暫時不去關心。

這部分代碼主要是爲了說明一個思路: webpack 的生命週期hook,其實是一個個插件的集合,表明的含義是,在某個階段須要掛載某些插件。
到這裏,腦海裏有這種大概雛形就好,接下來咱們開始介紹Tapablepromise

Tapable機制初探

Tapable的核心思路有點相似於nodejs中的events,最基本的發佈/訂閱模式。併發

const EventEmitter = require('events');
const myEmitter = new EventEmitter();

// 註冊事件對應的監聽函數
myEmitter.on('安歌發佈新文章', (title, tag) => {
    console.log("前去圍觀並吐槽",title, tag)
});

// 觸發事件 並傳入參數
myEmitter.emit('安歌發佈新文章',’標題tapable機制‘, '標籤webpack');

這個結構很簡單也很清晰:異步

  1. events.on 用於註冊要監聽的事件和對應的毀掉方法
  2. events.emit 用於觸發對應的事件

tapable的核心用法與此類似,那爲何屢次一舉要使用它呢?async

根據前面的demo,不妨假設一下,若是咱們註冊了不少事件,好比event.on(’起牀‘),event.on(’吃飯‘),event.on(’上班‘)等等,那事件之間可能就存在一些依賴關係,好比要先起牀而後才能上班這樣的時序依賴,而tapable就能夠幫助咱們很方便的管理這些關係。函數

基本用法

接下來用一個前幾天參加的公司中秋晚會的例子,來簡單說明一下Tapable的用法:
我把本身的參加流程分紅如下階段:

  1. 晚宴前

    1. 檢查着裝
    2. 乘坐班車到酒店
  2. 晚宴中

    1. 用餐並欣賞表演
    2. 在當前桌進行博餅
    3. 若是成爲當前桌狀元,那麼就留下來博王中王,
  3. 晚宴後

    1. 拍一些照片,打車回家
    2. 用拍好的照片發朋友圈記念

那麼先寫個全局demo:

// 1. 引入 tapable ,先無論具體的鉤子類型
const {
    SyncHook,
    SyncBailHook,
    SyncWaterfallHook,
 } = require("tapable");

// 2. 定義不一樣階段對應的鉤子,

// 鉤子: 晚宴前
let beforeDinner = new SyncHook(["stageName"]);
// 鉤子:晚宴中
let atTheDinner = new SyncBailHook(["stageName"]);
// 鉤子 晚宴後
let afterDinner = new SyncWaterfallHook(["stageName"]);


// 3. 爲不一樣階段註冊事件,這裏先寫出晚宴前的事件

beforeDinner.tap('檢查着裝', (stageName)=>{
    console.log(`${stageName}: 檢查着裝`)
})

beforeDinner.tap('乘坐班車到酒店', (stageName)=>{
    console.log(`${stageName}: 乘坐班車到酒店`)
})


// 每一個階段觸發自身須要執行的事件
beforeDinner.call('晚宴前');
atTheDinner.call('晚宴中');
afterDinner.call('晚宴後');

// 輸出結果:
// 晚宴前: 檢查着裝
// 晚宴前: 乘坐班車到酒店
// ... 省略後面的輸出

這個demo簡單的定義了三個階段,先不去關具體的hook類型,瞭解下總體的結構:

  1. beforeDinner在實例化時,使用數組聲明瞭參數stageName, 這個地方的參數類型僅僅做爲接口定義的目的使用,爲了方便觸發的時候傳入對應的參數;
  2. call方法其實就相似前文的emit,與之不一樣的是,event.emit表示事件觸發,而hook.call表示當前鉤子要開始執行鉤子上註冊的全部事件。(固然咱們只註冊了晚宴前的2個事件)
  3. hook.call(param)執行以後,該hook對應的事件就按照註冊順序以及特定規則(具體規則後面說明,暫時略過)依次執行,所以上面的beforeDinner.call('晚宴前');會輸出對應的階段名稱和事件名稱。

到這裏,咱們已經用上了最基本的tapable了。回顧下它和events最大的區別:

tapable不只提供了事件的註冊和執行,還用不一樣的Hook 將事件進行分類(這裏例子用三個階段將基礎事件分類)

SyncBailHook

接下來就是晚宴中的事件,這裏有個注意點:晚宴中的第三個事件」若是成爲當前桌狀元,那麼就留下來博王中王「是一個帶有前提條件的事件,因此咱們用了SyncBailHook,而且這麼註冊事件:

atTheDinner.tap('用餐並欣賞表演', (stageName) => {
    console.log(`${stageName}: 用餐並欣賞表演`);
})

atTheDinner.tap('在當前桌進行博餅', (stageName) => {
    console.log(`${stageName}: 在當前桌進行博餅`); 
    // 關鍵僞代碼
    let getChampion = false //若是得到狀元
    if(!getChampion){
        console.log(`${stageName}: 沒有得到當前桌狀元,不須要參與博王中王`);  // 注意這裏的return
        return '提早結束!';
    }
})

atTheDinner.tap('博王中王', (stageName) => {
    console.log(`${stageName}: 博王中王`);
})

SyncBailHook翻譯過來意思是「熔斷類型的鉤子」,做用就像保險絲,一旦有危險,則啓動保護(一旦該鉤子的某個事件,執行返回除了undefined之外的值,後面註冊的事件就再也不執行)。正如前面的例子中,若是在「當前桌子博餅」中沒有成功搏到「狀元」,就不會進行後面的「搏王中王」事件。經常使用於處理某些須要條件判斷才觸發的事件。

SyncWaterfallHook

晚宴以後的事件,與前面不用的地方在於:事件2發朋友圈 用的是事件1中所拍的照片,換句話說後面的事件依賴於前面事件的執行結果。因此能夠這麼寫:

afterDinner.tap('回家前拍照',  (stageName) => {
    console.log(`${stageName}: 拍一些照片,打車回家`);
    let pictures = ['image1','image2'];
    return pictures; 
})

afterDinner.tap('回家後發朋友圈', (pictures)=> {
   // 注意這裏的內置參數 再也不是stageName 而是pictures
   return console.log(`回家後,用${pictures}:發朋友圈`);
})

實例化afterDinner時使用了SyncWaterfallHook,顧名思義,這種瀑布式的鉤子,做用就是在執行該鉤子內註冊的事件時,會把每一個階段的執行結果傳遞給後面的階段。

小結

這部分咱們介紹了tapable的基本用法和三種基本類型的hook,大概能夠總結一下:

hook表示事件的集合,hook的類型決定了註冊在這個hook的事件如何執行

異步類型的hook

總覽

開胃菜結束,接下來要真正開始系統化的瞭解tapable了,(好消息是若是前面的例子都看懂了,後面的學起來會很是簡單,壞消息是:又要涉及前端最棘手的問題之一--異步)

先來一覽全部的hook類型:

image

整體上,hook類型分紅同步和異步兩大類,異步再分爲異步串行和異步並行。

先前已經介紹了同步hook裏面的前三種。第四種SynloopHook也簡要介紹下:

假設寫文章這個事情,分紅校對發表兩個步驟,校對必須3次以上,才能夠執行發表事件:

// 當監聽函數被觸發的時候,若是該監聽函數返回true時則這個監聽函數會反覆執行,若是返回 undefined 則表示退出循環
let writeArticle  = SyncLoopHook();
let count = 0;

writeArticle.tap('校對',()=>{
    console.log('執行校對', count++)
    if(count<3){
        return true; // 沒有達到3次則繼續校對
    }
    return
})

writeArticle.tap('發表',()=>{
    console.log('發表')
})

異步並行 AsyncParallelHook

異步的hook,註冊和觸發能夠用tapAsync/callAsynctapPromise/promise兩種語法,寫法上略有不用。直接上demo:

// AsyncParallelHook 鉤子:tapAsync/callAsync 的使用
const { AsyncParallelHook } = require("tapable");

// 建立實例
let asyncParallelHook = new AsyncParallelHook(["demoName"]);

// 註冊事件
console.time("time");
asyncParallelHook.tapAsync("異步事件1", (demoName, done) => {
    setTimeout(() => {
        console.log("1", demoName,  new Date());
        done(); //須要注意的是這裏的`done`方法 
    }, 1000);
});

asyncParallelHook.tapAsync("異步事件2", (demoName, done) => {
    setTimeout(() => {
        console.log("2", demoName,  new Date());
        done();
    }, 2000);
});

asyncParallelHook.tapAsync("異步事件3", (demoName, done) => {
    setTimeout(() => {
        console.log("3", demoName,  new Date());
        done();
        console.timeEnd("time");
    }, 3000);
});

// 觸發事件,讓監聽函數執行
asyncParallelHook.callAsync("異步並行", () => {
    // 只有當前鉤子的全部事件都執行done 才進入這個callback
    console.log("complete");
});

// 輸出
// 異步事件1 異步並行
// Sun Sep 08 2019 21:24:12 GMT+0800 (GMT+08:00) {}
// 異步事件2 異步並行
// Sun Sep 08 2019 21:24:13 GMT+0800 (GMT+08:00) {}
// 異步事件3 異步並行
// Sun Sep 08 2019 21:24:14 GMT+0800 (GMT+08:00) {}
// complete
// time: 3007.266845703125ms
// time: 3007.640ms

須要注意的是這裏的done方法, 每一個註冊的的事件均可以調用到這個done方法,這個方法的做用是:向對應的hook實例告知,當前的異步事件完成,只有當全部的事件回調都執行了done方法,纔會進入鉤子自己的回調函數(demo中的console.log("complete");)

從例子中的計時狀況來看,很明顯全部的事件是並行的 -- 事件1 2 3分別須要1s 2s 3s, 最終執行完也只花了3s。

tapPromise/promise來寫的話,以下:

asyncParallelHook.tapPromise("異步事件1", (demoName) => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log("1", demoName,  new Date());
            resolve("1");
        }, 1000);
    });
});

// ...省略重複代碼
asyncParallelHook.promise("異步並行").then(() => {
    console.log("最終結果", new Date());
}).catch(err => {
    console.log("發現錯誤", new Date());
});

區別在於:

  1. 使用tapPromise註冊時,回調函數必須返回一個promise
  2. 使用tabAsync註冊使用done表示當前執行完成,使用tapPromise時則只要使用resolve()便可
  3. 若是其中一個事件沒有resolve, 而是reject(error),那麼會進入asyncParallelHookcatch而不是then

這種寫法其實很相似ES6中的promise.all,比較好理解

異步串行AsyncSeriesHook

其實到這裏,已經一隻腳踏進成功的大門了。 異步串行和異步並行的寫法,徹底同樣。只須要簡單把前面例子中,實例化的語句改爲:

let asyncSeriesHook = new AsyncSeriesHook()

而後看看3個異步事件執行完後的事件間隔(並行的時候是3s,串行時總時長變成6s)。

沒錯,就是這麼簡單~!

引伸 -- 實例案例webpack-dev-middlewaretapable的應用

webpack-dev-middleware是一個webpack的插件,做用是監聽webpack的編譯變化並寫入到內存中。 核心代碼:

// webpack-dev-middleware/lib/context.js

const context = {
    state: false, 
    webpackStats: null, // 
    callbacks: [],
    options,
    compiler,
    watching: null,
    forceRebuild: false,
  };
  
   function invalid(callback) {
    if (context.state) {
      context.options.reporter(context.options, {
        log,
        state: false,
      });
    }

    // We are now in invalid state
    context.state = false;
    if (typeof callback === 'function') {
      callback();
    }
  }

  
  // 關鍵代碼 利用compile的hook 觀察編譯變化 並插入操做
  context.compiler.hooks.invalid.tap('WebpackDevMiddleware', invalid);
  context.compiler.hooks.run.tap('WebpackDevMiddleware', invalid);
  context.compiler.hooks.done.tap('WebpackDevMiddleware', done);
  context.compiler.hooks.watchRun.tap(
    'WebpackDevMiddleware',
    (comp, callback) => {
      invalid(callback);
    }
  );

核心的代碼就是使用webpack提供的內置hook watchRun來插入自定義的操做(檢查編譯狀況,生成臨時結果到內存)

總結

呼~ tapable的內容大概寫完了,本文介紹了同步的幾種鉤子,和異步的2種表明性的鉤子,至於異步並行熔斷等等,就是前面介紹的鉤子的合成,比較簡單。回顧一下主要的內容:

  1. 同步hook觸發後,按照事件註冊順序依次調用,並根據鉤子類型,有一些特殊行爲(bail loop);
  2. 異步的hook有tapAsync/callAsynctapPromise/promise兩種使用方式,而且均可以觀察事件總體執行結果;
  3. 異步串行和異步並行的區別,在於註冊的事件依次執行(前一個完成纔開始執行後一個)仍是併發執行;

理解清楚tapable以後,再開始學習webpack的源碼,會相對順暢一些。

-----慣例偷懶分割線-----
若是以爲寫得很差/有錯誤/表述不明確,都歡迎指出
若是有幫助,歡迎點贊和收藏,轉載請徵得贊成後著明出處。若是有問題也歡迎私信交流,主頁有郵箱地址
若是以爲做者很辛苦,也歡迎打賞~

相關文章
相關標籤/搜索