Dive into React——Fiber架构


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 操作
  }
}

这个递归有几个致命问题:

  1. 不可中断 :一旦进入递归,JavaScript 引擎无法暂停它。调用栈被层层压入,直到整棵树遍历完才弹出。这意味着如果树很大(比如 1000 个组件),这 1000 个组件必须一口气处理完
  2. 调用栈不可操控:浏览器的调用栈(Call Stack)是引擎内部管理的,JavaScript 代码无法"保存当前调用栈状态、稍后恢复"。递归到第 500 层时,你没有办法说"先暂停,把当前的 500 层调用栈存起来,等下恢复"。
  3. 帧超时 = 掉帧:浏览器每 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 选择链表树的原因:

  1. 递归的语义完美匹配树结构:DFS 遍历一棵组件树,天然就是"先处理子节点,再回溯父节点"
  2. O(1) 级别的暂停/恢复 :只需保存一个 workInProgress 指针("当前处理到哪个节点"),恢复时直接从它继续
  3. 不需要序列化/反序列化:链表节点是内存中的对象引用,暂停时不需要把状态写到磁盘

Fiber 牺牲了什么

  1. 内存开销:每个组件对应一个 FiberNode 对象(约 20+ 个字段),两棵树就是两倍内存
  2. 概念复杂度:双缓存、优先级、可中断渲染等概念显著增加了理解和调试难度
  3. 不是真正的并行:仍然是单线程,只是"合作式调度"(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 选择单对象的原因:

  1. 内存局部性:一个对象的所有字段在堆内存中紧挨着,CPU 缓存命中率高。拆成多个对象后,每次渲染阶段切换都要跳到不同的内存位置
  2. GC 压力:一个对象比三个对象的 GC 扫描成本更低
  3. 代码简洁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;
}

注意这里的设计:

  1. 首次创建:创建新 Fiber 节点,设置 alternate 双向指向
  2. 后续复用:直接拿已有的 alternate,重置 flags,复制 current 的最新状态
  3. 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 状态

牺牲了什么

  1. 内存翻倍:同一棵组件树,在内存中始终存在两套 Fiber 节点
  2. alternate 指针维护:每个 Fiber 节点需要额外的 alternate 字段和配对逻辑
  3. 状态复制的复杂度: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 段:直觉锚定

想象你在读一本目录嵌套的书。阅读规则是:

  1. 翻开一章 → 先看它的第一节(child)
  2. 第一节如果有子节 → 继续看子节(一直往下钻)
  3. 钻到最底没有子节了 → 回头看这一节的下一节兄弟(sibling)
  4. 兄弟也看完了 → 回到父节,看父节的下一个兄弟
  5. 所有兄弟都看完 → 继续往上回(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.jscreateRoot 流程中被赋值。

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 的 updateClassComponentconstructClassInstance 中创建:

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 的原因是:

  1. beginWork 只负责调和------计算"应该有什么子节点",不负责创建实际资源
  2. completeWork 负责实际创建------当所有子节点都 beginWork 完毕后,才开始创建 DOM
  3. 错误处理更简单------如果 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?

相关推荐
光影少年7 小时前
react中的Context 为什么会导致性能问题?
前端·javascript·react.js
Z_Wonderful7 小时前
react部署更新后旧 chunk 404、用户浏览器缓存旧页面的问题与(路由跳转使用相对路径而不是绝对路径的关系)分析,并提供解决方案
javascript·react.js·缓存
weelinking16 小时前
【产品】12_接入数据库——让数据永久保存
jvm·数据库·python·react.js·数据挖掘·前端框架·产品经理
qcx2318 小时前
【系统学AI】25 论文导读 ①:两篇改变 AI 的开山之作——Attention Is All You Need & ReAct
前端·人工智能·react.js·transformer
weixin_397574091 天前
AgentRAG与ReAct推理链:从检索增强到推理增强
前端·react.js·前端框架
喵个咪1 天前
基于 Next.js 的 Headless CMS 前端架构:技术解析与二次开发导引
前端·react.js·next.js
zach1 天前
React中的兄弟通讯之发布订阅模式
前端·react.js
倾颜1 天前
React 自定义 Hook 实战:把 AI Chat 的会话流和滚动体验从组件中拆出来
前端·react.js·next.js