深入理解 React Concurrent Rendering - 流暢的 React 第七章 並行 React 筆記

2025年12月29日19 分鐘閱讀

React 18 引入的並行渲染(Concurrent Rendering)是 React 架構的重大革新。本文將從同步渲染的痛點出發,逐步解析並行渲染的核心機制,並探討實際應用中需要注意的問題。

同步渲染的瓶頸

在並行渲染出現之前,React 採用同步渲染(Synchronous Rendering)模式。這種模式的運作方式非常直接:一旦開始渲染,就必須一次性完成,中途無法暫停或被插隊。

這帶來了兩個關鍵問題:

阻塞主執行緒:主執行緒是瀏覽器處理使用者互動(點擊、輸入)和畫面繪製的唯一通道。當同步渲染佔用主執行緒時,所有使用者操作都會被擱置,直到渲染完成。在複雜應用中,這會導致畫面凍結、按鈕沒反應,嚴重影響使用者體驗。

缺乏優先級機制:所有任務一視同仁,沒有輕重緩急之分。後台的低優先級任務可能阻塞關鍵的使用者互動。

並行渲染 Concurrent Rendering 的核心思想

並行渲染的關鍵突破在於讓渲染過程變得「可中斷」。React 透過時間分片(Time Slicing)技術,將大型渲染任務分解成多個可在幾毫秒內完成的小區塊。

這帶來三大優勢:

  • 優先級排程:根據更新的重要性和緊急性排定處理順序
  • 時間分片:將渲染工作切分成小塊,分散在多個 frame 中處理
  • 可中斷性:高優先級任務可以打斷低優先級任務

核心機制解析

Fiber 架構

Fiber 是 React 實現並行渲染的基礎架構。在 Fiber 出現之前,React 的 reconciliation 過程依賴 JavaScript 的原生呼叫堆疊(Call Stack),一旦開始就必須執行到底。Fiber 徹底改變了這個模式,讓 React 擁有了「自己的堆疊」,可以隨時暫停、恢復或放棄渲染工作。

1. 作為「工作單元」的虛擬堆疊幀

在 Fiber 架構中,元件樹被重新實作為一個由 Fiber 節點組成的鏈結串列(Linked List):

  • 單位化工作:每個 Fiber 節點代表一個「工作單元」,就像 React 在記憶體中維護的「虛擬堆疊幀(Virtual Stack Frame)」,讓 React 不再受限於 JavaScript 原生呼叫堆疊的同步執行限制
  • 攜帶元數據:每個 Fiber 物件保存了元件的類型、狀態、Props,以及指向父層(return)、子層(child)與兄弟層(sibling)的指標,使 React 能隨時暫停並在之後精確回到該位置繼續工作
FiberNode {
  type,           // 元件類型
  stateNode,      // 對應的 DOM 節點或元件實例
  pendingProps,   // 新的 props
  memoizedState,  // 當前狀態
  return,         // 父 Fiber
  child,          // 第一個子 Fiber
  sibling,        // 下一個兄弟 Fiber
  alternate,      // 對應的另一棵樹上的 Fiber
  lanes,          // 優先級標記
}

2. 實現「可中斷」的渲染過程(Time-Slicing)

Fiber 將更新過程拆分為兩個階段,使渲染變得非阻塞:

渲染階段(Render Phase):非同步且可中斷。Fiber 在執行過程中不斷檢查是否超過了約 5ms 的時間切片預算,如果時間用盡,會透過 shouldYield() 讓出主執行緒給瀏覽器處理更高優先級的任務(如使用者輸入),並在下一幀恢復工作。

提交階段(Commit Phase):同步且不可中斷。一旦整個 Work-in-progress 樹準備就緒,Fiber 會一次性將變更應用到實際 DOM 上,確保 UI 的一致性。

渲染階段 Render Phase(可中斷) 提交階段 Commit Phase(不可中斷)
┌─────────────────────┐    ┌─────────────────┐
│ beginWork           │    │ commitRoot      │
│   ↓                 │    │   ↓             │
│ 處理 Fiber 節點      │ →  │ 更新 DOM        │
│   ↓                 │    │   ↓             │
│ shouldYield()?      │    │ 執行 Effects    │
│   ↓                 │    └─────────────────┘
│ completeWork        │
└─────────────────────┘

3. 支持「雙重緩衝」機制(Double Buffering)

Fiber 節點中的 alternate 指標讓 React 可以在記憶體中同時存在兩個版本的 Fiber 樹:

  • Current 樹:目前顯示在螢幕上的樹
  • Work-in-progress 樹:正在後台構建、尚未完成的更新樹

這種機制允許 React 在後台安靜地準備新 UI,不會干擾當前視圖,直到渲染完成後再快速交換指標(Swap pointers)。如果高優先級更新插入,可以直接丟棄未完成的 Work-in-progress 樹,重新開始。

Current Tree          Work-in-progress Tree
    A ←─ alternate ─→ A'
   / \                / \
  B   C              B'  C'(建構中)

4. 優先級調度的執行者

Fiber 與 Lanes 系統緊密配合,決定哪些工作應該先做:

  • 每個 Fiber 節點的 lanes 欄位標記了該節點待處理更新的優先級
  • React 在遍歷 Fiber 樹時,會根據當前正在處理的 renderLanes 決定是否處理該節點的更新
  • 這讓 React 能像「多工處理者」一樣,優先處理點擊、打字等緊急更新,並推遲或丟棄已過時的後台任務

5. 賦能現代高級功能

沒有 Fiber 的架構支撐,以下功能將無法實現:

  • Suspense:Fiber 允許 React 在等待元件載入或資料獲取時暫停子樹的渲染,展示 fallback UI,而不會鎖定整個界面
  • 流式伺服器渲染(Streaming SSR):藉由 Fiber 的增量渲染能力,React 可以在伺服器端部分元件準備好後就開始流式傳輸 HTML
  • 並行渲染(Concurrent Rendering):讓 React 同時準備多個版本的 UI,優化使用者體驗

調度器(Scheduler)

調度器是並行渲染的決策中樞,負責管理主執行緒的讓出(yielding)。它的核心職責包括:

  • 任務排序:接收所有更新請求,決定「哪些工作現在執行」以及「哪些工作稍後執行」
  • 主執行緒管理:策略性地安排任務,確保瀏覽器主執行緒不會被長時間阻塞
  • 優先級協調:透過 Render Lanes 機制,根據任務緊急程度進行排序

調度器的運作機制依賴瀏覽器 API(如 setTimeoutMessageChannel)來安排任務執行時機:

  • 同步任務(Sync Lane):排入微任務(microtask),在當前事件循環結束時立即處理
  • 非同步任務(Non-Sync Lane):排程回調函數,等主執行緒有空檔時再處理

Render Lanes

Lanes 是 React 用來標記和組織更新優先級的位元遮罩系統。不同類型的更新會被分配到不同的 Lane:

Lane 類型超時時間使用場景
SyncLane / InputContinuousLane~250ms使用者點擊、輸入、手勢
TransitionLane~5000ms頁面跳轉、非緊急更新
IdleLane永不過期低優先級背景任務

React 原始碼解析

ensureRootIsScheduled

這個函式是調度流程的入口,每當 React Root 接收到更新時就會觸發。它的職責是確保 Root 被加入調度佇列,並且有對應的微任務來處理這個調度:

1export function ensureRootIsScheduled(root: FiberRoot): void {
2 // 將 root 加入調度佇列(linked list 結構)
3 if (root === lastScheduledRoot || root.next !== null) {
4 // 已在佇列中,跳過
5 } else {
6 if (lastScheduledRoot === null) {
7 firstScheduledRoot = lastScheduledRoot = root;
8 } else {
9 lastScheduledRoot.next = root;
10 lastScheduledRoot = root;
11 }
12 }
13
14 // 標記可能有同步工作待處理
15 mightHavePendingSyncWork = true;
16
17 // 確保調度被觸發
18 ensureScheduleIsScheduled();
19}

scheduleTaskForRootDuringMicrotask

這個函式在微任務中執行,負責實際的調度決策:

1function scheduleTaskForRootDuringMicrotask(
2 root: FiberRoot,
3 currentTime: number,
4): Lane {
5 // 1. 標記飢餓的 lanes,防止低優先級更新被無限延遲
6 markStarvedLanesAsExpired(root, currentTime);
7
8 // 2. 決定下一批要處理的 lanes
9 const nextLanes = getNextLanes(root, ...);
10
11 // 3. 根據 lane 類型決定調度方式
12 if (includesSyncLane(nextLanes)) {
13 // 同步工作:在 microtask 結束時自動執行
14 root.callbackPriority = SyncLane;
15 return SyncLane;
16 } else {
17 // 非同步工作:透過 Scheduler 排程
18 const schedulerPriorityLevel = lanesToEventPriority(nextLanes);
19 const newCallbackNode = scheduleCallback(
20 schedulerPriorityLevel,
21 performWorkOnRootViaSchedulerTask.bind(null, root),
22 );
23 return newCallbackPriority;
24 }
25}

防止飢餓機制

markStarvedLanesAsExpired 確保等待太久的更新能被強制執行:

1export function markStarvedLanesAsExpired(
2 root: FiberRoot,
3 currentTime: number,
4): void {
5 const pendingLanes = root.pendingLanes;
6 const expirationTimes = root.expirationTimes;
7
8 let lanes = pendingLanes;
9 while (lanes > 0) {
10 const index = pickArbitraryLaneIndex(lanes);
11 const lane = 1 << index;
12 const expirationTime = expirationTimes[index];
13
14 if (expirationTime === NoTimestamp) {
15 // 尚未設定過期時間,計算並設定
16 expirationTimes[index] = computeExpirationTime(lane, currentTime);
17 } else if (expirationTime <= currentTime) {
18 // 已過期,標記為 expired 強制執行
19 root.expiredLanes |= lane;
20 }
21
22 lanes &= ~lane;
23 }
24}

調度流程圖

concurrent-react1.png

實際場景分析

場景 1:使用者點擊按鈕

onClick={() => setState(x)}
  → ensureRootIsScheduled(root)
  → includesSyncLane(nextLanes) = true
  → 不額外排程,等 microtask 結束時同步執行

場景 2:startTransition 更新

startTransition(() => setState(x))
  → ensureRootIsScheduled(root)
  → nextLanes = TransitionLane
  → scheduleCallback(NormalSchedulerPriority, performWork)
  → 可被中斷的非同步渲染

場景 3:Suspense 等待數據

render(<Suspense><AsyncComponent /></Suspense>)
  → isWorkLoopSuspendedOnData() = true
  → 取消 callback,等待 ping 或新的更新

並行渲染 Hooks

useTransition

useTransition 將狀態更新標記為「非緊急」,適用於可能導致畫面卡頓的操作(如頁面跳轉、篩選大量資料)。

1const [isPending, startTransition] = useTransition();
2
3// isPending:指示非緊急更新是否進行中,可用於顯示 loading 狀態
4// startTransition:包裹耗時的狀態更新
5
6function handleFilter(value) {
7 // 輸入框立即響應
8 setInputValue(value);
9
10 // 篩選結果可以延遲,不阻塞輸入
11 startTransition(() => {
12 setFilteredList(computeExpensiveFilter(value));
13 });
14}

實際應用:在即時聊天室中,當伺服器不斷傳來新訊息時,用 startTransition 包裹 setMessages 操作,確保使用者打字的流暢度優先於訊息列表的更新。

useTransition 的 Lane 調度流程

useTransition 完整狀態機

concurrent-react2.png

實際場景:Tab 切換

從以上的例子中可以發現,使用 useTransition 讓大型的 UI 更新不會阻塞使用者互動,同時透過 isPending 提供即時的視覺反饋,並且在使用者改變心意時能夠立即響應而不必等待前一個渲染完成。

useDeferredValue

useDeferredValue 延遲更新 UI 的某個部分,讓舊內容多顯示一下,直到新內容準備好。相較於 debounce 和 throttle,它的優勢在於「可被中斷」—— 如果使用者在延遲期間輸入了新內容,React 會放棄舊的渲染。

1function SearchResults({ query }) {
2 // deferredQuery 會「落後」於 query
3 const deferredQuery = useDeferredValue(query);
4
5 // 輸入框始終保持流暢
6 // 搜尋結果在系統有餘裕時才更新
7 return ;
8}

useDeferredValue 內部機制

concurrent-react3.png concurrent-react4拷貝.png

這就是 useDeferredValue 能夠比 debounce/throttle 更有效的原因:它不是簡單地「延遲固定時間」,而是透過 Lane 優先級系統,讓 React 自動決定何時處理延遲的更新,並且在有新輸入時能夠主動中斷舊的渲染工作。

useDeferredValue 的 Lane 調度流程

畫面撕裂(Tearing)問題

畫面撕裂是並行渲染獨有的副作用。想像這個情境:React 正在渲染一個列表,渲染到一半時被暫停。如果暫停期間外部資料源發生變化,恢復渲染時後續元件就會讀取到新資料,導致同一畫面上的元件顯示了來自不同時間點的資料。

Tearing 問題示意

useSyncExternalStore

這個 Hook 專門解決 Tearing 問題:

1const state = useSyncExternalStore(
2 subscribe, // 訂閱外部資料源
3 getSnapshot, // 取得當前快照
4 getServerSnapshot // SSR 用的快照
5);

運作原理:

  1. 訂閱外部資料:監聽資料源變化
  2. 取得快照:在渲染時取得資料的一致性快照
  3. 強制同步更新:資料變化時觸發同步更新,確保所有元件一起重新渲染

名稱中的 "Sync" 具有雙重含義:透過強制執行同步(synchronous)更新,達成將 UI 與外部資料同步化(synchronize)的目的。

useSyncExternalStore 如何解決 Tearing

完整使用範例

concurrent-react5.png

1// 實際使用範例:訂閱瀏覽器視窗寬度
2function useWindowWidth() {
3 return useSyncExternalStore(
4 // subscribe:訂閱 resize 事件
5 (callback) => {
6 window.addEventListener('resize', callback);
7 return () => window.removeEventListener('resize', callback);
8 },
9 // getSnapshot:取得當前寬度
10 () => window.innerWidth,
11 // getServerSnapshot:SSR 時的預設值
12 () => 1024
13 );
14}
15
16// 使用
17function ResponsiveComponent() {
18 const width = useWindowWidth();
19 return 視窗寬度:{width}px;
20}

這就是為什麼當你使用 Redux、Zustand 等外部狀態管理時,它們內部都會使用 useSyncExternalStore 來確保在並行渲染環境下不會出現 Tearing 問題。

總結

並行渲染讓 React 從一口氣渲染完進化到「可中斷、可排程」的模式。理解 Fiber、Scheduler 和 Lanes 的協作機制,能幫助我們更好地運用 useTransitionuseDeferredValue 等 API 來優化應用效能,同時也要注意 Tearing 等潛在問題並使用 useSyncExternalStore 來解決。

延伸閱讀

標籤:

react流暢的 React