【手写 Vue2.x 源码】第三十二篇 - diff 算法 - 乱序比对

551 阅读6分钟

一,前言

上篇,diff 算法 - 比对优化(下),主要涉及以下几个点:

  • 介绍了儿子节点比较的流程
  • 介绍并实现了头头、尾尾、头尾、尾头4种特殊情况比对

本篇,继续介绍 diff 算法 - 乱序比对


二,乱序比对

1,前文回顾

之前两篇主要介绍了,在进行乱序比对前针对几种特殊情况的处理,以提升比对性能:

  • 一方有儿子,一方没有儿子;
    • 老的有儿子,新的没有儿子:直接将多余的老 dom 元素删除即可;
    • 老的没有儿子,新的有儿子:直接将新的儿子节点放入对应的老节点中即可;
  • 新老节点都有儿子时,进行头头、尾尾、头尾、尾头对比;
  • 头头、尾尾、头尾、尾头均没有命中时,进行乱序比对;

本篇,主要介绍diff算法的乱序比对,目标是尽可能复用老节点,以提升渲染性能;

2,乱序比对方案

下面这种情况,头头、尾尾、头尾、尾头都不相同:

image.png

在理想情况下,AB 节点是可以被复用的:

image.png

方案设计

以新节点为主,以老节点做参照,先到老儿子集合中去找能复用的节点,再将不能复用老节点删掉;

创建映射关系

根据老儿子集合,创建一个节点key和索引index的映射关系mapping,用于判定节点是否可被复用:

取新节点,依次到老的索引列表mapping中查找是否存在,如果存在就复用,不存在则重新创建;

image.png

3,乱序比对过程分析

1,先比对一下头头、尾尾、头尾、尾头,都没有命中:

image.png

查找F是否在映射关系中,不在,直接做插入操作:插入到老的头指针前面的位置

即:将F节点插入到A节点的前面,并将新的头指针向后移动:

image.png

2,再对比一下头头、尾尾、头尾、尾头,还是没有命中:

image.png

继续查找B是否在映射关系中,B在映射关系中,复用B节点并做移动操作:将复用节点移动到头指针指向节点的前面;

即:将老的B节点移动到A节点的前面,并将新的头指针向后移动:

image.png

备注:由于原来的B节点被移动走了,所以之前的空位置要做标记,后续指针移动至此直接跳过;

3,继续比对一次头头、尾尾、头尾、尾头,命中了头头比对:

image.png

这时,按照头头比对的逻辑:老的头指针向后移动,新的头指针也向后移动;(同理,如果命中尾尾比对,将新老尾指针都向前进行移动)

由于之前B节点已经移动到A节点前面了,所以老的头指针需要跳过原始B节点的位置,直接移动到C节点所在的位置:

image.png

备注:这里使用到了之前B节点移动走之后所做的空位置标记;

4,继续比对一次头头、尾尾、头尾、尾头,没有命中:

image.png

查找E是否在映射关系中,不在,直接做插入操作:插入到老的头指针前面的位置

即:将E节点插入到C节点的前面,并将新的头指针向后移动:

备注:永远是插入到老的头指针前面的位置;

image.png

5,继续比对一次头头、尾尾、头尾、尾头,没有命中:

image.png

查找G是否在映射关系中,不在,直接做插入操作:插入到老的头指针前面的位置;

即:将G节点插入到C节点的前面,并将新的头指针向后移动:

image.png

6,由于新儿子数组已全部比对完成,剩余的老节点直接删除即可

依次删除“从老的头节点到老的尾节点”区域的全部节点:

image.png

所以,最终结果为F B A E G;其中,对A、B节点实现了节点复用;


三,代码实现

1,新老节点更新示例

let render1 = compileToFunction(`<div>
    <li key="A">A</li>
    <li key="B">B</li>
    <li key="C">C</li>
    <li key="D">D</li>
</div>`);

let render2 = compileToFunction(`<div>
    <li key="F" style="color:pink">F</li>
    <li key="B" style="color:yellow">B</li>
    <li key="A" style="color:blue">A</li>
    <li key="E" style="color:red">E</li>
    <li key="P" style="color:red">P</li>
</div>`);

2,创造映射关系

根据老儿子集合创建节点key与索引index的映射关系mapping

// src/vdom/patch.js#updateChildren#makeKeyByIndex

function updateChildren(el, oldChildren, newChildren) {
  // ...
  
  /**
   * 根据children创建映射
   */
  function makeKeyByIndex(children) {
     let map = {}
     children.forEach((item, index)=>{
       map[item.key] = index;
     })
     console.log(map)
     debugger;
     return map
  }

  let mapping = makeKeyByIndex(oldChildren);
  // ...
}

查看控制台输出mapping信息:

image.png


3,处理步骤

节点筛查:到mapping映射中,查找新节点是否存在:

  • 不存在,将当前参加比对的新节点,插入到老的头指针对应的节点前面;
  • 存在,复用老节点,将当前参加比对的老节点移动到老的头指针前面;

节点复用的实现步骤:

  1. 插入dom
  2. 执行patch方法更新属性
  3. 原位置置空
  4. 指针移动
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {

  // 循环开始,优先处理 oldStartVnode 和 oldEndVnode 为空的情况
  // 原因:之前节点被移走时,原始位置被置空为undefined,处理时需要跳过
  if(!oldStartVnode){
    oldStartVnode = oldChildren[++oldStartIndex];
  }else if(oldEndVnode){
    oldEndVnode = oldChildren[--oldEndIndex];
    
  // 前面 4 种逻辑(头头、尾尾、头尾、尾头),主要考虑实际使用中的特殊场景
  } else if (isSameVnode(oldStartVnode, newStartVnode)) {// 头头比较
    // ...
  }else if(isSameVnode(oldEndVnode, newEndVnode)){// 尾尾比较
    // ...
  }else if(isSameVnode(oldStartVnode, newEndVnode)){// 头尾比较
    // ...
  }else if(isSameVnode(oldEndVnode, newStartVnode)){// 尾头比较
    // ...
  }else{// 不满足特殊情况,执行乱序比对
  
    // 筛查当前新的头指针对应的节点在mapping中是否存在
    let moveIndex = mapping[newStartVnode.key]
    
    // 1)不存在:将当前比对的新节点插入到老的头指针对用的节点前面
    if(moveIndex == undefined){
      // 将当前新的虚拟节点创建为真实节点,插入到老的开始节点前面
      el.insertBefore(createElm(newStartVnode), oldStartVnod e.el);
      
    // 2)存在:复用老节点
    }else{  
      // 移动节点:将当前比对的老节点移动到老的头指针前面
      let moveVnode = oldChildren[moveIndex];
      el.insertBefore(moveVnode.el, oldStartVnode.el);
      
      // 更新属性:节点移动完成之后,对比并更新属性
      patch(moveVnode, oldStartVnode)
      
      // 标记:由于复用节点会在 oldChildren 中被移走,移走后之前位置需标记为空(后续指针移动时,会跳过空标记位置)
      oldChildren[moveIndex] = undefined;
    }
    
    // 指针置位:每次处理完成后,新节点的头指针都要向后移动一位
    newStartVnode = newChildren[++newStartIndex];
  }
}

备注:

  • 新指针置位:无论节点是否可复用,新指针都会向后移动,所以在最后统一进行处理;

  • 老指针置位:当存在节点复用时,老节点的指针移动操作,将会在 4 种特殊情况的处理中完成置位;

4,删除多余的老节点

注意:当新老节点进行对比时,可能部分节点后已经被移动走了(复用),节点移走后的原始位置被设置为 undefined;

所以,在删除多余节点时,在新老指针的区间中可能存在着undefined节点,这些节点已经被复用了,需要跳过对这些节点的删除:

// 2,旧的多,(以旧指针为参照)删除多余的真实节点
if(oldStartIndex <= oldEndIndex){
  for(let i = oldStartIndex; i <= oldEndIndex; i++){
    let child = oldChildren[i];
    // child有值时才删除;原因:节点有可能在移走时被置为undefined
    child && el.removeChild(child.el);
  }
}

5,测试乱序比对更新

更新前:

image.png

更新后:

image.png

根据控制台输出,节点更新情况如下:

  • A节点被复用,只更新了样式属性;
  • FEG 节点为新增节点;
  • B节点被复用,仅做了移动操作;

这样,就尽可能的复用了老节点;


四,结尾

本篇,diff 算法-乱序比对,主要涉及以下几个点:

  • 介绍了乱序比对的方案;
  • 介绍了乱序比对的过程分析;
  • 实现了乱序比对的代码逻辑;

下篇,diff 算法的阶段性梳理;


维护日志:

  • 20210811:
    • 二级标题与排版微调;
    • 修改存在瑕疵的图示和部分表达不够明确的语句;
  • 20230221:
    • 添加了内容中的代码和关键字高亮;
    • 调整了代码注释和换行,便于代码逻辑理解;
    • 优化了大量内容,使逻辑表述更加准确易懂;
    • 更新了文章摘要;