內存洩漏追查記
之前接手了一個已經跑了兩年的 React 專案。使用者回饋說頁面跑久了會變慢,重新整理後才恢復。Chrome DevTools 的 Memory 面板顯示,每次路由切換後都有大約 15MB 的記憶體無法被回收。15MB 聽起來不多,但使用者的辦公電腦把頁面開上一整天,累積起來就是好幾 GB。
我打開了 Heap Snapshot,準備抓記憶體洩漏。結果讓我愣在原地。
洩漏的物件不是閉包,不是事件監聽器,不是全域變數——而是 FiberNode。成千上萬個 FiberNode 物件躺在 heap 裡,每個大約 300 位元組,但它們像藤蔓一樣互相糾纏,child 指向一個,sibling 指向另一個,return 指向上一個,alternate 指向另一棵樹的對應節點。這些指標織成了一張巨大的網,垃圾回收器在這張網裡迷路了,不知道哪些節點該回收,哪些不該。
我花了三天時間追查這些 FiberNode 的來龍去脈。第二天,我在
ReactFiber.js裡盯著FiberNode建構函式看了整整一個小時。那是一段樸素的程式碼——沒有精巧的演算法,沒有複雜的設計模式,就是一堆this.xxx = null。但正是這堆賦值語句,定義了 React 執行期世界的全部。javascript 代碼解讀複製代碼function FiberNode(tag, pendingProps, key, mode) { this.tag = tag; this.key = key; this.elementType = null; this.type = null; this.stateNode = null; this.return = null; this.child = null; this.sibling = null; this.index = 0; this.ref = null; this.pendingProps = pendingProps; this.memoizedProps = null; this.memoizedState = null; this.updateQueue = null; this.dependencies = null; this.mode = mode; this.flags = NoFlags; this.subtreeFlags = NoFlags; this.lanes = NoLanes; this.childLanes = NoLanes; this.alternate = null; // ... }我數了數,二十多個欄位。不多。但每一個欄位都肩負著一項使命——有的描述元件的身分,有的連結樹的結構,有的記錄工作的狀態,有的標記副作用。二十多個欄位的組合,撐起了 React 的全部執行期——元件樹、狀態管理、排程優先級、副作用追蹤、雙緩衝……全部都在這裡。
我關掉 DevTools,喝了口冷掉的咖啡。問題的根因找到了——一個第三方路由函式庫在卸載時忘了斷開 Fiber 樹的根節點引用。三行程式碼的修復。但那兩天裡我對 Fiber 資料結構的理解,比過去一年都深。
早期的 React(v15 及之前)用 Virtual DOM 來描述畫面。Virtual DOM 是什麼?本質上是 ReactElement 的樹——巢狀的物件結構,每個物件有 type、props、children。
javascript 代碼解讀複製代碼{
type: 'div',
props: {
className: 'container',
children: [
{ type: 'h1', props: { children: 'Hello' } },
{ type: 'p', props: { children: 'World' } }
]
}
}
這種結構有兩個致命問題。
第一,它只描述了「應該長什麼樣」,沒有描述「要做什麼工作」。 每次 setState,React 需要從頭遍歷整棵樹,比較新舊差異,決定哪些節點要更新。遍歷過程中產生的中間狀態——走到哪了、下一個該去哪、有哪一些副作用需要執行——全部存在 JavaScript 的呼叫堆疊上。結果就是遍歷一旦開始就無法中斷,因為呼叫堆疊裡的資訊在函式返回後就丟失了。
第二,它是不可變的。 每次更新都建立一棵全新的樹。舊樹丟棄,新樹接管。這對於簡單的應用沒有問題,但對於需要增量更新的場景(如動畫、高頻率輸入),效能代價巨大。
Fiber 資料結構就是為了解決這兩個問題而生的。它的核心設計判斷是:節點不應該只描述 UI,還應該描述工作。每個 Fiber 節點就是一個工作單元——它知道自己是什麼類型的元件、連結到哪些上下游節點、有什麼待處理的更新、屬於什麼優先級、需要什麼副作用。
這是資料結構層面的典範轉移——從「描述 UI 的樹」到「描述工作的鏈結串列」。
打開 packages/react-reconciler/src/ReactInternalTypes.js,看看 Fiber 類型的完整定義:
javascript 代碼解讀複製代碼// https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactInternalTypes.js
export type Fiber = {
// ===== 第一重身分:元件描述 =====
tag: WorkTag, // 元件類型標記(函式/類別/Host/文字...)
key: null | string, // React key
elementType: any, // ReactElement.type(未解析)
type: any, // 解析後的元件(函式引用/類別/字串標籤)
stateNode: any, // 關聯的真實實例(DOM 節點/元件實例)
// ===== 第二重身分:樹的結構 =====
return: Fiber | null, // 父節點
child: Fiber | null, // 第一個子節點
sibling: Fiber | null, // 下一個兄弟節點
index: number, // 在父節點 children 中的位置
// ===== 第三重身分:工作狀態 =====
pendingProps: any, // 新的 props(待處理)
memoizedProps: any, // 上一次 render 使用的 props
memoizedState: any, // 上一次 render 使用的 state
updateQueue: mixed, // 狀態更新佇列
dependencies: Dependencies | null, // Context 依賴
mode: TypeOfMode, // 執行模式
lanes: Lanes, // 本節點的工作優先級
childLanes: Lanes, // 子樹的工作優先級
// ===== 第四重身分:副作用追蹤 =====
flags: Flags, // 本節點的副作用
subtreeFlags: Flags, // 子樹的副作用
deletions: Array<Fiber> | null, // 需要刪除的子節點
alternate: Fiber | null, // 雙緩衝中的對應節點
ref: any, // ref 引用
};
這四個維度的組合,讓一個 Fiber 節點同時扮演了四種角色:
| 維度 | 對應欄位 | 回答的問題 |
|---|---|---|
| 元件描述 | tag, key, elementType, type, stateNode | 「我是誰?」 |
| 樹的結構 | return, child, sibling, index | 「我在哪?」 |
| 工作狀態 | pendingProps, memoizedProps, memoizedState, updateQueue, lanes | 「我要做什麼?」 |
| 副作用追蹤 | flags, subtreeFlags, deletions, alternate | 「我做完了要通知誰?」 |

每一個欄位都是執行期某個功能的必要載體。刪掉任何一個欄位,React 的某個功能就會損壞。alternate 刪掉,雙緩衝沒了。lanes 刪掉,並發排程沒了。flags 刪掉,DOM 更新不知道要做什麼。
先看看 tag 欄位。它不是隨便一個數字,而是來自 ReactWorkTags.js 的列舉:
javascript 代碼解讀複製代碼// https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactWorkTags.js
export const FunctionComponent = 0;
export const ClassComponent = 1;
export const HostRoot = 3; // 樹根節點
export const HostPortal = 4; // Portal 子樹入口
export const HostComponent = 5; // DOM 元素(div, span...)
export const HostText = 6; // 文字節點
export const Fragment = 7;
export const Mode = 8; // StrictMode / ConcurrentMode
export const ContextConsumer = 9;
export const ContextProvider = 10;
export const ForwardRef = 11;
export const Profiler = 12;
export const SuspenseComponent = 13;
export const MemoComponent = 14;
export const SimpleMemoComponent = 15;
export const LazyComponent = 16;
export const IncompleteClassComponent = 17;
export const DehydratedFragment = 18;
export const SuspenseListComponent = 19;
export const ScopeComponent = 21;
export const OffscreenComponent = 22;
export const LegacyHiddenComponent = 23;
export const CacheComponent = 24;
export const TracingMarkerComponent = 25;
export const HostHoistable = 26; // 可提升資源(link preload)
export const HostSingleton = 27; // 單例元素(html, head, body)
export const IncompleteFunctionComponent = 28;
export const Throw = 29; // 用於拋出錯誤的特殊元件
export const ViewTransitionComponent = 30;
export const ActivityComponent = 31;
32 種型別。從最常見的 FunctionComponent(0)到專門用於錯誤處理的 Throw(29),從代表 DOM 元素的 HostComponent(5)到伺服器端渲染啟用相關的 DehydratedFragment(18)。每種型別在 beginWork 和 completeWork 中都有自己的處理邏輯。
tag 欄位是 reconciler 的「路由表」。當 beginWork 處理一個 Fiber 節點時,第一個判斷就是 switch (fiber.tag)——不同 tag 走不同的分支。函式元件需要呼叫函式取得子節點,類別元件需要呼叫 render 方法,Host 元件需要建立 DOM 元素,Suspense 元件需要檢查是否已 resolve……路由決策完全基於這個小小的整數。
看看 ReactFiber.js 中的 FiberNode 建構函式:
javascript 代碼解讀複製代碼// https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiber.js
function FiberNode(tag, pendingProps, key, mode) {
// --- Instance(實例標識) ---
this.tag = tag;
this.key = key;
this.elementType = null; // ReactElement.type(未解析的原始值)
this.type = null; // 解析後的值(函式引用/類別建構函式/DOM 標籤字串)
this.stateNode = null; // 關聯的真實平台實例
// --- Fiber(鏈結串列指標) ---
this.return = null; // 父 Fiber
this.child = null; // 第一個子 Fiber
this.sibling = null; // 下一個兄弟 Fiber
this.index = 0; // 在父節點 children 中的位置索引
this.ref = null; // ref 引用
this.refCleanup = null; // ref 清理函式(useImperativeHandle 等)
// --- Input / Output(輸入輸出) ---
this.pendingProps = pendingProps; // 新的 props
this.memoizedProps = null; // 上一次 render 使用的 props
this.updateQueue = null; // 更新佇列(state、callbacks)
this.memoizedState = null; // 上一次 render 使用的 state
this.dependencies = null; // Context 依賴列表
// --- Mode(執行模式) ---
this.mode = mode; // ConcurrentMode / StrictMode / NoMode
// --- Effects(副作用標記) ---
this.flags = NoFlags; // 本節點需要執行的副作用
this.subtreeFlags = NoFlags; // 子樹中需要執行的副作用
this.deletions = null; // 需要刪除的子節點列表
// --- Scheduling(排程) ---
this.lanes = NoLanes; // 本節點需要處理的工作的優先級
this.childLanes = NoLanes; // 子樹需要處理的工作的優先級
// --- Alternate(雙緩衝) ---
this.alternate = null; // 指向另一棵樹的對應節點
// --- Profiler(效能分析,DEV 模式) ---
if (enableProfilerTimer) {
this.actualDuration = 0;
this.actualStartTime = -1;
this.selfBaseDuration = 0;
this.treeBaseDuration = 0;
}
}
這段程式碼樸素得讓人驚訝。沒有繼承鏈,沒有複雜的初始化邏輯,就是一個函式,一堆 this.xxx = yyy。但正是這種樸素,賦予了 Fiber 節點極高的靈活性——它是一個純資料物件,可以被任意複製、修改、丟棄、重建,而不需要擔心隱藏的副作用。
注意 elementType 和 type 的差別——這兩個欄位經常讓人困惑:
| 欄位 | 值(函式元件) | 值(類別元件) | 值(DOM 元素) |
|---|---|---|---|
elementType |
函式引用(App) |
類別建構函式(MyComponent) |
字串('div') |
type |
同上 | 同上 | 同上 |
大多數情況下它們相等。但在 ForwardRef 和 Lazy 元件中,elementType 和 type 不同——elementType 是外層包裝(REACT_FORWARD_REF_TYPE),type 是內層的真實元件。Reconciler 用 elementType 判斷是否需要重新渲染(比較 key 和 type),用 type 執行實際的元件呼叫。
這三個欄位是 Fiber 架構的靈魂。它們把一個階層化的元件樹,轉換成了一個可以遍歷、中斷、恢復的鏈結串列結構。

child:指向第一個子節點。如果一個元件有多個子節點,只能透過 child 找到第一個,然後透過 sibling 鏈遍歷其餘的。
sibling:指向下一個兄弟節點。child → sibling → sibling → null 這條鏈,完整地表示了父節點的所有子節點。
return:指向父節點。為什麼叫 return 不叫 parent?因為這是從遍歷視角命名的——處理完當前節點後,要「返回到」哪個節點繼續處理。在深度優先遍歷中,處理完一個葉節點後,要 return 到它的父節點,然後檢查父節點的 sibling。return 比 parent 更精確地表達了這個語意。
這三個指標的組合,讓 Fiber 樹可以表達任意複雜的巢狀結構——但遍歷它不需要遞迴,只需要一個 while 迴圈和一個當前指標。遍歷過程中的「位置信息」完全保存在物件圖裡,不在呼叫堆疊上。這就是 Fiber 可以中斷和恢復的根本原因。
lanes 和 childLanes 是 Fiber 並發排程的核心欄位。它們都是 31 位元的整數,用位元遮罩表示不同的更新優先級。
lanes:這個 Fiber 節點自身有待處理的工作。比如使用者在這個元件上呼叫了 setState,它的 lanes 欄位會被標記上對應的優先級。childLanes:這個 Fiber 節點的子樹中有待處理的工作。如果子樹深處的某個元件有更新,這個更新資訊會透過 childLanes 向上傳播到所有祖先節點。為什麼需要 childLanes?因為 workLoop 需要快速判斷「一棵子樹裡有沒有工作要做」。如果沒有 childLanes,workLoop 需要遍歷整棵子樹才能確定是否有待處理的工作——這在大型應用中不可接受。有了 childLanes,workLoop 只需要檢查根節點的 childLanes——如果為 0,整棵樹都沒有工作;如果非 0,沿著標記了優先級的分支向下找。

Counter 呼叫了 setState,Counter.lanes 被標記為 DefaultLane。這個標記向上傳播——Counter.return(App)的 childLanes 被標記,App.return(HostRoot)的 childLanes 也被標記。當排程器檢查是否有工作時,它看到 HostRoot.childLanes !== 0,就知道子樹裡有工作要做。但它不需要檢查 Header 分支——因為 App.childLanes 只標記了 Counter 所在的分支,Header 的 childLanes 為 0,可以被安全跳過。
flags 和 subtreeFlags 是 Fiber 副作用系統的核心。它們也是位元遮罩,每一位代表一種副作用類型:
javascript 代碼解讀複製代碼// https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberFlags.js
export const NoFlags = /* */ 0b000000000000000000000000000;
export const Placement = /* */ 0b000000000000000000000000010;
export const Update = /* */ 0b000000000000000000000000100;
export const PlacementAndUpdate = /* */ 0b000000000000000000000000110;
export const Deletion = /* */ 0b000000000000000000000001000;
export const ChildDeletion = /* */ 0b000000000000000000000010000;
export const ContentReset = /* */ 0b000000000000000000000100000;
export const Callback = /* */ 0b000000000000000000001000000;
export const DidCapture = /* */ 0b000000000000000000010000000;
export const ForceClientRender = /* */ 0b000000000000000000100000000;
export const Ref = /* */ 0b000000000000000001000000000;
export const Snapshot = /* */ 0b000000000000000010000000000;
export const Passive = /* */ 0b000000000000000100000000000;
export const Visibility = /* */ 0b000000000000001000000000000;
export const StoreConsistency = /* */ 0b000000000000010000000000000;
export const HostEffectMask = /* */ 0b000000000000011111111111111;
export const Hydrating = /* */ 0b000000000000100000000000000;
export const BeforeMutationMask = /* */ 0b000000000000010000000100100;
export const MutationMask = /* */ 0b000000000000001010000010110;
export const LayoutMask = /* */ 0b000000000000000001000001000;
export const PassiveMask = /* */ 0b000000000000000100000000000;
| Flag | 含義 |
|---|---|
Placement |
這是一個新節點,需要插入到 DOM 中 |
Update |
節點的 props 或 state 發生了變化,需要更新 |
Deletion |
節點需要被刪除 |
ChildDeletion |
子節點中有被刪除的(用於清理 refs) |
Ref |
節點的 ref 需要更新(attach 或 detach) |
Snapshot |
類別元件的 getSnapshotBeforeUpdate 需要呼叫 |
Passive |
useEffect 回呼需要執行 |
Visibility |
Offscreen 元件的可見性發生了變化 |
flags 標記本節點的副作用。當 beginWork 處理一個 Fiber 節點時,如果發現它需要被插入(新建立的節點)或更新(props 變化了),就在 flags 欄位上打上對應的位元。
subtreeFlags 標記子樹中所有副作用的按位或。completeWork 在「完成」一個節點時(也就是它的所有子節點都已處理完畢),會把子節點的 flags 和 subtreeFlags 合併到自己的 subtreeFlags 中。這樣,當遍歷到達根節點時,subtreeFlags 包含了整棵樹的所有副作用類型——commit 階段只需要檢查根節點的 subtreeFlags,就能決定需要執行哪些階段的副作用處理。
deletions 欄位是一個特殊的陣列。當一個子節點需要被刪除時,它不是在自己的 flags 上標記 Deletion——而是被放入父節點的 deletions 陣列中。為什麼?因為被刪除的節點不會再參與後續的遍歷,如果副作用標記在它自己身上,commit 階段可能找不到它。放入父節點的 deletions 陣列,確保了 commit 階段能可靠地執行清理操作。
alternate 是 Fiber 雙緩衝機制的核心欄位。每個 Fiber 節點都有一個 alternate,指向另一棵樹(current 或 workInProgress)中的對應節點。

當 React 開始一次新的渲染時,它會從 current 樹的根節點出發,透過 createWorkInProgress 建立 workInProgress 樹。這個過程中,每建立一個新的 workInProgress Fiber,就透過 alternate 欄位和 current Fiber 雙向連結:
javascript 代碼解讀複製代碼// https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiber.js
workInProgress.alternate = current;
current.alternate = workInProgress;
alternate 連結帶來的好處是巨大的:
workInProgress.alternate.memoizedProps 取得上一次 render 的 props,決定是否需要重新渲染。createWorkInProgress 優先重用 alternate 節點,減少物件建立和垃圾回收壓力。current 變成 workInProgress,workInProgress 變成舊的 current。原子操作,沒有中間狀態。我一直有一個問題:createRoot(container) 建立的「根」到底是個什麼東西?為什麼有兩個「根」的概念?
React 的「根」實際上是一個雙層結構:
FiberRootNode(根容器):由 createFiberRoot 建立。它不是 Fiber,是一個獨立的物件型別。它管理整個 React 應用的全域狀態——containerInfo(容器 DOM 節點)、pendingLanes(所有待處理的優先級)、callbackNode(排程回呼)、onUncaughtError / onCaughtError(錯誤處理回呼)。一個 React 應用通常只有一個 FiberRootNode。
RootFiber(根 Fiber):是一個 tag = HostRoot 的 Fiber 節點。它是 Fiber 樹的入口點——所有元件都是它的後代。它有 alternate,參與雙緩衝。RootFiber 的 stateNode 指向 FiberRootNode,FiberRootNode 的 current 指向 current 樹的 RootFiber。

為什麼要兩層?因為 FiberRootNode 不參與 Fiber 樹的遍歷和雙緩衝。它的欄位(如 pendingLanes、callbackNode)是全域性的,不需要「兩個版本」。如果把所有欄位都放在 RootFiber 上,雙緩衝時就需要複製這些全域狀態,既浪費記憶體又容易出錯。分層設計讓「全域狀態」和「樹節點」各自歸位。
javascript 代碼解讀複製代碼// https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactInternalTypes.js
export type FiberRoot = {
// 容器資訊
tag: RootTag, // LegacyRoot / ConcurrentRoot
containerInfo: Container, // DOM 容器節點
// 樹指標
current: Fiber, // 指向 current 樹的 RootFiber
// 排程狀態
callbackNode: mixed, // Scheduler 的回呼把手
callbackPriority: Lane, // 當前回呼的優先級
pendingLanes: Lanes, // 所有待處理的 lane
expiredLanes: Lanes, // 已過期的 lane(必須同步執行)
// 事件處理
onUncaughtError: (error: mixed, errorInfo: {...}) => void,
onCaughtError: (error: mixed, errorInfo: {...}) => void,
onRecoverableError: (error: mixed, errorInfo: {...}) => void,
// ... 更多欄位
};
位元遮罩是效能優化的利器
Fiber 的 lanes、flags、subtreeFlags 都是位元遮罩。位元運算在 JavaScript 中雖然有 32 位元限制,但對於表達「多選狀態」(一個節點可以有多個副作用、多個優先級)來說,位元遮罩比陣列或物件更有效率:
|)新增標記:flags |= Placement&)檢查標記:if (flags & Placement) { ... }~)和與(&)清除標記:flags &= ~Placement&)提取最低位的 1:lanes & -lanes 取得最高優先級這些操作都是 O(1) 的,在現代 JavaScript 引擎中可以被高度最佳化。
Fiber 資料結構的四重身分分離——元件描述、樹結構、工作狀態、副作用追蹤——讓不同的團隊成員可以專注於不同的領域:
tag、type、memoizedStatelanes、childLanesflags、subtreeFlags、stateNodealternate、ref這些欄位共同定義在 Fiber 類型中,構成了團隊的共享契約。每個人都在同一個資料結構上工作,但關注點不同。
| React Fiber 的設計 | 遷移策略 |
|---|---|
| 四重身分分離(元件/樹/工作/副作用) | 核心資料實體按職責維度拆分欄位組,每個團隊負責一組 |
| 位元遮罩表達多選狀態 | 狀態和標記用位元運算管理,減少陣列遍歷和物件巢狀 |
childLanes 向上傳播 |
樹狀結構中需要「快速判斷子樹是否有工作」時,用聚合欄位避免全量遍歷 |
alternate 雙緩衝 |
任何需要「準備新版本再原子切換」的場景,維護兩個版本的並行結構 |
FiberRootNode 與 RootFiber 分層 |
全域狀態與樹節點狀態分離,避免雙緩衝時複製全域資料 |
回到那個 FiberNode 建構函式。
javascript 代碼解讀複製代碼function FiberNode(tag, pendingProps, key, mode) {
this.tag = tag;
this.key = key;
this.elementType = null;
this.type = null;
this.stateNode = null;
this.return = null;
this.child = null;
this.sibling = null;
this.index = 0;
this.ref = null;
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.memoizedState = null;
this.updateQueue = null;
this.dependencies = null;
this.mode = mode;
this.flags = NoFlags;
this.subtreeFlags = NoFlags;
this.lanes = NoLanes;
this.childLanes = NoLanes;
this.alternate = null;
}
二十多個欄位。沒有魔法,沒有黑科技。但它們的組合方式,支撐了現代前端框架中最複雜的執行期系統之一。
tag 決定了「我是誰」。child / sibling / return 決定了「我在哪裡,和誰連結」。pendingProps / memoizedState / updateQueue 決定了「我要做什麼」。lanes / childLanes 決定了「什麼時候做」。flags / subtreeFlags / deletions 決定了「做完之後要通知誰」。alternate 決定了「如果被打斷了,怎麼恢復到剛才的狀態」。
這些欄位的組合,讓 React 能夠:在 16 毫秒的幀間隙裡穿插多個不同優先級的更新;在渲染過程中回應使用者輸入而不丟失位置信息;在 commit 之前安全地丟棄不完整的工作;在 DOM 操作完成後再呼叫生命週期方法。
一個好的資料結構,就像一個好的建築結構——它不顯眼,但它決定了整個系統的可能性和上限。Fiber 資料結構是 React 的骨架。Hooks、Concurrent Mode、Suspense、Server Components——這些華麗的上層建築,都站在這二十多個欄位的肩膀之上。