关于Vue3 diff算法的三两事
前言
这段时间在学习Vue的渲染相关的知识,通过阅读霍春阳老师的《VueJs设计与实现》以及自己的上手实践,也是对Vue的如何进行渲染有了更深的认识。
Vue的渲染,涉及到页面初始化,以及更新(包括对节点的增删改)等。那么说起更新,就不得不提起Vue的diff算法了。
虚拟DOM
在说diff算法之前,我们首先来了解一下虚拟DOM。
那么什么是虚拟DOM呢?简单来说,虚拟DOM就是一个对象,它是用来描述真实DOM的。
上述给出的伪代码,就是虚拟DOM的庐山真面目了,怎么样?也没有这么神秘吧?
那么Vue为什么要用虚拟DOM来描述真实DOM呢,包括React、Angular等主流框架也采用虚拟DOM,虚拟DOM有什么好处呢?
当我们操作真实DOM时,浏览器都会对整个页面进行重新渲染,如果DOM节点数不多的情况下并不会有什么影响。但在大型项目中,DOM节点数是非常多的,如果我们仅仅修改了一处DOM就要重新渲染整个页面的话,对浏览器来说负担还是比较大的。
而虚拟DOM它能保证渲染性能的下限,让浏览器用最少的操作来渲染真实DOM,只重新滚渲染有差异的DOM节点。那么,在DOM节点数非常庞大的情况下,就可以极大的提升性能,而不是“牵一发动全身”。
快速diff
在Vue3中采用了新的比较新旧两组子节点的算法——快速diff。
那么它的核心是什么呢?
找出新旧子节点中相同的前置以及后置节点,对其进行patch更新,若中间的节点无法进行简单的增删实现更新,则根据节点的索引关系构造出最长递增子序列,尽最大可能减少节点的移动,提高性能。(以上是我根据霍春阳老师的《Vue.JS设计与实现》提炼的)
光看这段话还是比较晦涩难懂的,废话不多说,直接上图!
首先来看看前置和后置的比较,初始大概是这样的:

首先是前置,i指针指向新旧子节点的开头,而e1和e2指针分别指向旧新子节点的结尾,循环遍历,直到节点不相同为止,伪代码如下:
那么比较两个节点相同的条件是什么呢?首先是节点的类型(比如说都是p或者div等) 得相同,但这样就够了吗?如果两个节点类型相同,但是children不一样的话就会无法确认对应的关系了,所以这也是Vue中key的重要作用,可以把它当作每一个节点的唯一标识,就像我们的身份证一样,都是独一无二的。
那么我们给节点加上key后,经过前置对比的结果如下图:

随后进行后置的对比,伪代码如下:
进行后置对比后,结果如下图:

在上述的前置和后置比较中,为什么节点相同时要对它进行patch呢?因为节点虽然是同一个节点,但是它的children可能会发生变化 (或是文本内容发生更改,或是有了多个子节点) ,所以需要对它进行重新更新,比如下面这种情况。
前后置对比完后,在理想情况下,我们只需要对newChildren中多出的节点进行添加,没有的节点进行删除即可。
而在不理想的情况下,需要对节点进行移动,新增和删除。
首先,我们先来看看理想情况。
理想情况
newChildren比oldChildren短
理想情况下,没有需要移动的节点,当newChildren比oldChildren短时,只需要删除掉oldChildren中多余的节点,当i小于等于e1时,进行删除操作,伪代码如下。
newChildren比oldChildren长
当newChildren比oldChildren长时,只需要新增oldChildren中oldChildren中没有的节点,当i小于等于e2时,进行新增操作,伪代码如下。
与删除不同的是,新增需要找到锚点,也就是说需要找到元素要新增在哪里。
新增的索引位置位于e2+1(也就是oldChildren和newChildren从末尾到开头最后一个相同的节点)的前面,当它大于newChildren的长度时,说明新元素要新增在节点末尾。
看到这里,如果大家忘了i、e1、e2各自的含义,不妨重新温习一下。
i指向newChildren和oldChildren的前置节点,遍历过后指向newChildren和oldChildren前置节点第一个不同的节点索引;e1和e2指向newChildren和oldChildren的后置节点,遍历过后指向newChildren和oldChildren后置节点第一个不同的节点索引。
复杂情况
接下来就是比较复杂的情况了,就比如开头的那种情况:
可以发现,此时无法单纯的对元素进行简单的增加删除就可以完成的了,此时需要对元素进行移动才能完成更新。
在Vue3中,定义了一个source数组,长度为newChildren未处理的节点数量,也就是e2-i+1,这个数组用于存储newChildren的子节点在oldChildren子节点中的位置索引,后续用其生成最长递增子序列,计算出最少需要移动的节点。
首先,遍历newChildren,通过未处理的节点生成key:value的映射表,key为子节点的key值,value为子节点的索引值,便于在后续查找元素索引(也是为了降低双重for循环带来的时间复杂度)。
接着,我们就可以遍历oldChildren,找出可复用的节点,对其进行更新,也去收集它在oldChildren中的索引位置,接着上述代码接着写。
接下来,我们来根据图来加深对这段代码的理解。

可以看到,根据代码所生成的source数组为[3,1,2]。
随后,通过最长递增子序列算法生成source的最长递增子序列,结果为[1,2],这里的1和2并不是数组中的值1和2,而是它们数组中对应的下标。
seq数组是根据source数组生成的最长递增子序列,而source数组的值是newChildren未处理节点在oldChildren中的索引,根据上述可以看出D-4是需要移动的,B-2、C-3是不需要移动的。
那么为什么要从后往前遍历呢?是为了保证当前节点的后续节点都处理完成了,避免未处理的节点对当前节点的影响。
总结
以上,就是我个人对diff算法的理解了,如果有讲述的不清楚、错误的地方欢迎大家评论批评。
