React 的调和算法与 Diff 策略

第五编 · 第八章:React 的调和算法与 Diff 策略的深入分析


调和是什么

调和(Reconciliation)是 React 用来对比两棵树的算法,决定哪些部分需要更新,哪些可以复用。

当你调用 setState 时,React 会生成一棵新的 React Element 树,然后和上一棵渲染在内存中的树做对比。这个对比过程就是调和。

用 Andrew Clark 在 React Fiber Architecture 文档里的话说:"The algorithm React uses to diff one tree with another to determine which parts need to be changed."1

但 Fiber 之前和 Fiber 之后的调和实现有本质区别。React 15 的调和是递归的、同步的,不能中断。Fiber 重写之后,调和变成了可中断的工作单元,可以在执行过程中被更高优先级的任务打断。


Diff 策略的基本假设

React 的 Diff 算法建立在两个假设上。这两个假设是性能优化的关键,但也意味着在某些场景下你需要主动配合 React,才能获得最佳性能。

假设一:不同类型的组件产生不同的树。 React 会认为如果一个位置上是 <div> 变成 <span>,那么整个子树都需要被替换,而不是去比较它们的子节点是否相同。

// 旧树
<div>
  <span>old content</span>
</div>

// 新树
<span>
  <span>new content</span> // 这里虽然是 span,但它在 React 眼里是完全不同的子树
</span>

React 不会尝试复用第一个 <span>,而是直接卸载旧的子树、挂载新的子树。这意味着稳定组件类型能帮助 React 复用已有节点

假设二:开发者可以通过 key 来暗示哪些子元素是稳定的。 对于列表,React 假设有相同 key 的元素是同一个,不需要重新创建。

// 使用 key 帮助 React 识别稳定元素
{items.map(item => (
  <li key={item.id}>{item.name}</li>
))}

没有 key 或 key 不稳定(如使用索引)会导致 React 认为每个列表项都是新创建的,可能引发性能问题和状态丢失。


三个场景下的 Diff 策略

场景一:两个不同类型的元素

// 之前
<div><span>old</span></div>

// 之后
<span><span>new</span></span>

React 会认为 <div><span> 完全不同,直接卸载整个旧子树(包括 span),挂载新的。这个过程会触发组件的卸载和挂载生命周期函数。

这引出一个常见的性能优化原则:保持 DOM 节点类型稳定,避免不必要的容器类型切换。比如把 <div> 换成 <section> 会导致整棵子树重建。

场景二:同类型 DOM 元素

// 之前
<div className="before" style={{color: 'red'}} />

// 之后
<div className="after" style={{color: 'blue'}} />

React 会认为这是同一个 <div>,只更新变化的属性。className 从 "before" 变成 "after",style 的 color 从 red 变成 blue。不会重新创建 div 节点,只会应用必要的 DOM 属性变更。

这是 React 更新 DOM 的常见模式:尽量复用同类型节点,只变更必要的属性。

场景三:同类型组件元素

// 之前
<Parent>
  <Child name="old" />
</Parent>

// 之后
<Parent>
  <Child name="new" />
</Parent>

组件类型相同但 props 不同,React 会认为这是同一个组件实例,会触发这个组件的更新流程:

  1. 组件实例保持不变
  2. 组件的 props 更新
  3. 组件的 render 方法被调用
  4. Diff 算法继续对比子树的返回结果

key 的正确使用方式

列表渲染中的 key 看起来是个小问题,但踩坑的人不少。

不要使用数组索引作为 key,除非你确定列表不会变化(插入、删除、排序):

// 危险:使用索引作为 key
items.map((item, index) => (
  <li key={index}>{item.name}</li>
))

当列表中间插入一个新项时,所有现有元素的索引都会变化,React 会认为它们都是新的——这可能导致状态混乱和性能下降。

使用稳定、唯一、可预测的 ID 作为 key

// 推荐:使用唯一 ID
items.map(item => (
  <li key={item.id}>{item.name}</li>
))

key 只需要在兄弟元素之间唯一,不需要全局唯一。同一层级的 li 之间 key 不能重复,但不同层级的 key 可以相同。


Fiber 架构下的调和变化

在 Fiber 架构下,调和不再是一次性完成的递归过程,而是被拆分成了多个工作单元。

每个 Fiber 节点都有 effectTag 标记,表示这个节点需要执行什么类型的更新:Placement(插入)、Update(更新)、Deletion(删除)等。

Render Phase 阶段,React 遍历 Fiber 树,标记每个有变化的节点。Commit Phase 阶段,React 根据 effectTag 一次性应用所有 DOM 变更。

这就是为什么 React 17 引入的 Concurrent Mode 能实现"可中断渲染":调和被拆成小单元后,浏览器可以在这些单元之间插入其他任务,不会出现长时间阻塞。


调和和渲染的关系

调和(Reconciliation)和渲染(Rendering)是两个不同的概念:

  • 调和:决定哪些 parts 需要更新,这是纯 JavaScript 计算
  • 渲染:把变更应用到 DOM 上,这会实际改变浏览器页面

React DOM 和 React Native 能共享同一个调和器,但使用不同的渲染器。调和算法本身不关心最终输出到哪里,只关心"怎么把旧树变成新树"。这个分离是 React 多平台支持的基础。


这一章想说的

调和是 React 对比新旧两棵 React Element 树、决定哪些需要更新的过程。Diff 算法建立在两个假设上:不同类型元素产生不同树,相同 key 暗示子元素稳定。

React 通过这 三个 Diff 策略(不同类型元素、同类型 DOM 元素、同类型组件元素)实现了高效的树对比。但这些策略依赖开发者的配合:保持 DOM 类型稳定、使用唯一稳定的 key。

Fiber 架构让调和变成了可中断的工作单元,为 React 的并发渲染奠定了基础。

Footnotes

  1. https://github.com/acdlite/react-fiber-architecture