【手写 Vue2.x 源码】第三十一篇 - diff 算法 - 比对优化(下)

286 阅读8分钟

一,前言

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

  • 介绍了如何对儿子节点进行比对;
  • 新老儿子节点可能存在的 3 种情况及代码实现;
  • 新老节点都有儿子时,diff 的方案介绍与处理逻辑分析;

本篇,diff 算法-比对优化(下)


二,比对优化

1,前文回顾

上一篇提到,新老儿子节点比对可能存在的 3 种情况及对应的处理方法:

  • 情况 1:老的有儿子,新的没有儿子

    处理方法:直接将多余的老dom元素删除即可;

  • 情况 2:老的没有儿子,新的有儿子

    处理方法:直接将新的儿子节点放入对应的老节点中即可;

  • 情况 3:新老都有儿子

    处理方法:执行diff比对,即:乱序比对;

针对情况 3 新老儿子节点的比对,采用了“头尾双指针”的方法,如图所示:

image.png

优先对新老儿子节点的“头头、尾尾、头尾、尾头”节点进行比对,若均未能命中,最后再执行乱序比对;

2,节点比对的结束条件

结束条件:直至新老节点一方遍历完成,比对才结束;

即:"老的头指针和尾指针重合"或"新的头指针和尾指针重合";

image.png

此时,由于发生"老的头指针和尾指针重合",比对结束,图上状态便是循环中的最后一次比对;

新老节点比对完成后,可复用节点已识别完成,老节点中A、B、C、D节点被复用;将新增节点E添加到老的节点中即可;

将上述逻辑框架转化为代码实现,如下:

// src/vdom/patch.js

/**
 * 新老都有儿子时,执行乱序比对,即 diff 算法的核心逻辑
 * 备注:采用头尾双指针的方式;对头头、尾尾、头尾、尾头 4 种特殊情况做优化;
 *
 * @param {*} el 
 * @param {*} oldChildren  老的儿子节点
 * @param {*} newChildren  新的儿子节点
 */
function updateChildren(el, oldChildren, newChildren) {
    
    // 声明头尾指针(老)
    let oldStartIndex = 0;
    let oldStartVnode = oldChildren[0];
    let oldEndIndex = oldChildren.length - 1;
    let oldEndVnode = oldChildren[oldEndIndex];
    
    // 声明头尾指针(新)
    let newStartIndex = 0;
    let newStartVnode = newChildren[0];
    let newEndIndex = newChildren.length - 1;
    let newEndVnode = newChildren[newEndIndex];

    // while 循环的中止条件:新老其中一方遍历完成即为结束;
    // 即"老的头指针和尾指针重合"或"新的头指针和尾指针重合" 
    while(oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex){
        // 1,优先做4种特殊情况比对:头头、尾尾、头尾、尾头
        // 2,若未能命中,则采用 diff 乱序比对
        // 3,比对完成后移动指针,继续下一轮比对,直至比对完成
    }

    // 比对完成后,继续处理剩余节点...
}

备注:由于diff算法采用了while循环进行处理,所以复杂度为O(n);

3,情况 1:新儿子比老儿子多,插入新增的

情况 1:新儿子比老儿子多,又分为“从头部开始移动指针”和“从尾部部开始移动指针”两种情况;

从头部开始移动指针

头头比对:

第一次匹配,匹配后移动新老头指针:

image.png

第二次匹配,匹配后移动新老头指针:

image.png

通过多次比对后,直至老节点的头尾指针发生重合,此时,D节点就是while循环的最后一次比对:

image.png

本次比对完成之后,指针会继续向后移动一次,将导致老节点的头指针越过尾指针,此时,while循环结束;

while循环结束时的指针状态如下:

image.png

此时,新节点的头指针指向的节点E为新增节点,后面可能还存在F、G、H等其它新增节点,需要将它们(即从newStartIndexnewEndIndex之间的所有节点),全部添加到老节点的儿子集合中;

代码实现:

while(oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex){ 
    
    // 头头比对:
    if(isSameVnode(oldStartVnode, newStartVnode)){ 
    
        // ******* 1,比对新老虚拟节点 *******
        // isSameVnode 只能判断标签和 key 一样,但属性可能还有不同;
        // 因此,继续调用 patch 方法,对新老虚拟节点中的属性做递归更新操作;
        patch(oldStartVnode, newStartVnode); 
        
        // ******* 2,比对完成后,更新指针和节点位置 *******
        oldStartVnode = oldStartVnode[++oldStartIndex]; // 更新老的头指针和头节点
        newStartVnode = newStartVnode[++newStartIndex]; // 更新新的头指针和头节点
    } 
}

// 新的节点有多余时,追加到 dom 中
if(newStartIndex <= newEndIndex){ 
    // 遍历多余节点:新的开始指针和新的结束指针之间的节点
    for(let i = newStartIndex; i <= newEndIndex; i++){ 
       // 获取虚拟节点并生成真实节点,添加到 dom 中 
       el.appendChild(createElm(newChildren[i])) 
    } 
}

isSameVnode方法,只能判断标签和 key 是否完全一样,无法判断属性变化;

因此,需要继续通过 patch 方法,对新老虚拟节点中的属性进行递归更新操作;

测试效果:对复用节点做属性更新,并添加新节点

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="A" style="color:red">A</li>
    <li key="B" style="color:blue">B</li>
    <li key="C" style="color:yellow">C</li>
    <li key="D" style="color:pink">D</li>
    <li key="E">E</li>
    <li key="F">F</li>
</div>`);

更新前:

image.png

更新后:

image.png

执行结果:A、B、C、D节点被复用并更新了样式属性,且继续添加了新节点;

即:尽可能复用原有节点,仅更新需要更新的部分;

问题:

  • 将新儿子中的新增节点直接追加到老儿子集合中,使用appendChild即可;
  • 但是,如果新增的节点在头部位置,就不能用appendChild了,看下面的尾尾比对分析;

从尾部开始移动指针

尾尾比对:

image.png

尾指针向前移动,当老节点的头尾指针重合,即while循环的最后一次比对:

image.png

比对完成指针向前移动后,循环结束时的指针状态如下:

image.png

while比对完成后,需要将剩余新节点(EF)添加到老儿子中的对应位置(当前应添加到老儿子集合的头部)

image.png

问题:如何向头部位置新增节点

问题:如何将新增节点EF放置到A前面?

分析:

  • 首先,想要添加到A节点的前面,就不能再使用appendChild做向后追加操作了;
  • 前面的代码是指“从新的头指针到新的尾指针”这一区间的节点,即for (let i = newStartIndex; i <= newEndIndex; i++) 所以,从处理顺序上,是先处理E节点,再处理F节点

先处理E节点:将E节点放置到A节点前的位置:

image.png

再处理F节点:将F节点插入到A节点与E节点之间的位置:

image.png

这样,当新增区域的头尾指针重合,即为最后一次比对;

方案设计:两种比对方式的合并处理

新增的节点的两种情况:有可能被追加到后面,也有可能被插入到前面:

  • 头头比较时,将新增节点追加到老儿子集合的尾部;
  • 尾尾比较时,将新增加点添加到老儿子集合的头部;

综合以上两种情况,如何确定向前 or 向后添加节点呢?

image.png

这取决于while循环结束时,新儿子集合的尾指针newChildren[newEndIndex + 1]上是否存在节点:

  • 如果无节点:说明是从头向尾进行比对的,新增节点需要被追加到老儿子集合后面,使用appendChild直接追加即可;
  • 如果有节点:说明是从尾向头进行比对的,新增节点需要被添加到老儿子集合前面,使用insertBefore插入指定到位置;

以上分析对应的代码实现,如下:

// 1,新的多(以新指针为参照),插入新增节点
if (newStartIndex <= newEndIndex) {

  // ****** 先获取参照物 ******
  // 判断当前尾节点的下一个元素是否存在:
  //  1,如果存在:说明是尾尾比对,插入到当前尾节点下一个元素前面;
  //  2,如果不存在(下一个是 null):说明是头头比对,追加即可
    
  // 取参考节点 anchor:决定新节点放置到前边还是后边
  //  逻辑:取 newChildren 的尾部 +1,判断是否为 null
  //  解释:若有值则说明是向前移动,取出当前虚拟元素的真实节点 el,并将新节点添加到此真实节点之前
  // (由于是向前移动比对,故此虚拟元素在前一次比对中,已经复用了老节点 el,所以直接取新的虚拟节点上的 el 即可)
  let anchor = newChildren[newEndIndex + 1] == null ? null : newChildren[newEndIndex + 1].el
  
  // ****** 再根据参照物进行处理 ******
  // 遍历多出的节点:新的开始指针和新的结束指针之间的节点
  for (let i = newStartIndex; i <= newEndIndex; i++) {
      
    // 获取对应的虚拟节点,并生成真实节点,添加到 dom 中
    // el.appendChild(createElm(newChildren[i]))
    
    // 逻辑合并:将 appendChild 更换为 insertBefore 处理
    //  效果:既有 appendChild 又有 insertBefore 功能,直接放入参考节点即可;
    //  解释:对于 insertBefore 方法,如果 anchor=null,就等同于appendChild;如果有值,则是 insertBefore;
    el.insertBefore(createElm(newChildren[i]), anchor)
  }
}

这里非常重要的一个思想,就是找到参考节点anchor;之后,再将新的真实节点放置于参考节点之前即可;

注意此处insertBefore方法的妙用:

  • 当第二个参数为null时,效果等同于appendChild追加;
  • 当第二个参数不为null时,效果是insertBefore插入;

所以,同时具备了appendChild追加和insertBefore插入的效果;

以上,对新节点比老节点多的两种情况,分别进行了处理;

除此之外,还可能存在老节点比新节点多的情况,那么,该如何处理呢?


4,情况 2:老儿子比新儿子多,删除多余

代码示例: 老儿子比新儿子多

let render1 = compileToFunction(`<div>
    <li key="A" style="color:red">A</li>
    <li key="B" style="color:blue">B</li>
    <li key="C" style="color:yellow">C</li>
    <li key="D" style="color:pink">D</li>
</div>`);

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

如图:

image.png

老的比新的多,在移动过程中就会出现:新的已经到头了,但老的还有;

所以,当移动结束时:老的头指针会和尾指针重合,新的头指针会越过新的尾指针;

image.png

代码实现:

将老儿子集合“从头指针到尾指针”区域中,多余的真实节点删除:

// 2,老儿子比新儿子多,(以旧指针为参照)删除多余的真实节点
if(oldStartIndex <= oldEndIndex){
  for(let i = oldStartIndex; i <= oldEndIndex; i++){
    let child = oldChildren[i];
    el.removeChild(child.el);
  }
}

5,情况 3:反序情况(头尾、尾头)

反序情况:如图,新老儿子集合中的节点顺序是完全相反的;

image.png

这种情况下,可以使用“老的头指针”和“新的尾指针”进行比较,即“头尾比对”

image.png

每次比较完成后,“老的头指针”向后移动,“新的尾指针”向前移动;并在比对完成后,直接将老节点A放置到老节点集合的最后:

image.png

更确切的说,应该是插入到尾指针的下一个节点(null)之前;

(在移动前,想象尾指针指向的D节点后面,还存在着下一个节点为null

说明:

js本身是无法做到“向一个元素之后添加一个元素”的;比如:appendChild 是向最后进行追加;

因此,在逻辑上,只能是先找到目标元素的下一个元素,再向下一个元素之前添加一个新的元素;


继续比对B,比对完成后继续移动指针,并移动B,插入到尾指针的下一个节点之前(这时尾指针D的下一个节点,边是上一次移动过来的A,所以B就插入到了DA之间)

image.png

继续CC比,比对完成后继续移动指针,并移动C,插入到尾指针的下一个节点之前(这时,尾指针D的下一个是上一次移动过来的B

image.png

接下来继续比对D,此时,就会发现“旧的头指针”和“新的头指针”都指向了D;

这时,就比对完成了,D无需再移动,结果就是D C B A

(整个反序过程,共移动了3 次,只对节点进行了移动操作,并没有创建新节点)

结论:对于反序操作来说,需要去比对头尾指针(老的头和新的尾),每次比对完成之后,头指针向右移动,尾指针向左移动;

代码实现,添加“头尾比较”逻辑:

while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {

  if (isSameVnode(oldStartVnode, newStartVnode)) {
    patch(oldStartVnode, newStartVnode);
    oldStartVnode = oldChildren[++oldStartIndex];
    newStartVnode = newChildren[++newStartIndex];
  }else if(isSameVnode(oldEndVnode, newEndVnode)){
    patch(oldEndVnode, newEndVnode);
    oldEndVnode = oldChildren[--oldEndIndex];
    newEndVnode = newChildren[--newEndIndex];
    
  // 头尾比较:老的头节点和新的尾节点做对比
  }else if(isSameVnode(oldStartVnode, newEndVnode)){
    // patch 方法只会 diff 比较并更新属性,但元素的位置不会变化
    patch(oldStartVnode, newEndVnode); // diff:会递归比对儿子
    
    // 移动节点:将当前的节点插入到最后一个节点的下一个节点的前面去
    el.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling);
    
    // 指针置位--下一次循环继续处理
    oldStartVnode = oldChildren[++oldStartIndex];
    newEndVnode = newChildren[--newEndIndex];
  }
}

注意:

由于dom具有移动性,appendChild、insertBefore操作都会使dom产生移动效果;

在做指针置位前,必须先完成节点的插入操作,之后才能移动指针,否则原来的dom就会被移走;

测试效果:

更新前:老节点ABCD均没有样式属性;

image.png

更新后:老节点ABCD被复用,并添加了对应的样式属性;

image.png

同理,尾头比对的代码实现:

while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    if (isSameVnode(oldStartVnode, newStartVnode)) {
      patch(oldStartVnode, newStartVnode);
      oldStartVnode = oldChildren[++oldStartIndex];
      newStartVnode = newChildren[++newStartIndex];
    }else if(isSameVnode(oldEndVnode, newEndVnode)){
      patch(oldEndVnode, newEndVnode);
      oldEndVnode = oldChildren[--oldEndIndex];
      newEndVnode = newChildren[--newEndIndex];
    }else if(isSameVnode(oldStartVnode, newEndVnode)){
      patch(oldStartVnode, newEndVnode);
      el.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling);
      oldStartVnode = oldChildren[++oldStartIndex];
      newEndVnode = newChildren[--newEndIndex];
      
    // 尾头比较
    }else if(isSameVnode(oldEndVnode, newStartVnode)){
      patch(oldEndVnode, newStartVnode);  // patch方法只会更新属性,元素的位置不会变化
      // 移动节点:将老的尾节点移动到老的头节点前面去
      el.insertBefore(oldEndVnode.el, oldStartVnode.el);// 将尾部插入到头部
      // 移动指针
      oldEndVnode = oldChildren[--oldEndIndex];
      newStartVnode = newChildren[++newStartIndex];
    }
}

测试效果:

let render1 = compileToFunction(`<div>
    <li key="E">E</li>
    <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="D" style="color:pink">D</li>
    <li key="C" style="color:yellow">C</li>
    <li key="B" style="color:blue">B</li>
    <li key="A" style="color:red">A</li>
</div>`);	

更新前:老节点ABCD均没有样式属性;

image.png

更新后:老节点ABCD被复用,并添加了对应的样式属性,老节点E被删除

image.png


三,结尾

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

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

下篇,diff算法 - 乱序比对;


维护日志

  • 20210805:
    • 添加了“从尾部开始移动指针”的图示
    • 添加了“问题:如何向头部位置新增节点”
    • 修改了部分有问题的图示
    • 修改了几处语义表达不够准确的地方
  • 20230219:
    • 更新了文章目录结构;
    • 添加了大量内容说明和代码注释,使比对过程清晰易懂;
    • 添加了文章内容中的代码和关键字高亮;
    • 更新了文章摘要;
  • 20230221:
    • 调整部分描述,使表达更加准确易懂;