跳至主要内容

課程:JavaScript 與 React 底層原理 第 21 堂:效能優化收尾

65 列表渲染的瓶頸

當我們在開發一個簡單的待辦事項清單或使用者列表時,React 的表現總是流暢得令人驚艷。然而,當業務邏輯成長,資料量從 10 筆跳到 1,000 筆、甚至 10,000 筆時,你可能會發現原本靈活的 UI 開始變得「黏稠」:捲動時出現白屏、點擊按鈕後有明顯的延遲、甚至整個瀏覽器分頁直接崩潰。

這通常不是因為你的 memo 沒寫好,也不是因為你的 useEffect 依賴陣列出錯。這是一個更深層、關於「物理極限」的問題。

瀏覽器渲染流水線:為什麼 DOM 節點越多越慢?​

要理解這個問題,我們必須暫時放下 React,回到瀏覽器的核心。當我們說「渲染一個列表」時,瀏覽器實際上在進行一場極其複雜的「關鍵渲染路徑」(Critical Rendering Path, CRP)大隊接力。

這條流水線通常包含以下步驟:

  1. JavaScript:執行程式碼,決定要新增哪些元素。
  2. Style:計算每個元素對應的 CSS 規則。
  3. Layout (Reflow):計算每個元素在螢幕上的幾何位置(寬度、高度、座標)。
  4. Paint (Repaint):將元素轉換成像素(畫出顏色、邊框、陰影)。
  5. Composite:將不同的圖層合成為最終看到的畫面。

Layout 與 Paint 的指數級開銷​

當你向 DOM 中插入 1,000 個節點時,瀏覽器的 Layout 階段會面臨巨大的壓力。在 DOM 樹中,一個節點的改變往往會引發連鎖反應。如果你在列表頂部插入一個節點,瀏覽器必須重新計算該節點之後所有節點的位置。

這種計算並非線性(O(n)),在某些複雜的佈局(如 Flexbox 或 Grid 嵌套)中,計算成本可能會更高。當 N 達到數千時,Layout 階段可能就會耗費超過 100 毫秒,這遠遠超出了我們追求的 16.6ms(60 FPS)流暢預算。

記憶體的隱形殺手​

你是否思考過,一個普通的 <div> 標籤在記憶體中佔用多少空間?

在 JavaScript 中,這不只是一個字串 <div></div>。它是一個繼承自 HTMLElement 的巨大對象,擁有數百個屬性和方法(如 className, style, onclick, offsetWidth 等)。

如果你渲染一個 5,000 筆資料的列表,每筆資料有 10 個 DOM 節點(包含頭像、名稱、描述、按鈕等),你實際上在記憶體中建立並維護了 50,000 個複雜的 DOM 物件。這些物件不只佔用堆積(Heap)空間,還會增加垃圾回收(GC)的負擔。當 JavaScript 引擎頻繁進行 GC 來清理這些物件時,主執行緒就會被阻塞,造成畫面卡頓。

React 的雙重負擔:Render Phase 與 Commit Phase​

在 Topic 8 中,我們深入探討過 Fiber 架構。Fiber 雖然讓渲染變得「可中斷」,但它並不能魔法般地讓「大量的計算」消失。在處理巨量列表時,React 會在兩個階段面臨挑戰。

Render Phase:JavaScript 的運算成本​

即使我們不操作真實 DOM,React 在 Render Phase 仍有繁重的工作:

  • 建立 Element:對於 5,000 個列表項,React 必須呼叫 5,000 次 createElement(或 JSX 編譯後的代碼),生成 5,000 個 React Element 物件。
  • Diffing (Reconciliation):當列表資料更新時,React 必須遍歷舊的 Fiber 樹與新的 Element 樹進行比對。即使大部分節點都沒有變動,遞迴(或循環)遍歷 5,000 個節點本身的 CPU 耗時就已經非常可觀。

想像一下,你的 workLoop 必須處理這 5,000 個工作單元。即使 Fiber 可以讓出主執行緒,但「總工作量」依然在那裡。這就是為什麼在處理大數據時,即使是純 JS 的部分也會讓你的風扇開始狂轉。

Commit Phase:真實 DOM 的集體爆發​

當 React 完成 Diffing,進入 Commit Phase 時,它會一次性地將所有的變更套用到真實 DOM。

這是一個「同步且不可中斷」的過程。如果你有 5,000 個節點需要 Placement(掛載),React 會在短時間內呼叫數千次 document.createElement 和 appendChild。這會觸發瀏覽器的巨量 Reflow 和 Repaint。這時,Fiber 的調度能力也救不了你,因為 Commit 必須是原子的(Atomic),以確保 UI 的一致性。這段「同步阻塞」的時間,就是使用者感覺到瀏覽器「當掉」的瞬間。

為什麼傳統優化(memo/useMemo)在長列表面前顯得無力?​

在之前的課程中,我們學會了用 React.memo 阻止子元件不必要的渲染,用 useMemo 快取昂貴的計算。但在 5,000 筆資料的場景中,這些優化往往會遇到瓶頸。

1. 初始掛載的「開門黑」​

React.memo 只能優化「更新」,無法優化「第一次渲染」。當你的頁面初次載入時,React 依然必須完整執行那 5,000 個元件的邏輯並建立 DOM。對於使用者來說,這意味著進入頁面後會有長達數秒的白屏或載入轉圈。

2. 「必要的渲染」依然昂貴​

假設你正在開發一個「搜尋篩選」功能。當使用者輸入一個字,原本 5,000 筆資料過濾後剩下 3,000 筆。這 3,000 筆資料的渲染是「必要的」,React.memo 不會攔截它。但這 3,000 個節點的毀滅與重建,依然會觸發前述的渲染流水線災難。

3. 記憶體壓力的持續存在​

即便你的元件渲染很快,只要它們存在於 DOM 樹中,瀏覽器就必須為這數萬個節點維護樣式計算和層疊上下文(Stacking Context)。這是一種「持續性的效能債務」,會讓頁面的滾動行為變得越來越沉重。

量化思維:消失的 16.6 毫秒​

讓我們來算一筆帳。

假設你的一個列表項元件非常簡單,React 處理它的 Render + Commit 時間總共只需要 0.1 毫秒(這已經是非常理想的狀態了)。

  • 如果渲染 10 筆:$0.1ms \times 10 = 1ms$(遠低於 16.6ms,非常流暢)。
  • 如果渲染 100 筆:$0.1ms \times 100 = 10ms$(接近預算邊界,開始有感)。
  • 如果渲染 1,000 筆:$0.1ms \times 1,000 = 100ms$(明顯卡頓,掉幀嚴重)。
  • 如果渲染 10,000 筆:$0.1ms \times 10,000 = 1,000ms = 1秒$(UI 完全凍結,使用者體驗極差)。

你會發現,當 $N$ 增加到一定程度,單個元件的細微優化已經無法解決問題。即使你把單個元件的耗時從 0.1ms 壓低到 0.05ms,在 10,000 筆資料面前,你依然需要 0.5 秒來渲染,這在現代 Web 標準中是不可接受的。

思考題:我們真的需要這麼多 DOM 嗎?​

在結束這一部分之前,我想請你思考一個問題:

如果你的螢幕一次只能顯示 10 筆資料,而你的 DOM 樹裡卻塞了 5,000 個節點,這是不是一種巨大的浪費?

我們之所以感到卡頓,是因為我們試圖讓瀏覽器一次處理「整個世界」,而使用者實際上只關心「他看得到的那個小窗口」。

如果我們能讓 React 只渲染使用者當下看得到的那 10 或 20 個節點,無論後端傳來的是 5,000 筆還是 5,000,000 筆資料,瀏覽器的負擔是不是永遠都會維持在「渲染 20 個節點」的水平?

這就是我們下一節要討論的核心技術——列表虛擬化(List Virtualization)。

總結與銜接​

本章關鍵點​

  • DOM 的物理開銷:DOM 物件比 JS 普通物件沉重得多,大量節點會導致 Layout 和 Paint 階段耗時呈指數級成長。
  • React 的運算負擔:大量 Fiber 節點的建立與 Diffing 會阻塞主執行緒,而 Commit 階段的同步 DOM 操作則是卡頓的主要元兇。
  • 優化的局限性:memo 和 useMemo 解決的是「重複」的問題,而非「總量」的問題。當節點總數超過瀏覽器承受範圍時,傳統優化手段將會失效。
  • 量化思維:效能問題往往是 O(n) 的積累,當 N 超過 16.6ms 的預算,就必須改變策略。

既然我們已經看清了長列表渲染的物理瓶頸,下一部分我們將學習如何運用「虛擬化」的哲學,將 O(n) 的渲染壓力轉化為 O(1),只處理可視範圍內的節點,徹底解決大數據列表的效能問題。