Vue patch过程(一)—— diff算法

3,539 阅读5分钟

这是我参与更文挑战的第2天,活动详情查看: 更文挑战

该文章主要是简单介绍Vue patch的整个流程,包括:diff算法以及创建新节点的过程

前言

Vue的虚拟DOM算法是基于Snabbdom库,有较好的速度和模块机制。Vue diff使用双指针,边对比边更新DOM。

而React主要使用diff队列保存需要更新哪些DOM,得到patch树,再统一批量更新DOM。

diff 特点

  1. 只会在同级进行比较,不会跨级比较。
  2. diff过程中,循环从两边向中间靠拢

Vue patch过程

_update

Vue在挂载实例的时候,mountComponent方法中有个重点的函数:_update,该函数是Vue的一个私有实例方法,它的作用是将Vnode渲染成真实的DOM,定义在src/core/instance/lifecycle.js中,它内部主要是调用了vm.__patch__方法 image.png

__patch__

image.png

vm.__patch__则是直接指向Vue.prototype.__patch__,在浏览器环境中,该__path__方法是直接指向patch方法,它的定义在src/platforms/web/runtime/index.js中

patch

这个patch方法的定义是在src/platforms/web/runtime/patch.js中,它写的非常简洁,就是调用了createPatchFunction函数的返回值

image.png

createPatchFunction

createPatchFunction方法是定义在src/core/vnode/patch.js中,整体来看patch.js就只向外暴露出了一个方法createPatchFunction,该方法返回一个patch函数。到此为止,我们才真正走到Vue patch节点的地方。

image.png

接下来让我们具体来看下这个createPatchFunction到底做了什么:

patch函数

Vue在patch节点的时候会先判断新老节点是否是相同类型的节点,如果sameVNode为false,则直接销毁oldVnode,渲染newVnode;否则进入patchVnode方法

image.png

patchVnode

接下来看一下patchVnode方法:

patchVnode接收六个参数:oldVnode,vnode,insertdVnodeQueue,ownerArray,index,removeOnly

patchVnode首先会判断是否是文本节点,如果是直接将文本内容替换就好,如果不是则开始对比children。

如果只有新节点有children,则执行addVnodes,添加新子节点

如果只有旧节点有children,则直接removeVnode,删除旧节点

如果新旧节点都有children,则执行updateChildren方法。

image.png

updateChildren

diff过程的实现主要是在updateChildren函数中。

  1. 虚拟DOM渲染真实DOM时会对新老VNode的开始结束位置进行标记,oldStartIdx,newStartIdx,oldEndIdx,newEndIdx

image.png

  1. 标记好节点后,进入到while循环中,该循环的退出条件是老节点或者新节点的开始位置大于终止位置
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    ...
}
  1. 循环过程中首先对新老节点的首尾节点进行特殊比较

image.png

具体分析来看:

  • 首先当新老节点开始节点满足sameVnode时,直接patchVnode;同时新老节点开始索引+1

  • 接下来比较新老节点的结束节点,如果sameVnode为true,同样直接patchVnode;同时新老节点结束索引-1

  • 接下来老节点的开始节点与新节点的结束节点比较,如果sameVnode为true,说明oldStartVnode要移动到oldEndVnode后边(nextSibling)去了。先将这两个节点patchVnode,同时将真实的DOM节点通过insertBefore移动到oldEndVnode后边。老节点的开始索引要+1,但是由于老的开始节点移动到老的结束节点后边了,则对应的新节点的结束索引需要-1

  • 最后一种比较就是老节点的结束节点与新节点的开始节点比较,满足sameVnode,说明老节点的oldEndVnode要移动到老节点的oldStartVnode之前。将这两个节点patchVnode之后,需要将当前的真实DOM移动到oldStartVnode前边,所以与之对应的,老节点的结束索引要-1,新节点的开始索引要+1

  1. 如果新老节点首尾的四种比较结果都不满足sameVnode,则开始根据key值查找是否有可复用的新节点队列中的开始节点,即newStartVNode(注意:这里只去在旧节点数组中查找新节点组中的头节点,不处理新节点组的尾节点)。

如果新节点具有key值,则开始查找事先已经建立好的以oldVnode为key,对应的index为value的哈希表;否则就在整个老节点树中遍历查找。这里也说明定义了key值的重要性。

从哈希表中找出与newStartVnode一致key的oldVnode,索引为idxInOld。如果满足sameVnode,则patchVnode,同时将这个真实DOM节点移动到oldStartVnode对应的真实DOM前边,同时将老节点队列中idxInOld索引对应的值置为undefined;置为undefined,代表这个节点已经处理过,我们在新一轮查找中,遇到老的子节点值为undefined可以直接跳过。

如果在哈希表中没找到,则说明索引newStartIdx对应的的newVnode在oldVnode队列中不存在,无法节点复用,直接调用createElm创建一个新的dom节点放到oldStartVnode对应的真实DOM前边。

image.png

map表生成函数:

function createKeyToOldIdx (children, beginIdx, endIdx) {
  let i, key
  const map = {}
  for (i = beginIdx; i <= endIdx; ++i) {
    key = children[i].key
    if (isDef(key)) map[key] = i
  }
  return map
}

新节点没定义key值,则从当前老节点的起始位置开始查找

function findIdxInOld (node, oldCh, start, end) {
    for (let i = start; i < end; i++) {
      const c = oldCh[i]
      if (isDef(c) && sameVnode(node, c)) return i
    }
  }
  1. 循环结束后,可能会有新老节点两个数组中一个处理完了而另一个还有未处理的节点的情况出现。所以去判断索引值,做相应的添加或删除。若newStartIdx>newEndIdx,说明新节点数目大于老节点,则把多出的节点创建出来添加到真实DOM中,否则把多余的节点从真实DOM中删除。

image.png

如何判断两个节点是否可以复用

首先来回顾下Vue中Vnode都包含哪些属性,具体Vnode定义在src/core/vdom/vnode.js文件中,有兴趣可以去看。

export default class VNode {
  tag: string | void;
  data: VNodeData | void;
  children: ?Array<VNode>;
  text: string | void;
  elm: Node | void;
  ns: string | void;
  context: Component | void; // rendered in this component's scope
  key: string | number | void;
  componentOptions: VNodeComponentOptions | void;
  componentInstance: Component | void; // component instance
  parent: VNode | void; // component placeholder node

  // strictly internal
  raw: boolean; // contains raw HTML? (server only)
  isStatic: boolean; // hoisted static node
  isRootInsert: boolean; // necessary for enter transition check
  isComment: boolean; // empty comment placeholder?
  isCloned: boolean; // is a cloned node?
  isOnce: boolean; // is a v-once node?
  asyncFactory: Function | void; // async component factory function
  asyncMeta: Object | void;
  isAsyncPlaceholder: boolean;
  ssrContext: Object | void;
  fnContext: Component | void; // real context vm for functional nodes
  fnOptions: ?ComponentOptions; // for SSR caching
  devtoolsMeta: ?Object; // used to store functional render context for devtools
  fnScopeId: ?string; // functional scope id support
  
  constructor() {
      ...
  }
}
sameVnode

sameVnode函数用来判断两个几点是否可以复用,要求首先新老节点的key必须相等,其次是标签要相同,接下来判断两个Vnode是否都具有data属性。其中input标签会特殊判断

function sameVnode (a, b) {
  return (
    a.key === b.key && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}
sameInputType
function sameInputType (a, b) {
  if (a.tag !== 'input') return true
  let i
  const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type
  const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type
  return typeA === typeB || isTextInputType(typeA) && isTextInputType(typeB)
}

看几个工具函数的定义:/src/shared/utils

export function isUndef (v: any): boolean %checks {
  return v === undefined || v === null
}

export function isDef (v: any): boolean %checks {
  return v !== undefined && v !== null
}

参考&&推荐文章

Vue diff算法解析

为什么Vue不要用index作为key