11.3 如何移动元素
需要移动时,先求
source数组的最长递增子序列:这些节点在更新前后相对顺序没变,可以不动;其余节点从后往前逐个insert到「下一个节点」之前,从而用最少的移动次数完成更新。
一、什么是最长递增子序列(LIS)
- 递增子序列:从给定序列中挑出一个子序列,子序列中的值递增,元素在原序列中不一定连续。
- 一个序列可能有很多个递增子序列,其中最长的那一个就是最长递增子序列。
- 例:
[0, 8, 4, 12]的最长递增子序列是[0, 8, 12]([0, 4, 12]也是正确答案之一)。
二、seq 在快速 Diff 中的意义
getSequence(source)返回的是最长递增子序列中的元素在 source 数组中的位置索引(下标,不是元素值)。- 例:
source = [2, 3, 1, -1],递增的部分是值2, 3,返回结果却是它们的下标[0, 1]。 - 意义:在新的一组子节点中,重新编号后下标为 0、1 的这两个节点(key3、key4),更新前后顺序没有发生变化 → 不需要移动。
- 因为取的是最长递增子序列,「不需要移动」的节点数量最多,所以移动次数最少。
三、移动逻辑:倒序循环 + insert
if (moved) {
// 计算最长递增子序列:返回其在 source 中的位置索引
const seq = getSequence(source)
// s 指向最长递增子序列的最后一个元素
let s = seq.length - 1
// i 指向新的一组子节点的最后一个元素
let i = count - 1
// 倒序遍历:从后往前匹配,锚点可以直接使用下一个节点
for (i; i >= 0; i--) {
if (source[i] === -1) {
// ① source[i] 为 -1 → 全新节点,挂载
const pos = i + newStart
const newVNode = newChildren[pos]
const nextPos = pos + 1
const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null
patch(null, newVNode, container, anchor)
} else if (i !== seq[s]) {
// ② 不在 seq 中 → 需要移动:插入到「下一个节点」的 DOM 之前
const pos = i + newStart
const newVNode = newChildren[pos]
const nextPos = pos + 1
const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null
insert(newVNode.el, container, anchor)
} else {
// ③ i === seq[s] → 该节点不需要移动,s 指向 seq 的上一个元素
s--
}
}
}
- 必须倒序:循环方向与节点在 DOM 中的最终顺序相反,因此锚点可以直接取下一个节点(
nextPos)的真实 DOM,不必额外查找。 s只在遇到「不需要移动」的节点时递减,与i一起在seq上从右向左推进。
四、getSequence 的实现要点
- 贪心 + 二分:
result存的是下标:result中下标对应的值构成递增序列,其长度就是当前最长递增子序列的长度,每一项代表「长度为 i+1 的递增子序列的最小末尾元素的下标」;- 遇到比当前末尾(
arr[result[result.length - 1]])更大的值 → 直接push; - 否则在
result上二分查找第一个「值不小于arrI」的位置并替换,让末尾更小,为后面接更多元素留出空间。
- 记录前驱:
p初始为arr.slice(),p[i]记录「下标 i 接在哪个下标之后」;- 只靠替换得到的
result值是对的、下标未必对,所以最后必须用p回溯:从result的末尾往前推,把每一项换成真正的前驱下标。
function getSequence(arr) {
const p = arr.slice() // 记录前驱下标,用于回溯
const result = [0] // 存递增子序列的下标
let i, j, u, v, c
const len = arr.length
for (i = 0; i < len; i++) {
const arrI = arr[i]
if (arrI !== 0) {
j = result[result.length - 1]
if (arr[j] < arrI) {
// 比末尾大,直接接在末尾之后
p[i] = j
result.push(i)
continue
}
// 二分查找第一个值 >= arrI 的位置
u = 0
v = result.length - 1
while (u < v) {
c = ((u + v) / 2) | 0
if (arr[result[c]] < arrI) {
u = c + 1
} else {
v = c
}
}
if (arrI < arr[result[u]]) {
if (u > 0) {
p[i] = result[u - 1]
}
result[u] = i // 用更小的末尾替换
}
}
}
// 回溯:把 result 里的下标换成真正的前驱下标
u = result.length
v = result[u - 1]
while (u-- > 0) {
result[u] = v
v = p[v]
}
return result
}
只要长度的话不需要
p和回溯:草稿lengthOfLIS就是「贪心 + 二分」只维护result并返回len的写法;快速 Diff 需要的是下标序列,所以必须记录前驱再回溯。
五、示例
- 旧:
1 2 3 4 6 5;新:1 3 4 2 7 5;source = [2, 3, 1, -1],count = 4,newStart = 1 getSequence(source) → [0, 1]:下标 0(key3)、1(key4)的节点不需要移动
| i | source[i] | 命中的分支 | 动作 |
|---|---|---|---|
| 3 | -1 | ① 新增 | key7 挂载到 key5 的 DOM 之前 |
| 2 | 1 | ② i !== seq[s] | key2 插入到 key7 的 DOM 之前 |
| 1 | 3 | ③ i === seq[s]=1,s-- | key4 不移动 |
| 0 | 2 | ③ i === seq[s]=0,s-- | key3 不移动 |
六、易错点
getSequence返回的是下标数组(在source中的位置),不是子序列的值。source[i] === -1表示新增节点,要先挂载再处理后续节点,否则锚点会取到不存在的 DOM。getSequence内部跳过的是arrI !== 0(Vue 源码以0作为「无对应旧节点」的占位值),与本书source.fill(-1)的字面值不同但含义一致,阅读源码时不要被绕进去。 循环必须倒序:只有从后往前,nextPos位置的节点才已处于最终位置,可以直接拿来当锚点。
相关
- 11.1 相同的前置元素和后置元素:预处理摘掉相同的前置 / 后置节点,减少需要移动的节点数量。
- 11.2 判断是否需要进行 DOM 移动操作:
source数组与moved标记,是本篇计算最长递增子序列的输入。