什么是分包?怎么利用分包優(yōu)化
以下文章來源于Tecvan ,作者范文杰
一、什么是分包
默認情況下,Webpack 會將所有代碼構建成一個單獨的包,這在小型項目通常不會有明顯的性能問題,但伴隨著項目的推進,包體積逐步增長可能會導致應用的響應耗時越來越長。歸根結底這種將所有資源打包成一個文件的方式存在兩個弊端:
「資源冗余」:客戶端必須等待整個應用的代碼包都加載完畢才能啟動運行,但可能用戶當下訪問的內(nèi)容只需要使用其中一部分代碼
「緩存失效」:將所有資源達成一個包后,所有改動 —— 即使只是修改了一個字符,客戶端都需要重新下載整個代碼包,緩存命中率極低
這些問題都可以通過對產(chǎn)物做適當?shù)姆纸獠鸢鉀Q,例如 node_modules 中的資源通常變動較少,可以抽成一個獨立的包,那么業(yè)務代碼的頻繁變動不會導致這部分第三方庫資源被無意義地重復加載。為此,Webpack 專門提供了 SplitChunksPlugin 插件,用于實現(xiàn)產(chǎn)物分包。
二、使用 SplitChunksPlugin
SplitChunksPlugin 是 Webpack 4 之后引入的分包方案(此前為 CommonsChunkPlugin),它能夠基于一些啟發(fā)式的規(guī)則將 Module 編排進不同的 Chunk 序列,并最終將應用代碼分門別類打包出多份產(chǎn)物,從而實現(xiàn)分包功能。
使用上,SplitChunksPlugin 的配置規(guī)則比較抽象,算得上 Webpack 的一個難點,仔細拆解后關鍵邏輯在于:
SplitChunksPlugin 通過 module 被引用頻率、chunk 大小、包請求數(shù)三個維度決定是否執(zhí)行分包操作,這些決策都可以通過 optimization.splitChunks 配置項調(diào)整定制,基于這些維度我們可以實現(xiàn):
單獨打包某些特定路徑的內(nèi)容,例如 node_modules 打包為 vendors
單獨打包使用頻率較高的文件
SplitChunksPlugin 還提供配置組概念 optimization.splitChunks.cacheGroup,用于為不同類型的資源設置更有針對性的配置信息
SplitChunksPlugin 還內(nèi)置了 default 與 defaultVendors 兩個配置組,提供一些開箱即用的特性:
node_modules 資源會命中 defaultVendors 規(guī)則,并被單獨打包
只有包體超過 20kb 的 Chunk 才會被單獨打包
加載 Async Chunk 所需請求數(shù)不得超過 30
加載 Initial Chunk 所需請求數(shù)不得超過 30
?
這里所說的請求數(shù)不能等價對標到 http 資源請求數(shù),下文會細講
?
綜上,分包邏輯基本上都圍繞著 Module 與 Chunk 展開,在介紹具體用法之前,有必要回顧一下 Chunk 的基礎知識。
2.1 什么是 Chunk
在《有點難的知識點:Webpack Chunk 分包規(guī)則詳解》一文中,我們已經(jīng)了解到 Chunk 是打包產(chǎn)物的基本組織單位,讀者可以等價認為有多少 Chunk 就會對應生成多少產(chǎn)物(Bundle)。Webpack 內(nèi)部包含三種類型的 Chunk:
Initial Chunk:基于 Entry 配置項生成的 Chunk
Async Chunk:異步模塊引用,如 import(xxx) 語句對應的異步 Chunk
Runtime Chunk:只包含運行時代碼的 Chunk
?
關于運行時的概念,可參考《Webpack 原理系列六:徹底理解 Webpack 運行時》
?
而 SplitChunksPlugin 默認只對 Async Chunk 生效,開發(fā)者也可以通過 optimization.splitChunks.chunks 調(diào)整作用范圍,該配置項支持如下值:
字符串 'all' :對 Initial Chunk 與 Async Chunk 都生效,建議優(yōu)先使用該值
字符串 'initial' :只對 Initial Chunk 生效
字符串 'async' :只對 Async Chunk 生效
函數(shù) (chunk) => boolean :該函數(shù)返回 true 時生效
例如:
module.exports = {
//...
optimization: {
splitChunks: {
chunks: 'all',
},
},
}
2.2 分包策略詳解
2.2.1 根據(jù) Module 使用頻率分包
SplitChunksPlugin 支持按 Module 被 Chunk 引用的次數(shù)決定是否進行分包,開發(fā)者可通過 optimization.splitChunks.minChunks 設定最小引用次數(shù),例如:
module.exports = {
//...
optimization: {
splitChunks: {
// 設定引用次數(shù)超過 4 的模塊才進行分包
minChunks: 3
},
},
}
需要注意,這里“被 Chunk 引用次數(shù)”并不直接等價于被 import 的次數(shù),而是取決于上游調(diào)用者是否被視作 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 引入,此時引用次數(shù)為 3,命中 optimization.splitChunks.minChunks = 2 規(guī)則,因此該模塊「可能」會被單獨分包,最終產(chǎn)物:
entry-a.js
entry-b.js
async-module.js
commont.js
2.2.2 限制分包數(shù)量
在滿足 minChunks 基礎上,還可以通過 maxInitialRequest/maxAsyncRequests 配置項限定分包數(shù)量,配置項語義:
maxInitialRequest:用于設置 Initial Chunk 最大并行請求數(shù)
maxAsyncRequests:用于設置 Async Chunk 最大并行請求數(shù)
這里所說的“請求數(shù)”,是指加載一個 Chunk 時所需同步加載的分包數(shù)。例如對于一個 Chunk A,如果根據(jù)分包規(guī)則(如模塊引用次數(shù)、第三方包)分離出了若干子 Chunk A?,那么請求 A 時,瀏覽器需要同時請求所有的 A?,此時并行請求數(shù)等于 ? 個分包加 A 主包,即 ?+1。
舉個例子,對于上例所說的模塊關系:
若 minChunks = 2 ,則 common 模塊命中 minChunks 規(guī)則被獨立分包,瀏覽器請求 entry-a 時,則需要同時請求 common 包,并行請求數(shù)為 1 + 1=2。
而對于下述模塊關系:
若 minChunks = 2 ,則 common-1 、common-2 同時命中 minChunks 規(guī)則被分別打包,瀏覽器請求 entry-b 時需要同時請求 common-1 、common-2 兩個分包,并行數(shù)為 2 + 1 = 3,此時若 maxInitialRequest = 2,則分包數(shù)超過閾值,SplitChunksPlugin 會放棄 common-1 、common-2 中體積較小的分包。maxAsyncRequest 邏輯與此類似,不在贅述。
并行請求數(shù)關鍵邏輯總結如下:
Initial Chunk 本身算一個請求
Async Chunk 不算并行請求
通過 runtimeChunk 拆分出的 runtime 不算并行請求
如果同時有兩個 Chunk 滿足拆分規(guī)則,但是 maxInitialRequests(或 maxAsyncRequest) 的值只能允許再拆分一個模塊,那么體積更大的模塊會被優(yōu)先拆解
2.2.3 限制分包體積
在滿足 minChunks 與 maxInitialRequests 的基礎上,SplitChunksPlugin 還會進一步判斷 Chunk 包大小決定是否分包,這一規(guī)則相關的配置項非常多:
minSize:超過這個尺寸的 Chunk 才會正式被分包
maxSize:超過這個尺寸的 Chunk 會嘗試繼續(xù)做分包
maxAsyncSize:與 maxSize 功能類似,但只對異步引入的模塊生效
maxInitialSize:與 maxSize 類似,但只對 entry 配置的入口模塊生效
enforceSizeThreshold:超過這個尺寸的 Chunk 會被強制分包,忽略上述其它 size 限制
那么,結合前面介紹的兩種規(guī)則,SplitChunksPlugin 的主體流程如下:
SplitChunksPlugin 嘗試將命中 minChunks 規(guī)則的 Module 統(tǒng)一抽到一個額外的 Chunk 對象;
判斷該 Chunk 是否滿足 maxInitialRequests 閾值,若滿足則進行下一步
判斷該 Chunk 資源的體積是否大于上述配置項 minSize 聲明的下限閾值;
如果體積「小于」 minSize 則取消這次分包,對應的 Module 依然會被合并入原來的 Chunk
如果 Chunk 體積「大于」 minSize 則判斷是否超過 maxSize、maxAsyncSize、maxInitialSize 聲明的上限閾值,如果超過則嘗試將該 Chunk 繼續(xù)分割成更小的部分
?
雖然 maxSize 等上限閾值邏輯會產(chǎn)生更多的包體,但緩存粒度會更小,命中率相對也會更高,配合持久緩存與 HTTP 2 的多路復用能力,網(wǎng)絡性能反而會有正向收益。
?
以上述模塊關系為例:
若此時 Webpack 配置的 minChunks 大于 2,且 maxInitialRequests 也同樣大于 2,如果 common 模塊的體積大于上述說明的 minxSize 配置項則分包成功,commont 會被分離為單獨的 Chunk,否則會被合并入原來的 3 個 Chunk。
?
注意,這些屬性的優(yōu)先級順序為:
maxInitialRequest/maxAsyncRequests < maxSize < minSize
而命中 enforceSizeThreshold 閾值的 Chunk 會直接跳過這些屬性判斷,強制進行分包。
?
2.3 使用
cacheGroups
2.3.1 理解緩存組
除上述 minChunks、maxInitialRequest、minSize 等基礎規(guī)則外,SplitChunksPlugin 還提供了 cacheGroups 配置項用于為不同文件組設置不同的規(guī)則,例如:
module.exports = {
//...
optimization: {
splitChunks: {
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
minChunks: 1,
minSize: 0
}
},
},
},
};
示例通過 cacheGroups 屬性設置 vendors 緩存組,所有命中 vendors.test 規(guī)則的模塊都會被視作 vendors 分組,優(yōu)先應用該組下的 minChunks、minSize 等分包配置。
除了 minChunks 等分包基礎配置項之外,cacheGroups 還支持一些與分組邏輯強相關的屬性,包括:
test:接受正則表達式、函數(shù)及字符串,所有符合 test 判斷的 Module 或 Chunk 都會被分到該組
type:接受正則表達式、函數(shù)及字符串,與 test 類似均用于篩選分組命中的模塊,區(qū)別是它判斷的依據(jù)是文件類型而不是文件名,例如 type = 'json' 會命中所有 JSON 文件
idHint:字符串型,用于設置 Chunk ID,它還會被追加到最終產(chǎn)物文件名中,例如 idHint = 'vendors' 時,輸出產(chǎn)物文件名形如 vendors-xxx-xxx.js
priority:數(shù)字型,用于設置該分組的優(yōu)先級,若模塊命中多個緩存組,則優(yōu)先被分到 priority 更大的組
緩存組的作用在于能為不同類型的資源設置更具適用性的分包規(guī)則,一個典型場景是將所有 node_modules 下的模塊統(tǒng)一打包到 vendors 產(chǎn)物,從而實現(xiàn)第三方庫與業(yè)務代碼的分離。
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 命名的產(chǎn)物
對引用次數(shù)大于等于 2 的模塊,也就是被多個 Chunk 引用的模塊,單獨打包
開發(fā)者也可以將默認分組設置為 false,關閉分組配置,例如:
module.exports = {
//...
optimization: {
splitChunks: {
cacheGroups: {
default: false
},
},
},
};
2.4 配置項回顧
最后,我們再回顧一下 SplitChunksPlugin 支持的配置項:
minChunks:用于設置引用閾值,被引用次數(shù)超過該閾值的 Module 才會進行分包處理
maxInitialRequest/maxAsyncRequests:用于限制 Initial Chunk(或 Async Chunk) 最大并行請求數(shù),本質(zhì)上是在限制最終產(chǎn)生的分包數(shù)量
minSize:超過這個尺寸的 Chunk 才會正式被分包
maxSize:超過這個尺寸的 Chunk 會嘗試繼續(xù)做分包
maxAsyncSize:與 maxSize 功能類似,但只對異步引入的模塊生效
maxInitialSize:與 maxSize 類似,但只對 entry 配置的入口模塊生效
enforceSizeThreshold:超過這個尺寸的 Chunk 會被強制分包,忽略上述其它 size 限制
cacheGroups:用于設置緩存組規(guī)則,為不同類型的資源設置更有針對性的分包策略
三、拆分運行時包
在《Webpack 原理系列六:徹底理解 Webpack 運行時》一文中,已經(jīng)比較深入介紹 Webpack 運行時的概念、組成、作用與生成機制,大致上我們可以將運行時理解為一種補齊模塊化、異步加載等能力的應用骨架,用于支撐 Webpack 產(chǎn)物在各種環(huán)境下的正常運行。
運行時代碼的內(nèi)容由業(yè)務代碼所使用到的特性決定,例如當 Webpack 檢測到業(yè)務代碼中使用了異步加載能力,就會將異步加載相關的運行時注入到產(chǎn)物中,因此業(yè)務代碼用到的特性越多,運行時就會越大,有時甚至可以超過 1M 之多。
此時,可以將 optimization.runtimeChunk 設置為 true,以此將運行時代碼拆分到一個獨立的 Chunk,實現(xiàn)分包。
四、最佳實踐
那么,如何設置最適合項目情況的分包規(guī)則呢?這個問題并沒有放諸四海皆準的通用答案,因為軟件系統(tǒng)與現(xiàn)實世界的復雜性,決定了很多計算機問題并沒有銀彈,不過我個人還是總結了幾條可供參考的最佳實踐:
「盡量將第三方庫拆為獨立分包」
例如在一個 React + Redux 項目中,可想而知應用中的大多數(shù)頁面都會依賴于這兩個庫,那么就應該將它們從具體頁面剝離,避免重復加載。
但對于使用頻率并不高的第三方庫,就需要按實際情況靈活判斷,例如項目中只有某個頁面 A 接入了 Three.js,如果將這個庫跟其它依賴打包在一起,那用戶在訪問其它頁面的時候都需要加載 Three.js,最終效果可能反而得不償失,這個時候可以嘗試使用異步加載功能將 Three.js 獨立分包
「保持按路由分包,減少首屏資源負載」
設想一個超過 10 個頁面的應用,假如將這些頁面代碼全部打包在一起,那么用戶訪問其中任意一個頁面都需要等待其余 9 個頁面的代碼全部加載完畢后才能開始運行應用,這對 TTI 等性能指標明顯是不友好的,所以應該盡量保持按路由維度做異步模塊加載,所幸很多知名框架如 React、Vue 對此都有很成熟的技術支持
「盡量保持」 **chunks = 'all'**
optimization.splitChunks.chunks 配置項用于設置 SplitChunksPlugin 的工作范圍,我們應該盡量保持 chunks = 'all' 從而最大程度優(yōu)化分包邏輯
以上。
作者:范文杰
歡迎關注微信公眾號 :前端陽光