diff 算法主要用于比较更新前后两个虚拟 DOM 树,目的是能够实现对真实 DOM的高效更新,保证页面在数据变化时只进行最小程度的 DOM 操作。 vue2中采用的是双端diff算法,而vue3对其进行了一定的优化升级,采用了快速diff算法,这里主要介绍一下两种算法的具体步骤和主要区别。
1. vue2-双端diff算法
无论是vue2还是vue3的diff算法,有一个特点是相同的,就是只会进行同层级节点之间的比较,而不会进行跨层比较。
单个节点比较
从根节点开始,比较同一层级的新旧节点,对比每个节点是否相同。
这里的相同 是指节点的类型 相同以及key相同,比较的结果可以分为下面两类:
- 相同:说明旧的 DOM 节点可以进行复用,只需要对比新旧虚拟节点的属性并进行更新
- 不同:该节点以及往下的子节点没有意义了,因此直接销毁旧虚拟DOM对应的真实DOM,然后根据新虚拟DOM节点递归创建真实DOM,同时挂载到新虚拟DOM节点。
双端对比
在同一层级有多个节点需要比较时,具体的对比流程采用的就是双端对比,顾名思义新旧虚拟 DOM 树分别有一个头指针(oldStart\newStart)和一个尾指针(oldEnd\newEnd),指向头节点和尾节点。
这里我们要进行的是新旧虚拟节点 的比较,来决定旧的 DOM节点 要保持不动、进行移动还是删除;如果新虚拟节点 在旧虚拟节点列表中不存在,还要进行新的DOM节点的创建。
在比较过程中,按照下列具体五个步骤比较:
- 比较旧头节点(oldStart)和新头节点(newStart)
新旧虚拟节点相同的话表示旧的头DOM节点可以直接复用,将oldStart指针和newStart指针同时+1,继续步骤一;
不同的话进入步骤二。
- 比较旧尾节点(oldEnd)和新尾节点(newEnd)
同样,如果新旧虚拟节点相同表明旧的尾DOM节点可以直接复用,将 oldEnd 和 newEnd 指针同时-1,继续步骤一;
不同的话进入步骤三。
- 比较旧头节点(oldStart)和新尾节点(newEnd)
如果比较相同,表明旧的头DOM节点在更新以后已经变成了尾节点,需要进行移动操作,将旧的头DOM节点移动到旧的尾DOM节点之后。oldStart指针+1, newEnd指针-1。继续步骤一。
不同的话进入步骤四。
- 比较旧尾节点(oldEnd)和新头节点(newStart)
同理,如果比较相同,表明旧的尾DOM节点在更新以后已经成为了头节点,需要进行移动操作,需要将旧的尾DOM节点移动到旧的头DOM节点之前。oldEnd指针-1, newStart指针+1。继续步骤一。
不同的话进入步骤五。
- 经过上述四步之后,我们已经完成了头头、尾尾、头尾、尾头四种比较,如果上述四种比对都不相同的话,我们会进行暴力对比,直接在旧节点中遍历寻找新节点。
-
找到:如果在旧的虚拟节点列表中找到了当前的新虚拟节点,说明可以直接复用旧的DOM节点,将旧DOM节点移动到oldStart对应的DOM节点之前即可。
-
没找到:如果在旧的虚拟节点列表中没有找到当前的新虚拟节点,说明当前的虚拟节点在更新前不存在,需要创建新的DOM节点,并插入oldStart对应的DOM节点之前。
-
无论找到还是没找到,都需要在完成之后将newStart指针+1。继续从步骤一开始。
遍历完成
我们在不断进行上述五个步骤之后,最终节点指针一定只有以下两种情况:
- oldStart > oldEnd
- newStart > newEnd
情况1表明旧的虚拟节点已经完成了遍历,但是newStart-newEnd之间的新节点在旧节点中都不存在,因此需要逐个进行新节点的创建,并按顺序插入到oldStart对应的DOM节点之后。
情况2表明新的虚拟节点已经完成了遍历,但是oldStart-oldEnd之间的旧节点在新节点中不存在,因此需要逐个进行旧节点的删除。
经过上述所有步骤,我们就完成了 vue2 的双端diff,完成了DOM树的更新。
2. vue3-快速diff算法
vue2的双端diff算法相比于直接暴力比对已经有了一定程度的优化,减少了对真实DOM的操作次数。但是实际上它还有可以优化的空间,因为它仍然存在额外的移动操作。
以下图为例,根据双端diff算法,需要进行e节点和m节点的创建和插入操作,b、c、d节点的移动操作,但是我们对比新旧DOM可以发现实际上b、c、d节点在变化前后的相对顺序并没有发生变化,因此实际上我们并不需要去移动它们,只需要将a节点移动到d节点的后面就能完成更新。
vue3 针对这种多余的移动操作进行了优化,主要分为三个步骤:
- 头头相比
与vue2相同,比较新旧头节点
- 尾尾相比
与vue2相同,比较新旧尾节点
-
完成上述两个步骤之后,又要分为两种情况讨论:
(1)新旧列表有任意一方完成了全部节点的遍历:
- 新节点列表完成遍历,旧节点列表有剩余:将对应的旧 DOM 节点全部删除
- 旧节点完成遍历,新节点列表有剩余:创建对应的 DOM 节点,插入到旧头 DOM 节点之后
(2)新旧列表都没有完成全部节点的遍历:
这种情况处理较为复杂,也是 vue3 相对于 vue2 diff算法的主要优化之处,大致可以分为五个步骤:
-
初始化keyToNewIndexMap
keyToNewIndexMap 是一个 Map, 用来保存新节点下标。示意代码如下:遍历newStart-newEnd之间的新节点,将key和下标的映射存储到 keyToNewIndexMap 中。
jsconst keyToNewIndexMap = new Map(); for (let i = newStartIdx; i <= newEndIdx; i++) { const key = newChildren[i].key; keyToNewIndexMap.set(key, i); }
keyToNewIndexMap 主要作用是根据旧虚拟节点的 key 值快速找到其在新节点列表中对应的节点下标。
-
初始化newIndexToOldIndexMap
newIndexToOldIndexMap 是一个数组,其长度和未处理新节点个数的长度(
newEndIdx - newStartIdx + 1
)一致,并且初始化每一项都为0。随后遍历oldStart到oldEnd的节点,在之前建立的 keyToNewIndexMap 中查找当前旧节点的 key 对应的 newIndex, 查看是否有对应的新节点:
- newIndex存在:旧节点可以复用,继续进行下面三个步骤:1.调用 patch 函数更新节点内容,2.将旧节点的索引 +1 记录到
newIndexToOldIndexMap[newIndex - newStartIdx]
中(这里+1是为了将索引值0与初始值0做出区别,0是用来表示新节点在旧节点中不存在)
示意代码如下:
jslet maxNewIndexSoFar = 0; for (let i = oldStartIdx; i <= oldEndIdx; i++) { const oldNode = oldChildren[i]; let newIndex = keyToNewIndexMap.get(oldNode.key); if (!newIndex) { // 旧节点在新节点中不存在,卸载 } else { patch(oldNode, newChildren[newIndex], container); newIndexToOldIndexMap[newIndex - newStartIdx] = i + 1; } }
- newIndex不存在:说明该旧节点在新节点中不存在,需要进行卸载操作
- newIndex存在:旧节点可以复用,继续进行下面三个步骤:1.调用 patch 函数更新节点内容,2.将旧节点的索引 +1 记录到
-
计算最长递增子序列
终于到了 vue3 相关的面试经典题--计算最长递增子序列。这一步主要针对上一步得到的
newIndexToOldIndexMap
,计算出其中最长的递增子序列increasingNewIndexSequence
。newIndexToOldIndexMap
是新节点在旧节点列表中的索引值数组,而计算出的最长递增子序列increasingNewIndexSequence
所代表的含义是在新旧节点列表中相对顺序保持不变的最长的节点序列,对于increasingNewIndexSequence
中的节点我们不需要进行任何操作,因为它们的相对顺序并未发生任何变化。我们只需要移动或是新建子序列之外的节点,就能通过最少的操作步骤完成节点的更新。举例来说,我们上一步得到的
newIndexToOldIndexMap
是 [0,1,2,4,3,5],最长递增子序列包含的元素是1、2、3、5,对应的索引值数组是[1,2,4,5],这就表明我们只需要对新节点列表索引为newStart+0的节点进行新建操作,对旧节点列表索引为2的节点进行移动操作即可。- 移动和挂载节点
根据上一步计算的结果我们获取了需要新建和移动的节点索引,在这一步我们进行具体的操作:
-
计算当前新节点在新节点列表中的索引
newIndex = newStartIdx + i
- 对 i 从大(newEnd - newStart)到小(0)进行倒序遍历,之所以采用倒序遍历是节点的草如操作需要后续节点作为锚点,因此从后往前来完成节点的更新
- newStartIdx 是未处理节点的起始索引
-
获取锚点 DOM,其目的是为了作为节点移动的参照物
- 计算方法为
newIndex + 1 < newChildren.length ? newChildren[newIndex + 1].el : null
- 计算方法为
-
根据
newIndexToOldIndexMap
判断新节点需要创建还是移动- 如果
newIndexToOldIndexMap[i] === 0
,表明节点之前不存在,需要进行创建 - newIndexToOldIndexMap[i]不为零,又可以分为两种情况:
increasingNewIndexSequence.includes(i) === true
,当前节点存在于最长递增子序列中,不需要进行任何操作increasingNewIndexSequence.includes(i) === false
,当前节点不存在于最长递增子序列,需要进行移动操作,移动到锚点之前。
依然以 newIndexToOldIndexMap 为 [0,1,2,4,3,5] 为例:
inii === 5, 锚点DOM为null newIndexToOldIndexMap[i] !== 0 表明新节点在旧节点列表中存在,可复用 increasingNewIndexSequence.includes(i) === true 表明节点在最长递增子序列中,不需要进行任何操作 i === 4, 锚点DOM为节点5的真实DOM newIndexToOldIndexMap[i] !== 0 表明新节点在旧节点列表中存在,可复用 increasingNewIndexSequence.includes(i) === false 表明节点不在最长递增子序列中,需要进行移动操作,挂载到锚点之前 i === 3, 锚点DOM为节点4的真实DOM newIndexToOldIndexMap[i] !== 0 表明新节点在旧节点列表中存在,可复用 increasingNewIndexSequence.includes(i) === true 表明节点在最长递增子序列中,不需要进行任何操作 i === 2, 锚点DOM为节点3的真实DOM newIndexToOldIndexMap[i] !== 0 表明新节点在旧节点列表中存在,可复用 increasingNewIndexSequence.includes(i) === true 表明节点在最长递增子序列中,不需要进行任何操作 i === 1, 锚点DOM为节点2的真实DOM newIndexToOldIndexMap[i] !== 0 表明新节点在旧节点列表中存在,可复用 increasingNewIndexSequence.includes(i) === true 表明节点在最长递增子序列中,不需要进行任何操作 i === 0, 锚点DOM为节点1的真实DOM newIndexToOldIndexMap[i] !== 0 表明新节点在旧节点列表中不存在,需要新建节点并挂在到锚点之前
实际上我们进行了两步操作就完成了节点的更新,1-将i===4的DOM节点挂载到i===5的DOM节点之前,2-新建i===0的DOM节点并挂载到i===1的DOM节点之前
- 如果
小结
vue2 采用双端 diff 算法,vue3 采用快速 diff 算法。
二者之间存在相同的步骤,就是都会进行头头、尾尾节点的对比。
主要的区别在于之后的步骤中,vue2 会继续进行旧头新尾和旧尾新头的比较,如果都不匹配就继续进行暴力比对。这样的方法存在的主要问题就是会增加节点的操作步骤。
vue3 通过建立新节点在旧节点中对应的索引列表并求出最长递增子序列,最长递增子序列中的元素在更新前后相对顺序并未发生变化,因此无需进行任何操作,只需要对其它节点进行新建或是移动操作即可。这种方法相比于 vue2 大大减少了对节点的操作次数。