【手写 Vue2.x 源码】第三十三篇 - diff 算法 - 收尾 + 阶段性总结

320 阅读7分钟

一,前言

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

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

本篇,diff 算法的阶段性梳理


二,初渲染与视图更新流程

  • Vue初渲染时,会调用mountComponent方法进行挂载,在mountComponent方法中,会创建一个 watcher

  • 当数据更新时,进入Object.definePropertyset方法,在set方法中,会调用dep.notify()通知收集的watcher调用update方法做更新渲染;

  • Watcher类的update方法中,调用了queueWatcher方法将watcher进行缓存、去重操作

  • queueWatcher方法中调用flushschedulerQueue方法,执行所有watcher.run并清空队列

  • Watcher类中的run方法,内部调用了Watcher类中的get方法:记录当前watcher并调用getter

  • this.getterWatcher类实例化时传入的视图更新方法fn,即updateComponent视图渲染逻辑

  • 执行updateComponent中的vm._render,使用最新数据重新生成虚拟节点并调用update更新视图


三,diff 算法的外层更新

Vue中,每次数据变化时,并不会对节点做全量的替换,而是会对新老虚拟节点进行diff比对:

  • 首次渲染,根据虚拟节点生成真实节点,替换掉原来的节点;
  • 更新渲染,生成新的虚拟节点,并与老的虚拟节点比对,尽可能复用老节;

diff算法,又叫同层比对算法,采用深度优先递归

采用了“头尾指针”的处理,通过对新老虚拟节点进行比对,尽可能复用原有节点,以提升渲染性能;

节点可复用的依据:

  • 标签名和key均相同,即判定为可复用节点;

patch方法:做节点的递归更新,通过节点类型oldVnode.nodeType,判断是否为真实节点;

  • 非真实节点,即为真实dom时,执行初渲染逻辑;
  • 是真实节点,需要对新老虚拟节点进行比对;

新老虚拟节点比对:

  • 节点相同时,复用老节点,更新文本、样式等属性即可;
  • 节点不相同时,使用新的真实节点:createElm(vnode),替换老的真实节点:oldVnode.el
oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el);

文本的处理:

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

元素的处理:

  • 新老元素都有的属性,用新值覆盖老值;
  • 新的没有但老的有的属性,直接删除即可;

style样式属性的处理:

  • 老样式对象中有,新样式对象中没有,删掉多余样式;
  • 新样式对象中有,直接覆盖到老样式对象中即可;

四,diff 算法的比对优化

1,新老儿子节点的情况

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

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

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

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

  • 情况 3:新老都有儿子

    处理方法:进行diff比对;

2,新老儿子节点的 diff 比对

  • 新老儿子节点的比对,采用了头尾双指针的方法;

  • 新老节点都有儿子时,进行头头、尾尾、头尾、尾头对比;

  • 头头、尾尾、头尾、尾头均没有命中时,进行乱序比对;


五,diff 算法的乱序比对

根据老儿子集合创建一个节点key和索引index的映射关系 mapping;用新儿子节点依次到mapping中查找是否存在可复用的节点;

  • 存在复用节点,更新可复用节点属性并移动到对应位置;(移动走的老位置要做空标记)
  • 不存在复用节点,创建节点并添加到对应位置;

最后,再将不可复用的老节点删除;


六,diff 算法收尾

1,问题分析

至此,已经完成了diff算法的全部逻辑编写,但一直使用模拟新老节点更新;

原因在于,每次更新时都执行patch(vm.$el, vnode)

// src/lifecycle.js

export function lifeCycleMixin(Vue){
  Vue.prototype._update = function (vnode) {
    const vm = this;
    // 传入当前真实元素vm.$el,虚拟节点vnode,返回新的真实元素
    vm.$el = patch(vm.$el, vnode);
  }
}

在之前使用两个虚拟节点模拟diff的更新时,已经对patch方法做了调整,使之既能够支持初渲染,也能够支持更新渲染:

// src/vdom/patch.js

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

  // 是否真实节点:虚拟节点无此属性
  const isRealElement = oldVnode.nodeType;  
  
  if (isRealElement) {// 真实节点
  
    // 1,根据虚拟节点创建真实节点
    const elm = createElm(vnode);
    
    // 2,使用真实节点替换掉老节点
    // 找到元素的父亲节点
    const parentNode = oldVnode.parentNode;
    // 找到老节点的下一个兄弟节点(nextSibling 若不存在将返回 null)
    const nextSibling = oldVnode.nextSibling;
    
    // 将新节点 elm 插入到老节点 el 的下一个兄弟节点 nextSibling 的前面
    // 备注:若 nextSibling 为 null,insertBefore 等价于 appendChild
    parentNode.insertBefore(elm, nextSibling);
    
    // 删除老节点 el
    parentNode.removeChild(oldVnode);
    
    return elm;
  } else { // diff:新老虚拟节点比对
    
    // 1,同级比对,不是相同节点时,不考虑复用(放弃跨层复用),直接用新的替换旧的
    if (!isSameVnode(oldVnode, vnode)) {
      return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el);
    }

    // 2,相同节点时,复用老节点,更新差异点(比如:属性)
    // 文本没有标签名,需要进行单独处理:由于文本不存在儿子,直接更新即可(组件Vue.component(‘xxx’)即为组件 tag)
    let el = vnode.el = oldVnode.el;  // 节点复用:将老节点el,赋值给新节点el
    
    
    if (!oldVnode.tag) {  // 文本:没有标签名
      // 文本内容发生变化时,用新内容覆盖老内容
      if (oldVnode.text !== vnode.text) {
        return el.textContent = vnode.text;
      }
    }

    // 元素的处理:相同节点,且新老节点不都是文本时
    updateProperties(vnode, oldVnode.data);

    // 比较儿子节点
    let oldChildren = oldVnode.children || {};
    let newChildren = vnode.children || {};
    
    // 情况 1:老的有儿子,新的没有儿子;直接把老的 dom 元素干掉即
    if (oldChildren.length > 0 && newChildren.length == 0) {
      el.innerHTML = '';//暴力写法直接清空;更好的处理是封装removeChildNodes方法:将子节点全部删掉,因为子节点可能包含组件
      
    // 情况 2:老的没有儿子,新的有儿子;直接将新的插入即可
    } else if (oldChildren.length == 0 && newChildren.length > 0) {
      newChildren.forEach((child) => {// 注意:这里的child是虚拟节点,需要变为真实节点
        let childElm = createElm(child); // 根据新的虚拟节点,创建一个真实节点
        el.appendChild(childElm);// 将生成的真实节点,放入 dom
      })
      
    // 情况 3:新老都有儿子
    } else {  
      // 递归: updateChildren 内部会调用 patch方法,
      // patch 方法内部还会继续调用 updateChildren; (patch 方法是更新的入口)
      updateChildren(el, oldChildren, newChildren)
    }
    
    return el;// 返回新节点
  }
}

2,正常使用方式

修改index.html:模拟div标签复用,仅更新span标签中的文本name

<!-- diff算法 -->
<body>

  <!-- 场景:div 标签复用,仅更新 span 标签中的文本 name -->
  <div id="app">
    <span>{{name}}</span>
  </div>
  
  <script src="./vue.js"></script>
  <script>
    let vm = new Vue({
      el: "#app",
      data() {
        return { name: 'Brave' }
      }
    });
    
    setTimeout(() => {
      vm.name = "BraveWang";
    }, 1000);
  </script>
</body>

2,测试修改前效果

测试patch方法修改前的效果:

image.png

测试结果:div标签及子节点被全部销毁,并重新创建了一次;

原因分析:每次都执行vm.$el = patch(vm.$el, vnode)时,并没有区分初渲染和更新渲染;

3,如何区分初渲染和更新渲染

如何区分初渲染和更新渲染?

  • 第一次渲染时,在vm.preVnode上保存当前Vnode;
  • 第二次渲染时,先取vm.preVnode,若有值,即为更新渲染;
  • 初渲染,执行patch(vm.$el, vnode)
  • 更新渲染,执行patch(preVnode, vnode)

4,代码实现

export function lifeCycleMixin(Vue){

  Vue.prototype._update = function (vnode) {
  
    const vm = this;
    // 取上一次的 preVnode
    let preVnode = vm.preVnode;
    // 渲染前,先保存当前 vnode
    vm.preVnode = vnode;
    
    // 1)preVnode 没值,为初渲染;
    if(!preVnode){
      // 传入当前真实元素vm.$el,虚拟节点vnode,返回新的真实元素
      vm.$el = patch(vm.$el, vnode);
      
    // 2)preVnode 有值,说明已有节点,当前为更新渲染:新老虚拟节点做 diff 比对
    }else{
      vm.$el = patch(preVnode, vnode);
    }
  }
}

5,测试修改后的效果:

测试patch方法修改后的效果:

image.png

测试结果:div标签被复用,只更新了span中的name


七,结尾

本篇,diff算法阶段性梳理,主要涉及以下几个点:

  • 初渲染与视图更新流程;
  • diff 算法的外层更新;
  • diff 算法的比对优化;
  • diff 算法的乱序比对;
  • 初渲染和更新渲染判断;

下篇,组件的初始化流程介绍;


更新日志

  • 20210807:添加“diff 算法收尾”部分;更新“结尾”部分;更新文章标题和摘要;
  • 20230222:添加了内容中的代码和关键字高亮,优化了大量的内容,使语义更加准确易懂;更新了文章摘要;