说说 React diff 的原理是什么?

一、是什么

Diff 算法是 React 协调(Reconciliation)过程的核心,用于比较前后两棵虚拟 DOM 树的差异,计算出最小的 DOM 操作集合。理论上完整比较两棵树的时间复杂度为 O(n³),但 React 通过三个启发式策略将复杂度降低到 O(n)。

旧虚拟 DOM 树     新虚拟 DOM 树
      A                 A
     / \               / \
    B   C             B   D      ← C → D 类型不同,整棵子树替换
   / \   \           / \
  E   F   G         E   F

二、三大 Diff 策略

策略一:Tree Diff — 同层级比较

React 只对同一层级的节点进行比较,不会跨层级移动节点。如果一个节点在旧树的某一层级存在而在新树的对应层级不存在,React 会直接删除它及其所有子节点,然后在新位置创建新节点。

层级 0:    A ←→ A      ✓ 同层比较
层级 1:   B C ←→ B D   ✓ 同层比较
层级 2:  E F G ←→ E F  ✓ 同层比较

不会发生:把层级 2 的 G 移动到层级 1

这个策略基于一个实践假设:DOM 节点跨层级移动的情况极少。放弃跨层级优化换来的是从 O(n³) 到 O(n) 的巨大性能提升。

策略二:Component Diff — 相同类型假设

对于组件节点,React 依据组件类型(函数/类引用)判断是否可以复用:

  • 相同类型:保留组件实例,更新 props,继续递归比较子树
  • 不同类型:销毁旧组件及其整棵子树,创建新组件
// 类型相同 → 复用,只更新 props
<UserCard name="张三" />  →  <UserCard name="李四" />

// 类型不同 → 销毁 UserCard,创建 AdminCard
<UserCard name="张三" />  →  <AdminCard name="张三" />

即使两个不同类型的组件渲染出完全相同的 DOM 结构,React 也不会尝试复用,而是直接替换。这个策略虽然在极端情况下不是最优的,但避免了昂贵的跨类型比较。

策略三:Element Diff — Key 标识

对于同一层级的同类型子元素列表,React 使用 key 来标识每个元素的身份,从而判断元素是新增、删除还是移动。

// 旧列表
<ul>
  <li key="a">苹果</li>
  <li key="b">香蕉</li>
  <li key="c">橙子</li>
</ul>

// 新列表:在头部插入一项
<ul>
  <li key="d">葡萄</li>
  <li key="a">苹果</li>
  <li key="b">香蕉</li>
  <li key="c">橙子</li>
</ul>

有 key 的情况下,React 通过 key 识别出 a/b/c 都未变化,只需在头部插入 d。如果没有 key,React 按索引对比,会认为每个位置的内容都变了,导致全部更新。

三、单元素 Diff

当新旧子节点都是单个元素时,diff 逻辑相对简单:

1. 比较 key 是否相同
   ├── key 不同 → 标记旧节点删除,创建新节点
   └── key 相同 → 继续比较 type
       ├── type 不同 → 标记旧节点删除,创建新节点
       └── type 相同 → 复用 Fiber 节点,更新 props

代码层面的行为:

// key 和 type 都相同 → 复用
<div key="box" className="old" />  →  <div key="box" className="new" />

// type 不同 → 不复用
<div key="box" />  →  <span key="box" />

// key 不同 → 不复用
<div key="a" />  →  <div key="b" />

四、列表 Diff 详解

列表 Diff 是最复杂的部分。React 采用从左到右遍历的策略,分为两轮处理:

第一轮:顺序比较

从左到右逐个比较新旧列表,直到遇到不匹配的节点:

旧:A  B  C  D  E
新:A  B  D  C  F

第一轮从左开始:
  A ←→ A  ✓ key 和 type 相同,复用
  B ←→ B  ✓ key 和 type 相同,复用
  C ←→ D  ✗ key 不同,停止第一轮

第二轮:Map 查找

将旧列表剩余节点放入 Map(key → Fiber),然后遍历新列表剩余节点在 Map 中查找可复用的节点:

旧列表剩余放入 Map:{ C → FiberC, D → FiberD, E → FiberE }

遍历新列表剩余:
  D → 在 Map 中找到 FiberD → 复用,标记移动
  C → 在 Map 中找到 FiberC → 复用,标记移动
  F → Map 中不存在 → 创建新节点

Map 中剩余:E → 标记删除

移动判断逻辑

React 使用 lastPlacedIndex 记录已处理的旧节点中最大的索引值,用于判断节点是否需要移动:

旧列表(带索引):A(0)  B(1)  C(2)  D(3)
新列表:          A     C     D     B

处理过程(lastPlacedIndex 初始为 0):
  A:旧索引 0 >= lastPlacedIndex(0) → 不移动,lastPlacedIndex = 0
  C:旧索引 2 >= lastPlacedIndex(0) → 不移动,lastPlacedIndex = 2
  D:旧索引 3 >= lastPlacedIndex(2) → 不移动,lastPlacedIndex = 3
  B:旧索引 1 <  lastPlacedIndex(3) → 需要移动

结果:只有 B 需要移动到末尾

这个算法的特点是:从左到右遍历,已经处于正确相对顺序的节点不需要移动

五、Diff 过程中的 Fiber 操作

在 Fiber 架构下,diff 的结果不是直接操作 DOM,而是给 Fiber 节点打上 Effect Tag:

Effect Tag含义对应 DOM 操作
Placement新增或移动appendChild / insertBefore
Update属性变化修改 DOM 属性
Deletion删除removeChild
ChildDeletion子节点删除批量 removeChild
render 阶段:遍历 Fiber 树,比较新旧节点,标记 Effect Tag

commit 阶段:遍历 Effect List,根据 Effect Tag 执行真实 DOM 操作

六、Key 的正确使用

为什么不能用 index 作为 key

当列表发生增删或重排时,index 会发生错位,导致 React 误判节点对应关系:

// 初始:items = ['A', 'B', 'C']
// 渲染:<li key={0}>A</li> <li key={1}>B</li> <li key={2}>C</li>

// 删除 B 后:items = ['A', 'C']
// 渲染:<li key={0}>A</li> <li key={1}>C</li>

// React 以为:key=0 的 A 没变,key=1 的内容从 B 变成了 C,key=2 被删除
// 实际上:应该是 key=1 的 B 被删除

这不仅导致多余的 DOM 操作,还会引发组件状态错乱——被复用的组件保留了错误的 state。

Key 的最佳实践

// 使用稳定且唯一的业务 ID
{users.map(user => <UserCard key={user.id} user={user} />)}

// 没有 ID 时,可以用内容的组合生成
{tags.map(tag => <Tag key={`${tag.category}-${tag.name}`} tag={tag} />)}

// 纯静态列表且不会重排时,index 可以接受
{menuItems.map((item, index) => <MenuItem key={index} item={item} />)}

七、Diff 算法的局限性

React 的 diff 策略是启发式的,在某些场景下并非最优:

  1. 跨层级移动:如果组件从树的一个位置移到另一个位置(层级变化),React 会销毁重建而非移动
  2. 尾部插入优于头部插入:由于从左到右遍历,在列表头部插入会导致大量节点标记为移动
  3. 类型变化导致子树重建:即使子树完全相同,只要根节点类型不同就会全部销毁
// 这会导致整个子树销毁重建,即使 children 完全一样
{isSection ? (
  <section><Content /></section>
) : (
  <div><Content /></div>
)}

// 更好的做法:保持类型不变
<div className={isSection ? 'section' : 'div'}>
  <Content />
</div>

八、总结

React diff 算法通过三个核心策略实现了 O(n) 的比较效率:

  1. Tree Diff:只比较同层级节点,不跨层级
  2. Component Diff:相同类型复用,不同类型替换
  3. Element Diff:通过 key 识别列表元素的增删移动

理解 diff 原理有助于编写高性能的 React 代码:保持组件类型稳定、为列表提供稳定且唯一的 key、避免不必要的层级变化。这些实践与 diff 算法的假设保持一致,能让 React 做出最优的更新决策。