當前端 Bundle 過大時,瀏覽器要下載、解析與執行的 JavaScript 隨之增加。Lighthouse 文件也把 Code Splitting 與移除未使用程式碼,列為改善 Total Blocking Time 的做法。想了解瀏覽器底層解析與渲染效能的基礎細節,可先前往 瀏覽器渲染機制解析:DOM Tree、Reflow 與 Repaint 效能優化 觀看。本篇將深入講解模組打包優化、動態導入與無用程式碼移除策略,協助開發者突破前端效能瓶頸。
一、 核心速查與常見瓶頸對照
在大型專案中,未經優化的 JavaScript Bundle 往往包含大量未使用的套件與過早載入的頁面元件。以下為常見的效能瓶頸與架構對照。
1. 常見錯誤寫法的工程瓶頸
- 全域引入巨大第三方套件:例如直接
import _ from 'lodash'。npm 上lodash套件的主檔是lodash.js,沒有宣告 ESM 入口,整個函式庫會被帶進首屏主檔。 - 缺乏路由級分割:所有頁面元件集中在單一 Entry Point,導致使用者造訪首頁時必須下載後台管理系統的龐大程式碼。
- 使用 CommonJS 格式的套件:Rollup 文件指出,CommonJS 必須整個工具或函式庫一併匯入,無法像 ES Modules 那樣靜態分析,Tree Shaking 因此失效。
2. 核心機制規格對照表
| 優化技術 | 核心運作原理 | 主要好處 | 常見配置陷阱 |
|---|---|---|---|
| Code Splitting | 將單一打包檔拆分為多個非同步 Chunk,依需求按需載入。 | 顯著降低首屏 Bundle 大小與載入時間。 | 拆分過細導致 HTTP 請求過多。 |
| Tree Shaking | 依賴 ES Modules 的靜態結構(import、export),剔除未被使用的導出程式碼。 | 減少最終輸出的位元組體積。 | 引入具備副作用(Side Effects)的模組。 |
| Dynamic Import | 使用 import() 語法建立分割點,回傳 Promise 物件。 | 實現元件級與模組級的延遲載入。 | 未妥善處理非同步載入中的載入狀態。 |
二、 關鍵機制解析:底層規範與注意事項
1. Tree Shaking 的靜態分析原理
Tree Shaking 依賴於 ES Modules(ESM)的靜態結構特性。ESM 的 import 宣告只能出現在模組最上層,匯入來源也只能是字串常值,讓模組能在執行前就被靜態分析與連結(MDN)。Webpack、Rollup 等打包工具因此能在打包階段找出從未被引用的匯出並移除;Webpack 需要將 mode 設為 production,才會真正把這些程式碼從輸出中剔除。
2. 動態導入與非同步 Chunk 拆分機制
import() 會回傳 Promise,讓模組在需要時才載入(MDN)。打包工具遇到 import() 時,會把該模組切成獨立的 Chunk,瀏覽器等到程式碼執行到那一行才請求這個檔案。分割並非越細越好:每個 Chunk 都是一個額外的檔案請求,Webpack 的 SplitChunksPlugin 也只在共用相依達到大小門檻(size thresholds)時,才把它抽成獨立 Chunk。
三、 實作範本:生產環境重點程式碼
以下提供兩個在生產環境中常見的 Code Splitting 與 Tree Shaking 最佳實作範本。
範本一:React 路由級動態導入與非同步載入
React.lazy 讓元件程式碼延後到第一次 render 才載入,載入期間要用 <Suspense> 顯示替代畫面;傳給 lazy 的函式須回傳 Promise,並解析成帶有 default 匯出的模組(React 官方文件)。以下以此實現路由級 Code Splitting:
import React, { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router';
// 透過動態導入將頁面元件分割為獨立 Chunk
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<div className="loading-spinner">載入中...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
export default App;
三個以 lazy 搭配 Suspense 建立的頁面元件,經 Vite 打包後會各自輸出獨立的 JS 檔;瀏覽器開啟不同路徑時,只會多載入對應頁面的那一個檔案:

範本二:精準匯入第三方套件以確保 Tree Shaking 生效
避免整包引入大型工具庫,改採具體路徑匯入或使用支援 ES Modules 的模組寫法:
// 錯誤示範:lodash 主檔不是 ESM 入口,整包引入後 Tree Shaking 無法發揮作用
// import { debounce } from 'lodash';
// 正確示範:改用 ESM 版的 lodash-es,只引入需要的特定模組
import debounce from 'lodash-es/debounce';
const handleResize = debounce(() => {
console.log('視窗大小已調整');
}, 300);
window.addEventListener('resize', handleResize);
以 Vite 8.3.5 單獨打包這段範例,輸出的 JS 約 2.3 kB;若改成 import * as _ from 'lodash-es' 取得整個命名空間物件,輸出約 89.5 kB,而 import { debounce } from 'lodash-es' 的具名匯入約 2.2 kB。
四、 常見問題與排錯(Q&A)
Q1:為什麼動態導入產生的非同步 Chunk 檔案過多會導致效能不增反減?
- 問題原因:過度使用 Code Splitting 將每個微小元件或工具函式都切成獨立的 Chunk,頁面載入時就得多發出許多檔案請求,減少單一檔案體積的好處會被請求數量抵消。Webpack 的
SplitChunksPlugin預設也只在共用相依達到大小門檻時才獨立成 Chunk。 - 解法:應以「路由級(Route-level)」或「大型功能區塊(Feature-level)」作為主要的分割粒度,並透過 Webpack 的 SplitChunksPlugin
cacheGroups將lodash、react這類第三方套件抽成獨立的 vendor chunk,因為它們比本地原始碼更少變動,瀏覽器可以沿用快取(Webpack 快取指南)。
Q2:Webpack 與 Vite 在處理 Tree Shaking 時的底層模組解析機制有何差異?
- 問題原因:Webpack 在
production模式下依靠 ES Modules 的靜態結構移除未使用的匯出,其中usedExports靠 Terser 偵測陳述式的副作用,sideEffects旗標則能整個略過不用的模組,官方文件說明後者效果更好。Vite 在開發環境以原生 ESM 提供原始碼;生產環境的打包器隨版本不同:舊版使用 Rollup,Vite 8 起改用 Rolldown,build.rollupOptions也改名為build.rolldownOptions。 - 解法:在 Webpack 專案中,確認
package.json設定了"sideEffects": false或指定副作用檔案清單;Rollup 同樣靜態分析import與export,排除沒用到的程式碼,但 CommonJS 套件需要靠外掛(@rollup/plugin-commonjs)轉換才能處理。使用 Vite 時,先確認專案實際用的版本與打包器,再查對應版本的選項名稱。
Q3:如何排查第三方套件導致 Tree Shaking 失效的常見原因?
- 問題原因:套件若輸出的是 CommonJS 格式(使用
module.exports),打包工具得整包匯入,無法靜態分析哪些匯出沒被用到(Rollup 文件);套件的package.json若沒有"sideEffects"提示,Webpack 也無從得知哪些檔案可以安全略過(Webpack 文件)。 - 解法:優先改用原生提供 ES Modules 的版本,例如由
lodash換成lodash-es(npm 上的描述是「Lodash exported as ES modules」,package.json宣告"sideEffects": false)。若必須使用該套件,可改用具體檔案路徑進行深層匯入,例如import debounce from 'lodash-es/debounce'。
Q4:在前端路由級 Code Splitting 中,如何有效預載入關鍵頁面模組?
- 問題原因:雖然 Code Splitting 能縮減首屏載入體積,但當使用者點擊導航切換至其他頁面時,才開始下載對應的 Chunk 往往會造成短暫的白畫面或載入延遲。
- 解法:在使用者游標懸停(Hover)在導航連結上時,用
<link rel="prefetch">或動態呼叫import(),預先載入下一個可能的頁面模組。MDN 說明prefetch是給瀏覽器的提示,讓它預先抓取並快取使用者未來導覽可能用到的資源,優先權較低;Webpack 也提供import(/* webpackPrefetch: true */ './LoginModal.js')這種魔法註解,會在父 Chunk 載入後產生<link rel="prefetch">,讓瀏覽器在閒置時抓取。
Q5:如何用 Bundle Analyzer 檢視打包結果中各個 Chunk 的組成?
- 問題原因:開始拆分程式碼之後,很難憑感覺判斷各個模組最後落在哪個 Chunk、哪些模組佔了最多體積。
- 解法:在建構流程中整合 Bundle Analyzer。Webpack 可用
webpack-bundle-analyzer,它以可縮放的互動式 treemap 呈現 bundle 內容;rollup-plugin-visualizer的 README 說明可用來找出哪些模組佔用空間,也提供 Vite 與 Rolldown 的設定範例。依圖表檢視各 Chunk 的實際大小後,再調整splitChunks規則或匯入方式。
Q6:純副作用宣告在 package.json 中的 sideEffects 欄位該如何正確配置?
- 問題原因:Webpack 官方文件指出,只為副作用而匯入的 CSS 檔,必須列入
sideEffects清單,否則在 production 模式下會被當成無用程式碼移除,元件仍會渲染,但沒有樣式。同理,會初始化全域狀態的檔案(例如 polyfill)若被標成無副作用,也可能被略過。 - 解法:將
sideEffects設置為陣列格式,明確列出需要保留副作用的檔案;陣列可以使用簡單的 glob 樣式。例如:{ "name": "my-project", "sideEffects": [ "*.css", "*.scss", "./src/polyfills.js" ] }
參考資料與延伸閱讀
- MDN Web Docs: JavaScript modules
- Webpack Documentation: Code Splitting
- Webpack Documentation: Tree Shaking
- Webpack Documentation: Caching
- Vite Official Guide: Building for Production
- Vite Guide: Migration from v7(Rolldown)
- Rollup Documentation: Introduction(Tree-shaking)
- MDN Web Docs: import()
- MDN Web Docs: rel=prefetch
- React 官方文件:lazy
- Lighthouse: Total Blocking Time
- 瀏覽器渲染機制解析:DOM Tree、Reflow 與 Repaint 效能優化
發佈留言