目录
开篇
本文目的是分享我对Vue2.x中的diff算法的一些浅薄理解。如有错误还望指出。
之前的博文都是清一色的大量源码加注解堆砌的,非常不便于读者阅读和理解,此篇博文我会换一种写法并尽最大努力将我的想法表达清楚。
源码版本
本文用到Vue源码版本为 v2.6.10,此版本是本文发布日能下载到的最新版。
算法概述
Diff算法主要使用了3个函数,分别是patch
,patchVnode
和updateChildren
。
patch
先说patch (oldVnode, vnode, hydrating, removeOnly)
,patch的oldVnode与vnode参数指的一定是组件的根节点变化前后的虚拟节点。
为什么?
因为,在Vue中每个Dep实例对应一个变量(vm.$options.data.xxx.xxx....
),每个Dep实例内保存了与此实例相关的全部watcher实例的引用(或者说是监听了此变量的watcher实例的引用),这些watcher分两类,一类是用户使用$watch
或其它方式定义的watcher,另一类则是Vue在创建组件实例的同时,随组件实例一起创建的renderWatcher(它本质上也是个watcher实例,只是被存到了vm._watcher
中用做特殊用途),每个renderWatcher与组件是一对一的关系。renderWatcher监听的对象是一个函数,函数内部只调用了一个patch
方法,patch方法的参数是_render
方法的返回值,而这个返回值就是当前组件的虚拟DOM。也就是说,当某个组件的虚拟DOM被修改的时候,dep会通知其对应的renderWatcher,此时renderWatcher就会被添加到待更新队列中,等待某个时间点会调用patch方法。并将当前组件的根节点的新旧vnode传入patch方法,以便让patch可以计算出真实DOM需要作出的最小变化,最后更新组件。因此我认为patch就是组件更新的入口。
那这里有个问题,既然每个组件实例都有_vnode
属性用来描述页面的真实DOM,为什么Vue不直接根据_vnode生成真实DOM然后替换页面上的已有DOM呢?
在我的理解中,Vue希望在每次DOM需要被修改的时候(虚拟DOM发生改变之后),可以尽量多的复用之前已经渲染在页面上的DOM节点,而不是直接清空原有DOM节点之后创建新的节点。而对于组件来说,它能掌握的信息只是变化前后的虚拟DOM,它是无法直接得知哪些节点发生了变化的。Diff算法的目的就是通过对比新旧虚拟DOM找出节点的变化,找出哪些节点是可以复用的并复用它们,哪些节点又是需要增删改的并处理它们。patch
方法就是Diff算法的入口(我认为组件更新实际就是在执行Diff算法)。
patchVnode 与 updateChildren
再说patchVnode
和updateChildren
。这两个函数构成了一组递归操作,目的是从组件根节点开始,对新旧虚拟DOM层层递归进行比较(如下图)。拿红色层为例,patchVnode用于比较红色层内的1号节点在新旧VNode中的异同(并更新),而updateChildren用于比较1号节点的子节点是否有增删或移动(并更新),如果像图中这样变化前后的2号3号节点是同一个节点(调用sameVnode
方法来判断),则再调用patchVnode去处理2和3。处理2的时候又会updateChildren处理其子节点。
总结下来就是:
patchVnode
对相同节点进行更新并调用updateChildren处理子节点,updateChildren对子节点进行判断,可能会进行增删和移动,如遇相同节点则再调用patchVnode
进行更新。
只是在这个过程中有一些约定好的规则。首先,Vue不会跨层比较,:
oldVNode的红色层只会和newVNode的红色层做比对,不会和其他层作比对。oldVNode的黑色层和newVNode的绿色层属于同一层级的节点,但它们父节点不同,因此也不会进行比较。
做这些限制的目的是为了优化算法效率。Diff本就是想用cpu和内存资源换渲染时间,如果Diff效率低,计算过程占用了大量时间的话,也就失去了它的价值了。
算法细节
在对比过程中,Vue是以vnode为主,与oldVnode进行比对的。并根据比对结果对vnode.elm进行处理。elm存放的是从oldVnode.elm取得的组件对应的真实DOM节点(大家可以通过vm._vnode.elm或vm.$el拿到它,对它做修改不会触发patch,而是直接改变页面显示)。
下面我根据自己的理解描述一下整个比较过程。
patch
首先patch方法被调用,并接收到两个参数oldVnode
与vnode
,这里的oldVnode与vnode是且只能是需要进行更新的组件的根节点变化前后的虚拟DOM。oldVnode为更新前的虚拟DOM,vnode为更新后的虚拟DOM。接下来patch需要通过对比两者来异同来决定组件对应的真实DOM需要做哪些改动。
patch方法规则如下:
- 如果 没有 vnode,但 有 oldVnode。则表示用户将当前组件的根节点删除了,此时执行删除组件的操作。(删除)
- 如果 有 vnode,但 没有 oldVnode。表示当前组件根节点是用户新增的节点。执行新增组件的操作。(新增)
- 如果 有 vnode,也 有 oldVnode。且它们是相同虚拟节点,则表示用户有可能对当前组件进行了修改。此时调用
patchVnode
更新根节点。(更新) - 如果 有 vnode,也 有 oldVnode。但它们是不同虚拟节点,则表示用户用一个新组件替换掉了当前页面上的这个组件,此时不管 oldVnode是不是真实DOM(因为它已经没有价值了),就直接以vnode为参照创建一个真实DOM,然后用新真实DOM替换掉之前的老真实DOM。顺便一提,刷新页面的时候走的就是这个条件。(替换)
patchVnode
patchVnode大体做了两件事,第一件事是对当前节点的属性进行更新,第二件事是对节点包含的内容进行更新。另外,很重要的一点是,patchVnode的参数oldVnode与vnode一定是同一节点(sameNode)
节点属性更新
如果当前节点是一个标签,并且它包含属性则对它进行属性更新。
属性更新用到了7个函数,如下所示:
1: updateAttrs(oldVnode, vnode)
//更新用户在标签上自定义的属性,如<div a="1" b="2"></div>
中的a和b
2: updateClass(oldVnode, vnode)
3: updateDOMListeners(oldVnode, vnode)
4: updateDOMProps(oldVnode, vnode)
5: updateStyle(oldVnode, vnode)
6: update(oldVnode, vnode)
7: updateDirectives(oldVnode, vnode)
我没有对它们一一做研究,只是看了下updateAttrs(oldVnode, vnode)
的源码,方法内部逻辑如下:
updateAttrs(oldVnode, vnode)
方法首先遍历vnode的用户自定义属性(vnode.data.attrs
),用每个属性去和老节点中的中同名自定义属性作对比。如果前后两个属性的值不相同,则代表用户新增或者修改或删除了此属性。
例如节点修改前是这样:<div a="1" b="2"></div>
修改后是这样: <div a="3" b="2"></div>
那么vnode.data.attrs.a
与oldVnode.data.attrs.a
不相等。此时会使用 setAttr(elm, key, cur)
更新真实DOM(vnode.elm)中相应的值。至于具体到底是新增、修改还是删除,是交给baseSetAttr (el, key, value)
方法来分辨的。el表示真实DOM节点(也就是vnode.elm),key表示属性名,value表示属性值。如果value为 null 或者 false,则表示删除el中名称为key的属性,否则表示在el中添加或修改名为key的属性且值设置为value。
之后遍历oldVnode.data.attrs,如果发现oldVnode.data.attrs里面有某些属性在vnode.data.attrs里面不存在(undefined或null),则表示节点变化后这些属性被删除了。此时只需要删除真实DOM中的对应属性即可。
节点内容更新
规则如下:
- 如果vnode与oldVnode均有子节点,则调用
updateChildren
对子节点数组进行下一步操作。 - 如果vnode有子节点,而oldVnode无子节点,则说明用户在当前节点下添加了子节点。此时对应的是这种情况:
<div></div>
改为<div><p>孩子1</p></div>
。不过如果oldVnode包含文本节点则应先删除文本,再添加子节点,此处对应的是这样情况:<div>abc</div>
改为<div><p>孩子1</p></div>
- 如果vnode是空节点(无子节点无文本),而oldVnode有子节点,说明用户删除了当前节点下的子节点。对应的情况是这样:
<div><p>孩子1</p></div>
改为<div></div>
- 如果vnode是空节点(无子节点无文本),oldVnode包含文本节点,则将当前节点的文本设为空字符串。对应情况为:
<div>abc</div>
改为<div></div>
- 如果vnode包含文本且与oldVnode的文本不一致则更新DOM节点的文本,对应情况:
<div>abc</div>
改为<div>123</div>
或
<div></div>
改为<div>123</div>
或
<div><p>孩子1</p></div>
改为<div>123</div>
updateChildren
之前提到过,Vue在比对子节点数组的时候只对同级节点作比较,所以问题就可以简化为如何对“两个数组进行比较”,只不过数组中存放的都是虚拟节点而已。另外,此函数接收的参数oldCh指的是oldVnode的子节点构成的数组,newCh指的是vnode的子节点构成的数组。
两个数组作比较只需要一个双层循环就搞定了。举个例子,如果内层循环为newCh而外层循环为oldCh。我现在对oldCh数组的第一个元素做判断,我要拿着这个元素去和newCh里面的元素一个个比过去,假设在对比到newCh中第三个元素的时候发现它和自己一模一样,则表示oldVNode数组的第一个元素的位置发生了变化,在新数组中它变到了第三的位置。此时对oldCh数组的第一个元素的判断完毕,判断结果为:它的位置发生了变化。至此第一次外层循环结束。(改)这对应的情况可能是
<div>
<p>1</p>
<p>2</p>
<p>3</p>
</div>
改为
<div>
<p>2</p>
<p>3</p>
<p>1</p>
</div>
下面进行第二次外循环,现在开始对oldCh中的第二个元素做判断,第二个元素直到对比完newCh数组的最后一个元素也没找到和和自己一样的元素。此时它恍然大悟,哦 原来自己被删掉了。(因为自己在新数组中不存在了嘛,必然是被删掉了)。(删)对应的情况可能是下面这样;
<div>
<p>1</p>
<p>2</p>
<p>3</p>
</div>
改为
<div>
<p>1</p>
<p>3</p>
</div>
就这么比啊比,直到oldCh数组的元素已经都做过对比操作了,但是newCh里面还有没与oldCh数组元素配对成功的元素。结果很明显,这些newCh数组中剩余的元素是新添加的元素。(增)
上面这种方式确实可以确定子节点数组需要做的操作,但效率太低。请想象一些极端情况。比如当我们在比较oldCh的第三个元素的时候,发现它和newCh中的最后一个元素相同,这其实浪费了很多的cpu资源(假设oldCh与newCh均有上百个元素)。
因此我们可以做个优化,在每次循环开始之前,先拿当前元素和newCh中的最后一个元素作比较,如果不同无所谓,我们只浪费了微乎其微的cpu资源做了次判断而已,但很幸运,我们通过这次对比发现oldCh的第三个元素和newCh的最后一个元素是相同节点,因此之后本次内循环可以都不用做了,直接做后续处理即可,这样一来节省了非常多的cpu资源。
Vue的Diff算法其实就是做了很多这样的优化。
优化1:
Vue为oldCh和newCh分别添加了一对游标,默认指向数组的第一个和最后一个元素。
Vue列出了几种特殊情况,在双循环开始之前先判断是否属于这几种特殊情况之一,如果是,则直接进行处理而不需要再进行双层循环。如果不是,再使用双层循环来判断。
具体规则如下:
-
如果oldStartIdx指向的元素为undefined则oldStartIdx右移,同样的如果oldEndIdx指向的元素不存在则oldEndIdx左移。
这个操作的目的是快速去掉oldCh左右两端的无效数据。为什么会出现元素值为undefined呢?往下看就知道了。 -
如果oldStartIdx和newStartIdx是相同元素则对其调用patchVNode。oldStartIdx和newStartIdx都向右移动。 同样的,如果newEndIdx和oldEndIdx是相同元素对其调用patchVNode。newEndIdx和oldEndIdx都向左移动。我对这个优化操作的理解是:可能Vue认为很多时候节点变化前后它的子节点数组的首尾元素仍是相同元素。
-
如果oldStartIdx和newEndIdx是相同元素则对其调用patchVNode,oldStartIdx右移,newEndIdx左移。如果oldEndIdx和newStartIdx是相同元素则对其调用patchVNode,oldEndIdx左移,newStartIdx右移。我对这个优化操作的理解是:可能Vue认为 用户对子元素做的操作中有很多都是把第一个子元素移动到最后一个的位置。或者是把最后一个子元素移动到第一个的位置。想象我们在做待办事件列表的时候,有可能允许用户对待办事项打勾然后待办事项变为已完成并加到列表末尾?又或者用户将已完成的事项再提到列表开头作为待办事项?为了证实猜想我写了个小Demo测试了一下,发现确实是这种情况,Demo很简单就不贴了。
其余情况只能通过双层循环来比对出来了。双层循环内的规则如下:
- 如果newStartIdx指向的元素在oldCh里面不存在,则创建新节点。
- 如果在oldCh里面找到某个元素和newStartIdx指向的元素相同了,则:
- 先对这个元素做应用patchVnode方法来更新属性。
- 而后将老数组中的vnode设置为undefind。(读到这里就知道为什么while循环一开始会跳过老数组的首尾的undefind元素的了吧!)
- 最后将老数组中的vode对应的真实DOM节点移动到上一个已经排好的DOM节点的下一个兄弟节点之前的位置。 (其实就是移动到上一个已经拍好的DOM节点之后的位置。只不过因为源码中用的是insertBefore,所以必须插入到某个节点之前。)
最后,如果其中一个数组遍历完毕(头部游标跑到了尾部游标之后)之后发现,另一个数组还有未匹配到的元素,此时还需要做