10.5 移除不存在的元素
双端比较的另一种收尾:新 children 处理完了、旧 children 还有剩余,说明这些旧节点已经不存在于新子节点中,必须把它们的真实 DOM 卸载掉。
一、节点被移除的例子
- 旧子节点:
p-1、p-2、p-3 - 新子节点:
p-1、p-3
| 轮次 | 命中步骤 | 本轮操作 | 索引(旧 / 新) |
|---|---|---|---|
| 1 | 第 1 步(旧头 p-1 = 新头 p-1) | 只打补丁,不移动 | (1, 2) / (1, 1) |
| 2 | 第 2 步(旧尾 p-3 = 新尾 p-3) | 仍在尾部,只打补丁 | (1, 1) / (1, 0) |
第 3 轮 newStartIdx(1)已大于 newEndIdx(0),循环条件不成立 → 结束。此时 oldStartIdx = 1、oldEndIdx = 1,旧节点 p-2 残留,却没有任何一步处理它。
二、收尾:卸载区间内的旧节点
在循环结束后补一个分支:当 newEndIdx < newStartIdx && oldStartIdx <= oldEndIdx 成立时,说明索引 oldStartIdx ~ oldEndIdx 区间内的旧节点都应该被卸载,用一个 for 循环逐个处理。
// 循环结束后检查索引值的情况
if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
// 有新的节点遗留 → 挂载(见 10.4)
for (let i = newStartIdx; i <= newEndIdx; i++) {
patch(null, newChildren[i], container, oldStartVNode?.el)
}
} else if (newEndIdx < newStartIdx && oldStartIdx <= oldEndIdx) {
// 移除操作:有旧的节点遗留 → 逐个卸载
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
// 该位置可能已被置为 undefined(节点已被复用到别处),跳过
if (!oldChildren[i]) continue
unmount(oldChildren[i])
}
}
三、为什么必须调用 parent.removeChild
虚拟节点只是描述,页面上那个真实 DOM 元素仍然挂在容器里。只把节点从 oldChildren 数组里去掉,DOM 不会消失,所以卸载必须操作真实 DOM:
function unmount(vnode) {
// Fragment 需要卸载其 children
if (vnode.type === Fragment) {
vnode.children.forEach(c => unmount(c))
return
}
const parent = vnode.el.parentNode
if (parent) {
// 通过 vnode.el 拿到真实 DOM,再把它从父节点上摘掉
parent.removeChild(vnode.el)
}
}
unmount(vnode)里的vnode.el就是挂载时缓存下来的真实 DOM 引用,parent.removeChild(vnode.el)才能真正把元素从页面上移走;- 所以收尾循环里逐个
unmount(oldChildren[i]),真实 DOM 就与新 children 保持一致了。
四、易错点
收尾循环里必须判空
if (!oldChildren[i]) continue:oldStartIdx ~ oldEndIdx这段区间内可能有被 10.3 的oldChildren[idxInOld] = undefined置空的位置(节点已经复用到别处),对undefined调用unmount会在vnode.el处报错。
两个收尾分支互斥:
oldStartIdx > oldEndIdx只会与「新增」同时出现,newStartIdx > newEndIdx只会与「删除」同时出现。
五、双端 Diff 收尾小结
| 循环结束时的索引状态 | 结论 | 处理方式 |
|---|---|---|
oldStartIdx > oldEndIdx、newStartIdx <= newEndIdx | 新 children 有剩余 | 逐个 patch(null, ...) 挂载剩余新节点 |
newStartIdx > newEndIdx、oldStartIdx <= oldEndIdx | 旧 children 有剩余 | 逐个 unmount(...) 卸载剩余旧节点(内部 parent.removeChild) |
| 两边都越界 | 更新完成 | 无需处理 |
至此双端 Diff 才算完整:四步比较 + 非理想状况查找 + 两种收尾。第 11 章的快速 Diff 会在它的基础上进一步减少比较次数(先做前置 / 后置预处理,再用最长递增子序列决定移动)。
相关
- 10.1 双端比较的原理:四个索引、四步比较与 DOM 移动规则
- 10.3 非理想状况的处理方式:被复用的节点为什么要置为
undefined - 10.4 添加新元素:另一种收尾——新节点有剩余时逐个挂载