VNode 的 diff 算法都是对同级节点的比较,因为如果节点类型不同,会直接视为发生了替换,如果节点类型相同,才会进行下一步对比,因此可以将时间复杂度做到线性对数阶甚至线性阶。
单节点的 diff 比较简单,我们主要关注对子节点列表的 diff。
Vue3 在不同场景下有不同的 diff 策略。
patchBlockChildren
首先 Vue 将一些编译时的优化信息保存到了渲染函数中,某些 VNode 节点在创建时会生成 Block 节点,使用 dynamicChildren
属性收集节点内的动态子节点,也就是官方文档里面提到的树结构打平。组件模板根节点、条件渲染、列表渲染、Fragment
、Teleport
等节点都会创建 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
第二轮循环使用索引 oldEnd
和 newEnd
,从尾部递减,比较两组尾部节点。
1.3 mount
如果旧节点处理完毕,有未处理的新节点,说明都是新增节点,依次挂载到 newEnd
前。
1.4 unmount
如果新节点处理完毕,有未处理的旧节点,说明都需要卸载,依次卸载即可。
2. 快速 diff
核心逻辑
如果新旧节点都未处理完毕,需要对剩下的节点进一步操作。
2.1 创建辅助结构
使用剩下的新节点构建一个 keyToNewIndexMap
集合保存 key
到 newIndex
的映射关系。
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)
}