vue3源码学习(8) -- runtime-core(4):双端diff算法

278 阅读4分钟

vue3源码学习(8) -- runtime-core(4):diff算法

前言

本文将继续学习children更新中最为重要的最为重要的部分,ARRAY TO ARRAY的更新,也就是大名鼎鼎的diff算法其核心就是锁定中间的乱序部分

Array TO Array

diff算法也分为两种情况:有keykey两种情况

无key情况下的简易流程:

  • 获取旧节点的长度
  • 获取新节点的长度
  • 取两个长度中较小的长度值
  • 从0位置开始依次进行比较for(i=0;i<commentLength;i++)
  • 如果旧节点数 > 新节点数,移除多余的就节点

image.png

  • 如果旧节点数 < 新节点数,增加节点

image.png

有key的情况下:进行双端diff算法。接下来我们着重学习双端diff算法 双端diff算法的流程和处理场景:

  • ①左侧对比或右侧对比
  • ②新的比来的长或新的比老的短
  • ③中间对比

左侧对比

image.png

// (a b) c
// (a b) d e

const prevChildren = {
    h("p",{key:'A'},"A")
    h("p",{key:'B'},"B")
    h("p",{key:'C'},"C") 
}
const newChildren = {
    h("p",{key:'A'},"A")
    h("p",{key:'B'},"B")
    h("p",{key:'D'},"D")
    h("p",{key:'E'},"E")
     
}

当出现下图所示时候,范围锁定 image.png

所以可确定i的条件: i <= e1 && i <=e2

function patchChildren(n1,n2,container,preComponent){
    /*其他代码*/
    if(shapeFlag & shapeFlags.text_children){
        /*其他代码*/
    }else{  
        if(preShapeFlag & shapeFlag.text_children){
        /*其他代码*/
         }else{
             patchKeyChildren(c1,c2)
         }    
    }
}
function patchKeyChildren(c1,c2,container,parentComponent){
    
    let i = 0
    let e1 = c1.length -1
    let e2 = c2.length -1
    
    function isSameVnodeType(n1,n2){
        //怎么判断n1 === n2
        //  type  和 key
        return n1.type ===n2.type && n1.key ===n2.key   // 将key挂载到vnode中
     }
    
    //左侧
    while(i <= e1 && i <= e2){
        const n1 = c1[i]
        const n2 = c2[i]
        //判断vnode 是否相同
        if(isSameVnodeType(n1,n2)){
           
         // key 或者 type相同时,递归调用patch进行props和下一层的children更新
         patch(n1,n2,container,parentComponent)   
         }else{
             break
         }
         i++
   }
}

右侧对比

image.png

//     (b c) 
// d e (b c) 

const prevChildren = {
    h("p",{key:'A'},"A")
    h("p",{key:'B'},"B")
    h("p",{key:'C'},"C") 
}
const newChildren = {
    h("p",{key:'D'},"D")
    h("p",{key:'E'},"E")
    h("p",{key:'B'},"B")
    h("p",{key:'C'},"C")
     
}

当出现下图所示时候,范围锁定。 image.png

所以可确定i的条件: i <= e1 && i <=e2

patchKeyChildren(c1,c2,container,parentComponent){
    /*其他代码*/
    //左侧
    while(i <= e1 && i<=e2){ /*其他代码*/ }
    
    //右侧
    while(i <= e1 && i<=e2){
        const n1 = c1[e1]
        const n2 = c2[e2]
        
        //判断vnode 是否相同
        if(isSameVnodeType(n1,n2)){
           
         // key 或者 type相同时,递归调用patch进行props和下一层的children更新
         patch(n1,n2,container,parentComponent)   
         }else{
             break
         }
         e1--
         e2--
    }
}    

通过左侧或者右侧对比会锁定一个范围

新的比老的长需要进行创建element

需要创建的element在右侧

image.png

// (a b) 
// (a b) c

const prevChildren = {
    h("p",{key:'A'},"A")
    h("p",{key:'B'},"B")
    
}
const newChildren = {
    h("p",{key:'A'},"A")
    h("p",{key:'B'},"B")
    h("p",{key:'C'},"C")
     
}

image.png

所以可确定i的条件: e1 < i <=e2

patchKeyChildren(c1,c2,container,parentComponent){
    /*其他代码*/
    //左侧
    while(i <= e1 && i<=e2){ /*其他代码*/ }
    
    //右侧
    while(i <= e1 && i<=e2){/*其他代码*/}
    
    //新的比旧的长
    if(i > e1){
        if( i <= e2){
            patch(null,c2[i],container,parentComponent)
        }
    }    
 }   

需要创建的element在左侧

image.png

//   (a b) 
// c (a b) 

const prevChildren = {
    h("p",{key:'A'},"A")
    h("p",{key:'B'},"B")
    
}
const newChildren = {
    h("p",{key:'C'},"C")
    h("p",{key:'A'},"A")
    h("p",{key:'B'},"B")    
}

当出现下图所示,范围确定

image.png

此时可以看到i的范围同样满足:e1 < i <= e2

所以之前实现的流程仍然适合,不过不能的是C的创建位置不是在后面,而是插在A之前,所以我们需要对patch函数中的挂载element操作进行修改

//renderer.ts

function mountElement(vnode,container,parentComponent,anchor){
    const el = vnode.el = document.createElement(vnode.type)
    
    //props
    const { props } = vnode
    for (const key in props) {
      const val = props[key];
      hostPatchProp(el, key, null, val);
    }
    // children
    const { children, shapeFlag } = vnode;

    if (shapeFlag & shapeFlags.text_children) {
     
      el.textContent = children;
    } else if (shapeFlag & shapeFlags.array_children) {
      mountChildren(vnode, el, parentComponent, anchor);
      
      //修改挂载el的方式 不仅仅是appendChild
      hostInsert(el, container, anchor);   
    }
    
function hostInsert(child,parent,anchor){
    //添加到指定位置
    parent.insertBefore(child,anchor || null)
}

此时修改之前patchKeyChildren函数中的代码,获取到anchor锚点

function patchKeyChildren(c1,c2,container,parentComponent){
    //左侧对比
    /*代码*/
    //右侧对比
    /*代码*/
    // 新的比老的长
    if(i > e1){
        if( i <= e2){
            const nextPos = e2 + 1
            const anchor =  nextPos < c2.length ?  c2[nexPos].el :null   // 如果e2 + 1  >c2.length  表示需要在后面添加 
            while ( i <= e2){
               patch(null,c2[i],contianer,parentComponent,anchor)
               i ++ 
           }
        }
   }

老的比新的长

需要删除的元素在右侧 image.png

// (a b) 
// (a b) c

const prevChildren = {
    h("p",{key:'A'},"A")
    h("p",{key:'B'},"B")
    h("p",{key:'C'},"C")
    
}
const newChildren = {
   
    h("p",{key:'A'},"A")
    h("p",{key:'B'},"B")    
}

如下图所示,锁定范围

image.png 可以得出i的范围 : e2 < i <= e1

代码实现

patchKeyChildren(c1,c2,container,parentComponent,anchor){
    /*其他代码*/
    //左侧
    while(i <= e1 && i<=e2){ /*其他代码*/ }
    
    //右侧
    while(i <= e1 && i<=e2){/*其他代码*/}
    
    //新的比旧的长
    if(i > e1){
        /*其他代码*/
    }else if( i > e2 ){
        while( i <= e1){
            //删除节点
            hostRemove(c2[i].el)
            i++
         }
     }    
 } 
function  hostRemove(child) {
  const parent = child.parentNode;
  if (parent) {
    parent.removeChild(child);
  }
}

需要删除的元素在左侧

image.png

// c (a b)
//   (a b)
const prevChildren = {
    h("p",{key:'C'},"C")
    h("p",{key:'A'},"A")
    h("p",{key:'B'},"B")
   
    
}
const newChildren = {
   
    h("p",{key:'A'},"A")
    h("p",{key:'B'},"B")    
}

如下图所示,锁定范围

image.png 可以得出i的范围 : e2 < i <= e1,之前事件的代码仍然符合

中间对比

中间对比又分为三种情况

  • 删除老的,在老的里面存在,新的里面不存在

  • 创建新的, 在老的里面不存在,新的里面存在

  • 移动,节点在新的老的里面都存在,只是位置发生该改变

删除

// a b ( c d ) f g
// a b ( e c ) f g
const prevChildren = {
    
    h("p",{key:'A'},"A")
    h("p",{key:'B'},"B")
    h("p",{key:'C',id="c-prev"},"C")
    h("p",{key:'D'},"D")
    h("p",{key:'F'},"F")
    h("p",{key:'G'},"G")
   
    
}
const newChildren = {
   
    h("p",{key:'A'},"A")
    h("p",{key:'B'},"B") 
    h("p",{key:'E'},"E")
    h("p",{key:'C',id="c-next"},"C")
    h("p",{key:'F'},"F")
    h("p",{key:'G'},"G")
}

上述实例代码中可以看到d元素只存在于老节点,并不存在于新的节点,那么我们需要做的就是删除d

应该怎么删除呢?

通常我们会想到 遍历新节点队列中的(e,c)列表(之前实现的功能能够将a、b、f、g渲染出来),判断是D节点是否存在,此时时间复杂度为O(n)

但我们在渲染element之前,我们给element传递了一个props为Key,所以我们可以利用KEY,我们可以将(e,c)通过key映射一个,map,结构{E(key):i,C(key):i},接下来我们就可以通过旧节点的key来查找在新节点中是否存在,若存在就patch,不存在就删除。此时事件复杂读为O(1)

代码实现

patchKeyChildren(c1,c2,container,parentComponent,anchor){
    let i = 0
    let e1 = n1.length - 1
    let e2 = n2.length - 1
    
    
    //左侧对比
    while( i <= e1 && i <= e2){
        /*其他代码*/
        // 相同 patch   i++
        //不同 break
     }
    
    //右侧代码
    while( i <= e1 && i <= e2){
        /*其他代码*/
         // 相同 patch  e1--, e2--
        //不同 break
    }
    
    // 若新的比老的长
    if( i > e1){
        if( i <= e2){
            /*其他代码*/
            // i<=e2 条件下 创建新的  i++
         }
    }
    //新的比老的短   
    else if( i > e2){     
        /*其他代码*/
        //  i<= e1 情况下  删除老的  i++
    }
     //中间对比
    else{  
        //记录新老节点的起始位置
        let s1 = i //老
        let s2 = i // 新
        
        //建立新节点的映射表
        cosnt keyToNewIndexMap = new Map()
        let (let i = s2; i <=e1; i++){
            const nextChid = c2[i]
            keyToNewIndexMap.set(nextChid.key,i)
         }
         
         let( let i = s1; i<=e2 ; i++){
             const prevChild = c1[i]
             let newIndex
             // 存在key
             if(prevChild.key !== null){
                 newIndex = keyToNewIndexMap.get(prevChild.ley)
              }else{
                  //不存在key,需要遍历新节点列表
                  for(let j = s2; j<= e1;j++){
                      if(isSameVnodeType(prevchild,c2[j])){
                          newIndex = j;      
                          break
                  }
               }  
               if(newIndex === undefined){
                   hostRemove(prevChild.el)
               }else{
                    patch(prevChild,c2[newIndex],container,parentCpmonent,null)
               }
          }     
     }   
}      

此时 执行代码可以看到D元素已经被删除

image.png

逻辑优化

// a b ( c e d ) f g
// a b ( e c ) f g

当新节点都已经比较完成后,老节点仍然存在没有对比过的,后续存在的老节点可以直接进行删除

  • 记录新节点的数量 toBePatch
  • 已经更新的数量

patchKeyChildren(c1,c2,container,parentComponent,anchor){
    /*代码*/
    
    else {
        let s1 = i
        let s2 = i
        
        const toBePatched = e2 -s2 + 1   //需要进行更新节点的数量
        const patched = 0  //当前已经更新的节点的数量
        for( let i = s1; i<=e2 ; i++){
            const prevChild = c1[i]
        
            if(patched >= toBePatched){
                hostRemove(preChild.el)
                continue  // 后面不用执行,跳出此次循环
            }   
            /*其他代码*/
            if(newIndex === undefined){
                   hostRemove(prevChild.el)
               }else{
                    patch(prevChild,c2[newIndex],container,parentCpmonent,null)
                    patched ++
                }
           }
      }
 }   
 

移动

下面例子中,我们需要移动e节点

// a b ( c d e ) f g     

// a b ( e c d ) f g    

暴力解法

  • 分别判断 c,d,e是否在新的节点中,如果存在则插到指定位置,如果不存在就删除,这就相当于对(c d e)模块进行了重新排列,十分消耗性能。

更好的提供性能的方法:不需要移动的节点就可以移动,尽可能减少dom的移动。 我们对比新旧节点来看,仅仅需要把e节点移动到c d之前即可实现。这样减少dom元素的操作,就可以降低性能损耗。

那么怎么做到只移动e就可以实现需求呢?

我们可以得到需要移动的区域的vnode的节点索引值(c,d,e)=>(2,3,4),移动后的vnode节点索引值(e,c,d)=>(4,2,3),所以我们要做的只是移动(e:4) ,这时可以将(c d)=>(2,3)看作一个稳定的序列。那么剩下的就是将不稳定节点(e)进行增删改查,重新排列位置,使其满足新节点队列的位置。所以我们通过新旧节点对比区域找到最长递增子序列,然后遍历旧节点的对比区域,判断节点是否在最长递增子序列中,如果在其中则不需要进行操作,不存在则进行操作。这样就可以尽可能地减少dom操作

总结就是:根据新旧节点列表对比区域的映射关系找到最长递增子序列,对比旧节点对比区域中需要进行操作的区域,进行dom操作,达到最终效果

怎么找到最长递增子序列? 参考leetcode 300题

最长递增子序列

/*
* 求最长递增子序列在原数组的下标数组
* @param arr {number[]}
* @return {number[]}}
*/
function getSequence(arr:number[]):number[] {
   //浅拷贝arr
  const p = arr.slice();
  const len = arr.length;
  //存储最长递增子序列对应arr中下标的数组
  const result = [0];
  
  let i, j, u, v, c;
 
  for (let i = 0; i < len; i++) {
    const arrI = arr[i];
    //排除等于0的情况
    
    if (arrI !== 0) {
      j = result[result.length - 1]; //获取当前reslut的最大值的下标  
         //如果当前val 大于当前递增子序列的最大值的时候,直接添加
      if (arr[j] < arrI) {
        p[i] = j;  //保存上一次递增子序列最后一个值的索引
        result.push(i);
        continue;
      }
      /*二分查找*/
      
      //定义二分查找区间[u,v]
      u = 0;
      v = result.length - 1;
      while (u < v) {
         //求中间值(向下取整)
        c = (u + v) >> 1;
        if (arr[result[c]] < arrI) {
          u = c + 1;
        } else {
          v = c;
        }
      }
      // 当前递增子序列按顺序找到第一个大于 val 的值,将其替换
      if (arrI < arr[result[u]]) {
        if (u > 0) {
        // 保存上一次递增子序列最后一个值的索引
          p[i] = result[u - 1];
        }
        // 此时有可能导致结果不正确,即 result[left + 1] < result[left]
        // 所以我们需要通过 _arr 来记录正常的结果
        result[u] = i;
      }
    }
  }
  // 修正贪心算法可能造成最长递增子序列在原数组里不是正确的顺序
  u = result.length;
  v = result[u - 1];
  // 倒序回溯,通过之前 _arr 记录的上一次递增子序列最后一个值的索引
  // 进而找到最终正确的索引
  while (u-- > 0) {
    result[u] = v;
    v = p[v];
  }
  return result;
}

getSequence([4,2,3])  // [1,2]为最长递增子序列

移动逻辑实现:

patchKeyChildren(c1,c2,container,parentComponent,anchor){
    /*代码*/
    
    else {
        let s1 = i
        let s2 = i
        
        const toBePatched = e2 -s2 + 1   //需要进行更新节点的数量
        const patched = 0  //当前已经更新的节点的数量
        
        const newIndexToOldIndexMap = new Array(toBePatched)  //建立新建节点的映射关系 定长的数组
        newIndexToOldIndexMap.forEach((i) => {newIndexToOldMap[i] = 0})
        
        for ( let i = s1; i<=e2 ; i++){
            const prevChild = c1[i]
        
            if(patched >= toBePatched){
                hostRemove(preChild.el)
                continue  // 后面不用执行,跳出此次循环
            }   
            let newIndex
            /*其他代码*/
            if(newIndex === undefined){
                   hostRemove(prevChild.el)
            }else{
               
                  newIndexToOldIndexMap(newIndex - s2)  =  i + 1  //映射关系 
                  patch(prevChild,c2[newIndex],container,parentCpmonent,null)
                  patched ++
            }
         }
         
         const increasingNewIndexSequence  = getSequence(newIndexToOldIndexMap)
         let j
         for(let i = toBePatch - 1; i >= 0; i--){
         
             const nextIndex = i + s2
             const nextChild = c2[nextIndex]
             const anchor = nextIndex + 1 < c2.length ? c2[indexIndex + 1].el : null
             if(j < 0 || i !== increasingNewIndexSequence[j])
                hostInsert(nextChild.el, container, anchor);
                console.log("移动位置")
             }else{
                 j--
             }
         }    
             
      }
 }   

新增

之前我们实现中间对比中移动逻辑的时候,创建了新旧节点对比区域的映射关系数组,从中可以看到,如果旧节点中的数据新节点中没有,就不会出现在映射关系数组中,即newIndexToOldIndexMap[i] === 0,此时就需要创建数组


/*其他代码*/

 const increasingNewIndexSequence  = getSequence(newIndexToOldIndexMap)
         let j
         for(let i = toBePatch - 1; i >= 0; i--){
         
             const nextIndex = i + s2
             const nextChild = c2[nextIndex]
             const anchor = nextIndex + 1 < c2.length ? c2[indexIndex + 1].el : null
             
             
            if (newIndexToOldIndexMap[i] === 0) {
              patch(null, nextChild, container, parentComponent, anchor);
            }else{
             if(j < 0 || i !== increasingNewIndexSequence[j])
                hostInsert(nextChild.el, container, anchor);
                console.log("移动位置")
             }else{
                 j--
             }
         }    
       }    

到目前为止,我们的array to array的简单实现已经完成,接下来 我们通过一个中和案例来检验一下具体流程

综合案例

// 综合例子
// a,b,(c,d,e,z),f,g
// a,b,(d,c,aky,y,e),f,g

const prevChildren = [
    h("p", { key: "A" }, "A"),
    h("p", { key: "B" }, "B"),
    h("p", { key: "C" }, "C"),
    h("p", { key: "D" }, "D"),
    h("p", { key: "E" }, "E"),
    h("p", { key: "Z" }, "Z"),
    h("p", { key: "F" }, "F"),
    h("p", { key: "G" }, "G"),
];

const nextChildren = [
    h("p", { key: "A" }, "A"),
    h("p", { key: "B" }, "B"),
    h("p", { key: "D" }, "D"),
    h("p", { key: "C" }, "C"),
    h("p", { key: "aky" }, "aky"),
    h("p", { key: "Y" }, "Y"),
    h("p", { key: "E" }, "E"),
    h("p", { key: "F" }, "F"),
    h("p", { key: "G" }, "G"),
];