
一句话: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 的语义
九、面试延伸
- 「Fiber 是什么?为什么需要它?」
Fiber 是 React 16 引入的可中断的渲染架构。它把「整棵树的渲染」拆成一个个「工作单元(Fiber 节点)」,用链表指针(return/child/sibling)替代递归,因此可以在处理完一个单元后暂停(检查是否有更高优先级的任务)、之后从记录的位置恢复。需要它的原因是:React 15 的递归 reconciler 一旦开始就跑完,遇到大列表/深树会阻塞主线程几百毫秒,导致输入延迟与掉帧。 -
「Fiber 节点里有哪些关键字段?」
四类:① 组件信息(tag/type/stateNode——宿主组件指向真实 DOM,类组件指向实例);② 树结构指针(return父、child长子、sibling兄弟——用它们实现「无递归遍历」);③ 更新信息(pendingProps/memoizedProps/memoizedState/updateQueue——Hooks 链表就挂在memoizedState上);④ 调度信息(alternate双缓存、flags副作用标记、lanes/childLanes优先级)。 -
「为什么用链表遍历替代递归?」
因为递归无法在中间暂停——JS 的调用栈一旦进入递归,就只能一路执行到底(除非抛异常)。而链表遍历是「一个循环 + 当前节点指针」:每一步结束都能保存nextUnitOfWork并return(让出主线程),下一帧再从这个指针继续。这是「可中断渲染」的必要前提,也是 Fiber 这个「链式结构」的核心设计目的。 -
「什么是双缓存树?」
React 在内存中维护两棵 Fiber 树:current(当前屏幕上显示的)与workInProgress(正在构建的)。渲染时基于current创建/复用workInProgress的节点,两个节点通过alternate互相指向;工作完成后,只需把root.current指向新的树(一次指针切换)就完成了「提交」。好处是:① 用户永远看到一致的一棵树;② 渲染中途被打断时可以直接丢弃workInProgress而不影响屏幕。 -
「render 阶段和 commit 阶段分别做什么?为什么 render 必须纯?」
render 阶段:遍历 Fiber 树、执行组件函数、diff 出变化、收集副作用标记——可中断、可丢弃、可重放;commit 阶段:把副作用一次性应用到 DOM(mutation → layout),并触发 effect,不可中断。render 必须纯的原因正是「它可能被中断后重放或整个丢弃」——如果里面有副作用(请求、改外部变量、随机值),就会重复执行或产生不一致的结果。 -
「时间切片是怎么实现的?」
React 用独立的 Scheduler 包做调度:给一次工作分配一个时间片(约 5ms),每处理完一个 Fiber 单元就检查「是否用完时间片或出现更高优先级任务」,是则让出主线程(用MessageChannel调度下一次继续),让浏览器处理输入/动画/绘制,下一帧再继续。关键点:被中断的是 render 阶段;commit 阶段永远一次做完(否则界面会出现中间态)。 -
「Lane 模型是什么?为什么要用位掩码?」
Lane 是 React 18 的优先级模型——用一个 32 位整数的不同位表示不同优先级(SyncLane、InputContinuousLane、DefaultLane、TransitionLanes、IdleLane)。用位掩码的原因是:① 一个 fiber 上可能同时有多个待处理更新,用「按位或」就能把它们合并进一个整数;② 判断「是否包含某类优先级」只需位运算(极快);③ 能表达「一批车道」的概念(如所有 transition 车道)。此外childLanes让 React 能跳过整棵没有更新的子树。 -
「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 让出主线程,主要收益是「响应性」而不是「吞吐量」。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!