Webpack Chunk 分包規則詳解

Tecvan

我是範文傑,在字節跳動做前端,想寫點有深度的技術文章,喜歡就關注一下唄

在前面系列文章提到,webpack 實現中,原始的資源模塊以 Module 對象形式存在、流轉、解析處理。

而 Chunk 則是輸出產物的基本組織單位,在生成階段 webpack 按規則將 entry 及其它 Module 插入 Chunk 中,之後再由 SplitChunksPlugin 插件根據優化規則與 ChunkGraph 對 Chunk 做一系列的變化、拆解、合併操作,重新組織成一批性能 (可能) 更高的 Chunks 。運行完畢之後 webpack 繼續將 chunk 一一寫入物理文件中,完成編譯工作。

綜上,Module 主要作用在 webpack 編譯過程的前半段,解決原始資源 “「如何讀」” 的問題;而 Chunk 對象則主要作用在編譯的後半段,解決編譯產物 “「如何寫」” 的問題,兩者合作搭建起 webpack 搭建主流程。

Chunk 的編排規則非常複雜,涉及 entry、optimization 等諸多配置項,我打算分成兩篇文章分別講解基本分包規則、SplitChunksPlugin 分包優化規則,本文將集中在第一部分,講解 entry、異步模塊、runtime 三條規則的細節與原理。

Webpack 4 之後編譯過程大致上可以拆解爲四個階段 (參考:[萬字總結] 一文喫透 Webpack 核心原理):

在構建 (make) 階段,webpack 從 entry 出發根據模塊間的引用關係 (require/import) 逐步構建出模塊依賴關係圖 (ModuleDependencyGraph),依賴關係圖表達了模塊與模塊之間互相引用的先後次序,基於這種次序 webpack 就可以推斷出模塊運行之前需要先執行那些依賴模塊,也就可以進一步推斷出那些模塊應該打包在一起,那些模塊可以延後加載 (異步執行),關於模塊依賴圖的更多信息,可以參考我另一篇文章 《有點難的 webpack 知識點:Dependency Graph 深度解析》。

到了生成 (seal) 階段,webpack 會根據模塊依賴圖的內容組織分包 —— Chunk 對象,默認的分包規則有:

默認規則集中在 compilation.seal 函數實現,seal 核心邏輯運行結束後會生成一系列的 Chunk、ChunkGroup、ChunkGraph 對象,後續如 SplitChunksPlugin 插件會在 Chunk 系列對象上做進一步的拆解、優化,最終反映到輸出上纔會表現出複雜的分包結果。

我們聊聊默認生成規則。

Entry 分包處理

重點:seal 階段遍歷 entry 對象,爲每一個 entry 單獨生成 chunk,之後再根據模塊依賴圖將 entry 觸達到的所有模塊打包進 chunk 中。

在生成階段,Webpack 首先根據遍歷用戶提供的 entry 屬性值,爲每一個 entry 創建 Chunk 對象,比如對於如下配置:

module.exports = {
  entry: {
    main: "./src/main",
    home: "./src/home",
  }
};

Webpack 遍歷 entry 對象屬性並創建出 chunk[main] 、chunk[home] 兩個對象,此時兩個 chunk 分別包含 main 、home 模塊:

初始化完畢後,Webpack 會讀取 ModuleDependencyGraph 的內容,將 entry 所對應的內容塞入對應的 chunk (發生在 webpack/lib/buildChunkGrap.js 文件)。比如對於如下文件依賴:

main.js 以同步方式直接或間接引用了 a/b/c/d 四個文件,分析 ModuleDependencyGraph 過程會逐步將 a/b/c/d 模塊逐步添加到 chunk[main] 中,最終形成:

PS: 基於動態加載生成的 chunk 在 webpack 官方文檔中,通常稱之爲 「Initial chunk」 。

異步模塊分包處理

重點:分析 ModuleDependencyGraph 時,每次遇到異步模塊都會爲之創建單獨的 Chunk 對象,單獨打包異步模塊。

Webpack 4 之後,只需要用異步語句 require.ensure("./xx.js") 或 import("./xx.js") 方式引入模塊,就可以實現模塊的動態加載,這種能力本質也是基於 Chunk 實現的。

Webpack 生成階段中,遇到異步引入語句時會爲該模塊單獨生成一個 chunk 對象,並將其子模塊都加入這個 chunk 中。例如對於下面的例子:

// index.js, entry 文件
import 'sync-a'
import 'sync-b'

import('async-c')

在 index.js 中,以同步方式引入 sync-a、sync-b;以異步方式引入 async-a 模塊;同時,在 async-a 中以同步方式引入 sync-c 模塊。對應的模塊依賴如:

此時,webpack 會爲入口 index.js、異步模塊 async-a.js 分別創建分包,形成如下數據:

這裏需要引入一個新的概念 —— Chunk 間的父子關係。由 entry 生成的 Chunk 之間相互孤立,沒有必然的前後依賴關係,但異步生成的 Chunk 則不同,引用者 (上例 index.js 塊) 需要在特定場景下使用被引用者 (上例 async-a 塊),兩者間存在單向依賴關係,在 webpack 中稱引用者爲 parent、被引用者爲 child,分別存放在 ChunkGroup._parents 、ChunkGroup._children 屬性中。

上述分包方案默認情況下會生成兩個文件:

運行時,webpack 在 index.js 中使用 promise 及 __webpack_require__.e 方法異步載入並運行文件 src_async-a_js.js ,從而實現動態加載。

PS: 基於異步模塊的 chunk 在 webpack 官方文檔中,通常稱之爲 「Async chunk」 。

Runtime 分包

重點:Webpack 5 之後還能根據 entry.runtime 配置單獨打包運行時代碼。

除了 entry、異步模塊外,webpack 5 之後還支持基於 runtime 的分包規則。除業務代碼外,Webpack 編譯產物中還需要包含一些用於支持 webpack 模塊化、異步加載等特性的支撐性代碼,這類代碼在 webpack 中被統稱爲 runtime。舉個例子,產物中通常會包含如下代碼:

/******/ (() => {
  // webpackBootstrap
  /******/ var __webpack_modules__ = {}; // The module cache
  /************************************************************************/
  /******/ /******/ var __webpack_module_cache__ = {}; // The require function
  /******/

  /******/ /******/ function __webpack_require__(moduleId) {

    /******/ /******/ __webpack_modules__[moduleId](
      module,
      module.exports,
      __webpack_require__
    ); // Return the exports of the module
    /******/

    /******/ /******/ return module.exports;
    /******/
  } // expose the modules object (__webpack_modules__)
  /******/

  /******/ /******/ __webpack_require__.m = __webpack_modules__; /* webpack/runtime/compat get default export */
  /******/

  // ...
})();

編譯時,Webpack 會根據業務代碼決定輸出那些支撐特性的運行時代碼 (基於 Dependency 子類),例如:

雖然每段運行時代碼可能都很小,但隨着特性的增加,最終結果會越來越大,特別對於多 entry 應用,在每個入口都重複打包一份相似的運行時代碼顯得有點浪費,爲此 webpack 5 專門提供了 entry.runtime 配置項用於聲明如何打包運行時代碼。用法上只需在 entry 項中增加字符串形式的 runtime 值,例如:

module.exports = {
  entry: {
    index: { import: "./src/index", runtime: "solid-runtime" },
  }
};

Webpack 執行完 entry、異步模塊分包後,開始遍歷 entry 配置判斷是否帶有 runtime 屬性,如果有則創建以 runtime 值爲名的 Chunk,因此,上例配置將生成兩個 chunk:chunk[index.js] 、chunk[solid-runtime],並據此最終產出兩個文件:

在多 entry 場景中,只要爲每個 entry 都設定相同的 runtime 值,webpack 運行時代碼最終就會集中寫入到同一個 chunk,例如對於如下配置:

module.exports = {
  entry: {
    index: { import: "./src/index", runtime: "solid-runtime" },
    home: { import: "./src/home", runtime: "solid-runtime" },
  }
};

入口 index、home 共享相同的 runtime ,最終生成三個 chunk,分別爲:

同時生成三個文件:

至此,webpack 分包規則的基本邏輯就介紹完畢了,實現上,大部分功能代碼都集中在:

默認分包規則最大的問題是無法解決模塊重複,如果多個 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,某些情況下反而對性能造成了不小的負面影響。

在 webpack 4 之後則引入了更負責的設計 —— ChunkGroup 專門實現關係鏈管理,配合 SplitChunksPlugin 能夠更高效、智能地實現**「啓發式分包」**,這裏的內容很複雜,我打算拆開來在下一篇文章再講,感興趣的同學記得關注。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/dFrRY_ntUwmIOXzs8TYcFQ