扯皮
本文主要讨论 Vue2 以及 Vue3 的 diff 算法,以讲解对比更新流程为主,不掺和一行 diff 算法源码,可以理解为 diff 算法流程的图文版🤔。
之前面试的时候其实经常会谈到 diff 算法,但答的总是不太好,最大的问题是你的脑子里可能知道流程,但是语言表达不出来。
面试中很少让你手撸一个完整的 diff 算法,但如果只是口述的话其实也很抽象,讲着讲着自己都乱了,思来想去可能还是自己没理解😀。
所以最好的方法还是配合案例画图展示整个流程,因此决定做个总结,把整体对比流程画图过一遍,以后忘了就回看。
或者面试当中直接跟面试官说自己写过一篇 diff 的文章让他一边看文章一边跟他讲,这是最舒服的😏。
正文
更新情景
针对于虚拟 DOM 更新的情景一共有四种,针对于这四种情景有不同的更新策略:
- 旧:文本 新:文本 -> 直接进行文本更新
- 旧:文本 新:数组 -> 原文本清空,挂载数组中的节点
- 旧:数组 新:文本 -> 卸载数组中的节点,设置文本
- 旧:数组 新:数组 -> 执行 diff 算法核心逻辑
在 Vue2、Vue3 的源码中均有体现,当然它们并没有针对这四种情景按照顺序去判断更新,所以会有大量的嵌套判断逻辑,这也是考虑逻辑的复用优化,但最终的效果肯定涵盖了这四种情景,这里简单读一下源码并解释部分内容
Vue2: src/core/vdom/patch.js (patchVnode 函数)
Vue3:packages/runtime-core/src/renderer.ts (patchChildren 函数)
很显然当新旧虚拟 DOM 树的子节点都是虚拟节点组成的数组时是最为复杂的,也是我们本文着重讨论的情况。
Vue2 diff 算法
Vue2 的 diff 更新被称作双端 diff,它的核心对比逻辑是从两个数组的首尾端点开始不断进行四次比较。
我们先来看下面的例子体会双端 diff 的优势,也是设计与实现书中举例的,针对这种更新节点的移动可以看到有两种方式:
很显然第一种方式需要进行两次 DOM 移动操作,而第二种方式只需一次 DOM 移动操作,能够减少一次 DOM 操作那就是一次很好的性能提升。
但如果从代码实现的角度来讲第一种方式更容易一些,因为我们按照数组遍历的思维可以针对于每个节点进行判断,如果需要移动就进行移动好了,不需要考虑移动带来的性能开销。
双端 diff 解决的问题就在于这里,即使代码实现的方案会稍微复杂一些,但也实现了尽量减少节点的移动次数。
双端比较
双端比较是整个双端 diff 的核心,几乎贯穿整个双端 diff,首先它会初始化四个索引,这四个索引就是新旧节点数组的端点:
针对于这四个端点会进行四次比对,比对顺序如下:
- newStartIndex <-> oldStartIndex
- newEndIndex <-> oldEndIndex
- newEndIndex <-> oldStartIndex
- newStartIndex <-> oldEndIndex
总结一下对比的步骤:先首后末,先横向再交叉,整体呈现为一个漏斗形状。我们以新的节点数组为主方向,它的对比顺序如下图所示:
那么下面需要研究的是针对于这四次比对应该采取什么样的策略。
无论哪次比对都是判断节点是否相同(key、type),如果不相同则按照比对步骤继续往下,相同则会采取不一样的策略并结束此轮比对,我们把每个比对单拉出来看:
-
newStartIndex <-> oldStartIndex:如果比对节点相同说明无需移动,先针对于该节点进行 patch 更新,之后同时向下移动两个索引(+1操作)
-
newEndIndex <-> oldEndIndex:如果比对节点相同说明无需移动,先针对于该节点进行 patch 更新,之后同时向上移动两个索引(-1操作)
-
newEndIndex <-> oldStartIndex:如果比对节点相同说明需要移动 ,此时新节点在末端,旧节点在首端,因此需要将旧节点插入至 oldEndIndex 节点的下面,之后移动两个索引,新节点索引向上移动,旧节点索引向上移动
-
newStartIndex <-> oldEndIndex:如果比对节点相同说明需要移动 ,此时新节点在首端,旧节点在末端,因此需要将就旧节点插入至 oldStartIndex 节点的上面,之后移动两个索引,新节点索引向下移动,旧节点索引向上移动
总结一下对比更新的规律:横向对比只移动索引,交叉对比移动元素并且移动索引
当一轮对比结束后我们需要再次进行下一轮对比以此循环,因为每一轮的对比都会移动索引,因此最终对比结束循环的条件是:oldStartIndex > oldEndIndex || newStartIndex > newEndIndex
我们根据这个对比方式来应用到之前的例子:
显然我们只需要通过三轮对比就已更新完成,并且整个过程只进行了一次 DOM 操作:移动 A 节点。
单轮对比后没有移动索引的情况
上面的案例是较为理想化的,因为我们有一个前置条件:每一轮的对比都会移动索引,所以最终循环肯定会结束的,但确实会有情况一轮对比下来都没有相同节点,那四个索引都不会移动,这就会导致无限循环问题。
直接来看一个案例吧:
因此我们在这个循环当中不仅要考虑四次比较,现在还会多一个分支:四次比较后都没有移动索引的处理
针对于这种情况其实就比较无脑了,我们直接让新的一组子节点中的头部节点去旧的一组子节点中寻找相同节点,这里的头部节点指的是 newStartIndex 所指向的节点。
以上面的案例来讲就是去 old 中查找是否有与 C 相同的节点,如果找到就需要进行移动。因为平行的节点在第一次端点对比已经判断是不相同的节点,那剩下的节点中不管 C 节点的位置在哪肯定都需要移动。
那就要考虑移动的策略,因为 C 节点在 new 中是头部节点,那我们只需要将在 old 中找到的节点移动至当前头部节点的前面 ,即 oldStartIndex 前面,在移动之前要进行一次 patch 。移动过后我们将 old 数组中当前位置的节点设为 undefined ,表示该节点已经移动了,后续不再判断该位置。最后移动 newStartIndex 索引(+1操作)
我们画图来看整个流程:
现在索引移动了,那一开始我们的死循环就打破了,后面开始重复双端比较的过程即可,注意需要把 undefined 的节点跳过。
后续的比较我就不画图了,直接口述吧:
- A <-> A:same: patch 更新 and 移动索引 next round
- B <-> D:different:do nothing
- D <-> B:different: do nothing
- D <-> D:same: patch 更新 and move D behind B (old) and 移动索引 next round
- B <-> B:same: patch 更新 and 移动索引 finish loop
现在我们回到一开始去旧的一组子节点中寻找相同节点,以上是找到了相同节点需要移动,但也可能没有找到。
针对于没有找到的情况其实就非常简单了,那就相当于当前 newStartIndex 节点是多出来的,我们需要进行挂载操作 ,挂载的位置就是 oldStartIndex 节点的前面 ,挂载完成后移动 newStartIndex 索引(+1操作),看下面例子 E 节点的挂载:
节点移动的另一种情况
在上面的情况我们已经考虑到了节点的移动,实际上面的情况是过于不理想的 ,因为初始的四次对比一次都没有成功,但是针对于移动还有另外一种情况,考虑下面的例子:
不难发现 D 就是新增的节点,同时它位于 newStartIndex 位置,我们跟着 diff 流程走一遍:
可以看到已经退出循环了,但是针对于 D 节点还没有进行处理,相当于现在我们要在双端比较的循环外面增加一个分支,即针对于额外节点的处理:
这时候我们可以观察一下四个索引的位置关系,可以发现循环是由 old 的两个索引打破,而另外两个索引是符合循环条件的(newStartIndex <= newOldIndex),不难发现其实新增节点个数是 newOldIndex - newStartIndex + 1(不考虑 undefined 节点的情况),因为相等的情形就是上例:只有一个节点需要挂载。因此针对于新增的节点就是根据 newStartIndex ~ newOldIndex 范围的节点进行挂载:
以上就是针对于添加节点的处理。
节点的删除
最后我们考虑节点的删除,也就是旧的一组节点中有多余的节点。
实际上它跟上面节点移动的思路类似,我们无法在双端对比循环中找到待删除的节点,但可以等循环结束后,通过 oldStartIndex 与 oldEndIndex 两个索引的位置关系判断是否有待删除的节点。
这次我们举一个比较综合的例子,其中 D、E 为待删除节点,从头到尾走一遍 diff 流程:
到此整个双端比较的循环结束,我们现在来观察四个索引位置:
可以看到这次与添加节点的情形正好相反,循环是由 new 的两个索引打破,而另外两个索引符是符合循环条件的(oldStartIndex <= newOldEndIndex),不难发现其实待删除节点个数是 newOldIndex - oldEndIndex + 1(不考虑 undefined 节点的情况)。因此针对于待删除的节点就是根据 oldStartIndex ~ oldEndIndex 范围的节点进行卸载:
Finish
到此为止我们已经走通了双端 diff 的所有情形:更新、添加、移动、移除
稍微口述总结一下双端 diff 流程:
- 循环双端对比四个索引节点进行四次比较,横向更新移动索引,交叉更新移动元素并移动索引
- 考虑四次比较都不符合的情况,取新的节点数组的头节点去旧的节点数组中查找,如果未找到则挂载该头节点,找到则移动该节点至头部位置
- 整个循环对比结束后根据其四个索引位置进行判断,若新的节点数组中有剩余节点则进行挂载,旧的节点数组中有剩余节点则进行卸载
Vue3 diff 算法
Vue3 中将 diff 算法称之为快速 diff,顾名思义就是快。这里的快速 diff 指的是有 key 的情景,我们可以看到 Vue3 的源码区分了有 key 和 无 key 的情况。
无 key 时采用的就是从头开始进行横向对比,新的节点数组有未对比的节点则进行挂载,旧的节点数组有未对比的节点则进行卸载:
我们着重研究有 key 的情况。
预处理
与双端 diff 类似,快速 diff 刚开始也是进行首尾节点的对比,但快速 diff 是先从头部节点开始往下比较更新,遇到不同停止,之后从尾部开始向上比较更新,遇到不同停止。
Vue 将这步操作为预处理,在快速 diff 中主要有三个索引,因为预处理都是从起始索引 0 开始,而由于新的一组节点和旧的一组节点的长度可能不同,因此尾部索引可能不同,我们来看下面的例子:
可以看到通过预处理操作后,我们将剩余未被处理的节点锁定在了三个索引的范围内:
快速 diff 的核心就是针对于这部分未被处理的节点完成:添加、删除、移动操作,其中移动部分就是要借助大名鼎鼎的最长递增子序列完成。
当然针对于这部分节点处理之前我们还有其他工作,我们一步一步来看。
双端节点的添加和删除
预处理一旦遇到不相同的节点就会停止,因此就会出现这样的情景:
- 新增或待删除的节点出现在首部,终止首部的预处理
- 新增或待删除的节点出现在尾部,终止尾部的预处理
先说新增节点的情况,我们省略预处理步骤,重点观察预处理后三个索引的位置关系,看下面的例子:
其实不难总结出:headIndex > oldTailIndex && headIndex <= newTailIndex 时就会出现需要添加节点的情景,而需要添加的节点范围就是:headIndex ~ newTailIndex,我们进行遍历挂载即可。
下面来看看删除节点的例子:
和添加节点正好相反:headIndex > newTailIndex && headIndex <= oldTailIndex 时就会出现需要删除节点的情景,而需要删除节点的范围就是:headIndex ~ oldTailIndex,我们进行遍历卸载即可。
中间节点的更新和删除
下面就进入到 diff 的核心流程,我们从简单的情景开始一步一步深入。
针对于未被预处理的节点,如果当中也存在相同的节点我们就需要进行更新,只不过这些节点后续可能还需要进行移动。其次就是删除节点。
不过这次就需要讲讲代码的流程了,因为后续要解释计算问题,来看下面的例子:
第一步我们需要构建 Map 结构,该结构存储的是在新的一组节点中的中间节点,其中键名为 key,键值为 index ,只需通过 headIndex ~ newTailIndex 锁定中间节点个数(newTailIndex - headIndex + 1),遍历新的一组节点来初始化 Map 结构。
第二步我们去遍历旧的一组节点的中间节点(headIndex ~ oldTailIndex),借助之前的 Map 结构来查找相同 key 的节点,如果不存在说明就是待删除的节点,直接卸载即可,存在就直接进行 patch 更新。 当然也存在 Map 结构中多余的节点,比如上例中的 A 节点,这说明是新增的节点,我们后续进行处理。
判断节点的移动
我们能够确定的是中间节点可能会出现需要移动的情况,但是需要用代码进行判断,这里快速 diff 通过两个变量实现:moved 移动标志、maxNewIndexSoFar 新节点的索引位置
其实判断移动节点移动的思路很简单,节点移动是什么意思?之前的节点在后面,现在在前面了。之前的节点在前面,现在在后面了,这就是需要移动的节点。
我们把这个思路以位置索引来讲述,在上一节的第二步中去遍历旧的一组节点时我们有从 Map 结构中取出这些节点在新的一组节点中的索引,我们可以进行比较:D 索引:0 (old) -> 3 (new),C 索引:1 (old) -> 2 (new) ... 注意现在只遍历两个节点就已经可以判断移动了,由于之前 D 是头部节点,它的索引比其他节点都要小,但现在 D 的索引居然比 C 的索引还要大,说明 D 至少移动到了 C 的后面,我们直接更改 moved 移动标志,说明现在有节点移动了,而 maxNewIndexSoFar 变量就是在遍历的过程中存储上一个节点在新的一组节点中的索引,起比较作用。
因此我们在上一节的第二步中增加判断是否存在移动节点的逻辑:
节点的移动和添加
下面就来到快速 diff 最最核心的部分了,就是对节点的移动的操作,如何实现最小化移动节点。
首先我们需要创建一个长度为新的一组节点的中间节点个数的数组,并初始化每个元素为 0。
注意这里是新的一组节点的中间节点,而不是旧的一组节点,因为新的一组节点中还会存在新增的节点我们还没有处理,新的一组节点的长度可能会比旧的一组节点长。
这个数组存储的是新的一组子节点中的节点在旧的一组子节点中的位置,而这部分内容也是在第二步遍历旧的一组节点中完成的,我们还按照之前的例子省略其他步骤,来看这个数组存储的内容:
可以看到这里面在填写元素时都进行了 +1 处理,这是为了与最开始初始化的值区分,比如像 D 节点因为是头部节点填写时值为 0,但它已经不再是最初初始化的状态了,因为遍历完成后还是初始值说明对应的节点是新增节点,需要进行挂载。
之后就是针对于这个数组来求出最长递增子序列,这里省略过程直接给出结果:
newIndexToOldIndexMap: [0, 4, 2, 1] => seq: [0, 1]
注意求出的最长递增子序列是其元素的索引数组而不是元素的数组,当然最长子序列不止一个,我们按照 [0, 1] 来继续解释
这里的最长递增子序列的含义是:在新的一组子节点中,索引值为 0 和 1 的这两个节点在更新前后顺序没有发生变化
之后的操作就是倒序遍历新的一组节点以及子序列,进行两次比对,我们用索引 i 表示遍历节点,用索引 j 表示子序列索引:
- 比对 newIndexToOldIndexMap[i] 中是否为初始值 0,如果是说明该节点是新增节点,直接进行挂载操作
- 不是初始值,则判断 i 是否与 seq[j] 相等,如果不相等则说明当前节点需要移动,而这里的移动其实就是直接以后面的节点为锚点插入即可。不相等则移动 j--。注意这里如果遍历过程中 j < 0 说明当前节点也需要移动。
以下是整个比对过程:
注意我们这里举例为了简便就直接忽略了预处理步骤,但代码编写时还需要考虑索引的变化关系,比如 newIndexToOldIndexMap 的构造是单独将预处理后的中间部分,它的起始索引并不是新的一组节点的起始索引,而是中间部分的起始索引。
Finish
到此整个快速 diff 也结束了,同样也简单口述总结一下快速 diff 流程:
- 初始化三个索引,首尾开始进行预处理比对操作,遇到不同节点停止
- 考虑双端新增节点和待删除节点的情形,根据预处理后三个索引位置来判断需要挂载和卸载的节点
- 针对于预处理后的剩余节点,以新的一组节点建立 Map 索引,遍历旧的一组节点来判断需要移除的节点进行卸载
- 建立一个 Map 数组并初始化为 0用来存储新的一组节点中的节点在旧的一组节点中的位置。遍历旧的一组节点过程中判断出中间节点是否有需要移动的节点,并填写 Map 数组
- 根据新的 Map 数组求得其最长递增子序列,倒序遍历新的一组中间节点,判断如果其 Map 数组元素为初始值 0 则表示需要挂载节点,如果遍历的索引与最长子序列的元素值不相等说明需要移动
尾声
不管是双端 diff 还是快速 diff,它们的核心起始就是找到需要移动的节点,做最小化移动操作。
实际上我们很难自己分析出双端 diff 和 快速 diff 的优劣,这里不考虑模板编译的加成,只有官方给出的对比数据说明快速 diff 要比双端 diff 优秀。
但是我们之前举的例子中其实已经能够看出快速 diff 的优势了,就在双端 diff 删除节点的例子,直接来看 step5:
我们想要的操作其实就是删除 E、D 节点,但是由于双端 diff 的流程把删除的操作放到了最后,而在它之前还需要进行查找节点移动操作,造成了 B 节点的移动,实际上这次 DOM 操作是完全没有必要的,直接把 E、D 节点删除即可。
快速 diff 就解决了这个问题,它在移动之前就先进行了删除操作,或者说快速 diff 把移动的性能消耗排列在最高位,因此尽量保证不进行移动节点,所以放到了最后,这也看出快速 diff 其本身的优势所在。