Vue源码解析(3)-diff算法优化策略

1,163 阅读4分钟

一.实现流程

image.png

二.diff算法节点比较准则

  • diff算法是对新的虚拟dom与老的虚拟dom进行精细化比较最后将比较的结果重新渲染反映到真实的dom上

  • diff算法只对同一个虚拟dom才进行精细化比较,否则只会进行暴力插入和插入

  • diff算法只会进行同层比较不会进行跨层比较

三.diff算法的优化策略

四指针:

  • oldStartIdx(旧前指针)

  • newStartIdx(新前指针)

  • oldEndIdx(旧后指针)

  • newEndIdx(新后指针)

Map缓存

四.diff算法指针的命中查找规则

4.1 命中失败的出口:

oldStartIdx>oldEndIdx && newStartIdx>newEndIdx

4.2 命中查找规则

① 新前与旧前 ② 新后与旧后 ③ 新后与旧前 ④ 新前与旧后 按照顺序依次命中且只命中一个若四个都未命中则重新执行循环

4.3 命中细节

4.3.1 新前与旧前

旧前-> A        A <-新前
       B        B 
旧后-> C        M
                N 
                C <-新后

当新前与旧前命中之后,新前旧前指针都会继续向下移动

             A        A 
             B        B 
旧前-> 旧后-> C        M <-新前
                      N 
                      C <-新后

当新前与旧后指针没有命中的时候 则判断新后与旧后指针是否命中 若命中则两个指针向上移动

             A        A 
      旧后-> B        B 
     旧前->  C        M <-新前
                      N <-新后
                      C 

此时由于oldStartIdx>oldEndIdx所以循环结束,此时发现新前与新后两个指针所囊括的元素是我们需要新增的元素

新增准则:我们要在旧前指针(C)之前插入需要新增的元素(M,N)

4.3.2 新后与旧后

旧前-> A        C <-新前
       B        D <-新后
       C       
旧后-> D            
               

当旧前与新前指针未命中的时候 判断新后与旧后是否命中 命中则上移指针

旧前-> A        C <-新前 <-新后
       B        D 
旧后-> C       
      D            
               

当旧前与新前指针未命中的时候 判断新后与旧后是否命中 命中则上移指针

                  <-新后              
旧前-> A        C <-新前 
旧后-> B        D 
      C       
      D            
               

此时newStartIdx>newEndIdx循环结束,此时发现旧前与旧后指针囊括的元素就是我们需要删除的元素,于是我们遍历指针删除这两个元素

4.3.3 新后与旧前

旧前-> A        C <-新前 
       B        B 
旧后-> C        A <-新后
                              

当旧前与新前以及旧后与新后都不匹配的时候,我们尝试命中旧前与新后,命中后我们将旧前插入到旧后之后,并移动指针

             C <-新前 
旧前-> B     B <-新后
旧后-> C     A 
      A                                

当旧前与新前以及旧后与新后都不匹配的时候,我们尝试命中旧前与新后,命中后我们将旧前插入到旧后之后,并移动指针

                   C <-新前 <-新后
                   B 
旧前-> 旧后-> C     A 
             B
             A                                

旧前与新前命中移动指针两边都结束了循环

4.4.4 新前与旧后

旧前-> A        C <-新前 
       B        A 
旧后-> C        B <-新后                           

当前三种命中方式都没命中,但是命中了新前和旧后的第四种命中方式,我们将旧后节点插入到旧前之前,并移动相应的指针。

      C
旧前-> A        C 
旧后-> B        A <-新前 
               B <-新后                           

然后后续都是命中①结束,两边都结束循环。

4.5 四种方式都未命中

当四种方式都未命中的情况下需要进行一下操作,为了性能的优化我们利用Map缓存所有旧节点的索引,如果新节点是全新的节点则我们找不到其在旧节点中的对应的索引说明需要插入该新节点,如果新节点可以在旧节点中找到对应的索引则需要移动该节点,并且都需要将新前指针向后移动,结束循环并删除多余的元素

旧前-> A        D <-新前 新后
       B  
旧后-> C                            

我们可以看到这种情况四种都命中不了 我们需要将D插入在旧前的前面 并移动新前指针结束循环 并删除旧前指针到旧后指针囊括的ABC元素

旧前-> A        B <-新前 新后
       B  
旧后-> C                            

这种情况也是四种都不命中,我们需要将新节点中对应旧节点中的那个元素插入到旧前的前面 并移动指针 结束循环 并删除旧前指针与旧后指针囊括的元素ABC。

4.6 总结

上述的分析已经非常详尽,我们在实现diff算法的时候只需要按照上述的6种情况编写代码即可,从上述六种情况来看,我们插入元素的方式有三种,删除元素的方式有一种。

五.根据上述分析手写diff(可能与源码有些区别)

function checkSameVnode(a,b){
  return a.sel == b.sel && a.key == b.key  // 判断是否是同一个虚拟节点
}

export default function updateChildren(parentElm,oldCh,newCh){
  let oldStartIdx = 0 // 旧前
  let newStartIdx = 0 // 新前
  let oldEndIdx = oldCh.length-1 // 旧后
  let newEndIdx = newCh.length-1 // 新后
  let oldStartVnode = oldCh[0] // 旧前节点
  let oldEndVnode = oldCh[oldEndIdx] // 旧后节点
  let newStartVnode = newCh[0]  // 新前节点
  let newEndVnode = newCh[newEndIdx] // 新后节点
  let keyMap = null;

  while(oldStartIdx<=oldEndIdx && newStartIdx <= newEndIdx){
    if(oldStartVnode == null || oldStartVnode === undefined){
      oldStartVnode = oldCh[++oldStartIdx]
    }else if(oldEndVnode == null oldEndVnode === undefined){
      oldEndVnode = oldCh[--oldEndIdx]
    }else if(newStartVnode == null newStartVnode === undefined){
      newStartVnode = newCh[++newStartIdx]
    }else if(newEndVnode == null || newEndVnode === undefined){
      newEndVnode = newCh[--newEndIdx]
    }else if(checkSameVnode(oldStartVnode,newStartVnode)){
      // 新前和旧前 对比判断是否要更新
      patchVnode(oldStartVnode,newStartVnode)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    }else if(checkSameVnode(oldEndVnode,newEndVnode)){
      // 新后和旧后
      patchVnode(oldEndVnode,newEndVnode)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    }else if(checkSameVnode(oldStartVnode,newEndVnode)){
      // 新后与旧前
      // 此时需要移动节点 移动新前指向的节点到旧后的后
      patchVnode(oldStartVnode,newEndVnode)
      parentElm.insertBefore(oldStartVnode.elm,oldEndVnode.elm.nextSibling)
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    }else if(checkSameVnode(newStartVnode,oldEndVnode)){
      // 新前与旧后
      // 此时需要将新前节点移动到旧前的最前面
      patchVnode(oldEndVnode,newStartVnode)
      parentElm.insertBefore(oldEndVnode.elm,oldStartVnode.elm)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    }else{
      // 四种都没出现  用map缓存相当于循环了 移动节点
      // 缓存
      if(!keyMap){
        keyMap = {}
        for(let i=oldStartIdx;i<=oldEndIdx;i++){
          const key = oldCh[i].key;
          if(key!=undefined){
            keyMap[key] = i;
          }
        }
      }
      // 寻找未匹配到的项在旧节点中的位置
      const idxInOld = keyMap[newStartVnode.key]
      if(idxInOld == undefined){
        // 是全新的项 需要加入 并删除oldStartIdx到oldEndIdx之间的元素
        parentElm.insertBefore(createElement(newStartVnode),oldStartVnode.elm)
      }else{
        // 不是全新的项 需要移动并且将在oldCh中匹配到的元素设为undefined 移动到旧前之前 然后也是要删除oldStartIdx到oldEndIdx之间的元素
        const elmToMove = oldCh[idxInOld];
        patchVnode(elmToMove,newStartVnode);
        // 把这项设置未undefined
        oldCh[idxInOld] = undefined
        // 移动到旧前之前
        parentElm.insertBefore(elmToMove.elm,oldStartVnode.elm)
      }
      newStartVnode = newCh[++newStartIdx]
      
    }
  }
  // 循环结束后
  if(newStartIdx <= newEndIdx){
    // 要插入 
    // 插入的话是插入在未处理的节点之前
      const before = newCh[newEndIdx+1] == null ? null : newCh[newEndIdx+1].elm;
    for(let i=newStartIdx;i<=newEndIdx;i++){
      parentElm.insertBefore(createElement(newCh[i]),before)
    }
  }else if(oldStartIdx<=oldEndIdx){
    // 要删除
    for(let i = oldStartIdx;i<=oldEndIdx;i++){
      if(oldCh[i]){
        parentElm.removeChild(oldCh[i].elm)
      }
    }
  }
}