跳至主要內容

React 的公開模型是 UI = f(state):你描述介面在給定 state 下應長什麼樣子,當 state 改變時,React 更新輸出。

一次 render 不是「React 呼叫 component 然後更新 DOM」。那個描述跳過了 stale closures、state 在 list items 之間跳動、開發時 Effect 跑兩次,以及被放棄的 transitions 背後的機制。

React 維護自己的介面表示。一次 update 被 scheduled,React 在 render phase 計算可能的下一棵樹,然後才在 commit phase 發布結果。


text
event, promise, or external store
  → enqueue an update
  → assign a priority
  → render and reconcile a possible next tree
  → commit the finished changes
  → let the browser paint

Render purity、state snapshots、以 type 與 key 決定 identity,以及 render/commit 分割,屬於公開模型。像 Fiber、lanes、beginWork 這類名稱描述的是 React 19.2 的實作。應用程式碼不應依賴它們。

Fiber 的走訪與可中斷 rendering,見 Beyond The DOM 的 React Fiber 與 Concurrent Rendering in React 18。


1. JSX 產生的是描述,不是 DOM Nodes

JSX 是描述 UI 的語法。Transform 會把它轉成 React element——一份描述,不是 DOM node,也不是 Fiber。


tsx
function Greeting({ name }: { name: string }) {
  return <h1 className="greeting">Hello, {name}</h1>
}

ts
{
  type: "h1",
  key: null,
  props: {
    className: "greeting",
    children: ["Hello, ", name],
  },
}

  • Elements 是便宜、immutable 的 snapshots。建立一個並不會 mount 任何東西、跑 Effect,或碰 DOM。
  • Component element 的 type 是 function。React 稍後在 rendering 時呼叫它。
  • 直接呼叫 component——Greeting({ name: "Ada" })——會繞過 identity 與 Hook bookkeeping。
  • React 可能為一次永遠不會 commit 的 render 建立並檢查許多 elements。

要分開看待三棵樹:


text
React elements   declarative descriptions returned by components
Fiber tree       React's persistent bookkeeping and unit-of-work tree
Host tree        DOM nodes, native views, or another renderer's output

  • Host tree 取決於 renderer:react-dom、React Native、React Three Fiber。React 本身對 renderer 無關。
  • 一個新的 element object 不代表一個新的 DOM node。一次 component render 不代表一次可見的 browser update。
  • React Native 如何透過 Expo 做這件事,見 深入理解 React Native。

2. Fiber 是 React 的 Unit of Work

Elements 是暫時的描述。React 需要一個能從一次 render 存活到下一次的持久結構:Fiber tree。

  • 一個 Fiber 是某個 component、host element、text node 或 Suspense boundary 的 virtual stack frame。Host nodes 也有 Fiber——不只 function components。
  • Fibers 使用 child、sibling 與 return 連結。那是 linked list,不是 children 陣列。下一個 unit of work 永遠是跟著 pointer 走。
  • 舊的 stack reconciler 用 JavaScript call stack 的遞迴走樹,無法暫停。Fiber 用 work loop 走,所以 React 可以在 units 之間停下,再從存好的 pointer 繼續。
  • Current Fiber 是螢幕上的那棵樹。它的 alternate 是 work-in-progress Fiber,React 在那裡計算下一狀態。
  • 這是 double buffering:私下突變 WIP,保持 current tree 與 DOM 穩定。放棄工作靠丟掉 WIP,而不是突變螢幕。
  • Fibers 與 subtrees 可以重用。React 不會從頭克隆整個應用。

text
{
  type,            // function, host tag, or other Fiber kind
  child, sibling, return,
  memoizedProps,
  memoizedState,
  alternate,       // the other buffer
}

text
App
  child → Header
            sibling → Main
                        child → Article

Article.return → Main
Main.return    → App

text
current tree                         work-in-progress tree

App ───────────── alternate ───────────── App
 └─ List ──────── alternate ──────────── List
     └─ Item ──── alternate ──────────── Item

3. State Setter 會 Schedule 工作

呼叫 state setter 並不會突變正在執行的 function 所捕捉的 state 變數。它為未來的 render queue 一個值。


tsx
function Counter() {
  const [count, setCount] = useState(0)

  function increment() {
    setCount(count + 1)
    console.log(count) // Still 0 in this event handler
  }

  return <button onClick={increment}>{count}</button>
}

  • count binding 屬於某一次 invocation。Handler 關閉的是那次 render 的 snapshot。
  • 可觀察模型:current state snapshot + queued updates → next state snapshot。
  • React batches updates,以便在一組相關 setters 之後只 render 一次。Batching 不是把每次 update 合併成一個值。
  • 當下一個值等於當前值(Object.is)時 bail out,是優化,不是保證。

當幾次 updates 依賴前一次結果時,updater functions 很重要:


tsx
function addThree() {
  setCount((value) => value + 1)
  setCount((value) => value + 1)
  setCount((value) => value + 1)
}

Failure: 三次 setCount(count + 1) 都捕捉同一個 count,都請求 1。它們不會形成一串 +1 操作。


4. Lanes 代表 Priority

不是每次 update 都同樣緊急。打字應該立刻有感覺;一份大的 search result 可以等。

React 19.2 用 lanes 表示 priority——bitmask 裡的 bits,讓幾類 pending 工作可以同時存在。


text
urgent input update       ─┐
default data update        ├─ pending lanes on the root
transition update          ┤
retry for Suspense        ─┘

一次 click 通常佔的是 SyncLane——urgent-input 這類工作在實作裡的名字,不是應用程式碼會 import 的東西。

一次很長的 blocking render 會佔住 main thread:React 結束之前,browser 不能 paint、處理 click,或捲動。那正是 Fiber 與 lanes 要避免的凍結——但只對 opt-in 的 renders 成立。

Concurrent rendering 是 按 feature opt-in。預設的 setState 仍是一次不中斷的 transaction。startTransition、useTransition、useDeferredValue,以及相關的 Suspense 行為,才標記 React 可以中斷的工作。

Concurrent render 期間,scheduler 可以在 Fibers 之間 yield(實作裡的 shouldYield——大約幾毫秒的 time slice,不是 API)。React 把控制權交回 browser,再從同一棵 work-in-progress tree 繼續。若更高 priority 的 update 到來,它可以丟掉這份 WIP 並重啟。


  • React 可能開始一次 transition,收到 urgent input,丟掉這次 transition 嘗試,commit urgent 結果,再對著最新的 tree 重啟 transition。
  • startTransition 把該 function 裡同步 schedule 的 React updates 標成 transition priority。
  • 它不會把 JavaScript 搬到另一條 thread、讓昂貴計算變免費,或延遲傳給 startTransition 的那個 function。
  • useTransition 是同樣的標記,外加 isPending,讓你可以顯示 pending UI,不必藏起 上一次已 commit 的樹。
  • useDeferredValue 是你並未親自 schedule 的那個值的對應做法:先繼續顯示舊值,讓較重的 derived render 追上來。
  • Lane 名稱與 yield 預算可能隨版本改變。耐用的課是:React 分開追蹤 哪些工作 pending,以及 現在該嘗試哪些工作。

tsx
function handleChange(value: string) {
  setQuery(value)

  startTransition(() => {
    setFilter(value)
  })
}

同一套 urgent/transition 拆分就是 counter 與 heavy chart demo:increment 保持 urgent;把 setSeed 包進 startTransition,chart 就可以 yield。


tsx
const [isPending, startTransition] = useTransition()

function regenerate() {
  startTransition(() => {
    setSeed(nextSeed())
  })
}

return (
  <button onClick={regenerate} disabled={isPending}>
    {isPending ? "Updating…" : "Regenerate"}
  </button>
)

5. Render Phase 計算候選樹

Rendering 回答的是:「若套用這些 updates,樹應該長什麼樣?」


text
while next unit of work:
  beginWork(fiber)          // descend: invoke component, reconcile children
  → if child exists, that child is next
  → else completeWork(fiber) // ascend: prepare host, bubble effect flags
      then sibling, or return to parent

  concurrent and shouldYield?  pause; resume this pointer later

走訪順序仍是 depth-first。與遞迴 call stack 的差別是:這個 loop 把下一個 Fiber 存在變數裡,所以可以在 units 之間停下。

  • beginWork 處理一個 Fiber 的 inputs。對 function component,這是 React 呼叫它、跑 Hooks,然後 reconcile 回傳的 children 的地方。對 host element,它 reconcile children,不呼叫 component function。
  • completeWork 在 descendants 完成後跑。對 host components,React 可以建立或準備 DOM instances,並把 effect flags 往 root 冒泡。這裡不突變 DOM。
  • Render phase 必須是 pure,因為它是 speculative。Concurrent render 可能 yield、重啟、render 不止一次,或在不 commit 的情況下丟掉結果。預設的 blocking render 仍會把這個 loop 從頭跑完。
  • Rendering 可以讀 props、state、context 與 immutable snapshots。它可以計算並回傳 elements。
  • 它不得發送 analytics、突變共享 objects、subscribe,或編輯 DOM。

tsx
let nextId = 0

function Row() {
  nextId += 1
  return <li>Row {nextId}</li>
}

Failure: 可見的 IDs 現在取決於 React 碰巧呼叫 Row 多少次,包括開發時檢查與被放棄的 renders。


6. Reconciliation 決定 Identity

React 用 type、position 與 key,把新的 child elements 對上現有 Fibers。

對同一位置的 child:

  • 相同 type 且 key 相容:重用 Fiber 並保留其 state。
  • 不同 type:unmount 舊 subtree 並 mount 一個新的。
  • 不同 key:即使 type 相同,也是新的 identity。

tsx
function Profile({ editing }: { editing: boolean }) {
  return editing ? <Editor /> : <Preview />
}

Editor 與 Preview 佔同一位置但 type 不同,所以切換模式會重置 subtree 的 state。

Key 可以刻意重置 state。<Editor key={documentId} /> 通常比用 Effect 清掉每個 field 更乾淨。


tsx
{
  todos.map((todo) => <TodoRow key={todo.id} todo={todo} />)
}

當一個 item 被移除,React 用新的 element list 對上 current Fibers:


text
keys in the new list:  1, 3
current sibling list:  1 → 2 → 3

key 1 matches → reuse / clone that Fiber
key 2 is gone → mark a deletion flag (no DOM removal yet)
key 3 matches → reuse / clone that Fiber

缺少的 Fiber 在 render 期間只被 標記。Host node 在 commit phase 才刪除。這讓 render phase 保持 pure 且可中斷。

  • Keys 只需要在 siblings 之間唯一。它們不是普通 prop。
  • State 並不「活在 component function 裡面」。React 把它關聯到 parent tree 某個位置上 Fiber 的 identity。

Failure: 陣列 index 只有在 identity 真的跟隨 position 時才安全。若 reorder 改變了誰擁有 index 0,React 可能把 state 留在那個 index,並掛到錯誤的 data 上。


7. Commit Phase 發布結果

Render 完成時,React 有一棵 Fiber tree,以及描述必要變更的 flags。對一個 root 來說,committing 是 synchronous。React 不會展示一次 commit 的一半與另一次的一半。


text
before-mutation work
  → DOM mutations and deletions
  → current tree switches to the finished tree
  → refs and layout effects
  → browser gets an opportunity to paint
  → passive effects

  • getSnapshotBeforeUpdate 在 mutations 之前立刻讀取 host 資訊。
  • Ref callbacks 與 useLayoutEffect 在 paint 之前觀察已 commit 的 DOM。Layout 工作會擋住 paint——留給必須在使用者看到這一幀之前完成的測量。
  • useEffect 是 passive,通常在 paint 之後跑。
  • React 再次跑一個 Effect 之前,會先跑上一次的 cleanup。Unmount 時跑最後一次 cleanup。
  • DOM 改變與 pixels 出現不是同一件事。一次很長的 layout Effect 可以延遲一次 commit 的 paint,即使 DOM 已經更新。

tsx
useEffect(() => {
  const connection = connect(roomId)
  connection.open()

  return () => connection.close()
}, [roomId])

8. Hooks 是 Fiber 上的 Positional State

Hook state 由 React 儲存,不在本地 JavaScript 變數裡。Function component Fiber 把 Hooks 存成 linked list。Dispatcher 按 呼叫順序 走那條 list。


text
Profile fiber
  memoizedState
    → useState hook
    → useReducer hook
    → useEffect hook
    → null

  • 第一個 useState 拿到第一個 state cell。第二個 Hook 拿到第二個 cell。
  • 「只在 top level 呼叫 Hooks」是為了保住這個序列。條件應放在 Hook 裡面。
  • 每個 state Hook 擁有一條 update queue。較低 priority 的 updates 可以繼續 queued,稍後再 replay。
  • 每次 render 都會建立新的 handlers,捕捉那次 render 的 props 與 state。「Stale closure」就是較舊 render 的程式碼稍後才跑。
  • 省略一個 reactive dependency 並不會把它凍在最新值——它會保住較舊的 closure。

tsx
useEffect(() => {
  const id = setInterval(() => {
    console.log(count)
  }, 1000)

  return () => clearInterval(id)
}, []) // This closure keeps the initial count.

Failure: 若一個 Hook 坐在 if (editable) 後面,當 editable 改變時,它之後的每個 Hook 都會 移位,React 就無法再對上 cells。


9. Bailouts 與 Memoization 跳過工作,不是跳過意義

當 props、state、context 與 lanes 顯示它及其 descendants 在當前 priority 不需要工作時,一個 Fiber 可以 bail out。

  • memo 在 component boundary 加上 props 比較。
  • useMemo 在 committed renders 之間快取一次計算,只要 dependencies 保持相等。
  • useCallback 對 function reference 做同樣的事。
  • 這些是效能工具,不是語意保證。若 React 重新計算,結果仍然必須正確。
  • Parent 可以 render 而 child 不 render。Component 可以 render 而 DOM 不變。

Runtime 決定一個 Fiber 能否 bail out。React Compiler Internals 是 build-time 那一半:HIR、effects 與 reactive scopes 如何自動產生更細的 cache boundaries。

調查效能時,把三個問題分開:

  1. 是否有 update 被 scheduled?
  2. 哪些 components 做了 render 工作?
  3. 哪些 host 變更與 Effects 被 committed?

10. Suspense 把等待變成 Render Control Flow

Suspense 讓一次 render 可以說樹的一部分還沒準備好。在 React 19 裡,use 可以讀一個 Promise。


tsx
function UserProfile({
  userPromise,
}: {
  userPromise: Promise<{ name: string }>
}) {
  const user = use(userPromise)
  return <h2>{user.name}</h2>
}

function Page({ userPromise }: { userPromise: Promise<{ name: string }> }) {
  return (
    <Suspense fallback={<p>Loading profile...</p>}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  )
}

  • 若 Promise 仍 pending,React 會 suspend 那條路徑,找到最近的 Suspense boundary,並可以 render 它的 fallback。
  • Suspense 不會 fetch。Framework 或 cache 必須提供一個 stable Promise。
  • 等待屬於 candidate tree。Committed tree 不必因為另一次尚未完成的 render 而消失。
  • 對非緊急 navigation,React 可以讓已經可見的內容留在螢幕上,同時準備下一狀態。

Failure: 每次 client render 都建立一個新的 Promise,可能反覆 suspend,因為每次 render 看到的都是新的 resource。


11. Hydration 重用 Server HTML

Server rendering 產生 HTML,但 HTML 不含 React state、event handling,或 client Fiber tree。Hydration 在匹配現有 host nodes 的同時,建立 client 表示。


text
server
  React tree → HTML stream → browser displays content

client
  load JavaScript
    → create root work
    → match Fibers to existing DOM
    → attach event behavior
    → continue as an interactive React tree

  • Hydration 不是「加上 event listeners」。React 必須重建 state 與 context、把 Fibers 關聯到 host nodes,並驗證 markup 相容。
  • Mismatch 表示 server 與 client 對同一棵初始樹產生了不同描述——時鐘、隨機值、僅 browser 的分支、locale 差異、無效的 HTML nesting。
  • 讓初始 render 可確定,傳入 server snapshot,或在 hydration 之後再 render 僅 client 的資訊。
  • suppressHydrationWarning 是一層的逃生口,不是通用修復。
  • Suspense boundaries 讓 React 分段 hydrate,整頁不必等一次全有或全無的 render。

12. Strict Mode 測試可重啟的程式碼

在開發時,Strict Mode 可能把 render functions 呼叫兩次、為 Effects 多跑一輪 setup-and-cleanup,並重跑 ref callbacks。這些檢查在 production 不會以同樣方式發生。

  • 目的是找出在 rendering 被重啟、或 synchronization 被 remount 時會壞掉的程式碼。
  • 會突變 props 的 impure render,會在那次額外 render 上暴露自己。
  • 沒有 cleanup 的 Effect 會暴露重複 subscriptions。解法是一次 cleanup,讓 setup → cleanup → setup 等價於一次真正的 setup。
  • Render 期間純、Effect cleanup 對稱的程式碼,不必在意 React 做了多少次 speculative 嘗試。

13. 端到端跟一次 Update

沒有 concurrent feature 時,一次沉重的 setState 會在 main thread 上把 work loop 跑完。頁面在那次 render 結束前無法 paint,也接不下一次按鍵。

使用者在 search field 打字。應用緊急更新 input,並在 transition 裡過濾一份大 list。

  1. Handler 呼叫 setQuery(urgent)以及 startTransition + setFilter(transition)。
  2. React 先選 urgent 工作,render、reconcile,並 commit input DOM。
  3. Browser 可以 paint 最新的 input 值。
  4. React 以可中斷的 slices 嘗試這次 transition。若又有一次按鍵到來,它可以丟掉這份 WIP,commit 更新的 urgent input,再對著最新的 tree 重啟 filter。
  5. 當一次 transition render 完成,React commit 過濾後的 list。Passive Effects 隨後跑。
  • React 從不突變較舊 handler 裡的 query binding。它用新的 snapshots 建立新的 renders。
  • 部分完成的 transition 永遠不會漏進 DOM。只有完成的 commit 才會改變可見的 host tree。即使 isPending 為 true,上一次已 commit 的 UI 仍留在螢幕上。

最短且準確的模型:


text
Elements describe.
Fibers remember.
Queues collect.
Lanes prioritize.
Rendering computes.
Reconciliation matches.
Committing publishes.
Effects synchronize.

當 React 行為讓你意外時:

  1. 哪個 render 建立了這個 callback? 它的 closure 包含那次 render 的 props 與 state。
  2. Setter 拿到的是 replacement 還是 updater? 重複的 replacements 可能都用同一份 snapshot。
  3. 這個 component 的 identity 是什麼? Parent position、type 與 key。
  4. 這段程式碼是在 render 還是 commit 期間跑? Render 必須是 pure。
  5. React 是 render 了、commit 了,還是兩者都做了? Component 裡的 console log 並不能證明 DOM 變了。
  6. 這次工作可能被重啟過嗎? Strict Mode 與 concurrent rendering 會把這件事變得可見。
  7. 所有 reactive Effect dependencies 都宣告了嗎? 若沒有,很可能正在重用較舊的 closure。

常見誤解:

  • Fiber 不是「virtual DOM」。 Elements 是描述;Fibers 是 reconciler 持久的工作與 state。
  • Fiber 是 linked list 上的 loop,不是 JavaScript stack 上的遞迴。 這才讓暫停與繼續成為可能。
  • Fiber 不會讓每次 render 都更快。 它的好處是給工作排優先級,並避免擋住 browser——而且只對 opt-in concurrent rendering 的工作成立。
  • Concurrent 不代表 parallel,也不是每個 setState 的預設。 React 仍然在 main thread 上協調 JavaScript。Concurrent features 讓它可以交錯、yield,並丟掉過時的 renders。
  • Fiber 不是 React Three Fiber。 一個是 reconciler。另一個是以它命名的 Three.js renderer。

較新的 APIs 見 React 19 新功能。可視化:React Fiber 與 Concurrent Rendering in React 18。主要參考:Andrew Clark 的 React Fiber architecture notes,以及 React 文件中的 render and commit、state as a snapshot、preserving state、Effects、transitions、Suspense 與 hydration。


Recap Q&A