聊聊vue2.5的patch过程(diff算法)

3,706 阅读7分钟

简介

Vue2.0开始,引入了Virtual Dom,了解diff过程可以让我们更高效的使用框架,必要时可以进行手工优化,本文针对的是Vue2.5.7版本中的Virtual Dom进行分析,力求以图文并茂的方式来分析diff的过程。

其中patch过程中所用到的diff算法来源于snabbdom

PS: 如有不对之处,还望指正。

什么是VNode?

我们知道,浏览器中真实的DOM节点对象上的属性和方法比较多,如果每次都生成新的DOM对象,对性能是一种浪费,在这种情况下,Virtual Dom出现了,而VNode是用来模拟真实DOM节点,即把真实DOM树抽象成用JavaScript对象构成的抽象树,从而可以对这颗抽象树进行创建节点、删除节点以及修改节点等操作,在这过程中都不需要操作真实DOM,只需要操作JavaScript对象,当数据发生改变时,在改变真实DOM节点之前,会先比较相应的VNode的的数据,如果需要改变,才更新真实DOM,大大提升了性能。同时VNode不依赖平台。

具体可以通过以下代码查看标准DOM对象上的方法和属性

const dom = document.createElement('div');
for (let key in dom) {
    console.log(key)
}

VNode构造函数具体结构如下(具体见源码):

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
  fnScopeId: ?string; // functional scope id support
}

mounted过程都发生了什么?

在了解patch过程之前,先来大概了解下mounted过程,我们知道,Vue最终会调用$mounted方法来进行挂载。 一般来说,Vue有两条渲染路径,分别对应生命周期中mounted和updated两个钩子函数,分别如下:

 1)组件实例初始化创建生成DOM

在该过程时,初始的Vnode为一个真实的DOM节点或者undefined(创建组件)

$mounted => mountComponent => updateComponent => _render => _update => patch => createElm => nodeOps.insert => removeVnodes

 1)组件数据更新时更新DOM

在该过程,初始化Vnode为之前的prevVnode,不是真实DOM节点

flushSchedulerQueue => watcher.run => watcher.get => updateComponent => _render => _update => patch => patchVnode => updateChildren

其中,_render函数内部则是调用createElement方法将渲染函数转为VNode,而_update函数则是在内部调用patch方法将VNode转化为真实的DOM节点。

createElement和patch过程是一个深度遍历过程,也就是"先子后父",即先调用子类的mounted或updated钩子方法,在调用父类的该钩子。

附上一张$mounted流程图:

\$mounted过程

patch原理分析

patch过程也是一个深度遍历过程,比较只会在同层级进行,不会跨层级比较,借用一篇相当经典的文章 React’s diff algorithm中的图,图能很好的解释该过程,如下:

React’s diff algorith
patch接收6个参数,其中两个主要参数是vnode和oldVnode,也就是新旧两个虚拟节点,下面详细介绍下patch过程

1、patch逻辑

1、如果vnode不存在,而oldVnode存在,则调用invodeDestoryHook进行销毁旧的节点
2、如果oldVnode不存在,而vnode存在,则调用createElm创建新的节点
3、如果oldVnode和vnode都存在
 1)如果oldVnode不是真实节点且和vnode是相同节点(调用sameVnode比较),则调用patchVnode进行patch
 2)如果oldVnode是真实DOM节点,则先把真实DOM节点转为Vnode,再调用createElm创建新的DOM节点,并插入到真实的父节点中,同时调用removeVnodes将旧的节点从父节点中移除。

2、patchVnode逻辑

1、如果vnode和oldVnode完全一致,则什么都不做处理,直接返回
2、如果oldVnode和vnode都是静态节点,且具有相同的key,并且当vnode是克隆节点或是v-once指令控制的节点时,只需要把oldVnode的elm和oldVnode.children都复制到vnode上即可
3、如果vnode不是文本节点或注释节点
 1)如果vnode的children和oldVnode的children都存在,且不完全相等,则调用updateChildren更新子节点
 2)如果只有vnode存在子节点,则调用addVnodes添加这些子节点
 3)如果只有oldVnode存在子节点,则调用removeVnodes移除这些子节点
 4)如果oldVnode和vnode都不存在子节点,但是oldVnode为文本节点或注释节点,则把oldVnode.elm的文本内容置为空

4、如果vnode是文本节点或注释节点,并且vnode.text和oldVnode.text不相等,则更新oldVnode的文本内容为vnode.text

3、updateChildren逻辑

updateChildren方法主要通过while循环去对比2棵树的子节点来更新dom,通过对比新的来改变旧的,以达到新旧统一的目的。

1、如果oldStartVnode不存在,则将oldStartVnode设置为下一个节点
2、如果oldEndVnode不存在,则将oldEndVnode设置为上一个节点
3、如果oldStartVnode和newStartVnode是同一个节点(sameVnode),则调用patchVnode进行patch重复流程,同时将oldStartVnode和newStartVnode设置为下一个节点
4、如果oldEndVnode和newEndVnode是同一个节点(sameVnode),则调用patchVnode进行patch重复流程,同时将oldEndVnode和newEndVnode设置为上一个节点
5、如果oldStartVnode和newEndVnode是同一个节点(sameVnode),则调用patchVnode进行patch重复流程,同时将oldStartVnode设置为下一个节点,newEndVnode设置为上一个节点,需要对DOM进行移动
6、如果oldEndVnode和newStartVnode是同一个节点(sameVnode),则调用patchVnode进行patch重复流程,同时将oldEndVnode设置为上一个节点,newStartVnode设置为下一个节点,需要对DOM进行移动
7、否则,尝试在oldChildren中查找与newStartVnode具有相同key的节点
 1)如果没有找到,则说明newStartVnode是一个新节点,则调用createElem创建一个新节点,同时将newStartVnode设置为下一个节点
 2)如果找到了具有相同key的节点
  (1)如果找到的节点与newStartVnode是同一个节点(sameVnode),则调用patchVnode进行patch重复流程,同时把newStartVnode.elm移动到oldStartVnode.elm之前,并把newStartVnode设置为下一个节点,需要对DOM进行移动
  (2)否则,调用createElm创建一个新的节点,同时把newStartVnode设置为下一个节点

上述过程中,如果oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx,即oldChildren和newChildren节点在遍历过程中如果任意一个的开始索引和结束索引重合,则表明遍历结束。

遍历结束后,还需针对oldChildren和newChildren没有遍历的节点进行处理,分为以下两种情况:

1)如果oldStartIdx大于oldEndIdx,说明newChildren可能还未遍历完,则需要调用addVnodes添加newStartIdx到newEndIdx之间的节点
2)如果newStartIdx大于newEndIdx,说明oldChildren可能还未遍历完,则需要调用removeVnodes移除oldStartIdx到oldEndIdx之间的节点

附上一张流程图:

patch过程
针对以上过程,对其中的各个情况都分别简单举个例子,进行分析,可以自行debugger

情况一:oldStartVnode和newStartVnode是相同节点

情况一
情况二:oldEndVnode和newEndVnode是相同节点

情况二
情况三:oldStartVnode和newEndVnode是相同节点

情况三
情况四:oldEndVnode和newStartVnode是相同节点

情况四
情况五:oldStartVnode、oldEndVnode、newStartVnode和newEndVnode都不是相同节点

情况五
附上总图:

总图

小结

1、不设key,newCh和oldCh只会进行头尾两端的相互比较,设key后,除了头尾两端的比较外,还会从用key生成的对象oldKeyToIdx中查找匹配的节点,所以为节点设置key可以更高效的利用dom。

2、diff的遍历过程中,只要是对dom进行的操作都调用nodeOps.insertBefore,nodeOps.insertBefore只是原生insertBefore的简单封装。
比较分为两种,一种是有vnode.key的,一种是没有的。但这两种比较对真实dom的操作是一致的。

3、对于与sameVnode(oldStartVnode, newStartVnode)和sameVnode(oldEndVnode,newEndVnode)为true的情况,不需要对dom进行移动。