系列文章: 前端工程師的 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 最佳化思維?