Day14 把 TypeScript 工程讲完了,阶段三正式进入框架内核。React 近几年的所有大事:Concurrent Mode、Suspense、useTransition、React 19 的 use(),根都在同一个地方:Fiber 架构。今天把它彻底拆开。
1. 技术难点:同步递归渲染为什么非改不可
React 16 之前(以及 Vue 至今的大部分更新),渲染一棵组件树用的是朴素递归:render() 返回 vdom,协调器对比新旧 vdom 后同步改 DOM。在中小项目里它简单可靠,但有一个致命结构缺陷:
- 递归一旦开始就无法停下。React 的函数组件天然是树形递归,一个组件渲染完才能轮到下一个。整棵树的协调工作挤在一个调用栈里同步跑完,中途没有任何"检查点"。当一次更新涉及几千个组件时,主线程被独占几十甚至上百毫秒,期间用户的输入、滚动、动画全部排队等待。浏览器一帧只有约 16.6ms,超过这个预算就会掉帧、卡输入。
- 隐式调用栈没有"存档"能力。递归的状态全压在函数栈帧里,那是运行时私有数据,框架够不着。想"做一半暂停,让高优先级任务插队,再回来接着做",就必须能回答一个问题:下一次该从哪个组件继续。调用栈给不出这个答案,它只能一路弹完。
- 副作用不能中途发生。如果协调过程中边对比边改 DOM,一旦被中断,页面上就会残留半新半旧的状态,用户看到撕裂的界面。所以任何"可中断"方案的前提,是把副作用全部推迟到最后一口气做完。
这三点合起来指向一个结论:渲染必须从"一次函数调用"变成"可调度的工作队列"。而队列里的每一项得是一个能独立暂停、能被记住、能被丢弃重来的最小工作单元,这就是 Fiber。
2. 完整解法:Fiber 架构四件套
2.1 把调用栈摊平成链表:FiberNode
Fiber 的本质,是把递归调用栈显式化为一个可持久化的数据结构。每个组件对应一个 Fiber 节点,节点之间不靠函数嵌套,而靠三个指针连接:
child:第一个子节点sibling:下一个兄弟节点return:父节点(指回去,遍历结束要能向上回溯)
一棵树因此变成了可随意进出、可存档恢复的链表结构。真实的 FiberNode 还挂着 type/key(对应 vdom 元素)、pendingProps/memoizedProps(新旧 props)、stateNode(组件实例或真实 DOM)、alternate(双缓存对端)、lanes(优先级位掩码)、flags(副作用标记)。
2.2 双缓存树:用户永远看不到"半成品"
React 同时维护两棵树:current(已经提交、正在屏幕上展示的树)和 workInProgress(在后台静默构建的新树),通过 alternate 互指。协调阶段只在 workInProgress 上施工,屏幕上的一切不受打扰;整棵树构建完毕才做一次指针切换,current 整体替换。这就是难点 3 的解法:要么不动,要么一次到位,中间态永远对用户不可见,丢弃半成品树也毫无心理负担。
2.3 render 阶段:DFS 工作循环
render 阶段从根出发,逐个处理工作单元,顺序是标准深度优先:
beginWork:处理当前 Fiber(按需创建 DOM、调和 children 生成子 Fiber)- 有
child就下钻;没有就completeWork收尾当前节点 - 向右走
sibling;兄弟走完沿return向上找下一个带兄弟的祖先
主循环每次只调用 performUnitOfWork 处理一个单元,处理完把"下一个单元"存进全局 nextUnitOfWork。这个变量就是整个架构的断点存档:无论何时被中断,下次回来从它接着走即可,不需要重建任何调用栈。
2.4 时间切片 + 优先级抢占:并发的地基
Scheduler 用宏任务(MessageChannel,不用 requestIdleCallback,兼容性差且有 50ms 调用上限问题)驱动工作循环,每个宏任务内设置一个 deadline(约 5ms 预算),workLoop 每处理完一个单元就检查是否超时:
while (nextUnitOfWork && !shouldYield()) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
超时就主动让出主线程,浏览器得以渲染一帧、响应输入,下个宏任务再回来继续。同时每个更新按 lane 位掩码标记优先级:离散事件(输入、点击)对应最高优先级,startTransition 包住的更新是低优先级。高优先级任务到达时,直接丢弃正在构建的低优先级 workInProgress 树,低优先级任务转入重试队列稍后再来。render 阶段是纯函数、无副作用,所以丢弃永远安全。被中断、被抢占、被重建,用户看到的始终是完整一致的旧树,直到新树 commit。
2.5 commit 阶段:不可中断的"最后一口气"
所有真实副作用(DOM 插入删除、useLayoutEffect、useEffect)都推迟到 commit 阶段,按 mutation → layout → passive 三段同步执行,绝不切片。因为 DOM 一旦开始改动,中断就会留下中间态,commit 必须一口气跑完。所以 Fiber 的全部灵活性都在 render 阶段,commit 阶段是"牺牲灵活性换原子性"。
2.6 可运行的最小实现
下面这段代码把上述骨架浓缩成约百行,Node 直接可跑。它演示低优先级大列表渲染过程中,高优先级输入事件到达时的完整行为:时间片让出 → 抢占 → 丢弃低优先级树 → 高优先级 commit → 低优先级重试。代码注释标出了与真实 React 的对应关系。
// mini-fiber.mjs —— 最小可运行 Fiber 协调器(Node 直接跑)
// 运行: node mini-fiber.mjs
// ---------- 1. 超轻量 DOM 桩:只记录挂载顺序,便于观察 commit ----------
const createEl = (tag) => ({ tag, children: [] });
const createText = (text) => ({ tag: '#text', text });
// ---------- 2. vdom 工厂(不依赖 JSX) ----------
const h = (type, props, ...children) => ({
type,
props: props ?? {},
// 字符串子节点统一规范化为 TEXT 节点,与 JSX 文本子节点行为一致
children: children.flat().map((c) =>
typeof c === 'string' || typeof c === 'number'
? { type: 'TEXT', props: { nodeValue: String(c) }, children: [] }
: c,
),
});
// ---------- 3. Fiber 与全局状态 ----------
let wipRoot = null; // 正在构建的 workInProgress 树根(双缓存的后台树)
let nextUnit = null; // 下一个工作单元 = 中断恢复点(对应 nextUnitOfWork)
let epoch = 0; // 渲染代数:抢占重建时自增,旧单元据此作废
function createFiber(vnode, parent) {
return { vnode, parent, dom: null, child: null, sibling: null };
}
function createDom(fiber) {
const { type, props } = fiber.vnode;
if (type === 'TEXT') return (fiber.dom = createText(props.nodeValue));
fiber.dom = createEl(type);
for (const k in props) if (k !== 'nodeValue') fiber.dom[k] = props[k];
}
// beginWork:建 DOM + 把 children 串成 child/sibling 链表
function beginWork(fiber) {
if (fiber.vnode.type !== 'ROOT') createDom(fiber);
if (fiber.vnode.type === 'TEXT') return;
let prev = null;
for (const c of fiber.vnode.children) {
const f = createFiber(c, fiber);
if (!prev) fiber.child = f;
else prev.sibling = f;
prev = f;
}
}
// 深度优先取下一个单元:有 child 下钻;否则向右找 sibling;再向上找带 sibling 的祖先
function nextWorkUnit(fiber) {
if (fiber.child) return fiber.child;
let n = fiber;
while (n) {
if (n.sibling) return n.sibling;
n = n.parent;
}
return null;
}
const slow = (ms) => { const t = Date.now() + ms; while (Date.now() < t) {} };
function perform(fiber) {
beginWork(fiber);
slow(0.5); // 模拟复杂组件计算量,让时间片真实耗尽
return nextWorkUnit(fiber);
}
// commit:一次性把整棵新树挂到容器。对应真实 React 的 commit 阶段:不可中断
function commit(fiber, container) {
if (!fiber) return;
if (fiber.dom && fiber.parent?.dom) fiber.parent.dom.children.push(fiber.dom);
commit(fiber.child, container);
commit(fiber.sibling, container);
}
// ---------- 4. 调度器:时间切片 + 优先级抢占 ----------
const SLICE_MS = 3; // 一个时间片的预算,耗尽就让出主线程
let renderLabel = '';
let pendingHigh = null; // 抢占注入点(模拟高优先级事件到达)
let retryQueue = []; // 被抢占的低优先级任务,稍后重试(对应 transition 语义)
let sliceCounter = 0; // 日志节流
function startRender(vnode, container, label, fromScrap) {
epoch++;
const root = createFiber({ type: 'ROOT', props: {}, children: [vnode] }, null);
root.dom = container;
wipRoot = root;
nextUnit = root;
renderLabel = label;
console.log(`[${label}] ${fromScrap ? '重建(epoch=' + epoch + ')' : '开始渲染(epoch=' + epoch + ')'} 队列中有 ${countNodes(vnode)} 个节点`);
workLoop();
}
function workLoop() {
const deadline = Date.now() + SLICE_MS;
let done = 0;
while (nextUnit && Date.now() < deadline) { // shouldYield() 的雏形
nextUnit = perform(nextUnit);
done++;
}
if (!nextUnit) {
commit(wipRoot.child); // 树建完,一次性提交,切换 current 树
console.log(`[${renderLabel}] commit 完成,容器内节点: ${container.children.map((c) => c.tag).join(' -> ')}`);
wipRoot = null;
if (retryQueue.length) { // 高优先级提交后,让被抢占的低优先级任务重试
const [v, c, l] = retryQueue.shift();
startRender(v, c, l, true);
} else if (pendingHigh) {
const [v, c, l] = pendingHigh;
pendingHigh = null;
startRender(v, c, l, false);
}
return;
}
if (++sliceCounter % 5 === 0) {
console.log(`[${renderLabel}] 时间片耗尽(已处理 ${done} 单元),让出主线程,等待下个宏任务`);
}
if (pendingHigh) {
// 抢占:丢弃当前 workInProgress 树(render 无副作用,丢弃安全),任务转入重试队列
console.log(` >> 高优先级到达!丢弃低优先级 workInProgress 树,任务转入重试队列`);
retryQueue.push([wipRoot.vnode.children[0], wipRoot.dom, renderLabel]);
const [v, c, l] = pendingHigh;
pendingHigh = null;
startRender(v, c, l, false); // 高优先级立即重建并优先 commit
} else {
setTimeout(workLoop, 0); // 让出主线程,下个宏任务回来继续
}
}
const countNodes = (v) =>
v.type === 'TEXT' ? 1 : 1 + v.children.reduce((s, c) => s + countNodes(c), 0);
// ---------- 5. 场景演示 ----------
// 低优先级:渲染 300 项大列表(901 个 fiber 单元,必然跨多个时间片)
const bigList = h('ul', null,
...Array.from({ length: 300 }, (_, i) => h('li', { 'data-i': i }, h('span', null, 'item ' + i))));
// 高优先级:一个输入框小组件(离散事件对应最高优先级 lane)
const inputView = h('div', null, h('input', { type: 'text' }), h('span', null, '已输入: '));
const container = createEl('root');
console.log('=== 场景:低优先级大列表渲染中,用户输入事件(高优先级)到达 ===\n');
setTimeout(() => { pendingHigh = [inputView, container, '高:输入框']; }, 60);
startRender(bigList, container, '低:大列表', false);
实际运行的关键输出(已节流):
[低:大列表] 开始渲染(epoch=1) 队列中有 901 个节点
[低:大列表] 时间片耗尽(已处理 2 单元),让出主线程,等待下个宏任务
...(反复让出)...
>> 高优先级到达!丢弃低优先级 workInProgress 树,任务转入重试队列
[高:输入框] 开始渲染(epoch=2) 队列中有 4 个节点
[高:输入框] commit 完成,容器内节点: div
[低:大列表] 重建(epoch=3) 队列中有 901 个节点
[低:大列表] commit 完成,容器内节点: div -> ul
注意两个细节:低优先级任务从未"做一半就提交",高优先级 commit 后容器里只有 div(输入框),随后低优先级重建(epoch 3)并整体提交,容器从 div 干净地变成 div -> ul。中间的每次让出和抢占,用户都感知不到任何撕裂。
3. 应用场景
- 大列表输入卡顿:表格几千行、搜索框每敲一个字触发一次全表重渲染,同步渲染必然丢帧。用 Fiber 的优先级体系,输入更新走最高 lane 直接 commit,列表重渲染降级为可抢占的普通更新,甚至包进
useDeferredValue/startTransition让它在空闲时间片里慢慢做完。这是并发特性对业务最直接的收益。 - Suspense 与数据加载:组件抛出一个 Promise,Fiber 把该分支标记为挂起并中断构建,Promise resolve 后重新调度该分支。Suspense 能"等一等再渲染"的机制与抢占共用同一套可中断渲染地基,React 19 的
use()让这个模型从lazy扩展到任意异步资源。 - 保持交互响应性的代价模型:写 React 时要区分"急事"和"不急的事"。状态更新默认可能被中断,副作用必须放进 effect(commit 阶段),render 里永远不做有副作用的事。这个心智模型一旦建立,
useTransition的返回值(isPending)和useDeferredValue就都是顺理成章的工具。 - 与 Vue 3 的对比:Vue 用细粒度响应式把更新范围缩到最小,再靠微任务批量同步提交,大部分更新根本不需要中断;React 粒度粗(整树协调),但用可中断换取"永远不让用户等"。两者一个省事、一个让路,是两种工程哲学,不是简单的谁快谁慢。
4. 总结
Fiber 架构只回答了一个问题:怎么让渲染可以随时停下、记住进度、被人插队、还能安全重来。答案是放弃函数调用栈的便利,把工作显式地做成数据结构:链表化的 fiber 树提供断点,双缓存保证原子可见,时间切片把大任务拆进帧间隙,lane 优先级让插队有秩序,commit 阶段用"不可中断"换"永不撕裂"。React 18 的并发、Suspense、过渡更新,React 19 的 Actions,全部是这套地基上的应用层。看任何 React 源码或报错栈时记住一句话:看到 beginWork 是它在往下钻,看到 completeUnitOfWork 是它在回溯,看到 "render was interrupted" 是它被更高优先级的人插了队。这套"递归变循环、调用栈变显式数据"的思路,离开 React 也通用,协程、redux-saga、甚至操作系统的进程调度,都是同一个故事。