為什麼 Bundle 會變肥#
現代前端開發幾乎都採模組化思維,搭配 Webpack、Rollup 等 bundler 把所有檔案 minify、uglify 後打包成單一 JavaScript bundle。專案規模增長後,這種「全部一包」的策略很快就會踩到痛點:bundle size 失控、首次載入時間拉長、使用者體驗下降。
Code Splitting 就是針對這個問題的解法:
把原本單一的 bundle 切成多個小 chunk,讓瀏覽器可以平行載入、按需載入,或對變動少的部分獨立做快取。
仔細想想會發現,「按需載入」這四個字其實就是 Lazy Loading 的精神。差別只在於延遲載入的標的變成程式碼本身,從元件、頁面層級切割出獨立的 JavaScript 檔案。
本章節會聚焦在兩種最常用的技巧:
- 抽離第三方套件
- 動態載入(Dynamic Import)功能模組
範例會以 Webpack 為主,但概念可以平移到其他 bundler。
Bundle 分析的好幫手#
在開始切之前,建議先把 Webpack Bundle Analyzer 這個 plugin 設定起來。它能用視覺化方式呈現各個 bundle chunk 的組成,讓你一眼看出哪些套件特別肥、哪些 chunk 適合被拆開。後續的優化策略幾乎都依賴這份視覺化資訊來判斷。
抽離第三方套件#
這個技巧把 bundle 區分成兩類:
- Application Bundle:自己撰寫的 UI 與商業邏輯,變動頻率高。
- Vendor Bundle:第三方套件,也就是
node_modules,變動頻率低。
Vendor Bundle 適合獨立打包的關鍵在於:因為它幾乎不變,瀏覽器可以長時間快取它。當應用層程式碼更新時,使用者只需重新下載較小的 Application Bundle,Vendor Bundle 直接吃快取,再訪速度大幅提升。
可以再進一步把第三方套件依照關聯性切成多個檔案。優點是更新單一套件時只會讓對應的 chunk 失效,其他 chunk 仍能命中快取;代價則是配置邏輯變得更複雜。
動態載入 Dynamic Import#
我們平常在檔案開頭寫 import 屬於靜態載入(static import),這些模組會在頁面載入時就被解析、引入。但這套寫法在以下兩種情境會卡關:
- 模組路徑需要動態決定。
- 模組需要根據特定邏輯或時機才引入。
這時就該換成動態載入(Dynamic Import):
等到真正需要某段程式碼時,才透過網路載入對應的 JS bundle。
ESM (ECMAScript Modules) 的 import 語法支援回傳 Promise 的 import() 形式。例如:
function getComponent() {
return import("lodash").then(({ default: _ }) => {
const element = document.createElement("div");
element.innerHTML = _.join(["Hello", "webpack"], " ");
return element;
});
}只有在 getComponent 被呼叫時,lodash 才會被當成另一個 chunk 載入。Webpack 會自動幫我們做這件事,不需要額外手寫拆分邏輯。
接下來看看兩個常見的 Dynamic Import 應用場景。
根據路由做 Dynamic Import#
長期數字通常會顯示「使用者大多只造訪幾個熱門頁面」。如果採用 Client Side Rendering,預設會把所有頁面打包進同一個 bundle,那些不常被造訪的頁面也會白白被載入。
針對這種情境可以對路由做 Dynamic Import:當切換到特定路徑時才載入對應頁面的 chunk。React 的 React.lazy 配合 react-router 就是經典組合:
import React, { Suspense, lazy } from "react";
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
const Home = lazy(() => import("./pages/Home"));
const About = lazy(() => import("./pages/About"));
function App() {
return (
<Router>
<Suspense fallback={<div>Loading...</div>}>
<Switch>
<Route exact path="/" component={Home} />
<Route path="/about" component={About} />
</Switch>
</Suspense>
</Router>
);
}
React.lazy目前在 SSR 環境無法直接使用。如果專案採用 SSR,可以考慮 Loadable Components。
針對肥大套件做 Dynamic Import#
另一個常見策略是把「特別肥但不會立刻用到」的套件抽出來。Webpack Bundle Analyzer 在這時候特別有用,可以快速找出 bundle 中佔比異常的模組。
舉個實務例子:要支援直播功能必須引入 hls.js 這種重量級模組,但它只在某個特定頁面才會用到。這種情況很適合做 Dynamic Import:
useEffect(() => {
async function loadHls() {
await import("hls.js");
}
loadHls();
}, []);只有訪問該頁面時才會去載入 hls.js 對應的 bundle,初次載入的 bundle size 大幅下降。
拆分共用 chunk 的進階技巧#
當多個動態載入的 chunk 重複包含同一個小元件時,那段共用程式碼會被重複下載。這時可以把「至少在 N 個 chunk 中被引用」的程式碼再額外抽出成共享 chunk:
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
},
default: {
name: 'default',
minChunks: 2,
reuseExistingChunk: true,
enforce: true,
priority: -20,
},
},
},
},但要小心過度共用:如果某個共用 chunk 其實只有部分頁面真的需要它,會出現「載入了用不到的程式碼」的反效果。這種狀況下,可以再對該共用元件做 Dynamic Import,讓它只在真正需要的頁面載入。
不必對每一個頁面都做 Dynamic Import。如果某些頁面是大多數使用者必經的入口,立即載入反而體驗更好。優化的依據永遠是真實的使用數字,而不是「能拆就拆」。
一個小總結#
Code Splitting 與 Dynamic Import 是大型專案不可或缺的優化手段。專案規模一旦放大,把所有東西塞在同一個 bundle 幾乎等於宣告載入體驗不佳。配合 Webpack Bundle Analyzer 等工具,找出高優先級的拆分目標,逐步把單一大 bundle 拆成可以平行下載、按需載入、長效快取的多個小 chunk。
原文出處#
- 原書/iThome:https://ithelp.ithome.com.tw/articles/10274467