往期文章:
引言
webpack的成功之處,不僅在於強大的打包構建能力,也在於它靈活的外掛機制。
也許你瞭解過webpack的外掛與鉤子機制;但你或許不知道,webpack內部擁有超過180個鉤子,這些鉤子與模組(內建外掛)之間的「建立」「註冊」「呼叫」關係非常複雜。因此,掌握webpack內部外掛與鉤子間的關係會幫助我們更進一步理解webpack的內部執行方式。
「webpack模組/內建外掛與鉤子關係圖?」:複雜性也可窺見一斑。
本文的第一部分會先介紹鉤子(hook)這個重要的概念與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.js
和shout.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.js
和shout.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原始碼並梳理內部各個模組與外掛執行流程與關係,可能也會碰到和我一樣的麻煩。下面就來說一下:
2.2. 外掛與鉤子機制帶來的問題
首先,可以看到由於圖比較細,所以它會比網上常見的整體流程圖要複雜;但是,即使只算上webpack常用外掛、compiler
鉤子與compilation
鉤子,這張圖也只算是其中一小部分。更不用說另外上百個你可能從未接觸過的鉤子。這些模組與鉤子交織出了一個複雜的webpack系統。
其次,在原始碼閱讀與整理的過程中,還會遇到幾個問題:
-
聯絡鬆散。根據以上的例子,你可以發現:使用tapable鉤子類似事件監聽模式,雖然能有效解耦,但鉤子的註冊與呼叫幾乎完全無關,很難將一個鉤子的“建立 - 註冊 - 呼叫”過程有效聯絡起來。
-
模組互動基於鉤子。webpack內部模組與外掛在很多時候,是通過鉤子機制來進行聯絡與呼叫的。但是,基於鉤子的模式是鬆散的。例如你看到原始碼裡一個模組提供了幾個鉤子,但你並不知道,在何時、何地該鉤子會被呼叫,又在何時、何地鉤子上被註冊了哪些方法。這些以往都是需要我們通過在程式碼庫中搜尋關鍵詞來解決。
-
鉤子數量眾多。webpack內部的鉤子非常多,數量達到了180+,型別也五花八門。除了官網列出的
compiler
與compilation
中那些常用的鉤子,還存在著眾多其他可以使用的鉤子。有些有用的鉤子你可能無從知曉,例如我最近用到的localVars
、requireExtensions
等鉤子。 -
內建外掛眾多。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相應原始碼處:
由於關係非常複雜(600+關係),可以對關係型別進行篩選,只展示關心的內容:
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的小工具主要通過:
- 遍歷webpack原始碼模組檔案
- 語法分析獲取鉤子相關資訊
- 加工原始採集資訊,轉換為力導向圖所需格式
- 基於力導向圖資料構建前端web視覺化服務
- 最後再輔以一些互動功能
目前我在使用它幫助閱讀與整理webapck原始碼與編譯流程。也許有些朋友也碰到了類似問題,分享出來希望它也能在某些方面對你有所幫助。如果你也對webpack或者這個工具感興趣,希望能多多支援我的文章和工具,一同交流學習~?
告別「webpack配置工程師」
寫在最後。
webpack是一個強大而複雜的前端自動化工具。其中一個特點就是配置複雜,這也使得「webpack配置工程師」這種戲謔的稱呼開始流行?但是,難道你真的只滿足於玩轉webpack配置麼?
顯然不是。在學習如何使用webpack之外,我們更需要深入webpack內部,探索各部分的設計與實現。萬變不離其宗,即使有一天webpack“過氣”了,但它的某些設計與實現卻仍會有學習價值與借鑑意義。因此,在學習webpack過程中,我會總結一系列【webpack進階】的文章和大家分享。
歡迎感興趣的同學多多交流與關注!
往期文章: