手写Vue2源码(十一)—— diff算法

691 阅读6分钟

前言

通过手写Vue2源码,更深入了解Vue;

在项目开发过程,一步一步实现Vue核心功能,我会将不同功能放到不同分支,方便查阅;

另外我会编写一些开发文档,阐述编码细节及实现思路;

源码地址:手写Vue2源码

为何需要diff

如果没有diff,每次修改数据,更新视图,都重新进行一遍页面渲染,耗费性能。

我们知道,vue中template会编译成render函数,执行render函数会生成VNode;我们更新数据时,只需要比较oldVnode和newVnode,比较过程中应尽可能去复用老的dom,只更新我们修改的那一小块dom即可。

patch改写

当数据变化时,会执行相关watcher的watcher.run()方法,如果是渲染watcher,则进一步会执行mountComponent——>updateComponent——>vm._update(vm._render());即重新执行render函数,然后将新生成的Vnode与老的vnode作为参数,执行patch(oldVnode, vnode)方法。

注:

  1. $mount时才创建AST树,即一个组件只创建一次AST树
  2. 后续组件的更新只是重新执行vm._update(vm._render()),即重新执行render函数,以及生成真实dom,不重复创建AST树
  3. vue只会管理自己的template,手动直接创建dom,vue不会管理,后续这些dom中的数据更新,也与vue无关

首先,我们改写一下_update()方法,对老的vnode进行缓存:

// src/lifecycle.js
Vue.prototype._update = function (vnode) {
    const vm = this;
    const prevVnode = vm._vnode; // 获取上一次的vnode
    vm._vnode = vnode; // 保存本次的vnode

    // 【核心】patch是渲染vnode为真实dom
    if (!prevVnode) {
      // 初次渲染
      vm.$el = patch(vm.$el, vnode); // 初次渲染 vm._vnode肯定不存在 要通过虚拟节点 渲染出真实的dom 赋值给$el属性
    } else {
      // 视图更新
      vm.$el = patch(prevVnode, vnode); // 更新时把上次的vnode和这次更新的vnode穿进去 进行diff算法
    }
};

可见,第一次渲染和之后的更新,都是执行的 patch() 方法:

// src/vdom/patch.js
export function patch(oldVnode, vnode) {
  // 1. 第一次渲染【组件元素】时;没有$el,也没有oldVnode
  if (!oldVnode) {
    // 组件的创建过程是没有el属性的
    return createElm(vnode);
  } else {
    // Vnode没有设置nodeType,值为undefined;真实节点可以获取到nodeType
    const isRealElement = oldVnode.nodeType;
    // 2. 如果是初次渲染元素节点
    if (isRealElement) {
      const oldElm = oldVnode;
      const parentElm = oldElm.parentNode;
      // 将虚拟dom转化成真实dom节点
      const el = createElm(vnode);

      // 插入到 老的el节点 的下一个节点的前面,就相当于插入到老的el节点的后面
      // 这里不直接使用父元素appendChild是为了不破坏替换的位置
      parentElm.insertBefore(el, oldElm.nextSibling);

      // 删除老的el节点
      parentElm.removeChild(oldVnode);
      return el;
    } else {
      /**
       * 3. 如果是更新视图
       */
      // 1. 如果标签名不一样,直接删掉老的,换成新的
      // debugger;
      if (oldVnode.tag !== vnode.tag) {
        // vnode.el就是在 createElm(vnode)创建真实dom时添加到vnode上的,vnode.el是真实dom
        oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el); // oldVnode.el代表的是真实dom节点
      }
      // 2. 如果新旧节点是一个文本节点(新节点是一个文本节点,则旧节点一定是文本节点,否则两者tag不同,会走上面的判断)
      if (!vnode.tag) {
        if (oldVnode.text !== vnode.text) {
          oldVnode.el.textContent = vnode.text;
        }
      }
      // 3. 不符合上面两种,代表标签名一致,并且不是文本节点
      const el = (vnode.el = oldVnode.el); // 为了节点复用 所以直接把旧的虚拟dom对应的真实dom赋值给新的虚拟dom的el属性
      updateProperties(vnode, oldVnode.data); // 更新属性
      const oldCh = oldVnode.children || []; // 老的儿子
      const newCh = vnode.children || []; // 新的儿子
      if (oldCh.length > 0 && newCh.length > 0) {
        // 3.1. 新老都存在子节点
        updateChildren(el, oldCh, newCh); // 【核心算法】
      } else if (oldCh.length) {
        // 3.2 老的有儿子,新的没有
        el.innerHTML = "";
      } else if (newCh.length) {
        // 3.3 新的有儿子,老的没儿子
        for (let i = 0; i < newCh.length; i++) {
          const child = newCh[i];
          el.appendChild(createElm(child));
        }
      }
    }
  }
}

patch逻辑梳理:

  1. 第一次渲染组件元素时,组件的vnode中没有el元素,所以vm.$el为undefined,有第一个判断
  2. 第一次渲染元素节点,oldVnode为真实元素$el,走第二个判断
  3. 组件更新时,oldVnode和vnode都可以取到,且不是真实dom,走第三个判断
    1. 如果新旧vnode的标签名不一样,直接删掉老的,换成新的
    2. 如果新旧节点是文本节点,且文本内容不相等,直接采用新的
    3. 使用 updateProperties(vnode, oldVnode.data) 方法更新属性
    4. 如果新旧VNode都有子节点,使用 updateChildren(el, oldCh, newCh) 方法进行子节点的对比
    5. 如果老的有儿子,新的没有儿子,直接将老的innerHTML设为空
    6. 如果新的有儿子,老的没儿子,直接将新的子节点生成真实dom,插入老的节点中

patch流程

updateProperties

先看第一个核心方法 —— 属性更新:

// src/vdom/patch.js
// 初次调用时oldProps为空,更新时oldProps可能有值,都可以调用此方法来解析vnode的属性
function updateProperties(vnode, oldProps = {}) {
  let newProps = vnode.data || {};
  let el = vnode.el; // 真实节点

  // 如果新的节点没有该属性,需要把老的节点属性移除
  for (let k in oldProps) {
    if (!newProps[k]) {
      el.removeAttribute(k);
    }
  }

  // 对style样式做特殊处理,如果新的没有,需要把老的style值置为空
  let newStyle = newProps.style || {};
  let oldStyle = oldProps.style || {};
  for (let key in oldStyle) {
    if (!newStyle[key]) {
      el.style[key] = "";
    }
  }

  // 遍历新的属性,进行增加操作
  for (const key in newProps) {
    if (key === "style") {
      for (const styleName in newProps.style) {
        el.style[styleName] = newProps.style[styleName];
      }
    } else if (key === "class") {
      el.className = newProps.class;
    } else {
      // 给这个元素添加属性 值就是对应的值
      el.setAttribute(key, newProps[key]);
    }
  }
}

updateChildren

第二个核心方法 —— 子节点比对:

// src/vdom/patch.js
// 判断两个vnode的标签和key是否相同,如果相同,就可以认为是同一节点就地复用
function isSameVnode(oldVnode, newVnode) {
  return oldVnode.tag === newVnode.tag && oldVnode.key === newVnode.key;
}
// diff算法核心,采用双指针的方式,对比新老vnode的儿子节点
function updateChildren(parent, oldCh, newCh) {
  let oldStartIndex = 0; //老儿子的起始下标
  let oldStartVnode = oldCh[0]; //老儿子的第一个节点
  let oldEndIndex = oldCh.length - 1; //老儿子的结束下标
  let oldEndVnode = oldCh[oldEndIndex]; //老儿子的结束节点

  let newStartIndex = 0; // 新儿子的,同上
  let newStartVnode = newCh[0];
  let newEndIndex = newCh.length - 1;
  let newEndVnode = newCh[newEndIndex];

  // 根据key来创建老的儿子的index映射表;类似 {'a':0,'b':1}:表示key为'a'的节点在第一个位置,key为'b'的节点在第二个位置

  function makeIndexByKey(children) {
    let map = {};
    children.forEach((item, index) => {
      item.key && (map[item.key] = index);
    });
    return map;
  }
  // 生成oldCh的映射表(key:index)
  let keysMap = makeIndexByKey(oldCh);

  // 只有当新老儿子的双指标的起始位置不大于结束位置的时候,才能循环;
  // 一方的开始位置大于结束位置,说明该方循环完毕,需要结束循环
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    // 如果节点已经被移走了,直接跳过
    if (!oldStartVnode) {
      oldStartVnode = oldCh[++oldStartIndex];
    } else if (!oldEndVnode) {
      oldEndVnode = oldCh[--oldEndIndex];
    } else if (isSameVnode(oldStartVnode, newStartVnode)) {
      // 头头比较
      patch(oldStartVnode, newStartVnode); // 递归比较儿子以及他们的子节点
      // 指针往后移一位,startVnode也相应改变
      oldStartVnode = oldCh[++oldStartIndex];
      newStartVnode = newCh[++newStartIndex];
    } else if (isSameVnode(oldEndVnode, newEndVnode)) {
      // 尾尾比较
      patch(oldEndVnode, newEndVnode); // 递归比较儿子以及他们的子节点
      // 指针往前移一位,endVnode也相应改变
      oldEndVnode = oldCh[--oldEndIndex];
      newEndVnode = newCh[--newEndIndex];
    } else if (isSameVnode(oldStartVnode, newEndVnode)) {
      // 头尾比较
      patch(oldStartVnode, newEndVnode);
      //  比较完,就需要将递归的结果,放到oldEndVnode后面(因为新的是在尾部,所以当头尾比较满足samavnode时,需要将老的vnode移到尾部,与newCh顺序保持一致)
      parent.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling); // 比较完,就需要将结果移动到末尾
      // 指针改变,oldStartVnode、newEndVnode相应改变
      oldStartVnode = oldCh[++oldStartIndex];
      newEndVnode = newCh[--newEndIndex];
    } else if (isSameVnode(oldEndVnode, newStartVnode)) {
      // 尾头比较
      patch(oldEndVnode, newStartVnode);
      //  比较完,就需要将递归的结果,放到oldStartVnode前面(因为新的是在头部,所以当尾头比较满足samavnode时,需要将老的vnode移到头部,与newCh顺序保持一致)
      parent.insertBefore(oldEndVnode.el, oldStartVnode.el);
      // 指针改变,oldEndVnode、newStartVnode相应改变
      oldEndVnode = oldCh[--oldEndIndex];
      newStartVnode = newCh[++newStartIndex];
    } else {
      // 如果以上四种情况都不满足,需要进行暴力对比
      // 在oldCh中寻找newStartVnode对应key相同的节点(keysMap是表示oldCh中key-index对应关系的对象)
      let moveIndex = keysMap[newStartVnode.key];
      if (!moveIndex) {
        // 如果老的节点找不到与newStartVnode相同key的节点,则直接将newStartVnode插入
        parent.insertBefore(createElm(newStartVnode), oldStartVnode.el);
      } else {
        // 如果在oldCh中找到与newStartVnode相同key的节点
        let moveVnode = oldCh[moveIndex]; // 找得到就拿到老的节点
        oldCh[moveIndex] = null; //  这个是占位操作,避免数组塌陷,防止老节点移动走了之后破坏了初始的映射表位置,即后续如果再次采用乱序比对会出现索引位置错乱(因为moveVnode是根据索引获取的)
        parent.insertBefore(moveVnode.el, oldStartVnode.el); //把找到的节点移动到最前面
        patch(moveVnode, newStartVnode); //  递归patch
      }
      // 指针和newStartVnode相应做出改变
      newStartVnode = newCh[++newStartIndex];
    }
  }

  // 如果老节点循环完毕了,但是新节点还有;证明新节点需要被添加到头部或者尾部
  if (newStartIndex <= newEndIndex) {
    // 此时newStartIndex并非为0,而是等于oldCh比对完时,newCh所处的位置
    // 遍历newCh剩余的节点,生成真实dom,插入到parent中
    for (let i = newStartIndex; i <= newEndIndex; i++) {
      // 看下一个指针是否为null,不是的话,取它的el属性
      // 这是一个优化写法 insertBefore的第二个参数是null等同于appendChild作用
      const anchor =
        newCh[newEndIndex + 1] == null ? null : newCh[newEndIndex + 1].el;
      parent.insertBefore(createElm(newCh[i]), anchor);
    }
  }

  // 如果新节点循环完毕,老节点还有;证明老的节点需要直接被删除
  if (oldStartIndex <= oldEndIndex) {
    // 遍历oldCh剩余的节点,将他们从parent中删除
    for (let i = oldStartIndex; i <= oldEndIndex; i++) {
      let child = oldCh[i];
      parent.removeChild(child.el);
    }
  }
}

updateChildren流程分析:

  1. 采用双指针的方式来对比新旧vnode的子节点 双指针
  2. 子节点对比流程:
    1. 初始化指针及指针对应的oldEndVnode、oldStartVnode、newEndVnode、newStartVnode
    2. oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex时,循环进行下列比对,直到某一方所有节点比较完毕
      1. 根据是否是sameVnode(tag和key都相同则是sameVnode),判断采用 首首对比尾尾对比首尾对比尾首对比中的哪一种,并且递归patch处理子孙节点;
      2. 如果上述四种都没匹配上,则采用暴力对比:在oldChildren中查找与newStartVnode匹配的节点。如果匹配上了,就将该节点移到oldStartVnode前面;如果没匹配上,直接在oldStartVnode前面插入newStartVnode
      3. 在以上五种比对的过程中,比对完需要移动oldCh中节点的位置,移动指针,以及重新设置oldEndVnode、oldStartVnode、newEndVnode、newStartVnode的值
    3. 如果newCh或oldCh其中一方比对完成:
      1. 当newCh比对完了(即依然存在oldStartIndex <= oldEndIndex),则将oldCh中剩余的节点全部删除
      2. 当oldCh比对完了(即依然存在newStartIndex <= newEndIndex),将newCh中剩余的节点添加到oldCh中

小结

  1. diff采用同层比较,不跨层比较
  2. 采用双指针比较同层子节点
  3. 后代节点使用patch递归比对
  4. 设置key可以最大化的利用节点

系列文章