最近在深入学习 Virtual DOM
的相关知识,参考了许多资料,也拜读了许多大神的文章,所以在这里大致的整理成了比较适合自己理解的方式,方便时不时回来翻翻,复习一下,篇幅较长,主要会分为三个部分来分别进行介绍,具体章节如下,目录名就差不多代表了章节的相关内容
在上一章当中,我们主要讨论了渲染器是如何更新各种类型的 VNode
的,本章是第三部分,也是最后一部分,主要介绍渲染器当中的核心,也就是传说中的 diff
算法,主要参考的是 HcySunYang/vue-design,本章相关内容如下
- 减小
DOM
操作的性能开销 - 尽可能的复用
DOM
元素key
的作用- 找到需要移动的节点
- 移动节点
- 添加新元素
- 移除不存在的元素
- 双端比较算法
- 非理想情况
- 添加新元素
- 移除不存在的元素
inferno
当中的Diff
算法- 相同的前置和后置元素
- 判断是否需要进行
DOM
移动 DOM
移动的方式
减小 DOM 操作的性能开销
在上一章当中,我们在遇到了旧的 children
和新的 children
均为多个子节点的情况,如下图
我们当时提到,只有当新旧子节点的类型都是多个子节点时,核心 diff
算法才派得上用场,并且当时我们采用了一种仅能实现目标但并不完美的算法,即遍历旧的子节点,将其全部移除,再遍历新的子节点,将其全部添加,虽然能够达到目的,但并非最佳处理方式,在正式展开之前,我们先来思考一下上面的算法存在的问题,可以简化为下图
简单来说就是遍历旧的 VNode
,通过旧 VNode
对真实 DOM
的引用取得真实 DOM
,即可将已渲染的 DOM
移除,接着遍历新的 VNode
并将其全部添加到页面中,我们可以先假定它们都是 <li>
标签,想象一下如果它们只是单纯的交换位置,或者只是简单的调整了一下包含的文本,我们是不是可以复用已有 DOM
元素呢?
实际上是可以的,我们在介绍 pathcElement
函数时了解到,当新旧 VNode
所描述的是相同标签时,那么这两个 VNode
之间的差异就仅存在于 VNodeData
和 children
上,所以我们完全可以通过遍历新旧 VNode
,并一一比对它们,这样对于任何一个 DOM
元素来说,由于它们都是相同的标签,所以更新的过程是不会移除和新建任何 DOM
元素的,而是复用已有 DOM
元素,需要更新的只有 VNodeData
和 children
,优化后的算法如下图所示
但是会发现有问题,如果新旧 children
的长度相同的话是可行的,但是如果不同呢,所以我们就可以大致的分为三种情况来单独的进行处理,代码如下
1 | function patchChildren(prevChildFlags, nextChildFlags, prevChildren, nextChildren, container) { |
实际上,这个算法就是在没有 key
时所采用的算法,该算法是存在优化空间的,下面我们将分析如何进一步优化
尽可能的复用 DOM 元素
在上一小节中,我们通过减少 DOM
操作的次数使得更新的性能得到了提升,但它仍然存在可优化的空间,我们假设新旧 children
如下
1 | // 旧的 children |
如果还是使用我们之前的算法,patch
函数知道它们是相同的标签,所以只会更新 VNodeData
和子节点,由于这两个标签都没有 VNodeData
,所以只需要更新它们的子节点,它会依次进行比对,然后调用 patchText
方法来更新文本子节,比如在上面的示例当中,它会执行三次,实际上我们通过观察可以很明显的发现,最佳的操作应该是『通过移动元素的位置来达到更新的目的』,那么应该如何移动元素来完成更新呢?
key 的作用
所以,我们需要在新旧 children
的节点中保存映射关系,以便我们能够在旧 children
的节点中找到可复用的节点,这时候我们就需要给 children
中的节点添加唯一标识,也就是我们常说的 key
,有了 key
我们就能够明确的知道新旧 children
中节点的映射关系,比如下面这个例子
知道了映射关系,我们就很容易判断新 children
中的节点是否可被复用,我们只需要遍历新 children
中的每一个节点,并去旧 children
中寻找是否存在具有相同 key
值的节点
1 | // 遍历新的 children |
找到需要移动的节点
我们已经找到了可复用的节点,并进行了合适的更新操作,下一步需要做的,就是判断一个节点是否需要移动以及如何移动
我们可以先来看看不需要移动的情况,如上图,我们假定以上面的算法来执行,流程是下面这样的
- 取出新
children
的第一个节点,并尝试在旧children
中寻找li-a
,找到了,索引为0
- 取出新
children
的第二个节点,并尝试在旧children
中寻找li-b
,找到了,索引为1
- 取出新
children
的第三个节点,并尝试在旧children
中寻找li-c
,找到了,索引为2
可以发现,我们先后遇到的索引顺序依次为 0 ==> 1 ==> 2
,这是一个递增的顺序,这就说明
- 如果在寻找的过程中遇到的索引呈现递增趋势,则说明新旧
children
中节点顺序相同,不需要移动操作 - 相反的,如果在寻找的过程中遇到的索引值不呈现递增趋势,则说明需要移动操作
下面再来看一个例子
还是按照我们上面的流程,来看一看执行结果
- 取出新
children
的第一个节点,并尝试在旧children
中寻找li-c
,找到了,索引为2
- 取出新
children
的第二个节点,并尝试在旧children
中寻找li-a
,找到了,索引为0
- 取出新
children
的第三个节点,并尝试在旧children
中寻找li-b
,找到了,索引为1
这次可以发现,递增的趋势被打破了,索引顺序依次为 2 ==> 0 ==> 1
在第二次查找过程中我们遇到了 0 < 2
的情况,这说明在旧 children
中 li-a
的位置要比 li-c
靠前,但在新的 children
中 li-a
的位置要比 li-c
靠后,所以得知 li-a
是需要被移动的
在第三次查找过程中,1
也是小于 2
的,这说明在旧 children
中节点 li-b
的位置也要比 li-c
的位置靠前,但在新的 children
中 li-b
的位置要比 li-c
靠后,所以 li-b
也需要被移动
在这个过程当中可以发现,因为有 2
这个数字的存在才使得我们能够知道哪些节点需要移动,我们可以把它称为,寻找过程中在旧 children
中所遇到的最大索引值
如果在后续寻找的过程中发现存在索引值比最大索引值小的节点,意味着该节点需要被移动
实际上,这就是 React
所使用的算法,我们可以使用一个叫做 lastIndex
的变量存储寻找过程中遇到的最大索引值,并且它的初始值为 0
移动节点
现在我们已经有办法找到需要移动的节点了,接下应该如何移动这些节点?先还是按照之前的流程来运行一下我们之前的示例
- 新
children
中的第一个节点是li-c
,它在旧children
中的索引为2
,由于li-c
是新children
中的第一个节点,所以它始终都是不需要移动的,只需要调用patch
函数更新即可
这里有一个需要注意的地方,即新 children
中的 li-c
节点在经过 patch
函数之后,也将存在对真实 DOM
元素的引用(因为我们在 patchElement
函数当中已经让 nextVNode.el
也引用了该元素)
- 新
children
中的第二个节点li-a
,它在旧children
中的索引是0
,由于0 < 2
所以li-a
是需要移动的节点,所以我们需要做的就是把li-a
节点对应的『真实』DOM
移动到li-c
节点所对应『真实』DOM
的后面
1 | // 用来存储寻找过程中遇到的最大索引值 |
我们可以用下图来描述这个过程
添加新元素
我们之前考虑的只是一种情况,如果在新 children
中包含了一个全新的节点,这意味着在旧 children
中是找不到该节点的,针对这种情况我们需要单独处理
1 | let lastIndex = 0 |
移除不存在的元素
当然,除了要将全新的节点添加到容器元素之外,我们还应该把已经不存在了的节点移除
我们需要在外层循环结束之后,再优先遍历一次旧的 children
,并尝试拿着旧 children
中的节点去新 children
中寻找相同的节点,如果找不到则说明该节点已经不存在于新 children
中了,这时我们应该将该节点对应的真实 DOM
移除
1 | let lastIndex = 0 |
至此,第一种算法我们算是介绍完毕了,这个也就是 React
所采用的 diff
算法,不过还有可以优化的空间,我们后面在进行介绍
双端比较算法
之前提到 React
的 diff
算法是存在优化空间的,想要要找到优化的关键点,我们首先要知道它存在什么问题,来看下图
我们可以通过观察可以发现,其实最优的解决方案应该是把 li-c
节点对应的真实 DOM
移动到最前面即可,只需要一次移动即可完成更新,然而 React
所采用的 diff
算法在更新如上案例的时候,会进行两次移动
所以在这种情况下,我们就可以采用一种新的思路,即『双端比较』,所谓双端比较,就是同时从新旧 children
的两端开始进行比较的一种方式,所以我们需要四个索引值,分别指向新旧 children
的两端,如下图所示
我们使用四个变量 oldStartIdx
、oldEndIdx
、newStartIdx
以及 newEndIdx
分别存储旧 children
和新 children
的两个端点的位置索引,除了位置索引之外,我们还需要拿到四个位置索引所指向的 VNode
,用代码表示如下
1 | let oldStartIdx = 0 |
在一次比较过程中,最多需要进行四次比较
- 使用旧
children
的头一个VNode
与新children
的头一个VNode
比对,即oldStartVNode
和newStartVNode
比较对 - 使用旧
children
的最后一个VNode
与新children
的最后一个VNode
比对,即oldEndVNode
和newEndVNode
比对 - 使用旧
children
的头一个VNode
与新children
的最后一个VNode
比对,即oldStartVNode
和newEndVNode
比对 - 使用旧
children
的最后一个VNode
与新children
的头一个VNode
比对,即oldEndVNode
和newStartVNode
比对
可以用下图来描述在一次比对过程中的四个步骤
下面是对该过程的实现,其实简单来说就是上述四个步骤分别对应着不同的结果,我们只需要针对结果进行不同的处理即可
1 | if (oldStartVNode.key === newStartVNode.key) { |
每次比对完成之后,如果在某一步骤中找到了可复用的节点,我们就需要将相应的位置索引后移或者前移一位,以上图为例
- 拿旧
children
中的li-a
和新children
中的li-d
进行比对,由于二者key
值不同,所以不可复用,什么都不做 - 拿旧
children
中的li-d
和新children
中的li-c
进行比对,同样不可复用,什么都不做 - 拿旧
children
中的li-a
和新children
中的li-c
进行比对,什么都不做 - 拿旧
children
中的li-d
和新children
中的li-d
进行比对,由于这两个节点拥有相同的key
值,所以我们在这次比对的过程中找到了可复用的节点
即 oldEndVNode
和 newStartVNode
拥有相同的 key
值,这说明 li-d
节点所对应的真实 DOM
原本是最后一个子节点,并且更新之后它应该变成第一个子节点,所以我们需要把 li-d
所对应的真实 DOM
移动到最前方即可
1 | if (oldStartVNode.key === newStartVNode.key) { |
这一步更新完成之后,新的索引关系可以用下图来表示
这样,一次比对就完成了,并且位置索引已经更新,那么什么时候比对才能结束呢?如下
1 | while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { |
我们将每一轮比对所做的工作封装到一个 while
循环内,循环结束的条件是要么 oldStartIdx
大于 oldEndIdx
,要么 newStartIdx
大于 newEndIdx
,如此往复下去,流程如下图所示
最后得到的结果如下
1 | while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { |
如上,在经过循环对比之后,我们可以发现真实的 DOM
的顺序已经与新 children
中节点的顺序保持一致了,也就是说我们圆满的完成了目标,另外观察上述流程可以发现,此时 oldStartIdx
和 newStartIdx
分别比 oldEndIdx
和 newEndIdx
要大,所以这将是最后一轮的比对,循环将终止,以上就是双端比较的核心原理
非理想情况
在之前的讲解中,我们所采用的是较理想的例子,换句话说,在每一轮的比对过程中,总会满足四个步骤中的一步,但实际上大多数情况下并不会这么理想,如下图所示,在上面的每一步对比当中,都无法找到可复用的节点,在这种情况下,我们只能拿新 children
中的第一个节点尝试去旧 children
中寻找,试图找到拥有相同 key
值的节点
1 | while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { |
这里的关键点并不在于我们找到了位置索引,而是要明白,当我们在旧的 children
中找到了与新 children
中第一个节点拥有相同 key 值的节点,这就意味着旧 children
中的这个节点所对应的真实 DOM
在新 children
的顺序中,已经变成了第一个节点,所以我们需要把该节点所对应的真实 DOM
移动到最前头
1 | while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { |
我们用一张图来描述这个过程结束之后的状态
由于原本旧 children
中的 li-b
节点,此时已经变成了 undefined
,所以在后续的比对过程中 oldStartIdx
或 oldEndIdx
二者当中总会有一个位置索引优先达到这个位置,也就是说此时 oldStartVNode
或 oldEndVNode
两者之一可能是 undefined
,这说明该位置的元素在之前的比对中被移动到别的位置了,所以不再需要处理该位置的节点,这时我们需要跳过这一位置
1 | while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { |
当 oldStartVNode
或 oldEndVNode
不存在时,说明该节点已经被移动了,我们只需要跳过该位置即可,以上就是我们所说的双端比较的非理想情况的处理方式
添加新元素
在上一小节中,我们尝试拿着新 children
中的第一个节点去旧 children
中寻找与之拥有相同 key
值的可复用节点,然后并非总是能够找得到,当新的 children
中拥有全新的节点时,就会出现找不到的情况
由于 li-d
节点的位置索引是 newStartIdx
,这说明 li-d
节点是当前这一轮比较中的当前索引范围内的第一个节点,所以只要把它挂载到位于 oldStartIdx
位置的节点所对应的真实 DOM
前面就可以了,即 oldStartVNode.el
,只需要增加一行代码即可
1 | while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { |
我们再来看下面这种情况
在上图最后一种情况当中,此时 oldEndIdx
的值将变成 -1
,它要小于 oldStartIdx
的值,这时循环的条件不在满足,意味着更新完成,然而通过上图可以很容易的发现 li-d
节点被遗漏了,所以我们需要在循环终止之后,对 oldEndIdx
和 oldStartIdx
的值再次进行检查
如果在循环结束之后 oldEndIdx
的值小于 oldStartIdx
的值则说明新的 children
中『存在还没有被处理的全新节点』,这时我们应该调用 mount
函数将其挂载到容器元素中
1 | while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { |
这样我们就完整的实现了完整的添加新节点的功能
移除不存在的元素
对于双端比较,最后一个需要考虑的情况就是当有元素被移除时的情况
通过上图可以发现,在循环结束之后,并不满足条件 oldEndIdx < oldStartIdx
而是满足条件 newEndIdx < newStartIdx
,基于此,我们可以认为循环结束后,一旦满足条件 newEndIdx < newStartId
则说明有元素需要被移除
1 | while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { |
以上就是相对完整的双端比较算法的实现,这是 Vue2
所采用的算法,借鉴于开源项目 snabbdom
inferno 当中的 Diff 算法
在 Vue3
中采用了另外一种 diff
算法,它借鉴于 ivi 和 inferno,在 DOM
操作的各个方面,ivi
和 inferno
都要稍优于 vue2
的双端比较,例如在创建 VNode
时就确定其类型,以及在 mount/patch
的过程中采用位运算来判断一个 VNode
的类型,在这个基础之上再配合核心的 diff
算法,才使得性能上产生一定的优势,本节我们就来看看这个算法的实现原理
相同的前置和后置元素
实际上本节介绍的 diff
算法最早应用于两个不同文本之间的差异比较,在真正进行核心的 diff
算法之前,会有一个预处理的过程,例如可以先对两个文本进行类似相等的比较,比如我们有两段文本,如下
1 | text1: I use vue for app development |
我们通过肉眼可以很容易的发现,这两段文本头部和尾部分别有一段相同的文本,所以真正需要进行 diff
的部分就变成了
1 | text1: vue |
这么做的好处是:在某些情况下,我们能够轻松的判断出单独的文本插入和删除,比如
1 | text1: I like react |
又或者是下面这种
1 | text1: I like react too |
很显然,该预处理过程在上例的情况下能够避免 diff
算法的执行,从而提高 diff
效率,我们来尝试着将其应用到我们 VNode
的 diff
中,总共分为四个阶段,流程如下
如上图第一部分所示,新旧 children
拥有相同的前缀节点和后缀节点,对于前缀节点,我们可以建立一个索引,指向新旧 children
中的第一个节点,并逐步向后遍历,直到遇到两个拥有不同 key
值的节点为止
1 | // 更新相同的前缀节点 |
操作完成的状态如上图第二部分所示,这里有一个需要注意的地方就是,当 while
循环终止时,索引 j
的值为 1
,接着我们需要处理的是相同的后缀节点,由于新旧 children
中节点的数量可能不同,所以我们需要两个索引分别指向新旧 children
的最后一个节点,并逐步向前遍历,直到遇到两个拥有不同 key
值的节点为止
1 | // 更新相同的后缀节点 |
该步操作完成以后为上图第三部分所示,同样需要注意的是,在这一步更新完成之后 prevEnd
的值为 0
,nextEnd
的值为 1
,现在,我们就着重的来看看这三个字段的值
1 | j: 1 |
我们发现在这种情况下 j > prevEnd
并且 j <= nextEnd
,这说明当新旧 children
中相同的前缀和后缀被更新之后,旧 children
中的节点已经被更新完毕了,而新 children
中仍然有剩余节点
实际上新 children
中位于 j
到 nextEnd
之间的所有节点都应该是新插入的节点(上图最后一部分),观察得知,新的节点都出现在 li-b
节点的前面,而我们又可以使用 nextEnd + 1
来表示 li-b
节点的位置,所以我们可以使用一个循环遍历索引 j -> nextEnd
之间的节点,并逐个将其插入到 li-b
节点之前即可
1 | // 满足条件,则说明从 j -> nextEnd 之间的节点应作为新节点插入 |
同样的,我们在来看下面这个反过来的示例,分为两个阶段
在我们去掉意义上相同的后缀之后,也同样可以得到三个值
1 | j: 1 |
我们发现在这种情况下 j > nextEnd
并且 j <= prevEnd
,这就说明在旧 children
中有位于索引 j
到 prevEnd
之间的节点,都应该被移除
1 | if (j > prevEnd && j <= nextEnd) { |
我们可以来观察一下以上的代码结构
1 | // while 循环向后遍历,直到遇到拥有不同 key 值的节点为止 |
我们发现在两个 while
循环中,索引 j
和 索引 prevEnd
、nextEnd
是以从两端向中间靠拢的趋势在变化的,而在两个 while
循环结束之后,我们会根据这三个索引的大小关系来决定应该做什么样的操作
但是我们可以发现,假设在第一个 while
循环结束之后,索引 j
的值已经大于 prevEnd
或 nextEnd
,那么就已经没有必要再去执行第二个 while
循环了,因为一旦索引 j
大于 prevEnd
则说明旧 children
与新 children
中的所有节点都已经参与了 patch
,这时也就没有必要再执行后续的操作了
1 | outer: { |
我们定义了 label
名字为 outer
的 label
语句块,并分别在两个 while
循环中添加了判断语句,无论在哪个循环中,只要索引 j
的值大于了 prevEnd
或 nextEnd
二者之一,我们就 break
该语句块,从而避免了无用的代码执行
判断是否需要进行 DOM 移动
其实在上文部分,我们通过判断索引的大小关系,能够提前知道哪些元素被添加,哪些元素被移除,但这毕竟属于一种特殊情况,大部分情况下可能未必如此理想,如下图当中第一部分所示
实际上无论是 React
的 diff
算法,还是 snabbdom
的 diff
算法,其重点无非就是,判断是否有节点需要移动,以及应该如何移动和寻找出那些需要被添加或移除的节点,所以接下来,我们就来看看如何判断那些节点需要移动,以及如何移动
通过观察上图当中第二部分逻辑,我们会发现,此时索引 j
既不大于 prevEnd
也不大于 nextEnd
,所以我们需要添加新的判断来处理这种情况,在这种情况之下,我们需要构造一个数组 source
,该数组的长度等于新 children
在经过预处理之后剩余未处理节点的数量,并且该数组中每个元素的初始值为 -1
,如上图第三部分所示
该数组中的每一个元素分别与新 children
中剩余未处理的节点对应,实际上 source
数组将用来存储新 children
中的节点在旧 children
中的位置,后面将会使用它计算出一个最长递增子序列,并用于 DOM
移动,如上图第四部分所示,我们可以通过两层 for
循环来完成这个工作,外层循环用于遍历旧 children
,内层循环用于遍历新 children
,完成后代码如下
但是这里存在一个小问题,我们使用了双层 for
循环,所以其时间复杂度为 O(n^2)
,在这里我们可以采用『空间换时间的方式』,将复杂度降低到 O(n)
,所以我们为新的 children
中的节点构建一个 key
到位置索引的索引表,如上图最后一部分所示,其实无论采用哪一种方式,最终我们的目的是对新旧 children
中具有相同 key
值的节点进行更新,同时检测是否需要移动操作
这里需要注意,我们可以从图中看到,我们的
source
数组的第四个元素值仍然为初始值-1
,这是因为新children
中的li-g
节点不存在于旧children
中
综合后的代码如下所示
1 | /** |
DOM 移动的方式
在上一小节,我们的主要目的有两个
- 判断出是否需要进行
DOM
移动操作,所以我们建立了moved
变量作为标识,当它的值为true
时则说明需要进行DOM
移动 - 构建
source
数组,它的长度与去掉相同的前置或者后置节点后新children
中剩余未处理节点的数量相等,并存储着新children
中的节点在旧children
中位置,后面我们会根据source
数组计算出一个最长递增子序列,并用于DOM
移动操作
关于最长递增子序列可见 求解给定序列的最长递增子序列,这里就不详细展开了
可以如上面小结当中最后一步流程所示,现在我们已经可以通过判断变量 moved
的值来确定是否需要进行 DOM
移动操作
1 | if (moved) { |
实现流程如下图所示
我们对新 children
中的剩余未处理节点进行了重新编号,li-c
节点的位置是 0
,以此类推,而最长递增子序列是 [ 0, 1 ]
(代表的是最长递增子序列中的各个元素在 source
数组中的位置索引),这就说明
在新
children
的剩余未处理节点中,位于位置0
和位置1
的节点的先后关系与他们在旧children
中的先后关系相同,或者我们可以理解为位于位置0
和位置1
的节点是不需要被移动的节点
简单总结就是,通过最长递增子序列,我们可以在上图第一部分当中得到如下信息
li-c
节点和li-d
节点将在接下来的操作中不会被移动- 换句话说只有
li-b
节点和li-g
节点是可能被移动的节点 - 又因
li-g
节点位置对应的source
数组元素的值为-1
,这说明应该作为全新的节点被挂载 - 所以只有
li-b
节点需要被移动
所以,如图中第二部分所示,我们使用两个索引 i
和 j
分别指向新 children
中剩余未处理节点的最后一个节点和最长递增子序列数组中的最后一个位置,并从后向前遍历,结合上图,我们可以得到如下代码
1 | if (moved) { |
参考
如果想了解更多的相关内容,可以参考以下链接