干貨篇:
【webpack SplitChunksPlugin 配置詳解】
【前端性能優(yōu)化探討及瀏覽器緩存機(jī)制】文末已經(jīng)厘清,項(xiàng)目打包時(shí)要合理地合并/拆分 js,旨在控制單個(gè)資源體積的同時(shí)保證盡量少的請(qǐng)求次數(shù)( js 個(gè)數(shù)),避免請(qǐng)求高并發(fā)和資源過(guò)大導(dǎo)致阻塞加載。
然而光整js拆包還不夠,最終輸出的靜態(tài)資源文件 (js、css、img 等),需采用內(nèi)容摘要算法命名,以開(kāi)啟長(zhǎng)期時(shí)效的強(qiáng)緩存。那就先以文件名配置作鋪墊。
文件以內(nèi)容摘要 hash 值命名以實(shí)現(xiàn)持久緩存
通過(guò)對(duì)output.filename和output.chunkFilename的配置,利用[contenthash]占位符,為js文件名加上根據(jù)其內(nèi)容生成的唯一 hash 值,輕松實(shí)現(xiàn)資源的長(zhǎng)效緩存。也就是說(shuō),無(wú)論是第幾次打包,內(nèi)容沒(méi)有變化的資源 (如js、css) 文件名永遠(yuǎn)不會(huì)變,而那些有修改的文件就會(huì)生成新的文件名 (hash 值) 。
module.exports = {
output: {
path: __dirname + '/dist',
filename: '[name].[contenthash:6].js',
chunkFilename: '[name].[contenthash:8].js',
},
}
如果是 webpack 4,還需要分別固定
moduleId和chunkId,以保持名稱的穩(wěn)定性。
因?yàn)?webpack 內(nèi)部維護(hù)了一個(gè)自增的數(shù)字 id,每個(gè) module 都有一個(gè) id。當(dāng)增加或刪除 module 的時(shí)候,id 就會(huì)變化,導(dǎo)致其它 module 雖然內(nèi)容沒(méi)有變化,但由于 id 被強(qiáng)占,只能自增或者自減,導(dǎo)致整個(gè)項(xiàng)目的 module id 的順序都錯(cuò)亂了。
也就是說(shuō),如果引入了一個(gè)新模塊或刪掉一個(gè)模塊,都可能導(dǎo)致其它文件的 moduleId 發(fā)生改變,相應(yīng)地文件內(nèi)容也就改變,緩存便失效了。
同樣地,chunk 的新增/減少也會(huì)導(dǎo)致 chunk id 順序發(fā)生錯(cuò)亂,那么原本的緩存就不作數(shù)了。
解決辦法:
-
moduleId:
HashedModuleIdsPlugin插件 (webpack 4) →optimization.moduleIds: 'deterministic'(webpack 5)
在 webpack 5 無(wú)需額外配置,使用默認(rèn)值就好。 -
chunkId:
[NamedChunksPlugin]()插件 (webpack 4) →optimization.chunkIds(webpack 5)
但這個(gè)方法只對(duì)命名 chunk 有效,我們的懶加載頁(yè)面生成的 chunk 還需要額外設(shè)置,如vue-cli 4的處理:
// node_modules/@vue/cli-service/lib/config/app.js
chainWebpack: config => {
config
.plugin('named-chunks')
.use(require('webpack/lib/NamedChunksPlugin'), [chunk => {
if (chunk.name) {
return chunk.name
}
const hash = require('hash-sum')
const joinedHash = hash(
Array.from(chunk.modulesIterable, m => m.id).join('_')
)
return `chunk-` + joinedHash
}])
}
在 webpack 5 optimization.chunkIds默認(rèn)開(kāi)發(fā)環(huán)境'named',生產(chǎn)環(huán)境'deterministic',因此我們無(wú)需設(shè)置該配置項(xiàng)。而且 webpack 5 更改了 id 生成算法,異步 chunk 也能輕松擁有固定的 id 了。
至于圖片和 CSS 文件
- CSS 是通過(guò) mini-css-extract-plugin 插件的
filename和chunkFilename定義文件名,值用 hash 占位符如[contenthash:8]實(shí)現(xiàn)緩存配置的。 - 而圖片文件,是在 file-loader 的 name 配置項(xiàng)用
[contenthash]處理的。
注 ??:webpack 5 廢棄了 file-loader,改用output.assetModuleFilename定義圖片字體等資源文件的名稱,如assetModuleFilename: 'images/[contenthash][ext][query]'。
可以去看看 vue-cli 4 源碼 @vue/cli-service/lib/config/下的配置處理,或者瞅【file-loader 配置詳解以及資源相對(duì)路徑處理】這篇,這里不詳述。
SplitChunksPlugin 拆包實(shí)戰(zhàn)
回歸正題來(lái)講代碼分包。
用 SplitChunksPlugin 插件控制 webpack 打包輸出的精髓就在于,提取公共代碼,防止模塊被重復(fù)打包、拆分過(guò)大的 js 文件、合并零散的 js 文件。但 js 體積和數(shù)量都要小這倆目標(biāo)是相矛盾的,因此并沒(méi)有標(biāo)準(zhǔn)的方案,需運(yùn)用中庸之道,結(jié)合項(xiàng)目的實(shí)際情況去找到最合適的拆包策略。
vue-cli 4 默認(rèn)處理
結(jié)合我用 vue-cli 4 搭的項(xiàng)目,來(lái)看下 vue-cli 通過(guò) chainWebpack 覆蓋掉 SplitChunksPlugin cacheGroups項(xiàng)默認(rèn)值的配置(整理后):
(vue-cli chainWebpack配置處大致是node_modules/@vue/cli-service/lib/config/app.js:38)
module.exports = {
entry: {
app: './src/main',
},
output: {
path: __dirname + '/dist',
filename: 'static/js/[name].[contenthash:8].js',
chunkFilename: 'static/js/[name].[contenthash:8].js',
},
optimization: {
splitChunks: {
chunks: 'async', // 只處理異步 chunk,這里兩個(gè)緩存組都另配了 chunks,那么就被無(wú)視了
minSize: 30000, // 允許新拆出 chunk 的最小體積
maxSize: 0, // 旨在與 HTTP/2 和長(zhǎng)期緩存一起使用。它增加了請(qǐng)求數(shù)量以實(shí)現(xiàn)更好的緩存。它還可以用于減小文件大小,以加快二次構(gòu)建速度。
minChunks: 1, // 拆分前被 chunk 公用的最小次數(shù)
maxAsyncRequests: 5, // 每個(gè)異步加載模塊最多能被拆分的數(shù)量
maxInitialRequests: 3, // 每個(gè)入口和它的同步依賴最多能被拆分的數(shù)量
automaticNameDelimiter: '~',
cacheGroups: { // 緩存組
vendors: {
name: `chunk-vendors`,
test: /[\\/]node_modules[\\/]/,
priority: -10, // 緩存組權(quán)重,數(shù)字越大優(yōu)先級(jí)越高
chunks: 'initial' // 只處理初始 chunk
},
common: {
name: `chunk-common`,
minChunks: 2, // common 組的模塊必須至少被 2 個(gè) chunk 共用 (本次分割前)
priority: -20,
chunks: 'initial', // 只針對(duì)同步 chunk
reuseExistingChunk: true // 復(fù)用已被拆出的依賴模塊,而不是繼續(xù)包含在該組一起生成
}
},
},
},
};
我們配置了 webpack-bundle-analyzer 插件,便于觀察和分析打包結(jié)果。

運(yùn)行打包后,發(fā)現(xiàn)入口文件依賴的第三方包被全數(shù)拆出放進(jìn)了chunk-vendors.js,剩下的同步依賴都被打包進(jìn)了app.js,而其他都是懶加載組件生成的異步 chunk。并沒(méi)有打包出所謂的公共模塊合集chunk-common.js。

解讀下此配置的拆分實(shí)現(xiàn):
- 入口來(lái)自 node_modules 文件夾的同步依賴放入
chunk-vendors; - 被至少 2 個(gè) 同步 chunk 共享的模塊放入
chunk-common; - 符合每個(gè)緩存組其他條件的情況下,能拆出的模塊整合后的體積必須大于
30kb(在進(jìn)行 min+gz 之前的體積)。小了不生成新 chunk。 - 每個(gè)異步引入模塊并行請(qǐng)求的數(shù)量 (即它本身和它的同步依賴被拆分成的 js 個(gè)數(shù))不能多于
5個(gè);每個(gè)入口文件和它的同步依賴最多能被拆成3個(gè) js。 - 即使不匹配任何一個(gè)緩存組,splitChunks.* 級(jí)別的最小 chunk 屬性
minSize也會(huì)影響所有異步 chunk,效果是體積大于minSize值的公共模塊會(huì)被拆出。(除非 splitChunks.*chunks: 'initial')
公共模塊即>= 2個(gè)異步 chunk 共享的模塊,同minChunks: 2。

針對(duì) 3、4 兩點(diǎn)作特別說(shuō)明:vue-cli 4 內(nèi)置 webpack 4,而 webpack 5 的 SplitChunksPlugin 的默認(rèn)配置是不同的,如
minSize: 20000, maxAsyncRequests: 30, maxInitialRequests: 30, enforceSizeThreshold: 50000。而maxSize默認(rèn)值即為 0,不用像 webpack 4 這樣額外設(shè)置。enforceSizeThreshold的用途是體積大于該值就對(duì) chunk 進(jìn)行強(qiáng)制拆分 (默認(rèn)值約50kb)。
體積大于 maxSize 的 chunk 便能被拆分,為 0 表示不設(shè)限。因此只是作為一個(gè)提示存在,在 webpack 5 便被弱化了。同時(shí)需要滿足的是 chunk 能拆出的模塊不小于minSize值。
綜上,webpack 5 能讓 chunk 在合理的范圍更細(xì)粒度地拆分,以便更好地支持和利用HTTP/2來(lái)進(jìn)行長(zhǎng)緩存。 故 3、4 兩點(diǎn)我們會(huì)根據(jù)當(dāng)下標(biāo)準(zhǔn)重新配置。
所以查 Api 的時(shí)候切記要弄清版本。
同時(shí)我們發(fā)現(xiàn),部分 node_modules 包被重復(fù)打包進(jìn)了一些異步加載的 js 中 (如下)。

這個(gè) js 是根據(jù)上面第 5 點(diǎn)生成的,另如果對(duì)異步 chunk 名字有疑問(wèn),是我在動(dòng)態(tài)引入的時(shí)候用了 webpackChunkName magic comment(魔術(shù)注釋)。此處為兩個(gè)異步 chunk 名用'~'分隔符連接是為了說(shuō)明模塊來(lái)源,也是 webpack 的自行處理。
【SplitChunksPlugin 干貨篇】已經(jīng)講得很詳盡,這里不再重復(fù)。

它其實(shí)是兩個(gè)異步模塊guide-add、guide-edit共同引用的組件,由于體積過(guò)大 (超過(guò)minSize) 被 webpack 單獨(dú)拆分出來(lái)。而且據(jù)觀察其實(shí)大部分懶加載組件都未引入第三包,那這個(gè)code-js的重復(fù)就更顯得突兀和沒(méi)有必要了。
這和沒(méi)有打包出任何公共模塊(chunk-common) ,都是chunks: 'initial'的鍋。這倆緩存組都只負(fù)責(zé)拆入口 (entry point) 和其同步依賴的模塊,異步 chunk 里的第三方自然拆不出來(lái)。而且單入口的情況默認(rèn)生成的 initial chunk 只有一個(gè),上哪和其他同步 chunk 共享模塊呀 (minChunks: 2的意思是至少 2 個(gè) chunk 共同引入的同步模塊) 。
必須清楚
minChunks的共用是面向 chunk 的,有些文章會(huì)誤寫(xiě)成模塊之間共享。同時(shí)了解 SplitChunksPlugin 拆包前 webpack 對(duì)于 chunk 的初始分包狀態(tài)也至關(guān)重要。不清楚可以 ?? 【webpack SplitChunksPlugin 配置詳解】 開(kāi)篇處)。
還有chunk-vendors.js和app.js的體積都太大了,特別是初始第三方包竟有 841kb。非常不利于首屏加載的響應(yīng)速度。以上說(shuō)明 vue-cli 4 的處理還是有些不盡人意,那我們來(lái)自行優(yōu)化看看吧。
拆包優(yōu)化
再回顧下這張圖:

基礎(chǔ)類庫(kù) chunk-libs
構(gòu)成項(xiàng)目必不可少的一些基礎(chǔ)類庫(kù),如vue+vue-router+vuex+axios這種標(biāo)準(zhǔn)的全家桶,它們的升級(jí)頻率都不高,但每個(gè)頁(yè)面都需要它們。(一些全局被共用的,體積不大的第三方庫(kù)也可以放在其中:比如nprogress、js-cookie等)UI 組件庫(kù)
理論上 UI 組件庫(kù)也可以放入 libs 中,但它實(shí)在是過(guò)大,不管是Element-UI還是Ant Designgzip 壓縮完都要 200kb 左右,可能比 libs 里所有的包加起來(lái)還要大不少,而且 UI 組件庫(kù)的更新頻率也相對(duì)比 libs 要更高一點(diǎn)。我們會(huì)及時(shí)更新它來(lái)解決一些現(xiàn)有的 bugs 或使用一些新功能。所以建議將 UI 組件庫(kù)單獨(dú)拆成一個(gè)包。自定義組件/函數(shù) chunk-commons
這里的 commons 分為 必要和非必要。
必要組件是指那些項(xiàng)目里必須加載它們才能正常運(yùn)行的組件或者函數(shù)。比如你的路由表、全局 state、全局側(cè)邊欄/Header/Footer 等組件、自定義 Svg 圖標(biāo)等等。這些其實(shí)就是你在入口文件中依賴的東西,它們都會(huì)默認(rèn)打包到app.js中。
非必要組件是指被大部分懶加載頁(yè)面使用,但在入口文件 entry 中未被引入的模塊。比如:一個(gè)管理后臺(tái),你封裝了很多select或者table組件,由于它們的體積不會(huì)很大,它們都會(huì)被默認(rèn)打包到到每一個(gè)懶加載頁(yè)面的 chunk 中,這樣會(huì)造成不少的浪費(fèi)。你有十個(gè)頁(yè)面引用了它,就會(huì)包重復(fù)打包十次。所以應(yīng)該將那些被大量共用的組件單獨(dú)打包成chunk-commons。
不過(guò)還是要結(jié)合具體情況來(lái)看。一般情況下,你也可以將那些非必要組件/函數(shù)也在入口文件 entry 中引入,和必要組件/函數(shù)一同打包到app.js之中也是沒(méi)什么問(wèn)題的。低頻組件
低頻組件和上面的自定義公共組件chunk-commons最大的區(qū)別是,它們只會(huì)在一些特定業(yè)務(wù)場(chǎng)景下使用,比如富文本編輯器、js-xlsx前端 excel 處理庫(kù)等。一般這些庫(kù)都是第三方的且大于30kb(緩存組外的默認(rèn)minSize值),也不會(huì)在初始頁(yè)加載,所以 webpack 4 會(huì)默認(rèn)打包成一個(gè)獨(dú)立的 js。一般無(wú)需特別處理。小于minSize的情況會(huì)被打包到具體使用它的頁(yè)面 js (異步 chunk) 中。業(yè)務(wù)代碼
就是我們平時(shí)經(jīng)常寫(xiě)的業(yè)務(wù)代碼。一般都是按照頁(yè)面的劃分來(lái)打包,比如在 vue 中,使用路由懶加載的方式加載頁(yè)面component: () => import('./Guide.vue')webpack 默認(rèn)會(huì)將它打包成一個(gè)獨(dú)立的異步加載的 js。

再回觀我們之前的app.js和chunk-vendors.js。它們都是初始加載的 js,由于體積太大需要在合理范圍內(nèi)拆分成更小一些的 js,以利用瀏覽器的并發(fā)請(qǐng)求,優(yōu)化首頁(yè)加載體驗(yàn)。
- 為了縮減初始代碼體積,通常只抽入口依賴的第三方、另行處理懶加載頁(yè)面的庫(kù)依賴更為合理。而我的項(xiàng)目中除了重復(fù)的一個(gè),異步模塊無(wú)其他第三方引入。就簡(jiǎn)單交由
commons緩存組去處理。vue 我通過(guò) webpack 的 externals 配了 CDN,故沒(méi)有打包進(jìn)來(lái)。 -
chunk-vendors.js的Element-UI組件庫(kù)應(yīng)單獨(dú)分出為chunk-elementUI.js,由于它包含在第三方包的緩存組內(nèi),要給它設(shè)置比libs更高的優(yōu)先級(jí)。 -
app.js中圖標(biāo)占了大頭可以單獨(dú)抽出來(lái),把自定義 svg 都放到chunk-svgIcon.js中; - 備一個(gè)優(yōu)先級(jí)最低的
chunk-commons.js,用于處理其他公共組件
splitChunks: {
chunks: "all",
minSize: 20000, // 允許新拆出 chunk 的最小體積,也是異步 chunk 公共模塊的強(qiáng)制拆分體積
maxAsyncRequests: 6, // 每個(gè)異步加載模塊最多能被拆分的數(shù)量
maxInitialRequests: 6, // 每個(gè)入口和它的同步依賴最多能被拆分的數(shù)量
enforceSizeThreshold: 50000, // 強(qiáng)制執(zhí)行拆分的體積閾值并忽略其他限制
cacheGroups: {
libs: { // 第三方庫(kù)
name: "chunk-libs",
test: /[\\/]node_modules[\\/]/,
priority: 10,
chunks: "initial" // 只打包初始時(shí)依賴的第三方
},
elementUI: { // elementUI 單獨(dú)拆包
name: "chunk-elementUI",
test: /[\\/]node_modules[\\/]element-ui[\\/]/,
priority: 20 // 權(quán)重要大于 libs
},
svgIcon: { // svg 圖標(biāo)
name: 'chunk-svgIcon',
test(module) {
// `module.resource` 是文件的絕對(duì)路徑
// 用`path.sep` 代替 / or \,以便跨平臺(tái)兼容
// const path = require('path') // path 一般會(huì)在配置文件引入,此處只是說(shuō)明 path 的來(lái)源,實(shí)際并不用加上
return (
module.resource &&
module.resource.endsWith('.svg') &&
module.resource.includes(`${path.sep}icons${path.sep}`)
)
},
priority: 30
},
commons: { // 公共模塊包
name: `chunk-commons`,
minChunks: 2,
priority: 0,
reuseExistingChunk: true
}
},
};



格式美化后的index.html引入的 js 如下:

當(dāng)然還可以更細(xì)化地拆分,比如拆出全局組件、第三方里再拆出個(gè)較大的包/或者直接用 CDN 引入。其實(shí)優(yōu)化就是一個(gè)博弈的過(guò)程,抉擇讓 a bundle 大一點(diǎn)還是 b bundle? 是讓首次加載快一點(diǎn)還是讓 cache 的利用率高一點(diǎn)?不要過(guò)度追求顆粒化的前提下,盡量利用瀏覽器緩存就可以啦。