快速Diff算法
快速 Diff 算法在实测中性能最优。它借鉴了文本 Diff 算法中的预处理思路,先处理新旧两组子节点中相同的前置节点和相同的后置节点。当前置节点和后置节点全部处理完毕后,如果无法简单地通过挂载新节点或者卸载已经不存在的节点来完成更新,则需要根据节点的索引关系,构造出一个最长递增子序列。最长递增序列所指向的节点即为不需要移动的节点。
不同于简单Diff算法和双端Diff算法,快速Diff算法包含预处理步骤,这其实是借鉴了纯文本Diff算法的思路。
在纯文本Diff算法中,存在对两段文本进行预处理的过程。例如:在对两段文本进行Diff之前,可以先对他们进行全等比较:
if (text1 === text2) return
这也称为快捷路径。如果两段文本全等,那么就无需进行核心Diff算法的步骤。除此之外,预处理过程还会处理两段文本相同的前缀和后缀。假设有如下两段文本:
TEXT1: I use vue for app development;
TEXT2: I use react for app development;
这两段文本的头部和尾部分别有一段相同的内容“I use”和“for app development”。因此对于 TEXT1 和 TEXT2 来说,真正需要进行 Diff 操作的部分是
TEXT1: vue
TEXT2: react
以下面两组节点为例:
通过观察不难发现,两组节点具有相同的前置节点p-1以及相同的后置节点p-2和p-3,如下图左侧所示。对于相同的前置节点和后置节点,由于他们在新旧两组子节点中的相对位置不变,所以我们无需移动他们,但仍然需要在他们之间打补丁。
对于前置节点,我们可以建立索引 j,其初始值为0,用来指向两组子节点的开头,如上图右侧所示。然后开启一个 while 循环,让索引 j递增,直到遇到不相同的节点为止,如下面 patchKeyChildren 函数的代码所示:
function patchKeyedChildren(n1, n2, container) {
const newChildren = n2.children;
const oldChildren = n1.children;
// 处理相同的前置节点
// 索引 j 指向新旧两组子节点的开头
let j = 0;
let oldVnode = oldChildren[j];
let newVNode = newChildren[j];
// while 循环向后遍历,直到遇到拥有不同 key 值的节点为止
while(oldVNode.key === newVNode.key) {
// 调用 patch 函数进行更新
patch(oldVNode, newVNode, container);
// 更新索引 j,让其递增
j++;
oldVnode = oldChildren[j];
newVNode = newChildren[j];
}
}
在上面这段代码中,使用 while 循环查找所有相同的前置节点,并调用 patch 函数进行打补丁,直到遇到 key 值不同的节点为止。这样就完成了对前置节点的更新,更新操作过后,新旧两组子节点的状态如下:
这里需要注意的是,当 while 循环终止时,索引 j 的值为1。接下来需要处理相同的后置节点。由于新旧两组子节点的数量可能不同,索引我们需要两个索引 newEnd 和 oldEnd,分别指向新旧两组子节点中的最后一个节点,如下图所示:
我们再开启一个 while 循环,并从后向前遍历这两组子节点,直到遇到 key 值不同的节点为止,如下代码所示:
function patchKeyedChildren(n1, n2, container) {
const newChildren = n2.children;
const oldChildren = n1.children;
// 处理相同的前置节点
// 索引 j 指向新旧两组子节点的开头
let j = 0;
let oldVnode = oldChildren[j];
let newVNode = newChildren[j];
// while 循环向后遍历,直到遇到拥有不同 key 值的节点为止
while (oldVNode.key === newVNode.key) {
// 调用 patch 函数进行更新
patch(oldVNode, newVNode, container);
// 更新索引 j,让其递增
j++;
oldVnode = oldChildren[j];
newVNode = newChildren[j];
}
// 更新相同的后置节点
// 索引 oldEnd 指向旧的一组子节点的最后一个节点
let oldEnd = oldChildren.length - 1;
// 索引 newEnd 指向新的一组子节点的最后一个节点
let newEnd = newChildren.length - 1;
oldVnode = oldChildren[oldEnd];
newVNode = newChildren[newEnd];
// while 循环从后向前遍历,直到遇到拥有不同 key 值的节点为止
while (oldVNode.key === newVNode.key) {
// 调用 patch 函数进行更新
patch(oldVNode, newVNode, container);
// 递减 oldEnd 和 newEnd
--oldEnd;
--newEnd;
oldVnode = oldChildren[oldEnd];
newVNode = newChildren[newEnd];
}
}
与处理相同的前置节点一样,在 while 循环内,需要调用 patch 函数进行打补丁,然后递减两个索引 oldEnd 和 newEnd 的值。在这一步更新操作过后,新旧两组子节点的状态如下:
由图可知,当相同的前置节点和后置节点被处理完毕后,旧的一组子节点已经被全部处理了,而在新的一组子节点中,还遗留了一个未被处理的节点p-4。其实不难发现,节点p-4是新增节点。那么如何用程序得出“节点p-4是新增节点”这个结论,需要观察 j、newEnd 和 oldEnd之间的关系。
- 条件一 oldEnd < j 成立:说明在预处理过程中,所有的旧子节点都处理完毕了。
- 条件二 newEnd >= j 成立:说明在预处理过后,在新的一组子节点中,仍然有未被处理的节点,而这些遗留的节点将被视作新增节点。
如果条件一和条件二同时成立,说明在新的一组子节点中,存在遗留节点,且这些节点都是新增节点。因此我们需要将他们挂载到正确的位置。如下图所示:
在新的一组子节点中,索引值处于 j 和 newEnd 之间的任何节点都需要作为新的子节点进行挂载。如上图可知,锚点元素是p-2节点对应的真实 DOM 前面。具体代码如下:
function patchKeyedChildren(n1, n2, container) {
const newChildren = n2.children;
const oldChildren = n1.children;
// 更新相同的前置节点
// 省略部分代码
// 更新相同的后置节点
// 省略部分代码
// 预处理完毕后,如果满足如下条件,则说明从 j --> newEnd 之间的节点应作为新节点插入
if (j > oldEnd && j <= newEnd) {
// 锚点的索引
const anchorIndex = newEnd + 1;
// 锚点元素
const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null;
// 采用 while 循环,调用 patch 函数逐个挂载新增节点
while (j <= newEndb) {
patch(null, newChildren[j++], container, anchor);
}
}
}
上面代码中,首先计算锚点的索引值(即 anchorIndex )为 newEnd + 1。如果小于新的一组子节点的数量,则说明锚点元素在新的一组子节点中,所以直接使用 newChildren[anchorIndex].el 作为锚点元素;否则说明索引 newEnd 对应的节点已经是尾部节点了,此时无需提供锚点元素。我们开启一个 while 循环,用来遍历索引 j 和索引 newEnd 之间的节点,并调用 patch 函数挂载他们。
上面的案例展示了新增节点的情况,接下来是删除节点的情况,如下图所示:
我们同样使用索引 j、oldEnd 和 newEnd 进行标记,如下图所示:
当对相同的前置节点和后置节点经过预处理后,新的一组子节点已经全部被处理完毕了,而旧的一组子节点中遗留了一个节点p-2。这说明应该卸载节点p-2。实际上遗留的节点可能有多个,如下图所示:
索引 j 和索引 oldEnd之间的任何节点都应该被卸载,具体实现如下:
function patchKeyedChildren(n1, n2, container) {
const newChildren = n2.children;
const oldChildren = n1.children;
// 更新相同的前置节点
// 省略部分代码
// 更新相同的后置节点
// 省略部分代码
// 预处理完毕后,如果满足如下条件,则说明从 j --> newEnd 之间的节点应作为新节点插入
if (j > oldEnd && j <= newEnd) {
// 省略部分代码
} else if (j > newEnd && j <= oldEnd) {
// j -> oldEnd 之间的节点应该被卸载
while (j <= oldEnd) {
unmount(oldChildren[j++]);
}
}
}
上面的这段代码中,新增了一个 else…if 分支。当条件满足 j > newEnd && j <= oldEnd 时,则开启一个 while 循环,并调用 unmount 函数逐个进行卸载这些遗留节点。
判断是否需要进行 DOM 移动操作
前面讲了快速 Diff 算法的预处理过程,即处理相同的前置节点和后置节点。但是前面给的例子是比较理想化的,当处理完相同的前置节点和后置节点以后,新旧两组子节点中总会有一组子节点全部都被处理完毕。这种情况下,只需要简单地挂载、卸载节点即可。有时情况会比较复杂,如下图左侧:
可以看出