type
status
date
slug
summary
tags
category
icon
password
📢 大家好,我是小陈同学,这篇文章将彻底厘清React Diff 算法的原理📢愿你忠于自己,热爱生活
一、示例代码
文件:
01-验证diff算法.html 核心代码如下:说明:
- 状态
items是一个数组,里面有id和value。
addItem:在数组尾部添加一个新元素。
moveFirstItem:把第一个元素挪到最后。
- 上半部分列表:
key={item.id},并显示id-value。
- 下半部分列表:
key={index},并显示index-value。
这样设计,是为了更直观地观察:当我们操作列表时,id、索引 index、真实显示顺序之间的关系,以及 React Diff 算法是如何利用
key 的。二、React 渲染更新流程回顾
每次调用
setItems 时,React 大致做三步:- 重新执行组件函数
App() - 根据新的
items返回新的 JSX。 - JSX 被 Babel 转成虚拟 DOM(JS 对象结构)。
- 新旧虚拟 DOM 做 Diff
- 比较新旧虚拟 DOM 树:
- 哪些节点可以复用?
- 哪些需要新增?
- 哪些需要删除?
- 哪些只需要更新属性/文本?
- 按 Diff 结果更新真实 DOM
- 尽量复用原来的 DOM 节点,只做必要改动。
目标:在保证页面结果正确的前提下,尽量少操作真实 DOM。
三、React Diff 算法的三个核心假设
- 同层比较,不跨层
- React 只在同一层级的子节点中进行比较。
- 如果节点类型变了(例如从
<ul>变成<div>),React 认为整棵子树都会被替换。
- 通过
key识别同一节点 - 在同一个父节点下的一组子节点(如
<li>列表),React 使用key来判断: key相同 → 认为是“同一节点”,优先复用。key不同 → 认为是“不同节点”,会删除旧的、创建新的。- 所以:
key是 Diff 算法的“身份证”。
- 列表 Diff 使用 O(n) 线性算法
- 没有做最小编辑距离那种昂贵算法。
- 而是用一趟遍历 +
key映射来找出“复用/新增/删除/移动”。 - 因此:
key提供的语义越清晰,Diff 的结果越合理。
四、场景一:添加元素时的 Diff(addItem)
1. 状态变化前后
初始
items:调用一次
addItem() 后:2. 上半部分:key={item.id}(稳定 key)
旧虚拟 DOM children(简化):
新虚拟 DOM children:
Diff 结果(从
key 角度):- 旧 keys:1、2、3
- 新 keys:1、2、3、4
- key=1、2、3 → 节点被复用(内容没变,几乎不动)。
- key=4 → 新增节点,在 DOM 末尾插入
<li>。
真实 DOM 操作:
- 只在最后多插入一个
<li>元素;
- 原来的三个
<li>DOM 节点保持不动 → “最小化重绘”。
此时你在页面上看到的是:
- 上半列表:
1-Apple、2-Banana、3-Cherry、4-New 4
而且每个数据项的
id 与 DOM 节点的 key 是一一对应的。3. 下半部分:key={index}(索引 key)
旧索引:0、1、2
新索引:0、1、2、3
此时推演结果:
- 对于“只在尾部 append”的操作,上下两种 key 策略表现基本一致:
- 旧索引 0、1、2 复用;
- 新增索引 3 对应一个新
<li>。
你看到的下半列表是:
0-Apple、1-Banana、2-Cherry、3-New 4
结论:在“只往末尾加元素”的简单场景里,用 index 做 key 看不出大问题。
五、场景二:重排元素时的 Diff(moveFirstItem)
现在看更能暴露 Diff 行为的
moveFirstItem:对
[1,2,3] 来说:- 旧顺序:
[1, 2, 3]
- 新顺序:
[2, 3, 1]
1. 上半部分:key={item.id}
旧虚拟 DOM:
新虚拟 DOM:
Diff 过程要点:
- React 根据新列表中每个
key,在旧列表中寻找: - key=2:找到旧第二个节点 → 复用,移动到第一位;
- key=3:找到旧第三个节点 → 复用,移动到第二位;
- key=1:找到旧第一个节点 → 复用,移动到最后。
- DOM 层面:
- 三个
<li>DOM 节点被真正移动顺序,而不是重新创建。
此时你看到的上半列表内容是(视觉上):
2-Banana
3-Cherry
1-Apple
可以清楚地看出:id 是跟着数据项一起“移动”的。
这说明:在使用稳定的 key=id 时,React 能够通过 Diff 只移动 DOM 节点位置,最大化复用节点。
2. 下半部分:key={index}
旧虚拟 DOM:
新虚拟 DOM:
注意:
- key 仍然是 0、1、2(因为 index 每次都是按新数组位置重新计算)。
- React 的视角:
key=0:旧<li>节点是0-Apple,新的是0-Banana→ 认为同一节点,只改内容。key=1:1-Banana→1-Cherry。key=2:2-Cherry→2-Apple。
真实 DOM 操作:
- 没有任何
<li>节点被移动位置;
- 只是三个
<li>节点仍然按旧顺序排着,但各自显示的文本内容被修改。
你在页面下半部分看到的结果是(内容对,但意义不同):
0-Banana
1-Cherry
2-Apple
问题在于:
- 对 React 来说,“key=0 的那一个 DOM 节点一直是同一个,只是内容从 Apple 变成 Banana”;
- 但从数据模型看,“原来排在第一位的 item(id=1)已经跑到最后了”。
如果
<li> 里包的是一个有内部状态的子组件(例如有输入框、复选框、动画等):- 使用
key=id时:状态跟着“id 对应的数据项”一起重排;
- 使用
key=index时:状态跟着“原来的 DOM 节点”走,但 index 变了 → 容易出现状态错位。
六、为什么不要用 index 作为 key?
结合这个例子,可以更直观地理解原因:
- index 跟“数组下标”绑定,而不是跟“数据项身份”绑定
- 一旦中间插入/删除/重排,index 就会重新计算。
- 但 React 只认 key,不认你数组里面的 id 或 value。
- 重排/插入/删除时,会造成“错误复用”
- React 以为“这个 key 的节点还是同一个”,就会直接拿旧 DOM 来用,只改内容。
- 但对你来说,“这个位置的数据项已经换人了”。
- 有状态子组件最容易出问题
- 比如:输入框的值、勾选状态、动画进度……
- 这些状态跟 DOM 节点绑定,而不是跟你的
items[i]绑定。 - 一旦 key 不再真正代表“数据项身份”,状态和数据就会错位。
在你这个 demo 里,通过显示
id-value 和 index-value:- 能很清楚地看到:同一次操作之后,
- 上半部分的 id 顺序是如何变化的;
- 下半部分的 index 是如何重新计算、而 DOM 节点却没真正移动的。
七、实践总结:如何写出“对 Diff 友好”的列表
- 凡是列表渲染,都要加
key - 不管是
<li>、<tr>、自定义组件<Item />,都应该有key。
- 优先使用稳定且唯一的业务 id
- 典型写法:
- 避免使用 index 作为 key
- 只在非常简单、确定不会插入/删除/重排的列表中、临时使用 index。
- 一旦有:
- 中间插入元素、
- 删除元素、
- 改变顺序, 就会带来潜在问题。
- 理解“最小化重绘”的底层逻辑
- React 并不是“每次都整棵树重建”;
- 而是借助 Virtual DOM + Diff + key 信息:
- 复用能复用的节点;
- 只在必须的地方插/删/改/移动 DOM。
八、带 input 的示例:直观看到 index 作为 key 的问题
上面的分析主要是通过文本和顺序来理解 Diff 行为,这一节我们再通过一个带输入框的示例,直观看到:
- 使用
key=id时,输入框里的内容会跟着对应数据项一起移动;
- 使用
key=index时,输入框里的内容会留在原来的 DOM 位置,导致显示错乱。
1. 示例代码
下面是一个完整的 demo,你可以直接放在新的 html 文件里试一试:
提示:如果你的目录结构不同,可以把 script src 路径改成你实际的 React、ReactDOM、Babel 路径即可。
2. 操作步骤 & 观察现象
- 先看左边“使用稳定 key:id”的列表:
- 在第二行(例如
2-Banana)的输入框里输入一些自定义内容,比如我改了 Banana; - 点击几次「Move First Item」按钮;
- 你会发现:
- 文本
我改了 Banana会跟着2-Banana这一行一起移动; - 不管
2-Banana排在第几行,它的输入框内容都对得上。
- 再看右边“使用 index 作为 key”的列表:
- 同样在第二行输入
我改了 Banana; - 然后点击「Move First Item」按钮;
- 你会看到:
- 行的 label 变了(比如变成了
1-Cherry), - 但输入框里的内容
我改了 Banana还留在原来的 DOM 节点上; - 也就是说:输入框的内容对不上这一行的 label 了,状态和数据“错位”了。
3. 现象背后的原因
ItemInput有自己的本地状态text,这个状态是跟“组件实例”绑定的;
- React 通过
key来判断“这是不是同一个组件实例”: - 使用
key=id时: - 每个数据项(id)始终对应同一个
ItemInput实例; - 重排只是“移动实例的位置”,状态自然也跟着对应的数据项移动;
- 使用
key=index时: - 每一行的组件实例是跟“行号”绑定的,而不是跟“数据项 id”绑定;
- 重排后,index 重新计算,但 React 认为“key=0 的还是原来那个组件实例”, 于是把新的数据渲染到旧的组件上,旧的状态却没变,导致显示错乱。
4. 小结:什么时候 index 还能用?
- 只有在下面这种非常受限的场景中,index 还能勉强接受:
- 列表是静态的,不会插入、删除、重排,只是在末尾简单 append;
- 列表项本身没有有意义的本地状态(只是简单展示文本/图标)。
- 一旦:
- 中间插入 / 删除元素;
- 重排顺序;
- 或者列表项内部有表单、动画、复杂状态;
就应该坚决使用稳定的业务 key(例如 id),避免 index 带来的各种“幽灵 bug”。
- 作者:NotionNext
- 链接:https://tangly1024.com/article/2eddb897-8f81-80e3-a903-c86e48de9cc0
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。






