Vue3 Diff 算法详解

201 阅读4分钟

VNode 的 diff 算法都是对同级节点的比较,因为如果节点类型不同,会直接视为发生了替换,如果节点类型相同,才会进行下一步对比,因此可以将时间复杂度做到线性对数阶甚至线性阶。

单节点的 diff 比较简单,我们主要关注对子节点列表的 diff。

Vue3 在不同场景下有不同的 diff 策略。

patchBlockChildren

首先 Vue 将一些编译时的优化信息保存到了渲染函数中,某些 VNode 节点在创建时会生成 Block 节点,使用 dynamicChildren 属性收集节点内的动态子节点,也就是官方文档里面提到的树结构打平。组件模板根节点、条件渲染、列表渲染、FragmentTeleport 等节点都会创建 Block 节点。

有了编译器收集的动态节点,我们可以就跳过静态节点,直接处理动态。这类节点都是稳定的,我们直接按顺序 patch 即可。

for (let i = 0; i < newChildren.length; i++) {
  const oldVNode = oldChildren[i]
  const newVNode = newChildren[i]

  patch(oldVNode, newVNode)
}

patchKeyedChildren

但是列表渲染的节点是不稳定的,或者一些其他场景需要进行 full diff,这个时候就需要用到快速 diff 算法了。

1. 双端预处理

我们先对前置节点和后置节点进行预处理,开启两轮循环,找出在新旧两组节点中匹配的节点 n1.type === n2.type && n1.key === n2.key 直接 patch 即可 ;

1.1 sync from start

第一轮循环使用索引 i,从 0 开始递增,比较两组头部结点。

1.2 sync from end

第二轮循环使用索引 oldEndnewEnd,从尾部递减,比较两组尾部节点。

1.3 mount

如果旧节点处理完毕,有未处理的新节点,说明都是新增节点,依次挂载到 newEnd 前。

1.4 unmount

如果新节点处理完毕,有未处理的旧节点,说明都需要卸载,依次卸载即可。

2. 快速 diff 核心逻辑

如果新旧节点都未处理完毕,需要对剩下的节点进一步操作。

2.1 创建辅助结构

使用剩下的新节点构建一个 keyToNewIndexMap 集合保存 keynewIndex 的映射关系。

const keyToNewIndexMap = new Map()
for (...) keyToNewIndexMap.set(newChild.key, newIndex)

新建一个 newIndexToOldIndexMap 数组,保存新节点匹配的节点在旧节点列表中的位置,初始值为 0

// 后面我们会使用这个数组构建一个最长递增子序列
// 这里使用 0 作为默认值是为了和源码保持统一,这个特殊值说明新节点在旧节点列表中没有匹配的节点
// 后面在保存索引时,需要 +1 避免和索引 0 冲突,因为我们关心的是大小顺序,+1 不影响最终结果
const newIndexToOldIndexMap = new Array(toBePatched).fill(0)

2.2 patch and unmount

遍历剩下的旧节点,在 keyToNewIndexMap 中通过 key 找到匹配的新节点,然后 patch,并在 newIndexToOldIndexMap 更新 newIndex 对应的值 oldIndex+1

如果找不到匹配的节点,说明这个旧节点需要卸载,unmount 即可。

2.3 最长递增子序列

在上面遍历旧节点列表的过程中,我们会通过匹配新节点的索引变化关系判断是否有节点发生了移动,如果发生了移动,就在遍历结束后,计算出一个最长递增子序列,来帮助我们等会儿高效地进行移动操作。

const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : []

最长递增子序列保存的是新节点的索引,这些新节点对应的旧节点索引在原旧节点列中重呈递增关系,这几个节点如果保持不动,就能达到尽可能减少 DOM 移动操作的目的。

关于最长递增子序列的计算可以看这里:最长递增子序列算法——逐行分析、看完就懂

2.4 move and mount

反向遍历剩下的新节点,从 newIndexToOldIndexMap 数组中查看该节点在旧节点列表中的 oldIndex+1

  • 如果为初始值 0,说明是新增节点,mount 新节点到下一个新节点前面即可。
  • 否则说明有匹配的旧节点,判断节点是否发生了移动 if (moved) {...}
    • 如果没有发生了移动,则什么也不需要做,因为之前遍历旧节点时已经 patch 过了。
    • 如果发生了移动,判断该节点是否需要移动。
      • 如果该节点的索引,等于最长递增子序列的最后一个值,说明该节点是不需要移动的,我们只需要消费最后一个值,然后进入下一轮循环。
      • 否则说明该节点需要移动,移动到下一个新节点前即可。

至此,更新、卸载、挂载、移动操作都已经处理完毕。

patchUnkeyedChildren

如果我们在使用列表渲染时,没有为节点设置 key,那么就会使用就地更新的策略。

1. patch common

首先对相同位置的新旧节点执行 patch 更新。

const oldLength = c1.length
const newLength = c2.length
const commonLength = Math.min(oldLength, newLength)
for (i = 0; i < commonLength; i++) {
    patch(c1[i], c2[i])
}

2. remove old

如果旧节点列表更长,unmount 卸载剩余节点。

if (oldLength > newLength) {
    const start = commonLength
    unmountChildren(c1, start)
}

3. mount new

如果新节点列表更长,mount 挂载多余节点。

if (newLength > oldLength) {
    const start = commonLength
    mountChildren(c2, start)
}