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

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

一前言

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

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

下篇diff 算法-节点比对


二diff 算法

上一篇完成了 patch 方法的改造

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

在这之前先介绍一下 diff 算法

1diff 算法的简单介绍

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

首先dom 是一个树型结构

image.png

在日常开发中很少会将 B 和 A 或是 D 和 A 的位置进行调换即很少将父亲和儿子节点进行交换

而且跨层的节点比对会非常麻烦所以diff 算法考虑到应用场景与性能只会进行同层节点的比较

2diff 算法的比较方式

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

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

image.png

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

image.png

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

D 比较完成后没有儿子继续比较 E当前层处理完成返回上层处理

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

所以diff 比对是深度优先遍历的递归比对

备注递归比对是 vue2 的性能瓶颈当组件树庞大时会有性能问题

3diff 算法的节点复用

如何确定两个节点为复用一般来说相同标签的元素即可进行复用
但也有标签相同实际场景并不希望复用的情况这时可使用 key 属性进行标记
如果 key 不相同即便标签名相同的两个元素也不会进行复用

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

  1. 标签名和 key 均相同是相同节点
  2. 如果标签名和 key 不完全相同不是相同节点

isSameVnode 方法用于判断是否为相同节点

// src/vdom/index.js

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

当新老虚拟节点的标签和 key 均相同时即 isSameVnode 返回 true复用节点仅做属性更新即可


三虚拟节点比对

1不是相同节点的情况

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

// 模拟初渲染
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);

// 模拟新的虚拟节点 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-if 和 v-else 的情况

所以不是相同节点不考虑复用放弃跨层复用直接使用新的替换掉旧的真实节点

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

image.png

image.png

如何替换节点

由于父节点的标签名不同导致节点不复用
需根据新的虚拟节点生成真实节点并替换掉老节点
  1. 使用新的虚拟节点创建真实节点

    createElm(vnode);

  2. 替换老节点如果获取到老的真实节点

    根据vnode生成真实节点时通过 vnode.el 将真实节点与虚拟节点进行了映射
    所以此时可以通过 oldVnode.el 获取到老的真实节点

    备注$el是指整棵树这里不可用

结论

  • 新的真实节点createElm(vnode);
  • 老的真实节点oldVnode.el
export function patch(oldVnode, vnode) {
  const isRealElement = oldVnode.nodeType;
  if(isRealElement){// 真实节点
    const elm = createElm(vnode);
    const parentNode = oldVnode.parentNode;
    parentNode.insertBefore(elm, oldVnode.nextSibling); 
    parentNode.removeChild(oldVnode);
    return elm;
  }else{ // diff新老虚拟节点比对
    console.log(oldVnode, vnode)
    if(!isSameVnode(oldVnode, vnode)){// 不是相同节点不考虑复用直接替换
      return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el);
    }
  }
}
当包含子组件时每个组件都有一个 watcher
将会通过 diff 进行局部更新并不会做整个树的更新
所以只要组件拆分合理一般不会有性能问题

2是相同节点的情况

如果元素的标签名和 key 都相同即判定为相同节点即isSameVnode返回 true

此时只需要更新属性即可文本、样式等

2-1 文本的处理

  • 文本节点没有标签名
  • 文本节点没有有儿子
// 文本的处理文本可以直接更新因为文本没有儿子
// 组件中 Vue.component‘xxx’xxx 就是组件的 tag
let el = vnode.el = oldVnode.el;  // 节点复用将老节点 el 赋值给新节点 el
if(!oldVnode.tag){// 文本没有标签名
  if(oldVnode.text !== vnode.text){// 内容变更更新文本内容
    return el.textContent = vnode.text;// 新内容替换老内容
  } else{
    return; 
  }
}

2-2 元素的处理

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

需要对 updateProperties 方法进行重构调整

重构前直接传入真实元素vnode.el 和 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)
    updateProperties(vnode.el, data)
    children.forEach(child => {
      vnode.el.appendChild(createElm(child))
    });
  } else {
    vnode.el = document.createTextNode(text)
  }
  return vnode.el;
}

function updateProperties(el, props = {} ) { 
  for(let key in props){
    el.setAttribute(key, props[key])
  }
}
updateProperties 方法的重构方式
第一个参数是新的虚拟节点
第二个参数是老的数据因为需要对新老数据做 diff 比对

重构后updateProperties方法既有渲染功能又有更新功能

// src/vdom/patch.js

export function createElm(vnode) {
  let{tag, data, children, text, vm} = vnode;
  if(typeof tag === 'string'){
    vnode.el = document.createElement(tag)
    updateProperties(vnode, data) // 修改。。。
    children.forEach(child => {
      vnode.el.appendChild(createElm(child))
    });
  } else {
    vnode.el = document.createTextNode(text)
  }
  return vnode.el;
}

// 1,初次渲染用oldProps给vnode的 el 赋值即可
// 2,更新逻辑拿到老的props和vnode中的 data 进行比对
function updateProperties(vnode, oldProps = {} ) { 
  let el = vnode.el; // dom上的真实节点上边复用老节点时已经赋值了
  let newProps = vnode.data || {};  // 拿到新的数据
  // 新旧比对两个对象比对差异
  for(let key in newProps){ // 直接用新的盖掉老的但还要注意老的里面有可能新的里面没有了
    el.setAttribute(key, newProps[key])
  }
  // 处理老的里面有可能新的里面没有的情况需要再删掉
  for(let key in oldProps){
    if(!newProps[key]){
      el.removeAttribute(key)
    }
  }
}

测试节点元素名相同属性不同

// 调用 updateProperties 属性更新
updateProperties(vnode, oldVnode.data);
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

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){
    if(key == 'style'){ // 处理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调整文章的排版布局

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: vue