简介
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流程图:

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

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之间的节点
附上一张流程图:

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






小结
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进行移动。