React 原理全景图
scss
┌─────────────────────────────────────────────────────────┐
│ React 运行时架构 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
│ │ 1.Fiber │ │ 2.Hooks │ │ 3.渲染 │ │ 4.Diff │ │
│ │ 架构 │→│ 原理 │→│ 流程 │→│ 算法 │ │
│ │ (5考点) │ │ (9考点) │ │ (7考点) │ │ (4考点) │ │
│ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │
│ ↑ ↑ ↑ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
│ │ 5.调度 │ │ 6.性能 │ │ 7.事件 │ │ 8.高级 │ │
│ │ 与并发 │ │ 优化 │ │ 系统 │ │ 特性 │ │
│ │ (6考点) │ │ (5考点) │ │ (3考点) │ │ (7考点) │ │
│ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │
└─────────────────────────────────────────────────────────┘
学习路径:Fiber 架构(基础骨架) → Hooks 原理(状态管理) → 渲染流程(核心管线) → Diff 算法(复用策略) → 调度与并发(优先级控制) → 性能优化 → 事件系统 → 高级特性
考点 1.1:Fiber 是什么,为何替换 Stack Reconciler
第 0 段:直觉锚定
想象你在手抄一本书。旧方案(Stack Reconciler) 的规则是:拿起笔就必须一口气抄完整本书才能放下。中途哪怕你的老板(浏览器)喊你"先去处理用户输入!",你也听不到------你埋头苦抄,老板只能干等。如果书太厚,超过了一个时间片(约 16ms),浏览器就错过了一帧的画面更新,用户看到的就是卡顿。
Fiber 方案 的规则变了:每抄完一页,你就抬头看一眼------"老板有没有更紧急的事?"。如果有,放下笔去处理,处理完再回来接着抄。这里的关键是:你必须记住自己抄到哪了 。这个"抄到哪了"的信息,就是 Fiber 节点;"抬头检查"的动作,就是 shouldYield();"放下笔再拿起来"的能力,就是链表树(return/child/sibling)赋予的可中断遍历。
第 1 段:问题背景
Stack Reconciler 的致命缺陷
React 15 及之前使用的是 Stack Reconciler (栈调和器)。它的核心逻辑是一个递归函数:
scss
// Stack Reconciler 的简化模型
function reconcile(parentVNode, children) {
for (child of children) {
// 对每个子节点递归处理
reconcile(child, child.children); // ← 递归调用,无法中断
// 处理 DOM 操作
}
}
这个递归有几个致命问题:
- 不可中断 :一旦进入递归,JavaScript 引擎无法暂停它。调用栈被层层压入,直到整棵树遍历完才弹出。这意味着如果树很大(比如 1000 个组件),这 1000 个组件必须一口气处理完。
- 调用栈不可操控:浏览器的调用栈(Call Stack)是引擎内部管理的,JavaScript 代码无法"保存当前调用栈状态、稍后恢复"。递归到第 500 层时,你没有办法说"先暂停,把当前的 500 层调用栈存起来,等下恢复"。
- 帧超时 = 掉帧:浏览器每 16.6ms 需要一帧来完成样式计算、布局、绘制。如果 JS 执行超过这个时间,浏览器只能跳过这一帧的绘制。用户看到的动画就会掉帧、卡顿。
Fiber 要达到的目标
Fiber 项目(React 16+)的设计目标是:
- 可中断:渲染工作可以被切分成小单元,在任意单元之间暂停和恢复
- 可优先级调度:用户输入等高优先级任务可以打断低优先级的渲染
- 可复用/丢弃:中断后恢复时,可以选择复用之前的工作或从头开始
⚠️ 常见先入为主的误解: 很多人以为 Fiber 是"多线程渲染"或"Web Worker 渲染"。实际上 Fiber 仍然是单线程 的,所有工作还是在主线程上执行。它做的事情是把一大块同步工作切成小块,在块与块之间让出主线程,让浏览器有机会处理用户输入和绘制。
第 2 段:核心数据结构
FiberNode------一个可控的"虚拟调用栈帧"
Fiber 的核心思路是:用自己的数据结构模拟调用栈。每个 Fiber 节点就相当于调用栈中的一个栈帧(Stack Frame),但它是由 React 自己管理的,所以可以随时暂停和恢复。
以下是 FiberNode 的核心字段(位于 packages/react-reconciler/src/ReactFiber.js 第 138 行起):
php
FiberNode {
┌───── 树结构指针(替代递归调用栈的寻路能力) ─────┐
│ return: Fiber | null // 父节点(类比:调用栈的 return address) │
│ child: Fiber | null // 第一个子节点 │
│ sibling: Fiber | null // 下一个兄弟节点 │
│ index: number // 在兄弟中的位置 │
└──────────────────────────────────────────────────────────────────────────┘
┌───── 节点身份 ─────┐
│ tag: WorkTag // 节点类型标签(FunctionComponent=0, HostComponent=5, ...)
│ type: any // 具体的组件函数/标签名(如 "div"、App 函数)
│ key: string | null // 列表中的唯一标识,给 Diff 用
└─────────────────────┘
┌───── 实例引用 ─────┐
│ stateNode: any // 对应的真实 DOM / 类组件实例 / FiberRootNode
└─────────────────────┘
┌───── Props & State ─────┐
│ pendingProps: any // 本次待处理的 props
│ memoizedProps: any // 上次处理完的 props
│ updateQueue: Update // 待处理的更新队列
│ memoizedState: any // 上次处理完的 state(Hooks 链表头)
└──────────────────────────┘
┌───── 副作用标记 ─────┐
│ flags: Flags // 本节点的副作用(Placement/Update/Deletion)
│ subtreeFlags: Flags // 子树的副作用汇总(优化:跳过无副作用子树)
│ deletions: Fiber[] // 待删除的子节点
└────────────────────────┘
┌───── 优先级 ─────┐
│ lanes: Lanes // 本节点的优先级位掩码
│ childLanes: Lanes // 子树中的最高优先级
└────────────────────┘
┌───── 双缓存 ─────┐
│ alternate: Fiber | null // 指向另一棵树的对应节点
└────────────────────┘
}
链表树的三节点实例
kotlin
App (return=null)
│
├── child → Header (return=App)
│ │
│ └── child → h1 (return=Header)
│ │
│ └── sibling → nav (return=Header)
│
└── sibling → Main (return=App)
│
└── child → List (return=Main)
关键点:return / child / sibling 三个指针替代了递归调用栈。遍历这棵树时,不再需要 JS 引擎的调用栈来记住"我在哪、回溯到哪",而是通过这三个指针直接寻路。
第 3 段:运行流程
Stack Reconciler 的执行时序(旧方案)
scss
用户点击 → setState() → mountOrUpdate() → 递归 reconcile 整棵树 → commit DOM
│ │
│← ─ ─ ─ ─ ─ 不可中断,占用主线程 ─ ─ ─ ─ ─ ─ ─ ─ →│
│ 如果超过 16ms,浏览器无法绘制这一帧 │
Fiber 的执行时序(新方案)
scss
用户点击 → setState() → scheduleUpdateOnFiber()
│
▼
┌─── workLoop ───┐
│ │
│ performUnitOfWork(fiber) ← 处理一个节点
│ │
│ ▼
│ shouldYield()? ──── Yes ──→ 让出主线程
│ │ │
│ No │
│ │ │
│ 下一个 fiber │
│ │ │
│ ▼ │
│ 还有节点吗? ── No → commit DOM │
│ │ │
│ Yes │
│ │ │
└───────────┘ │
│
浏览器绘制/处理输入 ◄─────┘
│
下一个宏任务恢复 ─────────┘
源码追踪
1. 定位: react@18.3.1 · packages/react-reconciler/src/ReactFiberWorkLoop.js · workLoopConcurrent
2. 签名解读:
csharp
function workLoopConcurrent() {
// 无参数,从全局变量获取当前工作进度
// 执行条件:有工作 且 没有被要求让出
}
3. 阅读入口: 打开 ReactFiberWorkLoop.js,搜索 workLoopConcurrent,它很短(约 10 行)。
4. 关键逻辑(伪代码):
scss
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
// performUnitOfWork 处理一个 Fiber 节点,返回下一个要处理的节点
workInProgress = performUnitOfWork(workInProgress);
}
// 要么 workInProgress === null(整棵树处理完了)
// 要么 shouldYield() === true(时间到了,需要让出主线程)
}
对比 Stack Reconciler 的同步版本:
scss
function workLoopSync() {
while (workInProgress !== null) {
// 注意:没有 shouldYield() 检查
workInProgress = performUnitOfWork(workInProgress);
}
}
唯一的区别就是 !shouldYield() ------这就是 Fiber 可中断能力的全部秘密。
第 4 段:设计动机与权衡
核心约束:浏览器单线程模型
JavaScript 和浏览器渲染共享同一个主线程。一帧(16.6ms)的时间必须分配给:
yaml
|← JS 执行 →|← 样式计算 →|← 布局 →|← 绘制 →|← 空闲 →|
| | | | | |
0ms 5ms 7ms 10ms 12ms 16.6ms
如果 JS 执行占了 15ms,留给浏览器只有 1.6ms 做样式计算+布局+绘制,必然掉帧。
为什么选链表树而不是其他方案
| 方案 | 可中断 | 可恢复 | 内存开销 | 实现复杂度 |
|---|---|---|---|---|
| 递归(旧方案) | ❌ | ❌ | 低(调用栈) | 低 |
| 数组存储所有节点 | ✅ | ✅ | 中 | 需要额外的索引管理 |
| 链表树(Fiber 选择的) | ✅ | ✅ | 中(每节点一个对象) | 中 |
Fiber 选择链表树的原因:
- 递归的语义完美匹配树结构:DFS 遍历一棵组件树,天然就是"先处理子节点,再回溯父节点"
- O(1) 级别的暂停/恢复 :只需保存一个
workInProgress指针("当前处理到哪个节点"),恢复时直接从它继续 - 不需要序列化/反序列化:链表节点是内存中的对象引用,暂停时不需要把状态写到磁盘
Fiber 牺牲了什么
- 内存开销:每个组件对应一个 FiberNode 对象(约 20+ 个字段),两棵树就是两倍内存
- 概念复杂度:双缓存、优先级、可中断渲染等概念显著增加了理解和调试难度
- 不是真正的并行:仍然是单线程,只是"合作式调度"(cooperative scheduling),不利用多核
第 5 段:次级误解和边界
误解 1:"Fiber 让渲染变快了"
不准确。 Fiber 让渲染变得更流畅(不卡顿),而不是更快。如果一棵树需要处理 100ms 的计算量,Stack Reconciler 和 Fiber 都需要 100ms。区别是:
- Stack:100ms 全部阻塞主线程,中间错过约 6 帧
- Fiber:切分成小块,每块约 5ms,之间让浏览器绘制,总共仍然约 100ms,但中间不丢帧
误解 2:"Fiber 就是虚拟 DOM"
不是。 虚拟 DOM(React Element)是 JSX 编译后的轻量描述对象({type, props, key})。FiberNode 是 React 内部的调度单元。它们的关系是:
markdown
JSX → React Element(虚拟 DOM)→ FiberNode(调度单元)→ 真实 DOM
一次性的描述 持久化的、有状态的工作节点
边界:什么情况下 Fiber 也会卡
如果单个 performUnitOfWork(处理一个 Fiber 节点)本身就很慢(比如某个组件的 render 函数跑了一个巨大的循环),shouldYield() 只能在节点之间检查,无法在节点内部中断。所以如果你的某个组件一次渲染需要 50ms,Fiber 也救不了------这个 50ms 是不可中断的。
现在我们知道了 Fiber 通过链表树和 shouldYield() 机制解决了 Stack Reconciler 不可中断的问题,每个 Fiber 节点就是一个可暂停的工作单元。
但 FiberNode 上的 20 多个字段各自承担什么职责?return/child/sibling 只是树结构,那 flags、lanes、memoizedState 这些字段在渲染流程中扮演什么角色?这就是下一个考点「Fiber 节点的完整数据结构」要处理的事情。
考点 1.2:Fiber 节点的完整数据结构
第 0 段:直觉锚定
把 FiberNode 想象成一个超级详细的快递面单。一个普通的面单只有"收件人、地址、电话"。但 FiberNode 是一个"全信息面单",上面不仅写了"我是谁"(tag/type),还写了"我从哪来"(return)、"我的孩子在哪"(child)、"我兄弟是谁"(sibling)、"包裹里有什么变化"(flags)、"这个包裹有多急"(lanes)、以及"上次发货时的备份面单在哪"(alternate)。
每个字段都回答一个具体问题,我们按"这个字段在渲染流程的哪个阶段被读/写"来分组理解。
第 1 段:问题背景
考点 1.1 我们知道了 Fiber 是为了可中断渲染而设计的。但要实现可中断,Fiber 节点必须承载远超虚拟 DOM 的信息量。虚拟 DOM(React Element)只需要描述"要渲染什么",而 FiberNode 还需要记录:
- 上次渲染的状态是什么(恢复时需要)
- 这次渲染有哪些变化(commit 阶段需要)
- 优先级有多高(调度时需要)
- 和另一棵树的关系是什么(双缓存需要)
这就是 FiberNode 有 20+ 个字段的原因。
第 2 段:核心数据结构
源码位置:packages/react-reconciler/src/ReactFiber.js 第 138-197 行。
我按职责分组讲解,每组标注它在渲染流程的哪个阶段被使用。
第一组:身份标识(beginWork 入口读取)
typescript
┌──────────────────────────────────────────────────┐
│ tag: WorkTag // 节点类型,决定 beginWork 走哪个分支 │
│ type: any // 具体的组件函数 / HTML 标签名 │
│ key: string | null // 列表中的唯一标识,给 Diff 用 │
│ elementType: any // 通常等于 type,某些情况不同 │
└──────────────────────────────────────────────────┘
tag 是最关键的字段。beginWork 函数内部就是一个巨大的 switch,根据 tag 值分发到不同的处理函数:
xml
tag 值 常量名 含义 beginWork 走向
─────────────────────────────────────────────────────────────────
0 FunctionComponent 函数组件 → updateFunctionComponent
1 ClassComponent 类组件 → updateClassComponent
3 HostRoot 根节点 (FiberRoot) → updateHostRoot
5 HostComponent 原生标签 (div/span) → updateHostComponent
6 HostText 纯文本 → updateHostText
7 Fragment <>...</> → updateFragment
9 ContextConsumer context 消费者 → updateContextConsumer
10 ContextProvider context 提供者 → updateContextProvider
11 ForwardRef forwardRef 包裹的组件 → updateForwardRef
13 SuspenseComponent <Suspense> → updateSuspenseComponent
14 MemoComponent React.memo 包裹的组件 → updateMemoComponent
你可以打开
ReactWorkTags.js(第 44-73 行)看完整的 tag 列表。
type 配合 tag 使用:
- tag=0 (FunctionComponent) → type 是你的组件函数本身(如
function App() {}) - tag=5 (HostComponent) → type 是字符串(如
"div"、"span") - tag=6 (HostText) → type 没有实际用途(文本节点没有标签名)
第二组:树结构指针(遍历时使用)
php
┌─────────────────────────────────────────────────────────────┐
│ return: Fiber | null // 父节点 │
│ child: Fiber | null // 第一个子节点(注意:只有一个入口) │
│ sibling: Fiber | null // 下一个兄弟节点 │
│ index: number // 在兄弟中的位置序号 │
└─────────────────────────────────────────────────────────────┘
这三个指针构成单向链表树。一个三节点的实例:
css
<div> Fiber(div)
<h1> │
<p> child → Fiber(h1)
</div> │
sibling → Fiber(p)
注意 child 只指向第一个子节点,其余子节点通过 sibling 链串联。return 指向父节点------名字叫 return 是因为在函数调用的类比中,"回到调用者"就是 return。
为什么不用 children 数组? 因为数组是连续内存,插入/删除需要移动元素。链表插入/删除是 O(1),这在 Diff 阶段频繁操作子节点时更高效。
第三组:实例引用(commit 阶段使用)
arduino
┌──────────────────────────────────────────────────┐
│ stateNode: any // 对应的"真实实例" │
└──────────────────────────────────────────────────┘
这个字段指向什么,完全取决于 tag:
scss
tag stateNode 指向
──────────────────────────────────────────────
FunctionComponent (0) null(函数组件无实例)
ClassComponent (1) new Component() 的实例对象
HostComponent (5) 真实 DOM 节点(如 HTMLDivElement)
HostText (6) 真实文本节点(如 Text)
HostRoot (3) FiberRootNode(根容器对象)
函数组件的 stateNode 是 null,这也是为什么函数组件没有 this------它根本没有实例对象。函数组件的"状态"存在 memoizedState 字段里的 Hooks 链表中。
第四组:Props & State(render 阶段读写)
arduino
┌──────────────────────────────────────────────────────────────┐
│ pendingProps: any // 本次待处理的新 props(从父组件传来)│
│ memoizedProps: any // 上次渲染完成后的 props │
│ updateQueue: Update // 待处理的更新队列 │
│ memoizedState: any // 上次渲染完成后的 state │
│ dependencies: Dependencies // 该节点订阅的 context 等依赖 │
└──────────────────────────────────────────────────────────────┘
这几个字段的协作流程:
ini
beginWork 入口
│
├── 读 pendingProps(新的 props)
├── 读 memoizedProps(旧的 props)→ 如果浅相等,可以跳过渲染(bail out)
├── 读 memoizedState(旧的 state / Hooks 链表头)
├── 读 updateQueue → 取出待处理的 setState
│
▼ 执行组件函数,计算新的 props/state
│
├── 写 memoizedProps = pendingProps(更新为新的 props)
├── 写 memoizedState = 新的 state
├── 写 updateQueue = null 或保留未处理的更新
memoized 的含义: "memoized" 在 React 源码中统一表示"上次计算的结果被缓存下来了"。memoizedProps = "上次缓存下来的 props",memoizedState = "上次缓存下来的 state"。
第五组:副作用标记(completeWork 收集,commit 阶段消费)
php
┌──────────────────────────────────────────────────────────────┐
│ flags: Flags // 本节点需要执行什么 DOM 操作 │
│ subtreeFlags: Flags // 子树中所有 flags 的并集 │
│ deletions: Fiber[] | null // 需要被删除的子节点列表 │
└──────────────────────────────────────────────────────────────┘
flags 使用二进制位掩码,一个节点可以同时有多个标记:
ini
NoFlags = 0b0000000000000000000000000000000 // 无操作
Placement = 0b0000000000000000000000000000010 // 需要插入 DOM
Update = 0b0000000000000000000000000000100 // 需要更新属性
Deletion = ChildDeletion = 0b0000000000000000000000000010000 // 需要删除
Passive = 0b0000000000000000000100000000000 // 有 useEffect 需要执行
Ref = 0b0000000000000000000001000000000 // ref 需要更新
位掩码的好处:可以用位运算快速判断和组合------flags & Placement 判断是否需要插入,flags |= Update 添加更新标记。
subtreeFlags 是一个性能优化:在 completeWork 阶段,子节点的 flags 会冒泡到父节点的 subtreeFlags。这样 commit 阶段遍历时,如果某个父节点的 subtreeFlags 为 NoFlags,就可以直接跳过整棵子树,不用逐个检查。
ini
div (subtreeFlags = Placement | Update)
│
├── h1 (flags = Update, subtreeFlags = 0)
│ │
│ └── span (flags = 0)
│
└── p (flags = Placement, subtreeFlags = Placement)
第六组:优先级(调度阶段使用)
arduino
┌──────────────────────────────────────────────────────────────┐
│ lanes: Lanes // 本节点涉及的优先级 │
│ childLanes: Lanes // 子树中的最高优先级 │
└──────────────────────────────────────────────────────────────┘
lanes 的细节在考点 5.1 详细讲。现在只需要知道:lanes 用 31 位二进制表示优先级,不同位代表不同类型的更新(用户输入 > 普通更新 > 过渡动画 > 空闲任务)。调度器通过 lanes 决定哪些更新先处理、哪些延后。
第七组:双缓存(跨渲染周期使用)
php
┌──────────────────────────────────────────────────────────────┐
│ alternate: Fiber | null // 指向另一棵树的对应节点 │
└──────────────────────────────────────────────────────────────┘
alternate 是双缓存树的纽带。考点 1.3 会展开讲这个字段的具体用法。现在只需要记住:每个 Fiber 节点都有一个"分身",两棵树通过 alternate 互相指向对方。
css
current 树: Fiber(A) ←──alternate──→ Fiber(A') :workInProgress 树
↑ ↑
屏幕上正在显示的 正在构建的
第 3 段:运行流程
一个 FiberNode 的字段在一次完整渲染周期中被读写的时序:
typescript
┌─ 调度阶段 ─────────────────────────────────────────────────────┐
│ 读 lanes → 判断优先级是否足够高 │
│ 读 childLanes → 子树中是否有高优先级更新 │
└────────────────────────────────────────────────────────────────┘
│
▼
┌─ Render 阶段 (beginWork) ─────────────────────────────────────┐
│ 读 tag → switch 分发到对应处理函数 │
│ 读 type → 获取组件函数 / 标签名 │
│ 读 pendingProps → 传入组件函数 │
│ 读 memoizedProps → 和 pendingProps 浅比较,可能跳过渲染 │
│ 读 memoizedState → Hooks 链表头 │
│ 读 updateQueue → 取出 setState 的更新 │
│ 读 return / child / sibling → 遍历树结构 │
│ 写 flags → 标记副作用 │
│ 写 memoizedState → 更新 Hooks 链表 │
│ 写 memoizedProps → 缓存新 props │
└────────────────────────────────────────────────────────────────┘
│
▼
┌─ Render 阶段 (completeWork) ──────────────────────────────────┐
│ 读 tag → 决定如何处理(创建 DOM / 收集属性) │
│ 读 type → 创建对应类型的 DOM 节点 │
│ 读 flags → 收集子节点 flags │
│ 写 stateNode → 创建 DOM 节点后赋值 │
│ 写 subtreeFlags ← 子节点 flags 冒泡 │
│ 写 deletions → 收集需要删除的子节点 │
└────────────────────────────────────────────────────────────────┘
│
▼
┌─ Commit 阶段 ─────────────────────────────────────────────────┐
│ 读 flags → 执行对应的 DOM 操作 │
│ 读 subtreeFlags → 决定是否跳过子树 │
│ 读 stateNode → 获取真实 DOM 进行操作 │
│ 读 deletions → 执行删除 │
│ 读 ref → 更新 ref 指向 │
└────────────────────────────────────────────────────────────────┘
第 4 段:设计动机与权衡
为什么把所有信息放在一个对象里,而不是拆分成多个对象?
备选方案: 比如把"树结构"放在一个 TreeNode 里,把"渲染状态"放在一个 RenderState 里,把"副作用"放在一个 EffectInfo 里。
React 选择单对象的原因:
- 内存局部性:一个对象的所有字段在堆内存中紧挨着,CPU 缓存命中率高。拆成多个对象后,每次渲染阶段切换都要跳到不同的内存位置
- GC 压力:一个对象比三个对象的 GC 扫描成本更低
- 代码简洁 :
fiber.flags |= Placement比三种不同对象之间传递信息方便
牺牲的是:单个对象字段太多(20+),初次阅读源码时会觉得"信息过载"。所以上面按职责分组理解是正确的方法。
为什么 flags 用位掩码而不用字符串/枚举?
less
// 字符串方案(假设):
if (fiber.flag === 'placement' || fiber.flag === 'update') { ... }
// 位掩码方案(实际):
if (fiber.flags & (Placement | Update)) { ... }
位掩码的优势:
- 可以组合:一个节点可以同时是 Placement + Update,字符串做不到
- 位运算极快:CPU 单条指令完成,不需要字符串比较
- 冒泡高效 :
parent.subtreeFlags |= child.flags,一行代码合并所有子节点的标记
第 5 段:次级误解和边界
误解 1:"memoizedState 只存 state"
memoizedState 的实际含义是"该节点上次缓存的渲染结果中的状态部分"。对于不同类型的 Fiber 节点,它存的东西不一样:
kotlin
函数组件 → Hooks 链表头(第一个 hook 节点)
类组件 → this.state(组件实例的 state)
HostRoot → 待处理的更新队列(最顶层的 state)
误解 2:"每个 React Element 都有一个对应的 Fiber"
不完全正确。 React Element 是 JSX 的编译产物,它是一次性的。比如:
javascript
function App() {
return <div><h1>Hello</h1></div>;
}
每次调用 App() 都会产生全新的 Element 对象。但 Fiber 节点是持久的 ------同一个 <h1>Hello</h1> 位置的 Fiber 在更新时会被复用(通过 Diff 算法判断),不会每次都重新创建。Element 和 Fiber 的关系是 多对一:多次渲染产生的多个 Element 可能对应同一个 Fiber 节点。
边界:函数组件为什么没有 stateNode?
函数组件的设计哲学是"无实例"。它的状态全部存在 memoizedState 的 Hooks 链表里,不需要一个独立的实例对象。这也意味着:函数组件的 Fiber 上,你不能像类组件那样通过 fiber.stateNode 拿到组件实例。如果你需要在 DevTools 里查看函数组件的 state,实际读取的是 fiber.memoizedState 然后遍历 Hooks 链表。
现在我们知道了 FiberNode 的 20+ 个字段按职责分为 7 组,每组在渲染流程的不同阶段被读写。
但其中 alternate 字段指向"另一棵树的对应节点"------这个"另一棵树"是什么?为什么要同时维护两棵树?它们怎么交替工作?这就是考点 1.3「双缓冲树(Double Buffering)」要回答的问题。
考点 1.3:双缓冲树(Double Buffering)
第 0 段:直觉锚定
想象你是一个画家,面前有一块画布正在画廊展出(观众正在看)。现在你要画一幅新版本。
方案 A(没有双缓冲): 直接在展出的画布上改。画到一半,观众看到的是半成品------旧的背景已经擦掉了,新的还没画完。这就是"闪烁"。
方案 B(双缓冲): 你拿一块空白画布在后台画新版本。画好了,一瞬间把旧画布换成新的。观众永远只看到完整的画面。旧画布收起来,下次更新时再用它当"后台画布"。
React 的双缓冲就是这个方案 B:
- current 树 = 正在展出的画布(屏幕上正在显示的)
- workInProgress 树 = 后台画布(正在画的)
- commit 阶段的
root.current = finishedWork= 换画布的那一瞬间
第 1 段:问题背景
在考点 1.1 我们知道了 render 阶段是可中断的。这就带来了一个问题:如果渲染被中断,用户不应该看到半成品 UI。
如果 React 只有一棵树,在同一个树上边改边渲染,一旦渲染到一半被用户看到,就会出现界面闪烁、不完整的状态。双缓冲就是为了解决这个问题:永远在"后台"准备好完整的下一帧,准备好之后一次性切换。
⚠️ 常见先入为主的误解: 很多人以为 current 树和 workInProgress 树是两棵完全独立的树,每次渲染都从零构建。实际上它们是共享 Fiber 节点的"配对树" ------大量节点在更新时会被复用,只有真正变化的部分才会重新创建。
第 2 段:核心数据结构
FiberRootNode --- 两棵树的"总指挥"
源码位置:packages/react-reconciler/src/ReactFiberRoot.js 第 50 行起。
typescript
FiberRootNode {
// 最核心的字段:
current: Fiber // ← 指向 current 树的根 Fiber
// 切换这个指针 = 切换两棵树
containerInfo: any // DOM 容器(如 <div id="root">)
// 调度相关
pendingLanes: Lanes // 等待处理的优先级
callbackNode: any // 调度器返回的任务句柄
// 错误处理
onUncaughtError: Function
onCaughtError: Function
...
}
FiberRootNode 不是 Fiber 节点 ,它是一个独立的对象,作为整个 Fiber 树的容器。它的 current 字段指向 current 树的根 Fiber 节点。
三个层级的关系
scss
FiberRootNode(容器对象)
│
│ current(指针)
▼
Fiber(HostRoot, tag=3) ← current 树的根
│ │
│ alternate(互相指向) │
▼ ▼
Fiber(HostRoot, tag=3) ← workInProgress 树的根
│
│ child / sibling(链表树)
▼
Fiber(App) → Fiber(Header) → Fiber(Main) ...
关键:alternate 是配对关系的纽带。 每个 Fiber 节点通过 alternate 指向它在另一棵树中的"分身"。
less
current 树: App₀ ←──alternate──→ App₁ :workInProgress 树
│ │
child child
│ │
▼ ▼
current 树: Header₀ ←─alternate──→ Header₁ :workInProgress 树
│ │
child child
▼ ▼
current 树: h1₀ ←──alternate──→ h1₁ :workInProgress 树
下标 0 = current 树的节点,下标 1 = workInProgress 树的节点。App₀.alternate === App₁,App₁.alternate === App₀。
第 3 段:运行流程
createWorkInProgress --- 创建/复用 wip 节点
源码位置:packages/react-reconciler/src/ReactFiber.js 第 327 行。
ini
function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
let workInProgress = current.alternate;
if (workInProgress === null) {
// 首次:alternate 还不存在,创建一个新的 Fiber
workInProgress = createFiber(current.tag, pendingProps, current.key, current.mode);
workInProgress.elementType = current.elementType;
workInProgress.type = current.type;
workInProgress.stateNode = current.stateNode; // 共享同一个 DOM 节点
// 互相指向
workInProgress.alternate = current;
current.alternate = workInProgress;
} else {
// 非首次:alternate 已存在,复用它,只更新必要字段
workInProgress.pendingProps = pendingProps;
workInProgress.type = current.type;
// 重置副作用标记(上次的 flags 已经处理过了)
workInProgress.flags = NoFlags;
workInProgress.subtreeFlags = NoFlags;
workInProgress.deletions = null;
}
// 复制 current 的状态到 wip
workInProgress.child = current.child;
workInProgress.memoizedProps = current.memoizedProps;
workInProgress.memoizedState = current.memoizedState;
workInProgress.updateQueue = current.updateQueue;
workInProgress.lanes = current.lanes;
workInProgress.childLanes = current.childLanes;
return workInProgress;
}
注意这里的设计:
- 首次创建:创建新 Fiber 节点,设置 alternate 双向指向
- 后续复用:直接拿已有的 alternate,重置 flags,复制 current 的最新状态
- stateNode 共享 :
workInProgress.stateNode = current.stateNode------两棵树的同一个位置共享同一个真实 DOM 节点,不会创建两个 div
完整的双缓冲周期
markdown
时间线:
────────────────────────────────────────────────────────────────→
1. 初始状态
FiberRoot.current → current 树(屏幕上显示的)
2. setState 触发更新
scheduleUpdateOnFiber()
3. render 阶段开始
createWorkInProgress(current, pendingProps)
→ 拿到 current 树的 alternate(或创建新的)
→ 这就是 workInProgress 树的根
→ workInProgress 作为全局变量开始 DFS 遍历
4. render 阶段进行中(可中断)
每个 current 节点通过 createWorkInProgress 拿到对应的 wip 节点
beginWork 在 wip 上打 flags
completeWork 在 wip 上创建 DOM(但不插入)
此时:
- current 树完整无损 ← 屏幕上仍然显示旧的
- workInProgress 树正在构建中 ← 还没展示
5. render 阶段完成
workInProgress 树构建完毕,所有 flags 已打好
6. commit 阶段(不可中断)
根据 wip 树上的 flags 执行 DOM 操作
屏幕更新 ← 用户看到新 UI
7. 切换!(关键的一行代码)
root.current = finishedWork; // ← ReactFiberWorkLoop.js 第 4029 行
此时:
- 旧的 current 树变成了"后台画布"(下次更新时的 wip)
- 旧的 wip 树变成了"展出的画布"(新的 current)
8. 下一轮更新
重复步骤 2-7,角色互换
角色互换的具体过程
ini
第一次渲染前:
current 树: App₀ ← FiberRoot.current
wip 树: 不存在
第一次渲染后:
current 树: App₀ ← FiberRoot.current(commit 后切换)
实际切换为: App₁ ← FiberRoot.current
App₀.alternate → App₁
App₁.alternate → App₀
第二次渲染:
current = App₁(上次的新树)
createWorkInProgress(App₁) → 拿到 App₀(复用旧的!)
wip = App₀ → 在上面构建新的状态
第二次渲染后:
FiberRoot.current = App₀(又是它!但内容已经更新)
App₀ 和 App₁ 的角色来回切换
这就是"双缓冲"的核心:两个 Fiber 对象交替扮演 current 和 wip 的角色,永远不需要第三个。
第 4 段:设计动机与权衡
为什么是两棵树而不是每次新建
| 方案 | 内存开销 | GC 压力 | 创建成本 |
|---|---|---|---|
| 每次新建整棵树 | 高(每帧创建几百个 Fiber) | 极高(旧树等待 GC) | 高 |
| 双缓冲(alternate 复用) | 低(最多两套 Fiber) | 低(无频繁分配释放) | 低(复用已有对象) |
源码注释说得很清楚(ReactFiber.js 第 330 行):
We use a double buffering pooling technique because we know that we'll only ever need at most two versions of a tree.
这是一个对象池模式:永远只维护两个版本的 Fiber 节点,交替使用。第三帧来了?复用第一帧的节点。第四帧?复用第二帧的节点。
为什么 commit 之后才切换 current
如果在 render 阶段就切换 current,一旦渲染被中断,用户通过 ref、事件处理器等访问到的就是半成品状态。commit 完成后切换,保证了 current 永远指向完整的、一致的 UI 状态。
牺牲了什么
- 内存翻倍:同一棵组件树,在内存中始终存在两套 Fiber 节点
- alternate 指针维护:每个 Fiber 节点需要额外的 alternate 字段和配对逻辑
- 状态复制的复杂度:createWorkInProgress 需要正确复制所有状态字段,遗漏一个就会出 bug
第 5 段:次级误解和边界
误解 1:"两棵树的 DOM 节点也是两份"
不是。stateNode 字段是共享的:
ini
current.App₀.stateNode === workInProgress.App₁.stateNode
// 两个 Fiber 节点指向同一个 DOM 元素
双缓冲只是在 Fiber 层面,DOM 层面只有一份。commit 阶段是直接操作这一份 DOM。
误解 2:"current 树在 render 阶段完全不变"
大部分情况下是的------render 阶段只操作 wip 树。但有一个例外:当 render 阶段被高优先级任务打断时,React 可能会丢弃当前的 wip 树,基于 current 树重新创建新的 wip 树。此时 current 树作为"基线"被读取,但不会被修改。
边界:首次渲染时没有 alternate
首次渲染时只有 current 树(通过 createRoot 创建的),没有 wip 树。createWorkInProgress 发现 current.alternate === null,才会创建第一个 wip 节点。这就是源码中 if (workInProgress === null) 分支的含义------它只在首次遇到时执行,后续更新都走 else 分支(复用已有的 alternate)。
现在我们知道了 current 树和 wip 树通过 alternate 互为配对,render 阶段在 wip 树上构建,commit 后通过 root.current = finishedWork 一行代码切换。两棵树的角色永远在交替。
但 React 具体是怎么遍历这棵 Fiber 树的?beginWork 和 completeWork 之间是什么关系?为什么遍历顺序是"先子后兄最后父"?这就是考点 1.4「Fiber 的遍历顺序(DFS)」要回答的问题。
考点 1.4:Fiber 的遍历顺序(DFS)
第 0 段:直觉锚定
想象你在读一本目录嵌套的书。阅读规则是:
- 翻开一章 → 先看它的第一节(child)
- 第一节如果有子节 → 继续看子节(一直往下钻)
- 钻到最底没有子节了 → 回头看这一节的下一节兄弟(sibling)
- 兄弟也看完了 → 回到父节,看父节的下一个兄弟
- 所有兄弟都看完 → 继续往上回(return)
这个"先往下钻到底,再横向走兄弟,最后往上回"就是深度优先搜索(DFS) 。React 用两个函数配合完成这个遍历:beginWork 负责"往下钻",completeWork 负责"往上回" 。
第 1 段:问题背景
在考点 1.1 我们知道 Fiber 用链表树替代了递归调用栈。但链表树有了,怎么遍历它 ?递归遍历(旧方案)天然就是 DFS,但不可中断。Fiber 需要保持 DFS 的语义,同时让遍历可以在任意节点暂停。
React 的做法是把 DFS 拆成一个个原子步骤------每个步骤只处理一个 Fiber 节点。步骤之间就是可以暂停的点。
第 2 段:核心数据结构
遍历用到的指针只有三个(就是 FiberNode 的树结构字段):
kotlin
return → 回到父节点
child → 进入第一个子节点
sibling → 移到下一个兄弟节点
加上一个全局变量:
php
workInProgress: Fiber | null // "当前正在处理的节点"
// workInProgress = null → 整棵树处理完了
遍历过程中只靠修改这一个变量来推进。暂停时,只要保存 workInProgress 的值,恢复时从它继续即可。
第 3 段:运行流程
两个核心函数
1. 定位: react@18.3.1 · ReactFiberWorkLoop.js · performUnitOfWork(第 3059 行)
ini
function performUnitOfWork(unitOfWork: Fiber): void {
const current = unitOfWork.alternate;
// 步骤 1:对当前节点做 beginWork
let next = beginWork(current, unitOfWork, entangledRenderLanes);
// 步骤 2:缓存 props
unitOfWork.memoizedProps = unitOfWork.pendingProps;
// 步骤 3:决定下一步去哪
if (next === null) {
// beginWork 没返回子节点 → 这个节点"向下"的工作做完了
completeUnitOfWork(unitOfWork); // 开始"向上"回溯
} else {
// beginWork 返回了子节点 → 继续往下钻
workInProgress = next;
}
}
2. 定位: ReactFiberWorkLoop.js · completeUnitOfWork(第 3346 行)
ini
function completeUnitOfWork(unitOfWork: Fiber): void {
let completedWork = unitOfWork;
do {
// 步骤 1:对当前节点做 completeWork
const current = completedWork.alternate;
let next = completeWork(current, completedWork, entangledRenderLanes);
if (next !== null) {
// completeWork 产生了新工作(如 Suspense 恢复)
workInProgress = next;
return;
}
// 步骤 2:检查有没有兄弟
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
// 有兄弟 → 去处理兄弟(横向走)
workInProgress = siblingFiber;
return;
}
// 步骤 3:没有兄弟 → 回到父节点(往上回)
completedWork = completedWork.return;
workInProgress = completedWork;
// 循环:回到父节点后,继续检查父节点需不需要 complete
} while (completedWork !== null);
// completedWork === null → 回到了根之上,整棵树处理完了
}
遍历决策图
ini
performUnitOfWork(node)
│
▼
beginWork(node) → 返回 next
│
┌────┴────┐
│ │
next=null next≠null
│ │
▼ ▼
complete workInProgress = next ← 继续往下钻
UnitOfWork (回到 workLoop,下一个循环处理 child)
│
▼
completeWork(node)
│
├─ 有兄弟?
│ │
│ Yes → workInProgress = sibling ← 横向走
│ No
│ │
│ ▼
│ 回到 return(父节点)
│ │
│ └─ 父节点继续 completeWork 循环
│
└─ return === null?→ 整棵树完成
完整遍历示例
css
Fiber 树结构:
div
/ \
h1 p
/ \ \
span a em
遍历过程(数字 = 步骤序号):
1. performUnitOfWork(div) → beginWork(div) 返回 h1
workInProgress = h1
2. performUnitOfWork(h1) → beginWork(h1) 返回 span
workInProgress = span
3. performUnitOfWork(span) → beginWork(span) 返回 null(叶子)
→ completeUnitOfWork(span)
→ completeWork(span)
→ span.sibling = a ≠ null
→ workInProgress = a
4. performUnitOfWork(a) → beginWork(a) 返回 null(叶子)
→ completeUnitOfWork(a)
→ completeWork(a)
→ a.sibling = null
→ 回到 return = h1
→ completeWork(h1) ← h1 的所有子节点都处理完了
→ h1.sibling = p ≠ null
→ workInProgress = p
5. performUnitOfWork(p) → beginWork(p) 返回 em
workInProgress = em
6. performUnitOfWork(em) → beginWork(em) 返回 null(叶子)
→ completeUnitOfWork(em)
→ completeWork(em)
→ em.sibling = null
→ 回到 return = p
→ completeWork(p) ← p 的所有子节点都处理完了
→ p.sibling = null
→ 回到 return = div
→ completeWork(div) ← div 的所有子节点都处理完了
→ div.return = null
→ completedWork = null ← 到达根之上
整棵树处理完毕!
beginWork 和 completeWork 的执行时序
scss
时间线 →
beginWork(div) beginWork(h1) beginWork(span) completeWork(span)
│
beginWork(a) completeWork(a) completeWork(h1)
│
beginWork(p) beginWork(em) completeWork(em)
│
completeWork(p) completeWork(div)
←─ beginWork(向下钻)────────────────→
←─ completeWork(向上回)────────────────────────────────→
规律:beginWork 是"入"操作,completeWork 是"出"操作。 每个节点必定被 beginWork 一次、被 completeWork 一次。这和递归调用的"压栈/出栈"完全对应。
workLoopConcurrent 把遍历变成可中断
scss
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
每次循环只处理一个节点 (一个 beginWork 或一个 completeWork)。循环之间检查 shouldYield()。这意味着中断可以发生在任何两个节点之间,但绝不会在一个节点的处理过程中中断。
第 4 段:设计动机与权衡
为什么用迭代 DFS 而不是递归
| 递归 DFS | 迭代 DFS(React 选择的) | |
|---|---|---|
| 状态存储 | JS 调用栈(不可控) | workInProgress 变量(可控) |
| 可中断 | ❌ 调用栈无法保存/恢复 | ✅ 保存一个变量即可 |
| 内存 | O(深度) 调用栈帧 | O(1) 额外变量 |
| 代码直观性 | 更直观 | 需要手动管理遍历逻辑 |
为什么 beginWork 和 completeWork 分离
它们在渲染流程中承担完全不同的职责:
perl
beginWork(向下) completeWork(向上)
───────────── ──────────────
计算新的 props/state 创建 DOM 节点
执行组件函数 收集 flags 向上冒泡
调和子节点(Diff) 处理 ref
标记本节点的 flags 计算子树副作用汇总
分离的好处:子节点总是在父节点之前完成 completeWork。这保证了:
- useEffect 的清理和执行顺序是"子先于父"(考点 2.4 会讲到)
- flags 冒泡时,子节点的 flags 已经计算完毕,可以直接累加到父节点的 subtreeFlags
第 5 段:次级误解和边界
误解 1:"遍历完一棵树后才开始 commit"
是的,这是正确理解。render 阶段必须完整遍历整棵 workInProgress 树后,才会进入 commit 阶段。不可能出现"render 了一半就去 commit"的情况------即使 render 被中断了,恢复后也会继续 render 直到整棵树完成,才会 commit。
误解 2:"completeWork 的执行顺序就是 useEffect 的执行顺序"
方向对了但不够精确。completeWork 只是收集 effect 信息到 flags,useEffect 的实际执行发生在 commit 阶段之后。执行顺序确实是"子先于父",但原因是 React 在 commit 阶段按 DFS 遍历收集所有有 Passive flag 的节点,DFS 的特性决定了收集顺序就是子先父后。
边界:completeWork 返回了非 null 值
在 completeUnitOfWork 第 3386 行有一个分支:
kotlin
if (next !== null) {
// Completing this fiber spawned new work.
workInProgress = next;
return;
}
正常情况下 completeWork 返回 null。但在某些特殊场景(如 Suspense 恢复 、Error Boundary 捕获错误后重试),completeWork 可能返回一个新的 Fiber 节点,意味着"完成这个节点时发现了新工作"。此时遍历会中断当前回溯,跳到新工作继续。
现在我们知道了 performUnitOfWork 通过 beginWork/completeUnitOfWork 交替完成 DFS 遍历,每个节点先被 beginWork(向下钻),所有子节点处理完后被 completeWork(向上回)。
beginWork 根据 tag 分发到不同处理函数,completeWork 创建 DOM 和收集 flags------但每个 Fiber 节点的 stateNode 字段到底指向什么不同类型的对象?这就是考点 1.5「stateNode 指向什么」要回答的问题。
考点 1.5:stateNode 指向什么
第 0 段:直觉锚定
stateNode 是 Fiber 节点和外部世界的桥梁。如果 Fiber 树是 React 的"内部账本",stateNode 就是账本上写的"这个东西在现实中的实物在哪"。不同类型的 Fiber,"实物"不一样------有的是 DOM 节点,有的是 JS 对象,有的干脆没有实物。
第 1 段:问题背景
Fiber 树本身只是 React 内部的数据结构,不直接产生视觉效果。React 最终要操作 DOM 才能渲染 UI。stateNode 就是 Fiber 连接到真实 DOM(或组件实例)的那个指针。 commit 阶段所有 DOM 操作都通过它来完成------"找到父 DOM"、"操作子 DOM"、"更新属性",全靠 stateNode 定位。
第 2 段:核心数据结构
每种 tag 的 stateNode 指向不同的东西:
scss
tag stateNode 指向 赋值时机
─────────────────────────────────────────────────────────────────
HostRoot (3) FiberRootNode createRoot 时
FunctionComponent (0) null 永远不赋值
ClassComponent (1) new Component() 实例 beginWork 首次挂载时
HostComponent (5) 真实 DOM (如 HTMLDivElement) completeWork 首次挂载时
HostText (6) 真实 Text 节点 completeWork 首次挂载时
逐一详解
HostRoot(tag=3)--- 指向 FiberRootNode
php
FiberRootNode ←──── HostRoot Fiber.stateNode
│
└── current ────→ HostRoot Fiber ← 循环引用
这个在考点 1.3 已经讲过。HostRoot 的 stateNode 反向指向 FiberRootNode,形成循环引用。赋值发生在 createRoot 时。
源码位置:ReactFiber.js 第 150 行,初始值 this.stateNode = null,随后在 ReactFiberRoot.js 的 createRoot 流程中被赋值。
FunctionComponent(tag=0)--- 永远是 null
csharp
// ReactFiberCompleteWork.js 第 1088 行
case FunctionComponent:
case ForwardRef:
case Fragment:
case MemoComponent:
bubbleProperties(workInProgress);
return null;
// ← 没有 stateNode 赋值!
函数组件没有实例对象。它的"状态"存在 memoizedState 的 Hooks 链表里,不需要额外的实例。这也解释了为什么函数组件没有 this------根本就没有实例可以指向。
ClassComponent(tag=1)--- 指向类实例
scala
class App extends React.Component { ... }
Fiber(App, tag=1).stateNode → App { props, state, context, ... }
│
├── this.props
├── this.state
└── this.setState() ← 通过 fiber 指针实现
类实例在 beginWork 的 updateClassComponent → constructClassInstance 中创建:
scss
beginWork(ClassComponent)
→ updateClassComponent()
→ constructClassInstance() // new Component(props, context)
→ mountClassInstance() // 初始化 state、生命周期
→ fiber.stateNode = instance // 赋值!
实例内部也持有 fiber 的引用(instance._reactInternals),形成双向指向:
php
Fiber.stateNode → 实例
实例._reactInternals → Fiber
这是 this.setState() 能触发更新的基础------实例通过 _reactInternals 找到自己的 Fiber,然后入队更新。
HostComponent(tag=5)--- 指向真实 DOM
typescript
// ReactFiberCompleteWork.js 第 1356 行
case HostComponent: {
if (current !== null && workInProgress.stateNode != null) {
// 更新:已有 DOM,只需更新属性
updateHostComponent(current, workInProgress, type, newProps, renderLanes);
} else {
// 挂载:创建 DOM
// workInProgress.stateNode = createInstance(type, newProps, ...)
// ↑ 实际是 document.createElement(type)
}
}
首次挂载时,createInstance 内部调用 document.createElement(type) 创建真实 DOM,然后赋值给 stateNode。
更新时,stateNode 已经有值了(上次创建的 DOM),只需要 diff 新旧 props 更新属性。
HostText(tag=6)--- 指向真实文本节点
arduino
// ReactFiberCompleteWork.js 第 681 行
workInProgress.stateNode = createTextInstance(
newText, // 文本内容
rootContainerInstance, // 容器
currentHostContext,
workInProgress,
);
// createTextInstance 实际是 document.createTextNode(newText)
文本节点比 HostComponent 简单:没有属性、没有子节点,只有文本内容。
三节点实例图
javascript
function App() {
return <div><h1>Hello</h1></div>;
}
对应的 Fiber 树和 stateNode:
ini
Fiber(App, tag=0)
stateNode: null ← 函数组件无实例
│
└── child → Fiber(div, tag=5)
stateNode: <div> ← HTMLDivElement(真实 DOM)
│
└── child → Fiber(h1, tag=5)
stateNode: <h1> ← HTMLHeadingElement
│
└── child → Fiber("Hello", tag=6)
stateNode: "Hello" ← Text 节点
第 3 段:运行流程
stateNode 在完整渲染周期中的生命周期
ini
┌─ createWorkInProgress ──────────────────────────────┐
│ 首次:stateNode = null(新 Fiber,还没赋值) │
│ 复用:stateNode 保持 current.stateNode 的引用 │
│ (共享同一个 DOM!) │
└──────────────────────────────────────────────────────┘
│
▼
┌─ beginWork ──────────────────────────────────────────┐
│ ClassComponent 首次挂载: │
│ constructClassInstance → fiber.stateNode = 实例 │
│ 其他 tag:不修改 stateNode │
└──────────────────────────────────────────────────────┘
│
▼
┌─ completeWork ───────────────────────────────────────┐
│ HostComponent 首次挂载: │
│ createInstance → fiber.stateNode = DOM 元素 │
│ HostText 首次挂载: │
│ createTextInstance → fiber.stateNode = Text 节点 │
│ HostComponent 更新: │
│ stateNode 已存在 → updateHostComponent(diff 属性) │
│ FunctionComponent:不做任何 stateNode 操作 │
└──────────────────────────────────────────────────────┘
│
▼
┌─ commitMutationEffects ─────────────────────────────┐
│ 读 fiber.stateNode 获取真实 DOM │
│ 读 fiber.return.stateNode 获取父 DOM │
│ 执行 appendChild / removeChild / updateProperties │
└──────────────────────────────────────────────────────┘
commit 阶段如何通过 stateNode 操作 DOM
php
// commit 阶段的简化逻辑
// 插入节点(Placement flag)
function commitPlacement(fiber) {
// 1. 找到父 DOM
const parentFiber = fiber.return;
const parentDOM = parentFiber.stateNode;
// ↑ 如果父 Fiber 是函数组件(stateNode=null),
// 需要继续往上找,直到找到 stateNode 不为 null 的祖先
// 2. 插入
parentDOM.appendChild(fiber.stateNode);
// ↑ fiber.stateNode 就是之前 completeWork 创建的 DOM
}
// 更新属性(Update flag)
function commitUpdate(fiber) {
const dom = fiber.stateNode;
// diff 新旧 props,逐个更新变化的属性
updateDOMProperties(dom, fiber.memoizedProps, fiber.pendingProps);
}
这里有一个细节:如果父 Fiber 是函数组件(stateNode=null),React 需要沿 return 指针往上找,直到找到第一个 stateNode 不为 null 的祖先(通常是 HostComponent 的 DOM) 。这就是为什么 commit 阶段有 getHostParentFiber 这样的辅助函数。
第 4 段:设计动机与权衡
为什么函数组件没有 stateNode
函数组件的设计哲学是"无实例、无 this"。它的所有状态都存在 Hooks 链表(memoizedState)中。如果给函数组件也创建一个实例对象,就违背了它的设计初衷,也浪费内存。
为什么 stateNode 在 completeWork 中赋值(而非 beginWork)
对于 HostComponent 和 HostText,DOM 创建放在 completeWork 而不是 beginWork 的原因是:
- beginWork 只负责调和------计算"应该有什么子节点",不负责创建实际资源
- completeWork 负责实际创建------当所有子节点都 beginWork 完毕后,才开始创建 DOM
- 错误处理更简单------如果 beginWork 阶段出错(如 Suspense),不需要清理已创建的 DOM
为什么 current 和 wip 共享同一个 stateNode
在 createWorkInProgress 中:
ini
workInProgress.stateNode = current.stateNode; // 共享同一个 DOM
因为同一时刻只有一棵树在屏幕上,DOM 也只有一份。两棵 Fiber 树指向同一个 DOM 对象是完全安全的------commit 阶段通过这个共享的 stateNode 直接操作 DOM。
第 5 段:次级误解和边界
误解:"所有 Fiber 都有对应的真实 DOM"
不是。函数组件 Fiber(tag=0)、Fragment Fiber(tag=7)、ContextProvider Fiber(tag=10)等都没有对应的 DOM。它们是"逻辑节点",只存在于 Fiber 树中。它们的子节点在 DOM 层面会"穿透"到最近的 HostComponent 祖先上。
javascript
// Fragment 没有 DOM
<>
<div>A</div> {/* div 的 DOM 直接挂在 Fragment 的父 DOM 下 */}
</>
// 函数组件也没有 DOM
function Wrapper({ children }) {
return children; // children 的 DOM 直接挂在 Wrapper 的父 DOM 下
}
边界:删除节点时 stateNode 的清理
commit 阶段删除节点后,fiber.stateNode 不会立刻被清空。Fiber 节点本身可能还在 alternate 引用中被持有。真正释放内存靠 GC------当没有任何引用指向这个 DOM 节点时(已从 DOM 树移除 + Fiber 被回收),GC 才会回收它。
从 stateNode 到屏幕像素的完整链路
分三步:
第一步:completeWork --- 在内存中创建 DOM(不可见)
javascript
// completeWork 处理 HostComponent 首次挂载时
document.createElement('div') // 创建了一个 DOM 对象
// 但它是一个"孤儿",没有挂到页面的任何地方
fiber.stateNode = 这个DOM对象 // Fiber 持有引用
此时 DOM 对象存在于 JavaScript 堆内存中,但不在浏览器的 DOM 树里,用户完全看不到。
用代码类比:
ini
const div = document.createElement('div');
div.textContent = 'Hello';
// 此时 div 存在于内存中,但页面上什么都看不到
// 必须有 container.appendChild(div) 才会显示
第二步:commitMutationEffects --- 插入到页面 DOM 树
commit 阶段遍历所有带 Placement flag 的 Fiber,逐个把 stateNode 插入到父 DOM 中:
javascript
假设我们的组件:
function App() {
return <div><h1>Hello</h1><p>World</p></div>;
}
页面容器:<div id="root"></div>
commit 阶段的操作序列:
css
1. 处理 Fiber(div), flags=Placement
找到父 DOM:往上找 return → Fiber(HostRoot).stateNode → FiberRootNode.containerInfo → <div id="root">
操作:<div id="root">.appendChild(div.stateNode)
DOM 树此时:
<div id="root">
<div></div> ← 刚插入,还是空的(子节点还没处理)
</div>
2. 处理 Fiber(h1), flags=Placement
找到父 DOM:Fiber(div).stateNode → <div>(上一步插入的那个)
操作:<div>.appendChild(h1.stateNode)
DOM 树此时:
<div id="root">
<div>
<h1></h1> ← h1 插入了,但内部文本还没处理
</div>
</div>
3. 处理 Fiber("Hello"), flags=Placement
找到父 DOM:Fiber(h1).stateNode → <h1>
操作:<h1>.appendChild("Hello" 文本节点)
DOM 树:
<div id="root">
<div>
<h1>Hello</h1> ← 现在能看到文字了
</div>
</div>
4. 处理 Fiber(p), flags=Placement
父 DOM = Fiber(div).stateNode → <div>
操作:<div>.appendChild(p.stateNode)
5. 处理 Fiber("World"), flags=Placement
父 DOM = Fiber(p).stateNode → <p>
操作:<p>.appendChild("World" 文本节点)
最终 DOM 树:
xml
<div id="root">
<div>
<h1>Hello</h1>
<p>World</p>
</div>
</div>
关键点:找父 DOM 就是 fiber.return.stateNode 。如果 fiber.return 是函数组件(stateNode=null),就继续往上 .return 直到找到一个有 stateNode 的祖先。
第三步:浏览器绘制
commit 阶段结束后,React 的工作完成。浏览器接管:
css
commit 结束
│
▼
浏览器渲染管线(自动,React 不参与)
│
├── Recalculate Style ← 计算 CSS(这个 div 什么颜色?多大?)
├── Layout ← 计算几何位置(这个 div 在屏幕哪个位置?)
├── Paint ← 把像素画到图层上
└── Composite ← 合成图层,显示到屏幕
用户在这一步才真正看到新 UI。
完整时序图
scss
completeWork commitMutationEffects 浏览器渲染
│ │ │
createElement(div) │ │
createElement(h1) │ │
createTextNode │ │
createElement(p) │ │
createTextNode │ │
(全在内存中, │ │
用户看不到) │ │
│ │ │
▼ ▼ │
appendChild(div) │
appendChild(h1) │
appendChild(文本) │
appendChild(p) │
appendChild(文本) │
(DOM 操作, │
但浏览器还没绘制) │
│ │
▼ ▼
┌─────────────────┐
│ Style → Layout │
│ → Paint → Comp │
│ ← 用户看到画面 │
└─────────────────┘
stateNode 本身不会"渲染到浏览器上" 。stateNode 只是一个 JavaScript 引用,指向一个 DOM 对象。真正让内容显示在屏幕上的是 commit 阶段的 appendChild + 浏览器的渲染管线。stateNode 是桥梁------React 通过它找到 DOM 对象,然后通过 DOM API 把它挂到页面上。
回顾和答题
Fiber 架构主题块全部 5 个考点讲完了。 来做一次快速回顾:
php
1.1 Fiber 为何替换 Stack Reconciler → 可中断渲染
1.2 FiberNode 完整数据结构 → 7 组字段,各司其职
1.3 双缓冲树 → current ↔ wip 交替
1.4 DFS 遍历顺序 → beginWork 向下,completeWork 向上
1.5 stateNode 指向什么 → Fiber 连接外部世界的桥梁
请尝试回答下面问题吧:
第 1 题(考点 1.1 + 1.2): 请你向一个不了解 React 的人解释:React 为什么要发明 Fiber?FiberNode 上的 return / child / sibling 三个指针解决了什么问题?如果去掉 return 指针,会出什么问题?
第 2 题(考点 1.3): React 为什么同时维护两棵 Fiber 树(current 和 workInProgress)?如果只用一棵树,边渲染边更新会有什么问题?commit 阶段那行 root.current = finishedWork 是在什么时候执行的,为什么必须放在这个时机?
第 3 题(考点 1.4 + 1.5): 请解释 performUnitOfWork 是怎么决定"下一步去哪"的。beginWork 返回 null 和返回非 null 分别意味着什么?然后 completeUnitOfWork 又是怎么通过 sibling 和 return 决定去向的?另外,函数组件的 Fiber 节点有 stateNode 吗?如果没有,commit 阶段要操作它的子节点 DOM 时怎么找到父 DOM?