Lazy loaded image
前端
React - Diff 算法深度解
字数 3326阅读时长 9 分钟
2026-1-19
2026-1-19
type
status
date
slug
summary
tags
category
icon
password
📢 大家好,我是小陈同学,这篇文章将彻底厘清React Diff 算法的原理
📢 愿你忠于自己,热爱生活

一、示例代码

文件:01-验证diff算法.html 核心代码如下:
说明:
  • 状态 items 是一个数组,里面有 idvalue
  • addItem:在数组尾部添加一个新元素。
  • moveFirstItem:把第一个元素挪到最后。
  • 上半部分列表:key={item.id},并显示 id-value
  • 下半部分列表:key={index},并显示 index-value
这样设计,是为了更直观地观察:当我们操作列表时,id、索引 index、真实显示顺序之间的关系,以及 React Diff 算法是如何利用 key 的。

二、React 渲染更新流程回顾

每次调用 setItems 时,React 大致做三步:
  1. 重新执行组件函数 App()
      • 根据新的 items 返回新的 JSX。
      • JSX 被 Babel 转成虚拟 DOM(JS 对象结构)。
  1. 新旧虚拟 DOM 做 Diff
      • 比较新旧虚拟 DOM 树:
        • 哪些节点可以复用?
        • 哪些需要新增?
        • 哪些需要删除?
        • 哪些只需要更新属性/文本?
  1. 按 Diff 结果更新真实 DOM
      • 尽量复用原来的 DOM 节点,只做必要改动。
目标:在保证页面结果正确的前提下,尽量少操作真实 DOM。

三、React Diff 算法的三个核心假设

  1. 同层比较,不跨层
      • React 只在同一层级的子节点中进行比较。
      • 如果节点类型变了(例如从 <ul> 变成 <div>),React 认为整棵子树都会被替换。
  1. 通过 key 识别同一节点
      • 在同一个父节点下的一组子节点(如 <li> 列表),React 使用 key 来判断:
        • key 相同 → 认为是“同一节点”,优先复用。
        • key 不同 → 认为是“不同节点”,会删除旧的、创建新的。
      • 所以:key 是 Diff 算法的“身份证”。
  1. 列表 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-Apple2-Banana3-Cherry4-New 4
而且每个数据项的 id 与 DOM 节点的 key 是一一对应的。

3. 下半部分:key={index}(索引 key)

旧索引:0、1、2
新索引:0、1、2、3
此时推演结果:
  • 对于“只在尾部 append”的操作,上下两种 key 策略表现基本一致:
    • 旧索引 0、1、2 复用;
    • 新增索引 3 对应一个新 <li>
你看到的下半列表是:
  • 0-Apple1-Banana2-Cherry3-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=11-Banana1-Cherry
    • key=22-Cherry2-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?

结合这个例子,可以更直观地理解原因:
  1. index 跟“数组下标”绑定,而不是跟“数据项身份”绑定
      • 一旦中间插入/删除/重排,index 就会重新计算。
      • 但 React 只认 key,不认你数组里面的 id 或 value。
  1. 重排/插入/删除时,会造成“错误复用”
      • React 以为“这个 key 的节点还是同一个”,就会直接拿旧 DOM 来用,只改内容。
      • 但对你来说,“这个位置的数据项已经换人了”。
  1. 有状态子组件最容易出问题
      • 比如:输入框的值、勾选状态、动画进度……
      • 这些状态跟 DOM 节点绑定,而不是跟你的 items[i] 绑定。
      • 一旦 key 不再真正代表“数据项身份”,状态和数据就会错位。
在你这个 demo 里,通过显示 id-valueindex-value
  • 能很清楚地看到:同一次操作之后,
    • 上半部分的 id 顺序是如何变化的;
    • 下半部分的 index 是如何重新计算、而 DOM 节点却没真正移动的。

七、实践总结:如何写出“对 Diff 友好”的列表

  1. 凡是列表渲染,都要加 key
      • 不管是 <li><tr>、自定义组件 <Item />,都应该有 key
  1. 优先使用稳定且唯一的业务 id
      • 典型写法:
    1. 避免使用 index 作为 key
        • 只在非常简单、确定不会插入/删除/重排的列表中、临时使用 index。
        • 一旦有:
          • 中间插入元素、
          • 删除元素、
          • 改变顺序, 就会带来潜在问题。
    1. 理解“最小化重绘”的底层逻辑
        • React 并不是“每次都整棵树重建”;
        • 而是借助 Virtual DOM + Diff + key 信息:
          • 复用能复用的节点;
          • 只在必须的地方插/删/改/移动 DOM。

    八、带 input 的示例:直观看到 index 作为 key 的问题

    上面的分析主要是通过文本和顺序来理解 Diff 行为,这一节我们再通过一个带输入框的示例,直观看到:
    • 使用 key=id 时,输入框里的内容会跟着对应数据项一起移动
    • 使用 key=index 时,输入框里的内容会留在原来的 DOM 位置,导致显示错乱

    1. 示例代码

    下面是一个完整的 demo,你可以直接放在新的 html 文件里试一试:
    提示:如果你的目录结构不同,可以把 script src 路径改成你实际的 React、ReactDOM、Babel 路径即可。

    2. 操作步骤 & 观察现象

    1. 先看左边“使用稳定 key:id”的列表:
        • 在第二行(例如 2-Banana)的输入框里输入一些自定义内容,比如 我改了 Banana
        • 点击几次「Move First Item」按钮;
        • 你会发现:
          • 文本 我改了 Banana跟着 2-Banana 这一行一起移动
          • 不管 2-Banana 排在第几行,它的输入框内容都对得上。
    1. 再看右边“使用 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”。
    上一篇
    k8s集群部署
    下一篇
    React-组件的生命周期