陌上人如玉
公子世无双

4.2 Fiber 架构

4.2 Fiber 架构

一句话:Fiber 把 React 的渲染从「不可中断的递归」改造成「可中断的链表遍历」——每个 Fiber 节点是一个「工作单元」,带父子兄弟指针与优先级,从而支持时间切片、优先级调度与并发渲染。

一、为什么需要 Fiber

React 15 及之前的渲染方式(Stack Reconciler):

function reconcile(element) {
  // 递归遍历整棵元素树
  if (element.type) {
    updateDOM(element);
    element.children.forEach(reconcile);   // ⚠️ 递归
  }
}
问题:递归一旦开始就无法中断

① 浏览器的「帧预算」大约是 16.7ms(60fps)
   → 每帧留给 JS 的时间更少(大约几毫秒)

② React 15 的递归 reconciler 可能一次跑几百毫秒(大列表、深树)
   → 期间【无法让出主线程】
   → 用户的输入、动画、点击全部被阻塞(掉帧、卡顿、输入延迟)

③ 而且这个工作【无法被打断】:不能「先渲染一半,处理一下用户输入,再继续」

结论:需要一个「可中断、可恢复、可分优先级」的架构 → Fiber
Fiber 的核心思路:
① 把「整棵树的渲染」拆成「一个个小工作单元(Fiber 节点)」
② React 可以在处理完一个单元后检查「有没有更高优先级的任务」
   → 有就让出主线程,稍后再从「记录的位置」继续
③ 每个单元的工作量可控(一个组件的渲染/一个节点的 diff)
④ 加上「优先级」概念:紧急更新(输入)可以先做

二、Fiber 节点的结构

// Fiber 节点(简化)
{
  // ① 与「组件/元素」相关的信息
  tag: FunctionComponent,          // 类型标记(函数组件/类组件/宿主组件/...)
  type: MyComponent,               // 对应的函数 / class / 'div'
  key: null,
  stateNode: null,                 // 宿主组件对应真实 DOM;类组件对应实例

  // ② 与「树结构」相关的指针(⭐ 用它替代递归)
  return: parentFiber,             // 父节点(名字是 return,因为「工作完成后返回这里」)
  child: firstChildFiber,          // 第一个子节点
  sibling: nextSiblingFiber,       // 下一个兄弟节点

  // ③ 与「更新」相关的信息
  pendingProps: {},                // 本次渲染的新 props
  memoizedProps: {},               // 上次渲染的 props
  memoizedState: null,             // 上次渲染的状态(Hooks 链表就挂在函数组件的这里!)
  updateQueue: null,               // 更新队列

  // ④ 与「可中断渲染」相关的信息
  alternate: otherFiber,           // ⭐ 另一棵树的对应节点(双缓存)
  flags: 0,                        // ⭐ 副作用标记(Placement / Update / Deletion...)
  subtreeFlags: 0,                 // 子树中「是否含有副作用」
  lanes: 0,                        // ⭐ 优先级(车道)—— 本节点自己的待处理更新
  childLanes: 0,                   // 子树中的待处理更新
}
三个指针怎么「遍历整棵树」?

function workLoop() {
  let workInProgress = root;

  while (workInProgress !== null) {
    // ① 「递」阶段:处理当前节点,并进入它的第一个子节点
    workInProgress = performUnitOfWork(workInProgress);
  }
}

function performUnitOfWork(fiber) {
  // 1. 处理当前节点(渲染组件、diff 子元素)
  const child = beginWork(fiber);

  if (child !== null) {
    return child;                    // ⭐ 有子节点 → 继续往下(递)
  }

  // 2. 没有子节点 → 开始「归」阶段
  let current = fiber;
  while (current !== null) {
    completeUnitOfWork(current);                  // 处理「完成」逻辑(如收集副作用)

    if (current.sibling !== null) {
      return current.sibling;                     // 有兄弟 → 处理兄弟
    }
    current = current.return;                     // 没有兄弟 → 回到父节点
  }
  return null;                                    // 回到根了 → 本轮工作完成
}

关键点:整个过程是「先往深处做(递),做完一个分支再往上回(归),然后去兄弟分支」——用循环 + 指针替代了原来的递归,因此可以在任意一步暂停(保存 workInProgress 指针,下次从这里继续)。

三、双缓存树(Double Buffering)

React 在内存中同时维护两棵树:

        current 树(当前显示在屏幕上的)        workInProgress 树(正在构建的)
              ┌─────────┐                        ┌─────────┐
              │   App   │◄──── alternate ────►  │   App   │
              └────┬────┘                        └────┬────┘
                   │                                  │
              ┌────▼────┐                        ┌────▼────┐
              │  List   │◄──── alternate ────►  │  List   │
              └─────────┘                        └─────────┘

① 每次渲染,React 基于 current 树创建/复用对应的 workInProgress 节点
② 两个节点通过 alternate 互相指向(所以可以从任一棵找到另一棵)
③ 工作完成后,root.current 指向 workInProgress 树 → 「切换」瞬间完成
双缓存的好处:

① 用户始终看到「一致的一棵树」(不会看到渲染到一半的状态)
② 「提交」操作只是切换指针(root.current = workInProgressRoot)
③ 渲染过程中可以「丢弃」workInProgress 树(如被更高优先级打断),
   current 树完好无损 → 屏幕内容不受影响
④ alternate 让「复用节点」成为可能(减少新对象创建)
// 一个观察点:函数组件的 Hooks 链表就挂在 fiber.memoizedState 上
// 这也解释了「为什么 Hooks 顺序必须稳定」(见 2.1)
function Component() {
  const [a] = useState(0);      // fiber.memoizedState → hook(a) → hook(b) → null
  const [b] = useState('');
}

四、渲染的两个阶段

① Render 阶段(可中断 ⏸️)
   · 从 root 开始遍历,构建 workInProgress 树
   · 执行组件函数、diff 子元素、收集副作用标记(flags)
   · 这个阶段【可以被打断、可以被丢弃、可以重放】
   → 因此必须【纯】(不能有副作用!)

② Commit 阶段(不可中断 ▶️)
   · 把 workInProgress 树上的副作用一次性应用到 DOM
   · 分三个子阶段:
     a. before mutation(DOM 变更前)——如 getSnapshotBeforeUpdate
     b. mutation(真正改 DOM)——插入/更新/删除节点
     c. layout(DOM 变更后,绘制前)——执行 useLayoutEffect、ref 赋值
   · 然后是浏览器绘制(paint)
   · 最后是 passive effects —— 执行 useEffect(异步)
   → 这个阶段【必须一次做完】(否则用户会看到「改了一半」的界面)
阶段 是否可中断 能做什么 典型 API
Render ✅ 可中断 计算、纯函数调用 组件函数、useMemo 的计算
Commit(mutation) ❌ 操作 DOM —
Commit(layout) ❌ 读/写布局、同步副作用 useLayoutEffect、ref
Passive ❌(但异步) 异步副作用 useEffect

为什么 render 必须纯:因为它可能被「中断后重放」或「丢弃重来」——如果有副作用,就会执行多次或产生不一致的结果。这也是 StrictMode 双调用的理论基础(见 8.5)。

五、工作循环与时间切片

// 简化的工作循环(含时间切片)
let workInProgress = null;
let nextUnitOfWork = null;

function workLoopConcurrent() {
  // ⭐ 每处理完一个单元,就检查「是否该让出主线程」
  while (nextUnitOfWork !== null && !shouldYield()) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }
}

// shouldYield 的判断依据(简化)
function shouldYield() {
  // React 用「剩余时间」判断:
  // 每次调度会分配一个时间片(默认 5ms)
  return performance.now() >= deadline;
  // 或者「有没有更高优先级的任务」(如用户输入)
}
时间切片(Time Slicing)的机制:

① React 用 Scheduler(独立的调度包)来安排工作
② 每帧分配一小段时间片(约 5ms)给 React 工作
③ 时间片用完 → 让出主线程 → 浏览器去处理输入/动画/绘制
④ 下一帧继续(用 requestIdleCallback / MessageChannel 调度)
⑤ 如果期间来了「更高优先级的更新」(如用户输入),
   React 可以【中断】当前的 render 并【丢弃】已完成的部分,优先处理紧急更新

⚠️ 关键:被打断的是【render 阶段】;commit 阶段一定一次做完。
两种渲染模式:

① Legacy 模式(ReactDOM.render,React 17 及之前的默认)
   → 同步渲染,不支持中断

② Concurrent 模式(createRoot,React 18+)
   → 支持中断、优先级、时间切片
   → 但注意:即使是 Concurrent 模式,默认的 setState 仍然是「同步优先级」的,
     只有 useTransition / useDeferredValue / Suspense 等才使用「过渡优先级」

六、优先级:Lane 模型

// Lane 是用「位掩码」表示的优先级(一个 32 位整数)
const SyncLane = 0b0000000000000000000000000000001;             // 同步(最高)
const InputContinuousLane = 0b0000000000000000000000000000100;
const DefaultLane = 0b0000000000000000000000000010000;          // 默认
const TransitionLanes = 0b0000000001111111111000000000000;      // 过渡(低)
const IdleLane = 0b0100000000000000000000000000000;             // 空闲
为什么用「位掩码」而不是数字?

① 一个 fiber 上可以同时存在【多个】待处理的更新
   用位掩码就能「按位或」把它们合并到一个整数里
② 判断「是否包含高优先级更新」只需位运算(极快)
③ 能表达「一批车道(Lanes)」的概念(如所有 TransitionLanes)
优先级 触发来源
Sync 离散事件(click、keydown)、flushSync
InputContinuous 连续事件(mousemove、scroll)
Default 普通 setState、网络回调
Transition startTransition、useTransition、useDeferredValue
Idle 完全可延后的工作

fiber.lanes 记录「这个节点自己有多少待处理更新」,fiber.childLanes 记录「子树里有待处理更新」——这样遍历时可以跳过整棵没有更新的子树(性能关键)。

七、副作用收集与提交

Fiber 用 flags 标记「这个节点需要做什么 DOM 操作」:

Placement        → 需要插入新节点
Update           → 需要更新属性/文本
ChildDeletion    → 需要删除子节点
Ref              → 需要更新 ref
Passive          → 需要执行 useEffect
Layout           → 需要执行 useLayoutEffect
...

同时用 subtreeFlags 汇总「子树中是否有副作用」
→ 遍历时可以「跳过整棵没有副作用的子树」,不需要递归到每个节点
   (这是 React 相比「无脑递归整棵树」的一个重要优化)
Commit 阶段的三步(从 effect 的收集到执行):

① before mutation
   · 处理 snapshot(getSnapshotBeforeUpdate)
   · 调度 useEffect(把它推到「异步队列」)

② mutation
   · 真正操作 DOM:插入(Placement)、更新(Update)、删除(ChildDeletion)
   · 执行 useLayoutEffect 的【清理函数】
   · 重置 ref(对卸载的节点置 null)

③ layout
   · 执行 useLayoutEffect 的【effect 函数】
   · 赋值新的 ref
   · 触发 componentDidMount / componentDidUpdate

④ (浏览器绘制)

⑤ passive(异步)
   · 执行 useEffect 的【清理】与【effect】
// 顺序验证(推荐自己跑一遍)
function Demo({ id }: { id: number }) {
  console.log('render', id);

  useLayoutEffect(() => {
    console.log('layout effect', id);
    return () => console.log('cleanup layout', id);
  }, [id]);

  useEffect(() => {
    console.log('effect', id);
    return () => console.log('cleanup effect', id);
  }, [id]);

  return <div>{id}</div>;
}
// id: 1 → 2 时:
// render 2
// cleanup layout 1 → layout effect 2
// cleanup effect 1 → effect 2

八、常见坑

① 认为「Fiber 让 React 变快了」
   → 它主要提升「响应性」(不阻塞主线程),而不是「吞吐量」;
     甚至因为要维护 Fiber 树与调度,单次渲染的总开销略增

② 认为「所有更新都会被打断」
   → 只有 Concurrent 模式下、且标记为非紧急的更新才可能被打断;
     sync lane(如点击事件)仍然是同步不可中断的

③ 在 render 阶段做副作用
   → 可能被重放或丢弃,副作用会重复/不一致

④ 认为「useEffect 会在 render 之后立即执行」
   → 它在「浏览器绘制之后」异步执行;要「绘制前」用 useLayoutEffect

⑤ 以为「时间切片是 5ms 一帧一次」
   → 它是「动态」的:React 会根据「剩余时间」判断是否让出;
     如果高优先级任务多,React 会让出得更频繁

⑥ 认为「长列表一定会卡」
   → 如果更新被标记为 transition(useTransition),可以被打断,
     但「整体渲染完仍然要花时间」;真正的解法是虚拟滚动

⑦ 在 commit 阶段做「重计算」
   → commit 不能被打断,会直接阻塞绘制

⑧ 以为「Fiber 就是虚拟 DOM」
   → Fiber 是「工作单元/调度数据结构」,虚拟 DOM 是「UI 描述」;
     Fiber 节点里【包含】元素信息,但还有更多调度相关的字段

⑨ 认为 React 的调度能「并行渲染」(多线程)
   → 它仍是单线程的「可中断串行」;真正的并行渲染在实验特性里
     (如 React 的 offscreen/多线程探索),目前不适用于生产

⑩ 在 class 组件里用 shouldComponentUpdate 阻断更新时,忘记 childLanes 的语义

九、面试延伸

  1. 「Fiber 是什么?为什么需要它?」
    Fiber 是 React 16 引入的可中断的渲染架构。它把「整棵树的渲染」拆成一个个「工作单元(Fiber 节点)」,用链表指针(return / child / sibling)替代递归,因此可以在处理完一个单元后暂停(检查是否有更高优先级的任务)、之后从记录的位置恢复。需要它的原因是:React 15 的递归 reconciler 一旦开始就跑完,遇到大列表/深树会阻塞主线程几百毫秒,导致输入延迟与掉帧。

  2. 「Fiber 节点里有哪些关键字段?」
    四类:① 组件信息(tag / type / stateNode——宿主组件指向真实 DOM,类组件指向实例);② 树结构指针(return 父、child 长子、sibling 兄弟——用它们实现「无递归遍历」);③ 更新信息(pendingProps / memoizedProps / memoizedState / updateQueue——Hooks 链表就挂在 memoizedState 上);④ 调度信息(alternate 双缓存、flags 副作用标记、lanes/childLanes 优先级)。

  3. 「为什么用链表遍历替代递归?」
    因为递归无法在中间暂停——JS 的调用栈一旦进入递归,就只能一路执行到底(除非抛异常)。而链表遍历是「一个循环 + 当前节点指针」:每一步结束都能保存 nextUnitOfWork 并 return(让出主线程),下一帧再从这个指针继续。这是「可中断渲染」的必要前提,也是 Fiber 这个「链式结构」的核心设计目的。

  4. 「什么是双缓存树?」
    React 在内存中维护两棵 Fiber 树:current(当前屏幕上显示的)与 workInProgress(正在构建的)。渲染时基于 current 创建/复用 workInProgress 的节点,两个节点通过 alternate 互相指向;工作完成后,只需把 root.current 指向新的树(一次指针切换)就完成了「提交」。好处是:① 用户永远看到一致的一棵树;② 渲染中途被打断时可以直接丢弃 workInProgress 而不影响屏幕。

  5. 「render 阶段和 commit 阶段分别做什么?为什么 render 必须纯?」
    render 阶段:遍历 Fiber 树、执行组件函数、diff 出变化、收集副作用标记——可中断、可丢弃、可重放;commit 阶段:把副作用一次性应用到 DOM(mutation → layout),并触发 effect,不可中断。render 必须纯的原因正是「它可能被中断后重放或整个丢弃」——如果里面有副作用(请求、改外部变量、随机值),就会重复执行或产生不一致的结果。

  6. 「时间切片是怎么实现的?」
    React 用独立的 Scheduler 包做调度:给一次工作分配一个时间片(约 5ms),每处理完一个 Fiber 单元就检查「是否用完时间片或出现更高优先级任务」,是则让出主线程(用 MessageChannel 调度下一次继续),让浏览器处理输入/动画/绘制,下一帧再继续。关键点:被中断的是 render 阶段;commit 阶段永远一次做完(否则界面会出现中间态)。

  7. 「Lane 模型是什么?为什么要用位掩码?」
    Lane 是 React 18 的优先级模型——用一个 32 位整数的不同位表示不同优先级(SyncLane、InputContinuousLane、DefaultLane、TransitionLanes、IdleLane)。用位掩码的原因是:① 一个 fiber 上可能同时有多个待处理更新,用「按位或」就能把它们合并进一个整数;② 判断「是否包含某类优先级」只需位运算(极快);③ 能表达「一批车道」的概念(如所有 transition 车道)。此外 childLanes 让 React 能跳过整棵没有更新的子树。

  8. 「Fiber 让 React 变快了吗?」
    不一定。Fiber 的主要收益是「响应性」而非「吞吐量」:它让长渲染不阻塞用户输入与动画(可中断),但单次渲染因为要维护 Fiber 结构与调度,总开销可能略增。所以「Fiber 让 React 更快」是不准确的;准确说法是「Fiber 让 React 在长时间渲染时仍然可交互」,并且为优先级调度、Suspense、并发特性提供了基础。

一句话速记

Fiber 把「不可中断的递归」改成「可中断的链表遍历」(return/child/sibling 指针 + 循环),每个 Fiber 是「一个工作单元」,所以能暂停 / 恢复 / 丢弃;双缓存树(current 与 workInProgress,用 alternate 互指)让「提交」只是一次指针切换,用户永远看到一致的一棵树;渲染分两阶段——render(可中断、必须纯)与 commit(不可中断,DOM 操作 + layout effect,之后浏览器绘制,再异步执行 useEffect);Lane 位掩码表达优先级(Sync / Default / Transition / Idle),配合 childLanes 跳过无更新的子树;时间切片约 5ms 让出主线程,主要收益是「响应性」而不是「吞吐量」。

赞(0) 打赏
未经允许不得转载:陌上寒 » 4.2 Fiber 架构

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫