React 协调与 Fiber 架构

深入理解 React 的协调算法、Fiber 架构的设计动机、以及 React 如何决定更新哪些 DOM 节点。

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 的协调算法可以分解为两个独立的问题:

  1. 不同类型的组件:如何比较两个不同类型的组件
  2. 列表:如何高效比较列表中的元素

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 的目标是:

  1. 可中断:将协调工作拆分成小单元,可以暂停和恢复
  2. 优先级调度:高优先级更新(如用户输入)可以打断低优先级更新(如网络请求后的数据加载)
  3. 支持并发:这是 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 使用双缓存技术来避免闪烁:

  1. current Fiber:屏幕上正在显示的 Fiber 树
  2. 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>
  ));
}

问题场景:

  1. 在列表开头插入元素:所有元素的位置都变了,key 全部变化,React 销毁并重建所有元素
  2. 删除中间元素:后面的元素 key 全都变了,又是重建
  3. 组件状态丢失:如果列表项有状态(如输入框的内容),状态会混乱

使用稳定 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。


延展阅读