初始状态:
arr1
(旧子节点):['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
arr2
(新子节点):['a', 'b', 'e', 'c', 'd', 'i', 'g', 'h']
1. 从头部开始同步
从两个数组的头部开始,逐一比较对应位置的节点,直到遇到不同的节点为止。i
指针递增。
let i = 0 // 起始索引
const l2 = c2.length // 新子节点的长度
let e1 = c1.length - 1 // 旧子节点的尾部索引
let e2 = l2 - 1 // 新子节点的尾部索引
// 1. 从头部开始同步
// (a b) c
// (a b) d e
while (i <= e1 && i <= e2) {
// 当前比较的旧 vnode 和新 vnode
const n1 = c1[i]
const n2 = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
// 类型相同则进行深度比较
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
// 类型不同跳出循环
} else {
break
}
i++ // 头部指针自增
}
比较过程:
- 比较
arr1[0]
和arr2[0]
-> 都是'a'
,相同,继续。 - 比较
arr1[1]
和arr2[1]
-> 都是'b'
,相同,继续。 - 到达
arr1[2]
和arr2[2]
->'c'
vs'e'
,不同,停止。
头部指针
i
自增到 2
2. 从尾部开始同步
从两个数组的尾部开始,逐一比较对应位置的节点,直到遇到不同的节点为止。e1
和 e2
指针递减。
// 2. 从尾部开始同步
// a (b c)
// d e (b c)
while (i <= e1 && i <= e2) {
// 获取旧 vnode 的尾部元素
const n1 = c1[e1]
// 获取 vnode 的尾部元素,并进行优化处理:如果节点已挂载,则克隆它;否则规范化它
const n2 = (c2[e2] = optimized
? cloneIfMounted(c2[e2] as VNode)
: normalizeVNode(c2[e2]))
// 类型相同则进行深度比较
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
// 类型不同跳出循环
} else {
break
}
// 移动到数组的前一个元素进行下一次比较
e1-- // 旧子节点尾部指针递减
e2-- // 新子节点尾部指针递减
}
比较过程:
- 比较
arr1[7]
和arr2[7]
-> 都是'h'
,相同,继续。 - 比较
arr1[6]
和arr2[6]
-> 都是'g'
,相同,继续。 - 到达
arr1[5]
和arr2[5]
->'f'
vs'i'
,不同,停止。
旧子节点尾部指针
e1
自减2,变为5新子节点尾部指针
e2
自减2,变为5
3. 新子节点有剩余,添加新节点
成立条件为 i > e1
。i > e1
表示所有旧子节点都已经遍历完成,此时仍有新子节点没有被遍历到,添加它们。
// 3. 新子节点有剩余,要添加新节点
// (a b)
// (a b) c
// i = 2, e1 = 1, e2 = 2
// (a b)
// c (a b)
// i = 0, e1 = -1, e2 = 0
// 检查是否已处理完所有旧子节点,但仍有新子节点要处理
if (i > e1) {
// 检查是否还有未处理的新子节点
if (i <= e2) {
// 计算下一个要处理的新子节点的位置
const nextPos = e2 + 1
// 确定要插入的锚点。如果下一个位置小于新子节点数组的长度,
// 则使用该位置的元素作为锚点;否则,使用父锚点。
const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
// 循环处理剩余的新子节点
while (i <= e2) {
// 对每个新子节点执行 patch 操作。
// 如果启用了优化并且节点已挂载,则克隆它;否则,规范化它。
patch(
null, // 由于这是挂载新节点,旧节点参数为 null
(c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i])),
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
// 移动到下一个新子节点
i++
}
}
}
此时
i = 2
,e1 = 5
,e2 = 5
由于
i > e1
不成立,此步骤无操作
4. 旧子节点有剩余,删除多余节点
成立条件为 i > e2
。i > e2
表示所有新子节点都已经遍历完成,还有一些旧子节点没有被遍历到,删除它们。
// 4. 旧子节点有剩余,要删除多余节点
// (a b) c
// (a b)
// i = 2, e1 = 2, e2 = 1
// a (b c)
// (b c)
// i = 0, e1 = 0, e2 = -1
else if (i > e2) {
while (i <= e1) {
// 卸载多余的旧子节点
unmount(c1[i], parentComponent, parentSuspense, true)
i++
}
}
由于
i > e2
不成立,此步骤无操作
5. 处理未知子序列
-
5.1 为新子节点构建 { key: index }的映射
const s1 = i // 旧子节点的起始索引
const s2 = i // 新子节点的起始索引
// 5.1 为新子节点构建键到索引(key:index)的映射
const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
// 遍历新子节点
for (i = s2; i <= e2; i++) {
// 获取当前节点,如果启用了优化并且节点已挂载,则克隆它;否则,规范化它
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
// 如果节点存在 key
if (nextChild.key != null) {
// 如果在开发模式下发现重复的键,发出警告
if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {
warn(
`在更新期间发现重复的键(key):`,
JSON.stringify(nextChild.key),
`请确保键(key)是唯一的。`
)
}
// 将节点的 key 和其在数组中的索引添加到映射中
keyToNewIndexMap.set(nextChild.key, i)
}
}
遍历新子节点中的未知序列部分(['e', 'c', 'd', 'i']
),建立 key
到 index
的映射表 keyToNewIndexMap
。如下:
'e'
-> 2'c'
-> 3'd'
-> 4'i'
-> 5
-
5.2 遍历旧子节点列表,尝试匹配并更新节点,移除不再存在的节点
- 确定是否有节点移动 (
moved
) - 追踪新节点在旧节点列表中的位置 (
newIndexToOldIndexMap
) - 检查每个旧子节点是否可以在新子节点列表中找到匹配项,如果找不到,则移除该节点。
- 确定是否有节点移动 (
// 5.2 遍历旧子节点列表,尝试匹配并更新节点,移除不再存在的节点
let j
let patched = 0 // 已处理的新子节点数
const toBePatched = e2 - s2 + 1 // 需要处理的新子节点总数
let moved = false // 用于跟踪是否有节点移动
let maxNewIndexSoFar = 0 // 到目前为止遇到的最大的新节点索引
// 作为 Map<newIndex, oldIndex> 使用
// 注意 oldIndex 是以 +1 偏移量
// 用于确定最长递增子序列
const newIndexToOldIndexMap = new Array(toBePatched) // 追踪新节点在旧节点列表中的位置
// 初始化数组,每个元素值都为0
// 0 是一个特殊值,表示新节点没有对应的旧节点
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
// 遍历旧子节点列表
for (i = s1; i <= e1; i++) {
const prevChild = c1[i]
// 如果所有新子节点都已处理,则剩下的旧节点只能是需要移除的节点
if (patched >= toBePatched) {
unmount(prevChild, parentComponent, parentSuspense, true)
continue
}
let newIndex
// 如果旧节点有 key,则尝试在 keyToNewIndexMap 中找到对应的新节点索引
if (prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key)
} else {
// 对于没有 key 的节点,尝试找到类型相同的新节点
for (j = s2; j <= e2; j++) {
if (
newIndexToOldIndexMap[j - s2] === 0 &&
isSameVNodeType(prevChild, c2[j] as VNode)
) {
newIndex = j
break
}
}
}
// 如果在新节点列表中找不到对应节点,则卸载旧节点
if (newIndex === undefined) {
unmount(prevChild, parentComponent, parentSuspense, true)
} else {
// 更新 newIndexToOldIndexMap 以便于后续确定最长递增子序列
newIndexToOldIndexMap[newIndex - s2] = i + 1
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
// 如果新节点索引小于之前的最大索引,则表示节点有移动
moved = true
}
// 更新节点
patch(
prevChild,
c2[newIndex] as VNode,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
patched++
}
}
-
初始化
newIndexToOldIndexMap
为[0, 0, 0, 0]
(长度为新子节点未知序列的长度,对应'e', 'c', 'd', 'i'
)。 -
遍历旧子节点中的未知序列部分(
['c', 'd', 'e', 'f']
):- 遍历到
'c'
:i = 2
- 通过映射找到
'c'
在新子节点中的新位置是 3 newIndex
= 3newIndexToOldIndexMap[1] = 3
,newIndexToOldIndexMap
更新为[0, 3, 0, 0]
maxNewIndexSoFar
= 3moved
= false (因为这是第一个元素)
- 遍历到
'd'
:i = 3
- 通过映射找到
'd'
在新子节点中的新位置是 4 newIndex
= 4newIndexToOldIndexMap[2] = 4
,newIndexToOldIndexMap
更新为[0, 3, 4, 0]
maxNewIndexSoFar
= 4 (因为 4 > 3)moved
保持 false
- 遍历到
'e'
:i = 4
- 通过映射找到
'e'
在新子节点中的新位置是 2 newIndex
= 2newIndexToOldIndexMap[0] = 5
,newIndexToOldIndexMap
更新为[5, 3, 4, 0]
maxNewIndexSoFar
保持 4 (因为 2 < 4)moved
= true (因为 2 < 4)
- 遍历到
'f'
:i = 5
newIndex = undefined
- 在
arr2
中没有找到'f'
,因此卸载'f'
- 遍历到
-
5.3 移动和挂载新节点
- 通过 LIS 计算出最长递增子序列,LIS 用于优化节点的移动操作,确保只移动必要的节点。
- 倒序遍历新节点未知子序列,执行挂载或移动节点。倒序遍历是为了确保在移动或添加节点时,每个节点都可以被放置在正确的位置,尤其是当节点需要移动到其他已经存在的节点之前时。
// 5.3 移动和挂载新节点
// 仅在节点有移动时生成最长递增子序列
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap) // 计算最长递增子序列
: EMPTY_ARR
j = increasingNewIndexSequence.length - 1
// 倒序遍历新子节点的未知子序列,这样可以使用最后一个处理过的节点作为锚点
for (i = toBePatched - 1; i >= 0; i--) {
// 计算当前新节点在 arr2 中的索引
const nextIndex = s2 + i
// 获取当前要处理的新节点
const nextChild = c2[nextIndex] as VNode
// 确定当前新节点的锚点位置
const anchor =
nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
// 如果当前新节点在旧节点中没有对应的节点,则进行挂载
if (newIndexToOldIndexMap[i] === 0) {
patch(
null, // 旧节点为 null 表示这是一个新挂载的节点
nextChild,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
// 如果节点有移动,则根据条件判断是否需要移动当前节点
} else if (moved) {
// 条件是:没有递增子序列或当前节点不在递增子序列中
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor, MoveType.REORDER)
} else {
j--
}
}
}
根据之前的步骤,得出 newIndexToOldIndexMap
为 [5, 3, 4, 0]
根据 LIS 算出最长递增子序列 increasingNewIndexSequence
为 [1, 2]
。
- 逆向遍历新子节点中的未知序列 (
['e', 'c', 'd', 'i']
):toBePatched
= 4 (未知序列的长度)l2
=arr2.length
= 8
- 遍历过程 :
- 遍历 'i' (最后一个新增节点):
i = 3
nextIndex = s2 + i = 2 + 3 = 5
nextChild = arr2[nextIndex] = 'i'
anchor = nextIndex + 1 < l2 ? arr2[nextIndex + 1].el : parentAnchor = 'g'.el
j = 1
(LIS 的最后一个索引)newIndexToOldIndexMap[i] = 0
(表示'i'
是新节点)- 执行
patch
挂载'i'
- 执行
i--
- 遍历 'd' :
i = 2
nextIndex = 4
nextChild = 'd'
anchor = 'i'.el
(因为 'i' 刚被挂载)'d'
在 LIS 中,不需要移动j--
变为0
(因为j < 0 || 2 !== 2
不成立)- 执行
i--
- 遍历 'c' :
i = 1
nextIndex = 3
nextChild = 'c'
anchor = 'd'.el
'c'
在 LIS 中,不需要移动j--
变为-1
(因为j < 0 || 1 !== 1
不成立)- 执行
i--
- 遍历 'e' :
i = 0
nextIndex = 2
nextChild = 'e'
anchor = 'c'.el
'e'
不在 LIS 中,需要移动- 执行
move
将 'e' 移动到 'c' 的位置
- 遍历 'i' (最后一个新增节点):
手写一个 diff 算法
将 diff 算法的逻辑从 Vue3 的源码中剥离出来