Webpack 性能系列四:分包優化
一、什么是分包
默認情況下,Webpack 會將所有代碼構建成一個單獨的包,這在小型項目通常不會有明顯的性能問題,但伴隨著項目的推進,包體積逐步增長可能會導致應用的響應耗時越來越長。歸根結底這種將所有資源打包成一個文件的方式存在兩個弊端:
- 「資源冗余」:客戶端必須等待整個應用的代碼包都加載完畢才能啟動運行,但可能用戶當下訪問的內容只需要使用其中一部分代碼
- 「緩存失效」:將所有資源達成一個包后,所有改動 —— 即使只是修改了一個字符,客戶端都需要重新下載整個代碼包,緩存命中率極低
這些問題都可以通過對產物做適當的分解拆包解決,例如 node_modules 中的資源通常變動較少,可以抽成一個獨立的包,那么業務代碼的頻繁變動不會導致這部分第三方庫資源被無意義地重復加載。為此,Webpack 專門提供了 SplitChunksPlugin 插件,用于實現產物分包。
二、使用 SplitChunksPlugin
SplitChunksPlugin 是 Webpack 4 之后引入的分包方案(此前為 CommonsChunkPlugin),它能夠基于一些啟發式的規則將 Module 編排進不同的 Chunk 序列,并最終將應用代碼分門別類打包出多份產物,從而實現分包功能。
使用上,SplitChunksPlugin 的配置規則比較抽象,算得上 Webpack 的一個難點,仔細拆解后關鍵邏輯在于:
SplitChunksPlugin 通過 module 被引用頻率、chunk 大小、包請求數三個維度決定是否執行分包操作,這些決策都可以通過 optimization.splitChunks 配置項調整定制,基于這些維度我們可以實現:
單獨打包某些特定路徑的內容,例如 node_modules 打包為 vendors單獨打包使用頻率較高的文件
SplitChunksPlugin 還提供配置組概念 optimization.splitChunks.cacheGroup,用于為不同類型的資源設置更有針對性的配置信息
SplitChunksPlugin 還內置了 default 與 defaultVendors 兩個配置組,提供一些開箱即用的特性:
- node_modules 資源會命中 defaultVendors 規則,并被單獨打包
- 只有包體超過 20kb 的 Chunk 才會被單獨打包
- 加載 Async Chunk 所需請求數不得超過 30
- 加載 Initial Chunk 所需請求數不得超過 30
這里所說的請求數不能等價對標到 http 資源請求數,下文會細講
綜上,分包邏輯基本上都圍繞著 Module 與 Chunk 展開,在介紹具體用法之前,有必要回顧一下 Chunk 的基礎知識。
2.1 什么是 Chunk
在《有點難的知識點:Webpack Chunk 分包規則詳解》一文中,我們已經了解到 Chunk 是打包產物的基本組織單位,讀者可以等價認為有多少 Chunk 就會對應生成多少產物(Bundle)。Webpack 內部包含三種類型的 Chunk:
- Initial Chunk:基于 Entry 配置項生成的 Chunk
- Async Chunk:異步模塊引用,如 import(xxx) 語句對應的異步 Chunk
- Runtime Chunk:只包含運行時代碼的 Chunk
關于運行時的概念,可參考《Webpack 原理系列六:徹底理解 Webpack 運行時》
而 SplitChunksPlugin 默認只對 Async Chunk 生效,開發者也可以通過 optimization.splitChunks.chunks 調整作用范圍,該配置項支持如下值:
- 字符串 'all' :對 Initial Chunk 與 Async Chunk 都生效,建議優先使用該值
- 字符串 'initial' :只對 Initial Chunk 生效
- 字符串 'async' :只對 Async Chunk 生效
- 函數 (chunk) => boolean :該函數返回 true 時生效
例如:
- module.exports = {
- //...
- optimization: {
- splitChunks: {
- chunks: 'all',
- },
- },
- }
2.2 分包策略詳解
2.2.1 根據 Module 使用頻率分包
SplitChunksPlugin 支持按 Module 被 Chunk 引用的次數決定是否進行分包,開發者可通過 optimization.splitChunks.minChunks 設定最小引用次數,例如:
- module.exports = {
- //...
- optimization: {
- splitChunks: {
- // 設定引用次數超過 4 的模塊才進行分包
- minChunks: 3
- },
- },
- }
需要注意,這里“被 Chunk 引用次數”并不直接等價于被 import 的次數,而是取決于上游調用者是否被視作 Initial Chunk 或 Async Chunk 處理,例如:
- // common.js
- export default "common chunk";
- // async-module.js
- import common from './common'
- // entry-a.js
- import common from './common'
- import('./async-module')
- // entry-b.js
- import common from './common'
- // webpack.config.js
- module.exports = {
- entry: {
- entry1: './src/entry-a.js',
- entry2: './src/entry-b.js'
- },
- // ...
- optimization: {
- splitChunks: {
- minChunks: 2
- }
- }
- };
上例包含四個模塊,形成如下模塊關系圖:
示例中,entry-a、entry-b 分別被視作 Initial Chunk 處理;async-module 被 entry-a 以異步方式引入,因此被視作 Async Chunk 處理。那么對于 common 模塊來說,分別被三個不同的 Chunk 引入,此時引用次數為 3,命中 optimization.splitChunks.minChunks = 2 規則,因此該模塊「可能」會被單獨分包,最終產物:
- entry-a.js
- entry-b.js
- async-module.js
- commont.js
2.2.2 限制分包數量
在滿足 minChunks 基礎上,還可以通過 maxInitialRequest/maxAsyncRequests 配置項限定分包數量,配置項語義:
- maxInitialRequest:用于設置 Initial Chunk 最大并行請求數
- maxAsyncRequests:用于設置 Async Chunk 最大并行請求數
這里所說的“請求數”,是指加載一個 Chunk 時所需同步加載的分包數。例如對于一個 Chunk A,如果根據分包規則(如模塊引用次數、第三方包)分離出了若干子 Chunk A¡,那么請求 A 時,瀏覽器需要同時請求所有的 A¡,此時并行請求數等于 ¡ 個分包加 A 主包,即 ¡+1。
舉個例子,對于上例所說的模塊關系:

若 minChunks = 2 ,則 common 模塊命中 minChunks 規則被獨立分包,瀏覽器請求 entry-a 時,則需要同時請求 common 包,并行請求數為 1 + 1=2。
而對于下述模塊關系:

若 minChunks = 2 ,則 common-1 、common-2 同時命中 minChunks 規則被分別打包,瀏覽器請求 entry-b 時需要同時請求 common-1 、common-2 兩個分包,并行數為 2 + 1 = 3,此時若 maxInitialRequest = 2,則分包數超過閾值,SplitChunksPlugin 會放棄 common-1 、common-2 中體積較小的分包。maxAsyncRequest 邏輯與此類似,不在贅述。
并行請求數關鍵邏輯總結如下:
- Initial Chunk 本身算一個請求
- Async Chunk 不算并行請求
- 通過 runtimeChunk 拆分出的 runtime 不算并行請求
- 如果同時有兩個 Chunk 滿足拆分規則,但是 maxInitialRequests(或 maxAsyncRequest) 的值只能允許再拆分一個模塊,那么體積更大的模塊會被優先拆解
2.2.3 限制分包體積
在滿足 minChunks 與 maxInitialRequests 的基礎上,SplitChunksPlugin 還會進一步判斷 Chunk 包大小決定是否分包,這一規則相關的配置項非常多:
- minSize:超過這個尺寸的 Chunk 才會正式被分包
- maxSize:超過這個尺寸的 Chunk 會嘗試繼續做分包
- maxAsyncSize:與 maxSize 功能類似,但只對異步引入的模塊生效
- maxInitialSize:與 maxSize 類似,但只對 entry 配置的入口模塊生效
- enforceSizeThreshold:超過這個尺寸的 Chunk 會被強制分包,忽略上述其它 size 限制
那么,結合前面介紹的兩種規則,SplitChunksPlugin 的主體流程如下:
- SplitChunksPlugin 嘗試將命中 minChunks 規則的 Module 統一抽到一個額外的 Chunk 對象;
- 判斷該 Chunk 是否滿足 maxInitialRequests 閾值,若滿足則進行下一步
- 判斷該 Chunk 資源的體積是否大于上述配置項 minSize 聲明的下限閾值;
- 如果體積「小于」 minSize 則取消這次分包,對應的 Module 依然會被合并入原來的 Chunk
- 如果 Chunk 體積「大于」 minSize 則判斷是否超過 maxSize、maxAsyncSize、maxInitialSize 聲明的上限閾值,如果超過則嘗試將該 Chunk 繼續分割成更小的部分
雖然 maxSize 等上限閾值邏輯會產生更多的包體,但緩存粒度會更小,命中率相對也會更高,配合持久緩存與 HTTP 2 的多路復用能力,網絡性能反而會有正向收益。
以上述模塊關系為例:

若此時 Webpack 配置的 minChunks 大于 2,且 maxInitialRequests 也同樣大于 2,如果 common 模塊的體積大于上述說明的 minxSize 配置項則分包成功,commont 會被分離為單獨的 Chunk,否則會被合并入原來的 3 個 Chunk。
注意,這些屬性的優先級順序為:
maxInitialRequest/maxAsyncRequests < maxSize < minSize而命中 enforceSizeThreshold 閾值的 Chunk 會直接跳過這些屬性判斷,強制進行分包。
2.3 使用cacheGroups
2.3.1 理解緩存組
除上述 minChunks、maxInitialRequest、minSize 等基礎規則外,SplitChunksPlugin 還提供了 cacheGroups 配置項用于為不同文件組設置不同的規則,例如:
- module.exports = {
- //...
- optimization: {
- splitChunks: {
- cacheGroups: {
- vendors: {
- test: /[\\/]node_modules[\\/]/,
- minChunks: 1,
- minSize: 0
- }
- },
- },
- },
- };
示例通過 cacheGroups 屬性設置 vendors 緩存組,所有命中 vendors.test 規則的模塊都會被視作 vendors 分組,優先應用該組下的 minChunks、minSize 等分包配置。
除了 minChunks 等分包基礎配置項之外,cacheGroups 還支持一些與分組邏輯強相關的屬性,包括:
- test:接受正則表達式、函數及字符串,所有符合 test 判斷的 Module 或 Chunk 都會被分到該組
- type:接受正則表達式、函數及字符串,與 test 類似均用于篩選分組命中的模塊,區別是它判斷的依據是文件類型而不是文件名,例如 type = 'json' 會命中所有 JSON 文件
- idHint:字符串型,用于設置 Chunk ID,它還會被追加到最終產物文件名中,例如 idHint = 'vendors' 時,輸出產物文件名形如 vendors-xxx-xxx.js
- priority:數字型,用于設置該分組的優先級,若模塊命中多個緩存組,則優先被分到 priority 更大的組
緩存組的作用在于能為不同類型的資源設置更具適用性的分包規則,一個典型場景是將所有 node_modules 下的模塊統一打包到 vendors 產物,從而實現第三方庫與業務代碼的分離。
2.3.2 默認分組
Webpack 提供了兩個開箱即用的 cacheGroups,分別命名為 default 與 defaultVendors,默認配置:
- module.exports = {
- //...
- optimization: {
- splitChunks: {
- cacheGroups: {
- default: {
- idHint: "",
- reuseExistingChunk: true,
- minChunks: 2,
- priority: -20
- },
- defaultVendors: {
- idHint: "vendors",
- reuseExistingChunk: true,
- test: /[\\/]node_modules[\\/]/i,
- priority: -10
- }
- },
- },
- },
- };
這兩個配置組能幫助我們:
- 將所有 node_modules 中的資源單獨打包到 vendors-xxx-xx.js 命名的產物
- 對引用次數大于等于 2 的模塊,也就是被多個 Chunk 引用的模塊,單獨打包
開發者也可以將默認分組設置為 false,關閉分組配置,例如:
- module.exports = {
- //...
- optimization: {
- splitChunks: {
- cacheGroups: {
- default: false
- },
- },
- },
- };
2.4 配置項回顧
最后,我們再回顧一下 SplitChunksPlugin 支持的配置項:
- minChunks:用于設置引用閾值,被引用次數超過該閾值的 Module 才會進行分包處理
- maxInitialRequest/maxAsyncRequests:用于限制 Initial Chunk(或 Async Chunk) 最大并行請求數,本質上是在限制最終產生的分包數量
- minSize:超過這個尺寸的 Chunk 才會正式被分包
- maxSize:超過這個尺寸的 Chunk 會嘗試繼續做分包
- maxAsyncSize:與 maxSize 功能類似,但只對異步引入的模塊生效
- maxInitialSize:與 maxSize 類似,但只對 entry 配置的入口模塊生效
- enforceSizeThreshold:超過這個尺寸的 Chunk 會被強制分包,忽略上述其它 size 限制
- cacheGroups:用于設置緩存組規則,為不同類型的資源設置更有針對性的分包策略
三、拆分運行時包
在《Webpack 原理系列六:徹底理解 Webpack 運行時》一文中,已經比較深入介紹 Webpack 運行時的概念、組成、作用與生成機制,大致上我們可以將運行時理解為一種補齊模塊化、異步加載等能力的應用骨架,用于支撐 Webpack 產物在各種環境下的正常運行。
運行時代碼的內容由業務代碼所使用到的特性決定,例如當 Webpack 檢測到業務代碼中使用了異步加載能力,就會將異步加載相關的運行時注入到產物中,因此業務代碼用到的特性越多,運行時就會越大,有時甚至可以超過 1M 之多。
此時,可以將 optimization.runtimeChunk 設置為 true,以此將運行時代碼拆分到一個獨立的 Chunk,實現分包。
四、最佳實踐
那么,如何設置最適合項目情況的分包規則呢?這個問題并沒有放諸四海皆準的通用答案,因為軟件系統與現實世界的復雜性,決定了很多計算機問題并沒有銀彈,不過我個人還是總結了幾條可供參考的最佳實踐:
「盡量將第三方庫拆為獨立分包」
例如在一個 React + Redux 項目中,可想而知應用中的大多數頁面都會依賴于這兩個庫,那么就應該將它們從具體頁面剝離,避免重復加載。
但對于使用頻率并不高的第三方庫,就需要按實際情況靈活判斷,例如項目中只有某個頁面 A 接入了 Three.js,如果將這個庫跟其它依賴打包在一起,那用戶在訪問其它頁面的時候都需要加載 Three.js,最終效果可能反而得不償失,這個時候可以嘗試使用異步加載功能將 Three.js 獨立分包
「保持按路由分包,減少首屏資源負載」
設想一個超過 10 個頁面的應用,假如將這些頁面代碼全部打包在一起,那么用戶訪問其中任意一個頁面都需要等待其余 9 個頁面的代碼全部加載完畢后才能開始運行應用,這對 TTI 等性能指標明顯是不友好的,所以應該盡量保持按路由維度做異步模塊加載,所幸很多知名框架如 React、Vue 對此都有很成熟的技術支持
「盡量保持」 **chunks = 'all'**optimization.splitChunks.chunks 配置項用于設置 SplitChunksPlugin 的工作范圍,我們應該盡量保持 chunks = 'all' 從而最大程度優化分包邏輯