什么是分包?怎么利用分包優(yōu)化

以下文章來(lái)源于Tecvan ,作者范文杰

一、什么是分包
默認(rèn)情況下,Webpack 會(huì)將所有代碼構(gòu)建成一個(gè)單獨(dú)的包,這在小型項(xiàng)目通常不會(huì)有明顯的性能問(wèn)題,但伴隨著項(xiàng)目的推進(jìn),包體積逐步增長(zhǎng)可能會(huì)導(dǎo)致應(yīng)用的響應(yīng)耗時(shí)越來(lái)越長(zhǎng)。歸根結(jié)底這種將所有資源打包成一個(gè)文件的方式存在兩個(gè)弊端:

「資源冗余」:客戶端必須等待整個(gè)應(yīng)用的代碼包都加載完畢才能啟動(dòng)運(yùn)行,但可能用戶當(dāng)下訪問(wèn)的內(nèi)容只需要使用其中一部分代碼
「緩存失效」:將所有資源達(dá)成一個(gè)包后,所有改動(dòng) —— 即使只是修改了一個(gè)字符,客戶端都需要重新下載整個(gè)代碼包,緩存命中率極低
這些問(wèn)題都可以通過(guò)對(duì)產(chǎn)物做適當(dāng)?shù)姆纸獠鸢鉀Q,例如 node_modules 中的資源通常變動(dòng)較少,可以抽成一個(gè)獨(dú)立的包,那么業(yè)務(wù)代碼的頻繁變動(dòng)不會(huì)導(dǎo)致這部分第三方庫(kù)資源被無(wú)意義地重復(fù)加載。為此,Webpack 專門(mén)提供了 SplitChunksPlugin 插件,用于實(shí)現(xiàn)產(chǎn)物分包。

二、使用 SplitChunksPlugin
SplitChunksPlugin 是 Webpack 4 之后引入的分包方案(此前為 CommonsChunkPlugin),它能夠基于一些啟發(fā)式的規(guī)則將 Module 編排進(jìn)不同的 Chunk 序列,并最終將應(yīng)用代碼分門(mén)別類打包出多份產(chǎn)物,從而實(shí)現(xiàn)分包功能。

使用上,SplitChunksPlugin 的配置規(guī)則比較抽象,算得上 Webpack 的一個(gè)難點(diǎn),仔細(xì)拆解后關(guān)鍵邏輯在于:

SplitChunksPlugin 通過(guò) module 被引用頻率、chunk 大小、包請(qǐng)求數(shù)三個(gè)維度決定是否執(zhí)行分包操作,這些決策都可以通過(guò) optimization.splitChunks 配置項(xiàng)調(diào)整定制,基于這些維度我們可以實(shí)現(xiàn):
單獨(dú)打包某些特定路徑的內(nèi)容,例如 node_modules 打包為 vendors
單獨(dú)打包使用頻率較高的文件
SplitChunksPlugin 還提供配置組概念 optimization.splitChunks.cacheGroup,用于為不同類型的資源設(shè)置更有針對(duì)性的配置信息
SplitChunksPlugin 還內(nèi)置了 default 與 defaultVendors 兩個(gè)配置組,提供一些開(kāi)箱即用的特性:
node_modules 資源會(huì)命中 defaultVendors 規(guī)則,并被單獨(dú)打包
只有包體超過(guò) 20kb 的 Chunk 才會(huì)被單獨(dú)打包
加載 Async Chunk 所需請(qǐng)求數(shù)不得超過(guò) 30
加載 Initial Chunk 所需請(qǐng)求數(shù)不得超過(guò) 30
?
這里所說(shuō)的請(qǐng)求數(shù)不能等價(jià)對(duì)標(biāo)到 http 資源請(qǐng)求數(shù),下文會(huì)細(xì)講

?
綜上,分包邏輯基本上都圍繞著 Module 與 Chunk 展開(kāi),在介紹具體用法之前,有必要回顧一下 Chunk 的基礎(chǔ)知識(shí)。

2.1 什么是 Chunk
在《有點(diǎn)難的知識(shí)點(diǎn):Webpack Chunk 分包規(guī)則詳解》一文中,我們已經(jīng)了解到 Chunk 是打包產(chǎn)物的基本組織單位,讀者可以等價(jià)認(rèn)為有多少 Chunk 就會(huì)對(duì)應(yīng)生成多少產(chǎn)物(Bundle)。Webpack 內(nèi)部包含三種類型的 Chunk:

Initial Chunk:基于 Entry 配置項(xiàng)生成的 Chunk
Async Chunk:異步模塊引用,如 import(xxx) 語(yǔ)句對(duì)應(yīng)的異步 Chunk
Runtime Chunk:只包含運(yùn)行時(shí)代碼的 Chunk
?
關(guān)于運(yùn)行時(shí)的概念,可參考《Webpack 原理系列六:徹底理解 Webpack 運(yùn)行時(shí)》

?
而 SplitChunksPlugin 默認(rèn)只對(duì) Async Chunk 生效,開(kāi)發(fā)者也可以通過(guò) optimization.splitChunks.chunks 調(diào)整作用范圍,該配置項(xiàng)支持如下值:

字符串 'all' :對(duì) Initial Chunk 與 Async Chunk 都生效,建議優(yōu)先使用該值
字符串 'initial' :只對(duì) Initial Chunk 生效
字符串 'async' :只對(duì) Async Chunk 生效
函數(shù) (chunk) => boolean :該函數(shù)返回 true 時(shí)生效
例如:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'all',
    },
  },
}
2.2 分包策略詳解
2.2.1 根據(jù) Module 使用頻率分包
SplitChunksPlugin 支持按 Module 被 Chunk 引用的次數(shù)決定是否進(jìn)行分包,開(kāi)發(fā)者可通過(guò) optimization.splitChunks.minChunks 設(shè)定最小引用次數(shù),例如:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      // 設(shè)定引用次數(shù)超過(guò) 4 的模塊才進(jìn)行分包
      minChunks: 3
    },
  },
}
需要注意,這里“被 Chunk 引用次數(shù)”并不直接等價(jià)于被 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
    }
  }
};
上例包含四個(gè)模塊,形成如下模塊關(guān)系圖:








示例中,entry-a、entry-b 分別被視作 Initial Chunk 處理;async-module 被 entry-a 以異步方式引入,因此被視作 Async Chunk 處理。那么對(duì)于 common 模塊來(lái)說(shuō),分別被三個(gè)不同的 Chunk 引入,此時(shí)引用次數(shù)為 3,命中 optimization.splitChunks.minChunks = 2 規(guī)則,因此該模塊「可能」會(huì)被單獨(dú)分包,最終產(chǎn)物:

entry-a.js
entry-b.js
async-module.js
commont.js
2.2.2 限制分包數(shù)量
在滿足 minChunks 基礎(chǔ)上,還可以通過(guò) maxInitialRequest/maxAsyncRequests 配置項(xiàng)限定分包數(shù)量,配置項(xiàng)語(yǔ)義:

maxInitialRequest:用于設(shè)置 Initial Chunk 最大并行請(qǐng)求數(shù)
maxAsyncRequests:用于設(shè)置 Async Chunk 最大并行請(qǐng)求數(shù)
這里所說(shuō)的“請(qǐng)求數(shù)”,是指加載一個(gè) Chunk 時(shí)所需同步加載的分包數(shù)。例如對(duì)于一個(gè) Chunk A,如果根據(jù)分包規(guī)則(如模塊引用次數(shù)、第三方包)分離出了若干子 Chunk A?,那么請(qǐng)求 A 時(shí),瀏覽器需要同時(shí)請(qǐng)求所有的 A?,此時(shí)并行請(qǐng)求數(shù)等于 ? 個(gè)分包加 A 主包,即 ?+1。

舉個(gè)例子,對(duì)于上例所說(shuō)的模塊關(guān)系:



若 minChunks = 2 ,則 common 模塊命中 minChunks 規(guī)則被獨(dú)立分包,瀏覽器請(qǐng)求 entry-a 時(shí),則需要同時(shí)請(qǐng)求 common 包,并行請(qǐng)求數(shù)為 1 + 1=2。

而對(duì)于下述模塊關(guān)系:



若 minChunks = 2 ,則 common-1 、common-2 同時(shí)命中 minChunks 規(guī)則被分別打包,瀏覽器請(qǐng)求 entry-b 時(shí)需要同時(shí)請(qǐng)求 common-1 、common-2 兩個(gè)分包,并行數(shù)為 2 + 1 = 3,此時(shí)若 maxInitialRequest = 2,則分包數(shù)超過(guò)閾值,SplitChunksPlugin 會(huì)放棄 common-1 、common-2 中體積較小的分包。maxAsyncRequest 邏輯與此類似,不在贅述。

并行請(qǐng)求數(shù)關(guān)鍵邏輯總結(jié)如下:

Initial Chunk 本身算一個(gè)請(qǐng)求
Async Chunk 不算并行請(qǐng)求
通過(guò) runtimeChunk 拆分出的 runtime 不算并行請(qǐng)求
如果同時(shí)有兩個(gè) Chunk 滿足拆分規(guī)則,但是 maxInitialRequests(或 maxAsyncRequest) 的值只能允許再拆分一個(gè)模塊,那么體積更大的模塊會(huì)被優(yōu)先拆解
2.2.3 限制分包體積
在滿足 minChunks 與 maxInitialRequests 的基礎(chǔ)上,SplitChunksPlugin 還會(huì)進(jìn)一步判斷 Chunk 包大小決定是否分包,這一規(guī)則相關(guān)的配置項(xiàng)非常多:

minSize:超過(guò)這個(gè)尺寸的 Chunk 才會(huì)正式被分包
maxSize:超過(guò)這個(gè)尺寸的 Chunk 會(huì)嘗試?yán)^續(xù)做分包
maxAsyncSize:與 maxSize 功能類似,但只對(duì)異步引入的模塊生效
maxInitialSize:與 maxSize 類似,但只對(duì) entry 配置的入口模塊生效
enforceSizeThreshold:超過(guò)這個(gè)尺寸的 Chunk 會(huì)被強(qiáng)制分包,忽略上述其它 size 限制
那么,結(jié)合前面介紹的兩種規(guī)則,SplitChunksPlugin 的主體流程如下:

SplitChunksPlugin 嘗試將命中 minChunks 規(guī)則的 Module 統(tǒng)一抽到一個(gè)額外的 Chunk 對(duì)象;
判斷該 Chunk 是否滿足 maxInitialRequests 閾值,若滿足則進(jìn)行下一步
判斷該 Chunk 資源的體積是否大于上述配置項(xiàng) minSize 聲明的下限閾值;
如果體積「小于」 minSize 則取消這次分包,對(duì)應(yīng)的 Module 依然會(huì)被合并入原來(lái)的 Chunk
如果 Chunk 體積「大于」 minSize 則判斷是否超過(guò) maxSize、maxAsyncSize、maxInitialSize 聲明的上限閾值,如果超過(guò)則嘗試將該 Chunk 繼續(xù)分割成更小的部分
?
雖然 maxSize 等上限閾值邏輯會(huì)產(chǎn)生更多的包體,但緩存粒度會(huì)更小,命中率相對(duì)也會(huì)更高,配合持久緩存與 HTTP 2 的多路復(fù)用能力,網(wǎng)絡(luò)性能反而會(huì)有正向收益。

?
以上述模塊關(guān)系為例:



若此時(shí) Webpack 配置的 minChunks 大于 2,且 maxInitialRequests 也同樣大于 2,如果 common 模塊的體積大于上述說(shuō)明的 minxSize 配置項(xiàng)則分包成功,commont 會(huì)被分離為單獨(dú)的 Chunk,否則會(huì)被合并入原來(lái)的 3 個(gè) Chunk。

?
注意,這些屬性的優(yōu)先級(jí)順序?yàn)椋?br>
maxInitialRequest/maxAsyncRequests < maxSize < minSize

而命中 enforceSizeThreshold 閾值的 Chunk 會(huì)直接跳過(guò)這些屬性判斷,強(qiáng)制進(jìn)行分包。

?





2.3 使用
cacheGroups
2.3.1 理解緩存組
除上述 minChunks、maxInitialRequest、minSize 等基礎(chǔ)規(guī)則外,SplitChunksPlugin 還提供了 cacheGroups 配置項(xiàng)用于為不同文件組設(shè)置不同的規(guī)則,例如:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendors: {
            test: /[\\/]node_modules[\\/]/,
            minChunks: 1,
            minSize: 0
        }
      },
    },
  },
};
示例通過(guò) cacheGroups 屬性設(shè)置 vendors 緩存組,所有命中 vendors.test 規(guī)則的模塊都會(huì)被視作 vendors 分組,優(yōu)先應(yīng)用該組下的 minChunks、minSize 等分包配置。

除了 minChunks 等分包基礎(chǔ)配置項(xiàng)之外,cacheGroups 還支持一些與分組邏輯強(qiáng)相關(guān)的屬性,包括:

test:接受正則表達(dá)式、函數(shù)及字符串,所有符合 test 判斷的 Module 或 Chunk 都會(huì)被分到該組
type:接受正則表達(dá)式、函數(shù)及字符串,與 test 類似均用于篩選分組命中的模塊,區(qū)別是它判斷的依據(jù)是文件類型而不是文件名,例如 type = 'json' 會(huì)命中所有 JSON 文件
idHint:字符串型,用于設(shè)置 Chunk ID,它還會(huì)被追加到最終產(chǎn)物文件名中,例如 idHint = 'vendors' 時(shí),輸出產(chǎn)物文件名形如 vendors-xxx-xxx.js
priority:數(shù)字型,用于設(shè)置該分組的優(yōu)先級(jí),若模塊命中多個(gè)緩存組,則優(yōu)先被分到 priority 更大的組
緩存組的作用在于能為不同類型的資源設(shè)置更具適用性的分包規(guī)則,一個(gè)典型場(chǎng)景是將所有 node_modules 下的模塊統(tǒng)一打包到 vendors 產(chǎn)物,從而實(shí)現(xiàn)第三方庫(kù)與業(yè)務(wù)代碼的分離。

2.3.2 默認(rèn)分組
Webpack 提供了兩個(gè)開(kāi)箱即用的 cacheGroups,分別命名為 default 與 defaultVendors,默認(rèn)配置:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      cacheGroups: {
        default: {
          idHint: "",
          reuseExistingChunk: true,
          minChunks: 2,
          priority: -20
        },
        defaultVendors: {
          idHint: "vendors",
          reuseExistingChunk: true,
          test: /[\\/]node_modules[\\/]/i,
          priority: -10
        }
      },
    },
  },
};
這兩個(gè)配置組能幫助我們:

將所有 node_modules 中的資源單獨(dú)打包到 vendors-xxx-xx.js 命名的產(chǎn)物
對(duì)引用次數(shù)大于等于 2 的模塊,也就是被多個(gè) Chunk 引用的模塊,單獨(dú)打包
開(kāi)發(fā)者也可以將默認(rèn)分組設(shè)置為 false,關(guān)閉分組配置,例如:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      cacheGroups: {
        default: false
      },
    },
  },
};
2.4 配置項(xiàng)回顧
最后,我們?cè)倩仡櫼幌?SplitChunksPlugin 支持的配置項(xiàng):

minChunks:用于設(shè)置引用閾值,被引用次數(shù)超過(guò)該閾值的 Module 才會(huì)進(jìn)行分包處理
maxInitialRequest/maxAsyncRequests:用于限制 Initial Chunk(或 Async Chunk) 最大并行請(qǐng)求數(shù),本質(zhì)上是在限制最終產(chǎn)生的分包數(shù)量
minSize:超過(guò)這個(gè)尺寸的 Chunk 才會(huì)正式被分包
maxSize:超過(guò)這個(gè)尺寸的 Chunk 會(huì)嘗試?yán)^續(xù)做分包
maxAsyncSize:與 maxSize 功能類似,但只對(duì)異步引入的模塊生效
maxInitialSize:與 maxSize 類似,但只對(duì) entry 配置的入口模塊生效
enforceSizeThreshold:超過(guò)這個(gè)尺寸的 Chunk 會(huì)被強(qiáng)制分包,忽略上述其它 size 限制
cacheGroups:用于設(shè)置緩存組規(guī)則,為不同類型的資源設(shè)置更有針對(duì)性的分包策略
三、拆分運(yùn)行時(shí)包
在《Webpack 原理系列六:徹底理解 Webpack 運(yùn)行時(shí)》一文中,已經(jīng)比較深入介紹 Webpack 運(yùn)行時(shí)的概念、組成、作用與生成機(jī)制,大致上我們可以將運(yùn)行時(shí)理解為一種補(bǔ)齊模塊化、異步加載等能力的應(yīng)用骨架,用于支撐 Webpack 產(chǎn)物在各種環(huán)境下的正常運(yùn)行。

運(yùn)行時(shí)代碼的內(nèi)容由業(yè)務(wù)代碼所使用到的特性決定,例如當(dāng) Webpack 檢測(cè)到業(yè)務(wù)代碼中使用了異步加載能力,就會(huì)將異步加載相關(guān)的運(yùn)行時(shí)注入到產(chǎn)物中,因此業(yè)務(wù)代碼用到的特性越多,運(yùn)行時(shí)就會(huì)越大,有時(shí)甚至可以超過(guò) 1M 之多。

此時(shí),可以將 optimization.runtimeChunk 設(shè)置為 true,以此將運(yùn)行時(shí)代碼拆分到一個(gè)獨(dú)立的 Chunk,實(shí)現(xiàn)分包。

四、最佳實(shí)踐
那么,如何設(shè)置最適合項(xiàng)目情況的分包規(guī)則呢?這個(gè)問(wèn)題并沒(méi)有放諸四海皆準(zhǔn)的通用答案,因?yàn)檐浖到y(tǒng)與現(xiàn)實(shí)世界的復(fù)雜性,決定了很多計(jì)算機(jī)問(wèn)題并沒(méi)有銀彈,不過(guò)我個(gè)人還是總結(jié)了幾條可供參考的最佳實(shí)踐:

「盡量將第三方庫(kù)拆為獨(dú)立分包」
例如在一個(gè) React + Redux 項(xiàng)目中,可想而知應(yīng)用中的大多數(shù)頁(yè)面都會(huì)依賴于這兩個(gè)庫(kù),那么就應(yīng)該將它們從具體頁(yè)面剝離,避免重復(fù)加載。

但對(duì)于使用頻率并不高的第三方庫(kù),就需要按實(shí)際情況靈活判斷,例如項(xiàng)目中只有某個(gè)頁(yè)面 A 接入了 Three.js,如果將這個(gè)庫(kù)跟其它依賴打包在一起,那用戶在訪問(wèn)其它頁(yè)面的時(shí)候都需要加載 Three.js,最終效果可能反而得不償失,這個(gè)時(shí)候可以嘗試使用異步加載功能將 Three.js 獨(dú)立分包

「保持按路由分包,減少首屏資源負(fù)載」
設(shè)想一個(gè)超過(guò) 10 個(gè)頁(yè)面的應(yīng)用,假如將這些頁(yè)面代碼全部打包在一起,那么用戶訪問(wèn)其中任意一個(gè)頁(yè)面都需要等待其余 9 個(gè)頁(yè)面的代碼全部加載完畢后才能開(kāi)始運(yùn)行應(yīng)用,這對(duì) TTI 等性能指標(biāo)明顯是不友好的,所以應(yīng)該盡量保持按路由維度做異步模塊加載,所幸很多知名框架如 React、Vue 對(duì)此都有很成熟的技術(shù)支持

「盡量保持」 **chunks = 'all'**
optimization.splitChunks.chunks 配置項(xiàng)用于設(shè)置 SplitChunksPlugin 的工作范圍,我們應(yīng)該盡量保持 chunks = 'all' 從而最大程度優(yōu)化分包邏輯

以上。

作者:范文杰


歡迎關(guān)注微信公眾號(hào) :前端陽(yáng)光