react:浅聊 vdom 与 diff 算法

首先讲讲vdom

vdom 是虚拟dom,就是用普通js 对象去模拟真实dom 树结构。

真实dom 是什么?

比如说你写的:

复制代码
// html
<div id="box">
  <p>hello</p>
</div>

浏览器会创建一堆庞大、笨重、属性超多的 dom 对象:

  • HTMLElement
  • childNodes
  • style
  • className
  • 各种事件、原型链......

显而易见,操作真实 dom 非常慢。

vdom 呢?

就是用轻量的 JS 对象描述 dom:

复制代码
// js
const vdom = {
  type: 'div',
  props: { id: 'box' },
  children: [
    { type: 'p', props: null, children: ['hello'] }
  ]
}

它不是真实 dom,只是一个普通对象,非常轻、非常快。

为什么需要vdom?

因为:

  • 操作真实dom 超级慢
  • 操作js 对象超级快

react 是基于vdom 的前端框架,它做的事是:

  1. 数据变化 → 生成新vdom
  2. 和旧vdom 做 diff(对比差异)
  3. 算出最小改动
  4. 最后只操作一点点真实 dom

这就是性能高的原因。

diff 算法

讲讲理论

浏览器的 dom 操作(如添加、删除、修改节点)是非常消耗性能的,被称为 "昂贵" 的操作。

没有 diff 算法时:如果页面状态变了,react 可能会把整个页面销毁,重新渲染一遍。这会导致页面闪烁、卡顿,用户体验极差。

有了 diff 算法后:react 会先在内存中对比 "更新前" 和 "更新后" 的两棵vdom 树,精准计算出到底哪里变了。

是文字变了?只更新文本。

是颜色变了?只更新样式。

是列表顺序变了?只移动节点,而不是销毁重建。

所以说:diff 算法就是把成千上万的dom 操作减少到最少的几次。

在react 中,diff 算法扮演着"性能优化引擎"和"更新指挥官"的双重角色。简单来说,它的核心作用就是以最小的代价,将vdom 的变化同步到真实 dom 上。

简单diff 算法

两棵树做传统diff,每个节点都需要与另一棵树的全部节点逐一对比,这里是一层o(n);找到变化的节点后执行插入、删除、修改操作,这里又是一层o(n);整棵树上所有节点都要这样处理,还要再来一层o(n)。最终的复杂度就是o(nnn)。

这样的性能开销对于前端来说是不可接受的。想想看,如果有1000 个节点,渲染一次就要处理 1000 * 1000 * 1000,一共 10 亿次。

所以react 对diff 算法做了三项核心优化,将复杂度降低到了o(n):

  1. 只进行同层节点比较,不跨层级对比节点只会和同一层级的旧节点对比,不会跨父子层级查找,大幅减少对比次数。
  2. 节点类型不同,直接销毁重建如果新旧节点的标签类型不一样,react 不会继续对比子节点,而是直接删除旧节点,创建新节点。
  3. 列表节点使用 key 精准匹配渲染列表时,react 依靠 key 识别每个节点的唯一身份。key 相同,react 认为是同一个节点,会复用 dom 并更新内容;key 不同,则认为是新节点,执行删除或新建操作。这样就可以减少dom 的操作次数。

那么,假设渲染ABCD 一组节点,再次渲染时是ACDB,这时候怎么处理呢?

我们来讲一个通俗易懂的场景:

复制代码
想象你在操场上按旧顺序(旧列表)站了一排人:`[A, B, C, D]`。  
现在教练(Diff 算法)手里拿了一张新名单(新列表),要求大家按新名单的顺序重新排好。

教练手里的新名单是:`[A, C, D, B]`。

旧队伍(操场): A(0) - B(1) - C(2) - D(3) (括号里是旧索引)
新名单(目标): A - C - D - B
路标 (lastIndex): 初始为 -1 (表示还没人站定)

第一步:处理新名单第 1 个 ------ A
教练喊:"A 出来!"
A 在旧队伍的位置是 0。
判断: A 的位置 (0) 比路标 (-1) 靠后吗? 是。
结论: A 不需要动,它就在最前面。
更新路标: 教练把路标插到 0 的位置(lastIndex = 0)。意思是:"目前为止,最靠后的有效节点在索引 0"。

第二步:处理新名单第 2 个 ------ C
教练喊:"C 出来!"
C 在旧队伍的位置是 2。
判断: C 的位置 (2) 比路标 (0) 靠后吗? 是。
结论: C 不需要动。
为什么? 因为 A 在 0,C 在 2。在旧队伍里 A 就在 C 前面,在新名单里 A 也在 C 前面。它们的相对顺序没变! 既然相对顺序没变,DOM 结构里它们就是顺位的,不需要做插入移动操作。
更新路标: 教练把路标往前移到 2(lastIndex = 2)。

第三步:处理新名单第 3 个 ------ D
教练喊:"D 出来!"
D 在旧队伍的位置是 3。
判断: D 的位置 (3) 比路标 (2) 靠后吗? 是。
结论: D 不需要动。
为什么? 刚才定好的 C 在 2,D 在 3。旧队伍里 C 在 D 前面,新名单也是。顺势而为,不用动。
更新路标: 教练把路标移到 3(lastIndex = 3)。

第四步:处理新名单第 4 个 ------ B
教练喊:"B 出来!"
B 在旧队伍的位置是 1。
判断: B 的位置 (1) 比路标 (3) 靠后吗? 不是!(1 < 3)
结论: B 需要移动!
为什么? 这是关键!
刚才我们已经确认了 D (旧索引3) 是"不用动"的。
现在 B (旧索引1) 跑出来了。在旧队伍里,B 在 D 的前面。
但是在新名单里,B 要在 D 的后面。
冲突了! 物理上,B 被 D 挡在后面。为了让 B 跑到 D 后面去,必须把 B 从旧位置"拔出来",移动到新队列的末尾。

代码就是这样的:

复制代码
// 旧的子节点(旧虚拟DOM列表)
const oldChildren = n1.children
// 新的子节点(新虚拟DOM列表)
const newChildren = n2.children

// 核心变量:记录【旧列表中,已经遍历过的最大下标】
let lastIndex = 0

// 1. 【外层循环】遍历 **新列表**
// 关键:React 永远以【新列表】的顺序为最终顺序
for (let i = 0; i < newChildren.length; i++) {
    const newVNode = newChildren[i] // 当前新节点
    let j = 0                        // 旧列表遍历下标
    let find = false                 // 是否在旧列表中找到相同key

    // 2. 【内层循环】遍历 **旧列表**
    // 目的:找 key 相同的节点 → 代表可以复用
    for (; j < oldChildren.length; j++) {
        const oldVNode = oldChildren[j]

        // 3. 找到 key 相同 → 说明是同一个节点
        if (newVNode.key === oldVNode.key) {
            find = true // 标记找到了

            // 4. 执行更新:把旧节点更新成新节点内容(复用DOM)
            patch(oldVNode, newVNode, container)

            // ======================
            // 【核心:移动判断逻辑】
            // ======================
            if (j < lastIndex) {
                // ----------------------
                // 1. 需要移动 DOM
                // ----------------------
                // 找到要插入的位置:当前新节点的前一个节点的后面
                const prevVNode = newChildren[i - 1]
                // anchor 就是一个参照物节点,作用只有一个,告诉浏览器:把 DOM 插到谁的前面
                let anchor = null

                if (prevVNode) {
                    // 有前一个节点 → 插到它后面
                    anchor = prevVNode.el.nextSibling
                } else {
                    // 没有前一个 → 插到最开头
                    anchor = container.firstChild
                }

                // 移动 DOM:把旧节点 el 移动到 anchor 前面
                container.insertBefore(oldVNode.el, anchor)

            } else {
                // ----------------------
                // 2. 不需要移动,更新 lastIndex
                // ----------------------
                lastIndex = j
            }

            break // 找到就跳出,继续下一个新节点
        }
    }

    // 5. 如果在旧列表里【没找到】→ 说明是【新增节点】
    if (!find) {
        const prevVNode = newChildren[i - 1] // 前一个节点
        let anchor = null

        // 找插入位置:插到前一个节点后面
        if (prevVNode) {
            anchor = prevVNode.el.nextSibling
        } else {
            anchor = container.firstChild // 前面没节点,插最开头
        }

        // 6. 创建新DOM 插入页面
        patch(null, newVNode, container, anchor)
    }
}

为什么用key 不用index?

前面我们已经知道:react 的 diff 算法在对比列表时,会根据key 来识别节点身份,判断是否可以复用 dom、是否需要移动。key 是节点的唯一标识,相当于身份证。

但很多人习惯用 key={index},这是非常危险的,因为:

index 是位置,不是身份;位置会变,身份不能变。

下面用最简单的例子一步一步看:

  1. 初始数组(带输入框)

数组:[苹果,香蕉,橘子]

用 index 当 key:

  • key=0 → 苹果
  • key=1 → 香蕉
  • key=2 → 橘子

你在输入框里输入:

  • 苹果输入:我是苹果
  • 香蕉输入:我是香蕉
  • 橘子输入:我是橘子

重点:react 依赖 key 来复用 dom 节点,如果 key 错乱,react 会错误地保留旧 dom节点(及其内部的状态,如输入框内容、播放进度等)

  1. 删除第一个节点:苹果

数组变成:[香蕉,橘子]

这时候index 自动变了:

  • 香蕉 → index 0 → key=0
  • 橘子 → index 1 → key=1

  1. react diff 开始对比

react 只认 key,不认内容:

旧 key:0、1、2新 key:0、1

react 判断:

  • key=0 还在 → 复用 dom
  • key=1 还在 → 复用 dom
  • key=2 不在了 → 删除

它完全不管 key 对应的内容是不是换了!

  1. 灾难发生:内容错位
  • key=0 原来显示苹果,现在改成香蕉但输入框还是原来的 我是苹果
  • key=1 原来显示香蕉,现在改成橘子但输入框还是原来的 我是香蕉

最终页面显示:

复制代码
香蕉:我是苹果
橘子:我是香蕉

内容彻底错位!

  1. 为什么会这样?(核心原理)

因为:index 会随着数组增删、排序而变化,key 一旦变化,react 就无法识别节点真实身份**

  • 用 index → key 不稳定 → 身份错乱 → dom 错误复用 → 数据错位
  • 用唯一 id → key 稳定 → 身份正确 → dom 精准复用 → 不会错乱
  1. 结合前面 diff 列表移动逻辑理解

前面讲列表 diff(ABCD → ACDB)时:react 依靠 稳定不变的 key 才能判断:

  • 谁是谁
  • 谁需要移动
  • 谁可以复用

如果用 index 当 key:

  • 顺序一变,key 就变
  • diff 算法完全失效
  • 无法正确移动节点
  • 只能销毁重建,或出现错位 bug

要记住:key 是身份标识,不是位置序号,永远不要用 index 做 key!

相关推荐
恋猫de小郭2 小时前
Flutter 3.41.7 ,小版本但 iOS 大修复,看完只想说:这是人能写出来的 bug ?
android·前端·flutter
止语Lab2 小时前
记忆溢出:当你的 Agent 记得太多时会发生什么
前端·javascript·vue.js
天天向上10242 小时前
vue openlayers地图加载大量点位时优化
前端·javascript·vue.js
计算机魔术师2 小时前
【AI面试八股文 Vol.1.1 | 专题3:State Schema 设计】State Schema设计:TypedDict / Pydantic类型约束
linux·人工智能·面试
devil-J2 小时前
vue3+three.js中国3D地图
开发语言·javascript·3d
1368木林森2 小时前
字节 / 美团二面高频题:订单 30 分钟未支付自动取消?3 种进阶方案拆解 + 面试满分回答
面试·职场和发展
人道领域2 小时前
【LeetCode刷题日记】:151翻转字符串的单词(两种解法)
java·开发语言·算法·leetcode·面试
菩提小狗2 小时前
第42天:WEB攻防-PHP应用&MYSQL架构&SQL注入&跨库查询&文件读写_笔记|小迪安全2023-2024|web安全|渗透测试|
前端·安全·php
我叫黑大帅2 小时前
从零实现一个完整 RAG 系统:基于 Eino 框架的检索增强生成实战
后端·面试·go