11-快速Diff

91 阅读6分钟

快速Diff算法

由于该算法的实测速度非常快,所以Vue3借鉴并扩展了它,而Vue2使用的还是原来的双端Diff算法

相同的前置元素和后置元素

不同于简单Diff和双端Diff算法,快速Diff算法包含预处理步骤,这里借鉴了纯文本Diff算法的思路

纯文本Diff算法

在纯文本Diff算法中,存在对两段文本进行预处理的过程

例如,在对两段文本进行Diff之前,可以先对他们进行全等比较,也叫快捷路径,如果全等,则无需进入核心Diff算法的步骤了:

 if(text1 === text2) return

除此之外,还可以处理两段文本相同的前缀和后缀去掉相同的前缀后缀,此时需要处理的只有vuereact部分了,如:

 text1: 'I use vue for app development'
 text2: 'I use react for app development'

处理前缀后缀的这种方式,可以很轻松的判断文本的插入和删除,去掉相同的前缀后缀就可以判断插入删除操作

快速Diff算法

快速Diff算法就借鉴了纯文本Diff算法中的预处理步骤

我们可以来看一个例子:

在下面这个例子中,我们可以很清晰的看到新旧子节点中,前置节点p-1是一样的,后置节点p-2、p-3是一样的,所以他们在新旧子节点中的相对位置不变,我们只需要给他们打补丁就可以了

快速Diff例子.jpg

所以我们现在要看看具体怎么找到前置后置节点:

  • 对于前置节点,我们建立索引j初始值为0指向两组子节点的开头,然后用一个循环让索引j递增,直到遇到不相同节点为止

     function patchKeyedChildren(n1, n2, container) {
         const oldChildren = n1.children
         const newChildren = n2.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]
         }
     }
    
  • 按照同样的思路,更新后置节点即可,但是有一点不一样,处理前置节点我们是直接用一个变量索引j指向头节点就好了,但是处理后置节点不能这么做,因为两组节点的节点个数不一定相等,所以要用两个变量newEndoldEnd来指向新旧子节点的最后一个子节点

     function patchKeyedChildren(n1, n2, container) {
         const oldChildren = n1.children
         const newChildren = n2.children
         let j = 0
         let oldVNode = oldChildren[j]
         let newVNode = newChildren[j]
         while(oldVNode.key === newVNode.key){
             patch(oldVNode, newVNode, container)
             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]
         //循环从后向前遍历,直到遇到拥有不同key值的节点为止
         while(oldVNode.key === newVNode.key){
             //调用patch函数进行更新
             patch(oldVNode, newVNode, container)
             //递减oldEnd和newEnd
             oldEnd--
             newEnd--
             //更新现在的索引所指向的节点
             oldVNode = oldChildren[oldEnd]
             newVNode = newChildren[newEnd]
         }
     }
    
  • 现在我们已经处理好了前置后置节点,此时会发现新子节点中还遗漏了一个节点未被处理:p-4

    所以现在我们的任务就是使用程序判断出p-4是新增节点,可以比较三个索引之间的关系:

    • **oldEnd < j**成立,说明在预处理过程中,所有旧子节点都处理完毕了
    • **newEnd >= j**成立,说明在预处理过程后,在新的一组子节点中仍然有未被处理的节点,这些遗留的节点即为新增节点

    如果上述两个条件都满足,则说明新的一组子节点中有新增节点

    快速Diff新增节点情况.jpg

    由上述分析可以知道,在新子节点中,索引值处于jnewEnd之间的任何节点都需要作为新的子节点进行挂载,所以下一个问题就是应该怎么挂载到正确位置

    观察图可知,挂载的位置应该就是节点p-2之前,所以可以把p-2当作对应的锚点元素

     function patchKeyedChildren(n1, n2, container) {
         //处理相同的后前置节点
         //省略部分代码
         
         //处理相同的后置节点
         //省略部分代码
         
         //预处理完毕后,如果满足以下条件,则说明从j到newEnd之间的节点应作为新节点插入
         if(j > oldEnd && j <= newEnd){
             //锚点的索引
             const anchorIndex = newEnd + 1
             //锚点元素,小于节点长度则为null,防止索引newEnd对应的节点已经是尾部节点了
             const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null
             //采用while循环,调用patch函数逐个挂载新增节点
             while(j <= newEnd){
                 patch(null, newChildren[j++], container, anchor)
             }
         }
     }
    

知道怎么新增节点之后,我们再来看看卸载节点

其实思路大体都是和新增一样的,唯一不同的就是判断卸载操作的操作

快速Diff卸载节点情况.jpg

 function patchKeyedChildren(n1, n2, container) {
     //处理相同的后前置节点
     //省略部分代码
     
     //处理相同的后置节点
     //省略部分代码
     
     //预处理完毕后,如果满足以下条件,则说明从j到newEnd之间的节点应作为新节点插入
     if(j > oldEnd && j <= newEnd){
         const anchorIndex = newEnd + 1
         const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null
         while(j <= newEnd){
             patch(null, newChildren[j++], container, anchor)
         }
     }else if(j > newEnd && j <= oldEnd){
         //j到oldEnd之间的节点应该被卸载
         while(j <= oldEnd){
             unmount(oldChildren[j++])
         }
     }
 }

判断是否需要进行DOM移动操作

在上一节中,我们采用的例子都十分简单,只是简单的挂载卸载节点即可,但是在平时中的大部分情况,往往需要进行DOM节点的移动

现在我们再来看一个例子:

快速Diff复杂例子.jpg

在这个例子中,并没有那么理想化,在这其中,新子节点多出了p-7,少了一个节点p-6,相同的前置节点只有p-1,相同的后置节点只有p-5

可以知道,经过预处理后,无论是新的一组子节点,还是旧的一组子节点,都有部分节点未经处理,这时就需要我们进一步处理,处理的规则跟其他两个Diff算法一样:

  • 判断是否有节点需要移动,以及应该如何移动
  • 找出需要被添加或移除的节点

所以我们现在需要先判断哪个节点需要移动,判断是否需要进行DOM移动操作

在前面的代码中,我们处理了理想情况,现在我们要处理这种非理想情况,所以应该再代码中新增分支:

 function patchKeyedChildren(n1, n2, container) {
     //处理相同的后前置节点
     //省略部分代码
     
     //处理相同的后置节点
     //省略部分代码
     
     //预处理完毕后,如果满足以下条件,则说明从j到newEnd之间的节点应作为新节点插入
     if(j > oldEnd && j <= newEnd){
         //省略部分代码
     }else if(j > newEnd && j <= oldEnd){
         //j到oldEnd之间的节点应该被卸载
         //省略部分代码
     }else{
         //增加else分支来处理非理想情况
         
     }
 }

知道了要干什么之后,我们就可以开始分析具体处理思路了

  • 首先,要维护一个数组长度等于新的一组子节点在经过预处理后剩余未处理节点的数量,并且source中每个元素的初始值都是1

    维护这个数组的目的是要存储新的一组子节点中的每个节点在旧的一组子节点中的位置索引后面将会使用他计算出一个最长的递增子序列,并用于辅助完成DOM移动的操作

    快速Diff算法构建source数组.jpg

     function patchKeyedChildren(n1, n2, container) {
         //处理相同的后前置节点
         //省略部分代码
         
         //处理相同的后置节点
         //省略部分代码
         
         //预处理完毕后,如果满足以下条件,则说明从j到newEnd之间的节点应作为新节点插入
         if(j > oldEnd && j <= newEnd){
             //省略部分代码
         }else if(j > newEnd && j <= oldEnd){
             //j到oldEnd之间的节点应该被卸载
             //省略部分代码
         }else{
             //增加else分支来处理非理想情况
             //计算新子节点经过预处理后的长度
             const count = newEnd - j + 1
             //构造一个长度为count的数组
             const source = new Array(count)
             //用-1填充这个数组
             source.fill(-1)
         }
     }
    
  • 接下来,应该填充source数组,由于**source存储的是新子节点在旧子节点的位置索引**,所以source数组应该修改为[2, 3, 1, -1],如图所示

    具体的实现我们可以通过两层for循环来完成source数组的填充工作外层用于遍历旧的一组子节点,内层遍历新的一组子节点

    快速Diff填充Source数组.jpg

    function patchKeyedChildren(n1, n2, container) {
        //省略部分代码
        if(j > oldEnd && j <= newEnd){
            //省略部分代码
        }else if(j > newEnd && j <= oldEnd){
            //省略部分代码
        }else{
            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数组的填充,但是目前我们会发现,我们采用了两层循环的方式来填充数组,这种方法的复杂度比较高,当节点数量增多的时候,会带来性能问题

    所以我们优化这个问题,为新的一组子节点构建一张索引表,用来存储节点的key和节点位置索引之间的映射,这样我们就可以根据索引表快速填充source数组了(遍历旧子节点寻找索引表中对应的key),可以将时间复杂度降低至O(n)

    function patchKeyedChildren(n1, n2, container) {
        //省略部分代码
        if(j > oldEnd && j <= newEnd){
            //省略部分代码
        }else if(j > newEnd && j <= oldEnd){
            //省略部分代码
        }else{
            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 = newStart; i <= newEnd; i++){
                keyIndex[newChildren[i].key] = i
            }
            //遍历旧的一组子节点中剩余未处理的节点
            for(let i = oldStart; i <= oldEnd; i++){
                oldVNode = oldChildren[i]
                //通过索引表快速找到新的一组子节点中具有相同key值的节点位置
                const k = keyIndex[oldVNode.key]
                //如果k不为undefined,则说明存在这样一个节点
                if(typeof k !== 'undefined'){
                    //获取对应的新子节点
                    newVNode = newChildren[k]
                    //调用Patch完成更新
                    patch(oldVNode, newVNode, container)
                    //填充source数组
                    source[k - newStart] = i
                }else{
                    //没找到相同key值得节点则卸载旧子节点
                    unmount(oldVNode)
                }
            }
        }
    }
    
  • 完成了source的填充之后,我们就要思考怎么判断节点是否需要移动了

    我们需要新增两个变量movedpos前者代表是否需要移动节点后者代表遍历旧的一组子节点的过程中遇到的最大索引值k

    判断原来与简单Diff算法类似,如果在遍历过程中遇到的索引值呈现递增趋势,则说明不用移动节点,反之则需要

    除此之外,我们还需要一个数量标识代表已经更新过节点的数量,因为我们知道,已经更新过的节点数量应该小于新的一组子节点中需要更新的节点数量一旦前者超过后者(旧子节点多于新子节点),则说明有多余的节点,需要卸载

    function patchKeyedChildren(n1, n2, container) {
        //省略部分代码
        if(j > oldEnd && j <= newEnd){
            //省略部分代码
        }else if(j > newEnd && j <= oldEnd){
            //省略部分代码
        }else{
            const count = newEnd - j + 1
            const source = new Array(count)
            source.fill(-1)
            const oldStart = j
            const newStart = j
            //代表是否需要移动节点
            let moved = false
            //代表遍历旧的一组子节点中遇到的最大索引值k
            let pos = 0
            const keyIndex = {}
            for(let i = newStart; i <= newEnd; i++){
                keyIndex[newChildren[i].key] = i
            }
            //代表更新过的节点数量
            let patched = 0
            //遍历旧的一组子节点中剩余未处理的节点
            for(let i = oldStart; i <= oldEnd; i++){
                oldVNode = oldChildren[i]
                if(patched <= count){
                    const k = keyIndex[oldVNode.key]
                    if(typeof k !== 'undefined'){
                        newVNode = newChildren[k]
                        patch(oldVNode, newVNode, container)
                        //每更新一个节点,都要将patched + 1
                        patched++
                        source[k - newStart] = i
                        //判断节点是否需要移动
                        if(k < pos){
                            moved = true
                        }else{
                            pos = k
                        }
                    }else{
                        //没找到相同key值得节点则卸载旧子节点
                        unmount(oldVNode)
                    }
                }else{
                    //如果更新过的节点数量大于需要更新的节点数量,则卸载多余的节点
                    unmount(oldVNode)
                }
    
            }
        }
    }
    

如何移动元素

上一节中,我们已经实现了找到需要移动的DOM元素了,所以这一节我们要来实现如何移动DOM元素

在上一节的代码中,我们可以看到,如果movedtrue时,就需要移动节点

if(j > oldEnd && j <= newEnd){
        //省略部分代码
    }else if(j > newEnd && j <= oldEnd){
        //省略部分代码
    }else{
        //省略部分代码
        for(let i = oldStart; i <= oldEnd; i++){
        	//省略部分代码
        }
        if(moved){
            //如果moved为真,则需要进行DOM移动操作
        }
    }
}
  • 首先,我们需要先根据**source数组计算出它的最长递增子序列**,并且转化成元素在source数组中的位置索引

    if(moved){
        const seq = lis(sources)
    }
    

    快速Diffsource例子.jpg

    其中,最长子序列就是[2, 3],而我们通过lis计算出来的最长子序列是[0, 1],因为这个函数会返回最长递增子序列元素在source数组中的位置索引

  • 接下来,我们就要对节点重新编号

    要先忽略经过了预处理的节点,编号结果如下:

    快速Diff对节点重新编号.jpg

    而此处的最长子序列的作用就是:在新的一组子节点中,重新编号后索引值为01的节点在更新前后的顺序没有发生变化,所以不用移动

  • 编完号后,我们需要新创建两个索引值isi指向新的一组子节点中的最后一个节点,s指向最长递增子序列中的最后一个元素,并且开启一个for循环,让变量is按照图中箭头的方向移动,如图所示

    快速Diff索引.jpg

    if(j > oldEnd && j <= newEnd){
            //省略部分代码
        }else if(j > newEnd && j <= oldEnd){
            //省略部分代码
        }else{
            //省略部分代码
            for(let i = oldStart; i <= oldEnd; i++){
            	//省略部分代码
            }
            if(moved){
                //如果moved为真,则需要进行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--
                    }
                }
            }
        }
    }
    
  • 开启for循环之后,我们就可以开始更新了,首先当前的索引指向节点p-7,由于source数组中找到对应的元素值为-1,所以应该作为新子节点挂载

    if(moved){
        const seq = lis(source)
        let s = seq.length - 1
        let i = count - 1
        for(i; i >= 0; i--){
            //首先要判断索引为i的节点存不存在,如果不存在则要挂载
            if(source[i] === -1){
                //记录该结点在新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--
            }
        }
    }
    
  • 接下来,进入下一轮循环,索引指针移动到p-2处了,此时的分析步骤如下

    第一步,source[i]是否等于-1,可以查到并不等于-1,所以他不是一个全新的节点,因此不需要挂载它

    第二步,i !== seq[s]是否成立,此时索引i为2,索引s1,因此**2 !== seq[1]成立,节点p-2对应的真实DOM则需要移动**

    if(moved){
        const seq = lis(source)
        let s = seq.length - 1
        let i = count - 1
        for(i; i >= 0; i--){
            //首先要判断索引为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[nextPos].el : null
                //移动
                insert(newVNode.el, container, anchor)
            }else{
                //如果i === seq[s]则说明不需要移动,只需要让s指向下一个位置
                s--
            }
        }
    }
    
  • 接着,下一轮循环,也就是指针指到了p-4,同样还是对他进行分析:

    第一步,source[i]是否等于-1,可以查到并不等于-1,所以他不是一个全新的节点,因此不需要挂载它

    第二步, i !== seq[s]是否成立,此时索引i为1,索引s1,因此2 !== seq[1]不成立

    第三步,由于第一步和第二步都不成立,所以代码会执行最终的else分支,节点不需要移动,但是需要让s的值递减

    通过这三步判断,就可以确定该结点不需要移动了,则进入下一轮循环,而下一轮循环也是一样,节点p-3不需要移动,完成最后一轮的更新后,循环就会停止,更新完成

注:此处的递增子序列的求法在书中295