【webpack進階】視覺化展示webpack內部外掛與鉤子關係?

AlienZHOU發表於2018-09-30

往期文章:

引言

webpack的成功之處,不僅在於強大的打包構建能力,也在於它靈活的外掛機制。

也許你瞭解過webpack的外掛與鉤子機制;但你或許不知道,webpack內部擁有超過180個鉤子,這些鉤子與模組(內建外掛)之間的「建立」「註冊」「呼叫」關係非常複雜。因此,掌握webpack內部外掛與鉤子間的關係會幫助我們更進一步理解webpack的內部執行方式。

「webpack模組/內建外掛與鉤子關係圖?」:複雜性也可窺見一斑。

【webpack進階】視覺化展示webpack內部外掛與鉤子關係?


本文的第一部分會先介紹鉤子(hook)這個重要的概念與webpack外掛的工作方式。然而,熟悉的朋友會發現,這種靈活的機制使得webpack模組之間的聯絡更加鬆散與非耦合的同時,讓想要理清webpack內部原始碼結構與聯絡變得更困難。

所以,第二部分將會介紹webpack內部外掛與鉤子關係的視覺化展示工具?,用一張圖理清webpack內部這種錯綜複雜的關係。

視覺化工具使用效果圖:

【webpack進階】視覺化展示webpack內部外掛與鉤子關係?

1. webpack的外掛機制

在具體介紹webpack內建外掛與鉤子視覺化工具之前,我們先來了解一下webpack中的外掛機制。

webpack實現外掛機制的大體方式是:

  • 「建立」—— webpack在其內部物件上建立各種鉤子;
  • 「註冊」—— 外掛將自己的方法註冊到對應鉤子上,交給webpack;
  • 「呼叫」—— webpack編譯過程中,會適時地觸發相應鉤子,因此也就觸發了外掛的方法。

1.1. Tapable

Tapable就是webpack用來建立鉤子的庫。

The tapable packages exposes many Hook classes, which can be used to create hooks for plugins.

通過Tapable,可以快速建立各類鉤子。以下是各種鉤子的類函式:

const {
	SyncHook,
	SyncBailHook,
	SyncWaterfallHook,
	SyncLoopHook,
	AsyncParallelHook,
	AsyncParallelBailHook,
	AsyncSeriesHook,
	AsyncSeriesBailHook,
	AsyncSeriesWaterfallHook
} = require("tapable");
複製程式碼

以最簡單的SyncHook為例,它可以幫助我們建立一個同步的鉤子。為了幫助理解Tapable建立鉤子的使用方式,我們以一個“下班回家進門”的模擬場景來介紹Tapable是如何使用的。

現在我們有一個welcome.js模組,它設定了我們“進門回家”的一系列行為(開門、脫鞋…):

// welcome.js
const {SyncHook} = require('tapable');

module.exports = class Welcome {
    constructor(words) {
        this.words = words;
        this.sayHook = new SyncHook(['words']);
    }

    // 進門回家的一系列行為
    begin() {
        console.log('開門');
        console.log('脫鞋');
        console.log('脫外套');
        // 打招呼
        this.sayHook.call(this.words);
        console.log('關門');
    }
}
複製程式碼

首先,我們在建構函式裡建立了一個同步鉤子sayHook,它用來進行之後的打招呼。

然後,begin()方法描述了我們剛回家進門的一系列動作:開門、脫鞋、脫外套、關門。其中,在「脫外套」與「關門」之間是一個打招呼的行為,我們在此觸發了sayHook鉤子,並將words作為引數傳入其中。

注意,這裡的.call()的方法是Tapable提供的觸發鉤子的方法,不是js中原生的call方法。

觸發這一系列流程也非常簡單:

// run.js
const Welcome = require('./welcome');
const welcome = new Welcome('我回來啦!');
welcome.begin();

/* output:
 * 開門
 * 脫鞋
 * 脫外套
 * 關門
 * /
複製程式碼

接下來,我們希望有不同的打招呼方式 —— “普通地打招呼”和“大喊一聲”。

對應的我們會有兩個模組say.jsshout.js,通過.tap()方法在sayHook鉤子上註冊相應方法。

// say.js
module.exports = function (welcome) {
    welcome.sayHook.tap('say', words => {
        console.log('輕聲說:', words);
    });
};

// shout.js
module.exports = function (welcome) {
    welcome.sayHook.tap('shout', words => {
        console.log('出其不意的大喊一聲:', words);
    });
};
複製程式碼

最後,我們修改一下run.js,給welcome應用shout.js這個模組。

// run.js
const Welcome = require('./welcome');
const applyShoutPlugin = require('./shout');
const welcome = new Welcome('我回來啦!');
applyShoutPlugin(welcome);
welcome.begin();

/* output:
 * 開門
 * 脫鞋
 * 脫外套
 * 出其不意的大喊一聲: 我回來啦!
 * 關門
 * /
複製程式碼

這樣,我們就把打招呼的實現方式與welcome解耦了。我們也可以使用say.js模組,甚至和shout.js兩者同時使用。這就好比建立了一個“可插拔”的系統機制 —— 我可以根據需求自主選擇要不要打招呼,要用什麼方式打招呼。

雖然上面的例子非常簡單,但是已經可以幫助我們理解tapable的使用以及外掛的思想。

1.2. webpack中的外掛

在介紹webpack的外掛機制前,先簡單回顧下上面“進門回家”例子:

  • 我們的Welcome類是主要的功能類,其中包含具體的功能函式begin()與鉤子sayHook
  • run.js模組負責執行流程,控制程式碼流;
  • 最後,say.jsshout.js是獨立的“可插入”模組。根據需要,我們可以自主附加到主流程中。

理解了上面這個例子,就可以很好地類比到webpack中:

例如,webpack中有一個重要的類 —— Compiler,它建立了非常多的鉤子,這些鉤子將會散落在“各地”被呼叫(call)。它就類似於我們的Welcome類。

// Compiler類中的部分鉤子

this.hooks = {
    /** @type {SyncBailHook<Compilation>} */
    shouldEmit: new SyncBailHook(["compilation"]),
    /** @type {AsyncSeriesHook<Stats>} */
    done: new AsyncSeriesHook(["stats"]),
    /** @type {AsyncSeriesHook<>} */
    additionalPass: new AsyncSeriesHook([]),
    /** @type {AsyncSeriesHook<Compiler>} */
    beforeRun: new AsyncSeriesHook(["compiler"]),
    /** @type {AsyncSeriesHook<Compiler>} */
    run: new AsyncSeriesHook(["compiler"]),
    /** @type {AsyncSeriesHook<Compilation>} */
    emit: new AsyncSeriesHook(["compilation"]),
    ……
}
複製程式碼

然後,webpack中的外掛會將所需執行的函式通過 .tap() / .tapAsync() / .tapPromise() 等方法註冊到對應鉤子上。這樣,webpack呼叫相應鉤子時,外掛中的函式就會自動執行。

那麼,還有一個問題:webpack是如何呼叫外掛,將外掛中的方法在編譯階段註冊到鉤子上的呢?

對於這個問題,webpack規定每個外掛的例項,必須有一個.apply()方法,webpack打包前會呼叫所有外掛的.apply()方法,外掛可以在該方法中進行鉤子的註冊。

在webpack的lib/webpack.js中,有如下程式碼:

if (options.plugins && Array.isArray(options.plugins)) {
    for (const plugin of options.plugins) {
        plugin.apply(compiler);
    }
}
複製程式碼

上面這段程式碼會從webpack配置的plugins欄位中取出所有外掛的例項,然後呼叫其.apply()方法,並將Compiler的例項作為引數傳入。這就是為什麼webpack要求我們所有外掛都需要提供.apply()方法,並在其中進行鉤子的註冊。

注意,和.call()一樣,這裡的.apply()也不是js的原生方法。你會在原始碼中看到許多.call().apply(),但它們基本都不是你認識的那個方法。

2. 編譯期(Compiler中)鉤子的觸發流程

目前,網上已經有了一些解析webpack的優質文章。其中也不乏對webpack編譯流程整理與介紹的文章。

但是,由於我近期的工作與興趣原因,需要對webpack內部的執行步驟與細節做一些較為深入的調研,包括各種鉤子與方法的註冊、觸發時機、條件等等。目前的一些文章內容可能不足以支援,據此做了一定的整理工作。

2.1. 一張待完善的圖

下面是我之前梳理的Compiler.run()方法(編譯的啟動方法)的執行流程及鉤子觸發情況(圖中只涉及了一部分compilation的相關鉤子,完整版還需進一步整理):

【webpack進階】視覺化展示webpack內部外掛與鉤子關係?

但是梳理過程中其實出現了一些困難。如果你也曾經想要仔細閱讀webpack原始碼並梳理內部各個模組與外掛執行流程與關係,可能也會碰到和我一樣的麻煩。下面就來說一下:

2.2. 外掛與鉤子機制帶來的問題

首先,可以看到由於圖比較細,所以它會比網上常見的整體流程圖要複雜;但是,即使只算上webpack常用外掛、compiler鉤子與compilation鉤子,這張圖也只算是其中一小部分。更不用說另外上百個你可能從未接觸過的鉤子。這些模組與鉤子交織出了一個複雜的webpack系統。

其次,在原始碼閱讀與整理的過程中,還會遇到幾個問題:

  • 聯絡鬆散。根據以上的例子,你可以發現:使用tapable鉤子類似事件監聽模式,雖然能有效解耦,但鉤子的註冊與呼叫幾乎完全無關,很難將一個鉤子的“建立 - 註冊 - 呼叫”過程有效聯絡起來。

  • 模組互動基於鉤子。webpack內部模組與外掛在很多時候,是通過鉤子機制來進行聯絡與呼叫的。但是,基於鉤子的模式是鬆散的。例如你看到原始碼裡一個模組提供了幾個鉤子,但你並不知道,在何時、何地該鉤子會被呼叫,又在何時、何地鉤子上被註冊了哪些方法。這些以往都是需要我們通過在程式碼庫中搜尋關鍵詞來解決。

  • 鉤子數量眾多。webpack內部的鉤子非常多,數量達到了180+,型別也五花八門。除了官網列出的compilercompilation中那些常用的鉤子,還存在著眾多其他可以使用的鉤子。有些有用的鉤子你可能無從知曉,例如我最近用到的localVarsrequireExtensions等鉤子。

  • 內建外掛眾多。webpack v4+ 本身內建了許多外掛。即使非外掛,webpack的模組自身也經常使用tapable鉤子來互動。甚至可以認為,webpack專案中的各個模組都是“外掛化”的。這也使得幾乎每個模組都會和各種鉤子“打交道”。

這些問題導致了想要全面瞭解webpack中模組/外掛間作用關係(核心是與鉤子的關係)具有一定的困難。為了幫助理解與閱讀webpack原始碼、理清關係,我製作了一個小工具來視覺化展示內建外掛與鉤子之間的關係,並支援通過互動操作進一步獲取原始碼資訊。

3. Webpack Internal Plugin Relation

Webpack-Internal-Plugin-Relation是一個可以展現webpack內部模組(外掛)與鉤子間關係的工具。文章開頭展示的動圖就是其功能與使用效果。

github倉庫地址:github.com/alienzhou/w… 可以在這裡檢視 線上演示

3.1. 關係型別

模組/外掛與鉤子的關係主要分為三類:

  • 模組/外掛「建立」鉤子,如this.hooks.say = new SyncHook()
  • 模組/外掛將方法「註冊」到鉤子上,如obj.hooks.say.tap('one', () => {...});
  • 模組/外掛通過「呼叫」來觸發鉤子事件,如obj.hooks.say.call()

3.2. 效果演示

可以進行模組/外掛與鉤子之間的關係展示:

【webpack進階】視覺化展示webpack內部外掛與鉤子關係?

可以通過點選等互動,展示模組內鉤子資訊,雙擊直接跳轉至webpack相應原始碼處:

【webpack進階】視覺化展示webpack內部外掛與鉤子關係?

由於關係非常複雜(600+關係),可以對關係型別進行篩選,只展示關心的內容:

【webpack進階】視覺化展示webpack內部外掛與鉤子關係?

3.3. 工具包含的功能

具體來說,這個工具包含的功能主要包括:

  • 關係收集

    • 收集模組中hook的建立資訊,即鉤子的建立資訊;
    • 收集模組中hook的註冊資訊,記錄哪些模組對哪些鉤子進行了註冊;
    • 收集模組中hook的呼叫資訊,即鉤子是在程式碼中的哪一行觸發的;
    • 生成包含「模組資訊」、「鉤子資訊」、「原始碼位置資訊」等原始資料的檔案。
  • 視覺化展示

    • 使用力導向圖視覺化展示外掛、鉤子間關係。可以看到目前webpack v4中有超過180個鉤子與超過130個模組;
    • 展示所有模組與鉤子列表。
  • 互動資訊

    • 支援對力導向圖中節點的展現進行篩選;
    • 通過單擊javascript module類節點,可在左下角檢視模組的詳細資訊;
    • 雙擊javascript module類節點,可直接開啟webpack對應原始碼檢視;
    • 雙擊節點間關係,可直接開啟並定位原始碼具體行數,進行檢視;
    • 可以選擇要檢視的關係:建立-contain / 註冊-register / 呼叫-call。

3.4. 基於原始資料定製自己的功能

目前,工具會將原始的採集結果都保留下來。因此,如果你並不需要視覺化展示,或者有自己的定製化需求,那麼完全可以基於這些資訊進行處理,用於你所需的地方。模組的原始資訊結構如下:

"lib/MultiCompiler.js": {
  "hooks": [
    {
      "name": "done",
      "line": 17
    },
    {
      "name": "invalid",
      "line": 18
    },
    {
      "name": "run",
      "line": 19
    },
    {
      "name": "watchClose",
      "line": 20
    },
    {
      "name": "watchRun",
      "line": 21
    }
  ],
  "taps": [
    {
      "hook": "done",
      "type": "tap",
      "plugin": "MultiCompiler",
      "line": 37
    },
    {
      "hook": "invalid",
      "type": "tap",
      "plugin": "MultiCompiler",
      "line": 48
    }
  ],
  "calls": [
    {
      "hook": "done",
      "type": "call",
      "line": 44
    }
  ]
}
複製程式碼

4. 尾聲

這個Webpack-Internal-Plugin-Relation的小工具主要通過:

  1. 遍歷webpack原始碼模組檔案
  2. 語法分析獲取鉤子相關資訊
  3. 加工原始採集資訊,轉換為力導向圖所需格式
  4. 基於力導向圖資料構建前端web視覺化服務
  5. 最後再輔以一些互動功能

目前我在使用它幫助閱讀與整理webapck原始碼與編譯流程。也許有些朋友也碰到了類似問題,分享出來希望它也能在某些方面對你有所幫助。如果你也對webpack或者這個工具感興趣,希望能多多支援我的文章和工具,一同交流學習~?

告別「webpack配置工程師」

寫在最後。

webpack是一個強大而複雜的前端自動化工具。其中一個特點就是配置複雜,這也使得「webpack配置工程師」這種戲謔的稱呼開始流行?但是,難道你真的只滿足於玩轉webpack配置麼?

顯然不是。在學習如何使用webpack之外,我們更需要深入webpack內部,探索各部分的設計與實現。萬變不離其宗,即使有一天webpack“過氣”了,但它的某些設計與實現卻仍會有學習價值與借鑑意義。因此,在學習webpack過程中,我會總結一系列【webpack進階】的文章和大家分享。

歡迎感興趣的同學多多交流與關注!

往期文章:

相關文章