vue中diff算法的原理
一、Vue 中 Diff 算法的原理与具体过程
Vue 的 虚拟 DOM Diff 算法 是其高效渲染的核心,它通过比较新旧虚拟 DOM(VNode)的差异,计算出最小的 DOM 操作,从而提升性能。以下是 Vue 中 Diff 算法的详细原理和过程。
1. Diff 算法的核心策略
Vue 的 Diff 算法基于 “同层比较” 和 “双端比较” 策略,主要优化点包括:
- 同层比较:
- 只比较同一层级的节点,不跨层级移动(复杂度从 O(n³) 降到 O(n))。
- 如果节点类型不同(如
div
→span
),直接销毁旧节点,创建新节点。
- 双端比较(Four Pointers 算法):
- 对新旧子节点列表(
children
)进行 头-头、尾-尾、头-尾、尾-头 的交叉对比,尽量复用 DOM 节点。 - 通过
key
标识节点,优化列表更新(类似 React,但具体实现略有不同)。
- 对新旧子节点列表(
- 组件级别优化:
- 如果组件类型相同(如相同的 Vue 组件),复用实例并更新
props
/slots
。 - 如果组件类型不同,销毁旧组件,挂载新组件。
- 如果组件类型相同(如相同的 Vue 组件),复用实例并更新
2. Diff 的具体过程
(1)比较根节点(Patch)
- 如果新旧节点类型不同(如
div
→span
):
直接销毁旧节点及其子节点,创建新节点并插入 DOM。
// 旧 VNode
<div>Hello</div>// 新 VNode
<span>Hello</span>// 操作:删除 div,插入 span
- 如果节点类型相同(如都是
div
):- 更新节点的属性(如
class
、style
、event
)。 - 进入子节点比较(
updateChildren
)。
- 更新节点的属性(如
(2)比较子节点(UpdateChildren)
Vue 使用 双端指针法 对比新旧子节点列表(oldChildren
和 newChildren
):
- 初始化 4 个指针:
oldStartIdx
/oldEndIdx
:旧列表的头尾索引。newStartIdx
/newEndIdx
:新列表的头尾索引。
- 循环比较,直到任一列表遍历完成:
- 头-头相同(
oldStartVNode
===newStartVNode
):
复用节点,移动oldStartIdx
和newStartIdx
向右。 - 尾-尾相同(
oldEndVNode
===newEndVNode
):
复用节点,移动oldEndIdx
和newEndIdx
向左。 - 头-尾相同(
oldStartVNode
===newEndVNode
):
复用节点,但需要将旧节点移动到列表末尾,移动指针。 - 尾-头相同(
oldEndVNode
===newStartVNode
):
复用节点,但需要将旧节点移动到列表头部,移动指针。 - 如果均不匹配:
- 如果有
key
,尝试通过key
查找可复用节点。 - 如果找不到,创建新节点插入。
- 如果有
- 头-头相同(
- 处理剩余节点:
- 如果旧列表先遍历完,将新列表剩余节点插入 DOM。
- 如果新列表先遍历完,删除旧列表剩余节点。
*� 示例:
// 旧子节点列表
[A, B, C, D] // oldChildren// 新子节点列表
[D, A, B, E] // newChildren// Diff 过程:
1. 头-头(A ≠ D)→ 不匹配
2. 尾-尾(D ≠ E)→ 不匹配
3. 头-尾(A ≠ E)→ 不匹配
4. 尾-头(D === D)→ 复用 D,移动 D 到头部- 操作:移动 D 到最前面- 指针移动:oldEndIdx--, newStartIdx++5. 新一轮比较:- 旧列表:[A, B, C]- 新列表:[A, B, E]- 头-头(A === A)→ 复用 A- 头-头(B === B)→ 复用 B- 剩余:旧列表 [C],新列表 [E]- 删除 C,插入 E
3. Key 的作用
- 唯一标识节点:帮助 Diff 算法识别哪些节点可以复用。
- 错误用法(如用
index
当key
):
<!-- 不推荐:用 index 当 key -->
<div v-for="(item, index) in list" :key="index">{{ item }}
</div><!-- 推荐:用唯一 ID 当 key -->
<div v-for="item in list" :key="item.id">{{ item.name }}
</div>
列表顺序变化时(如排序、插入),会导致错误的复用和性能问题。
4. 总结
- Vue Diff 的核心:
- 同层比较 + 双端指针法 + Key 优化。
- 具体过程:
- 先比较根节点,再通过双端比较更新子节点列表。
- 优化建议:
- 始终为
v-for
设置唯一的key
(不要用index
)。 - 避免频繁变更节点类型(如
div
→span
)。
- 始终为
二、Vue Diff 算法完整过程详解
下面我将通过一个完整的 DOM 更新例子,详细讲解 Vue 中 Diff 算法的整个执行过程,包括根节点比较和子节点比较。
示例场景
我们有一个简单的待办事项列表组件,初始状态和更新后的状态如下:
初始状态(旧 VNode)
<div class="todo-list"><h2>待办事项</h2><ul><li key="1">任务A</li><li key="2">任务B</li><li key="3">任务C</li></ul></div>
更新后状态(新 VNode)
<div class="todo-list updated"><h3>我的待办</h3><ul><li key="3">任务C</li><li key="1">任务A</li><li key="4">任务D</li></ul></div>
Diff 算法完整执行过程
第一步:比较根节点
- 比较标签名:
- 旧节点:
div
- 新节点:
div
- 结果:相同,继续比较
- 旧节点:
- 比较属性:
el.className = "todo-list updated";
旧属性:`class="todo-list"`新属性:`class="todo-list updated"`操作:更新 class 属性
- 标记需要比较子节点:
- 根节点有子节点,进入子节点比较
第二步:比较子节点(第一层)
根节点有两个直接子节点:
- 标题(h2 → h3)
- 列表(ul)
比较第一个子节点(h2 vs h3)
- 比较标签名:
- 旧节点:
h2
- 新节点:
h3
- 结果:不同,执行替换
- 旧节点:
- 操作:
const newTitle = document.createElement('h3');
newTitle.textContent = '我的待办';
parentNode.replaceChild(newTitle, oldTitle);
- 创建新 h3 元素
- 设置文本内容为"我的待办"
- 用新 h3 替换旧 h2
比较第二个子节点(ul vs ul)
- 比较标签名:
- 相同(都是 ul),继续比较
- 比较属性:
- 无属性变化
- 比较子节点:
- 进入 ul 的子节点(li 列表)比较
第三步:比较 ul 的子节点(双端指针算法)
旧子节点列表:
[{ tag: 'li', key: '1', children: '任务A' }, // A1{ tag: 'li', key: '2', children: '任务B' }, // B2{ tag: 'li', key: '3', children: '任务C' } // C3
]
新子节点列表:
[{ tag: 'li', key: '3', children: '任务C' }, // C3{ tag: 'li', key: '1', children: '任务A' }, // A1{ tag: 'li', key: '4', children: '任务D' } // D4
]
初始化指针
oldStartIdx = 0 (A1)
oldEndIdx = 2 (C3)
newStartIdx = 0 (C3)
newEndIdx = 2 (D4)
第一轮比较
- 头头比较(A1 vs C3):
- key 不同(1 ≠ 3),不匹配
- 尾尾比较(C3 vs D4):
- key 不同(3 ≠ 4),不匹配
- 头尾比较(A1 vs D4):
- key 不同(1 ≠ 4),不匹配
- 尾头比较(C3 vs C3):
DOM 操作:
ul.insertBefore(li_C, li_A);
- key 相同(3 === 3),匹配
- 操作:* 复用 C3 节点* 将 C3 移动到新位置(第一个子节点)* 移动指针:+ oldEndIdx-- → 1 (B2)+ newStartIdx++ → 1 (A1)
第二轮比较
更新后的指针:
oldStartIdx = 0 (A1)
oldEndIdx = 1 (B2)
newStartIdx = 1 (A1)
newEndIdx = 2 (D4)
- 头头比较(A1 vs A1):
DOM 操作:无
- key 相同(1 === 1),匹配
- 操作:* 节点已在正确位置* 移动指针:+ oldStartIdx++ → 1 (B2)+ newStartIdx++ → 2 (D4)
第三轮比较
更新后的指针:
oldStartIdx = 1 (B2)
oldEndIdx = 1 (B2)
newStartIdx = 2 (D4)
newEndIdx = 2 (D4)
- 头头比较(B2 vs D4):
- key 不同(2 ≠ 4),不匹配
- 尾尾比较(B2 vs D4):
- key 不同(2 ≠ 4),不匹配
- 头尾比较(B2 vs D4):
- key 不同(2 ≠ 4),不匹配
- 尾头比较(B2 vs D4):
- key 不同(2 ≠ 4),不匹配
- 查找 key 映射:
- 在剩余旧节点中查找 key=4 的节点
- 未找到(只有 B2)
- 处理剩余节点:
DOM 操作:
// 创建并插入 D4
const li_D = document.createElement('li');
li_D.textContent = '任务D';
ul.appendChild(li_D);// 删除 B2
ul.removeChild(li_B);
创建新节点 D4 并插入删除旧节点 B2
最终 DOM 结构
<div class="todo-list updated"><h3>我的待办</h3><ul><li key="3">任务C</li><li key="1">任务A</li><li key="4">任务D</li></ul></div>
Diff 算法总结
- 根节点比较:
- 标签名相同 → 比较属性 → 更新 class
- 子节点不同 → 进入子节点比较
- 子节点比较:
- h2 → h3:标签不同,直接替换
- ul 列表比较:
- 使用双端指针算法
- 移动 C3 到开头
- 保留 A1
- 删除 B2
- 新增 D4
- DOM 操作汇总:
- 1 次属性更新(div 的 class)
- 1 次节点替换(h2 → h3)
- 1 次节点移动(C3 移动到开头)
- 1 次节点删除(B2)
- 1 次节点创建和插入(D4)