深度剖析 Diff 算法:React 是如何复用节点的?
从“列表一更新就乱跳、输入框错位、动画异常”这些真实 bug 出发,拆解 React Diff 的核心假设与策略:同层比较、单节点 Diff、多节点 Diff;讲清 key 的真实作用是“身份稳定”而非“性能开关”,以及为什么 index 当 key 会导致“复用错位”
你可能遇到过这种离谱现象:
- 列表头部插入一项,后面的输入框内容“串台”
- 列表重新排序,勾选状态跟着跑到别的行
- 加了动画后,元素明明没动却像“瞬移”
很多时候你会被建议:“给列表加 key”。
但这句话只对了一半——真正的关键不是“加 key”,而是:
让 React 在 Diff 时能正确识别每个节点的身份(identity),从而复用正确的节点。这一篇我们把 Diff 的核心策略拆开讲清:
- React Diff 的基本假设是什么?
- 单节点 vs 多节点 Diff 怎么做?
- key 到底决定了什么?
- 为什么 index 当 key 会“错”,而不是单纯“慢”?
0. 先给你一个高阶结论:Diff 解决的是“最小变更提交”,不是“找出所有不同”
React Diff 的任务不是做一个“完美的树对比”,而是:
- 在合理复杂度内(工程上可接受)
- 尽可能复用已有节点
- 生成一组最小且正确的 DOM 操作(插入/移动/删除/更新)
React 选择了一个非常关键的策略假设:
只在同一层级(同一父节点下)比较子节点
不做跨层级的节点移动推断。
这条假设直接把“树编辑距离”这种极难问题,压成了更可控的规模。
1. 为什么 React 不做“全局最优 Diff”?因为太贵、也不稳定
如果你想在两棵任意树之间求“最小编辑操作序列”,那属于经典的高复杂度问题:
- 计算量大到不可接受
- 还会引入很多“意料之外的移动”(对开发者不可预测)
React 的哲学是:
- 可预测优先
- 线性复杂度优先
- 把身份交给开发者(key)来声明
所以 React Diff 的核心设计就是两条:
- 同层比较
- 用 key 声明身份
2. 单节点 Diff:当 child 只有一个时,React 怎么判断“复用还是替换”?
先看一个常见场景:
return condition ? <A /> : <B />
这其实是“单节点 Diff”的典型情况:同一位置只能出现一个子节点。
2.1 复用的第一条件:type 一致
- 之前是
<A />,现在还是<A />:可复用 - 之前是
<A />,现在变<B />:直接替换(卸载 A,挂载 B)
为什么 type 这么重要?
type 决定了它背后对应的组件实例类型、状态结构、hooks 链表形态等。 type 变了,复用会导致状态语义崩坏。
2.2 key 在单节点里也有意义(但你不常感知)
单节点也可能出现 key,比如:
- 条件渲染下复用/不复用某个分支
- 强制重置组件状态(后面会给你一个技巧)
在单节点场景里,React 会用 “key + type” 来更稳定地判断身份。
3. 多节点 Diff:列表才是大头(也是 bug 集中地)
列表场景:
<ul>
{items.map((item) => (
<li key={item.id}>{item.text}</li>
))}
</ul>
多节点 Diff 的核心目标是:
- 尽可能复用已有节点
- 正确处理插入、删除、移动
- 生成最少的 DOM 操作
React 的多节点 Diff(概念上)可以分成两个阶段:
- 从左到右的顺序匹配(快路径)
- 建立映射(Map)处理乱序/移动(慢路径)
你不需要背具体实现,但要理解它为什么这么分。
4. 快路径:从左到右逐个比(最便宜)
当新旧列表大部分位置都没变时,React 可以直接顺序比:
- 旧第 0 个 vs 新第 0 个
- 旧第 1 个 vs 新第 1 个
- …
只要能对上(key/type 匹配),就复用并继续。
这种情况非常常见,比如:
- 只是在末尾 append
- 只是改了某些项的 props
- 列表长度没变,顺序也没变
这条路径能做到近似 O(n) 且常数小。
5. 慢路径:一旦对不上,就需要 key 来“认人”
当 React 发现“顺序对不上”:
- 可能是中间插入
- 可能是删除
- 可能是排序
- 可能是把某些项移动到别的位置
此时它需要一种机制:
能在旧列表里快速找到“同一个身份”的节点在哪里。
这就是 key 的真实价值:
- key 是“身份 ID”
- 它让 React 能把旧节点放进一个 Map(key -> fiber)
- 新列表遍历时用 key 去 Map 里找,找到就复用并记录是否发生了移动
如果你没给 key(或者 key 不稳定),React 就只能用位置猜,猜错就会复用错。
6. key 的真正作用:身份稳定(不是“性能开关”)
你可能听过: “key 能让 diff 更快”。
这句话容易误导你把 key 当成性能优化。
更准确的是:
key 首先是正确性机制,其次才可能影响性能。 它声明:这个节点在多次渲染之间是“同一个东西”。
6.1 没有 key,会发生什么?
没有 key 时,React 会退化到“按位置复用”:
- 新第 i 个复用旧第 i 个
- 如果你在头部插入了一项,后面全部错位复用
错位复用就会导致:
- 输入框 value 串到别的行
- checkbox 勾选状态跑偏
- 动画基于错误节点执行
- 子组件的内部 state “被继承到别人身上”
你看到的是 UI 诡异行为,本质是:节点身份错了。
7. 为什么不能用 index 当 key?因为“位置”不是“身份”
很多人知道“不要用 index 当 key”,但原因说不清,只会背:
“会导致性能问题”。
真正的核心是:会导致复用语义错误。
7.1 复现一个必现 bug:头部插入导致输入串台
function List() {
const [items, setItems] = React.useState([
{ id: 'a', text: 'A' },
{ id: 'b', text: 'B' },
{ id: 'c', text: 'C' },
])
return (
<>
<button onClick={() => setItems([{ id: 'x', text: 'X' }, ...items])}>
insert head
</button>
{items.map((item, index) => (
<Row key={index} item={item} />
))}
</>
)
}
function Row({ item }) {
const [value, setValue] = React.useState('')
return (
<div>
<span>{item.text}</span>
<input value={value} onChange={(e) => setValue(e.target.value)} />
</div>
)
}
你在第二行输入点东西,然后点击 “insert head”,你会发现:
- 输入框内容跑到别的行去了
为什么?
因为你用 index 当 key:
- 插入头部后,所有 index 都 +1
- React 认为“这些都是同一批节点,只是 props 变了”
- 于是复用了旧 Row 的内部 state(输入框 value)到“新位置”的 item 上
你以为你在“移动数据”,React 实际在“移动 state”。
7.2 什么时候 index 作为 key 是安全的?
只有在列表永不变序、永不插入删除的情况下,比如:
- 静态渲染的一组固定项
- 永远 append 到末尾且不会重排(仍要小心)
只要存在:
- 插入
- 删除
- 排序
- 过滤
index key 都很容易出错。
8. “用 key 强制重置组件”的技巧(很实用)
有时你希望某个子树“完全重建”,丢掉内部 state,比如:
- 切换 tab 时希望表单清空
- 切换用户时希望重置某个复杂组件
你可以利用 key 的身份语义:
<Editor key={userId} />
当 userId 变化:
- React 认为这是“另一个身份”
- 会卸载旧 Editor,挂载新 Editor
- 内部 state 全部重置
这也是 key 的正确用法:控制身份与复用边界。
9. Diff 与 Fiber 的关系:Fiber 记录“复用/移动/删除”的副作用
回到 Fiber 的视角:
- diff 的结果不是直接改 DOM
- 而是给 fiber 打上 flags(插入/移动/删除/更新)
- commit 阶段再一次性应用到 DOM
所以你可以把 Diff 视为:
生成一份“如何把旧 UI 变成新 UI 的计划书”。
而 key 决定了计划书里“哪些节点是同一个人”。
10. 本文小结:你需要记住的 6 个关键点
- React Diff 的核心假设:同层比较,避免全局最优但昂贵的树编辑距离
- 单节点 Diff:主要看 type(+ key) 决定复用还是替换
- 多节点 Diff:先走顺序匹配快路径,错位后再用 key 建 Map 走慢路径
- key 的本质是 身份(identity),不是性能开关
- index 当 key 会导致“身份随位置变化”,从而复用错位(state 串台)
- key 还能用来人为控制重建边界(切换 key -> 强制重置)
留言讨论
Discussion
欢迎交流与反馈