React 协调与 Fiber 架构
什么是协调
协调(Reconciliation)是 React 的核心算法,负责比较两颗虚拟 DOM 树,找出最小更新量来同步真实 DOM。
React 的官方文档将这个过程描述为:「当你使用 React 时,在某个时间点你可以调用 setState 函数来创建一棵 React 元素树。下一步,React 会自动计算这棵新树与前一个元素的差异,并据此高效地更新渲染的 DOM。」
理解协调机制不仅仅是面试需要——它能帮助你理解为什么某些代码模式会导致性能问题,以及为什么 React 对组件结构有特定的要求。
协调的基本策略
Tree Diff 的复杂度问题
对于两棵树的完整比较,算法复杂度是 O(n³),其中 n 是节点数量。这意味着如果,你有 1000 个节点,就需要 10 亿次比较,这在实际应用中是不可接受的。
React 采用了三个假设来将复杂度降低到 O(n):
假设一:不同类型的元素产生不同的树
// 如果元素类型变了,React 不会尝试比较,直接销毁旧树
<div>
<Counter />
</div>
// 变成
<span>
<Counter />
</span>
// React 会销毁 <div> 和所有子组件,创建新的 <span> 和子组件
假设二:开发者通过 key 属性暗示哪些子元素稳定
// key 帮助 React 识别哪些元素可以复用
<ul>
<li key="a">Alice</li>
<li key="b">Bob</li>
</ul>
// 更新后
<ul>
<li key="b">Bob</li> {/* 移动了位置 */}
<li key="a">Alice</li> {/* 移动了位置 */}
</ul>
// 有 key,React 知道这两个 <li> 可以复用,只是交换位置
// 没有 key,React 可能会销毁并重新创建所有 <li>
协调的两个子问题
React 的协调算法可以分解为两个独立的问题:
- 不同类型的组件:如何比较两个不同类型的组件
- 列表:如何高效比较列表中的元素
DOM 节点级别的 Diff
元素类型不同时
当根节点是不同类型时,React 会销毁旧树并重建新树:
// 旧树
<div className="before">
<Counter />
</div>
// 新树
<span className="after">
<Counter />
</span>
// React 会:销毁 <div className="before"> 和 Counter
// 然后创建 <span className="after"> 和 Counter
这个行为导致的一个常见问题是:不要把多个根元素用一个 div 包裹,否则切换时会销毁重建。
同类型元素
当比较两个同类型的 React 元素时,React 会保留 DOM 节点,只更新变化的属性:
// 旧
<div classNameName="before" title="old" />
// 新
<div classNameName="after" title="new" />
// React 只更新变化的属性
// className → className (实际是 class)
// title → new
子节点递归比较
对于子节点,React 默认递归比较:
// 旧
<ul>
<li key="0">first</li>
<li key="1">second</li>
</ul>
// 新
<ul>
<li key="1">second</li>
<li key="0">first</li>
</ul>
// 没有 key:React 会认为所有 <li> 都变了
// 有 key:React 识别出可以复用,只需要移动位置
Fiber 架构:协调的重新设计
为什么要 Fiber
在 React 16 之前,协调过程是同步的。当组件树很大时,比较过程可能需要几百毫秒,这期间浏览器无法响应用户输入,看起来就像卡住了。
Fiber 的目标是:
- 可中断:将协调工作拆分成小单元,可以暂停和恢复
- 优先级调度:高优先级更新(如用户输入)可以打断低优先级更新(如网络请求后的数据加载)
- 支持并发:这是 React Concurrent Mode 的基础
Fiber 的数据结构
Fiber 节点是 JavaScript 对象,每个 React 元素对应一个 Fiber 节点:
// 简化版的 Fiber 节点结构
const fiber = {
// 标识
type: 'div', // 组件类型
key: null, // key 属性
// 链表结构
child: fiber, // 第一个子节点
sibling: fiber, // 下一个兄弟节点
return: fiber, // 父节点
// 状态
stateNode: DOMNode, // 对应的真实 DOM 节点
memoizedState: any, // 缓存的状态
// 更新队列
pendingProps: any, // 新的 props
memoizedProps: any, // 上一次渲染的 props
};
双缓存技术
React 使用双缓存技术来避免闪烁:
- current Fiber:屏幕上正在显示的 Fiber 树
- workInProgress Fiber:正在构建的新 Fiber 树
// 当状态变化时
// 1. React 创建一个新的 workInProgress Fiber 树
// 2. 完成构建后,通过 pointer 交换 current 和 workInProgress
// 3. 屏幕显示新树
这种设计使得 React 可以在下一帧渲染之前完成所有计算,用户看不到"半成品"状态。
渲染阶段与提交阶段
Fiber 将更新分为两个阶段:
渲染阶段(Render Phase):
- 可中断
- 计算 diff,决定什么需要更新
- 执行 React 元素的创建、更新、删除
提交阶段(Commit Phase):
- 同步执行,不可中断
- 将变化应用到 DOM
- 执行副作用(如 useEffect、ref)
// 渲染阶段(可能被打断)
function performUnitOfWork(fiber) {
beginWork(fiber); // 开始处理这个 fiber
if (fiber.child) {
return fiber.child; // 继续处理子节点
}
completeWork(fiber); // 完成当前 fiber
if (fiber.sibling) {
return fiber.sibling; // 处理下一个兄弟
}
return fiber.return; // 返回父节点
}
// 提交阶段(不可打断)
function commitRoot(root) {
commitInsertion(root.current);
commitLayoutEffects(root);
commitPassiveEffects(root);
}
调度与优先级
lanes 模型
React 18 引入了 lanes(车道)模型来管理更新的优先级:
// 不同类型的更新有不同的优先级
const SyncLane = 0b0001; // 同步更新,如 click handler
const InputContinuousLane = 0b0100; // 连续输入,如 scroll
const DefaultLane = 0b1000; // 默认优先级
const TransitionLane = 0b10000; // 过渡更新,如 useTransition
饥饿问题
低优先级更新如果一直被高优先级打断,可能永远无法执行:
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
// 低优先级的网络请求
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => setResults(data));
}, [query]);
return results.map(r => <Result key={r.id} data={r} />);
}
如果用户快速输入(每个字符都是高优先级),这个搜索请求可能永远无法完成。解决方案是使用 useTransition 将更新标记为可中断的过渡更新。
key 的正确使用
为什么 index 作为 key 有问题
// 使用 index 作为 key
function List({ items }) {
return items.map((item, index) => (
<div key={index}>{item.name}</div>
));
}
问题场景:
- 在列表开头插入元素:所有元素的位置都变了,key 全部变化,React 销毁并重建所有元素
- 删除中间元素:后面的元素 key 全都变了,又是重建
- 组件状态丢失:如果列表项有状态(如输入框的内容),状态会混乱
使用稳定 ID
function List({ items }) {
return items.map(item => (
// 使用数据库 ID 或 UUID
<div key={item.id}>{item.name}</div>
));
}
key 的最佳实践
- 使用稳定的唯一标识:数据库 ID、UUID、nanoid
- 不要使用随机数:每次渲染都不同
- 不要使用数组索引:顺序变化时会导致问题
- key 只需要在兄弟节点间唯一:不需要全局唯一
面试中的表达
面试中聊到协调机制,通常是在考察你对 React 渲染模型和性能优化的理解:
React 的协调算法基于三个假设:不同类型元素产生不同树、相同类型元素只更新属性、子元素通过 key 来识别。实际的 diff 过程从根节点开始,递归比较子节点。
Fiber 架构是 React 16 引入的,它将协调工作拆分成可中断的小单元,并通过 lanes 模型实现优先级调度。这使得 React 可以同时处理多个更新,并让高优先级更新(如用户输入)打断低优先级更新(如数据加载)。
关于 key,面试中经常问为什么不能用 index。原因是 index 作为 key 在列表顺序变化时会导致所有元素被当作新的,破坏组件状态。应该使用稳定的唯一 ID。
延展阅读
- React Docs: Reconciliation — 官方协调文档
- React Fiber Architecture — Fiber 架构设计文档
- Lin Clark: A Cartoon Intro to Fiber — Fiber 架构可视化讲解
- React Conf 2017: Fiber Deep Dive — React 团队关于 Fiber 的详细解释