深入虚拟Dom和Vue2 Diff算法

1,679 阅读6分钟

本文主要从源码层深入分析Vue2中的Diff规则。不过在这之前还是需要回顾下虚拟Dom。

虚拟Dom概念

虚拟DOM(Virtual DOM)是对DOM的JS抽象表示,它们是JS对象,能够描述DOM结构和关系。应用

的各种状态变化会作用于虚拟DOM,最终映射到DOM上。

这里我画了一个简单的描述Vue中虚拟Dom和真实Dom,以及响应式数据之间的关系。我们通过JS操作响应式数据驱动虚拟Dom,虚拟Dom在patch方法下生成对应的真实Dom。而在Vue中抽象出虚拟Dom除了更高效的执行更新之外 ,而且patch这一层也可以根据不同的平台去做一些兼容性和跨平台的处理。

Vue中就是通过新旧虚拟DOM比对可以得到最小DOM操作量,配合异步更新策略减少刷新频率,从而提升性能。

patch(vnode, h('div', obj.foo))
  • Vue 1中有细粒度的数据变化侦测,它是不需要虚拟DOM的,但是细粒度造成了大量开销。下面不再用代号区分,本文中的Vue都指Vue2版本。
  • Vue中的虚拟Dom是基于snabbdom来实现的。

Patch

接下来就跟踪一下Dom/组件更新的时候,Vue是如何操作的。对Vue源码目录和结构还不熟悉的可以关注我的另一篇文章《阅读Vue源码的准备工作》。

1.patch被调用

找到组件更新的生命周期函数,找到patch调用的地方。目录是src>core>instance>lifecycle.js,在Vue.prototype._update中可以看到这段代码

    const vm: Component = this
    const prevEl = vm.$el
    const prevVnode = vm._vnode
    const restoreActiveInstance = setActiveInstance(vm)
    vm._vnode = vnode
    // Vue.prototype.__patch__ is injected in entry points
    // based on the rendering backend used.
    if (!prevVnode) {
      // initial render
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
      // updates
      vm.$el = vm.__patch__(prevVnode, vnode)
    }

vm.__patch__就是打补丁的过程,通过vm.__patch__获取真实的Dom节点。我们继续深入,看下这个__patch__方法的出处。之前我们说过__patch__中会根据平台做不一样的处理。我们不难找到在src\platforms\web\runtime中的index.js中,Vue的原型被加上了补丁方法。

import { patch } from './patch'
...
// install platform patch function
Vue.prototype.__patch__ = inBrowser ? patch : noop

同时这里也有一个平台的判断,如果处于浏览器环境就是patch。

2.patch方法被创建

直接进入到同级目录下的patch.js中,我们就找到了一个工厂函数createPatchFunction。

import * as nodeOps from 'web/runtime/node-ops'
import { createPatchFunction } from 'core/vdom/patch'
import baseModules from 'core/vdom/modules/index'
import platformModules from 'web/runtime/modules/index'

// the directive module should be applied last, after all
// built-in modules have been applied.
const modules = platformModules.concat(baseModules)

export const patch: Function = createPatchFunction({ nodeOps, modules })

这里很容易理解createPatchFunction的作用就是,传入平台特有的节点操作和属性操作,得到一个平台专属的patch方法。有兴趣的可以去对应的平台下面的runtime文件夹里面去看下节点操作,这里就不展开描述了(removeChild,removeChild,insertBefore,nextSibling……)

3.找到createPatchFunction定义的地方。

src\core\vdom\patch.js,这个文件有800多行,核心4个方法。sameVnode,sameInputType,createKeyToOldIdx和createPatchFunction。

最终我们根据平台的节点操作得到的path方法就在createPatchFunction中返回的,大概在第700行。

return function patch (oldVnode, vnode, hydrating, removeOnly) {}

4.patch的实现

进入patch函数,我们看下参数oldVnode和vnode分别代表新老节点。下面我分别用简单的图例(后面补充)和源码片段与场景对应起来,更容易理解。关键位置我已添加注释。

其中exp1和exp2的vnode的判断在大部分是不会走的,我们写vue的代码不会这么去写this.patch(oldVnode, vnode)……,所以平时我们写new Vue()的时候,走的是exp3的地方。

exp3的位置执行了isRealElement的判断,如果是真实节点就走的是初始化的流程。这里因为是不属于dom diff的流程所以不做深入分析。直接进入到 patchVnode。这里开始就是当新老节点都存在的时候,进行的patch操作。

  // exp1:新节点不存在,说明要执行删除节点操作。
   if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode);
      return;
    }
 // exp2:老节点不存在,说明要执行创建节点操作。
  if (isUndef(oldVnode)) {
      // empty mount (likely as component), create new root element
      isInitialPatch = true;
      createElm(vnode, insertedVnodeQueue);
    } else {
      // exp3:平时框架走这里
      const isRealElement = isDef(oldVnode.nodeType);
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // patch existing root node
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly);
      } else {
        // 初始化流程 真实Dom节点
        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."
              );
            }
          }
          // 将真实Dom转化为Vnode
          oldVnode = emptyNodeAt(oldVnode);
        }

        // replacing existing element
        const oldElm = oldVnode.elm;
        const parentElm = nodeOps.parentNode(oldElm);
        
        // create new node
        // 创建整颗树,将它追加到body里面,parentElm的旁边
        createElm(
          vnode,
          insertedVnodeQueue,
          oldElm._leaveCb ? null : parentElm,
          nodeOps.nextSibling(oldElm)
        );
         // update parent placeholder node element, recursively
        if (isDef(vnode.parent)) {
          let ancestor = vnode.parent;
          const patchable = isPatchable(vnode);
          while (ancestor) {
            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);
              }
              // #6513
              // invoke insert hooks that may have been merged by create hooks.
              // e.g. for directives that uses the "inserted" hook.
              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;
          }
        }

        // destroy old node
        if (isDef(parentElm)) {
          // 删掉原来的模板内容
          removeVnodes([oldVnode], 0, 0);
        } else if (isDef(oldVnode.tag)) {
          invokeDestroyHook(oldVnode);
        }
      }
    }

\

5.patchVnode(树级别diff)

patchVnode方法的定义在patch.js中。这里就不把整段源码贴出来了,节省篇幅。我们直接把patchVnode方法中比较关键的片段提出来分析。跳过前面一些关于缓存优化的代码,我们专注于diff的部分。

  function patchVnode(
    oldVnode,
    vnode,
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly
  ) { ... }

patchVnode的作用就是分析当前的2个节点(oldVnode和vnode)的类型。

  • 如果是元素,更新双方的属性和特性。同时比较双方的子元素,这个递归的过程叫做深度优先。
  • 如果双方是文本,则直接更新文本

先获取双方的孩子,下面的双方都是指oldVnode和newVnode。exp1位置,比较双方属性的代码在vue2中还是比较简单粗暴的,不管需不需要都会走一遍更新。Vue3中在这块做了很大的改进。

    const oldCh = oldVnode.children;
    const ch = vnode.children;
		// exp1:比较双方属性
    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);
    }

接下来就到了diff的第一层 树节点级的处理,紧接上面的if。

  // 分情况处理 根据新老节点的类型
    if (isUndef(vnode.text)) {
      if (isDef(oldCh) && isDef(ch)) {
        // exp1:双方都有子元素 重排
        if (oldCh !== ch)
          updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly);
      } else if (isDef(ch)) {
        // exp2:新节点有子节点 老的没有 则批量创建
        if (process.env.NODE_ENV !== "production") {
          checkDuplicateKeys(ch);
        }
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, "");
        // 批量创建
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
      } else if (isDef(oldCh)) {
        // exp3:老节点有子节点 新的没有 则批量删除
        removeVnodes(oldCh, 0, oldCh.length - 1);
      } else if (isDef(oldVnode.text)) {
        // exp4:老节点有文本 新节点没有文本 则把节点文本清空
        nodeOps.setTextContent(elm, "");
      }
    } else if (oldVnode.text !== vnode.text) {
      // exp5:都有文本 直接更新文本
      nodeOps.setTextContent(elm, vnode.text);
    }

这里我画了一个简单的示意图,可以对着exp1-exp4去理解。

  • 如果老节点不存在子节点,但是新节点有,说明需要批量这些新出现的节点。
  • 如果老节点存在子节点,新节点没有,则代表着需要批量删除这些节点。
  • 如果双方都有子节点 ,则执行重排操作,updateChildren()。即图中蓝色线框和橙色线框部分。从updateChildren()开始就进入到子节点的diff流程。

6.updateChildren(节点级diff)

到了节点级的diff,就属于高频次执行的算法了。所以这里必须用一种高效的方式去比较和打补丁。在这里,Vue还针对web平台做了特殊处理。

先用一些图来说明一下这个diff的规则。

  • 在新老两组VNode节点的左右头尾两侧都有一个变量标记,在遍历过程中这几个变量都会向中间靠拢。 当oldStartIdx > oldEndIdx或者newStartIdx > newEndIdx时结束循环。
  • 如果oldStartVnode与newEndVnode满足sameVnode。说明oldStartVnode已经跑到了oldEndVnode后面去了,进行patchVnode的同时还需要将真实DOM节点移动到oldEndVnode的后面。
  • 如果oldEndVnode与newStartVnode满足sameVnode,说明oldEndVnode跑到了oldStartVnode的前面,进行patchVnode的同时要将oldEndVnode对应DOM移动到oldStartVnode对应DOM的前面。
  • 如果以上情况均不符合,则在old VNode中找与newStartVnode相同的节点,若存在执行patchVnode,同时将elmToMove移动到oldStartIdx对应的DOM的前面。
  • 当然也有可能newStartVnode在old VNode节点中找不到一致的sameVnode,这个时候会调用createElm创建一个新的DOM节点。

以上判断的位置已经在源码中加上注释,有兴趣的可以仔细研究。

  function updateChildren(
    parentElm,
    oldCh,
    newCh,
    insertedVnodeQueue,
    removeOnly
  ) {
    // 创建4个游标和4个节点
    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;
    if (process.env.NODE_ENV !== "production") {
      checkDuplicateKeys(newCh);
    }

    // 首位游标交叉 循环结束
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      // 校正
      // start为空 ++
      // end为空 --
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx];
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        // 开头游标的vnode相同
        patchVnode(
          oldStartVnode,
          newStartVnode,
          insertedVnodeQueue,
          newCh,
          newStartIdx
        );
        oldStartVnode = oldCh[++oldStartIdx];
        newStartVnode = newCh[++newStartIdx];
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        // 结束游标的vnode相同
        patchVnode(
          oldEndVnode,
          newEndVnode,
          insertedVnodeQueue,
          newCh,
          newEndIdx
        );
        oldEndVnode = oldCh[--oldEndIdx];
        newEndVnode = newCh[--newEndIdx];
      } else if (sameVnode(oldStartVnode, newEndVnode)) {
        // 首尾游标的vnode相同 赋值的同时要移动游标
        // Vnode moved right
        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)) {
        // 尾首游标的vnode相同 赋值的同时要移动游标
        // Vnode moved left
        patchVnode(
          oldEndVnode,
          newStartVnode,
          insertedVnodeQueue,
          newCh,
          newStartIdx
        );
        canMove &&
          nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
        oldEndVnode = oldCh[--oldEndIdx];
        newStartVnode = newCh[++newStartIdx];
      } else {
        // 首尾都没有找到符合sameNode的节点
        // 拿出新数组开头第一个,去老数组中查找
        if (isUndef(oldKeyToIdx))
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
        if (isUndef(idxInOld)) {
          // 如果没找到 就创建
          // New element
          createElm(
            newStartVnode,
            insertedVnodeQueue,
            parentElm,
            oldStartVnode.elm,
            false,
            newCh,
            newStartIdx
          );
        } else {
          // 找到了相同的节点
          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 {
            // same key but different element. treat as new element
            // 如果不是老节点 直接创建新的节点 删掉老的节点
            createElm(
              newStartVnode,
              insertedVnodeQueue,
              parentElm,
              oldStartVnode.elm,
              false,
              newCh,
              newStartIdx
            );
          }
        }
        newStartVnode = newCh[++newStartIdx];
      }
    }
    if (oldStartIdx > oldEndIdx) {
      // 结束 缺少节点 创建
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
      addVnodes(
        parentElm,
        refElm,
        newCh,
        newStartIdx,
        newEndIdx,
        insertedVnodeQueue
      );
    } else if (newStartIdx > newEndIdx) {
      // 结束 存在多余节点 删除
      removeVnodes(oldCh, oldStartIdx, oldEndIdx);
    }
  }

当结束时oldStartIdx > oldEndIdx,这个时候旧的VNode节点已经遍历完了,但是新的节点还没有。说明了新的VNode节点实际上比老的VNode节点多,需要将剩下的VNode对应的DOM插入到真实DOM

中,此时调用addVnodes(批量调用createElm接口)。