「Webpack」從0到1學(xué)會 Code Splitting
本文轉(zhuǎn)載自微信公眾號「微醫(yī)大前端技術(shù)」,作者焦傳鍇。轉(zhuǎn)載本文請聯(lián)系微醫(yī)大前端技術(shù)公眾號。
一、前言
在默認(rèn)的配置情況下,我們知道,webpack 會把所有代碼打包到一個 chunk 中,舉個例子當(dāng)你的一個單頁面應(yīng)用很大的時候,你可能就需要將每個路由拆分到一個 chunk 中,這樣才方便我們實現(xiàn)按需加載。
代碼分離是 webpack 中最引人注目的特性之一。此特性能夠把代碼分離到不同的 bundle 中,然后可以按需加載或并行加載這些文件。代碼分離可以用于獲取更小的 bundle,以及控制資源加載優(yōu)先級,如果使用合理,會極大影響加載時間。
二、關(guān)于代碼分割
接下來我們會分別分析不同的代碼分隔方式帶來的打包差異,首先我們的項目假設(shè)有這兩個簡單的文件??
index.js
- import { mul } from './test'
- import $ from 'jquery'
- console.log($)
- console.log(mul(2, 3))
test.js
- import $ from 'jquery'
- console.log($)
- function mul(a, b) {
- return a * b
- }
- export { mul }
可以看到現(xiàn)在他們二者都依賴于 jquery 這個庫,并且相互之間也會有依賴。
當(dāng)我們在默認(rèn)配置的情況下進(jìn)行打包,結(jié)果是這樣的??,會把所有內(nèi)容打包進(jìn)一個 main bundle 內(nèi)(324kb)
那么我們?nèi)绾斡米钪苯拥姆绞綇倪@個 bundle 中分離出其他模塊呢?
1. 多入口
webpack 配置中的 entry ,可以設(shè)置為多個,也就是說我們可以分別將 index 和 test 文件分別作為入口:
- // entry: './src/index.js', 原來的單入口
- /** 現(xiàn)在分別將它們作為入口 */
- entry:{
- index:'./src/index.js',
- test:'./src/test.js'
- },
- output: {
- filename: '[name].[hash:8].js',
- path: path.resolve(__dirname, './dist'),
- },
這樣讓我們看一下這樣打包后的結(jié)果:
確實打包出了兩個文件!但是為什么兩個文件都有 320+kb 呢?不是說好拆分獲取更小的 bundle ?這是因為由于二者都引入了 jquery 而 webpack 從兩次入口進(jìn)行打包分析的時候會每次都將依賴的模塊分別打包進(jìn)去
沒錯,這種配置的方式確實會帶來一些隱患以及不便:
- 如果入口 chunk 之間包含一些重復(fù)的模塊,那些重復(fù)模塊都會被引入到各個 bundle 中。
- 這種方法不夠靈活,并且不能動態(tài)地將核心應(yīng)用程序邏輯中的代碼拆分出來。
那么有沒有方式可以既可以將共同依賴的模塊進(jìn)行打包分離,又不用進(jìn)行繁瑣的手動配置入口的方式呢?那必然是有的。
2. SplitChunksPlugin
SplitChunks 是 webpack5 自帶的開箱即用的一個插件,他可以將滿足規(guī)則的 chunk 進(jìn)行分離,也可以自定義配置。在 webpack5 中用它取代了 webpack4 中的用來解決重復(fù)依賴的 CommonsChunkPlugin 。
讓我們在我們的 webpack 配置中加上一些配置:
- entry: './src/index.js', // 這里我們改回單入口
- /** 加上如下設(shè)置 */
- optimization: {
- splitChunks: {
- chunks: 'all',
- },
- },
打包后的結(jié)果如圖:
可以看到很明顯除了根據(jù)入口打包出的 main bundle 之外,還多出了一個名為 vendors-node_modules_jquery_dist_jquery_js.xxxxx.js ,顯然這樣我們將公用的 jquery 模塊就提取出來了。
接下來我們來探究一下 SplitChunksPlugin 。首先看下配置的默認(rèn)值:
- splitChunks: {
- // 表示選擇哪些 chunks 進(jìn)行分割,可選值有:async,initial 和 all
- chunks: "async",
- // 表示新分離出的 chunk 必須大于等于 minSize,20000,約 20kb。
- minSize: 20000,
- // 通過確保拆分后剩余的最小 chunk 體積超過限制來避免大小為零的模塊,僅在剩余單個 chunk 時生效
- minRemainingSize: 0,
- // 表示一個模塊至少應(yīng)被 minChunks 個 chunk 所包含才能分割。默認(rèn)為 1。
- minChunks: 1,
- // 表示按需加載文件時,并行請求的最大數(shù)目。
- maxAsyncRequests: 30,
- // 表示加載入口文件時,并行請求的最大數(shù)目。
- maxInitialRequests: 30,
- // 強(qiáng)制執(zhí)行拆分的體積閾值和其他限制(minRemainingSize,maxAsyncRequests,maxInitialRequests)將被忽略
- enforceSizeThreshold: 50000,
- // cacheGroups 下可以可以配置多個組,每個組根據(jù) test 設(shè)置條件,符合 test 條件的模塊,就分配到該組。模塊可以被多個組引用,但最終會根據(jù) priority 來決定打包到哪個組中。默認(rèn)將所有來自 node_modules 目錄的模塊打包至 vendors 組,將兩個以上的 chunk 所共享的模塊打包至 default 組。
- cacheGroups: {
- defaultVendors: {
- test: /[\\/]node_modules[\\/]/,
- // 一個模塊可以屬于多個緩存組。優(yōu)化將優(yōu)先考慮具有更高 priority(優(yōu)先級)的緩存組。
- priority: -10,
- // 如果當(dāng)前 chunk 包含已從主 bundle 中拆分出的模塊,則它將被重用
- reuseExistingChunk: true,
- },
- default: {
- minChunks: 2,
- priority: -20,
- reuseExistingChunk: true
- }
- }
- }
默認(rèn)情況下,SplitChunks 只會對異步調(diào)用的模塊進(jìn)行分割(chunks: "async"),并且默認(rèn)情況下處理的 chunk 至少要有 20kb ,過小的模塊不會被包含進(jìn)去。
補(bǔ)充一下,默認(rèn)值會根據(jù) mode 的配置不同有所變化,具體參見源碼:
- const { splitChunks } = optimization;
- if (splitChunks) {
- A(splitChunks, "defaultSizeTypes", () => ["javascript", "unknown"]);
- D(splitChunks, "hidePathInfo", production);
- D(splitChunks, "chunks", "async");
- D(splitChunks, "usedExports", optimization.usedExports === true);
- D(splitChunks, "minChunks", 1);
- F(splitChunks, "minSize", () => (production ? 20000 : 10000));
- F(splitChunks, "minRemainingSize", () => (development ? 0 : undefined));
- F(splitChunks, "enforceSizeThreshold", () => (production ? 50000 : 30000));
- F(splitChunks, "maxAsyncRequests", () => (production ? 30 : Infinity));
- F(splitChunks, "maxInitialRequests", () => (production ? 30 : Infinity));
- D(splitChunks, "automaticNameDelimiter", "-");
- const { cacheGroups } = splitChunks;
- F(cacheGroups, "default", () => ({
- idHint: "",
- reuseExistingChunk: true,
- minChunks: 2,
- priority: -20
- }));
- F(cacheGroups, "defaultVendors", () => ({
- idHint: "vendors",
- reuseExistingChunk: true,
- test: NODE_MODULES_REGEXP,
- priority: -10
- }));
- }
cacheGroups 緩存組是施行分割的重中之重,他可以使用來自 splitChunks.* 的任何選項,但是 test、priority 和 reuseExistingChunk 只能在緩存組級別上進(jìn)行配置。默認(rèn)配置中已經(jīng)給我們提供了 Vendors 組和一個 defalut 組,**Vendors **組中使用 test: /[\\/]node_modules[\\/]/ 匹配了 node_modules 中的所有符合規(guī)則的模塊。
Tip:當(dāng) webpack 處理文件路徑時,它們始終包含 Unix 系統(tǒng)中的 / 和 Windows 系統(tǒng)中的 \。這就是為什么在 {cacheGroup}.test 字段中使用 [\/] 來表示路徑分隔符的原因。{cacheGroup}.test 中的 / 或 \ 會在跨平臺使用時產(chǎn)生問題。
綜上的配置,我們便可以理解為什么我們在打包中會產(chǎn)生出名為 vendors-node_modules_jquery_dist_jquery_js.db47cc72.js 的文件了。如果你想要對名稱進(jìn)行自定義的話,也可以使用 splitChunks.name 屬性(每個 cacheGroup 中都可以使用),這個屬性支持使用三種形式:
- boolean = false 設(shè)為 false 將保持 chunk 的相同名稱,因此不會不必要地更改名稱。這是生產(chǎn)環(huán)境下構(gòu)建的建議值。
- function (module, chunks, cacheGroupKey) => string 返回值要求是 string 類型,并且在 chunks 數(shù)組中每一個 chunk 都有 chunk.name 和 chunk.hash 屬性,舉個例子
- name(module, chunks, cacheGroupKey) {
- const moduleFileName = module
- .identifier()
- .split('/')
- .reduceRight((item) => item);
- const allChunksNames = chunks.map((item) => item.name).join('~');
- return `${cacheGroupKey}-${allChunksNames}-${moduleFileName}`;
- },
- string 指定字符串或始終返回相同字符串的函數(shù)會將所有常見模塊和 vendor 合并為一個 chunk。這可能會導(dǎo)致更大的初始下載量并減慢頁面加載速度。
另外注意一下 splitChunks.maxAsyncRequests 和 splitChunks.maxInitialRequests 分別指的是按需加載時最大的并行請求數(shù)和頁面初始渲染時候需要的最大并行請求數(shù)
在我們的項目較大時,如果需要對某個依賴單獨拆包的話,可以進(jìn)行這樣的配置:
- cacheGroups: {
- react: {
- name: 'react',
- test: /[\\/]node_modules[\\/](react)/,
- chunks: 'all',
- priority: -5,
- },
- },
這樣打包后就可以拆分指定的包:
更多配置詳見官網(wǎng)配置文檔
3. 動態(tài) import
使用 import()語法 來實現(xiàn)動態(tài)導(dǎo)入也是我們非常推薦的一種代碼分割的方式,我們先來簡單修改一下我們的 index.js ,再來看一下使用后打包的效果:
- // import { mul } from './test'
- import $ from 'jquery'
- import('./test').then(({ mul }) => {
- console.log(mul(2,3))
- })
- console.log($)
- // console.log(mul(2, 3))
可以看到,通過 import() 語法導(dǎo)入的模塊在打包時會自動單獨進(jìn)行打包
值得注意的是,這種語法還有一種很方便的“動態(tài)引用”的方式,他可以加入一些適當(dāng)?shù)谋磉_(dá)式,舉個例子,假設(shè)我們需要加載適當(dāng)?shù)闹黝}:
- const themeType = getUserTheme();
- import(`./themes/${themeType}`).then((module) => {
- // do sth aboout theme
- });
這樣我們就可以“動態(tài)”加載我們需要的異步模塊,實現(xiàn)的原理主要在于兩點:
至少需要包含模塊相關(guān)的路徑信息,打包可以限定于一個特定的目錄或文件集。
根據(jù)路徑信息 webpack 在打包時會把 ./themes 中的所有文件打包進(jìn)新的 chunk 中,以便需要時使用到。
4. 魔術(shù)注釋
在上述的 import() 語法中,我們會發(fā)現(xiàn)打包自動生成的文件名并不是我們想要的,我們?nèi)绾尾拍茏约嚎刂拼虬拿Q呢?這里就要引入我們的魔術(shù)注釋(Magic Comments):
- import(/* webpackChunkName: "my-chunk-name" */'./test')
通過這樣打包出來的文件:
魔術(shù)注釋不僅僅可以幫我們修改 chunk 名這么簡單,他還可以實現(xiàn)譬如預(yù)加載等功能,這里舉個例子:
我們通過希望在點擊按鈕時才加載我們需要的模塊功能,代碼可以這樣:
- // index.js
- document.querySelector('#btn').onclick = function () {
- import('./test').then(({ mul }) => {
- console.log(mul(2, 3));
- });
- };
- //test.js
- function mul(a, b) {
- return a * b;
- }
- console.log('test 被加載了');
- export { mul };
可以看到,在我們點擊按鈕的同時確實加載了 test.js 的文件資源。但是如果這個模塊是一個很大的模塊,在點擊時進(jìn)行加載可能會造成長時間 loading 等用戶體驗不是很好的效果,這個時候我們可以使用我們的 /* webpackPrefetch: true */ 方式進(jìn)行預(yù)獲取,來看下效果:
- // index,js
- document.querySelector('#btn').onclick = function () {
- import(/* webpackPrefetch: true */'./test').then(({ mul }) => {
- console.log(mul(2, 3));
- });
- };
可以看到整個過程中,在畫面初始加載的時候,test.js 的資源就已經(jīng)被預(yù)先加載了,而在我們點擊按鈕時,會從 (prefetch cache) 中讀取內(nèi)容。這就是模塊預(yù)獲取的過程。另外我們還有 /* webpackPreload: true */ 的方式進(jìn)行預(yù)加載。
但是 prefetch 和 preload 聽起來感覺差不多,實際上他們的加載時機(jī)等是完全不同的:
- preload chunk 會在父 chunk 加載時,以并行方式開始加載。prefetch chunk 會在父 chunk 加載結(jié)束后開始加載。
- preload chunk 具有中等優(yōu)先級,并立即下載。prefetch chunk 在瀏覽器閑置時下載。
- preload chunk 會在父 chunk 中立即請求,用于當(dāng)下時刻。prefetch chunk 會用于未來的某個時刻。
三、結(jié)尾
在最初有工程化打包思想時,我們會考慮將多文件打包到一個文件內(nèi)減少多次的資源請求,隨著項目的越來越復(fù)雜,做項目優(yōu)化時,我們發(fā)現(xiàn)項目加載越久用戶體驗就越不好,于是又可以通過代碼分割的方式去減少頁面初加載時的請求過大的資源體積。
本文中僅簡單介紹了常用的 webpack 代碼分割方式,但是在實際的項目中進(jìn)行性能優(yōu)化時,往往會有更加嚴(yán)苛的要求,希望可以通過本文的介紹讓大家快速了解上手代碼分割的技巧與優(yōu)勢。
參考
如何使用 splitChunks 精細(xì)控制代碼分割
Code Splitting - Webpack