系列文章: 前端工程師的 Modern Web 實踐之道 - Day 19
預計閱讀時間: 12 分鐘
難度等級: ⭐⭐⭐⭐☆
在前一篇文章中,我們深入探討了從首屏載入到執行時效能的全方位最佳化策略。今天我們將聚焦於現代前端專案中最關鍵的效能瓶頸之一:Bundle 大小問題。
你是否曾經遇到這樣的情況:專案開發時感覺一切順利,但部署到生產環境後,使用者反應網站載入速度慢得要命?打開 Network 面板一看,發現主要的 JavaScript bundle 檔案竟然高達 5MB、10MB,甚至更大?
這就是我們今天要解決的核心問題。透過系統化的 bundle 分析和最佳化技術,我們可以將專案體積減少 50%-80%,大幅提升使用者體驗。
讓我們先來看一個真實專案的問題:
# 構建後的產出
dist/
├── index.html (2KB)
├── assets/
│ ├── index-a1b2c3d4.js (3.2MB) ❌ 太大了!
│ ├── vendor-e5f6g7h8.js (2.1MB) ❌ 第三方套件也很大
│ └── index-i9j0k1l2.css (45KB) ✅ 還可以
一個簡單的 SPA 應用,主要的 JavaScript bundle 就超過 5MB。這會導致什麼問題?
實際影響分析:
// package.json
{
"dependencies": {
"lodash": "^4.17.21", // 530KB (只用了 3 個函式)
"moment": "^2.29.4", // 290KB (只用來格式化日期)
"antd": "^5.0.0", // 1.2MB (只用了 5 個組件)
"axios": "^1.3.0", // 150KB
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}
問題: 引入了大型套件,但只使用了極小部分功能。
// ❌ 錯誤方式:整包引入
import _ from 'lodash';
import moment from 'moment';
import { Button, Table, Modal, Form, Input } from 'antd';
// Bundle 包含了整個套件的程式碼
// ❌ 所有程式碼都在主 bundle 中
import Home from './pages/Home';
import Dashboard from './pages/Dashboard';
import Settings from './pages/Settings';
import AdminPanel from './pages/AdminPanel'; // 一般使用者根本用不到
function App() {
return (
<Router>
<Route path="/" component={Home} />
<Route path="/dashboard" component={Dashboard} />
<Route path="/settings" component={Settings} />
<Route path="/admin" component={AdminPanel} />
</Router>
);
}
// 多個套件內部都使用了 React
// 結果 bundle 中包含多份 React 程式碼
npm install --save-dev webpack-bundle-analyzer
// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
// ... 其他設定
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'server', // 啟動本地伺服器顯示報告
analyzerPort: 8888, // 報告伺服器埠號
openAnalyzer: true, // 自動開啟瀏覽器
generateStatsFile: true, // 生成 stats.json
statsFilename: 'bundle-stats.json'
})
]
};
# 生產環境構建並分析
npm run build
# 瀏覽器會自動開啟 http://localhost:8888
# 顯示互動式的 bundle 視覺化報告
分析報告會顯示三種大小:
/**
* Stat Size: 程式碼轉換前的原始大小
* Parsed Size: 瀏覽器解析的實際大小
* Gzipped Size: 壓縮後通過網路傳輸的大小(最重要的指標)
*/
重點關注項目:
npm install --save-dev rollup-plugin-visualizer
// vite.config.js
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
visualizer({
open: true, // 自動開啟報告
gzipSize: true, // 顯示 gzip 大小
brotliSize: true, // 顯示 brotli 大小
filename: 'dist/stats.html' // 輸出檔案位置
})
],
build: {
rollupOptions: {
output: {
manualChunks: {
// 手動分割程式碼(稍後詳細說明)
'react-vendor': ['react', 'react-dom'],
'ui-vendor': ['antd', '@ant-design/icons']
}
}
}
}
});
適用於任何構建工具,基於 source map 進行分析:
npm install --save-dev source-map-explorer
# 分析特定 bundle
npx source-map-explorer dist/assets/*.js
# 生成 HTML 報告
npx source-map-explorer dist/assets/*.js --html dist/sme-report.html
// ❌ Before: 使用 Moment.js (290KB)
import moment from 'moment';
const formatted = moment().format('YYYY-MM-DD HH:mm:ss');
// ✅ After: 使用 Day.js (7KB,API 完全相容)
import dayjs from 'dayjs';
const formatted = dayjs().format('YYYY-MM-DD HH:mm:ss');
// Bundle 減少: 283KB (gzipped: ~100KB)
// ❌ Before: 整包引入 (530KB)
import _ from 'lodash';
const result = _.debounce(fn, 300);
const unique = _.uniq(array);
// ✅ After: 使用 lodash-es 和 Tree Shaking
import debounce from 'lodash-es/debounce';
import uniq from 'lodash-es/uniq';
const result = debounce(fn, 300);
const unique = uniq(array);
// ✅ Better: 使用原生方法或輕量套件
const debounce = (fn, delay) => {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
};
const unique = array => [...new Set(array)];
// Bundle 減少: ~500KB
| 原套件 | 大小 | 替代方案 | 大小 | 節省 |
|---|---|---|---|---|
| Moment.js | 290KB | Day.js | 7KB | 283KB |
| Lodash | 530KB | 按需引入/原生 | 10-50KB | 480-520KB |
| Axios | 150KB | Fetch API | 0KB | 150KB |
| jQuery | 280KB | 原生 DOM API | 0KB | 280KB |
| Chart.js | 240KB | Recharts/原生 Canvas | 50-100KB | 140-190KB |
// ❌ Bad: 預設匯入會包含整個模組
import _ from 'lodash';
import * as antd from 'antd';
// ✅ Good: 具名匯入支援 Tree Shaking
import { debounce, throttle } from 'lodash-es';
import { Button, Table } from 'antd';
// ✅ Better: 針對不支援 Tree Shaking 的套件使用深層引入
import debounce from 'lodash/debounce';
// package.json
{
"sideEffects": false // 告訴打包工具所有模組都沒有副作用
}
// 或指定有副作用的檔案
{
"sideEffects": ["*.css", "*.scss", "./src/polyfills.js"]
}
// vite.config.js
export default defineConfig({
build: {
minify: 'terser',
terserOptions: {
compress: {
drop_console: true, // 移除 console
drop_debugger: true, // 移除 debugger
pure_funcs: ['console.log'] // 移除特定函式呼叫
}
}
}
});
// ❌ Before: 靜態引入所有路由
import Home from './pages/Home';
import Dashboard from './pages/Dashboard';
import AdminPanel from './pages/AdminPanel';
// ✅ After: 動態載入路由組件
import { lazy, Suspense } from 'react';
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Router>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/admin" element={<AdminPanel />} />
</Router>
</Suspense>
);
}
// 分割前
main.js: 3.2MB (使用者必須下載所有程式碼才能看到首頁)
// 分割後
main.js: 450KB (只包含核心程式碼和首頁)
chunk-dashboard.js: 380KB (訪問 Dashboard 時才載入)
chunk-admin.js: 520KB (訪問管理頁時才載入)
chunk-vendors.js: 1.8MB (共用的第三方套件)
// 首次載入: 450KB + 1.8MB = 2.25MB (減少 30%)
// 且核心 UI 可以更快顯示
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id) {
// 將 node_modules 中的套件分類打包
if (id.includes('node_modules')) {
// React 核心套件
if (id.includes('react') || id.includes('react-dom')) {
return 'react-vendor';
}
// UI 組件庫
if (id.includes('antd') || id.includes('@ant-design')) {
return 'ui-vendor';
}
// 圖表庫
if (id.includes('echarts') || id.includes('chart')) {
return 'chart-vendor';
}
// 其他第三方套件
return 'vendor';
}
}
}
},
chunkSizeWarningLimit: 500 // 當 chunk 超過 500KB 時發出警告
}
});
/**
* 僅在使用者需要時才載入 PDF 處理庫
*/
async function exportToPDF(data) {
// 動態載入 PDF 庫 (2MB)
const { jsPDF } = await import('jspdf');
const doc = new jsPDF();
// ... PDF 生成邏輯
doc.save('report.pdf');
}
// 只有點擊「匯出 PDF」按鈕時才會載入 jsPDF
/**
* 使用原生 Intersection Observer 實作圖片懶載入
*/
function useLazyImage() {
useEffect(() => {
const images = document.querySelectorAll('img[data-src]');
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute('data-src');
imageObserver.unobserve(img);
}
});
});
images.forEach(img => imageObserver.observe(img));
return () => imageObserver.disconnect();
}, []);
}
// 使用方式
<img data-src="large-image.jpg" alt="Description" />
/**
* 當使用者滑鼠移到連結上時,預載入目標頁面
*/
import { lazy } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
function Navigation() {
const handleMouseEnter = (path) => {
// 預載入組件
if (path === '/dashboard') {
import('./pages/Dashboard');
}
};
return (
<nav>
<Link
to="/dashboard"
onMouseEnter={() => handleMouseEnter('/dashboard')}
>
Dashboard
</Link>
</nav>
);
}
# 使用 npm-check-duplicates
npx npm-check-duplicates
# 輸出可能顯示
# ⚠️ react: 17.0.2, 18.2.0 (2 versions)
# ⚠️ lodash: 4.17.20, 4.17.21 (2 versions)
// package.json 中使用 resolutions (Yarn) 或 overrides (npm 8.3+)
{
"overrides": {
"react": "18.2.0",
"lodash": "4.17.21"
}
}
// webpack.config.js
module.exports = {
resolve: {
alias: {
// 強制所有模組使用相同版本的 React
react: path.resolve('./node_modules/react'),
'react-dom': path.resolve('./node_modules/react-dom')
}
}
};
一個電商管理後台系統,初始構建結果:
初始 Bundle 大小:
├── main.js: 3.8MB
├── vendor.js: 2.4MB
└── styles.css: 120KB
總計: 6.32MB (gzipped: 1.8MB)
npm install --save-dev webpack-bundle-analyzer
npm run build
# 開啟分析報告
發現的問題:
# 替換 Moment.js
npm uninstall moment
npm install dayjs
# 使用 lodash-es 支援 Tree Shaking
npm uninstall lodash
npm install lodash-es
// 程式碼修改
- import moment from 'moment';
+ import dayjs from 'dayjs';
- import _ from 'lodash';
+ import { debounce, throttle, cloneDeep } from 'lodash-es';
效果: Bundle 減少 800KB
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'react-core': ['react', 'react-dom', 'react-router-dom'],
'antd-ui': ['antd'],
'charts': ['echarts']
}
}
}
}
});
效果: Bundle 再減少 400KB
// 路由懶載入
const ProductList = lazy(() => import('./pages/ProductList'));
const OrderManagement = lazy(() => import('./pages/OrderManagement'));
const Analytics = lazy(() => import('./pages/Analytics'));
const UserSettings = lazy(() => import('./pages/UserSettings'));
// ECharts 按需載入
async function loadChart(type) {
if (type === 'line') {
const echarts = await import('echarts/lib/echarts');
await import('echarts/lib/chart/line');
return echarts;
}
}
效果: 首屏載入減少 1.2MB
最佳化後 Bundle 大小:
├── main.js: 520KB ⬇️ 86% reduction
├── react-core.js: 180KB
├── antd-ui.js: 450KB ⬇️ 62% reduction
├── charts.js: 320KB ⬇️ 62% reduction
├── chunk-product.js: 280KB (lazy loaded)
├── chunk-order.js: 310KB (lazy loaded)
└── styles.css: 95KB
首屏載入總計: 1.15MB (gzipped: 380KB) ⬇️ 79% reduction
| 指標 | 最佳化前 | 最佳化後 | 提升 |
|---|---|---|---|
| 首屏 JS 大小 | 6.2MB | 1.15MB | 81% ⬇️ |
| Gzip 後大小 | 1.8MB | 380KB | 79% ⬇️ |
| FCP (3G) | 8.5s | 2.1s | 75% ⬆️ |
| TTI (3G) | 15.2s | 4.3s | 72% ⬆️ |
| Lighthouse 分數 | 52 | 91 | 75% ⬆️ |
# VS Code 安裝 Import Cost 外掛
# 在程式碼編輯器中即時顯示每個 import 的大小
import moment from 'moment'; // 📦 288.45KB (gzipped: 71.56KB)
import dayjs from 'dayjs'; // 📦 6.58KB (gzipped: 2.69KB)
npx bundle-buddy dist/*.map
# 視覺化顯示不同 chunk 之間的重複程式碼
// webpack.config.js
module.exports = {
performance: {
maxAssetSize: 244000, // 單個檔案不超過 244KB
maxEntrypointSize: 244000, // 入口點不超過 244KB
hints: 'error' // 超過限制時報錯
}
};
// vite.config.js
export default defineConfig({
build: {
chunkSizeWarningLimit: 500,
rollupOptions: {
output: {
// 自動分割大檔案
experimentalMinChunkSize: 100000
}
}
}
});
<!-- 對於 React 等穩定套件,可以使用 CDN -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
external: ['react', 'react-dom'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM'
}
}
}
}
});
// 開發環境:使用快速的 source map
// vite.config.js
export default defineConfig({
build: {
sourcemap: process.env.NODE_ENV === 'production' ? 'hidden' : 'inline',
// 生產環境使用 hidden,不會在 bundle 中包含 source map
// 只在需要時上傳到錯誤追蹤平台
}
});
Bundle 分析是最佳化的起點: 使用 Webpack Bundle Analyzer、Rollup Visualizer 等工具視覺化分析專案依賴,找出體積大的模組和重複依賴。
系統化最佳化策略: 套件替換(Moment.js → Day.js)、Tree Shaking 最佳化、程式碼分割、動態載入、依賴去重,五大策略組合使用可達到 70-80% 的體積減少。
量化效能提升: 透過實際案例展示,合理的 bundle 最佳化可將首屏載入時間從 15 秒降至 4 秒,Lighthouse 分數從 52 提升到 91。
如何在團隊中建立 bundle 最佳化的文化? 考慮在 CI/CD 流程中加入自動化檢查,當 bundle 大小超過閾值時阻止部署。
微前端架構如何影響 bundle 最佳化策略? 當應用被拆分為多個獨立子應用時,如何避免重複載入相同的依賴?
HTTP/2 和 HTTP/3 對程式碼分割策略有何影響? 多路復用技術是否改變了傳統的 bundle 最佳化思維?