首先讲讲vdom
vdom 是虚拟dom,就是用普通js 对象去模拟真实dom 树结构。
真实dom 是什么?
比如说你写的:
// html
<div id="box">
<p>hello</p>
</div>
浏览器会创建一堆庞大、笨重、属性超多的 dom 对象:
HTMLElementchildNodesstyleclassName- 各种事件、原型链……
显而易见,操作真实 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 的前端框架,它做的事是:
- 数据变化 → 生成新vdom
- 和旧vdom 做 diff(对比差异)
- 算出最小改动
- 最后只操作一点点真实 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):
- 只进行同层节点比较,不跨层级对比节点只会和同一层级的旧节点对比,不会跨父子层级查找,大幅减少对比次数。
- 节点类型不同,直接销毁重建如果新旧节点的标签类型不一样,react 不会继续对比子节点,而是直接删除旧节点,创建新节点。
- 列表节点使用 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 是位置,不是身份;位置会变,身份不能变。
下面用最简单的例子一步一步看:
- 初始数组(带输入框)
数组:[苹果,香蕉,橘子]
用 index 当 key:
- key=0 → 苹果
- key=1 → 香蕉
- key=2 → 橘子
你在输入框里输入:
- 苹果输入:我是苹果
- 香蕉输入:我是香蕉
- 橘子输入:我是橘子
重点:react 依赖 key 来复用 dom 节点,如果 key 错乱,react 会错误地保留旧 dom节点(及其内部的状态,如输入框内容、播放进度等)
- 删除第一个节点:苹果
数组变成:[香蕉,橘子]
这时候index 自动变了:
- 香蕉 → index 0 → key=0
- 橘子 → index 1 → key=1
- react diff 开始对比
react 只认 key,不认内容:
旧 key:0、1、2新 key:0、1
react 判断:
- key=0 还在 → 复用 dom
- key=1 还在 → 复用 dom
- key=2 不在了 → 删除
它完全不管 key 对应的内容是不是换了!
- 灾难发生:内容错位
- key=0 原来显示苹果,现在改成香蕉但输入框还是原来的 我是苹果
- key=1 原来显示香蕉,现在改成橘子但输入框还是原来的 我是香蕉
最终页面显示:
香蕉:我是苹果
橘子:我是香蕉
内容彻底错位!
- 为什么会这样?(核心原理)
因为:index 会随着数组增删、排序而变化,key 一旦变化,react 就无法识别节点真实身份**
- 用 index → key 不稳定 → 身份错乱 → dom 错误复用 → 数据错位
- 用唯一 id → key 稳定 → 身份正确 → dom 精准复用 → 不会错乱
- 结合前面 diff 列表移动逻辑理解
前面讲列表 diff(ABCD → ACDB)时:react 依靠 稳定不变的 key 才能判断:
- 谁是谁
- 谁需要移动
- 谁可以复用
如果用 index 当 key:
- 顺序一变,key 就变
- diff 算法完全失效
- 无法正确移动节点
- 只能销毁重建,或出现错位 bug
要记住:key 是身份标识,不是位置序号,永远不要用 index 做 key!