2026.01.11
Renderer 更新 -> 双端 Diff
在上一章中,我们实现了 patchChildren 的大部分逻辑,但当新旧节点的子节点都是数组时,我们留下了一个 TODO。这种场景需要使用 diff 算法来高效地对比和更新子节点。本章将实现双端 diff 算法,这是 Vue 渲染器中最核心的优化之一。
在上一章中,我们实现了 patchChildren 的大部分逻辑,但当新旧节点的子节点都是数组时,我们留下了一个 TODO。这种场景需要使用 diff 算法来高效地对比和更新子节点。本章将实现双端 diff 算法,这是 Vue 渲染器中最核心的优化之一。

数组子节点的挑战
为什么需要 diff 算法
当新旧子节点都是数组时,我们不能简单地卸载所有旧节点再挂载所有新节点。这样虽然功能正确,但会导致大量不必要的 DOM 操作。diff 算法的目标就是找出新旧子节点之间的差异,并只对变化的部分进行最少的 DOM 操作。
function patchChildren(n1, n2) {
// ... 前面的逻辑
// 新旧子节点都是数组,使用 diff 算法
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 老的是数组
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 新的也是数组,进入全量 diff
patchKeyedChildren(n1.children, n2.children, el)
}
else {
// 新的不是数组,卸载老的数组
unmountChildren(n1.children)
}
}
// ...
}
双端 Diff 算法
核心思想
双端 diff 算法的核心思想是从两端同时向中间遍历,优先处理头部和尾部的相同节点。这种策略可以快速处理常见的场景,如在列表头部或尾部添加/删除元素。
实现步骤
function patchKeyedChildren(c1, c2, container) {
/**
* 双端 diff 算法流程:
* 1. 从头部开始对比,找出相同的前缀节点
* 2. 从尾部开始对比,找出相同的后缀节点
* 3. 根据对比结果处理剩余节点
*/
let i = 0 // 当前对比的起始索引
const e1 = c1.length - 1 // 旧子节点数组的最后一个元素索引
const e2 = c2.length - 1 // 新子节点数组的最后一个元素索引
// 1. 从头部开始对比(sync from start)
/*
* 场景示例:在尾部新增节点
* 旧节点 (c1): [a, b]
* 新节点 (c2): [a, b, c]
*
* 对比过程:
* - 初始状态: i = 0, e1 = 1, e2 = 2
* - 第一轮: a === a,patch(a, a),i++ -> i = 1
* - 第二轮: b === b,patch(b, b),i++ -> i = 2
* - 第三轮: i > e1,退出循环
* - 结果: i = 2, e1 = 1, e2 = 2
*/
while (i <= e1 && i <= e2) {
const n1 = c1[i]
const n2 = c2[i]
// 判断节点类型是否相同
if (isSameVNodeType(n1, n2)) {
// 类型相同,进行对比更新
patch(n1, n2, container)
}
else {
// 类型不同,停止头部对比
break
}
i++
}
// 2. 从尾部开始对比(sync from end)
/*
* 场景示例:在头部新增节点
* 旧节点 (c1): [a, b]
* 新节点 (c2): [c, a, b]
*
* 对比过程:
* - 头部对比后: i = 0, e1 = 1, e2 = 2(头部不同,立即退出)
* - 第一轮: b === b,patch(b, b),e1--, e2-- -> e1 = 0, e2 = 1
* - 第二轮: a === a,patch(a, a),e1--, e2-- -> e1 = -1, e2 = 0
* - 第三轮: e1 < i,退出循环
* - 结果: i = 0, e1 = -1, e2 = 0
*/
while (i <= e1 && i <= e2) {
const n1 = c1[e1]
const n2 = c2[e2]
if (isSameVNodeType(n1, n2)) {
// 类型相同,进行对比更新
patch(n1, n2, container)
}
else {
// 类型不同,停止尾部对比
break
}
e1--
e2--
}
// 3. 处理对比后的剩余节点
/*
* 根据 i、e1、e2 的关系,判断需要进行的操作:
* - i > e1 && i <= e2: 旧节点已全部对比完,新节点还有剩余 -> 需要挂载新节点
* - i > e2 && i <= e1: 新节点已全部对比完,旧节点还有剩余 -> 需要卸载旧节点
*/
// 3.1 需要挂载新节点
if (i > e1) {
// 确定锚点位置:新增节点应该插入到哪个位置
// 如果 e2 + 1 超出数组长度,说明是在末尾追加,anchor 为 null
// 否则,新节点应该插入到 c2[e2 + 1] 之前
const nextPos = e2 + 1
const anchor = nextPos < c2.length ? c2[nextPos].el : null
// 挂载 i 到 e2 范围内的所有新节点
while (i <= e2) {
patch(null, c2[i], container, anchor)
i++
}
}
// 3.2 需要卸载旧节点
else if (i > e2) {
// 卸载 i 到 e1 范围内的所有旧节点
while (i <= e1) {
unmount(c1[i])
i++
}
}
}
算法优化的场景
双端 diff 算法特别适合处理以下常见场景:
- 尾部追加:
[a, b]→[a, b, c]- 头部对比后,
i = 2, e1 = 1, e2 = 2 - 满足
i > e1,直接挂载新节点c
- 头部对比后,
- 头部插入:
[a, b]→[c, a, b]- 尾部对比后,
i = 0, e1 = -1, e2 = 0 - 满足
i > e1,在头部挂载新节点c
- 尾部对比后,
- 尾部删除:
[a, b, c]→[a, b]- 头部对比后,
i = 2, e1 = 2, e2 = 1 - 满足
i > e2,卸载节点c
- 头部对比后,
- 头部删除:
[a, b, c]→[b, c]- 尾部对比后,
i = 0, e1 = 0, e2 = -1 - 满足
i > e2,卸载节点a
- 尾部对比后,
关键点解析
1. 为什么从两端对比
从两端同时对比可以快速"消化掉"头部和尾部的相同节点,减少需要处理的复杂情况。大多数实际场景中,变化往往集中在列表的某一端,双端对比能够高效处理这些场景。
2. 锚点(anchor)的作用
在挂载新节点时,需要确定插入位置。anchor 参数指定了新节点应该插入到哪个现有节点之前:
- 如果
anchor为null,表示在末尾追加 - 如果
anchor是某个DOM元素,表示插入到该元素之前
3. 索引关系的判断
通过 i、e1、e2 三个索引的关系,可以准确判断对比后的状态:
i > e1:旧节点已全部对比完,新节点还有剩余i > e2:新节点已全部对比完,旧节点还有剩余i <= e1 && i <= e2:两端都还有未对比的节点(本章暂未处理,需要更复杂的diff算法)
总结
通过本章的学习,我们实现了双端 diff 算法的基础版本,了解了:
- diff 算法的必要性:
- 新旧子节点都是数组时,需要高效的对比策略
- 避免简单粗暴地卸载重建,减少
DOM操作 - 找出最小的变化集合,提升渲染性能
- 双端对比策略:
- 从头部开始对比,处理相同的前缀节点
- 从尾部开始对比,处理相同的后缀节点
- 通过两端对比,快速定位变化区域
- 处理对比结果:
i > e1:挂载新增的节点i > e2:卸载多余的节点- 使用
anchor确定插入位置,保证节点顺序正确
- 优化的场景:
- 头部/尾部追加或删除节点
- 这些是实际应用中最常见的列表变化场景
- 双端
diff能以O(n)的时间复杂度处理这些情况
- 算法的局限性:
- 当前实现只能处理头尾变化的简单场景
- 中间节点的移动、替换等复杂情况还未处理
双端 diff 算法是 Vue 渲染器优化的重要一环,虽然还有更复杂的场景需要处理,但它已经能够高效应对大部分常见的列表更新操作了。