React 應用的首屏負擔,常不是元件數量本身,而是入口模組透過靜態 import 把只在少數頁面使用的編輯器、圖表、試算表或管理工具一起帶進初始 Bundle。正確做法不是先調整所有 webpack 門檻,而是先建立可比較的正式建置基準,再把功能邊界改成動態載入,最後確認初始資源、非同步 Chunk、載入狀態與失敗處理都符合契約。

本文聚焦「路由或功能層級的 React 程式碼分割」這個資訊型搜尋意圖。它不討論無限捲動、CSS 選擇器、i18n 或一般前端效能清單,也不把來源筆記中的特定系統數據當成通用保證。所有範例都使用本機合成專案;正式環境的 Bundle 大小、快取命中與使用者體感,仍須由各站自己的建置產物與瀏覽器紀錄判斷。

React 程式碼分割的直接答案

把非首屏功能改為頂層宣告的 lazy(() => import(...)),用 Suspense 提供載入狀態,並比較修改前後的正式建置資源圖;只有確認重套件離開初始 Chunk、需要時才下載,才算完成分割。

這個判準比「產生了很多檔案」更可靠。webpack 的動態 import() 會建立非同步相依邊界,但最終 Chunk 仍受模組圖、共用相依與 optimization.splitChunks 規則影響。若某個重套件仍被入口、共用 Layout 或其他同步路徑靜態引用,它可能繼續留在初始下載中。反過來說,看到檔名變多也不代表首屏一定更快;過度切碎會增加請求、執行與協調成本。

React 官方文件說明,lazy 會延後載入元件程式碼,直到第一次嘗試渲染該元件。載入函式必須回傳 Promise,解析結果的 .default 必須是有效元件。程式碼尚未到達時,最近的 Suspense 邊界顯示 fallback;Promise 或解析結果會被 React 快取。這些都是 API 行為,不是 Bundle 大小保證。

搜尋意圖屬於 TOFU/MOFU:讀者先理解為何初始 Bundle 過大,再評估一個可測試的拆分方法。核心 KPI 應是初始 JavaScript 傳輸量、初始 Chunk 中的模組、首次需要功能時的 Chunk 請求、載入失敗率與互動等待時間;不能只追求總檔案數下降。

先定位真正進入初始 Bundle 的相依

第一步不是搜尋最大的 node_modules 資料夾,而是讀正式模式的建置輸出。開發伺服器通常包含 HMR、source map 與不同最佳化策略,不適合作為發佈比較基準。應固定 Node、套件鎖檔、建置命令及模式,保存修改前後的 stats 或資源清單,再回答三個問題:入口載入哪些 Chunk、目標套件位於哪個 Chunk、哪些模組把它引入。

來源筆記指出一個常見根因:只在編輯器頁使用的設定模組,卻從全域入口同步載入。這種情況的修正點應靠近唯一使用者,例如編輯器 wrapper 或該頁的路由邊界,而不是在 webpack 設定中強迫任意切檔。只要全域入口仍存在 side-effect import,打包器就必須保留其副作用,頁面層的 lazy route 也救不了這條同步鏈。

盤點時應把 JavaScript 與媒體分開。大型字型、影片與未壓縮圖片不會因 React.lazy 自動消失;它們需要自己的資源載入、格式、快取與子集策略。另一方面,重複套件也可能來自相容版本並存,這要先讀相依圖及使用點,不能直接刪除其中一套。程式碼分割解決「何時下載」,Tree Shaking 解決「未使用匯出能否移除」,去重則解決「相同或相容模組是否重複」,三者不是同一件事。

可採用以下決策順序:

  • 首屏必要:保留同步載入,但檢查 Tree Shaking、重複相依與快取。
  • 特定路由才需要:在路由元件或頁面入口使用 lazy 與動態 import()。
  • 點擊後才需要:在使用者意圖出現時載入,必要時經過量測再評估 prefetch。
  • 只有副作用初始化:把初始化移到真正需要的功能邊界,並確認重複執行是否安全。
  • 大型媒體:使用媒體專屬策略,不把它誤算成 JavaScript 分割成果。

正確放置 lazy、Suspense 與錯誤邊界

lazy 宣告應放在模組頂層,而不是函式元件內。React 官方明確警告:若在元件每次 render 時重新宣告 lazy 元件,狀態可能被重設。頂層宣告讓元件型別與載入 Promise 保持穩定,也讓程式碼審查能一眼辨識非同步邊界。

import { lazy, Suspense } from "react";

const ReportEditor = lazy(() => import("./ReportEditor"));

export function ReportRoute() {
  return (
    
      Loading editor…}>
        
      
    
  );
}

ReportEditor 必須以 default export 提供元件,除非載入函式自行把具名匯出映射成 { default: Component }。Suspense 處理「尚在載入」而不是所有失敗;若 Chunk 找不到、部署版本不一致或網路中斷,lazy 的 Promise 會 reject,React 會把拒絕原因交給最近的 Error Boundary。因此,產品級拆分至少需要可見的 loading、可理解的錯誤狀態,以及安全的重試或重新整理路徑。

邊界粒度應跟使用者任務一致。把整個應用包在唯一 Suspense 下,任何延遲都可能替換整頁;把每個小圖示都切成獨立 Chunk,又會讓模組圖與請求變得零碎。通常路由、重型對話框、編輯器或低頻管理功能是較清楚的起點。先處理最大且確定不在首屏的同步相依,再重新量測,避免照著待辦清單一路拆到收益已經不明顯。

webpack SplitChunks 的角色與常見誤判

webpack 官方把程式碼分割分成入口點、避免重複與動態匯入三類。對 React 頁面延後載入而言,動態 import() 是建立需求邊界的核心;SplitChunksPlugin 則在產生 Chunk 後,依共享關係、套件來源、大小與請求限制等條件重新組織模組。兩者可以合作,但不能互相替代。

webpack 5 正式模式的 splitChunks 預設主要處理 on-demand Chunk,並根據共享模組或 node_modules、最小大小及請求數等條件拆分。設定 chunks: "all" 會把 initial 與 async 都納入候選,但它不是萬用效能開關。若把所有 vendor 強制合成一個固定名稱,可能重新製造巨大的初始檔;若把 maxSize 當硬上限,也會誤解官方所說的「提示值」。

因此建議先保留預設值,用 stats 證明問題後再新增 cache group。每一條自訂規則都要說明:它要避免哪個重複、改變哪個入口、預期快取週期為何、失敗時如何回復。Chunk 名稱應使用內容雜湊以支援長期快取,HTML 與資源部署則要保持版本一致,避免舊頁面要求已被清除的檔案而出現 ChunkLoadError。

還要分清「初始體積」與「總體積」。將套件移到非同步 Chunk,可能大幅降低初始入口,但使用者進入功能時仍要下載同一套件,且 webpack runtime 會多一點載入邏輯。本次合成測試正是這個結果:同步版本只有一個約 69 KiB 的 main.js;動態版本的入口約 3.43 KiB,另有約 68.6 KiB 的按需 Chunk。總量沒有神奇消失,只是下載時點改變。

驗證與重現:固定 webpack 版本比較同步與動態匯入

以下流程使用 Node.js 24.19.0、npm 11.17.0、webpack 5.101.3、webpack-cli 6.0.1 與 lodash 4.17.21,在隔離目錄執行。它只驗證模組圖與輸出檔案,不連線正式站台。排程環境已實際執行;兩次建置均成功,但安裝時也回報相依樹有安全警示,因此此版本組合只作可重現實驗,不是正式專案的升級建議。

  1. 建立空白測試目錄,寫入鎖定上述版本的 package.json,並執行 npm install --ignore-scripts。以 npm ls webpack webpack-cli lodash --depth=0 確認版本完全一致。
  2. 建立 src/static.js,在模組頂層使用 import _ from "lodash";以 production mode 輸出至 dist-static,執行 npm run build:static。
  3. 建立 src/lazy.js,在按鈕事件中使用 await import("lodash");同樣以 production mode 輸出至 dist-lazy,執行 npm run build:lazy。
  4. 比較檔案清單與 webpack 輸出。同步組應只有初始 main.js;動態組應有較小的 main.js 與一個帶內容雜湊的非同步 JavaScript Chunk。缺少 Chunk、版本不符或建置非零結束碼都判定失敗。
npm install --ignore-scripts
npm ls webpack webpack-cli lodash --depth=0
npm run build:static
npm run build:lazy

本次真實結果為:版本檢查精確列出 webpack 5.101.3、webpack-cli 6.0.1、lodash 4.17.21;同步建置產生 69 KiB main.js;動態建置產生 3.43 KiB main.js 與 68.6 KiB 的雜湊 Chunk,兩者結束碼皆為 0。這只證明固定合成案例中 import() 建立按需輸出,不可外推成任何正式網站的速度提升百分比。

上線前檢查清單與效能 KPI

上線前應同時檢查功能、資源與部署契約:

  • 使用正式模式與同一份 lockfile,比較修改前後的 entrypoint 與 Chunk 模組。
  • 在乾淨瀏覽器工作階段確認首屏不要求目標 Chunk,進入功能時才要求。
  • 模擬慢速網路,確認 Suspense fallback 可見且不造成整頁無意義閃爍。
  • 模擬 Chunk 404 或離線,確認 Error Boundary 提供可恢復訊息,而非白屏或無限 loading。
  • 檢查內容雜湊、快取標頭、HTML 發佈順序與舊資源保留窗口,降低跨版本載入失敗。
  • 重新檢查大型字型、影片與圖片;不要把 JavaScript 改善掩蓋在媒體負擔下。
  • 記錄拆分前後的初始 JavaScript、長任務、互動等待及 Chunk 失敗率,不用單次本機秒數當結論。

North Star Metric 可定義為「核心首屏完成前實際需要的 JavaScript 位元組」,輔以 LCP、INP、Chunk 載入失敗率與功能首次開啟延遲。若初始 JS 下降,但進入主要功能時等待顯著增加,策略只是把成本往後推;這時應根據真實導航路徑評估預載,而不是直接取消所有分割。

結論

React 程式碼分割的價值不在製造更多檔案,而在讓下載時點符合使用者需求。先從正式建置找出同步引入鏈,再把低頻重型功能移到穩定的模組頂層 lazy 邊界,用 Suspense 與 Error Boundary補齊等待及失敗體驗,最後以初始資源圖和瀏覽器請求驗證結果。

若只做動態匯入而不檢查其他同步引用,套件仍可能留在首屏;若只調 splitChunks 而沒有清楚的功能邊界,則可能得到更複雜但沒有更好的資源圖。可維護的做法是一次處理一個已證明的瓶頸、固定版本重建、記錄通過與失敗條件,再依新的基準決定下一步。