Webpack Chunk 分包規則詳解

Tecvan

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

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

Chunk 則是輸出產物的基本組織單位,在生成階段 webpack 按規則將 entry 及其它 Module 插入 Chunk 中,之後再由 SplitChunksPlugin 插件根據優化規則與 ChunkGraphChunk 做一系列的變化、拆解、合併操作,重新組織成一批性能 (可能) 更高的 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 核心邏輯運行結束後會生成一系列的 ChunkChunkGroupChunkGraph 對象,後續如 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 分別包含 mainhome 模塊:

初始化完畢後,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-async-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._parentsChunkGroup._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