Fiber 架构
问题
什么是 React Fiber?为什么需要 Fiber 架构?什么是时间切片?
什么是 React Fiber? Fiber 是 React 16 重写的渲染调度系统。它做了三件事:
- 把组件树从「递归调用栈」改成「Fiber 节点链表」(child/sibling/return 三个指针),让遍历可以用循环实现,从而可中断、可恢复。
- 维护两棵 Fiber 树(current 和 workInProgress)做双缓存,渲染时在内存里改新树,提交时一次性切换,避免中间状态被用户看到。
- 给每个 Fiber 节点打上
lanes(优先级),高优任务可以打断低优任务。
为什么需要 Fiber 架构?
- React 15 的协调器是同步递归的,组件树一大就把主线程占满几十甚至上百毫秒,期间用户点击、输入、动画全都卡死。
- 根本原因是 JS 单线程 + 调用栈一旦开始无法中断。
- Fiber 把渲染拆成可中断的小任务,每处理一个节点就检查是否要让出主线程,让浏览器有机会响应交互、刷新动画,从而解决卡顿问题,也为并发模式(Suspense、Transition 等)打下基础。
什么是时间切片(Time Slicing)?
- 时间切片就是「每处理几个 Fiber 节点就停下来看看时间」:默认每片 5ms 左右,超时就调用
shouldYield让出主线程。 - 让出后 React 通过 Scheduler(基于
MessageChannel,不是requestIdleCallback)排队,等浏览器处理完事件和绘制再继续。 - 效果是即使一次更新要算 100ms,也能拆成 20 段,中间穿插用户事件,整体不掉帧。这是
useTransition、useDeferredValue能让低优更新「不卡 UI」的底层基础。
一句话概括
Fiber 是 React 16 重写的"渲染调度系统",它把"一口气渲染完整棵组件树"拆成"一小块一小块地渲染",从而让浏览器有空隙处理用户交互,不再卡死。
理解 Fiber,只需要抓住三个关键词:可中断、有优先级、双缓存。下面用一个生活类比把它们串起来。
把 React 渲染想象成做一桌满汉全席:
- React 15:厨师必须一次性把所有菜做完才能上桌,期间客人按服务铃也没人理(因为厨师只有一个,正埋头做菜)。
- React 16+ (Fiber):厨师把每道菜拆成"切菜→炒制→装盘"等小步骤,每做完一步就抬头看看有没有客人按铃(用户交互),有就先去招呼客人,回来再接着做。
"每道菜的某个小步骤" = 一个 Fiber 节点(工作单元) "抬头看一眼" = shouldYield 检查 "客人按铃比做菜重要" = 优先级调度 "做菜过程中先在后厨摆好,全部做完再一次性端上桌" = 双缓存
为什么需要 Fiber?先看看老 React 的痛点
React 15:一条道走到黑的递归
React 15 的协调器(Stack Reconciler)是同步递归的——遇到一个组件就递归处理它的所有子组件,直到把整棵树处理完才停下来。
// React 15 协调过程的伪代码
function reconcile(element) {
doSomeWork(element); // 处理当前节点
for (const child of element.children) {
reconcile(child); // 递归处理子节点(无法中断!)
}
}
这就像俄罗斯套娃:你一旦打开外面那层,就必须把里面所有层全部打开,中途不能停。
带来的问题:
如果组件树有几千个节点,渲染可能耗时 100ms 以上。这期间:
- 用户点击没反应
- 输入框打字延迟
- 动画掉帧
- 页面"卡死"
根本原因:JS 是单线程的,递归调用栈一旦开始就停不下来,浏览器没有空隙去响应用户。
Fiber 的破局思路:把大任务切成小任务
Fiber 把"递归"改成了"循环 + 链表",每处理一个节点就主动"喘口气",让浏览器看看有没有更紧急的事要做。
接下来的所有内容,都是在解释这个"切片 + 喘气"是怎么实现的。
Fiber 到底是什么?三个层次
"Fiber"这个词在 React 里有三重含义,不要混淆:
| 当你听到"Fiber"时 | 它指的可能是 |
|---|---|
| Fiber 架构 | 整套新的可中断渲染机制(最广义) |
| Fiber 节点 | 一种数据结构,每个组件实例对应一个 |
| 工作单元 | 渲染时一次只处理一个 Fiber 节点,处理完就检查是否要让出主线程 |
后面两个其实是同一个东西的两个角度:Fiber 节点既是"数据"也是"任务单元"。
Fiber 节点长什么样
interface FiberNode {
// 静态数据结构(描述组件)
tag: WorkTag; // 组件类型(函数组件、类组件、原生标签等)
type: any; // 对应的 React 元素类型
key: string | null; // key 属性
// 链接其他 Fiber(形成树结构)
return: FiberNode | null; // 父节点
child: FiberNode | null; // 第一个子节点
sibling: FiberNode | null; // 下一个兄弟节点
// 动态工作单元(状态相关)
pendingProps: any; // 新的 props
memoizedProps: any; // 上次渲染的 props
memoizedState: any; // 上次渲染的 state(或 Hooks 链表)
// 副作用
flags: Flags; // 副作用标记(插入、更新、删除等)
subtreeFlags: Flags; // 子树的副作用标记
// 调度优先级
lanes: Lanes; // 优先级
childLanes: Lanes; // 子树的优先级
// 双缓存
alternate: FiberNode | null; // 指向另一棵树中对应的 Fiber
}
Fiber 树结构:为什么用链表,不用数组?
关键设计:Fiber 节点不是用数组存子节点,而是用三个指针:
| 指针 | 指向 |
|---|---|
child | 第一个子节点 |
sibling | 下一个兄弟节点 |
return | 父节点 |
为什么这样设计? 因为递归依赖调用栈,调用栈无法中断。而链表遍历可以用循环实现:
// 用循环代替递归,随时可以 break 出去
let current: FiberNode | null = root;
while (current !== null) {
if (shouldYield()) break; // 关键:随时能停!
current = doWorkAndGetNext(current);
}
// 下次回来时,current 还在原来的位置,从这里继续即可
这就是 Fiber 实现"可中断"的数据结构基础——把递归改写成可暂停的链表遍历。
时间切片:浏览器是怎么"喘气"的
基本思路
把"渲染整棵树"这个大任务,拆成"渲染一个 Fiber 节点"的小任务。每个小任务执行完,看一眼时间——如果占用主线程超过 5ms 了,就停手,等浏览器忙完了(处理完用户事件、绘制完页面)再继续。
// Fiber 工作循环的核心
function workLoopConcurrent(): void {
// 只要还有节点要处理,且没超时,就继续
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
// 超时了?把剩下的工作丢回调度器,下一轮再来
}
function shouldYield(): boolean {
return performance.now() >= deadline; // 超过 5ms 就让出
}
人眼感知的流畅帧率是 60fps,意味着每帧约 16.6ms。这 16.6ms 里浏览器还要做样式计算、布局、绘制等工作,留给 JS 的时间不多。React 取了一个保守值 5ms,确保即使 JS 占满这段时间,浏览器仍有充裕时间完成本帧渲染。
调度器是怎么"等浏览器忙完"的?
很多人第一反应是用 requestIdleCallback(浏览器空闲时执行)。但 React 没用,原因有三:
- 兼容性差:Safari 长期不支持
- 触发不稳定:浏览器忙的时候可能很久才触发一次
- 频率太低:最多 50ms 触发一次,远低于 16ms 一帧
React 自己实现了调度器(Scheduler),底层用 MessageChannel 触发宏任务:
// 简化版 React 调度器
const channel = new MessageChannel();
channel.port1.onmessage = () => {
const deadline = performance.now() + 5; // 5ms 时间片
// 在时间片内尽可能多地执行任务
while (taskQueue.length > 0 && performance.now() < deadline) {
taskQueue.shift()?.();
}
// 还没做完?发个消息给自己,下一个宏任务继续
if (taskQueue.length > 0) {
channel.port2.postMessage(null);
}
};
为什么用 MessageChannel 而不是 setTimeout? setTimeout 在嵌套调用时可能有 4ms 左右的最小延迟,而 MessageChannel 可以把任务更快地排到下一个宏任务。这样 React 每做完一段工作就能把控制权还给浏览器,让浏览器有机会在任务间隙处理输入、布局和绘制,再继续后面的渲染工作。
优先级调度:紧急的事先做
光会"喘气"还不够。如果用户正在快速打字,但 React 还在慢慢渲染一个大列表,渲染完才更新输入框,体验依然糟糕。所以 Fiber 引入了优先级——更紧急的更新可以打断正在进行的低优先级渲染。
React 18 用 Lanes(车道)模型表示优先级,每个 Lane 是一个二进制位:
| 优先级 | 典型场景 | 特点 |
|---|---|---|
| SyncLane(同步) | 点击、输入、聚焦 | 最高,立即执行不可中断 |
| InputContinuousLane | 拖拽、滚动 | 高频连续事件 |
| DefaultLane | 普通 setState | 默认优先级 |
| TransitionLane | useTransition 包裹的更新 | 低,可被打断 |
| IdleLane | 离屏预渲染 | 最低,空闲才做 |
实战例子:搜索框输入时,输入框本身要立即响应,但搜索结果列表渲染慢一点没关系:
const [keyword, setKeyword] = useState('');
const [results, setResults] = useState<string[]>([]);
function handleChange(e: ChangeEvent<HTMLInputElement>) {
// 高优先级:输入框立即更新(SyncLane)
setKeyword(e.target.value);
// 低优先级:搜索结果可以慢慢更新,可被下次输入打断
startTransition(() => {
setResults(heavySearch(e.target.value));
});
}
如果用户连续输入,每次新的输入会打断上一次还没渲染完的搜索结果,避免"输入卡顿"。
饥饿问题:低优先级会饿死吗?
理论上,如果一直有高优先级任务进来,低优先级任务可能永远轮不到。React 用过期时间兜底:每个 Lane 都有过期时间,到期后就强制提升为同步执行,确保不会饿死。
双缓存:渲染过程中你看到的依然是旧画面
一个问题:渲染中途被打断了,用户会看到啥?
既然渲染可以被中断,那么中断的瞬间——新树构建到一半、更新计划只算了一半——用户看到的会不会是错乱的画面?
不会。 因为 Fiber 用了双缓存机制:所有改动先在内存里偷偷做,做完了一次性切换。这就像电影院换片——不会让你看到换片过程,灯一暗一亮,新片就开始了。
两棵树的分工
| 树 | 角色 |
|---|---|
| current 树 | 当前屏幕上显示的内容对应的 Fiber 树("上映中的电影") |
| workInProgress 树 | 内存里正在构建的新树("后台准备的下一部片") |
每个 Fiber 节点有一个 alternate 指针,指向另一棵树里和自己对应的那个节点。这样复用就很方便——大部分节点没变化,直接拿过来改改属性就行,不用重新创建。
工作流程
关键收益:
- ✅ 渲染中途被打断 → 丢掉 workInProgress 树即可,屏幕上的 current 树毫发无损
- ✅ 渲染完成 → 一次性切换,用户看不到中间状态
- ✅ 节省内存 → 通过 alternate 复用节点,不必每次都创建新对象
Render 与 Commit:一个能中断,一个不能
这是 Fiber 最关键的设计之一。Render 阶段可中断、可重启(在内存里玩,没影响),Commit 阶段必须一气呵成(碰真实 DOM,不能玩到一半)。
| 阶段 | 做什么 | 能否中断 | 安全吗 |
|---|---|---|---|
| Render | 构建 workInProgress 树、Diff、标记副作用 | ✅ 能 | 只在内存里改 |
| Commit | 把副作用应用到真实 DOM、调用生命周期 | ❌ 不能 | 涉及真实 DOM,必须原子 |
下面就来看完整的工作流程。
Fiber 工作流程:从更新到上屏的完整旅程
把前面所有概念串起来——当你调用 setState 时,React 内部到底发生了什么?
全景图
三个关键步骤拆解
① Render 阶段:beginWork(向下走)
从根节点开始向下遍历,每到一个节点就:
- 根据组件类型执行渲染(函数组件就调用函数,类组件就调用 render)
- 拿到新的子元素,和老的子节点对比(Diff),决定哪些节点要复用、新建、删除
- 给当前 Fiber 打上副作用标记(flags),但先不操作 DOM
- 返回第一个子节点,下一轮处理它
function beginWork(current, workInProgress): FiberNode | null {
switch (workInProgress.tag) {
case FunctionComponent:
// 执行函数组件,得到子元素
const children = renderWithHooks(workInProgress);
// 对比新旧子节点,决定复用/新建/删除
reconcileChildren(current, workInProgress, children);
return workInProgress.child; // 继续处理子节点
// ... 其他类型
}
}
② Render 阶段:completeWork(向上回溯)
当走到叶子节点(没有子节点了),开始向上回溯:
- 如果有兄弟节点,去处理兄弟节点(回到 beginWork)
- 没兄弟节点了,向上回到父节点,对父节点执行 completeWork
- completeWork 会创建 DOM 实例(但还没挂到页面上)、把子 DOM 拼到自己身上、把副作用标记冒泡到父节点
- 一直回溯到根节点,Render 阶段结束
function completeWork(current, workInProgress): void {
if (workInProgress.tag === HostComponent) { // 原生 DOM 节点
if (current === null) {
// 首次创建:在内存里 new 一个 DOM 节点(还没插入页面)
const dom = document.createElement(workInProgress.type);
workInProgress.stateNode = dom;
} else {
// 更新:比较新旧 props,记录哪些属性要改
markUpdate(workInProgress, oldProps, newProps);
}
}
bubbleProperties(workInProgress); // 副作用标记冒泡给父节点
}
beginWork解决 "这个组件该长啥样" 的问题(执行组件函数、Diff)completeWork解决 "怎么把它变成 DOM" 的问题(创建 DOM、收集副作用)
分开是因为:begin 时还不知道子节点是什么样,所以不能创建完整 DOM;必须等子节点都处理完,回溯时才能拼装。
③ Commit 阶段:把改动应用到真实 DOM
Render 阶段构建好的树,挂着一堆"待办事项"(副作用 flags)。Commit 阶段就是一气呵成地执行这些待办:
| 子阶段 | 干啥 |
|---|---|
| BeforeMutation | DOM 还没动,可以读取上一帧的 DOM 信息(如 getSnapshotBeforeUpdate) |
| Mutation | 真正修改 DOM:插入、更新、删除 |
| Layout | DOM 已更新,同步执行 useLayoutEffect、componentDidMount/Update |
| 指针切换 | root.current = workInProgress,新树正式上岗 |
| 异步执行 useEffect | 浏览器绘制完后再跑,不阻塞渲染 |
整个 Commit 必须同步完成,因为 DOM 改了一半被打断会出现错乱画面。
副作用:Render 标记,Commit 执行
前面反复提到"副作用 flags",这里专门讲清楚。
副作用(Side Effect) 在 Fiber 里特指"需要操作真实 DOM 或调用生命周期"的事情,比如插入节点、删除节点、更新属性等。
Render 阶段只标记,不执行:
// 用二进制位表示不同的副作用
const NoFlags = 0b0000;
const Placement = 0b0001; // 这个节点要插入
const Update = 0b0010; // 这个节点要更新属性
const Deletion = 0b0100; // 这个节点要删除
每个 Fiber 节点有个 flags 字段记录自己的副作用,还有个 subtreeFlags 记录子树有没有副作用。Commit 阶段通过 subtreeFlags 可以快速跳过没有副作用的整个子树,不用挨个遍历。
这种"先打标后执行"的设计,让 Render 阶段可以被打断重做(重做时重新打标即可),而 Commit 阶段又能高效地只处理需要变更的节点。
总结:Fiber 的三大支柱
回到开头的厨房类比,现在你应该能完全理解这张表了:
| Fiber 支柱 | 解决的问题 | 关键技术 |
|---|---|---|
| 可中断渲染 | 长任务阻塞主线程 | 链表数据结构 + workLoop 循环 + shouldYield |
| 优先级调度 | 紧急更新被慢渲染拖累 | Lanes 模型 + Scheduler + 过期时间 |
| 双缓存 | 中断和原子提交不能影响用户 | current/workInProgress 双树 + alternate 复用 |
一句话再总结:Fiber 把"一口气干完"改成"可以暂停、可以让位、可以反悔的小步快跑",让 React 在大型应用里也能保持流畅。
常见面试问题
Q1: 什么是 React Fiber?解决了什么问题?
答案:
Fiber 是 React 16 引入的新协调引擎,解决了 React 15 的同步渲染阻塞问题。
| React 15 | React 16+ (Fiber) |
|---|---|
| Stack Reconciler | Fiber Reconciler |
| 同步递归 | 可中断的循环 |
| 无法中断 | 可以暂停、恢复、放弃 |
| 长任务阻塞 | 时间切片,及时响应 |
Fiber 的三层含义:
- 架构:新的可中断协调算法
- 数据结构:每个组件对应一个 Fiber 节点
- 工作单元:最小的可执行单位
Q2: 什么是双缓存?为什么需要双缓存?
答案:
双缓存是同时维护两棵 Fiber 树:
| 树 | 作用 |
|---|---|
| current 树 | 当前屏幕显示的内容 |
| workInProgress 树 | 内存中正在构建的新内容 |
为什么需要:
- 不影响当前显示:构建过程中,用户看到的仍是 current 树
- 可中断:如果构建被中断,current 树不受影响
- 避免闪烁:构建完成后一次性切换,用户感知不到中间状态
- 复用节点:通过
alternate指针复用上次的 Fiber 节点
Q3: 什么是时间切片?React 是如何实现的?
答案:
时间切片是将长任务拆分成小任务,每个小任务执行后检查是否需要让出主线程。
实现原理:
// 简化实现
function workLoop(): void {
while (workInProgress && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
if (workInProgress) {
scheduleCallback(workLoop); // 下一帧继续
}
}
function shouldYield(): boolean {
return performance.now() >= deadline; // 超过 5ms
}
关键点:
- 每个时间片约 5ms
- 使用
MessageChannel实现调度(不用 rAF 是因为帧率不稳定) - 高优先级任务(如用户输入)可以打断低优先级任务
Q4: React 的优先级机制是怎样的?
答案:
React 18 使用 Lanes 模型管理优先级:
| 优先级 | 场景 | 特点 |
|---|---|---|
| SyncLane | 用户输入、点击 | 最高,同步执行 |
| InputContinuousLane | 拖拽、滚动 | 高,连续响应 |
| DefaultLane | 普通 setState | 默认 |
| TransitionLane | useTransition | 可被打断 |
| IdleLane | 预渲染、离屏渲染 | 最低,空闲执行 |
// 高优先级打断低优先级
const [isPending, startTransition] = useTransition();
function handleChange(e: ChangeEvent<HTMLInputElement>) {
// 高优先级:立即更新输入框
setInputValue(e.target.value);
// 低优先级:可被打断的搜索
startTransition(() => {
setSearchResults(search(e.target.value));
});
}
Q5: Render 阶段和 Commit 阶段的区别?
答案:
| 阶段 | Render 阶段 | Commit 阶段 |
|---|---|---|
| 可中断性 | ✅ 可中断 | ❌ 不可中断 |
| 主要工作 | 构建 Fiber 树、Diff、收集副作用 | 执行副作用、更新 DOM |
| 执行函数 | beginWork、completeWork | commitMutationEffects 等 |
| 副作用 | 只标记,不执行 | 执行副作用(DOM 操作、生命周期) |
| 与 DOM | 不触及 DOM | 操作 DOM |
Q6: Fiber 的时间切片(Time Slicing)是如何工作的?
答案:
时间切片的核心思想是将长时间的渲染任务拆分成多个小的工作单元,每执行完一个单元就检查是否需要让出主线程,从而保证浏览器有机会处理用户交互和页面渲染。
实现机制:
-
调度器使用 MessageChannel:React 没有使用
requestIdleCallback(因为兼容性差、触发频率不稳定),而是基于MessageChannel实现了自己的调度器。 -
5ms 时间片:每个时间片默认约 5ms,这个时间足够执行一些工作,又不会导致用户感知到卡顿。
-
shouldYield 判断:每处理完一个 Fiber 节点后,调用
shouldYield()检查是否超时。
// React Scheduler 简化实现
const yieldInterval = 5; // 5ms 时间片
let deadline = 0;
// 使用 MessageChannel 而非 requestIdleCallback
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = () => {
const currentTime = performance.now();
deadline = currentTime + yieldInterval;
// 执行任务队列中的任务
const hasMoreWork = flushWork(currentTime);
if (hasMoreWork) {
// 还有任务,继续调度
port.postMessage(null);
}
};
function shouldYield(): boolean {
const currentTime = performance.now();
// 超过 5ms 时间片,需要让出主线程
return currentTime >= deadline;
}
// 工作循环
function workLoopConcurrent(): void {
while (workInProgress !== null && !shouldYield()) {
// 执行一个工作单元
workInProgress = performUnitOfWork(workInProgress);
}
// 如果还有工作但需要让出,下一帧继续
}
// 请求调度
function scheduleWork(): void {
port.postMessage(null); // 触发 MessageChannel,下一个宏任务执行
}
工作流程:
- 兼容性差:Safari 不支持
- 触发频率不稳定:浏览器在高负载时可能很久不触发
- 无法控制时间片长度:React 需要精确控制每帧的执行时间
- FPS 限制:requestIdleCallback 只在帧末尾空闲时触发,最多 50ms 一次
Q7: Fiber 的优先级调度是怎么实现的?
答案:
React 18 使用 Lanes(车道) 模型来管理优先级。每个 Lane 是一个二进制位,可以方便地进行位运算来合并、比较优先级。
Lane 模型:
// Lane 定义(使用二进制位表示优先级)
type Lane = number;
type Lanes = number;
const NoLane: Lane = 0b0000000000000000000000000000000;
const SyncLane: Lane = 0b0000000000000000000000000000001; // 同步,最高优先级
const InputContinuousLane: Lane = 0b0000000000000000000000000000100; // 连续输入
const DefaultLane: Lane = 0b0000000000000000000000000010000; // 默认优先级
const TransitionLane1: Lane = 0b0000000000000000000000001000000; // Transition
const IdleLane: Lane = 0b0100000000000000000000000000000; // 空闲,最低优先级
// 位运算合并多个 Lane
function mergeLanes(a: Lanes, b: Lanes): Lanes {
return a | b;
}
// 判断是否包含某个 Lane
function includesLane(set: Lanes, lane: Lane): boolean {
return (set & lane) !== 0;
}
// 获取最高优先级的 Lane(最低位)
function getHighestPriorityLane(lanes: Lanes): Lane {
return lanes & -lanes;
}
不同操作对应的优先级:
| 优先级 | Lane | 场景 | 特点 |
|---|---|---|---|
| SyncLane | 最高位 | 用户点击、输入、focus | 同步执行,不可中断 |
| InputContinuousLane | 高 | 拖拽、滚动、mousemove | 连续响应,高频触发 |
| DefaultLane | 中 | 普通 setState、fetch 回调 | 默认优先级 |
| TransitionLane | 低 | useTransition、useDeferredValue | 可被打断 |
| IdleLane | 最低 | offscreen 预渲染 | 空闲时执行 |
优先级调度流程:
// 触发更新时,根据上下文分配 Lane
function requestUpdateLane(): Lane {
// 如果在离散事件中(click)→ SyncLane
// 如果在连续事件中(scroll)→ InputContinuousLane
// 如果在 transition 中 → TransitionLane
// 否则 → DefaultLane
if (isDiscreteEventContext) {
return SyncLane;
}
if (isContinuousEventContext) {
return InputContinuousLane;
}
if (currentTransition !== null) {
return claimNextTransitionLane();
}
return DefaultLane;
}
// 调度时选择最高优先级的 Lane 执行
function ensureRootIsScheduled(root: FiberRoot): void {
const nextLanes = getNextLanes(root);
const highestLane = getHighestPriorityLane(nextLanes);
if (highestLane === SyncLane) {
// 同步执行,不走 Scheduler
scheduleSyncCallback(performSyncWorkOnRoot);
} else {
// 根据优先级计算 Scheduler 优先级
const schedulerPriority = lanesToSchedulerPriority(highestLane);
scheduleCallback(schedulerPriority, performConcurrentWorkOnRoot);
}
}
饥饿问题处理:
低优先级任务可能一直被高优先级任务打断,导致永远无法执行(饥饿)。React 通过过期时间解决:
// 每个 Lane 有对应的过期时间
function markStarvedLanesAsExpired(root: FiberRoot, currentTime: number): void {
const pendingLanes = root.pendingLanes;
const expirationTimes = root.expirationTimes;
let lanes = pendingLanes;
while (lanes > 0) {
const lane = getHighestPriorityLane(lanes);
const expirationTime = expirationTimes[laneToIndex(lane)];
if (expirationTime <= currentTime) {
// 已过期,提升为同步优先级,确保立即执行
root.expiredLanes |= lane;
}
lanes &= ~lane; // 移除已处理的 lane
}
}
// 过期的 Lane 会被当作 SyncLane 处理,保证一定执行
Q8: 双缓冲(Double Buffering)在 Fiber 中是怎么工作的?
答案:
双缓冲是一种经典的图形渲染技术,React Fiber 借用这个概念来避免渲染过程中的 UI 不一致。React 同时维护两棵 Fiber 树,通过 alternate 指针互相连接。
两棵 Fiber 树:
| 树 | 作用 | 何时存在 |
|---|---|---|
| current 树 | 代表当前屏幕上显示的 UI | 始终存在 |
| workInProgress 树 | 正在内存中构建的新 UI | 渲染过程中存在 |
interface FiberNode {
// ...其他字段
alternate: FiberNode | null; // 指向另一棵树中对应的节点
stateNode: any; // 对应的真实 DOM 节点
}
// current 和 workInProgress 通过 alternate 互相引用
// currentFiber.alternate === workInProgressFiber
// workInProgressFiber.alternate === currentFiber
工作流程:
节点复用机制:
// 创建 workInProgress 节点时尝试复用
function createWorkInProgress(
current: FiberNode,
pendingProps: any
): FiberNode {
let workInProgress = current.alternate;
if (workInProgress === null) {
// 首次渲染,没有可复用的节点,创建新的
workInProgress = createFiber(current.tag, pendingProps, current.key);
workInProgress.type = current.type;
workInProgress.stateNode = current.stateNode; // 复用 DOM 节点
// 建立 alternate 连接
workInProgress.alternate = current;
current.alternate = workInProgress;
} else {
// 更新渲染,复用已有节点,只更新属性
workInProgress.pendingProps = pendingProps;
workInProgress.flags = NoFlags; // 重置副作用
workInProgress.subtreeFlags = NoFlags;
}
// 复制静态属性
workInProgress.child = current.child;
workInProgress.memoizedProps = current.memoizedProps;
workInProgress.memoizedState = current.memoizedState;
workInProgress.lanes = current.lanes;
return workInProgress;
}
Commit 阶段切换:
// Commit 完成后,交换 current 指针
function commitRoot(root: FiberRoot): void {
const finishedWork = root.finishedWork; // workInProgress 树的根节点
// 1. Before Mutation 阶段
commitBeforeMutationEffects(finishedWork);
// 2. Mutation 阶段:操作真实 DOM
commitMutationEffects(finishedWork);
// 3. 关键步骤:切换 current 指针
root.current = finishedWork;
// 此时 workInProgress 树变成了 current 树
// 旧的 current 树通过 alternate 保留,下次更新时作为 workInProgress 复用
// 4. Layout 阶段
commitLayoutEffects(finishedWork);
}
双缓冲的优势:
- 避免 UI 不一致:构建新树期间,屏幕上显示的始终是 current 树,用户不会看到中间状态
- 支持可中断渲染:如果渲染被中断,丢弃 workInProgress 树即可,current 树不受影响
- 节点复用:通过 alternate 指针,两棵树的节点可以互相复用,减少内存分配
- 一致性保证:所有 DOM 变更在 Commit 阶段一次性应用,然后切换 current 指针
双缓冲只在 Render 阶段体现可中断性。Commit 阶段是同步执行的,一旦开始就不会中断,这保证了 DOM 更新的原子性。
Q9: 说一下 Fiber 的遍历逻辑
答案:
Fiber 的遍历逻辑是固定的三步循环,全程只靠 child、sibling、return 三个指针,不依赖递归调用栈,因此可以随时暂停、断点续跑。
三条规则:
| 步骤 | 指针 | 动作 |
|---|---|---|
| ① 往下钻 | child | 优先走第一个子节点 |
| ② 往右走 | sibling | 没有子节点了,走下一个兄弟节点 |
| ③ 往上退 | return | 没有兄弟节点了,回到父节点,再找父节点的兄弟 |
遍历伪代码:
function workLoop(root: FiberNode): void {
let node: FiberNode | null = root;
while (node !== null) {
// 每处理完一个节点就可以检查是否需要让出主线程
if (shouldYield()) break; // 随时可暂停!
// ① 对当前节点执行工作(beginWork)
const child = performUnitOfWork(node);
if (child !== null) {
// 有子节点 → 往下钻
node = child;
continue;
}
// 没有子节点了,开始回溯
let current: FiberNode | null = node;
while (current !== null) {
// 对当前节点执行 completeWork(向上收集副作用)
completeUnitOfWork(current);
if (current.sibling !== null) {
// ② 有兄弟节点 → 往右走
node = current.sibling;
break;
}
// ③ 没有兄弟 → 往上退到父节点,继续找父节点的兄弟
current = current.return;
// 如果退到了根节点(return 为 null),遍历结束
if (current === null) {
node = null;
}
}
}
}
用一棵具体的树走一遍:
App
/ \
Header Main
/ \
List Footer
遍历顺序如下(→ 表示 beginWork,← 表示 completeWork):
| 步骤 | 节点 | 动作 | 使用的指针 |
|---|---|---|---|
| ① | App | beginWork | — |
| ② | Header | beginWork | child |
| ③ | Header | completeWork | 无 child |
| ④ | Main | beginWork | sibling |
| ⑤ | List | beginWork | child |
| ⑥ | List | completeWork | 无 child |
| ⑦ | Footer | beginWork | sibling |
| ⑧ | Footer | completeWork | 无 child,无 sibling |
| ⑨ | Main | completeWork | return,无 sibling |
| ⑩ | App | completeWork | return,遍历结束 |
为什么这个设计支持可中断?
关键在于:当前处理到哪个节点,完全由 workInProgress 这个指针记录。暂停时指针停在原地,下次恢复时从同一个位置继续走,不需要重建调用栈。
// 暂停前
workInProgress = ListFiber; // 指针停在 List 节点
// ...浏览器处理用户事件、绘制页面...
// 恢复后
// workInProgress 还是 ListFiber,从这里接着走
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
相比递归方案,递归的"当前位置"存在调用栈里,一旦中断调用栈就销毁了,没法恢复。而 Fiber 的链表 + 指针方案,状态全存在堆内存的对象里,天然支持暂停和恢复。
- 三个指针:
child(往下)→sibling(往右)→return(往上) - 固定优先级:先 child,再 sibling,最后 return
- 循环而非递归:用 while 循环 + 指针,取代递归调用栈
- 可中断的本质:当前位置存在指针里(堆内存),不依赖调用栈,随时能暂停和恢复
Q10: Fiber 节点和 DOM 节点是什么对应关系?
答案:
核心结论:不是所有 Fiber 节点都对应 DOM 节点。 Fiber 树比真实 DOM 树"胖"得多,因为 React 组件层级(函数组件、Context Provider 等)只存在于 Fiber 树中,不会产生 DOM。
Fiber 节点的 tag 类型与 DOM 的对应关系:
| Fiber tag | 示例 | 有对应的 DOM 节点吗 | stateNode 指向 |
|---|---|---|---|
| HostRoot | Fiber 树根节点 | ✅ #root 容器 | FiberRoot 对象 |
| HostComponent | <div>、<span>、<input> | ✅ 对应的真实 DOM 元素 | DOM Element |
| HostText | 纯文本 "hello" | ✅ 对应的 Text 节点 | DOM Text Node |
| FunctionComponent | <App />、<Header /> | ❌ 没有 | null |
| ClassComponent | class Foo extends Component | ❌ 没有 | 类实例(不是 DOM) |
| ContextProvider | <MyContext.Provider> | ❌ 没有 | null |
| ContextConsumer | <MyContext.Consumer> | ❌ 没有 | null |
| Fragment | <></> | ❌ 没有 | null |
| MemoComponent | React.memo(Comp) | ❌ 没有 | null |
| SuspenseComponent | <Suspense> | ❌ 没有 | null |
一个具体的例子:
function Header() {
return <h1>Title</h1>;
}
function App() {
return (
<div id="container">
<Header />
<p>Hello</p>
</div>
);
}
对应的 Fiber 树 vs 真实 DOM 树:
可以看到:App 和 Header 这两个函数组件 Fiber 没有对应的 DOM 节点,它们在 Fiber 树中承担组织结构和 Hooks 挂载的角色,但在 DOM 树中"不存在"。
这带来的一个实际影响——Commit 阶段插入 DOM 时需要"向上找宿主":
当 React 要把一个新节点插入 DOM 时,不能简单地 fiber.return.stateNode.appendChild(),因为父 Fiber 可能是函数组件(没有 DOM)。必须沿着 return 指针向上找,直到找到一个 HostComponent 或 HostRoot:
// Commit 阶段:找到最近的 DOM 父节点
function getHostParentFiber(fiber: FiberNode): FiberNode {
let parent = fiber.return;
while (parent !== null) {
if (parent.tag === HostComponent || parent.tag === HostRoot) {
return parent; // 找到了有 DOM 的祖先
}
parent = parent.return; // 跳过函数组件、Fragment 等
}
throw new Error('No host parent found');
}
// 类似地,删除时也需要向上找到 DOM 父节点来执行 removeChild
- 不是一一对应:只有 HostComponent(
<div>等原生标签)和 HostText(文本节点)才有对应的 DOM - 函数/类组件没有 DOM:它们的
stateNode为null(类组件指向实例,但实例也不是 DOM) stateNode字段:Fiber 节点通过stateNode指向对应的 DOM 元素- Commit 阶段的影响:插入/删除 DOM 时需要沿
return向上找到最近的宿主节点(HostComponent),不能直接用父 Fiber 的 DOM
Q11: Fiber 和虚拟 DOM 是什么关系?
答案:
很多人把 Fiber 和虚拟 DOM 混为一谈,但它们是不同层次的概念。理清三个角色:
| 概念 | 本质 | 创建时机 | 是否可变 |
|---|---|---|---|
| React Element | 普通 JS 对象,描述"UI 应该长什么样" | 每次 render 都重新创建 | ❌ 不可变(frozen) |
| Fiber Node | 工作单元对象,描述"组件的一切状态" | 首次渲染时创建,后续复用 | ✅ 可变(直接修改属性) |
| DOM Node | 真实的浏览器 DOM 元素 | Commit 阶段创建/更新 | ✅ 可变 |
// ① React Element(虚拟 DOM)—— JSX 编译结果
const element = <div className="box">Hello</div>;
// 等价于:
const element = {
type: 'div',
props: { className: 'box', children: 'Hello' },
key: null,
ref: null,
// 注意:没有 state、没有 hooks、没有副作用标记
};
// ② Fiber Node —— React 内部的工作单元
const fiber = {
type: 'div',
tag: HostComponent,
pendingProps: { className: 'box', children: 'Hello' },
memoizedProps: { className: 'box', children: 'Hello' },
memoizedState: null, // Hooks 链表(函数组件才有)
stateNode: domElement, // 指向真实 DOM
alternate: oldFiber, // 双缓存
flags: Update, // 副作用标记
lanes: DefaultLane, // 优先级
child: null, // 子 Fiber
sibling: null, // 兄弟 Fiber
return: parentFiber, // 父 Fiber
};
它们的关系:
一句话总结:React Element 是蓝图(描述要什么),Fiber Node 是施工记录(跟踪做到哪了),DOM Node 是最终建筑(用户看到的)。虚拟 DOM(React Element)是 Fiber 的输入,Fiber 拿到虚拟 DOM 后在内部进行 Diff、调度、标记副作用,最终产出对真实 DOM 的操作。
- 虚拟 DOM(React Element) 是轻量的不可变对象,每次渲染都会重新创建
- Fiber Node 是重量级的可变对象,首次创建后会被复用(通过 alternate)
- React Element → Fiber Node → DOM Node 是单向转化关系
- Fiber 不是虚拟 DOM 的替代品,而是虚拟 DOM 的调度和执行引擎
Q12: Hooks 是怎么存储在 Fiber 上的?
答案:
每个函数组件的 Fiber 节点有一个 memoizedState 字段,它指向一条单向链表,链表的每个节点对应组件中的一个 Hook 调用。
链表结构:
interface Hook {
memoizedState: any; // 当前 Hook 存储的值(不同 Hook 含义不同)
baseState: any; // 基础 state
baseQueue: Update | null;
queue: UpdateQueue | null; // 更新队列(setState 产生的更新挂在这里)
next: Hook | null; // 指向下一个 Hook(链表指针)
}
不同 Hook 的 memoizedState 存什么:
| Hook | memoizedState 存储的内容 |
|---|---|
useState | 当前 state 值 |
useReducer | 当前 state 值 |
useEffect | Effect 对象(包含 create、destroy、deps) |
useRef | { current: value } 引用对象 |
useMemo | [计算结果, deps] |
useCallback | [回调函数, deps] |
useContext | 不使用 memoizedState(直接从 Context 读取) |
一个具体的例子:
function Counter() {
const [count, setCount] = useState(0); // Hook 1
const [name, setName] = useState('React'); // Hook 2
const doubleCount = useMemo(() => count * 2, [count]); // Hook 3
useEffect(() => { // Hook 4
document.title = `${name}: ${count}`;
}, [count, name]);
return <div>{doubleCount}</div>;
}
对应的 Fiber 上的 Hooks 链表:
为什么 Hooks 不能在条件语句中调用?
因为 Hooks 是按调用顺序存储在链表中的,React 在更新时按顺序遍历链表来匹配每个 Hook。如果某次渲染少了一个 Hook 调用,后面的 Hook 都会错位:
function Bad({ showName }: { showName: boolean }) {
const [count, setCount] = useState(0); // 永远是 Hook 1 ✅
// ❌ 条件调用导致链表错位!
if (showName) {
const [name, setName] = useState('React'); // 有时是 Hook 2,有时不存在
}
// 当 showName 从 true 变成 false 时:
// React 期望 Hook 2 是 useState('React')
// 但实际读到的是 useEffect 的数据 → 崩溃!
useEffect(() => { /* ... */ }, []);
}
// showName = true 时的链表:
Hook1(count) → Hook2(name) → Hook3(effect) → null
// showName = false 时的链表:
Hook1(count) → Hook2(effect) → null
// ↑ React 以为这是 name 的 useState,实际是 effect → 错乱!
更多 Hooks 原理细节可参考 Hooks 原理。
- 存储位置:函数组件 Fiber 的
memoizedState字段 - 数据结构:单向链表,每个节点对应一个 Hook 调用
- 顺序依赖:按调用顺序匹配,所以不能在条件/循环中调用 Hooks
- 不同 Hook 存不同内容:useState 存 state 值,useEffect 存 Effect 对象,useMemo 存
[结果, deps]
Q13: 什么是 bailout 优化?React 什么时候会跳过组件渲染?
答案:
bailout(纾困/快速退出) 是 React 在 beginWork 阶段的一种优化:如果判断某个 Fiber 节点及其子树不需要更新,就直接跳过,不执行组件函数、不做 Diff,大幅减少工作量。
触发 bailout 的条件(必须同时满足):
// beginWork 中的 bailout 判断(简化)
function beginWork(current: FiberNode, workInProgress: FiberNode): FiberNode | null {
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
// 条件 1:props 没有变化(引用相等)
// 条件 2:context 没有变化
// 条件 3:当前 Fiber 没有待处理的更新(lanes 为空)
if (
oldProps === newProps &&
!hasContextChanged() &&
!includesSomeLane(workInProgress.lanes, renderLanes)
) {
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
}
// 不满足 bailout 条件,正常执行组件
// ...
}
function bailoutOnAlreadyFinishedWork(
current: FiberNode,
workInProgress: FiberNode,
renderLanes: Lanes
): FiberNode | null {
// 检查子树是否有待处理的更新
if (!includesSomeLane(workInProgress.childLanes, renderLanes)) {
// 子树也没有更新 → 整棵子树都跳过!
return null;
}
// 子树有更新 → 跳过当前节点,但继续处理子节点
cloneChildFibers(current, workInProgress);
return workInProgress.child;
}
bailout 的两个层级:
| 层级 | 条件 | 效果 |
|---|---|---|
| 跳过当前节点 | props 相同 + 无 context 变化 + 无 pending lanes | 不执行组件函数 |
| 跳过整棵子树 | 上述条件 + childLanes 也为空 | 当前节点和所有后代都跳过 |
一个具体的例子:
function App() {
const [count, setCount] = useState(0);
return (
<div>
<Counter count={count} /> {/* props 变了,正常渲染 */}
<ExpensiveList /> {/* 父组件渲染时,默认也会跟着渲染 */}
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
);
}
// 每次 App 重渲染时:
// 1. Counter 的 props 变了(count 不同)→ 正常渲染
// 2. ExpensiveList 虽然没有 props,但普通函数组件默认会随父组件重新执行
// 3. 如果 ExpensiveList 很重,通常需要 React.memo 帮它跳过无意义渲染
父组件重渲染时,普通子组件的函数通常也会重新执行。React 内部确实有 bailout 机制,但它判断的是 Fiber 上的 props、context、lanes、childLanes 等条件;对业务开发来说,更直观的规则是:普通组件默认跟着父组件渲染,React.memo 才会用 props 浅比较帮函数组件跳过渲染。
这就是 React.memo 存在的意义:
const ExpensiveList = React.memo(function ExpensiveList() {
// React.memo 会对 props 做浅比较(shallowEqual)
// 如果所有 prop 值都没变 → 复用上次结果,跳过组件函数执行
return <div>很耗时的列表...</div>;
});
bailout 的工作流程图:
- bailout 条件:props 引用相同 + 无 context 变化 + 无 pending lanes
- 两个层级:跳过当前节点(childLanes 不为空)或跳过整棵子树(childLanes 也为空)
- 普通组件默认会随父组件渲染:不要误以为"props 看起来没变"就一定跳过
- React.memo 的作用:用 props 浅比较跳过函数组件执行,使 bailout 更容易触发
- childLanes 的作用:让 React 知道子树是否有待处理的更新,避免无意义的遍历
Q14: Fiber 中的 Diff 发生在哪个阶段?怎么触发的?
答案:
Diff 发生在 Render 阶段的 beginWork 中,具体是在 reconcileChildren(协调子节点)函数里。
Diff 在整个流程中的位置:
beginWork 中触发 Diff 的过程:
function beginWork(current: FiberNode, workInProgress: FiberNode): FiberNode | null {
switch (workInProgress.tag) {
case FunctionComponent: {
// 1. 执行函数组件,得到新的 React Element(children)
const nextChildren = renderWithHooks(current, workInProgress);
// 2. 用新的 children 和 current 树上的旧 children 做 Diff
reconcileChildren(current, workInProgress, nextChildren);
// 3. 返回第一个子 Fiber,继续向下遍历
return workInProgress.child;
}
case HostComponent: {
// 原生标签也会走 reconcileChildren
const nextChildren = workInProgress.pendingProps.children;
reconcileChildren(current, workInProgress, nextChildren);
return workInProgress.child;
}
// ...
}
}
function reconcileChildren(
current: FiberNode | null,
workInProgress: FiberNode,
nextChildren: any
): void {
if (current === null) {
// 首次挂载:不做 Diff,直接创建所有子 Fiber
workInProgress.child = mountChildFibers(workInProgress, null, nextChildren);
} else {
// 更新:Diff 旧子 Fiber 和新 React Element
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child, // 旧的子 Fiber 链表
nextChildren // 新的 React Element
);
}
}
Diff 的输入和输出:
| 输入 | 输出 | |
|---|---|---|
| 旧数据 | current 树上的子 Fiber 链表 | — |
| 新数据 | 组件函数返回的新 React Element | — |
| 产出 | — | 新的 workInProgress 子 Fiber 链表 + flags 标记 |
Diff 产生的三种副作用标记:
| 标记 | 含义 | 何时产生 |
|---|---|---|
Placement | 新增或移动 | 新 Element 没有对应的旧 Fiber,或位置发生变化 |
Update | 属性更新 | 同类型节点但 props 不同 |
Deletion | 删除 | 旧 Fiber 没有对应的新 Element |
// Diff 结果示例
// 旧: A → B → C → D
// 新: A → C → E
// A: 复用旧 Fiber,标记 Update(如果 props 变了)
// B: 找不到匹配 → 标记 Deletion
// C: 复用旧 Fiber,位置变了 → 标记 Placement
// D: 找不到匹配 → 标记 Deletion
// E: 全新节点 → 创建新 Fiber,标记 Placement
这些 flags 在 Render 阶段只是被标记,真正的 DOM 操作在 Commit 阶段才执行。 这就是"Render 标记,Commit 执行"的设计——Render 阶段可以被打断和重做(重做时重新标记),不影响真实 DOM。
更多 Diff 算法细节(单节点 Diff、多节点 Diff、key 的作用等)可参考 Reconciliation 协调算法 和 虚拟 DOM 与 Diff 算法。
- 阶段:Render 阶段的
beginWork→reconcileChildren - 输入:旧的子 Fiber 链表(current.child)+ 新的 React Element(组件函数返回值)
- 输出:新的子 Fiber 链表 + 副作用标记(Placement/Update/Deletion)
- 不操作 DOM:Diff 只产生标记,真正的 DOM 操作在 Commit 阶段
- 首次挂载不 Diff:
current === null时直接创建,不需要对比