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

198 阅读6分钟

一,前言

上篇,介绍了diff算法-节点比对,主要涉及以下几点:

  • 介绍了 diff 算法、对比方式、节点复用;
  • 实现了外层节点的 diff 算法;
  • 不同节点如何做替换更新;
  • 相同节点如何做复用更新:文本、元素、样式处理;

本篇,diff 算法 - 比对优化;


二,比对儿子节点

1,前文回顾

上篇,通过构建两个虚拟节点来模拟v-if的效果,通过patch方法比对实现了外层节点的复用;

let vm1 = new Vue({
    data() {
        return { name: 'Brave' }
    }
})
let render1 = compileToFunction('<div style="color:blue">{{name}}</div>');
let oldVnode = render1.call(vm1)
let el1 = createElm(oldVnode);
document.body.appendChild(el1);

let vm2 = new Vue({
    data() {
        return { name: 'BraveWang' }
    }
})
let render2 = compileToFunction('<div style="color:red">{{name}}</div>');
let newVnode = render2.call(vm2);
setTimeout(() => {
    patch(oldVnode, newVnode); 
}, 1000);

执行结果:

初始化时,为蓝色文本

image.png

更新后,变为红色文本

image.png

遗留问题:当前,仅更新了外层divstyle,但内部的属性name并没有被更新;

问题原因:这是由于只做了第一层节点的比对和属性更新,没有进行深层的diff比对;

接下来,开始进行深层次比对;

2,如何比对儿子节点

比对儿子节点,需要将“新的儿子节点”和“老的儿子节点”都拿出来,依次进行比对:

//src/vdom/patch.js

/**
 * 将虚拟节点转为真实节点后插入到元素中
 * @param {*} el    当前真实元素 id#app
 * @param {*} vnode 虚拟节点
 * @returns         新的真实元素
 */
export function patch(oldVnode, vnode) {

  const isRealElement = oldVnode.nodeType;
  if(isRealElement){
  
    // 1,根据虚拟节点创建真实节点
    const elm = createElm(vnode);
    
    // 2,使用真实节点替换掉老节点
    const parentNode = oldVnode.parentNode;
    parentNode.insertBefore(elm, oldVnode.nextSibling); 
    parentNode.removeChild(oldVnode);
    
    return elm;
  }else{// diff:新老虚拟节点比对
  
    // 节点不能复用的情况
    if(!isSameVnode(oldVnode, vnode)){
      return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el);
    }
    
    // 处理文本
    let el = vnode.el = oldVnode.el;
    if(!oldVnode.tag){
      if(oldVnode.text !== vnode.text){
        return el.textContent = vnode.text;
      }else{
        return; 
      }
    }
    
    // 处理元素属性
    updateProperties(vnode, oldVnode.data);

    // TODO:比较儿子节点...
    let oldChildren = oldVnode.children || {};
    let newChildren = vnode.children || {};
  }
}

3,新老儿子节点的几种情况

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

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

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

// src/vdom/patch.js#patch

...
// 比较儿子节点
let oldChildren = oldVnode.children || {};
let newChildren = vnode.children || {};
    
// 情况 1:老的有儿子,新的没有儿子;直接将多余的老 dom 元素删除即可;
if(oldChildren.length > 0 && newChildren.length == 0){
  // 更好的处理:由于子节点中可能包含组件,需要封装 removeChildNodes 方法,将子节点全部删掉
  el.innerHTML = '';// 暴力写法,直接清空;
}

备注:这里直接清空innerHTML是暴力写法;由于子节点中可能包含组件,所以更好的处理方式是封装一个removeChildNodes方法,用于删掉全部子节点;

功能测试:老的有儿子,新的没有儿子

let vm1 = new Vue({
    data() {
        return { name: 'Brave' }
    }
})
let render1 = compileToFunction('<div style="color:blue">{{name}}</div>');
let oldVnode = render1.call(vm1)
let el1 = createElm(oldVnode);
document.body.appendChild(el1);

let vm2 = new Vue({
    data() {
        return { name: 'BraveWang' }
    }
})
let render2 = compileToFunction('<div style="color:red"></div>');
let newVnode = render2.call(vm2);

setTimeout(() => {
    patch(oldVnode, newVnode); 
}, 1000);

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

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

//src/vdom/patch.js#patch

...
// 比较儿子节点
let oldChildren = oldVnode.children || {};
let newChildren = vnode.children || {};

// 情况 1:老的有儿子,新的没有儿子;直接将多余的老 dom 元素删除即可;
if(oldChildren.length > 0 && newChildren.length == 0){
  el.innerHTML = '';
  
// 情况 2:老的没有儿子,新的有儿子;直接将新的儿子节点放入对应的老节点中即可
}else if(oldChildren.length == 0 && newChildren.length > 0){
  newChildren.forEach((child)=>{// 注意:这里的 child 是虚拟节点,需要变为真实节点
    let childElm = createElm(child); // 根据新的虚拟节点,创建一个真实节点
    el.appendChild(childElm);// 将生成的真实节点,放入 dom
  })
}

备注:newChildren中的child为虚拟节点,需要先通过createElm(child)创建为真实节点,再放入dom;

功能测试:老的没有儿子,新的有儿子

let vm1 = new Vue({
    data() {
        return { name: 'Brave' }
    }
})
let render1 = compileToFunction('<div style="color:blue"></div>');
let oldVnode = render1.call(vm1)
let el1 = createElm(oldVnode);
document.body.appendChild(el1);

let vm2 = new Vue({
    data() {
        return { name: 'BraveWang' }
    }
})
let render2 = compileToFunction('<div style="color:red">{{name}}</div>');
let newVnode = render2.call(vm2);

setTimeout(() => {
    patch(oldVnode, newVnode); 
}, 1000);

情况 3:新老都有儿子

处理方法:新老节点执行diff比对;

// src/vdom/patch.js#patch

...
// 比较儿子节点
let oldChildren = oldVnode.children || {};
let newChildren = vnode.children || {};

// 情况 1:老的有儿子,新的没有儿子;直接将对于的老 dom 元素干掉即可;
if(oldChildren.length > 0 && newChildren.length == 0){
  el.innerHTML = '';
  
// 情况 2:老的没有儿子,新的有儿子;直接将新的儿子节点放入对应的老节点中即可
}else if(oldChildren.length == 0 && newChildren.length > 0){
  newChildren.forEach((child)=>{
    let childElm = createElm(child);
    el.appendChild(childElm);
  })
  
// 情况 3:新老都有儿子
}else{
  // diff 比对的核心逻辑
  updateChildren(el, oldChildren, newChildren); 
}

在真正执行diff比对前,针对于【情况 1】“老的有儿子,新的没有儿子”和【情况 2】“老的没有儿子,新的有儿子”这两种特殊情况,优先进行了特殊处理;

当以上两种情况均不满足,即【情况 3】新老节点都有儿子时,就必须进行diff比对了;

所以,updateChildren方法才是diff算法的核心;


三,新老儿子 diff 比对的核心逻辑:updateChildren 方法

1,新老儿子 diff 比对方案介绍

继续,当新老节点都有儿子时,就需要对新老儿子节点进行比对了;

新老节点的比对方案是:采用“头尾双指针”的方式,对新老虚拟节点依次进行比对;

每次节点比对完成,如果是头节点就向后移动指针,尾节点就向前移动指针;

image.png

直至一方遍历完成,比对才结束;

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

image.png

这里,为了能够提升diff算法的性能,并不会直接全部采用最消耗性能的“乱序比对”;

而是结合了实际应用场景,优先对 4 种特殊情况进行了特殊的处理:头头、尾尾、头尾、尾头:

  • 头和头比较,将头指针向后移动;
  • 尾和尾比较,将尾指针向前移动;
  • 头和尾比较,将头指针向后移动,尾指针向前移动;
  • 尾和头比较,将尾指针向前移动,头指针向后移动;

每次比对时,优先进行头头、尾尾、头尾、尾头的比对尝试,若均未能命中,才会执行最暴力的乱序比对;

头头、尾尾、头尾、尾头是“小技能”,能够快速解决掉 4 种特殊且高频的问题,因此尽量使用小技能解决战斗;

乱序比对是“终极大招”,能够解决一切问题,但也是最耗费性能的,因此能不用尽量不用;

2,diff 比对的几种特殊情况(头头、尾尾、头尾、尾头)

备注:由于 4 种特殊情况需要画图说明,单独一篇:《第三十一篇 - diff 算法-比对优化(下)》

除了以上 4 种特殊情况外,其他情况就只能执行乱序比对了;

虽然是做乱序比对,但目标依然是最大程度的实现节点复用,以提升渲染性能;

备注:乱序比对如何进行节点复用,单独一篇:《第三十二篇 - diff 算法-乱序比对》


四,结尾

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

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

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


维护记录

  • 20230218:优化了内容排版,调整了部分内容描述,添加了内容中的关键字与代码高亮,更新了文章摘要;