10.4 添加新元素
双端比较遇到旧 children 中不存在的节点时要把它挂载(mount)到正确位置;如果新增节点出现在尾部,还要在 while 循环结束后收尾。
一、新节点出现在头部
- 旧子节点:
p-1、p-2、p-3 - 新子节点:
p-4、p-1、p-3、p-2
第一轮四步全不命中,拿新头节点 p-4 去旧 children 里 findIndex 也返回 -1 → p-4 是全新的节点。
该挂载到哪里? p-4 是新的一组子节点的头部节点,所以只需要把它挂载到当前头部节点的真实 DOM 之前即可;这里的「当前头部节点」就是旧 children 的头节点 p-1。
} else {
// 遍历旧 children,试图寻找与 newStartVNode 拥有相同 key 值的元素
const idxInOld = oldChildren.findIndex(
node => node.key === newStartVNode.key
)
if (idxInOld > 0) {
// ...找到可复用节点,移动 + 打补丁
} else {
// 说明 newStartVNode 是一个全新的节点,将其挂载到头部
// 锚点用当前头部节点 oldStartVNode.el
patch(null, newStartVNode, container, oldStartVNode.el)
}
// 无论走哪个分支都要推进 newStartIdx(写在 if/else 之外,避免死循环)
newStartVNode = newChildren[++newStartIdx]
}
新增节点也必须走
patch(null, ...):patch内部会执行mountElement,而mountElement负责把新建的真实 DOM 赋给vnode.el。跳过它直接插 DOM,后续就找不到这个节点的el。
二、新节点出现在尾部:循环结束后的收尾
- 旧子节点:
p-1、p-2、p-3 - 新子节点:
p-1、p-2、p-3、p-4
前三轮都靠第 1 步命中(新旧头节点依次配对),索引同步推进;到第 4 轮时 oldStartIdx(3)已经大于 oldEndIdx(2),循环条件不成立 → 循环直接结束。
此时 newStartIdx = 3 <= newEndIdx = 3,新节点 p-4 从头到尾都没被处理,说明算法有缺陷。
所以在 while 循环之后要检查索引情况:
| 循环结束时的条件 | 含义 | 处理 |
|---|---|---|
oldStartIdx > oldEndIdx 且 newStartIdx <= newEndIdx | 旧节点已全部处理完,新节点有剩余 | 新增:把剩余新节点挂载上去 |
newStartIdx > newEndIdx 且 oldStartIdx <= oldEndIdx | 新节点已全部处理完,旧节点有剩余 | 删除:把剩余旧节点卸载掉(见 10.5) |
| 两个条件都不成立(两边都越界) | 新旧节点都处理完了 | 什么都不做 |
// 循环结束后检查索引值的情况
if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
// 有新的节点遗留,需要挂载它们
for (let i = newStartIdx; i <= newEndIdx; i++) {
// 锚点取当前旧头节点的真实 DOM(它是 undefined 时的兜底写法见下一节)
patch(null, newChildren[i], container, oldStartVNode.el)
}
}
三、为什么锚点用 oldStartVNode.el
锚点决定新节点插到哪里,而这个锚点只能从旧 children 里取——新 children 中的节点还没有真实 DOM。
- 剩余的新节点位于头部时,旧的头节点正好是它们的「下一个兄弟」,插在
oldStartVNode.el之前位置就对了; - 剩余的新节点位于尾部时,
oldStartVNode可能已经被消费成undefined(如上例中oldStartIdx已经走到 3),此时oldStartVNode.el会抛Cannot read properties of undefined (reading 'el')。
写成
oldStartVNode?.el即可兜底:锚点为undefined等价于insertBefore(el, null),也就是追加到容器末尾——正是尾部新增节点想要的位置。
四、循环结束后的完整收尾
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 跳过已被置为 undefined 的位置
if (!oldStartVNode) {
oldStartVNode = oldChildren[++oldStartIdx]
} else if (!oldEndVNode) {
oldEndVNode = oldChildren[--oldEndIdx]
} else if (oldStartVNode.key === newStartVNode.key) {
// ...四步比较与 DOM 移动
} else {
// ...非理想状况:查找可复用节点 / 挂载新节点
}
}
// 循环结束后检查索引值的情况
if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
// 有新的节点遗留,需要挂载它们
for (let i = newStartIdx; i <= newEndIdx; i++) {
patch(null, newChildren[i], container, oldStartVNode?.el)
}
}
相关
- 10.3 非理想状况的处理方式:四步不命中时如何在旧 children 中找可复用节点
- 10.5 移除不存在的元素:另一种收尾——旧节点有剩余时逐个卸载
- 10.1 双端比较的原理:四个索引与四步比较