说说 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:
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 策略是启发式的,在某些场景下并非最优:
- 跨层级移动:如果组件从树的一个位置移到另一个位置(层级变化),React 会销毁重建而非移动
- 尾部插入优于头部插入:由于从左到右遍历,在列表头部插入会导致大量节点标记为移动
- 类型变化导致子树重建:即使子树完全相同,只要根节点类型不同就会全部销毁
// 这会导致整个子树销毁重建,即使 children 完全一样
{isSection ? (
<section><Content /></section>
) : (
<div><Content /></div>
)}
// 更好的做法:保持类型不变
<div className={isSection ? 'section' : 'div'}>
<Content />
</div>
八、总结
React diff 算法通过三个核心策略实现了 O(n) 的比较效率:
- Tree Diff:只比较同层级节点,不跨层级
- Component Diff:相同类型复用,不同类型替换
- Element Diff:通过 key 识别列表元素的增删移动
理解 diff 原理有助于编写高性能的 React 代码:保持组件类型稳定、为列表提供稳定且唯一的 key、避免不必要的层级变化。这些实践与 diff 算法的假设保持一致,能让 React 做出最优的更新决策。