双端比较算法是Vue中用于高效比较新旧VNode子节点的一种策略。该算法的核心思想是,通过从新旧VNode子节点的两端开始比较,逐步向中间靠拢,以找到最小的差异并据此更新DOM。以下是双端比较算法的大致流程:
- 初始化指针:设置四个指针,分别指向新旧VNode子节点的开始和结束位置。
- 首尾比较:首先比较新旧VNode子节点的首尾元素。如果首尾元素相同,则直接复用,并移动相应的指针。
- 交叉比较:如果首尾元素不同,则进行交叉比较,即比较旧节点的末尾和新节点的开头,以及旧节点的开头和新节点的末尾。
- 移动指针:根据比较结果,移动指针以缩小比较范围。如果找到匹配的节点,则复用该节点,并根据匹配情况调整指针位置。
- 结束条件:当任一节点的开始指针超过结束指针时,表明已经遍历完至少一个节点的所有子节点,此时结束比较。
这里以伪代码的形式进行展示,其中oldChildren是旧的虚拟DOM,newChildren是新的虚拟DOM,通过patch来更新真实的DOM结构
function diff(oldChildren, newChildren) {
let oldStartIdx = 0 // 旧子节点起始指针
let oldEndIdx = oldChildren.length - 1 // 旧子节点结束指针
let newStartIdx = 0 // 新子节点起始指针
let newEndIdx = newChildren.length - 1 // 新子节点结束指针
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 获取当前指针指向的节点
let oldStartVNode = oldChildren[oldStartIdx]
let oldEndVNode = oldChildren[oldEndIdx]
let newStartVNode = newChildren[newStartIdx]
let newEndVNode = newChildren[newEndIdx]
// --- 四种比较场景 ---
// 1. 旧头 vs 新头(复用)
if (oldStartVNode.key === newStartVNode.key) {
patch(oldStartVNode, newStartVNode) // 更新节点属性
oldStartIdx++
newStartIdx++
}
// 2. 旧尾 vs 新尾(复用)
else if (oldEndVNode.key === newEndVNode.key) {
patch(oldEndVNode, newEndVNode)
oldEndIdx--
newEndIdx--
}
// 3. 旧头 vs 新尾(移动:旧头移动到旧尾之后)
else if (oldStartVNode.key === newEndVNode.key) {
patch(oldStartVNode, newEndVNode)
insert(oldStartVNode.el, parentEl, oldEndVNode.el.nextSibling) // DOM移动操作
oldStartIdx++
newEndIdx--
}
// 4. 旧尾 vs 新头(移动:旧尾移动到旧头之前)
else if (oldEndVNode.key === newStartVNode.key) {
patch(oldEndVNode, newStartVNode)
insert(oldEndVNode.el, parentEl, oldStartVNode.el)
oldEndIdx--
newStartIdx++
}
// --- 如果四种场景都不匹配 ---
else {
// 尝试在旧子节点中查找与 newStartVNode 匹配的节点
let foundIdx = oldChildren.findIndex(node => node.key === newStartVNode.key)
if (foundIdx !== -1) {
// 找到可复用的旧节点,移动它到旧头之前
let foundVNode = oldChildren[foundIdx]
patch(foundVNode, newStartVNode)
insert(foundVNode.el, parentEl, oldStartVNode.el)
oldChildren[foundIdx] = undefined // 标记该位置已处理
} else {
// 没有可复用节点,创建新节点插入到旧头之前
createEl(newStartVNode)
insert(newStartVNode.el, parentEl, oldStartVNode.el)
}
newStartIdx++
}
}
// --- 处理剩余节点 ---
// 1. 如果旧子节点遍历完毕,剩余新节点需要新增
if (oldStartIdx > oldEndIdx && newStartIdx <= newEndIdx) {
for (let i = newStartIdx; i <= newEndIdx; i++) {
createEl(newChildren[i])
insert(newChildren[i].el, parentEl, oldChildren[oldStartIdx]?.el || null)
}
}
// 2. 如果新子节点遍历完毕,剩余旧节点需要删除
else if (newStartIdx > newEndIdx && oldStartIdx <= oldEndIdx) {
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
remove(oldChildren[i].el)
}
}
}
其中patch函数的核心作用是 复用已有的真实 DOM 节点,并用新虚拟节点(VNode)的属性更新对应的真实 DOM
问题1:patch 函数的具体更新逻辑
(1)更新属性(props)
新增/修改属性:将新 VNode 的 class、style、id、自定义属性等同步到真实 DOM。
删除旧属性:移除新 VNode 中不存在的旧属性。
// 伪代码示例:更新 class
if (newVNode.class !== oldVNode.class) {
el.className = newVNode.class;
}
(2)更新事件监听器
绑定新事件:若新 VNode 有事件(如 @click),则绑定到真实 DOM。
解绑旧事件:若旧事件不存在于新 VNode 中,则移除。
// 伪代码示例:更新事件
if (newVNode.onClick !== oldVNode.onClick) {
el.removeEventListener('click', oldVNode.onClick);
el.addEventListener('click', newVNode.onClick);
}
(3)更新子节点
递归对比子节点,触发子树的 Diff 算法:
// 伪代码:递归更新子节点
if (newVNode.children && oldVNode.children) {
updateChildren(el, oldVNode.children, newVNode.children);
}
(4)更新文本内容
若节点是文本节点,直接更新文本:
if (newVNode.text !== oldVNode.text) {
el.textContent = newVNode.text;
}
示例说明
假设新旧虚拟节点如下:
// 旧虚拟节点
oldVNode = {
tag: 'div',
class: 'old-class',
onClick: oldHandler,
children: [A, B]
};
// 新虚拟节点
newVNode = {
tag: 'div',
class: 'new-class',
onClick: newHandler,
children: [B, A, C]
};
patch 函数的操作步骤:
- 更新 class:将真实 DOM 的 class 从 old-class 改为 new-class。
- 更新事件:解绑旧的oldHandler,绑定新的 newHandler。
- 更新子节点:触发子节点的双端 Diff 算法,复用 B 和 A,新增 C,可能移动节点位置。
问题:在更新过程中旧节点数量是否变化?
旧虚拟节点(oldChildren)的数量不会改变,但真实 DOM 中子节点数量会增加:
(1)新旧虚拟节点的数量是固定的
旧节点列表(oldChildren):在 Diff 过程中是固定的,长度由初始渲染时决定。
新节点列表(newChildren):是目标结构,算法需将真实 DOM 更新到该结构。
无论是否插入新节点,oldChildren 和 newChildren 的虚拟节点数量是固定的,不会动态增减。
(2)真实 DOM 的子节点数量会暂时增加
插入新节点的本质:在真实 DOM 中新增了一个节点(newStartVNode.el)。
旧节点的存在性:未被复用的旧节点仍然存在于真实 DOM 中,直到被明确删除。
此时真实 DOM 的子节点数量为:
旧节点数量 + 新增节点数量 - 已删除的旧节点数量。
2. 示例场景
假设初始状态为(这里是虚拟DOM):
oldChildren: [A, B, C] // 旧节点数量为 3
newChildren: [D, A, B] // 新节点数量为 3
执行流程如下:
- 处理新头节点 D:
- 在 oldChildren 中未找到可复用的节点。 创建新节点 D,并插入到旧头节点 A 之前。
- 此时真实 DOM 结构为 [D, A, B, C](4 个节点)。
继续 Diff 后续节点:
- 新头指针后移(newStartIdx++),处理下一个新节点 A。 发现 A 可复用旧头节点,移动并更新指针。
- 最终清理未处理的旧节点: 新节点处理完毕后,剩余的旧节点 C 会被删除。 最终真实 DOM 结构:[D, A, B](与新节点数量一致)。