从栈到链表: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
这个过程如果用“调用栈”表达,像这样:
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:这次更新要用的 propsmemoizedProps:上次完成后记住的 propsmemoizedState:hook 链表/类组件 state 的缓存updateQueue:更新队列(setState/useState 的更新会进来)
这些字段让 React 可以:
- 复用上次计算结果
- 判断是否可以跳过
- 在中断后继续
4.3 副作用标记(告诉 commit 阶段要做什么)
常见概念:
flags:这个节点需要执行哪些 DOM 操作(插入/更新/删除等)subtreeFlags:子树里累计的 flags(便于快速跳过无副作用子树)
这意味着 render 阶段主要做两件事:
- 算出新的结构(以及要不要复用)
- 把“需要改什么”记录在 flags 上
commit 阶段再去真正动 DOM。
5. Fiber 如何实现“可中断”?——把渲染拆成 work loop
你不需要记内部函数名,但要有流程感。
Fiber 的 render 阶段更像:
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 件事
- Fiber 的动机是可调度:可中断、可恢复、可优先级
- 旧模型卡顿的根因是:递归调用栈不可中断,主线程被占满
- Fiber 把遍历从“隐式栈”变成“显式指针结构”(child/sibling/return)
- Fiber 节点不仅是 UI 描述,更是运行时工作单元 + 缓存 + 副作用标记
- 双缓存让
current稳定展示,workInProgress在后台计算,完成后再切换
留言讨论
Discussion
欢迎交流与反馈