快速Diff算法,顾名思义,该算法的实测速度非常快。该算法最早应用于ivi和inferno这两个框架,Vue.js3借鉴并进行了扩展。
不同于简单Diff算法和双端Diff算法,快速Diff算法包含预处理步骤,这其实也是借鉴了纯文本Diff算法的思路。在纯文本Diff算法中,存在对两段文本进行预处理的过程。
例如,在对两端文本进行Diff之前,会先对其进行全等比较,这也叫做快捷路径。如果全等,就不用进入核心Diff算法的步骤。此外,预处理过程还会处理两段文本相同的前缀和后缀。
两段文本中相同内容,就不需要进行核心Diff操作。这其实是简化问题的一种方式。这样在特定情况下能够判断文本的插入和删除,如下面的例子:
TEXT1: I like you
TEXT2: I like you too
经过比较可以发现,TEXT2是在TEXT1的基础上增加了字符串too。
快速Diff算法借鉴了纯文本Diff算法中预处理的步骤,看下面的例子:
旧子节点:p1,p2,p3
新子节点:p1,p4,p2,p3
可以发现,两组子节点有相同的前置节点p1和相同的后置节点p3和p4
对于相同的前置节点和后置节点,由于在新旧子节点中的相对位置不变,所以不需要移动,但还是要再它们之间打补丁
对于前置节点,可以建立索引j,其初始值为0,用来指向两组子节点的开头
然后开启一个while循环,让索引j递增,直到遇到不相同的节点为止,如下面的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[k]
}
}
这样就完成了对前置节点的更新。
这里要注意的是,循环终结时,索引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[k]
}
// 更新相同的后置节点
// 索引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和nextEnd
oldEnd--
newEnd--
oldVNode = oldChildren[oldEnd]
newVNode = newChildren[newEnd]
}
}
在这一步更新操作过后,新旧两组子节点状态如图:
可以看到最后还遗留一个未被处理的节点p4,可以看到,节点p4是一个新增节点,那么程序是如何得出“节点p4是新增节点的”,这里就需要观察三个索引j,newEnd和oldEnd之间的关系
- oldEnd<j 成立,说明在预处理过程中,所有旧子节点都处理完毕
- newEnd>=j成立,说明在预处理过后,在新的一组子节点中,仍然有未被处理的节点,这些遗留的节点将被看作新增节点
条件一和条件二同时成立时,说明在新的一组子节点中,存在遗留节点,且这些节点都是新增节点。因此要将其挂载到正确的位置。
在新的子节点中,索引值处于j和newEnd之间的任何节点都需要作为新的子节点进行挂载。这里,新增节点应该挂载到节点p2对应的真实DOM前面。所以节点p2对应的真实DOM节点就是挂载操作的锚点元素。下面看具体的代码实现:
function patchKeyedChildren(n1,n2,container){
const newChildren = n2.children
const oldChildren = n1.children
// 更新相同的前置节点
// 省略部分代码
// 更新相同的后置节点
// 省略部分代码
// 预处理完毕后,如果满足如下条件,说明j到nextEnd之间的节点应作为新节点插入
if(j>oldEnd && j<=newEnd){
// 锚点的索引
const anchorIndex = newEnd + 1
// 锚点元素
const anchor = anchorIndex < newChildren.length?newChildren[anchorIndex].el : null
// 采用while循环,调用patch函数逐个挂载新增节点
while(j<=newEnd){
patch(null,newChildren[j++],container,anchor)
}
}
}
首先计算锚点的索引值(即anchorIndex)为newEnd+1。如果小于新的一组子节点的数量,则说明锚点元素在新的一组子节点中,所以直接使用newChildren[anchorIndex].el
作为锚点元素;否则说明索引newEnd对应的节点已经是尾部节点了,这时无须提供锚点元素。有了锚点元素,就可以开启while循环了。
后面再来看看删除节点的情况:
看下面的例子
旧的子节点:p1,p2,p3
新的子节点:p1,p3
同样适用索引j,oldEnd和newEnd来进行标记,
在进行完预处理后,各个索引的关系如上图
同理遗留的节点可能是多个,也就是索引j和索引oldEnd之间的任何节点都应该被卸载,具体实现如下:
function patchKeyedChildren(n1,n2,container){
const newChildren = n2.children
const oldChildren = n1.children
// 更新相同的前置节点
// 省略部分代码
// 更新相同的后置节点
// 省略部分代码
if(j>oldEnd && j<=newEnd){
// 省略
}else if(j>newEnd && j<=oldEnd){
// j到oldEnd之间的节点应该被卸载
while(j<=oldEnd){
unmount(oldChildren[j++])
}
}
}
当满足条件j>newEnd && j<=oldEnd时,则开启一个while循环,并调用unmount去逐个卸载这些遗留节点