webpack SplitChunksPlugin vue-cli 4 拆包實(shí)戰(zhàn)

干貨篇:
【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、cssimg 等),需采用內(nèi)容摘要算法命名,以開(kāi)啟長(zhǎng)期時(shí)效的強(qiáng)緩存。那就先以文件名配置作鋪墊。

文件以內(nèi)容摘要 hash 值命名以實(shí)現(xiàn)持久緩存

通過(guò)對(duì)output.filenameoutput.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,還需要分別固定moduleIdchunkId,以保持名稱的穩(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 插件的filenamechunkFilename定義文件名,值用 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。

入口依賴的第三方包 chunk

解讀下此配置的拆分實(shí)現(xiàn):

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

針對(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è)異步 chunk 的公共模塊分出的包

這個(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.jsapp.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.jschunk-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.jsElement-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
    }
  },
};
最終打包結(jié)果
現(xiàn)在的`app.js`
異步 chunk 中拆出的公共模塊

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

index.html script 腳本部分

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容