【手写 Vue2.x 源码】第二十九篇 - diff 算法 - 节点比对

286 阅读11分钟

一,前言

上篇,diff 算法问题分析与 patch 方法改造,主要涉及以下几点:

  • 初始化与更新流程分析;
  • 问题分析与优化思路;
  • 新老虚拟节点比对模拟;
  • patch 方法改造;

下篇,diff 算法-节点比对


二,diff 算法

上一篇,已经完成了patch方法的改造;

接下来,开始编写“视图更新时,新老虚拟节点比对”的diff算法;

在开始之前,先简单介绍一下diff算法:

1,diff 算法简介

diff算法也叫做“同层比较“算法;

首先,dom是一个树型结构,参考下图:

image.png

在日常开发中,很少会将BA或是DA的位置进行调换,即:很少发生将父亲节点和儿子节点进行交换的场景

而且,进行跨层的节点对比会非常麻烦;所以,diff算法权衡考虑了实际应用场景与性能瓶颈,仅对同层节点进行比对;

2,diff 算法的比较方式

diff算法,将新、老虚拟节点这"两棵树"进行比对;

从树的根节点,即 LV1 层开始比较:

image.png

A比较完成后,查看A节点是否有儿子节点,即BC,优先比较B

image.png

B比较完成后,查看B节点是否有儿子节点,即DE,优先比较D,

D比较完成后,D 不再有儿子节点;继续比较E,当前层处理完成,返回上层继续处理;

继续比较CC有儿子F,继续比较F,最后全部比较完成,结束;

所以,从比对方式来看,diff比对是“深度优先遍历”的递归比对;

Vue2Vue3在节点更新时的性能对比:

  • 递归比对是vue2的性能瓶颈,当组件树庞大时会产生性能问题;
  • vue3中,会收集动态节点,并对他们的变化进行标记,根据标记进行更新,而无需使用diff递归比对一遍
  • Vue3是线性比对,而Vue2是两棵树的比对,效率上会比vue2高出很多;

3,diff 算法的节点复用

问题1:如何确定两个节点可以复用?

  • 一般来说,相同标签的元素即可进行复用;

问题2:标签相同就必须要复用吗?

  • 当然,在实际应用场景中,也存在即使标签相同,也不希望被复用的情况,这时,可以使用key属性对节点进行标记;
  • 如果 key 值不相同,即便标签名相同的两个元素,也不会进行复用;

场景举例:两个input切换时(v-if),由于节点复用导致切换后仍显示前还前input的值,可以对两个input设置不同key值解决此问题;

判断节点是否复用的标准

所以,在编写代码时,相同节点的复用标准如下:

  1. 标签名和key值均相同,即可判断为相同节点;
  2. 若标签名和key不完全相同,则不是相同节点;

判断节点是否复用 isSameVnode 方法

比对新老虚拟节点是否能够复用,所以,此方法应从属于vdom模块;

vdom模块,创建isSameVnode方法,用于判断是否为相同节点:

// src/vdom/index.js

/**
 * 判断两个虚拟节点是否是同一个虚拟节点
 * @param {*} newVnode 新虚拟节点
 * @param {*} oldVnode 老虚拟节点
 * @returns 
 */
export function isSameVnode(newVnode, oldVnode){
  // 判断逻辑:tag 标签名 和 key 完全相同
  return (newVnode.tag === oldVnode.tag)&&(newVnode.key === oldVnode.key); 
}

当新老虚拟节点的标签和key值均相同时(即isSameVnode方法返回true),复用老节点,仅更新其中的属性即可;


三,虚拟节点比对

1,新老节点相同的情况

模拟不同节点的更新

创建两个虚拟节点,模拟视图的更新:

// 1,模拟初渲染-oldVnode
let vm1 = new Vue({
    data() {
        return { name: 'Brave' }
    }
})
let render1 = compileToFunction('<div>{{name}}</div>');
let oldVnode = render1.call(vm1)
let el1 = createElm(oldVnode);
document.body.appendChild(el1);

// 2,模拟新的虚拟节点-newVnode
let vm2 = new Vue({
    data() {
        return { name: 'BraveWang' }
    }
})
let render2 = compileToFunction('<p>{{name}}</p>');
let newVnode = render2.call(vm2);

// diff:新老虚拟节点对比
setTimeout(() => {
    patch(oldVnode, newVnode); 
}, 1000);

由于新老虚拟节点的标签名tag不同(相当于模拟了v-ifv-else的情况),

所以不是相同节点,不考虑阶段复用,直接使用新的真实节点替换掉旧的真实节点;

这里的节点复用,指的是同层节点的复用,不考虑跨层节点复用的情况;

即只比较同层节点,如果节点不可复用,儿子就不用再比了,全部放弃复用,重新创建节点;

由于diff 算法是同层比对,算法的复杂度是On;

patch方法中,打印新老虚拟节点:

image.png

image.png

节点替换的逻辑分析

由于父节点的标签名不同,导致了节点不可被复用,
此时,即便子节点中存在可复用节点,也不再进行不低;
直接会根据新的虚拟节点生成新的真实节点,并替换掉老的真实节点;

1,使用新的虚拟节点创建出新的真实节点:

createElm(vnode);

2,要替换掉老节点,先要获取到老的真实节点:

之前,根据vnode虚拟节点生成真实节点时,通过vnode.el将真实节点与虚拟节点进行了映射;

所以,此时就能够轻松地通过oldVnode.el获取到老的真实节点了;

备注:这里获取真实节点不能使用$el$el是指整棵树,所以在此处不可用;

3,综合以上分析的结论:

  • 新的真实节点:createElm(vnode);
  • 老的真实节点:oldVnode.el

节点替换的代码实现

// src/vdom/patch.js

export function patch(oldVnode, vnode) {

  const isRealElement = oldVnode.nodeType;
  
  // oldVnode 为真实节点,初渲染流程
  if(isRealElement){
    const elm = createElm(vnode);
    const parentNode = oldVnode.parentNode;
    parentNode.insertBefore(elm, oldVnode.nextSibling); 
    parentNode.removeChild(oldVnode);
    return elm;
    
  // oldVnode 为虚拟节点,更新流程,执行新老虚拟节点比对
  } else {           
    console.log(oldVnode, vnode)
    if(!isSameVnode(oldVnode, vnode)){
      // 不是相同节点,不考虑复用直接替换
      // 使用新的虚拟节点生成新的真实节点并替换掉老的真实节点
      return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el);
    }
  }
}

备注:

  • 当前Demo相当于对整棵树进行了更新;
  • 当树中的节点包含子组件时,由于每个组件都拥有独立的渲染watcher,会通过diff进行局部更新,因此并不会对整个树进行更新;
  • 所以,只要组件拆分合理,一般不会出现性能问题;

1,新老节点不同的情况

如果两个元素的tag标签名和key都相同,即isSameVnode方法返回true,则判定为相同节点;对节点进行复用,只更新其中不同的地方即可;

这里“不同的地方”:指文本、

2-1 文本的处理

文本节点的特点:

  • 文本节点,没有标签名;
  • 文本节点,没有儿子;

因此,对于文本的处理,由于文本节点没有儿子,所以直接更新即可;

文本的复用逻辑:

  • 1,复用老文本:vnode.el = oldVnode.el将老节点el赋值给新节点el
  • 2,更新文本内容:el.textContent = vnode.text;

在组件Vue.component('xxx')中,组件的tag标签名就是xxx

// 节点复用:将老节点 el 赋值给新节点 el
let el = vnode.el = oldVnode.el;  

// 老节点没有标签名,说明是文本(之前已通过`isSameVnode`方法判定新老节点为相同节点)
if(!oldVnode.tag){

  // 更新文本内容
  if(oldVnode.text !== vnode.text){
    return el.textContent = vnode.text;
  } else{
    return; 
  }
}

处理完tag不存在时的文本节点后,继续处理tag存在的元素节点

2-2 元素的处理

相同节点且新老节点不都是文本时,会对元素进行处理,

更新元素的属性,需要对updateProperties方法进行功能改造:

重构前的updateProperties方法:

  • 比较暴力,直接使用data属性值替换掉真实元素vnode.el中对应的属性值;
  • 仅具有渲染功能,不具有更新功能;

重构前的渲染功能:

// src/vdom/patch.js

export function createElm(vnode) {
  let{tag, data, children, text, vm} = vnode;
  if(typeof tag === 'string'){
    vnode.el = document.createElement(tag)
    
    // 初渲染,使用 data 赋值
    updateProperties(vnode.el, data)
    children.forEach(child => {
      vnode.el.appendChild(createElm(child))
    });
  } else {
    vnode.el = document.createTextNode(text)
  }
  return vnode.el;
}

// 直接使用 data 属性值替换掉真实元素 vnode.el 中对应的属性值;
function updateProperties(el, props = {} ) { 
  for(let key in props){
    el.setAttribute(key, props[key])
  }
}

updateProperties方法的重构思路:

重构后的updateProperties方法,需要同时具备“渲染”功能和“更新”功能:

  • 初次渲染:使用oldPropsvnodeel赋值即可;
  • 更新渲染:拿到老的props和新的vnode中的data比对属性差异;

综上,将初次渲染与更新渲染的逻辑进行合并,得到重构逻辑:

  • 第一个参数:新的虚拟节点vnode(通过vnode.data可以拿到新的数据)
  • 第二个参数:老的数据(需要对新老数据进行diff比对,因此需要传入老数据)
  • 将传入的新数据vnode.data和老数据oldVnode.data两个数据对象进行比对,并更新数据对象;

重构后的updateProperties方法:

// src/vdom/patch.js

function updateProperties(vnode, oldProps = {} ) { 

  // 获取 dom 上的真实节点(在复用老节点时已经赋值)
  let el = vnode.el; 
  
  // 获取到新的数据
  let newProps = vnode.data || {};  
  
  // 新老数据比对:比对两个对象的差异
  for(let key in newProps){ 
    // 更新数据:直接使用新值覆盖老值
    el.setAttribute(key, newProps[key])
  }
  
  // 老数据中存在的 key,新数据中可能没有,这部分数据需要被删除
  for(let key in oldProps){
    if(!newProps[key]){
      el.removeAttribute(key)
    }
  }
}

重构后的更新流程:

let el = vnode.el = oldVnode.el;  

if(!oldVnode.tag){
  if(oldVnode.text !== vnode.text){
    return el.textContent = vnode.text;
  } else{
    return; 
  }
}

// 更新流程:传入新的虚拟节点和老的数据
// 执行逻辑:从新的虚拟节点vnode中获取到新数据,进行新老数据对象的合并,并将合并后的数据更新到已复用的真实节点上;
updateProperties(vnode, oldVnode.data);

重构后的渲染流程:

// src/vdom/patch.js

export function createElm(vnode) {
  let{tag, data, children, text, vm} = vnode;
  if(typeof tag === 'string'){
    vnode.el = document.createElement(tag)
    
    // 渲染流程:传入虚拟节点和数据
    // 执行逻辑:从虚拟节点vnode(此时没有新老的问题,因为没有老的,只有新的)中获取到新数据,进行新老数据对象的合并(新老数据是一样的),并将合并后的数据更新到已复用的真实节点上;
    updateProperties(vnode, data)
    
    children.forEach(child => {
      vnode.el.appendChild(createElm(child))
    });
  } else {
    vnode.el = document.createTextNode(text)
  }
  return vnode.el;
}

通过对重构后的执行逻辑分析:

实际上,就是使用更新逻辑去兼容渲染逻辑,从而实现updateProperties属性更新方法的复用,使之既具有渲染功能,又有更新功能;

测试:节点的元素名相同,属性不同的情况:

let vm1 = new Vue({
    data() {
        return { name: 'Brave' }
    }
})
let render1 = compileToFunction('<div id="a">{{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 class="b">{{name}}</div>');
let newVnode = render2.call(vm2);
setTimeout(() => {
    patch(oldVnode, newVnode); 
}, 1000);

测试结果:

image.png

image.png

更新前后,相同元素节点被复用,仅将id="a"更新为class="b"

除了属性需要更新外,还有其他一些特殊地属性也需要更新,比如:style样式;

内层的name属性并未更新,后续对儿子节点进行比对后,才能实现此功能;

2-3 style的处理

对于style样式属性,需要再执行一些特殊的逻辑处理:

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);

实例中,新老元素都具有style属性,因此不能使用当前逻辑el.setAttribute(key, newProps[key])直接处理;

style的属性值为字符串类型,不能直接进行替换,需要对样式属性进行收集,再进行比较和更新;

function updateProperties(vnode, oldProps = {} ) {
  let el = vnode.el;
  let newProps = vnode.data || {};

  let newStyly = newProps.style || {};  // 新样式对象
  let oldStyly = oldProps.style || {};  // 老样式对象
  
  // 老样式对象中有,新样式对象中没有,删掉多余样式
  for(let key in oldStyly){
    if(!newStyly[key]){
      el.style[key] = ''
    }
  }
  
  // 新样式对象中有,覆盖到老样式对象中
  for(let key in newProps){
    // 对 style 样式做处理
    if(key == 'style'){ 
      for(let key in newStyly){
          el.style[key] = newStyly[key]
      }
    }else{
      el.setAttribute(key, newProps[key])
    }
  }

  for(let key in oldProps){
    if(!newProps[key]){
      el.removeAttribute(key)
    }
  }
}

更新前:

image1.png

更新后:

image2.png

至此,外层的div已经实现了diff更新,但内层的name属性还并没有更新;

接下来,继续比对儿子节点,实现子节点的更新;


四,结尾

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

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

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


维护日志:

  • 20210806:调整文章的排版布局;
  • 20230217:调整部分内容描述,添加 2 个问题;
  • 20230218:调整文章目录和排版;补充了大量细节说明;补充了对 vue2 节点更新 diff 算法的性能问题说明;调整了代码注释的描述、换行和缩进,使思路更加清晰、便于理解;更新文章摘要;