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