Fiber 是 React 16 重写的渲染引擎——把递归变成链表,把”一口气跑完”变成”跑一段歇一段”,让渲染可以被中断、恢复、按优先级调度。
一、为什么需要 Fiber:Stack Reconciler 的困境
1.1 React 15 的渲染方式:递归到底
React 15 用的是 Stack Reconciler——名字就说明了问题:它用递归(调用栈)遍历组件树,一旦开始就停不下来。
setState()
→ 递归遍历整棵组件树
→ 对每个组件调用 render()
→ Diff 新旧 Virtual DOM
→ 计算出所有 DOM 操作
→ 一次性全部执行
这个过程是同步的、不可中断的。就像你走进一条没有出口的隧道——进去就得走到头,中途不能停下来。
1.2 问题:长任务阻塞主线程
浏览器的主线程同时负责 JS 执行和页面渲染。如果 React 的递归遍历耗时超过一帧(约 16ms),浏览器就没时间做渲染和响应用户操作——页面就卡了。
主线程时间线(React 15):
|--- 递归 Reconcile(50ms)---------------------------------------|
| |
| 没时间渲染 | 没时间渲染 | 没时间渲染 | 终于完了 | 渲染一帧 |
| 掉帧 ❌ | 掉帧 ❌ | 掉帧 ❌ | | |
❌ 用户输入没响应(事件处理被阻塞) ❌ 动画掉帧(浏览器没时间渲染) ❌ 页面看起来”冻住”了
1.3 根本原因:递归不可中断
递归调用的特点是——调用栈一压进去,就必须等它弹出来。你没法在递归到一半时说”暂停,我先处理别的事”。
| Stack Reconciler(React 15) | Fiber Reconciler(React 16+) | |
|---|---|---|
| 遍历方式 | 递归(调用栈) | 链表(可中断) |
| 可中断 | ❌ 一旦开始必须跑完 | ✅ 随时暂停,下次接着来 |
| 主线程占用 | 长时间独占 | 分片,每片 5ms |
| 优先级 | 无,先来先做 | 有,高优先级插队 |
Fiber 的核心目标就一个:让渲染过程可以被中断,把控制权还给浏览器,保证页面流畅。
二、Fiber 是什么:虚拟栈帧
2.1 从递归到链表
Fiber 的思路是:既然递归不可中断,那就不用递归。把递归拆成链表——每个节点是一个独立的工作单元,做完一个可以停下来,下次接着做下一个。
类比:Fiber 像可暂停的游戏存档——玩到一半可以保存退出,下次接着来。而递归像看一场不能暂停的电影——进了影院就得看到底。
2.2 每个 Fiber 节点就是一个”栈帧”
在递归模型里,每个组件的渲染信息存在调用栈的栈帧中——函数返回,栈帧就没了。Fiber 把这些信息从调用栈搬到了堆上,变成一个 JS 对象,叫做 Fiber 节点。
// 简化的 Fiber 节点结构
const fiber = {
// === 身份信息 ===
type: App, // 组件函数 / HTML 标签名
key: null, // 列表中的 key
props: { count: 1 }, // 传入的 props
// === 链表三指针(树结构) ===
return: parentFiber, // 父节点(为什么叫 return?因为"回到"调用者)
child: firstChildFiber, // 第一个子节点
sibling: nextFiber, // 下一个兄弟节点
// === 双缓冲 ===
alternate: currentFiber, // 指向另一棵树的对应节点
// === 副作用标记 ===
effectTag: "Placement", // 标记需要什么 DOM 操作(插入/更新/删除)
// === 状态 ===
memoizedState: hookList, // Hook 链表(函数组件的状态)
pendingProps: {}, // 待处理的新 props
};
2.3 链表三指针:child、sibling、return
这三个指针把树形结构变成了链表,让遍历可以一步步走,不用递归:
App (return: null)
/
div (return: App)
/ \
header main (sibling: header, return: div)
| |
h1 p (sibling: h1, return: header/main)
遍历顺序:App → div → header → h1 → main → p
// Fiber 树的遍历算法(简化版)
function workLoop() {
while (currentFiber && !shouldYield()) {
// 对当前 Fiber 执行工作
currentFiber = performUnitOfWork(currentFiber);
}
// shouldYield() 返回 true → 时间片用完,暂停
// 下次从 currentFiber 继续就行
}
function performUnitOfWork(fiber) {
// 1. 处理当前节点(执行组件函数 / Diff)
// 2. 返回下一个要处理的节点
if (fiber.child) return fiber.child; // 优先子节点
let next = fiber;
while (next) {
if (next.sibling) return next.sibling; // 其次兄弟节点
next = next.return; // 回到父节点
}
return null; // 遍历完了
}
关键洞察:递归用调用栈维护”我在哪”,Fiber 用链表指针维护”我在哪”。链表的好处是——你可以随时停下来,因为”我在哪”存在对象里,不在栈帧里。
三、双缓冲机制:current 树 ↔ workInProgress 树
3.1 为什么需要两棵树
React 同时维护两棵 Fiber 树:
| 树 | 含义 | 什么时候变 |
|---|---|---|
| current 树 | 当前屏幕上显示的 UI | Commit 阶段切换后更新 |
| workInProgress 树 | 正在内存中构建的新 UI | Render 阶段逐步构建 |
类比:双缓冲像画家在后台画——观众看到的是已完成的画(current),画家在画布背面画新的(workInProgress),画完了才翻过来展示。观众永远不会看到画到一半的画。
3.2 alternate 指针:两棵树的桥梁
每个 Fiber 节点通过 alternate 指向另一棵树的对应节点:
// current 树上的节点
currentFiber.alternate === workInProgressFiber;
// workInProgress 树上的节点
workInProgressFiber.alternate === currentFiber;
构建 workInProgress 树时,React 不是从零创建所有节点——它复用 current 树的节点,只创建新的或修改有变化的:
// 简化的复用逻辑
function createWorkInProgress(current, pendingProps) {
let workInProgress = current.alternate;
if (workInProgress) {
// 已有 alternate → 复用,更新 props
workInProgress.pendingProps = pendingProps;
} else {
// 没有 alternate → 新建
workInProgress = createFiber(current.type, pendingProps, current.key);
workInProgress.alternate = current;
current.alternate = workInProgress;
}
// 复制其他属性...
return workInProgress;
}
3.3 切换:一根指针的翻转
Render 阶段结束后,workInProgress 树构建完毕。Commit 阶段做的事很简单——把 FiberRoot 的 current 指针从旧树切到新树:
// Commit 阶段最后一步
root.current = workInProgressTree;
// 就这一行——屏幕上的 UI 就"瞬间"更新了
Render 阶段结束前:
root.current → currentTree(屏幕上显示的)
workInProgressTree(内存中构建好的)
Commit 阶段:
root.current → workInProgressTree(切换!)
下次渲染时:
旧的 currentTree 变成新的 workInProgressTree(角色互换)
双缓冲保证了用户永远不会看到半成品 UI——要么是旧的完整画面,要么是新的完整画面,没有中间态。
四、渲染两阶段:Render(可中断)与 Commit(不可中断)
4.1 Render 阶段:纯计算,可中断
Render 阶段做的事:遍历 Fiber 树,执行组件函数,Diff 新旧子树,给变化的节点打 effectTag。
// Render 阶段的核心工作(简化)
function renderPhase(fiber) {
// 1. 执行组件函数,拿到新的 JSX
const newChildren = fiber.type(fiber.pendingProps);
// 2. Diff:对比新旧子 Fiber,决定哪些要更新
reconcileChildren(fiber, newChildren);
// 3. 给有变化的子 Fiber 打 effectTag
// Placement = 插入新节点
// Update = 更新已有节点
// Deletion = 删除旧节点
}
这个阶段的特点:
| 特性 | 说明 |
|---|---|
| 纯计算 | 不操作真实 DOM,不产生可见副作用 |
| 可中断 | 时间片用完就暂停,下次接着来 |
| 可丢弃 | 如果有更高优先级更新进来,当前计算可以作废重来 |
| 可并发 | 多个更新可以交替进行 |
因为 Render 阶段不碰 DOM,所以中断、重做都没关系——反正用户看不到。
4.2 Commit 阶段:操作 DOM,不可中断
Commit 阶段做的事:遍历 effectList(所有打了 effectTag 的 Fiber),执行真实的 DOM 操作。
// Commit 阶段的核心工作(简化)
function commitPhase(root) {
const effectList = root.firstEffect; // 收集好的副作用链表
// 遍历 effectList,执行 DOM 操作
let effect = effectList;
while (effect) {
switch (effect.effectTag) {
case "Placement":
parentNode.appendChild(effect.stateNode); // 插入
break;
case "Update":
updateDOMProperties(effect.stateNode, effect); // 更新属性
break;
case "Deletion":
parentNode.removeChild(effect.stateNode); // 删除
break;
}
effect = effect.nextEffect;
}
// 最后:切换 current 指针
root.current = finishedWork;
}
这个阶段的特点:
| 特性 | 说明 |
|---|---|
| 操作 DOM | 产生可见的副作用 |
| 不可中断 | 必须一口气做完,保证 DOM 一致性 |
| 同步执行 | 一旦开始,直到所有 DOM 操作完成 |
为什么 Commit 不能中断?想象一下:插入了一个新节点,还没来得及删除旧节点就停了——用户看到新旧节点同时存在,UI 就不一致了。
4.3 两阶段对比
| Render 阶段 | Commit 阶段 | |
|---|---|---|
| 做什么 | 执行组件函数、Diff、打标记 | 操作真实 DOM |
| 可中断 | ✅ 可以 | ❌ 不可以 |
| 有副作用 | ❌ 纯计算 | ✅ 操作 DOM |
| 执行时机 | 时间片内,可分多次 | 一次性同步完成 |
| 类比 | 在后台画新画 | 把新画翻到前面展示 |
五、调度机制:时间切片与优先级
5.1 时间切片(Time Slicing)
Fiber 的调度核心是 MessageChannel + shouldYield:
// 简化的调度循环
function workLoopConcurrent() {
while (workInProgress && !shouldYield()) {
// shouldYield() 检查:当前时间片(5ms)用完了吗?
workInProgress = performUnitOfWork(workInProgress);
}
if (workInProgress) {
// 时间片用完但还没做完 → 安排下一个时间片继续
scheduleCallback(continueWork);
}
// 如果做完了 → 进入 Commit 阶段
}
时间切片的工作方式:
主线程时间线(Fiber):
|-- 5ms React --|-- 浏览器渲染 --|-- 5ms React --|-- 浏览器渲染 --|
做了一部分 页面流畅 ✅ 接着做 页面流畅 ✅
React 用 MessageChannel 而不是 setTimeout 来调度下一个时间片——因为 MessageChannel 的优先级比 setTimeout(0) 更高,能更快拿到控制权。
5ms 不是硬编码的魔法数字——它是经验值,保证浏览器有足够时间渲染(16ms 一帧 - 5ms React ≈ 11ms 给浏览器)。
5.2 优先级调度(Lane Model)
不是所有更新都一样急。用户打字输入要立刻响应,数据获取可以慢一点。React 用 Lane Model 给更新分优先级:
| 优先级 | Lane | 场景 | 特点 |
|---|---|---|---|
| 同步 | SyncLane | flushSync 包裹的更新 | 立即执行,不可中断 |
| 用户输入 | InputContinuousLane | 输入框打字、拖拽 | 高优先级,尽快执行 |
| 默认 | DefaultLane | setState 普通更新 | 正常优先级 |
| 过渡 | TransitionLane | useTransition / startTransition | 低优先级,可被中断 |
| 空闲 | IdleLane | 离屏渲染、预加载 | 最低优先级,有空再做 |
// 优先级调度的实际效果
function SearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
function handleChange(e) {
// 高优先级:用户打字,必须立刻响应
setQuery(e.target.value);
// 低优先级:搜索结果可以慢慢来
startTransition(() => {
setResults(search(e.target.value));
});
}
}
高优先级更新可以打断低优先级更新——用户打字时,搜索结果的渲染会被暂停,等输入处理完再继续。
Lane Model 用二进制位表示优先级(每个 Lane 是一个 bit),这样可以用位运算快速判断优先级关系。但作为使用者,你只需要理解”不同更新有不同优先级,高优先级可以打断低优先级”就够了。
5.3 可中断 + 可恢复
Fiber 架构让渲染过程可以暂停和恢复:
场景:正在渲染一个低优先级更新(搜索结果),用户突然打字
1. Render 阶段进行中(低优先级)
→ 处理到第 30 个 Fiber 节点
→ shouldYield() 返回 true(有高优先级更新进来)
2. 暂停当前渲染
→ 保存当前 workInProgress 指针
→ 开始处理高优先级更新(用户输入)
3. 高优先级更新完成
→ Commit 阶段执行,DOM 更新
4. 恢复低优先级渲染
→ 从第 30 个 Fiber 节点继续
→ 但如果低优先级更新的数据已经过期,直接丢弃重来
关键:workInProgress 树构建到一半可以暂停,因为进度存在 Fiber 对象里(链表指针),不在调用栈里。这就是 Fiber 把递归变链表的核心价值。
六、从 setState 到屏幕更新的完整流程
把前面所有知识串起来,看一次状态更新从头到尾经历了什么:
setState(newValue)
│
▼
① 创建 Update 对象
│ 包含:新值、优先级(lane)、回调
│
▼
② 入队到 Fiber 的 updateQueue
│ 多个 setState 可能合并到同一个队列
│
▼
③ 调度:根据优先级安排执行时机
│ 同步 → 立即执行
│ 普通 → 放入调度队列,等时间片
│ 过渡 → 低优先级队列
│
▼
④ Render 阶段(可中断)
│ 从根 Fiber 开始遍历
│ ├─ 执行组件函数,拿到新 JSX
│ ├─ Diff 新旧子 Fiber
│ ├─ 打 effectTag
│ ├─ 收集 effectList
│ └─ 时间片用完 → 暂停,下次继续
│
▼
⑤ Commit 阶段(不可中断)
│ 遍历 effectList
│ ├─ 操作真实 DOM(插入/更新/删除)
│ ├─ 执行 useEffect / useLayoutEffect
│ └─ 切换 current 指针
│
▼
⑥ 浏览器绘制
│ 浏览器拿到新的 DOM → 计算布局 → 绘制像素
│
▼
屏幕更新 ✅
6.1 批量更新
React 会自动批量处理状态更新——同一个事件处理函数里的多次 setState 只触发一次渲染:
function handleClick() {
setCount(1); // 不会立刻渲染
setName("yi"); // 不会立刻渲染
setOpen(true); // 不会立刻渲染
// 三个更新合并成一次渲染
}
React 18 之前,批量更新只在 React 事件处理中生效;React 18 之后,setTimeout、Promise、原生事件中的 setState 也会自动批量处理。
6.2 与 声明式与React的实现机制 的关系
在 声明式与React的实现机制 里,我们讲了 Diff 算法”算什么”——对比新旧 Virtual DOM,找出最小 DOM 操作。这篇讲的是”怎么算”——React 用什么架构来执行这个计算:
| 维度 | 声明式与React的实现机制 | 本篇 |
|---|---|---|
| 关注点 | Diff 算法的规则 | Diff 的执行架构 |
| 核心问题 | “哪些节点变了” | “怎么高效地算出哪些节点变了” |
| 关键概念 | Virtual DOM、Reconciliation | Fiber、双缓冲、时间切片 |
| 类比 | 裁缝的量尺(量出哪里要改) | 裁缝的工作台(怎么安排改衣服的流程) |
声明式那篇的链条是:setState → 重新渲染 → Diff → 更新 DOM。本篇展开的是中间两步——“重新渲染”和”Diff”具体怎么执行的。
七、Fiber 与 React 18 并发特性
Fiber 是 React 18 并发特性的基础设施。没有 Fiber 的可中断渲染,这些特性都不可能实现:
| React 18 特性 | 依赖 Fiber 的什么能力 |
|---|---|
startTransition / useTransition | 低优先级更新可被高优先级打断 |
useDeferredValue | 延迟渲染,让位给更紧急的更新 |
| Suspense 的流式 SSR | 服务端可以中断/恢复 HTML 流的发送 |
useId | 双缓冲两棵树需要稳定的 ID 生成 |
| 批量更新(自动批处理) | 调度器可以合并同一优先级的更新 |
Fiber 不是并发特性本身,而是让并发成为可能的底层架构。就像高速公路不是”快”本身,而是让”快”成为可能的基础设施。
八、一句话总结
Fiber 把递归变链表,让渲染可中断;双缓冲保证用户永远看不到半成品;时间切片让主线程不被独占;优先级调度让紧急更新先执行。 这四件事合在一起,就是 React 从”一口气跑完”到”跑一段歇一段”的架构升级。