課程:JavaScript 與 React 底層原理 第 21 堂:效能優化收尾
65 列表渲染的瓶頸
當我們在開發一個簡單的待辦事項清單或使用者列表時,React 的表現總是流暢得令人驚艷。然而,當業務邏輯成長,資料量從 10 筆跳到 1,000 筆、甚至 10,000 筆時,你可能會發現原本靈活的 UI 開始變得「黏稠」:捲動時出現白屏、點擊按鈕後有明顯的延遲、甚至整個瀏覽器分頁直接崩潰。
這通常不是因為你的 memo 沒寫好,也不是因為你的 useEffect 依賴陣列出錯。這是一個更深層、關於「物理極限」的問題。
瀏覽器渲染流水線:為什麼 DOM 節點越多越慢?
要理解這個問題,我們必須暫時放下 React,回到瀏覽器的核心。當我們說「渲染一個列表」時,瀏覽器實際上在進行一場極其複雜的「關鍵渲染路徑」(Critical Rendering Path, CRP)大隊接力。
這條流水線通常包含以下步驟:
- JavaScript:執行程式碼,決定要新增哪些元素。
- Style:計算每個元素對應的 CSS 規則。
- Layout (Reflow):計算每個元素在螢幕上的幾何位置(寬度、高度、座標)。
- Paint (Repaint):將元素轉換成像素(畫出顏色、邊框、陰影)。
- 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),只處理可視範圍內的節點,徹底解決大數據列表的效能問題。