mCell
4 / 14

从栈到链表:Fiber 架构是如何实现可中断渲染的?

从“为什么一次大更新会卡住页面”这个痛点出发,讲清 React 从递归栈渲染走向 Fiber 的必然性:可中断、可恢复、可优先级调度;并拆解 Fiber 节点结构与双缓存(current / workInProgress)

你肯定遇到过这种场景:

  • 页面列表很长、组件层级很深
  • 触发一次更新(输入、筛选、切换 tab)
  • 突然 UI 卡一下,输入也顿住,滚动掉帧

直觉会说:“React 慢了。”
但真正的问题是:旧的渲染模型不可中断,一旦开始就必须一口气跑完,主线程被占满,浏览器就没法在这段时间里做响应用户、绘制帧、处理输入。

这一篇我们只做一件事:把 Fiber 的“设计动机”和“核心结构”讲清楚,让你后面看调度、时间分片、并发更新时不会迷路。


0. 先给你一个结论:Fiber 不是“更快的虚拟 DOM”,而是“可调度的工作单元”

Fiber 最核心的目标不是提升单次更新的纯计算速度,而是让 React 具备:

  • 可中断(interruptible)
  • 可恢复(resumable)
  • 可分片(work can be sliced)
  • 可优先级(prioritized)
  • 可复用中间结果(reusing work)

一句话:把一次渲染拆成很多小块,每块都能被暂停/继续,必要时还能丢弃重来。


1. 为什么“递归渲染”会卡死?(从栈说起)

在 Fiber 之前,你可以把 React 的渲染粗略理解成:

  • 从根节点开始递归,深度优先遍历组件树
  • 每个节点做计算(调用 render / function component)
  • 生成下一棵树
  • 最终提交到 DOM

这个过程如果用“调用栈”表达,像这样:

text
render(App) render(Header) render(Content) render(List) render(ListItem #1) render(ListItem #2) ...

问题:调用栈一旦深入,就很难“优雅地停下来”

JS 的调用栈是“强绑定”的:

  • 递归一旦开始,就会持续占用主线程
  • 期间浏览器无法插队去做:
    • 输入事件处理
    • 滚动响应
    • 每一帧的绘制(paint)
    • 合成(composite)

结果就是:掉帧、卡顿、输入延迟


2. 浏览器一帧在做什么?为什么 React 需要“让出主线程”?

以 60fps 为例,每帧大约只有 16.67ms

在这一帧里,浏览器可能要做:

  • 处理输入与事件回调(JS)
  • 执行 requestAnimationFrame 回调(JS)
  • 样式计算、布局(layout)
  • 绘制(paint)
  • 合成(composite)

如果 React 的一次更新占用主线程 30ms:

  • 这两帧的工作被挤在一起
  • 你就会看到明显的卡顿

所以 React 需要一种能力:

在合适的时机暂停自己的工作,把主线程让给浏览器,等有空再继续。

这就是 Fiber 的根本动机。


3. 关键转折:把“递归调用栈”改造成“可遍历的数据结构”

要想可中断,就不能依赖“函数调用栈”来保存上下文。

Fiber 做了一个非常工程化的选择:

把组件树的遍历过程,从“隐式的调用栈”变成“显式的链表结构”, 这样遍历进度可以被保存为指针(当前处理到哪个 fiber)。

于是 React 不再“调用栈一路压到底”,而是:

  • 每处理一个节点,就把它当成一个 work unit(工作单元)
  • 处理完一个 work unit,就有机会检查是否需要让出主线程
  • 如果需要,就暂停;下次从保存的位置继续

这就是“从栈到链表”的本质变化。


4. Fiber 节点是什么?(你可以把它当成“增强版虚拟节点 + 调度信息”)

ReactElement 是“描述 UI 的对象”,而 Fiber 更像是:

  • 对应一个组件实例/节点的“运行时数据结构”
  • 除了 type/props,还包含调度、状态、关系指针等信息
  • 用来承载“工作”和“中间结果”

你可以把 Fiber 理解成一张“节点卡片”,大概包含几类信息:

4.1 结构关系(让遍历变成指针移动)

Fiber 通常至少有这些指针(概念级):

  • return:指向父节点(很多实现里叫 parent,但 React 用 return)
  • child:指向第一个子节点
  • sibling:指向下一个兄弟节点

这三个指针让整棵树变成一种可迭代结构:

  • 下钻:child
  • 横移:sibling
  • 回溯:return

你会发现:这就像把递归 DFS 变成“手动控制的 DFS”。

4.2 输入与输出(把 render 的结果缓存下来)

典型字段(概念):

  • pendingProps:这次更新要用的 props
  • memoizedProps:上次完成后记住的 props
  • memoizedState:hook 链表/类组件 state 的缓存
  • updateQueue:更新队列(setState/useState 的更新会进来)

这些字段让 React 可以:

  • 复用上次计算结果
  • 判断是否可以跳过
  • 在中断后继续

4.3 副作用标记(告诉 commit 阶段要做什么)

常见概念:

  • flags:这个节点需要执行哪些 DOM 操作(插入/更新/删除等)
  • subtreeFlags:子树里累计的 flags(便于快速跳过无副作用子树)

这意味着 render 阶段主要做两件事:

  1. 算出新的结构(以及要不要复用)
  2. 把“需要改什么”记录在 flags 上

commit 阶段再去真正动 DOM。


5. Fiber 如何实现“可中断”?——把渲染拆成 work loop

你不需要记内部函数名,但要有流程感。

Fiber 的 render 阶段更像:

text
while (还有待处理的 fiber) { 处理当前 fiber(计算它的 children / hooks / diff) 如果时间不够了(需要让出主线程): 暂停,保存当前位置 下次继续 }

关键点:

  • 每处理完一个 fiber,React 都有机会停下来
  • 当前位置就是一个 fiber 指针
  • 这就是“可中断 + 可恢复”的基础

6. 双缓存(Double Buffering):current 与 workInProgress 到底是什么?

这是 Fiber 最容易把人绕晕的地方,我们用一句话切开:

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

  • current:当前屏幕正在展示的那棵(已提交、稳定)
  • workInProgress:正在计算中的那棵(可能被中断、丢弃、重算)

6.1 为什么需要两棵?

因为 React 想保证:

  • 屏幕上展示的 UI 永远基于一棵“完整、可用”的树
  • 即使新的更新计算到一半被打断,也不会污染当前 UI 的一致性

所以 React 在后台“搭建” workInProgress:

  • 算完并通过检查后,一次性 commit
  • commit 完成后,让 workInProgress 变成新的 current(交换指针)

6.2 这带来的能力

  • render 阶段可以被中断、重启、合并更新
  • current 始终稳定
  • 并发更新时可以做“尝试性渲染”(后续讲时间分片/并发会更明显)

7. 一个直观类比:搭舞台与换景

把 current / workInProgress 类比成舞台:

  • current:观众正在看的舞台布景
  • workInProgress:后台正在搭的新布景

后台可以慢慢搭:

  • 搭一半停一下去处理别的事
  • 搭错了推倒重来
  • 直到准备好,才在某个瞬间“换景”(commit)

这个“换景”必须很快,所以 commit 阶段才强调“短、确定、不可中断”。


8. 本文小结:你应该牢牢记住的 5 件事

  1. Fiber 的动机是可调度:可中断、可恢复、可优先级
  2. 旧模型卡顿的根因是:递归调用栈不可中断,主线程被占满
  3. Fiber 把遍历从“隐式栈”变成“显式指针结构”(child/sibling/return)
  4. Fiber 节点不仅是 UI 描述,更是运行时工作单元 + 缓存 + 副作用标记
  5. 双缓存让 current 稳定展示,workInProgress 在后台计算,完成后再切换

留言讨论

Discussion

欢迎交流与反馈