上一章,我们介绍了简单 Diff 算法的实现原理。但是,简单 Diff 算法仍然存在很多缺陷,这些缺陷可以通过本章将要介绍的双端 Diff 算法解决。
一、双端比较的原理
简单 Diff 算法的问题在于,它对 DOM 的移动操作并不是最优的。我们拿上一章的例子来看, 如图 10-1 所示。
显而易见只需要一次 DOM 移动操作即可完成更新。但简单 Diff 算法进行了两次 DOM 移动操作, 不过本章我们要介绍的双端 Diff 算法可以做到。接下来,我们就来讨论双端 Diff 算法的原理。
顾名思义,双端 Diff 算法是一种同时对新旧两组子节点的两个端点进行比较的算法。因此, 我们需要四个索引值,分别指向新旧两组子节点的端点,如图 10-4 所示。
用代码来表达四个端点,如下面 patchChildren 和 patchKeyedChildren 函数的代码所示:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
// 封装 patchKeyedChildren 函数处理两组子节点
patchKeyedChildren(n1, n2, container)
} else {
// 省略部分代码
}
}
function patchKeyedChildren(n1, n2, container) {
const oldChildren = n1.children
const newChildren = n2.children
// 四个索引值
let oldStartIdx = 0
let oldEndIdx = oldChildren.length - 1
let newStartIdx = 0
let newEndIdx = newChildren.length - 1
}
在上面这段代码中,我们将两组子节点的打补丁工作封装到了 patchKeyedChildren 函数中。 在该函数内,首先获取新旧两组子节点 oldChildren 和 newChildren,接着创建四个索引值,分别指向新旧两组子节点的头和尾,即 oldStartIdx、oldEndIdx、newStartIdx 和 newEndIdx。有了索引后,就可以找到它所指向的虚拟节点了,如下面的代码所示:
function patchKeyedChildren(n1, n2, container) {
const oldChildren = n1.children
const newChildren = n2.children
// 四个索引值
let oldStartIdx = 0
let oldEndIdx = oldChildren.length - 1
let newStartIdx = 0
let newEndIdx = newChildren.length - 1
// 四个索引指向的 vnode 节点
let oldStartVNode = oldChildren[oldStartIdx]
let oldEndVNode = oldChildren[oldEndIdx]
let newStartVNode = newChildren[newStartIdx]
let newEndVNode = newChildren[newEndIdx]
}
其中,oldStartVNode 和 oldEndVNode 是旧的一组子节点中的第一个节点和最后一个节点, newStartVNode 和 newEndVNode 则是新的一组子节点的第一个节点和最后一个节点。有了这些信息之后,我们就可以开始进行双端比较了。怎么比较呢?如图 10-5 所示。
在双端比较中,每一轮比较都分为四个步骤,如图 10-5 中的连线所示。
- 第一步:比较旧的一组子节点中的第一个子节点 p-1 与新的一组子节点中的第一个子节点 p-4,看看它们是否相同。由于两者的 key 值不同,因此不相同,不可复用,于是什么都不做。
- 第二步:比较旧的一组子节点中的最后一个子节点 p-4 与新的一组子节点中的最后一个子节点 p-3,看看它们是否相同。由于两者的 key 值不同,因此不相同,不可复用,于是什么都不做。
- 第三步:比较旧的一组子节点中的第一个子节点 p-1 与新的一组子节点中的最后一个子节点 p-3,看看它们是否相同。由于两者的 key 值不同,因此不相同,不可复用,于是什么都不做。
- 第四步:比较旧的一组子节点中的最后一个子节点 p-4 与新的一组子节点中的第一个子 节点 p-4。由于它们的 key 值相同,因此可以进行 DOM 复用。
找到了可复用的节点,该怎么移动呢?我们注意到,第四步是比较旧的一组子节点的最后一个子节点与新的一组子节点的第一个子节点,发现两者相同。这说明:节点 p-4 原本是最后一个子节点,但在新的顺序中,它变成了第一个子节点。换句话说,节点 p-4 在更新之后应该是第一个子节点。对应到程序的逻辑,可以将其翻译为:将索引 oldEndIdx 指向的虚拟节点所对应的真实 DOM 移动到索引 oldStartIdx 指向的虚拟节点所对应的真实 DOM 前面。如下面的代码所示:
function patchKeyedChildren(n1, n2, container) {
const oldChildren = n1.children
const newChildren = n2.children
// 四个索引值
let oldStartIdx = 0
let oldEndIdx = oldChildren.length - 1
let newStartIdx = 0
let newEndIdx = newChildren.length - 1
// 四个索引指向的 vnode 节点
let oldStartVNode = oldChildren[oldStartIdx]
let oldEndVNode = oldChildren[oldEndIdx]
let newStartVNode = newChildren[newStartIdx]
let newEndVNode = newChildren[newEndIdx]
if (oldStartVNode.key === newStartVNode.key) {
// 第一步:oldStartVNode 和 newStartVNode 比较
} else if (oldEndVNode.key === newEndVNode.key) {
// 第二步:oldEndVNode 和 newEndVNode 比较
} else if (oldStartVNode.key === newEndVNode.key) {
// 第三步:oldStartVNode 和 newEndVNode 比较
} else if (oldEndVNode.key === newStartVNode.key) {
// 第四步:oldEndVNode 和 newStartVNode 比较
// 仍然需要调用 patch 函数进行打补丁
patch(oldEndVNode, newStartVNode, container)
// 移动 DOM 操作
// oldEndVNode.el 移动到 oldStartVNode.el 前面
insert(oldEndVNode.el, container, oldStartVNode.el)
// 移动 DOM 完成后,更新索引值,并指向下一个位置
oldEndVNode = oldChildren[--oldEndIdx]
newStartVNode = newChildren[++newStartIdx]
}
}
在这段代码中,我们增加了一系列的 if…else if… 语句,用来实现四个索引指向的虚拟节点之间的比较。拿上例来说,在第四步中,我们找到了具有相同 key 值的节点。这说明,原来处于尾部的节点在新的顺序中应该处于头部。于是,我们只需要以头部元素 oldStartVNode.el 作为锚点,将尾部元素 oldEndVNode.el 移动到锚点前面即可。但需要注意的是,在进行 DOM 的移动操作之前,仍然需要调用 patch 函数在新旧虚拟节点之间打补丁。在这一步 DOM 的移动操作完成后,接下来是比较关键的步骤,即更新索引值。由于第四步中涉及的两个索引分别是 oldEndIdx 和 newStartIdx&#x