背景
众所周知,vue的渲染器核心就是diff算法,往往我们操作DOM的性能比较大,而diff算法就是为了解决这个问题而诞生的。
定义
那什么是diff算法呢?
简单说就是新旧vnode的子节点都是一组节点时,为了以最小的性能开销完成更新操作,需要比较两组子节点,用于比较的算法就叫作dIff算法。
双端Diff算法
vue主要是采用的双端diff算法,顾名思义,双端diff算法是一种同时对新旧两组子节点的两个端点进行比较的算法,因此,我们需要四个索引值,分别指向新旧两组子节点的端点,如下图
用代码来表示四个端点,如下面代码所示
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
// 四个索引指向的 vnode 节点
let oldStartVNode = oldChildren[oldStartIdx]
let oldEndVNode = oldChildren[oldEndIdx]
let newStartVNode = newChildren[newStartIdx]
let newEndVNode = newChildren[newEndIdx]
}
在上面这段代码中,我们将两组子节点的打补丁工作封装到了patchKeyedChildren 函数中。在该函数内,首先获取新旧两组子节点 oldChildren 和 newChildren,接着创建四个索引值,分别指向新旧两组子节点的头和尾,即oldStartIdx、oldEndIdx、newStartIdx 和 newEndIdx。有了索引后,就可以找到它所指向的虚拟节点了。
diff逻辑
那我们有了这些信息后,怎么比较呢?
咱们直接看图
理想情况
在双端比较中,每一轮比较都分为四个步骤,如图中的连线所示。
● 第一步:比较旧的一组子节点中的第一个子节点 p-1 与新的一组子节点中的第一个子节点 p-4,看看它们是否相同。由于两者的 key 值不同,因此不相同,不可复用,于是什么都不做。
● 第二步:比较旧的一组子节点中的最后一个子节点 p-4 与新的一组子节点中的最后一个子节点 p-3,看看它们是否相同。由于两者的 key 值不同,因此不相同,不可复用,于是什么都不做。
● 第三步:比较旧的一组子节点中的第一个子节点 p-1 与新的一组子节点中的最后一个子节点 p-3,看看它们是否相同。由于两者的 key 值不同,因此不相同,不可复用,于是什么都不做。
● 第四步:比较旧的一组子节点中的最后一个子节点 p-4 与新的一组子节点中的第一个子节点 p-4。由于它们的 key 值相同,因此可以进行 DOM 复用。
可以看到,我们在第四步时找到了相同的节点,这说明它们对应的真实 DOM 节点可以复用。对于可复用的 DOM 节点,我们只需要通过 DOM 移动操作完成更新即可。那么应该如何移动 DOM 元素呢?为了搞清楚这个问题,我们需要分析第四步比较过程中的细节。我们注意到,第四步是比较旧的一组子节点的最后一个子节点与新的一组子节点的第一个子节点,发现两者相同。
这说明:节点 p-4 原本是最后一个子节点,但在新的顺序中,它变成了第一个子节点。换句话说,节点 p-4在更新之后应该是第一个子节点。对应到程序的逻辑,可以将其翻译为:将索引oldEndIdx 指向的虚拟节点所对应的真实 DOM 移动到索引 oldStartIdx 指向的虚拟节点所对应的真实 DOM 前面。如下面的代码所示:
01 function patchKeyedChildren(n1, n2, container) {
02 const oldChildren = n1.children
03 const newChildren = n2.children
04 // 四个索引值
05 let oldStartIdx = 0
06 let oldEndIdx = oldChildren.length - 1
07 let newStartIdx = 0
08 let newEndIdx = newChildren.length - 1
09 // 四个索引指向的 vnode 节点
10 let oldStartVNode = oldChildren[oldStartIdx]
11 let oldEndVNode = oldChildren[oldEndIdx]
12 let newStartVNode = newChildren[newStartIdx]
13 let newEndVNode = newChildren[newEndIdx]
14
15 if (oldStartVNode.key === newStartVNode.key) {
16 // 第一步:oldStartVNode 和 newStartVNode 比较
17 } else if (oldEndVNode.key === newEndVNode.key) {
18 // 第二步:oldEndVNode 和 newEndVNode 比较
19 } else if (oldStartVNode.key === newEndVNode.key) {
20 // 第三步:oldStartVNode 和 newEndVNode 比较
21 } else if (oldEndVNode.key === newStartVNode.key) {
22 // 第四步:oldEndVNode 和 newStartVNode 比较
23 // 仍然需要调用 patch 函数进行打补丁
24 patch(oldEndVNode, newStartVNode, container)
25 // 移动 DOM 操作
26 // oldEndVNode.el 移动到 oldStartVNode.el 前面
27 insert(oldEndVNode.el, container, oldStartVNode.el)
28
29 // 移动 DOM 完成后,更新索引值,并指向下一个位置
30 oldEndVNode = oldChildren[--oldEndIdx]
31 newStartVNode = newChildren[++newStartIdx]
32 }
33 }
在这段代码中,我们增加了一系列的 if…else if… 语句,用来实现四个索引指向的虚拟节点之间的比较。拿上例来说,在第四步中,我们找到了具有相同 key 值的节点。这说明,原来处于尾部的节点在新的顺序中应该处于头部。于是,我们只需要以头部元素 oldStartVNode.el 作为锚点,将尾部元素 oldEndVNode.el 移动到锚点前面即可。但需要注意的是,在进行 DOM 的移动操作之前,仍然需要调用patch 函数在新旧虚拟节点之间打补丁。
在这一步 DOM 的移动操作完成后,接下来是比较关键的步骤,即更新索引值。由于第四步中涉及的两个索引分别是 oldEndIdx 和 newStartIdx,所以我们需要更新两者的值,让它们各自朝正确的方向前进一步,并指向下一个节点。下图给出了更新前新旧两组子节点以及真实 DOM 节点的状态。
下图给出了在第四步的比较中,第一步 DOM 移动操作完成后,新旧两组子节点以及真实 DOM 节点的状态。
此时,真实 DOM 节点顺序为 p-4、p-1、p-2、p-3,这与新的一组子节点顺序不一致。这是因为 Diff 算法还没有结束,还需要进行下一轮更新。
因此,我们需要将更新逻辑封装到一个 while 循环中,如下面的代码所示:
01 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
02 if (oldStartVNode.key === newStartVNode.key) {
03 // 步骤一:oldStartVNode 和 newStartVNode 比较
04 } else if (oldEndVNode.key === newEndVNode.key) {
05 // 步骤二:oldEndVNode 和 newEndVNode 比较
06 } else if (oldStartVNode.key === newEndVNode.key) {
07 // 步骤三:oldStartVNode 和 newEndVNode 比较
08 } else if (oldEndVNode.key === newStartVNode.key) {
09 // 步骤四:oldEndVNode 和 newStartVNode 比较
10 // 仍然需要调用 patch 函数进行打补丁
11 patch(oldEndVNode, newStartVNode, container)
12 // 移动 DOM 操作
13 // oldEndVNode.el 移动到 oldStartVNode.el 前面
14 insert(oldEndVNode.el, container, oldStartVNode.el)
15
16 // 移动 DOM 完成后,更新索引值,指向下一个位置
17 oldEndVNode = oldChildren[--oldEndIdx]
18 newStartVNode = newChildren[++newStartIdx]
19 }
20 }
由于在每一轮更新完成之后,紧接着都会更新四个索引中与当前更新轮次相关联的索引,所以整个 while 循环执行的条件是:头部索引值要小于等于尾部索引值。
在第一轮更新结束后循环条件仍然成立,因此需要进行下一轮的比较。
我们发现下一轮循环比较旧的一组子节点中的尾部节点 p-3 与新的一组子节点中的尾部节点 p-3,两者的 key 值相同,可以复用。另外,由于两者都处于尾部,因此不需要对真实 DOM 进行移动操作,只需要打补丁即可。
在这一轮更新完成之后,新旧两组子节点与真实 DOM 节点的状态如下图
这一轮循环比较,我们找到了相同的节点,旧的一组子节点中的头部节点 p-1 与新的一组子节点中的尾部节点 p-1。两者的 key 值相同,可以复用。这说明:节点 p-1 原本是头部节点,但在新的顺序中,它变成了尾部节点。
因此,我们需要将节点 p-1 对应的真实DOM 移动到旧的一组子节点的尾部节点 p-2 所对应的真实 DOM 后面,同时还需要更新相应的索引到下一个位置。如下图。
比较旧的一组子节点中的头部节点 p-2 与新的一组子节点中的头部节点p-2。发现两者 key 值相同,可以复用。但两者在新旧两组子节点中都是头部节点,因此不需要移动,只需要调用 patch 函数进行打补丁即可。
此时,如上图所示,真实 DOM 节点的顺序与新的一组子节点的顺序相同了:p-4、p-2、p-1、p-3。另外,在这一轮更新完成之后,索引 newStartIdx 和索引 oldStartIdx 的值都小于 newEndIdx 和 oldEndIdx,所以循环终止,双端 Diff 算法执行完毕。
非理想情况
以上是当我们循环找可复用的节点的理想情况下,那如果我们第一次循环都无法找到复用的节点,应该怎么办?
具体做法是,拿新的一组子节点中的头部节点去旧的一组子节点中寻找,当我们拿新的一组子节点的头部节点 p-2 去旧的一组子节点中查找时,会在索引为 1 的位置找到可复用的节点。这意味着,节点 p-2 原本不是头部节点,但在更新之后,它应该变成头部节点。所以我们需要将节点 p-2 对应的真实 DOM 节点移动到当前旧的一组子节点的头部节点 p-1 所对应的真实 DOM 节点之前。
此时,真实 DOM 的顺序为:p-2、p-1、p-3、p-4。接着,双端 Diff 算法会继续进行。
在这一轮比较中,按照双端 Diff 算法的逻辑移动真实 DOM,即把节点 p-4 对应的真实 DOM 移动到旧的一组子节点中头部节点 p-1 所对应的真实 DOM 前面。
此时,真实 DOM 节点的顺序是:p-2、p-4、p-1、p-3。接着,开始下一轮的比较。在这一轮比较中,第一步就找到了可复用的节点。由于两者都处于头部,所以不需要对真实 DOM 进行移动,只需要打补丁即可。
此时,真实 DOM 节点的顺序是:p-2、p-4、p-1、p-3。接着,进行下一轮的比较。需要注意的一点是,此时旧的一组子节点的头部节点是 undefined。这说明该节点已经被处理过了,因此不需要再处理它了,直接跳过即可。
现在,四个步骤又重合了,接着进行最后一轮的比较。找到了可复用的节点。由于两者都是头部节点,因此不需要进行 DOM 移动操作,直接打补丁即可。
这时,满足循环停止的条件,于是更新完成。最终,真实 DOM 节点的顺序与新的一组子节点的顺序一致,都是:p-2、p-4、p-1、p-3。
有一种diff比较情况,新子节点的长度和旧子节点的长度不一致时,此时我们需要判断新旧子节点是否有遗留节点,如果新子节点有遗留,则需要作为新节点挂载,如果旧子节点有遗留,则需要把它们逐一卸载。
通过本章内容的diff算法讲解,你清楚双端diff算法的优势在哪了么
[1]: Vue.js设计与实现 - 霍春阳