紀錄工作經驗、相關知識,解決技術相關問題。

Javascript, 網站優化

JavaScript Code Splitting 與 Tree Shaking 效能調校實作

JavaScript Code Splitting 與 Tree Shaking 效能調校實作 特色圖片

當前端 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 檔;瀏覽器開啟不同路徑時,只會多載入對應頁面的那一個檔案:

開啟三個路徑時載入的 JS 檔清單,每個路徑都載入主檔與對應頁面的 Chunk
Vite 8.3.5 打包、react-router 7.18.5 的 BrowserRouter,Chrome 無頭模式在 Windows 11 開啟三個路徑時請求的 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"
      ]
    }

參考資料與延伸閱讀

發佈留言