概述
- 在前文 Webpack: Dependency Graph 管理模塊間依賴 中,我們已經詳細講解了「構建」階段如何從 Entry 開始逐步遞歸讀入、解析模塊內容,并最終構建出模塊依賴關系圖 —— ModuleGraph 對象。本文我們繼續往下,講解在接下來的「封裝」階段,如何根據 ModuleGraph 內容組織 Chunk,并進一步構建出 ChunkGroup、ChunkGraph 依賴關系對象的主流程。
主流程之外,我們還會詳細講解幾個比較模糊的概念:
- Chunk、ChunkGroup、ChunGraph 對象分別是什么?互相之間存在怎樣的交互關系?
- Webpack 默認分包規則,以及規則中存在的問題。
ChunkGraph 構建過程
在 前 Init、Make、Seal》中,我們已經介紹了 Webpack 底層構建邏輯大體上可以劃分為:「初始化、構建、封裝」三個階段:
其中,「構建」階段負責分析模塊間的依賴關系,建立起模塊之間的 依賴關系圖(ModuleGraph);緊接著,在「封裝」階段根據依賴關系圖,將模塊分開封裝進若干 Chunk 對象中,并將 Chunk 之間的父子依賴關系梳理成 ChunkGraph 與若干 ChunkGroup 對象。
「封裝」階段最重要的目標就是根據「構建」階段收集到的 ModuleGraph 關系圖構建 ChunkGraph 關系圖,這個過程的邏輯比較復雜:
我們簡單分析一下這里面幾個重要步驟的實現邏輯。
第一步非常關鍵: 調用 seal()
函數后,遍歷 entry
配置,為每個入口創建一個空的 Chunk
與 EntryPoint 對象(一種特殊的 ChunkGroup
),并初步設置好基本的 ChunkGraph
結構關系,為下一步驟做好準備,關鍵代碼:
class Compilation {seal(callback) {// ...const chunkGraphInit = new Map();// 遍歷入口模塊列表for (const [name, { dependencies, includeDependencies, options }] of this.entries) {// 為每一個 entry 創建對應的 Chunk 對象const chunk = this.addChunk(name);// 為每一個 entry 創建對應的 ChunkGroup 對象const entrypoint = new Entrypoint(options);// 關聯 Chunk 與 ChunkGroupconnectChunkGroupAndChunk(entrypoint, chunk);// 遍歷 entry Dependency 列表for (const dep of [...this.globalEntry.dependencies, ...dependencies]) {// 為每一個 EntryPoint 關聯入口依賴對象,以便下一步從入口依賴開始遍歷其它模塊entrypoint.addOrigin(null, { name }, /** @type {any} */ (dep).request);const module = this.moduleGraph.getModule(dep);if (module) {// 在 ChunkGraph 中記錄入口模塊與 Chunk 關系chunkGraph.connectChunkAndEntryModule(chunk, module, entrypoint);// ...}}}// 調用 buildChunkGraph 方法,開始構建 ChunkGraphbuildChunkGraph(this, chunkGraphInit);// 觸發各種優化鉤子// ...}
}
執行完成后,形成如下數據結構:
其次,若此時配置了 entry.runtime
,Webpack 還會在這個階段為運行時代碼 創建 相應的 Chunk 并直接 分配 給 entry
對應的 ChunkGroup
對象。一切準備就緒后調用 buildChunkGraph 函數,進入下一步驟。
第二步: 在 buildChunkGraph
函數內 調用 visitModules
函數,遍歷 ModuleGraph,將所有 Module 按照依賴關系分配給不同 Chunk
對象;這個過程中若遇到 異步模塊,則為該模塊 創建新的 ChunkGroup
與 Chunk
對象,最終形成如下數據結構:
第三步: 在 buildChunkGraph
函數中調用 connectChunkGroups
方法,建立 ChunkGroup
之間、Chunk
之間的依賴關系,生成完整的 ChunkGraph
對象,最終形成如下數據結構:
第四步: 在 buildChunkGraph
函數中調用 cleanupUnconnectedGroups
方法,清理無效 ChunkGroup
,主要起到性能優化作用。
自上而下經過這四個步驟后,ModuleGraph
中存儲的模塊將根據模塊本身的性質,被分配到 Entry、Async、Runtime 三種不同的 Chunk 對象,并將 Chunk 之間的依賴關系存儲到 ChunkGraph 與 ChunkGroup 集合中,后續可在這些對象基礎上繼續修改分包策略(例如 SplitChunksPlugin
),通過重新組織、分配 Module 與 Chunk 對象的歸屬實現分包優化。
Chunk vs ChunkGroup vs ChunkGraph
上述構建過程涉及 Chunk、ChunkGroup、ChunkGraph 三種關鍵對象,我們先總結它們的概念與作用,加深理解:
Chunk
:Module 用于讀入模塊內容,記錄模塊間依賴等;而 Chunk 則根據模塊依賴關系合并多個 Module,輸出成資產文件(合并、輸出產物的邏輯,我們放到下一章講解):
ChunkGroup
:一個ChunkGroup
內包含一個或多個Chunk
對象;ChunkGroup
與ChunkGroup
之間形成父子依賴關系:
ChunkGraph
:最后,Webpack 會將 Chunk 之間、ChunkGroup 之間的依賴關系存儲到compilation.chunkGraph
對象中,形成如下類型關系:
默認分包規則
綜合上述 ChunkGraph
構建流程最終會將 Module 組織成三種不同類型的 Chunk:
- Entry Chunk:同一個
entry
下觸達到的模塊組織成一個 Chunk; - Async Chunk:異步模塊單獨組織為一個 Chunk;
- Runtime Chunk:
entry.runtime
不為空時,會將運行時模塊單獨組織成一個 Chunk。
這是 Webpack 內置的,在不使用 splitChunks
或其它插件的情況下,模塊輸入映射到輸出的默認規則,是 Webpack 底層關鍵原理之一,因此有必要展開介紹每一種 Chunk 的具體規則。
Entry Chunk:
先從 Entry Chunk 開始,Webpack 首先會為每一個 entry
創建 Chunk
對象,例如對于如下配置:
module.exports = {entry: {main: "./src/main",home: "./src/home",}
};
遍歷 entry
對象屬性并創建出 chunk[main]
、chunk[home]
兩個對象,此時兩個 Chunk 分別包含 main
、home
模塊:
初始化完畢后,Webpack 會根據 ModuleGraph
的依賴關系數據,將 entry
下所觸及的所有 Module 塞入 Chunk (發生在 visitModules 方法),比如對于如下文件依賴:
main.js
以同步方式直接或間接引用了 a/b/c/d 四個文件,Webpack 會首先為 main.js
模塊創建 Chunk 與 EntryPoint 對象,之后將 a/b/c/d 模塊逐步添加到 chunk[main]
中,最終形成:
Async Chunk:
其次,Webpack 會將每一個異步導入語句(import(xxx)
及 require.ensure
)處理為一個單獨的 Chunk 對象,并將其子模塊都加入這個 Chunk 中 —— 我們稱之為 Async Chunk。例如對于下面的例子:
// index.js
import './sync-a.js'
import './sync-b.js'import('./async-a.js')// async-a.js
import './sync-c.js'
在入口模塊 index.js
中,以同步方式引入 sync-a、sync-b;以異步方式引入 async-a 模塊;同時,在 async-a 中以同步方式引入 sync-c
模塊,形成如下模塊依賴關系圖:
此時,Webpack 會為入口 index.js
、異步模塊 async-a.js
分別創建分包,形成如下 Chunk 結構:
并且 chunk[index]
與 chunk[async-a]
之間形成了單向依賴關系,Webpack 會將這種依賴關系保存在 ChunkGroup._parents
、ChunkGroup._children
屬性中。
Runtime Chunk:
最后,除了 entry
、異步模塊外,Webpack5 還支持將 Runtime 代碼單獨抽取為 Chunk。這里說的 Runtime 代碼是指一些為了確保打包產物能正常運行,而由 Webpack 注入的一系列基礎框架代碼,舉個例子,常見的 Webpack 打包產物結構如:
上圖紅框圈出來的一大段代碼就是 Webpack 動態生成的運行時代碼,編譯時,Webpack 會根據業務代碼,決定輸出哪些支撐特性的運行時代碼(基于 Dependency
子類),例如:
- 需要
__webpack_require__.f
、__webpack_require__.r
等功能實現最起碼的模塊化支持; - 如果用到動態加載特性,則需要寫入
__webpack_require__.e
函數; - 如果用到 Module Federation 特性,則需要寫入
__webpack_require__.o
函數; - 等等。
雖然每段運行時代碼可能都很小,但隨著特性的增加,最終結果會越來越大,特別對于多 entry 應用,在每個入口都重復打包一份相似的運行時顯得有點浪費,為此 Webpack5 提供了 entry.runtime
配置項用于聲明如何打包運行時代碼。用法上只需在 entry
項中增加字符串形式的 runtime
值,例如:
module.exports = {entry: {index: { import: "./src/index", runtime: "solid-runtime" },}
};
在 compilation.seal
函數中,Webpack 首先為 entry
創建 EntryPoint
,之后判斷 entry
配置中是否帶有 runtime
屬性,有則創建以 runtime
值為名的 Chunk,因此,上例配置將生成兩個 Chunk:chunk[index.js]
、chunk[solid-runtime]
,并據此最終產出兩個文件:
- 入口 index 對應的
index.js
文件; - 運行時配置對應的
solid-runtime.js
文件。
在多 entry
場景中,只要為每個 entry
都設定相同的 runtime
值,Webpack 運行時代碼就會合并寫入到同一個 Runtime Chunk 中,最終達成產物性能優化效果。例如對于如下配置:
module.exports = {entry: {index: { import: "./src/index", runtime: "solid-runtime" },home: { import: "./src/home", runtime: "solid-runtime" },}
};
入口 index
、home
共享相同的 runtime
值,最終生成三個 Chunk,分別為:
此時入口 chunk[index]
、chunk[home]
與運行時 chunk[solid-runtime]
也會形成父子依賴關系。
分包規則的問題
默認分包規則最大的問題是無法解決模塊重復,如果多個 Chunk 同時包含同一個 Module,那么這個 Module 會被不受限制地重復打包進這些 Chunk。比如假設我們有兩個入口 main/index 同時依賴了同一個模塊:
默認情況下,Webpack 不會對此做額外處理,只是單純地將 c 模塊同時打包進 main/index 兩個 Chunk,最終形成:
可以看到 chunk
間互相孤立,模塊 c 被重復打包,對最終產物可能造成不必要的性能損耗!
為了解決這個問題,Webpack 3 引入 CommonChunkPlugin
插件試圖將 entry 之間的公共依賴提取成單獨的 chunk
,但 CommonChunkPlugin
本質上還是基于 Chunk 之間簡單的父子關系鏈實現的,很難推斷出提取出的第三個包應該作為 entry
的父 chunk
還是子 chunk
,CommonChunkPlugin
統一處理為父 chunk
,某些情況下反而對性能造成了不小的負面影響。
為此,在 Webpack4 之后才專門引入了更復雜的數據結構 —— ChunkGroup
專門實現關系鏈管理,配合 SplitChunksPlugin
能夠更高效、智能地實現啟發式分包。
總結
綜上,「構建」階段負責根據模塊的引用關系構建 ModuleGraph;「封裝」階段則負責根據 ModuleGraph 構建一系列 Chunk 對象,并將 Chunk 之間的依賴關系(異步引用、Runtime)組織為 ChunkGraph —— Chunk 依賴關系圖對象。與 ModuleGraph 類似,ChunkGraph 結構的引入也能解耦 Chunk 之間依賴關系的管理邏輯,整體架構邏輯更合理更容易擴展。
不過,雖然看著很復雜,但「封裝」階段最重要的目標還是在于:確定有多少個 Chunk,以及每一個 Chunk 中包含哪些 Module —— 這些才是真正影響最終打包結果的關鍵因素。
針對這一點,我們需要理解 Webpack5 內置的三種分包規則:Entry Chunk、Async Chunk 與 Runtime Chunk,這些是最最原始的分包邏輯,其它插件(例如 splitChunksPlugin)都是在此基礎,借助 buildChunkGraph
后觸發的各種鉤子進一步拆分、合并、優化 Chunk 結構,實現擴展分包效果。
思考 Chunk
一定會且只會生產出一個產物文件嗎?為什么?mini-css-extract-plugin
、file-loader
這一類能寫出額外文件的組件,底層是怎么實現的?