Vue3源码学习系列(一)虚拟DOM Diff算法

616 阅读27分钟

前言

Vue应该是国内受众最广的前端框架,究其原因,主要是还是学习成本低,上手容易。大部分开发者,可能只需要学习一遍文档,就可以开始使用了。

曾经我也是其中一员,Vue使用已经比较长时间了,但是一直停留在使用的阶段,并未去探索其底层的实现原理和设计思想。

人生没有追求跟咸鱼有什么区别!那就撸起袖子干就完了!(一个重要原因,是学习源码的人越来越多,我实现不想被他们卷死,那只能努力卷死别人 😭)

希望通过Vue3源码学习系列,记录自己学习过程的经验总结,以便日后回顾。如果文章中有不正确的地方,还望各路大佬指正。

虚拟DOM

什么是虚拟DOM?简单来做,就是用js去描述真实的DOM对象结构。(Vue在编译过程中会通过ATS形成抽象语法树)

这样做的好处是抛弃的DOM中一些无用的信息,使得的整个树形结构更加简单清晰,使得对DOM的操作更加灵活。

老规矩,举个🌰:

真实的DOM

<ul id="list">
  <li class="item">张三</li>
  <li class="item">李四</li>
  <li class="item">王五</li>
</ul>

虚拟DOM

{
  tag:'ul',
  props:{ id:'list' },
  children: [
    { tag: 'li', props:{class:'item'}, children:'张三' },
    { tag: 'li', props:{class:'item'}, children:'李四' },
    { tag: 'li', props:{class:'item'}, children:'王五' }
  ]
}

如果将王五改成赵六,那新的虚拟DOM会变成:

{
  tag:'ul',
  props:{ id:'list' },
  children: [
    { tag: 'li', props:{class:'item'}, children:'张三' },
    { tag: 'li', props:{class:'item'}, children:'李四' },
    { tag: 'li', props:{class:'item'}, children:'赵六' }
  ]
}

一直有人说Vue性能高的原因是因为使用虚拟DOM,那是否通过用新的虚拟DOM去替换旧的虚拟DOM,性能上真的比直接替换真实的DOM要好呢??我们比较一下两者的过程:

我们看到第二种方式里,由于多了一个创建新的虚拟DOM,并将它渲染成真实DOM的过程,因此如果只是简单的用新的虚拟DOM替换旧的虚拟DOM,然后渲染成真实DOM,从性能上并不会比直接渲染成真实DOM要好。

真实的情况是,Vue通过差异化的算法,比较新旧两个虚拟DOM,从中找出需要变更的虚拟DOM节点,在真实DOM结构上,只操作了这些需要变更的DOM节点。

所以更严谨的说法,应该是通过虚拟DOM的差异化算法操作真实DOM,性能高于直接操作真实DOM。虚拟DOM的差异化算法就是我们接下去要讲的Diff算法。

比起只是学习Vue3中的Diff算法,同时学习Vue2和Vue3两者的Diff算法,应该能更好的理解Vue3的优化点在哪里

Diff的几个基本原则

只做同层比较,不会做跨层级比较

只有通同类型节点才做比较,非同类型节点,直接销毁旧节点并创建新节点

// Vue3 /packages/runtime-core/src/vnode.ts
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
  return n1.type === n2.type && n1.key === n2.key
}

Vue2中虚拟DOM Diff算法

我们先来看一下Vue2中和虚拟DOM相关的部分核心代码

patch(核心流程都在这个函数里)

patch方法比较新旧两个虚拟节点是否为统一类型

  • 是:通过patchVnode做更深层次的比较
  • 否:直接用新节点替换旧节点
// /src/core/vdom/patch.js

/**
 * @param oldVnode  旧的虚拟DOM节点,可以不存在或是一个 DOM 对象
 * @param vnode  新的虚拟DOM节点
 * @param hydrating  是否是服务端渲染
 * @param removeOnly  是给 transition-group 用的
 */
function patch(oldVnode, vnode, hydrating, removeOnly) {
  if (isUndef(vnode)) {
    // 如果没有新节点,但是旧节点存在,则直接触发destroy钩子
    if (isDef(oldVnode)) invokeDestroyHook(oldVnode);
    return;
  }

  let isInitialPatch = false;
  const insertedVnodeQueue = [];

  if (isUndef(oldVnode)) {
    // 如果旧节点不存在则直接创建新节点
    isInitialPatch = true;
    createElm(vnode, insertedVnodeQueue);
  } else {
    // 当新旧节点都存在的情况

    // 判断旧节点是否为真实的节点(dom元素的nodeType为1)
    const isRealElement = isDef(oldVnode.nodeType);
    // 比较是否为一个类型的节点
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // 旧节点不是真实节点且新旧节点是同一节点,则做更近一步的对比修改
      patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly);
    } else {
      // 旧节点不是真实元素,或新旧节点不是同一节点

      if (isRealElement) {
        // 挂载到真实元素和处理服务端渲染(这部分逻辑,目前我还没有理解清晰)
        if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
          oldVnode.removeAttribute(SSR_ATTR);
          hydrating = true;
        }
        if (isTrue(hydrating)) {
          if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
            invokeInsertHook(vnode, insertedVnodeQueue, true);
            return oldVnode;
          } else if (process.env.NODE_ENV !== 'production') {
            warn(
              'The client-side rendered virtual DOM tree is not matching ' +
                'server-rendered content. This is likely caused by incorrect ' +
                'HTML markup, for example nesting block-level elements inside ' +
                '<p>, or missing <tbody>. Bailing hydration and performing ' +
                'full client-side render.'
            );
          }
        }
        // 不是服务端渲染或服务端渲染失败,把 oldVnode 转换成 VNode 对象.
        oldVnode = emptyNodeAt(oldVnode);
      }

      // 旧节点的真实DOM
      const oldElm = oldVnode.elm;
      // 旧节点的父元素
      const parentElm = nodeOps.parentNode(oldElm);

      // 根据新的 vnode 创建一个 DOM 节点,挂载到父节点上
      createElm(
        vnode,
        insertedVnodeQueue,
        oldElm._leaveCb ? null : parentElm,
        nodeOps.nextSibling(oldElm)
      );

      // 递归更新父组件占位符,只有组件的渲染 VNode 才有vnode.parent
      // 考虑这样的情况:
      // parent-component 的模板为:
      //   <template>
      //     <child-component></child-component>
      //   <template>
      // child-component 的模板为:
      //   <template>
      //     <div class="child-root"></div>
      //   <template>
      //
      // 未渲染的 HTML:
      // <div id="root">
      //   <parent-component></parent-component>
      // </div>
      //
      // 渲染后的 HTML:
      // <div id="root">
      //   <div class="child-root"></div>
      // </div>
      if (isDef(vnode.parent)) {
        let ancestor = vnode.parent;
        const patchable = isPatchable(vnode);
        while (ancestor) {
          // 递归地将 vnode.elm 赋值给所有祖先占位 vnode 的 elm
          for (let i = 0; i < cbs.destroy.length; ++i) {
            cbs.destroy[i](ancestor);
          }
          ancestor.elm = vnode.elm;
          if (patchable) {
            for (let i = 0; i < cbs.create.length; ++i) {
              cbs.create[i](emptyNode, ancestor);
            }
            const insert = ancestor.data.hook.insert;
            if (insert.merged) {
              // start at index 1 to avoid re-invoking component mounted hook
              for (let i = 1; i < insert.fns.length; i++) {
                insert.fns[i]();
              }
            }
          } else {
            registerRef(ancestor);
          }
          ancestor = ancestor.parent;
        }
      }

      if (isDef(parentElm)) {
        // 销毁旧节点
        removeVnodes([oldVnode], 0, 0);
      } else if (isDef(oldVnode.tag)) {
        // 触发旧节点的destroy钩子
        invokeDestroyHook(oldVnode);
      }
    }
  }
  // 触发新节点的insert钩子
  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch);
  // 返回新节点的真实的DOM
  return vnode.elm;
}

sameVnode

someVnode方法判断是否是同一类型的节点

// Vue2 /src/core/vdom/patch.js
// 主要通过对key和标签名做比较
function sameVnode (a, b) {
  return (
    a.key === b.key && // 标签名是否一样
    a.asyncFactory === b.asyncFactory && ( // 是否都是异步工厂方法
      (
        a.tag === b.tag && // 标签名是否一样
        a.isComment === b.isComment && // 是否都为注释节点
        isDef(a.data) === isDef(b.data) && // 是否都定义了data
        sameInputType(a, b)  // 当标签为input时,type必须是否相同
      ) || (
        isTrue(a.isAsyncPlaceholder) && // 是否都有异步占位符节点
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

patchVnode

如果新旧节点是sameVnode,则不会重新创建DOM节点,而是通过patchVnode方法对原来的DOM节点做修补。

修补的大致逻辑是:

  • 如果oldVnode和vnode是同一个引用对象,则直接返回

  • 如果oldVnode的isAsyncPlaceholder为true,表示当前节点异步占位节点,直接返回

  • 如果 oldVnode 和 vnode 都是静态节点,且key相等,并且vnode是克隆节点或者是带有v-once 指令控制的节点时,把oldVnode.elm和oldVnode.child都复制到 vnode 上,然后返回

  • 如果vnode不是文本节点,则按以下步骤处理:

    1. 如果vnode和oldVnode都有子节点,而且子节点不是同一个引用对象的话,就调用updateChildren更新子节点

    2. 如果只有vnode有子节点,就创建子节点(addVnodes)

    3. 如果只有oldVnode有子节点,就删除该子节点(removeVnodes)

    4. 如果oldVnode是文本节点,就直接删除DOM上的文本

  • 如果vnode是文本节点,而且跟oldVnode文本内容不一样,则直接更新DOM上的文本

/**
 * @param oldVnode  旧的虚拟DOM节点
 * @param vnode  新的虚拟DOM节点
 * @param insertedVnodeQueue  插入节点的队列
 * @param ownerArray
 * @param index
 * @param removeOnly
 */
function patchVnode(
  oldVnode,
  vnode,
  insertedVnodeQueue,
  ownerArray,
  index,
  removeOnly
) {
  // 当新旧节点是同一引用对象,则直接返回
  if (oldVnode === vnode) {
    return;
  }

  // 如果虚拟节点的elm属性存在的话,就说明有被渲染过了,如果ownerArray存在,说明存在子节点,如果这两点到成立,那就克隆一个vnode节点
  if (isDef(vnode.elm) && isDef(ownerArray)) {
    vnode = ownerArray[index] = cloneVNode(vnode);
  }

  const elm = (vnode.elm = oldVnode.elm);

  // oldVnode是否存在异步占位符
  if (isTrue(oldVnode.isAsyncPlaceholder)) {
    // vnode是否存在异步工厂函数,主要是异步组件会使用到
    if (isDef(vnode.asyncFactory.resolved)) {
      hydrate(oldVnode.elm, vnode, insertedVnodeQueue);
    } else {
      vnode.isAsyncPlaceholder = true;
    }
    return;
  }

  // 处理静态节点
  // oldVnode和vnode是静态节点,且key属性都相等,且vnode是克隆的虚拟DOM或者是带有v-once的组件
  // 则更新componentInstance属性并且直接返回,说明整个组件没有发生变化
  if (
    isTrue(vnode.isStatic) &&
    isTrue(oldVnode.isStatic) &&
    vnode.key === oldVnode.key &&
    (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
  ) {
    vnode.componentInstance = oldVnode.componentInstance;
    return;
  }

  let i;
  const data = vnode.data;
  // 当vnode是组件时,hook包含init, prepatch, insert , destroy四个钩子
  // init 实例化子组件
  // prepatch 更新子组件
  // insert 调用子组件的 ’mounted‘生命周期,或者当’keepAlive‘存在的时候触发组件的activated生命周期
  // destroy调用子组件的 ’destroyed‘生命周期,或者当’keepAlive‘存在的时候触发组件的deactivated生命周期
  if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
    i(oldVnode, vnode);
  }

  const oldCh = oldVnode.children;
  const ch = vnode.children;
  // 调用各种更新,updateAttrs、updateClass、updateDOMListeners、updateDOMProps、updateStyle、updateDrectives
  if (isDef(data) && isPatchable(vnode)) {
    for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
    if (isDef((i = data.hook)) && isDef((i = i.update))) i(oldVnode, vnode);
  }
  if (isUndef(vnode.text)) {
    // vnode不是文本组件
    if (isDef(oldCh) && isDef(ch)) {
      // oldVnode和vnode都存在子节点,而且两者的子节点不是同一个引用对象,则更新子节点
      if (oldCh !== ch)
        updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly);
    } else if (isDef(ch)) {
      // 如果oldVnode不存在子节点,而vnode存在子节点
      // 当oldVnode是文本节点时,先置空文本
      // 然后创建子节点
      if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '');
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
    } else if (isDef(oldCh)) {
      // 如果oldVnode存在子节点,而vnode不存在子节点,则删除子节点
      removeVnodes(oldCh, 0, oldCh.length - 1);
    } else if (isDef(oldVnode.text)) {
      // 如果oldVnode是文本节点,则置空
      nodeOps.setTextContent(elm, '');
    }
  } else if (oldVnode.text !== vnode.text) {
    // 如果vnode是文本节点,而且跟oldVnode文本内容不一样,则直接更新DOM上的文本
    nodeOps.setTextContent(elm, vnode.text);
  }
  if (isDef(data)) {
    // 调用postpatch 钩子
    if (isDef((i = data.hook)) && isDef((i = i.postpatch))) i(oldVnode, vnode);
  }
}

updateChildren (Diff算法)

updateChildren应该是patch过程中最重要的一个方法,主要是对新旧虚拟DOM的子节点做对比,Diff算法就提现在这个过程中。对比的方法采用的是首尾指针法(或者叫双端比较法),

NewS、NewE、OldS、OldE分别代表新旧两个子节点数组的开始和结束索引,按照OldS和NewS、OldE和NewE、OldS和NewE、OldE和NewS进行两两比较,比较遵循以下几个原则:

  1. OldS和NewS同一类型节点(sameVnode),位置不变,OldS和NewS均+1
  2. OldE和NewE同一类型节点(sameVnode),位置不变,OldE和NewE均-1
  3. OldS和NewE同一类型节点(sameVnode),OldS移动到OldE之后,OldS +1, NewE -1
  4. OldE和NewS同一类型节点(sameVnode),OldE移动到OldS之前,NewS +1, OldE -1
  5. 不符合前面4种情况,则根据key生成OldS和OldE之间的index表,通过NewS指向节点的key在index表中查找该节点是否在OldS和OldE之间:若是,直接移动到OldS前,并把旧节点设置成undefined 。若不是,创建后,移动到OldS前,NewS +1
  6. 当旧的节点先遍历完(OldS > OldE), 则将[NewS, NewE] 之间的节点插入到真实的dom中(插入到NewS+1的节点之前)
  7. 当新的节点先遍历完(NewS > NewE), 则将[OldS, OldE] 之间的节点从真实的dom中移除

是否感觉一脸懵逼??莫慌,我们先通过几个图例来理解整个过程,最后再来看代码。

我们可以看到:

  • 旧的子节点是 a b c
  • 新的子节点是 a c b
  • NewS、NewE、OldS、OldE分别代表新旧两个子节点数组的开始和结束位置索引

第一次比较:

我们可以看到OldS和NewS指向的是同一个类型节点a(使用规则1),节点的位置不需要移动,同时OldS和NewS均+1

第二次比较:

  • OldS和NewS指向的不是同一类型节点(不适用规则1)
  • OldE和NewE指向的不是同一类型节点(不适用规则2)
  • OldS和NewE指向的是同一类型的节点b(适用规则3),我们把OldS移动到OldE之后,OldS +1,NewE -1

需要注意的是,我们移动节点位置,操作的是真实的DOM节点

第三次比较:

OldS和NewS指向的是同一类型节点c(适用规则1),节点的位置不需要移动,同时OldS和NewS均+1

此时,OldS大于OldE、NewS大于NewE,代表新旧子节点都检查完毕。

如果新子节点比旧子节点多的情况如何处理?

如上图,我们可以看到几个情况:

  • 新的子节点比旧的子节点多了一个c节点
  • 在新的子节点中,d和d的位置发生了变化

第一次比较:

我们可以看到OldS和NewS指向的是同一个类型节点a(使用规则1),节点的位置不需要移动,同时OldS和NewS均+1

第二次比较:

  • OldS和NewS指向的不是同一类型节点(不适用规则1)
  • OldE和NewE指向的不是同一类型节点(不适用规则2)
  • OldS和NewE指向的是同一类型节点b(适用规则3),我们把OldS移动到OldE之后,OldS +1,NewE -1

第三次比较:

  • OldS和NewS指向的不是同一类型节点(不适用规则1)
  • OldE和NewE指向的是同一类型节点(适用规则2),节点位置不变,OldE和NewE均-1

此时,OldS大于OldE,说明旧的子节点已经遍历完毕,而NewS等于NewE,新的子节点还没有遍历完毕,新的子节点多于旧的子节点,需要将多的子节点插入到真实的DOM中。

这里需要注意的是,多余的子节点插入的位置。按规则6,需要插入到NewS+1的节点之前的位置。此时NewS指向的是c,+1后变成d,因此需要插入到d对应的真实DOM节点之前的位置。

旧的子节点多于新的子节点,就是只是直接把多余的节点移除,逻辑比较简单,就不单独举例了。

最后,我们来看一种比较复杂的情况:

第一次比较:

  • OldS和NewS指向的不是同一类型节点(不适用规则1)
  • OldE和NewE指向的不是同一类型节点(不适用规则2)
  • OldS和NewE指向的不是同一类型节点(不适用规则3)
  • OldE和NewS指向的是同一类型节点(适用规则4),OldE移动到OldS之前,NewS +1, OldE -1

第二次比较:

  • OldS和NewS指向的不是同一类型节点(不适用规则1)
  • OldE和NewE指向的不是同一类型节点(不适用规则2)
  • OldS和NewE指向的不是同一类型节点(不适用规则3)
  • OldE和NewS指向的不是同一类型节点(不适用规则4)

不符合前面4项规则,那我们只能通过规则5来判断操作,还记得规则5吗?

根据key生成OldS和OldE之间的index表,通过NewS指向节点的key在index表中查找该节点是否在OldS和OldE之间:若是,直接移动到OldS前,并把旧节点设置成undefined ,若不是,创建后,移动到OldS前。NewS +1

  • 先根据key生成OldS和OldE之间的index表: {b:0, d:1, c:2}
  • 通过NewS指向的节点的key是e,在index中表找不到,所以该节点不在在OldS和OldE之间
  • 创建该节点的真实DOM,并插入到OldS指向的真实DOM之前,NewS +1

第三次比较:

  • OldS和NewS指向的是同一类型节点(适用规则1),节点位置不变,OldS和NewS均+1

第四次比较: 和第二次比较同样处理,不在详细描述,直接上图

此时,NewS大于NewE,说明新的子节点已经遍历完了,而OldS小于OldE,说明有多余的旧的子节点需要移除。

到此为止,我们已经通过图例,大致了解了Vue2中关于虚拟DOM的Diff算法,我们再来看一下代码:

/**
 * @param parentElm 父节点
 * @param oldCh 旧 VNode 的子 VNode 数组
 * @param newCh 新 VNode 的子 VNode 数组
 * @param insertedVnodeQueue
 * @param removeOnly
 */
function updateChildren(
  parentElm,
  oldCh,
  newCh,
  insertedVnodeQueue,
  removeOnly
) {
  let oldStartIdx = 0;
  let newStartIdx = 0;
  // 旧的子节点的最后一个索引
  let oldEndIdx = oldCh.length - 1;
  // 第一个旧的子节点
  let oldStartVnode = oldCh[0];
  // 最后一个旧的子节点
  let oldEndVnode = oldCh[oldEndIdx];
  // 新的子节点的最后一个索引
  let newEndIdx = newCh.length - 1;
  // 第一个新的子节点
  let newStartVnode = newCh[0];
  // 最后一个新的子节点
  let newEndVnode = newCh[newEndIdx];
  let oldKeyToIdx, idxInOld, vnodeToMove, refElm;

  const canMove = !removeOnly;

  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 判断oldStartVnode 和 oldEndVnode是否为 undefined,是因为最后一个 else 里的逻辑可能会将旧子节点设置为 undefined(规则5的其中一种处理情况)
    if (isUndef(oldStartVnode)) {
      oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
    } else if (isUndef(oldEndVnode)) {
      oldEndVnode = oldCh[--oldEndIdx];
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      // oldStartVnode 和 newStartVnode 是同一个 VNode(规则1)
      // 通过patchVnode做修补
      patchVnode(
        oldStartVnode,
        newStartVnode,
        insertedVnodeQueue,
        newCh,
        newStartIdx
      );
      oldStartVnode = oldCh[++oldStartIdx];
      newStartVnode = newCh[++newStartIdx];
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      // oldEndVnode 和 newEndVnode 是同一个 VNode(规则2)
      // 通过patchVnode做修补
      patchVnode(
        oldEndVnode,
        newEndVnode,
        insertedVnodeQueue,
        newCh,
        newEndIdx
      );
      oldEndVnode = oldCh[--oldEndIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (sameVnode(oldStartVnode, newEndVnode)) {
      // oldStartVnode 和 newEndVnode 是同一个 VNode(规则3)
      // 通过patchVnode做修补,然后移动节点位置
      patchVnode(
        oldStartVnode,
        newEndVnode,
        insertedVnodeQueue,
        newCh,
        newEndIdx
      );
      canMove &&
        nodeOps.insertBefore(
          parentElm,
          oldStartVnode.elm,
          nodeOps.nextSibling(oldEndVnode.elm)
        );
      oldStartVnode = oldCh[++oldStartIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (sameVnode(oldEndVnode, newStartVnode)) {
      // oldEndVnode 和 newStartVnode 是同一个 VNode(规则4)
      // 通过patchVnode做修补,然后移动节点位置
      patchVnode(
        oldEndVnode,
        newStartVnode,
        insertedVnodeQueue,
        newCh,
        newStartIdx
      );
      canMove &&
        nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
      oldEndVnode = oldCh[--oldEndIdx];
      newStartVnode = newCh[++newStartIdx];
    } else {
      // 处理规则5的情况
      // 根据 key 生成 OldS 和 OldE 之间的index表
      if (isUndef(oldKeyToIdx))
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
      // 查找新的子节点是否在 oldStartIdx 和 oldEndIdx 之间
      idxInOld = isDef(newStartVnode.key)
        ? oldKeyToIdx[newStartVnode.key]
        : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
      if (isUndef(idxInOld)) {
        // 新的子节点不存在,则在 oldStartVnode 对应的真实 DOM 节点之前,创建并插入新的真实 DOM 节点
        createElm(
          newStartVnode,
          insertedVnodeQueue,
          parentElm,
          oldStartVnode.elm,
          false,
          newCh,
          newStartIdx
        );
      } else {
        // 新的子节点存在,则直接移动到 oldStartVnode 对应的真实 DOM 节点之前,并将旧节点设置成 undefined
        // 这里再次判断了是否同一类型节点,以防 key 一样,但是节点类型不同
        vnodeToMove = oldCh[idxInOld];
        if (sameVnode(vnodeToMove, newStartVnode)) {
          patchVnode(
            vnodeToMove,
            newStartVnode,
            insertedVnodeQueue,
            newCh,
            newStartIdx
          );
          oldCh[idxInOld] = undefined;
          canMove &&
            nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);
        } else {
          createElm(
            newStartVnode,
            insertedVnodeQueue,
            parentElm,
            oldStartVnode.elm,
            false,
            newCh,
            newStartIdx
          );
        }
      }
      newStartVnode = newCh[++newStartIdx];
    }
  }

  if (oldStartIdx > oldEndIdx) {
    // oldChildren 先遍历完,说明 newChildren 存在多余节点,添加这些新节点
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
    addVnodes(
      parentElm,
      refElm,
      newCh,
      newStartIdx,
      newEndIdx,
      insertedVnodeQueue
    );
  } else if (newStartIdx > newEndIdx) {
    // newChildren 先遍历完,说明 oldChildren 存在多余节点,直接删除掉
    removeVnodes(oldCh, oldStartIdx, oldEndIdx);
  }
}

Vue3中虚拟DOM Diff算法

patch

核心流程同样是在patch函数,我们来看一下内部的逻辑

// /packages/runtime-core/src/renderer.ts
const patch: PatchFn = (
  n1,
  n2,
  container,
  anchor = null,
  parentComponent = null,
  parentSuspense = null,
  isSVG = false,
  slotScopeIds = null,
  optimized = false
) => {
  // 新旧VNode是同一个对象,则不做处理直接返回
  if (n1 === n2) {
    return;
  }

  // 旧VNode存在,而且新旧VNode不是同一个类型,则卸载旧的VNode
  if (n1 && !isSameVNodeType(n1, n2)) {
    anchor = getNextHostNode(n1);
    unmount(n1, parentComponent, parentSuspense, true);
    n1 = null;
  }

  // 如果新VNode的patchFlag是BAIL,则diff时不进行优化
  if (n2.patchFlag === PatchFlags.BAIL) {
    optimized = false;
    n2.dynamicChildren = null;
  }

  const { type, ref, shapeFlag } = n2;
  // 根据 VNode 类型进行不同的处理
  switch (type) {
    case Text: // 文本节点
      processText(n1, n2, container, anchor);
      break;
    case Comment: // 注释节点
      processCommentNode(n1, n2, container, anchor);
      break;
    case Static: // 静态节点
      if (n1 == null) {
        // 如果旧VNode不存在,则直接挂载新的节点
        mountStaticNode(n2, container, anchor, isSVG);
      } else if (__DEV__) {
        patchStaticNode(n1, n2, container, isSVG);
      }
      break;
    case Fragment: // // Fragment类型节点
      processFragment(
        n1,
        n2,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      );
      break;
    default:
      if (shapeFlag & ShapeFlags.ELEMENT) { // 元素节点
        processElement(
          n1,
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        );
      } else if (shapeFlag & ShapeFlags.COMPONENT) { // 组件类型节点
        processComponent(
          n1,
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        );
      } else if (shapeFlag & ShapeFlags.TELEPORT) { // Teleport类型节点
        (type as typeof TeleportImpl).process(
          n1 as TeleportVNode,
          n2 as TeleportVNode,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized,
          internals
        );
      } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) { // Suspense类型节点
        (type as typeof SuspenseImpl).process(
          n1,
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized,
          internals
        );
      } else if (__DEV__) {
        warn('Invalid VNode type:', type, `(${typeof type})`);
      }
  }

  // set ref
  if (ref != null && parentComponent) {
    setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2);
  }
};

isSameVNodeType

//  /packages/runtime-core/src/vnode.ts
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
  if (
    __DEV__ &&
    n2.shapeFlag & ShapeFlags.COMPONENT &&
    hmrDirtyComponents.has(n2.type as ConcreteComponent)
  ) { // 开发模式下的逻辑,忽略
    return false
  }
  // 当新旧VNode的类型和key均一致时,才判断为同一类型节点
  return n1.type === n2.type && n1.key === n2.key
}

PatchFlags

Vue2在patch阶段时会对VNode进行全量diff,但是我们知道有的节点只声明了动态文本或者动态class/style,那就没有必要去做全量的diff。

因此vue3对这部分进行了优化,在生成 AST 树后,生成VNode时,就会根据节点的特点打上对应的patchFlag,从而实现对节点的靶向更新。

我们先看PatchFlags的定义:

// /packages/shared/src/patchFlags.ts
export const enum PatchFlags {
  // 动态文本节点
  // 十进制: 1
  // 二进制: 0000 0000 0001
  TEXT = 1,
  
  // 动态 class
  // 十进制: 2
  // 二进制: 0000 0000 0010
  CLASS = 1 << 1,
  
  // 动态 style
  // 十进制: 4
  // 二进制: 0000 0000 0100
  STYLE = 1 << 2,
  
  // 动态属性,但不包含类名和样式。如果是组件,则可以包含类名和样式
  // 十进制: 8
  // 二进制: 0000 0000 1000
  PROPS = 1 << 3,
  
  // 具有动态 key 属性,当 key 改变时,需要进行完整的 diff 比较。
  // 十进制: 16
  // 二进制: 0000 0001 0000
  FULL_PROPS = 1 << 4,
  
  // 带有监听事件的节点
  // 十进制: 32
  // 二进制: 0000 0010 0000
  HYDRATE_EVENTS = 1 << 5,
  
  // 一个不会改变子节点顺序的 fragment
  // 十进制: 64
  // 二进制: 0000 0100 0000
  STABLE_FRAGMENT = 1 << 6,
  
  // 带有 key 属性的 fragment 或部分子字节有 key
  // 十进制: 128
  // 二进制: 0000 1000 0000
  KEYED_FRAGMENT = 1 << 7,
  
  // 子节点没有 key 的 fragment
  // 十进制: 256
  // 二进制: 0001 0000 0000
  UNKEYED_FRAGMENT = 1 << 8,
  
  // 一个节点只会进行非 props 比较
  // 十进制: 512
  // 二进制: 0010 0000 0000
  NEED_PATCH = 1 << 9,
  
  // 动态 slot
  // 十进制: 1024
  // 二进制: 0100 0000 0000
  DYNAMIC_SLOTS = 1 << 10,
  
  // 标识用户在模板的根级放置了*注释而创建的片段。这是一个仅限开发人员的标志,因为*注释在生产中是被剥离的。
  // 十进制: 2048
  // 二进制: 1000 0000 0000
  DEV_ROOT_FRAGMENT = 1 << 11,
  
  // 静态节点
  HOISTED = -1,
  
  // 指示在 diff 过程应该要退出优化模式
  BAIL = -2,
}

patchFlag 的分为两大类:

  • 当 patchFlag 的值大于 0 时,代表所对应的元素在 patchVNode 时或 render 时是可以被优化生成或更新的。
  • 当 patchFlag 的值小于 0 时,代表所对应的元素在 patchVNode 时,是需要被 full diff,即进行递归遍历 VNode tree 的比较更新过程。

PatchFlags的具体使用,我们后面会结合具体代码来讲。

ShapeFlags

ShapeFlag 按字面翻译就是形状标记,它的作用是标记节点的形状(到底是怎么样的节点),如普通元素、函数组件、普通组件、keep alive 路由组件等等。从而帮助render 的时,可以根据不同 ShapeFlag 的枚举值来进行不同的 patch 操作。

// /packages/shared/src/shapeFlags.ts
export const enum ShapeFlags {
  ELEMENT = 1, // 普通元素
  FUNCTIONAL_COMPONENT = 1 << 1, // 函数组件
  STATEFUL_COMPONENT = 1 << 2, // 有状态的组件
  TEXT_CHILDREN = 1 << 3, // 子节点为文本元素
  ARRAY_CHILDREN = 1 << 4, // 子节点为列表元素
  SLOTS_CHILDREN = 1 << 5, // 子节点为插槽
  TELEPORT = 1 << 6, // teleport组件
  SUSPENSE = 1 << 7, // suspense组件
  COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8, // 可以被keep alive的组件
  COMPONENT_KEPT_ALIVE = 1 << 9, // 已经被keep alive的组件
  COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT // 组件
}

ShapeFlags的具体使用,我们一样放到后面会结合具体代码来讲。

processElement

processElement用来处理元素节点,逻辑很简单:如果不存在旧节点,则直接挂载新节点;如果新旧节点都存在,则进行patch操作。

// /packages/runtime-core/src/renderer.ts
const processElement = (
    n1: VNode | null,
    n2: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    slotScopeIds: string[] | null,
    optimized: boolean
  ) => {
    isSVG = isSVG || (n2.type as string) === 'svg'
    if (n1 == null) { // 没有旧节点,直接挂载新节点
      mountElement(
        n2,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
    } else { // 新旧节点都存在,进行patch操作
      patchElement(
        n1,
        n2,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
    }
  }

patchElement

patchElement会更新元素的子节点,以及其本身的props、class、style等等

// /packages/runtime-core/src/renderer.ts
const patchElement = (
  n1: VNode,
  n2: VNode,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  const el = (n2.el = n1.el!);
  let { patchFlag, dynamicChildren, dirs } = n2;
  // 如果n2没有patchFlag,则设置成FULL_PROPS
  patchFlag |= n1.patchFlag & PatchFlags.FULL_PROPS;
  const oldProps = n1.props || EMPTY_OBJ;
  const newProps = n2.props || EMPTY_OBJ;
  let vnodeHook: VNodeHook | undefined | null;

  // 触发VNode的onVnodeBeforeUpdate钩子
  if ((vnodeHook = newProps.onVnodeBeforeUpdate)) {
    invokeVNodeHook(vnodeHook, parentComponent, n2, n1);
  }

  // 触发指令的beforeUpdate钩子
  if (dirs) {
    invokeDirectiveHook(n2, n1, parentComponent, 'beforeUpdate');
  }

  // 开发模式而且是热更新时,做全量 diff
  if (__DEV__ && isHmrUpdating) {
    patchFlag = 0;
    optimized = false;
    dynamicChildren = null;
  }

  const areChildrenSVG = isSVG && n2.type !== 'foreignObject';
  if (dynamicChildren) {
    // 靶向更新,只更新动态子节点
    patchBlockChildren(
      n1.dynamicChildren!,
      dynamicChildren,
      el,
      parentComponent,
      parentSuspense,
      areChildrenSVG,
      slotScopeIds
    );
    if (__DEV__ && parentComponent && parentComponent.type.__hmrId) {
      traverseStaticChildren(n1, n2);
    }
  } else if (!optimized) {
    // 没有动态子节点,做全量 diff
    patchChildren(
      n1,
      n2,
      el,
      null,
      parentComponent,
      parentSuspense,
      areChildrenSVG,
      slotScopeIds,
      false
    );
  }

  // 对节点属性做patch
  if (patchFlag > 0) {
    if (patchFlag & PatchFlags.FULL_PROPS) {
      // FULL_PROPS表示属性包含动态变化的属性key,即属性名本身就是动态的,e.g. :[foo]="bar"
      // 由于属性名本身具有动态不确定性,无法保证新旧属性的唯一对应关系,因此需要挂载新属性,同时卸载无效的旧属性
      // 因此只能对新旧属性做全量diff来保证属性更新的准确性,无法做到属性靶向更新
      patchProps(
        el,
        n2,
        oldProps,
        newProps,
        parentComponent,
        parentSuspense,
        isSVG
      );
    } else {
      // 动态class 绑定
      if (patchFlag & PatchFlags.CLASS) {
        if (oldProps.class !== newProps.class) {
          hostPatchProp(el, 'class', null, newProps.class, isSVG);
        }
      }

      // 动态style 绑定
      if (patchFlag & PatchFlags.STYLE) {
        hostPatchProp(el, 'style', oldProps.style, newProps.style, isSVG);
      }

      // PROPS表示除了动态class、style以外的常规动态属性,这些属性在编译阶段被收集到dynamicProps中
      // 在运行时只需要对dynamicProps中记录的属性进行靶向更新即可
      if (patchFlag & PatchFlags.PROPS) {
        // if the flag is present then dynamicProps must be non-null
        const propsToUpdate = n2.dynamicProps!;
        for (let i = 0; i < propsToUpdate.length; i++) {
          const key = propsToUpdate[i];
          const prev = oldProps[key];
          const next = newProps[key];
          // #1471 force patch value
          if (next !== prev || key === 'value') {
            hostPatchProp(
              el,
              key,
              prev,
              next,
              isSVG,
              n1.children as VNode[],
              parentComponent,
              parentSuspense,
              unmountChildren
            );
          }
        }
      }
    }

    // 动态的文本节点
    if (patchFlag & PatchFlags.TEXT) {
      if (n1.children !== n2.children) {
        hostSetElementText(el, n2.children as string);
      }
    }
  } else if (!optimized && dynamicChildren == null) {
    // 不做优化,而且没有动态子节点时, 做全量的 diff
    patchProps(
      el,
      n2,
      oldProps,
      newProps,
      parentComponent,
      parentSuspense,
      isSVG
    );
  }

  if ((vnodeHook = newProps.onVnodeUpdated) || dirs) {
    // 补丁渲染完成后触发生命周期钩子(nextTick中触发)
    queuePostRenderEffect(() => {
      vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, n2, n1);
      dirs && invokeDirectiveHook(n2, n1, parentComponent, 'updated');
    }, parentSuspense);
  }
};

整个函数的主要处理流程:

  • 判断新的VNode是否具有动态子节点:有,则只对动态子节点做patch;没有,则做全量的diff
  • 当patch标记存在,则根据patchFlag对节点属性做不同的patch
    • patchFlag 为 FULL_PROPS 时,元素中包含了动态的属性 key ,需要进行全量的 props diff。
    • patchFlag 为 CLASS 时,且新旧节点的 class 不一致时,会对 class 进行 patch。
    • patchFlag 为 STYLE 时,会对 style 进行更新。
    • patchFlag 为 PROPS 时,元素拥有动态的props 或者 attrs,将新节点的动态属性提取出来,并遍历这个这个属性中所有的 key,当新旧属性不一致,或者该 key 需要强制更新时,则调用 hostPatchProp 对属性进行更新。
    • patchFlag 为 TEXT 时,则表示元素的子节点是文本,如果新旧节点中的文本不一致,则调用 hostSetElementText 直接更新。
  • 当patch不存在,同时不存在优化标记,且动态子节点也不存在,则直接对 props 进行全量 diff

patchBlockChildren

<div>
  <span class="title">This is article list</span>
  <ul>
    <li>第一个静态的li</li>
    <li v-for="article in article_list" :key="article.id"> {{ article.title }}</li>
  </ul>
</div>

article_list数据内容:

article_list = [{
  id: 1,
  title: '第一篇文章'
}]

当article_list发生变化时,按vue2的patch过程,需要对全部节点做diff操作,包括span和第一个li。而这两个元素是静态,是不可能发生变化的。 因此在vue3里,会将动态子节点 (Block) 提取出来,只对动态子节点做更新,从而提高性能。

const result = {
  type: Symbol(Fragment),
  patchFlag: 64,
  children: [
    { type: 'span', patchFlag: -1, ...},
    {
      type: 'ul',
      patchFlag: 0,
      children: [
        { type: 'li', patchFlag: -1, ...},
        {
          type: Symbol(Fragment),
          children: [
            { type: 'li', patchFlag: 1 ...},
            { type: 'li', patchFlag: 1 ...}
          ]
        }
      ]
    }
  ],
  dynamicChildren: [
    {
      type: Symbol(Fragment),
      patchFlag: 128,
      children: [
        { type: 'li', patchFlag: 1 ...},
        { type: 'li', patchFlag: 1 ...}
      ]
    }
  ]
}

注意:dynamicChildren不仅收集直接动态子节点,还收集所有子代节点中的动态节点

我们来看一下patchBlockChildren的实现:

// /packages/runtime-core/src/renderer.ts
const patchBlockChildren: PatchBlockChildrenFn = (
  oldChildren,
  newChildren,
  fallbackContainer,
  parentComponent,
  parentSuspense,
  isSVG,
  slotScopeIds
) => {
  for (let i = 0; i < newChildren.length; i++) {
    const oldVNode = oldChildren[i];
    const newVNode = newChildren[i];
    // 在 patch 过程中,有几种情况是需要提供节点的真实父容器才能准确patch:
    // 1. Fragment类型: Fragment非真实容器,其子节点还是依赖其外层的真实父容器
    // 2. 新旧VNode非同一类型节点: 替换节点依赖于真实父容器
    // 3. 组件VNode或者teleport组件VNode: 组件中有可能是任何内容,因此需要真实父容器
    const container =
      oldVNode.el &&
      (oldVNode.type === Fragment ||
        !isSameVNodeType(oldVNode, newVNode) ||
        oldVNode.shapeFlag & (ShapeFlags.COMPONENT | ShapeFlags.TELEPORT))
        ? hostParentNode(oldVNode.el)!
        : fallbackContainer;
    patch(
      oldVNode,
      newVNode,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      true
    );
  }
};

patchChildren

前面我们已经介绍了patch过程的各种优化,但是无论如何,我们始终会有需要做全量Diff的场景,这个时候针对Diff算法的优化,就对Vue3性能的提升显得至关重要。

我们之前在讲Vue2的Diff算法的时候提到,key是作为判断是否是同一类型节点的判断因素之一,在Vue3中也是一样,作为VNode的唯一标识符,Vue3将节点key的情况分成两种:

  • 全部未标记key属性的子节点序列
  • 部分或者全部都标记了key属性的子节点序列

我们先来看一下patchChildren的实现:

const patchChildren: PatchChildrenFn = (
  n1,
  n2,
  container,
  anchor,
  parentComponent,
  parentSuspense,
  isSVG,
  slotScopeIds,
  optimized = false
) => {
  const c1 = n1 && n1.children;
  const prevShapeFlag = n1 ? n1.shapeFlag : 0;
  const c2 = n2.children;

  const { patchFlag, shapeFlag } = n2;
  if (patchFlag > 0) {
    if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
      // 部分或者全部都标记了key属性的子节点序列
      patchKeyedChildren(
        c1 as VNode[],
        c2 as VNodeArrayChildren,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      );
      return;
    } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
      // 全部未标记key属性的子节点序列
      patchUnkeyedChildren(
        c1 as VNode[],
        c2 as VNodeArrayChildren,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      );
      return;
    }
  }

  // children有三种可能:
  // 1. 文本节点
  // 2. 数组节点
  // 3. 空
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 当新的子节点是文本子节点时
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // 如果旧的子节点是数组,先把旧的子节点全部卸载
      unmountChildren(c1 as VNode[], parentComponent, parentSuspense);
    }
    if (c2 !== c1) {
      // 将新的文本子节点直接更新
      hostSetElementText(container, c2 as string);
    }
  } else {
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // 当旧的子节点是数组时
      if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // 如果新的子节点也是数组, 直接做全量的diff
        patchKeyedChildren(
          c1 as VNode[],
          c2 as VNodeArrayChildren,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        );
      } else {
        // 如果新的子节点是空,则直接卸载旧的子节点
        unmountChildren(c1 as VNode[], parentComponent, parentSuspense, true);
      }
    } else {
      // 当旧的子节点是文本节点时,直接置空
      if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
        hostSetElementText(container, '');
      }
      // 当新的子节点是数组时,直接挂载
      if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        mountChildren(
          c2 as VNodeArrayChildren,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        );
      }
    }
  }
};

patchUnkeyedChildren

全部没有key属性标记的子节点diff很简单,直接按顺序对子节点进行patch,对于多余的节点进行挂载 或卸载操作

const patchUnkeyedChildren = (
  c1: VNode[],
  c2: VNodeArrayChildren,
  container: RendererElement,
  anchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  c1 = c1 || EMPTY_ARR;
  c2 = c2 || EMPTY_ARR;
  const oldLength = c1.length;
  const newLength = c2.length;
  const commonLength = Math.min(oldLength, newLength);
  let i;
  // 在新旧两个子节点的公共部分,按顺序进行patch
  for (i = 0; i < commonLength; i++) {
    const nextChild = (c2[i] = optimized
      ? cloneIfMounted(c2[i] as VNode)
      : normalizeVNode(c2[i]));
    patch(
      c1[i],
      nextChild,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    );
  }
  if (oldLength > newLength) {
    // 如果旧的子节点多,则卸载多余的旧的子节点
    unmountChildren(
      c1,
      parentComponent,
      parentSuspense,
      true,
      false,
      commonLength
    );
  } else {
    // 如果新的子节点多,则挂载多余的新的子节点
    mountChildren(
      c2,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized,
      commonLength
    );
  }
};

patchKeyedChildren

现在我们终于迎来最核心的部分,Vue3在Diff算法上有了本质的提升,还记得Vue2的Diff算法吗?不记得的话,可以往前回顾一下!

Vue3的Diff算法由预处理、贪心算法+二分查找、回溯几个部分组成,同时也引入的最长递增子序列的概念。 由于patchKeyedChildren函数比较庞大,我们在讲解每个部分时,只关注与其相关的代码。

预处理

  • 从新旧子节点的首部按顺序向后遍历比较 (isSameVNodeType) ,如果是同一类型节点,则进行patch操作,如果不是,则立刻终止遍历
  • 从新旧子节点的尾部按顺序向前遍历比较 (isSameVNodeType) ,如果是同一类型节点,则进行patch操作,如果不是,则立刻终止遍历

我们来看一下相关代码:

const patchKeyedChildren = (
  c1: VNode[],
  c2: VNodeArrayChildren,
  container: RendererElement,
  parentAnchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  let i = 0;
  const l2 = c2.length;
  let e1 = c1.length - 1; // 旧的子节点尾部索引
  let e2 = l2 - 1; // 新的子节点尾部索引

  // 从头部向尾部遍历,遍历完新旧子节点中的任何一个后则终止遍历
  // 遍历过程中,如果遇到同一类型节点(type和key都相等),则直接进行patch操作,否则立刻跳出遍历
  // 此时,i记录的是跳出遍历是时,子序列的索引
  while (i <= e1 && i <= e2) {
    const n1 = c1[i];
    const n2 = (c2[i] = optimized
      ? cloneIfMounted(c2[i] as VNode)
      : normalizeVNode(c2[i]));
    if (isSameVNodeType(n1, n2)) {
      patch(
        n1,
        n2,
        container,
        null,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      );
    } else {
      break;
    }
    i++;
  }

  // 从尾部向头部遍历,只要e1和e2中有一个遇到i,则遍历停止
  // 遍历过程中,如果遇到同一类型节点(type和key都相等),则直接进行patch操作,否则立刻跳出遍历
  // 此时,e1和e2记录了新旧子节点最新的尾部索引位置
  while (i <= e1 && i <= e2) {
    const n1 = c1[e1];
    const n2 = (c2[e2] = optimized
      ? cloneIfMounted(c2[e2] as VNode)
      : normalizeVNode(c2[e2]));
    if (isSameVNodeType(n1, n2)) {
      patch(
        n1,
        n2,
        container,
        null,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      );
    } else {
      break;
    }
    e1--;
    e2--;
  }

  // 暂时忽略无关代码
};

我们通过图片来加深一下了解:

前置预处理

后置预处理

首尾相遇处理

前后置预处理时,可能出现新旧子节点首尾相遇的情况,比如 i>e1 或者i>e2,这就以为有旧子节点需要卸载或者有新子节点需要挂载,我们看一下代码如何处理:

const patchKeyedChildren = (
  c1: VNode[],
  c2: VNodeArrayChildren,
  container: RendererElement,
  parentAnchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  let i = 0;
  const l2 = c2.length;
  let e1 = c1.length - 1; // 旧的子节点尾部索引
  let e2 = l2 - 1; // 新的子节点尾部索引

  // 前置预处理

  // 后置预处理

  // 旧的子节点首尾指针相撞,新的子节点首尾指针未相撞,表示新的子节点首尾指针间
  // 的节点是新增节点,需要挂载它们
  // (a b)
  // (a b) c
  // i = 2, e1 = 1, e2 = 2
  // (a b)
  // c (a b)
  // i = 0, e1 = -1, e2 = 0
  if (i > e1) {
    if (i <= e2) {
      const nextPos = e2 + 1;
      const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor;
      while (i <= e2) {
        patch(
          null,
          (c2[i] = optimized
            ? cloneIfMounted(c2[i] as VNode)
            : normalizeVNode(c2[i])),
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        );
        i++;
      }
    }
  }

  // 旧的子节点首尾指针未相撞,新的子节点首尾指针相撞,表示旧的子节点首尾指针间
  // 的节点是多余节点,需要卸载它们
  // (a b) c
  // (a b)
  // i = 2, e1 = 2, e2 = 1
  // a (b c)
  // (b c)
  // i = 0, e1 = 0, e2 = -1
  else if (i > e2) {
    while (i <= e1) {
      unmount(c1[i], parentComponent, parentSuspense, true);
      i++;
    }
  } else {
    // 存在未知序列需要处理
  }
};

最长递增子序列

做完预处理后,剩下的是未知的子节点序列。那如何处理这部分节点呢?? 我们先来了解一个新概念: 最长递增子序列

维基百科:最长递增子序列(longest increasing subsequence)是指,在一个给定的数值序列中,找到一个子序列,使得这个子序列元素的数值依次递增,并且这个子序列的长度尽可能地大。最长递增子序列中的元素在原序列中不一定是连续的。

const array = [10, 9, 2, 5, 3, 7, 101, 18]
// array的最长递增子序列是 [2, 5, 7, 101]、[2, 5, 7, 18]、[2, 3, 7, 101]、[2, 3, 7, 18],长度是4

那如何能在一个数值序列里,找出最长递增子序列呢?Vue3里采用的是贪心算法+二分查找

贪心算法:也叫做贪婪算法,在每一步做选择时,总是选择当前最优的方法。

我们来看一个例子:

贪心算法的规则是:如果当前数值大于已选结果的最后一位,则直接往后新增,若当前数值更小,则直接替换前面第一个大于它的数值

我们分解一下整个过程:

  1. 初始化时,将数组序列的第一个值10,放到result数组中,此时result是**[10]**
  2. 第一次遍历,数值序列的第二个值是9,比result的最后一个值 (10) 小,按规则,用9替换10,此时result是 [9]
  3. 第二次遍历,数值序列的第三个值是2,比result的最后一个值 (9) 小,按规则,用2替换9,此时result是 [2]
  4. 第三次遍历,数值序列的第四个值是5,比result的最后一个值 (2) 大,按规则,直接放入result,此时result是 [2, 5]
  5. 第四次遍历,数值序列的第五个值是3,比result的最后一个值 (5) 小,按规则,需要替换result第一个大于它的值,因此用3替换5,此时result是 [2, 3]
  6. 第五次遍历,数值序列的第六个值是7,比result的最后一个值 (3) 大,按规则,直接放入result,此时result是 [2, 3, 7]
  7. 第六次遍历,数值序列的第七个值是101,比result的最后一个值 (7) 大,按规则,直接放入result,此时result是 [2, 3, 7, 101]
  8. 第七次遍历,数值序列的第八个值是18,比result的最后一个值 (101) 小,按规则,需要替换result第一个大于它的值,因此用18替换101,此时result是 [2, 3, 7, 18]

但是该算法最终的结果未必是我们想要的,我们来看一个例子:

为什么会有这个问题呢?那是因为我们在过程中,只追求了每次遍历的最优解,而没有考虑到全局最优解。

接下去,我们结合Vue3的代码来讲解Diff的过程,以及它是如何解决这个问题的。

如上图,我们可以看到,经过首尾预处理后:

  • i=2,e1=5,e2=5
  • 节点F需要卸载,节点I需要新挂载

为新的子节点序列,创建一个存储key=>index关系的Map

这一步主要是为方便后面旧vnode可以通过key快速匹配到相同key的新vnode,并进行patch

// 存在未知序列需要处理
const s1 = i; // 旧的子节点序列头部指针
const s2 = i; // 新的子节点序列头部指针

// 遍历新的子节点序列,记录节点的key=>index键值对
const keyToNewIndexMap: Map<string | number | symbol, number> = new Map();
for (i = s2; i <= e2; i++) {
  const nextChild = (c2[i] = optimized
    ? cloneIfMounted(c2[i] as VNode)
    : normalizeVNode(c2[i]));
  if (nextChild.key != null) {
    // 忽略调试模式代码
    keyToNewIndexMap.set(nextChild.key, i);
  }
}

此时的keyToNewIndexMap存储的数据是:

D => 2

E => 3

C => 4

I => 5

从头部遍历旧的子节点序列,通过key去判断旧VNode在新的子节点序列中是否存在。是,对节点进行patch; 否, 则卸载节点

let j;
let patched = 0; // 记录新序列中已经patch的节点个数
const toBePatched = e2 - s2 + 1; // 新序列中全部需要patch的节点总数
let moved = false; // 记录新VNode相对于旧VNode是否发生了位置移动
// 按顺序推进旧序列指针进行新旧节点patch时,记录旧节点对应新节点index
// 的峰值,如果相邻两个旧节点对应的新节点的相对位置不变,那么newIndex
// 应该是保持递增的,否则一旦newIndex变小,就说明相邻两旧节点对应的新
// 节点的相对位置发生了变化,那肯定发生了节点的移位操作
let maxNewIndexSoFar = 0;
// 用数组记录新旧节点index对应关系
// 用数组记录是为了后面创建最长递增子序列,从而用最少的次数把生需要移动的节点放到正确的位置
// 0 表示新节点没有相对应的旧节点,为了将旧节点index = 0和表示没有对应节点的 0 进行区分,因此对应的旧节点index均 +1
const newIndexToOldIndexMap = new Array(toBePatched);
// 初始化数组,每个子元素都是0
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0;

// 开始遍历老节点
for (i = s1; i <= e1; i++) {
  const prevChild = c1[i];
  if (patched >= toBePatched) {
    // 当旧节点找到对应的新节点时,会执行patch,同时patched计数加1
    // 当patched >= toBePatched时,说明新的子节点序列已经全部patch完毕
    // 剩余的旧节点直接卸载
    unmount(prevChild, parentComponent, parentSuspense, true);
    continue;
  }
  let newIndex;
  if (prevChild.key != null) {
    // 如果旧节点带有key,则通过key找到拥有相同key的新节点的index
    newIndex = keyToNewIndexMap.get(prevChild.key);
  } else {
    // 如果旧节点没有key,则在新的子节点序列里找出一个同样不带key的相似的新节点(type相等)
    for (j = s2; j <= e2; j++) {
      if (
        newIndexToOldIndexMap[j - s2] === 0 &&
        isSameVNodeType(prevChild, c2[j] as 节点)
      ) {
        newIndex = j;
        break;
      }
    }
  }
  if (newIndex === undefined) {
    // newIndex等于undefined,说明未找到与旧节点对应的新节点,则直接卸载旧节点
    unmount(prevChild, parentComponent, parentSuspense, true);
  } else {
    // 把老节点的索引,记录在存放新节点的数组中(加1)
    newIndexToOldIndexMap[newIndex - s2] = i + 1;
    // maxNewIndexSoFar记录与当前旧节点对应新节点的index最大值
    // newIndex递增,说明相邻旧节点对应的新节点相对位置没变化,无需移动节点
    // 一旦maxNewIndexSoFar变小,说明相邻节点相对位置发生变化,新的子节点序列一定发生移动行为
    if (newIndex >= maxNewIndexSoFar) {
      maxNewIndexSoFar = newIndex;
    } else {
      moved = true;
    }
    // patch 新旧节点
    patch(
      prevChild,
      c2[newIndex] as 节点,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    );
    // patched计数 +1
    patched++;
  }
}

此时newIndexToOldIndexMap的存储的新旧节点index的对应关系为:

  • I 是新增节点,所以是0
  • F 节点在新的子节点序列中未找到,所以直接卸载
  • C 在旧的子节点序列的index是2,加1后成3(D,E类似)

挂载新增的节点和将其他节点移动到正确的位置

在这个环节,Vue3通过最长递增子序列在newIndexToOldIndexMap中找到尽可能多的index递增的旧节点。因为我们是参考旧节点之间的顺序来对新节点进行移动,因此找到最多位置不便的旧节点,那新节点需要移动次数也就最少。

我们套用之前介绍的算法,来求得newIndexToOldIndexMap最长递增子序列试试。 是不是感觉哪里不对?求出来的结果是 [0, 3] ,按我们的设想应该是 [4, 5] 才对,因为D和E才是位置没有发生变化的两个节点。

我们来看一下最长递增子序列的算法:

function getSequence(arr: number[]): number[] {
  // 遍历 arr 的时候,每操作一次 result 进行 push 或者替换值时
  // 都把 result 被操作索引的前一项放到 p
  // 最后进行回溯时,通过最后一项,就可以通过p这个数组找到它的前一项
  const p = arr.slice();
  const result = [0]; // 记录最长增长子序列的索引的数组
  /**
   * 假设arr是 [2,4,5,3],此时p也是 [2,4,5,3]
   * result是 [0]
   */
  let i, j, u, v, c;
  const len = arr.length;
  for (i = 0; i < len; i++) {
    const arrI = arr[i];
    // 0 是特殊占位符,表示新增节点,不参与计算
    if (arrI !== 0) {
      // j 是子序列索引最后一项,将 j 在原数据 arr 对应的值和当前值 arrI 做比较
      // 如果 arrI 大,将 arrI 在 p 中的对应位置的值设置成 result 数组的最后一项
      //(通过 push 进去的这一项(索引 i) 在 p 中对应的位置存就是它的前一项)
      // 并且将arrI 对应的索引 i push 到 result
      j = result[result.length - 1];
      if (arr[j] < arrI) {
        p[i] = j;
        result.push(i);
        continue;
      }
      u = 0;
      v = result.length - 1;
      // 如果 arrI 小,通过二分查找,在result中找到第一个大于它的值,并进行替换
      while (u < v) {
        c = (u + v) >> 1;
        if (arr[result[c]] < arrI) {
          u = c + 1;
        } else {
          v = c;
        }
      }
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          // 逻辑和上面的 p[i] = j 一样 都是记录前一项
          p[i] = result[u - 1];
        }
        result[u] = i;
      }
    }
  }
  /**
   * 第一次遍历
   * p = [2,4,5,3]  result = [0]
   * 第二次遍历
   * p = [2,0,5,3]  result = [0,1]
   * 第三次遍历
   * p = [2,0,1,3]  result = [0,1,2]
   * 第四次遍历
   * p = [2,0,1,0]  result = [0,3,2]
   */
  u = result.length;
  v = result[u - 1];
  /**
   * u = 3, v = 2
   */
  // 进行回溯修补
  while (u-- > 0) {
    result[u] = v;
    // 最后一项在p中记录了它的前一项 所以取出前一项放在result
    v = p[v];
    /** 第一次遍历
     * result = [0,3,2]   v = 1
     * 第二次遍历
     * result = [0,1,2]   v = 0
     * 第三次遍历
     * result = [0,1,2]   v = 2
     */
  }
  return result;
}

这里需要注意一点,getSequence获取的是最长递增子序列的索引的数组

我们在上一步获得的newIndexToOldIndexMap的数据是 [4, 5, 3, 0] 我们按步骤来拆解真个过程:

先做遍历 这个时候的结果,明显并不是我们想要的。 D和E的节点的位置没有发生变化,所以result应该是[0, 1]才符合预期

回溯 我们通过回溯来进行修正

我们最终获得最长递增子序列的索引是 [0, 1]

最后我们来看一下通过这个索引来如何挂载和移动节点:

// 获取newIndexToOldIndexMap最长递增子序列的索引
const increasingNewIndexSequence = moved
  ? getSequence(newIndexToOldIndexMap)
  : EMPTY_ARR;
j = increasingNewIndexSequence.length - 1;
// 从新序列尾部向前遍历是为了将后面patch完的节点作为dom操作的定位锚点
for (i = toBePatched - 1; i >= 0; i--) {
  const nextIndex = s2 + i;
  const nextChild = c2[nextIndex] as VNode;
  // 获取定位锚点,以后面一个节点的dom元素为锚点,如果已经是末尾节点,那么
  // 锚点就是外部传入的锚点
  const anchor =
    nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor;
  if (newIndexToOldIndexMap[i] === 0) {
    // 如果索引对应的值是0,则说明是新节点,需要挂载
    patch(
      null,
      nextChild,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    );
  } else if (moved) {
    // 何时移动节点:
    // 1. 节点序列反序 (无最长稳定子序列)
    // 2. 当前新节点index和当前稳定子序列index不相同,说明是相对位置发生变化的节点
    if (j < 0 || i !== increasingNewIndexSequence[j]) {
      // 以定位锚点为插入位置进行新节点的dom移动
      move(nextChild, container, anchor, MoveType.REORDER);
    } else {
      // index相同说明当前新节点无需移动位置,因为最长稳定子序列中的index表示
      // 该index对应新节点未发生相对位置变化
      j--;
    }
  }
}

第一次遍历

  • nextIndex是5,对应节点 I
  • 以 H 作为锚点
  • newIndexToOldIndexMap[3]是0,说明 I 是新增节点,需要挂载

第二次遍历

  • nextIndex是4,对应节点 C
  • 以 I 作为锚点
  • i 是2,不在最长子序列的索引数组[0, 1],说明需要移动
  • 以 I 为锚点,移动到 I 之前

第三、四次遍历

  • 第三和第四次遍历时,i 分别是 1 和 0,都在最长子序列的索引数组[0, 1]中,不需要移动

总结

至此,Vue3的虚拟Dom的Diff算法整理完毕,整个逻辑比较复杂,我也是花了2天时间,才算是大致理清。其中肯定会有理解不透彻的地方,还望大家留言指正。