10.3 非理想状况的处理方式
四个步骤都没命中时,双端比较会「卡住」;此时退化成「拿新的一组子节点的头部节点,去旧的一组子节点里找可复用节点」。
一、什么是非理想状况
以这组更新为例:
- 旧子节点:
p-1、p-2、p-3、p-4 - 新子节点:
p-2、p-4、p-1、p-3
第一轮四步两两比较,key 全都不相等:
| 步骤 | 比较 | 结果 |
|---|---|---|
| 1 | p-1 vs p-2 | ✗ |
| 2 | p-4 vs p-3 | ✗ |
| 3 | p-1 vs p-3 | ✗ |
| 4 | p-4 vs p-2 | ✗ |
四步都不命中,索引也不会推进,算法无法继续 → 必须增加额外的处理步骤。
二、第 5 步:在旧 children 中查找可复用节点
思路:遍历旧的一组子节点,寻找与新的一组子节点的头部节点 newStartVNode 具有相同 key 的节点,把它的索引记为 idxInOld。
本例中拿 p-2 去旧 children 里找,会在索引 1 处找到 → 说明 p-2 原本不是头部节点,但更新后应该变成头部节点。
处理动作:
| 动作 | 原因 |
|---|---|
patch(vnodeToMove, newStartVNode, container) | 复用前先打补丁,更新节点自身内容 |
insert(vnodeToMove.el, container, oldStartVNode.el) | 以当前头部节点的真实 DOM 为锚点,把该节点插到头部之前 |
oldChildren[idxInOld] = undefined | 该位置的真实 DOM 已被移走,置空避免之后被重复处理 |
newStartVNode = newChildren[++newStartIdx] | 新子节点的头部节点已处理完,索引前进一位(推进位置见第四节) |
idxInOld不会为0:如果旧头节点就能命中,第一步早已处理;idxInOld > 0说明节点不在头部,需要移动。
findIndex返回-1表示旧 children 里根本没有这个节点 —— 它是新增节点,见 10.4 添加新元素。
三、被置为 undefined 的节点要跳过
oldChildren[idxInOld] = undefined 之后,旧 children 数组里就出现了空洞。如果不处理,下一轮 oldStartVNode.key / oldEndVNode.key 就会对 undefined 取属性而报错。
于是要在 while 循环最前面加两个判断分支:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 头/尾节点为 undefined,说明该位置已被处理过,直接跳到下一个位置
if (!oldStartVNode) {
oldStartVNode = oldChildren[++oldStartIdx]
} else if (!oldEndVNode) {
oldEndVNode = oldChildren[--oldEndIdx]
} else if (oldStartVNode.key === newStartVNode.key) {
// ...四步比较
}
}
这两个分支必须放在四步比较之前:那一格只是占位,真实 DOM 已经在别处,继续拿它参与比较会得到错误结果。
四、易错点:newStartIdx 的推进位置
这一步的索引推进如果写在 if (idxInOld > 0) 内部,那么当 idxInOld === -1(新增节点)时索引不前进,newStartVNode 永远不变 → 死循环。
正确做法是在 if / else 之外推进(10.4 会补上缺失的 else 分支):
if (idxInOld > 0) {
// ...找到可复用节点,移动 + 打补丁
} else {
// ...新增节点,挂载(见 10.4)
}
// 无论走哪个分支,都要更新 newStartIdx
newStartVNode = newChildren[++newStartIdx]
五、实现
} else {
// 遍历旧 children,试图寻找与 newStartVNode 拥有相同 key 值的元素
const idxInOld = oldChildren.findIndex(
node => node.key === newStartVNode.key
)
// idxInOld 大于 0,说明找到了可复用的节点,并且需要将其对应的真实 DOM 移动到头部
if (idxInOld > 0) {
// idxInOld 位置对应的 vnode 就是需要移动的节点
const vnodeToMove = oldChildren[idxInOld]
// 除移动操作外还应该打补丁
patch(vnodeToMove, newStartVNode, container)
// 将 vnodeToMove.el 移动到头部节点 oldStartVNode.el 之前,因此使用后者作为锚点
insert(vnodeToMove.el, container, oldStartVNode.el)
// 由于该位置的真实 DOM 已经移动到别处,因此将其设置为 undefined
oldChildren[idxInOld] = undefined
// 更新 newStartIdx 到下一个位置
newStartVNode = newChildren[++newStartIdx]
}
}
相关
- 10.1 双端比较的原理:四个索引、四步比较与 DOM 移动规则
- 10.4 添加新元素:
idxInOld === -1时把新节点挂载进去 - 10.5 移除不存在的元素:循环结束后卸掉旧节点中残留的节点