快速 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之间的关系。
如果条件一和条件二同时成立,说明在新的一组子节点中,存在遗留节点,且这些节点都是新增节点。因此我们需要将他们挂载到正确的位置。如下图所示:
在新的一组子节点中,索引值处于 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 函数逐个进行卸载这些遗留节点。
前面讲了快速 Diff 算法的预处理过程,即处理相同的前置节点和后置节点。但是前面给的例子是比较理想化的,当处理完相同的前置节点和后置节点以后,新旧两组子节点中总会有一组子节点全部都被处理完毕。这种情况下,只需要简单地挂载、卸载节点即可。有时情况会比较复杂,如下图左侧:
可以看出,与旧的一组子节点相比,新的一组子节点多出了一个新节点p-7,少了一个节点p-6。这个例子中相同地前置节点只有p-1,而相同地后置节点只有p-5,如上图右侧。
经过预处理后地两组子节点状态如下:
经过预处理后,新旧两组子节点都有部分节点未处理。接下来的处理规则:
观察上图可知,索引 j 、newEnd 和 oldEnd 不满足下面两个条件中的任何一个:
因此我们需要添加新的分支来处理上图中出现的情况,代码如下。后续处理逻辑会在 else 分支内。
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) {
// 省略部分代码
} else {
// 增加 else 分支来处理非理想情况
}
}
接下来地处理思路,首先,我们构造一个数组 source,它的长度等于新的一组子节点经过预处理后剩余节点的数量,并且 source 数组中每个元素的初始值都是 -1,如下图所示:
通过下面的代码完成 source 数组的构造:
if (j > oldEnd && j <= newEnd) {
// 省略部分代码
} else if (j > newEnd && j <= oldEnd) {
// 省略部分代码
} else {
// 构造 source 数组
// 新的一组子节点中剩余未处理节点的数量
const count = newEnd - j + 1;
const source = new Array(count);
source.fill(-1);
}
如上面的代码所示,首先计算新的一组子节点剩余未处理的节点数量,即 newEnd - j + 1,然后创建一个长度与之相同的数组 source ,最后使用 fill 函数完成数组的填充。数组 source 中的每一个元素分别与新的一组子节点剩余未处理的节点对应。实际上, source 数组将用来存储新的一组子节点中的节点在旧的一组子节点中的位置索引,后面将会使用它计算出一个最长递增子序列,并用于辅助完成 DOM 移动的操作。
上图展示了填充 source 数组的过程,我们可以通过双层 for 循环来完成 source 数组的填充工作,外层循环用于遍历旧的一组子节点,内层循环用来遍历新的一组子节点,代码如下:
if (j > oldEnd && j <= newEnd) {
// 省略部分代码
} else if (j > newEnd && j <= oldEnd) {
// 省略部分代码
} else {
// 构造 source 数组
// 新的一组子节点中剩余未处理节点的数量
const count = newEnd - j + 1;
const source = new Array(count);
source.fill(-1);
// oldStart 和 newStart 分别为起始索引值,即 j
const oldStart = j;
const newStart = j;
// 遍历旧的一组子节点
for (let i = oldStart; i <= oldEnd; i++) {
const oldVNode = oldChildren[i];
for (let k = newStart; k <= newEnd; k++) {
const newVNode = newChildren[k];
// 找到拥有相同 key 值的可复用节点
if (oldVNode.key === newVNode.key) {
// 调用 patch 进行更新
patch(oldVNode, newVNode, container);
// 最后填充 source 数组
source[k - newStart] = i;
}
}
}
}
这里需要注意的是,由于数组 source 的索引是从0开始的,而未处理的节点的索引未必从0开始,所以在填充数组时需要使用表达式 k - newStart 的值作为数组的索引值。外层循环的变量 i 就是当前节点在旧的一组子节点中的位置索引,因此直接将变量 i 的值赋给 source[k - newStart] 即可。
现在 source 数组已经填充完毕,但是代码中我们采用了两层嵌套的循环,其时间复杂度为 O(n1 * n2),其中 n1 和 n2 为新旧两组子节点的数量,我们也可以使用 O(n ^ 2) 表示。当新旧两组子节点数量较多时,双层嵌套循环会带来性能问题。出于优化的目的,我们可以为新的一组子节点构建一张索引表,用来存储节点的 key 和节点位置索引之间的映射,如下图:
if (j > oldEnd && j <= newEnd) {
// 省略部分代码
} else if (j > newEnd && j <= oldEnd) {
// 省略部分代码
} else {
// 构造 source 数组
// 新的一组子节点中剩余未处理节点的数量
const count = newEnd - j + 1;
const source = new Array(count);
source.fill(-1);
// oldStart 和 newStart 分别为起始索引值,即 j
const oldStart = j;
const newStart = j;
// 构建索引表
const keyIndex = {};
for (let i = oldStart; i <= newEnd; i++) {
keyIndex[newChildren[i].key] = i;
}
// 遍历旧的一组子节点中剩余未处理的节点
for (let i = oldStart; i <= oldEnd; i++) {
const oldVNode = oldChildren[i];
// 通过索引表快速找到新的一组子节点中具有相同 key 值的节点位置
const k = keyIndex[oldVNode.key];
if (typeof k !== 'undefined') {
const newVNode = newChildren[k];
// 调用 patch 进行更新
patch(oldVNode, newVNode, container);
// 最后填充 source 数组
source[k - newStart] = i;
} else {
// 没找到
unmount(oldVNode);
}
}
}
上面的代码中,同样使用了两个 for 循环,不过降低了时间复杂度至 O(n)。其中第一个 for 循环用于构建索引表,索引表存储的节点的 key 值与节点在新的一组子节点中位置索引之间的映射,第二个 for 循环用于遍历旧的一组子节点。我们拿旧子节点的 key 值去索引表 keyIndex 中查找该节点在新的一组子节点中的位置,并将结果存储到变量 k 中。如果 k 存在,说明节点是可复用的,所以调用 patch 函数进行节点打补丁,并填充 source 数组;否则说明该节点不在新的一组子节点中存在,需要调用 unmount 函数进行卸载。
接下来我们需要判断节点是否需要移动。实际上,快速 Diff 算法判断节点是否需要移动的方法与简单 Diff 算法类似,判断节点是否需要移动的原理:拿新的一组子节点中节点去旧的一组子节点中寻找可复用的节点,如果找到了,则记录该节点的位置,我们这个索引称为最大索引。在整个寻找过程中,如果一个节点的索引值小于最大索引,则说明这个节点对应的真实 DOM 需要移动。如下面代码所示:
if (j > oldEnd && j <= newEnd) {
// 省略部分代码
} else if (j > newEnd && j <= oldEnd) {
// 省略部分代码
} else {
// 构造 source 数组
// 新的一组子节点中剩余未处理节点的数量
const count = newEnd - j + 1;
const source = new Array(count);
source.fill(-1);
// oldStart 和 newStart 分别为起始索引值,即 j
const oldStart = j;
const newStart = j;
// 新增两个变量,moved 和 pos
let moved = false;
let pos = 0;
// 构建索引表
const keyIndex = {};
for (let i = newStart; i <= newEnd; i++) {
keyIndex[newChildren[i].key] = i;
}
// 遍历旧的一组子节点中剩余未处理的节点
for (let i = oldStart; i <= oldEnd; i++) {
const oldVNode = oldChildren[i];
// 通过索引表快速找到新的一组子节点中具有相同 key 值的节点位置
const k = keyIndex[oldVNode.key];
if (typeof k !== 'undefined') {
const newVNode = newChildren[k];
// 调用 patch 进行更新
patch(oldVNode, newVNode, container);
// 最后填充 source 数组
source[k - newStart] = i;
// 判断节点是否需要移动
if (k < pos) {
moves = true;
} else {
pos = k;
}
} else {
// 没找到
unmount(oldVNode);
}
}
}
上面这段代码,我们新增了 pos 和 moved 两个变量。前者初始值为0,代表遍历旧的一组子节点过程中的最大索引值 k;后者初始值是 false,代表是否需要移动节点。在简单Diff 算法时提到,如果在遍历过程中遇到的索引值呈现递增趋势,则说明不许要移动节点,反之则需要。所以在第二个 for 循环内,我们通过比较 k 与 pos 的值来判断节点是否需要移动。
除此之外,我们还需要一个数量标识,代表已经处理过的节点数量。已经更新过的节点数量应该小于新的一组子节点中需要更新的节点数量。一旦超过前者,则说明有多余的节点需要被卸载。代码如下:
if (j > oldEnd && j <= newEnd) {
// 省略部分代码
} else if (j > newEnd && j <= oldEnd) {
// 省略部分代码
} else {
// 构造 source 数组
// 新的一组子节点中剩余未处理节点的数量
const count = newEnd - j + 1;
const source = new Array(count);
source.fill(-1);
// oldStart 和 newStart 分别为起始索引值,即 j
const oldStart = j;
const newStart = j;
// 新增两个变量,moved 和 pos
let moved = false;
let pos = 0;
// 新增 patched 变量,代表更新过的节点
let patched = 0;
// 构建索引表
const keyIndex = {};
for (let i = newStart; i <= newEnd; i++) {
keyIndex[newChildren[i].key] = i;
}
// 遍历旧的一组子节点中剩余未处理的节点
for (let i = oldStart; i <= oldEnd; i++) {
const oldVNode = oldChildren[i];
// 如果更新过的节点数量(patched的值)小于等于需要更新的节点数量(source 数组的长度),则执行更新
if (patched <= count) {
// 通过索引表快速找到新的一组子节点中具有相同 key 值的节点位置
const k = keyIndex[oldVNode.key];
if (typeof k !== 'undefined') {
const newVNode = newChildren[k];
// 调用 patch 进行更新
patch(oldVNode, newVNode, container);
// 每更新一个节点,都将 patched 变量 +1
patched++;
// 最后填充 source 数组
source[k - newStart] = i;
// 判断节点是否需要移动
if (k < pos) {
moves = true;
} else {
pos = k;
}
} else {
// 没找到
unmount(oldVNode);
}
} else {
// 如果更新过的节点数量(patched的值)大于需要更新的节点数量(source 数组的长度),则卸载多余节点
unmount(oldVNode);
}
}
}
上面的代码中我们增加了 patched 变量,其初始值为0,代表更新过的节点。接着在第二个 for 循环中增加了判断patched <= count,如果此条件成立,则正常执行更新,并且每次更新 patched 的值自增1;否则说明剩下的节点都是多余的,需要调用 unmount 函数进行卸载。
现在我们通过 moved 的值可以判断是否需要移动节点,接下来讨论怎样移动节点。我们知道 source 数组中存储着新的一组子节点中的节点在旧的一组子节点中的位置,后面会根据 source 数组计算出一个最长递增子序列,用于DOM移动操作。代码如下:
if (j > oldEnd && j <= newEnd) {
// 省略部分代码
} else if (j > newEnd && j <= oldEnd) {
// 省略部分代码
} else {
// 省略部分代码
for (let i = oldStart; i <= oldEnd; i++) {
// 省略部分代码
}
if (moved) {
// 如果 moved 为true,则需要进行 DOM 移动操作
}
}
上面的代码中,我们新增了 if 判断分支,如果 moved 为 true,则说明需要进行 DOM 移动操作,所以用于 DOM 移动操作的逻辑将编写在该 if 语句块内。然后我们计算 source 数组的最长递增序列,其中 source 数组仍然取用上面的例子,如下图所示:
这个例子中,我们计算出 source 数组为 [2,3,1,-1]。
最长递增子序列:**给定一个数值序列,找到它的一个子序列,并且该子序列中的值是递增的,子序列中的元素在原序列不一定是连续。一个序列可能有多个递增子序列,其中最长的那一个就称为最长递增子序列。**举例:假设给定数值序列[0,8,4,12],那么他的最长子系列就是[0,8,12],当然,对于同一个数值序列来说,他的最长递增子序列可能有多个,例如[0,4,12]也是本例的答案之一。
if (moved) {
// 如果 moved 为true,则需要进行 DOM 移动操作
const seq = lis(source); // [0, 1]
}
上面的代码我们使用lis函数计算一个数组的最长递增子序列。lis函数接收 source 数组作为参数,并返回 source 数组的最长递增子序列之一。上面得到 source 数组的最长递增子序列是 [0, 1],因为 lis 函数的返回结果是最长递增子序列中的元素在 source 数组中的位置索引。如下图:
有了最长递增子序列的索引信息后,下一步要重新对节点进行编号,如下图:
观察上图,我们忽略了经过预处理的节点p-1和p-5。所以索引为0的节点是p-2,为索引为1的节点是p-3,以此类推。重新编号是为了让子序列 seq 与新的索引值产生对应关系。其实最长递增子序列 seq 拥有一个非常重要的意思。以上例来说,子序列 seq 的值为[0,1],它的含义是:在新的一组子节点中,重新编号后索引值为0和1的这两个节点在更新的前后顺序没有发生变化。换句话说,重新编号后,索引值为0和1的这两个节点不需要移动。在新的一组子节点中,节点p-3的索引为0,节点p-4的索引为1,所以节点p-3和节点p-4所对应的真实 DOM 不需要移动。也就是只有节点p-2和p-7可能需要移动。
为了完成节点的移动,我们还需要创建两个索引值 i 和 s:
如下图:
观察上图,为了简化图示,我们在去掉了旧的一组子节点以及无关的线条和变量。接下来我们开启一个 for 循环,让变量 i 和 s 按照上图中箭头的方向移动,代码如下:
if (moved) {
// 如果 moved 为true,则需要进行 DOM 移动操作
const seq = lis(source);
// s 指向最长递增子序列的最后一个元素
let s = seq.length - 1;
// i 指向新的一组子节点的最后一个元素
let i = count - 1;
// for 循环使得 i 递减,即按照图中箭头方向移动
for (i; i >= 0; i--) {
if (i !== seq[s]) {
// 如果节点的索引 i 不等于 seq[s] 的值,说明该节点需要移动
} else {
// 当 i === seq[s] 时,说明该位置的节点不需要移动
// 只需要让 s 指向下一个位置
s--;
}
}
}
上面的代码中,变量 i 就是节点的索引。在 for 循环内,判断条件 i !== seq[s],如果节点的索引 i 不等于 seq[s] 的值,说明该节点对应的真实 DOM 需要移动,否则说明当前访问的节点不需要移动,变量 s 递减。
接下来我们就按照上述思路执行更新。初始时索引 i 指向节点p-7。由于节点p-7对应的 source 数组中相同位置的元素值为 -1,所以我们应该将节点p-7作为全新节点进行挂载,如下面代码所示:
if (moved) {
// 如果 moved 为true,则需要进行 DOM 移动操作
const seq = lis(source);
// s 指向最长递增子序列的最后一个元素
let s = seq.length - 1;
// i 指向新的一组子节点的最后一个元素
let i = count - 1;
// for 循环使得 i 递减,即按照图中箭头方向移动
for (i; i >= 0; i--) {
if (source[i] === -1) {
// 说明索引 i 的节点是全新的节点,应该将其挂载
// 该节点在新的 children 中的真实位置索引
const pos = i + newStart;
const newVNode = newChildren[pos];
// 该节点的下一个节点的位置索引
const nextPos = pos + 1;
// 锚点
const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null;
// 挂载
patch(null, newVNode, container, anchor);
} else if (i !== seq[s]) {
// 如果节点的索引 i 不等于 seq[s] 的值,说明该节点需要移动
} else {
// 当 i === seq[s] 时,说明该位置的节点不需要移动
// 只需要让 s 指向下一个位置
s--;
}
}
}
如果 source[i] 的值为-1,说明索引为 i 的节点是全新的节点,于是我们调用 patch 函数将其挂载到容器中。这里需要注意的是,由于索引 i 是重新编号后的,因此为了得到真实索引值,需要计算表达式 i + newStart 的值。
新节点创建完毕后,for 循环已经执行了一次,此时索引 i 向上移动一步,指向了节点p-2,如下图所示:
接着进行下一轮 for 循环,满足条件 i !== seq[s],此时索引 i 值为2,索引 s 的值为1,故节点p-2需要移动,代码如下:
if (moved) {
// 如果 moved 为true,则需要进行 DOM 移动操作
const seq = lis(source);
// s 指向最长递增子序列的最后一个元素
let s = seq.length - 1;
// i 指向新的一组子节点的最后一个元素
let i = count - 1;
// for 循环使得 i 递减,即按照图中箭头方向移动
for (i; i >= 0; i--) {
if (source[i] === -1) {
// 省略部分代码
} else if (i !== seq[s]) {
// 如果节点的索引 i 不等于 seq[s] 的值,说明该节点需要移动
// 该节点在新的一组子节点真实的位置索引
const pos = i + newStart;
const newVNode = newChildren[pos];
// 该节点的下一个节点的位置索引
const nextPos = pos + 1;
// 锚点
const anchor = nextPos < newChildren.length ? newChildren[pos].el : null;
// 移动
insert(newVNode.el, container, anchor);
} else {
// 当 i === seq[s] 时,说明该位置的节点不需要移动
// 只需要让 s 指向下一个位置
s--;
}
}
}
可以看到,移动节点的实现思路类似于挂载全新的节点。不同点在于,移动节点是通过 insert 函数来完成的。接着下一轮的循环。此时索引 i 指向节点p-4,如下图:
更新过程仍然是分为三个步骤,由于不满足条件 source[i] !== -1 和 i !== seq[s],所以代码会最终执行 else 分支。这意味着,节点p-4所对应的真实 DOM 不需要移动,但仍然需要让索引 s 的值递减,即 s–。
然后进入下一个循环,此时的状态如下图:
此时索引 i 指向节点p-3。继续判断三个条件,最终得出,代码将执行 else 分支,也就是第三步,意味着节点p-3对应的真实 DOM 也不需要移动,在完成这一轮更新之后,循环将终止,更新完成。
需要强调的是,关于给定序列的递增子序列的求法,只给出下列求解给定序列的最长递增子序列的代码,取自Vue.js3。网络上有大量文章讲解了这方面的内容,可以进行自行查阅。
function getSequence(arr) {
const p = arr.slice();
const result = [0];
let i,j,u,v,c;
const len = arr.length;
for (i = 0; i < len; i++) {
const arrI = arr[i];
if (arrI !== 0) {
j = result[result.length - 1];
if (arr[j] < arrI) {
p[i] = j;
result.push(i);
continue;
}
u = 0;
v = result.length - 1;
while(u < v) {
c = ((u + v) / 2) | 0;
if (arr[result[c]] < arrI) {
u = c + 1;
} else {
v = c;
}
}
if (arrI < arr[result[u]]) {
if (u > 0) {
p[i] = result[u - 1]
}
result[u] = i;
}
}
}
u = result.length;
v = result[u - 1];
while(u-- > 0) {
result[u] = v;
v = p[v];
}
return result;
}
完整的 patchKeyedChildren 代码如下:
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];
}
// 预处理完毕后,如果满足如下条件,则说明从 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);
}
} else if (j > newEnd && j <= oldEnd) {
// j -> oldEnd 之间的节点应该被卸载
while (j <= oldEnd) {
unmount(oldChildren[j++]);
}
} else {
// 构造 source 数组
// 新的一组子节点中剩余未处理节点的数量
const count = newEnd - j + 1;
const source = new Array(count);
source.fill(-1);
// oldStart 和 newStart 分别为起始索引值,即 j
const oldStart = j;
const newStart = j;
// 新增两个变量,moved 和 pos
let moved = false;
let pos = 0;
// 新增 patched 变量,代表更新过的节点
let patched = 0;
// 构建索引表
const keyIndex = {};
for (let i = newStart; i <= newEnd; i++) {
keyIndex[newChildren[i].key] = i;
}
// 遍历旧的一组子节点中剩余未处理的节点
for (let i = oldStart; i <= oldEnd; i++) {
const oldVNode = oldChildren[i];
// 如果更新过的节点数量小于等于需要更新的节点数量,则执行更新
if (patched <= count) {
// 通过索引表快速找到新的一组子节点中具有相同 key 值的节点位置
const k = keyIndex[oldVNode.key];
if (typeof k !== 'undefined') {
const newVNode = newChildren[k];
// 调用 patch 进行更新
patch(oldVNode, newVNode, container);
// 每更新一个节点,都将 patched 变量 +1
patched++;
// 最后填充 source 数组
source[k - newStart] = i;
// 判断节点是否需要移动
if (k < pos) {
moves = true;
} else {
pos = k;
}
} else {
// 没找到
unmount(oldVNode);
}
} else {
// 如果更新过的节点数量(patched的值)大于需要更新的节点数量(source 数组的长度),则卸载多余 unmount(oldVNode);
}
}
if (moved) {
// 如果 moved 为true,则需要进行 DOM 移动操作
const seq = lis(source);
// s 指向最长递增子序列的最后一个元素
let s = seq.length - 1;
// i 指向新的一组子节点的最后一个元素
let i = count - 1;
// for 循环使得 i 递减,即按照图中箭头方向移动
for (i; i >= 0; i--) {
if (source[i] === -1) {
// 说明索引 i 的节点是全新的节点,应该将其挂载
// 该节点在新的 children 中的真实位置索引
const pos = i + newStart;
const newVNode = newChildren[pos];
// 该节点的下一个节点的位置索引
const nextPos = pos + 1;
// 锚点
const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null;
// 挂载
patch(null, newVNode, container, anchor);
} else if (i !== seq[s]) {
// 如果节点的索引 i 不等于 seq[s] 的值,说明该节点需要移动
// 该节点在新的一组子节点真实的位置索引
const pos = i + newStart;
const newVNode = newChildren[pos];
// 该节点的下一个节点的位置索引
const nextPos = pos + 1;
// 锚点
const anchor = nextPos < newChildren.length ? newChildren[pos].el : null;
// 移动
insert(newVNode.el, container, anchor);
} else {
// 当 i === seq[s] 时,说明该位置的节点不需要移动
// 只需要让 s 指向下一个位置
s--;
}
}
}
}
}