7张图、20分钟,包你学会 Vue3 是最优化计算 vdom
想必你已经听过 Vue3 的左右互博之术
(如果没有听过,可以点击 传送门 深入了解一下),为了增强我们的内功
,本文讨论当你通过算法得出乱序部分之后还要做些什么才能让改变尽可能小,让性能消耗变得更便宜~~~
如图,你通过双端对比
(三个指针,通过左序遍历和右序遍历确定变动的位置)已经找到了乱序的部分
思考一下,新老节点对比无非是以下三种情况:
- 1、新的比老的长 => 增加
- 2、新的比老的短 => 删除
- 3、新的和老的一样长 => 移动或增加或删除
老的虚拟节点有对应的真实 DOM,也就是已经渲染过的节点。而新的虚拟节点是一个对象,我们需要做的的就是新老虚拟节点对比
,得出最小的差异,去更新真实的 DOM。
前情回顾
- i 表示左序遍历 新节点与老节点对比 变动的位置 初始值为 0
- e1 表示右序遍历 老节点与新节点对比 变动的位置 初始值为 老节点最后一位的索引值
- e2 表示右序遍历 新节点与老节点对比 变动的位置 初始值为 新节点最后一位的索引值
用代码表示
if(i>e1){
// 新的比老的长
...
}else if(i>e2){
// 新的比老的短
...
}else{
// 一样长
}
新的虚拟节点比老的虚拟节点长
新的节点更长,需要增加节点,所以循环的条件就是 i > e1 并且 i 是小于或等于 e2。
增加有两种情况,如图所示:
代码实现:
// c1 是老节点树
// c2 是新节点树
// len2 = c2.length -1
if (i > e1) {
if (i <= e2) {
// 左侧 可以直接加在末尾
// 右侧的话 我们就需要引入一个 概念 锚点 的概念
// 通过 anchor 锚点 我们将新建的元素插入的指定的位置
const nextPos = e2 + 1
// 如果 e2 + 1 大于 c2 的 length 那就是最后一个 否则就是最先的元素
// 锚点是一个 元素
const anchor = nextPos < len2 ? c2[nextPos].el : null
while (i <= e2) {
// 再往深层的比较节点
// patch
i++
}
}
}
老的虚拟节点比新的虚拟节点长
老的更长所以是删除节点,循环的条件就是 i <= e1,i 是从左侧开始变动的位置,e1 则是从右侧开始变动的位置。但是也两种情况,如下图所示:
if (i > e2) {
// 老的比新的多 删除
// e1 就是 老的 最后一个
while (i <= e1) {
// 移除元素
hostRemove(c1[i].el);
i++;
}
}
新老节点一样长,处理中间乱序部分
我们根据这个例子来讨论,是不是只需要删除 e 和增加 y?cd 的位置相对稳定,重复利用即可!
既然两个节点树长度是一样的,我们可以通过遍历老节点,然后同时遍历新节点,检查是否在新的里面存在,此时时间复杂度为 O(n*n);显然不是最优,为了优化性能,我们可以为新的节点建立一个映射表,只要根据 key 去查是否存在;
如下图,我们得知变动元素在老节点中的索引分别是 c:2 d:3 e:4。
let s1 = i // i 是停止的位置 差异开始的地方
let s2 = i
// 建立新节点的映射表
const keyToNewIndexMap = new Map()
// 思考:为什么要建立映射表
// diff 的意义在与 减少 dom 操作
// 老的节点是 dom 元素 而新的节点是个对象
// 实际上我们还是操作老的 dom
// 映射表的意义在于我们可以快速的根据老节点的 key 来快速查到它在新的节点里面是哪个位置,然后对比位置关系再操作
// 循环 e2
for (let i = s2; i <= e2; i++) {
const nextChild = c2[i]; // c2 是新节点
keyToNewIndexMap.set(nextChild.key, i)
}
映射表如下:
{
'y':2,
'c':3,
'd':4
}
建立完映射表之后,我们在循环 e1(因为 e1 是老节点,我们所有的步骤都是为了减少 dom 的操作,所以我们要对比新老节点,改动其实是在 e1,对照 e2 改 e1)。
// 循环 e1
for (let i = s1; i <= e1; i++) {
const prevChild = c1[i]; // c1 是老节点
// 它的作用是告诉我们老节点的元素是否在新的里面
let newIndex // 临时变量索引
// 这里先只做简单的 key 值判断是否为同一个
if (prevChild.key !== null) {
// 用户输入了 key 那么 newIndex 就等于 映射表中 对应的索引值
newIndex = keyToNewIndexMap.get(prevChild.key)
} else {
// 没有输入 key
// 只有通过遍历的方式 去对比 两个节点是否相同
for (let j = s2; j < e2; j++) {
if (isSomeVNodeType(prevChild, c2[j])) {
// 如果相同的话 newIndex 就等于 老节点中的索引值 也就是 此时的 j
newIndex = j;
break;
}
}
}
// 上面几行代码所做的事情就是 拿到 新节点 在 老节点 对应的 索引值
// 有两种情况 undefined 或 有值
if (newIndex === undefined) {
// 新节点中不存在老节点的话 就可以直接删除此元素了
hostRemove(prevChild.el)
} else {
// 节点存在 不代表它的 props 或者它的子节点 是一样的
// patch => prevChild 和 c2[newIndex]
patch(prevChild, c2[newIndex], container, parentComponent, null)
}
}
通过上方的代码,我们可以实现以下实例,删除 Y\D。
const prevChildren = [
h("p", { key: "A" }, "A"),
h("p", { key: "B" }, "B"),
h("p", { key: "C", id: "c-prev" }, "C"),
h("p", { key: "Y" }, "Y"),
h("p", { key: "E" }, "E"),
h("p", { key: "D" }, "D"),
h("p", { key: "F" }, "F"),
h("p", { key: "G" }, "G"),
];
const nextChildren = [
h("p", { key: "A" }, "A"),
h("p", { key: "B" }, "B"),
h("p", { key: "C", id:"c-next" }, "C"),
h("p", { key: "E" }, "E"),
h("p", { key: "F" }, "F"),
h("p", { key: "G" }, "G"),
];
上方的代码,咱们可以优化一下,如果新的节点少于老的节点,当遍历完新的之后,就不需要再遍历了!
// 通过一个总数和一个遍历次数 来优化
// 要遍历的数量
const toBePatched = e2 - s2 + 1
// 已经遍历的数量
let patched = 0
...
// 循环 e1
for (let i = s1; i <= e1; i++) {
const prevChild = c1[i];
// === 改动 ===
if (patched >= toBePatched) {
// 说明已经遍历完了 在挨个删除
hostRemove(prevChild.el)
continue // 后面的就不会执行了
}
...
if (newIndex === undefined) {
hostRemove(prevChild.el)
} else {
patch(prevChild, c2[newIndex], container, parentComponent, null)
// patch 完就证明已经遍历完一个新的节点
patched++
}
}
到这一步,咱们还没有实现移动,如开头所说,有时候某些元素的相对位置是没有改变的。所以我们可以利用最长递增子序列将改变变得更小!
拆分问题 => 获取最长递增子序列
-
abcdefg -> 老
-
adecdfg -> 新
-
1.确定新老节点之间的关系 新的元素在老的节点中的索引 e:4,c:2,d:3 newIndexToOldIndexMap 的初始值是一个定值数组,初始项都是 0,newIndexToOldIndexMap = [0,0,0] => [5,3,4] 加了1 因为 0 是有意义的。 递增的索引值就是 [1,2]
-
2.最长的递增子序列 [1,2] 对比 ecd 这个变动的序列 利用两个指针 i 和 j i 去遍历新的索引值 ecd [0,1,2] j 去遍历 [1,2] 如果 i!=j 那么就是需要移动
第一步
// 新建一个定长数组(需要变动的长度) 性能是最好的 来确定新老之间索引关系 我们要查到最长递增的子序列 也就是索引值
const newIndexToOldIndexMap = new Array(toBePatched)
for (let i = 0; i < toBePatched; i++) {
newIndexToOldIndexMap[i] = 0
}
...
// 在获取到 newIndex 的时候赋值
if (newIndex === undefined) {
// 新节点不存在老节点的话 删除
hostRemove(prevChild.el)
} else {
// 实际上是等于 i 就可以 因为 0 表示不存在 所以 定义成 i + 1
newIndexToOldIndexMap[newIndex - s2] = i + 1
// 节点存在 不代表它的 props 或者它的子节点 是一样的
patch(prevChild, c2[newIndex], container, parentComponent, null)
// patch 完就证明已经遍历完一个新的节点
patched++
}
...
第二步
// 获取最长递增子序列 newIndexToOldIndexMap 再上一步已经赋好了值
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap)
let j = increasingNewIndexSequence.length - 1
// 倒序的好处就是 能够确定稳定的位置
// ecdf
// cdef
// 如果是从 f 开始就能确定 e 的位置
// 从最后开始就能依次确定位置
for (let i = toBePatched; i >= 0; i--) {
const nextIndex = i + s2 // i 初始值是要遍历的长度 s2 是一开始变动的位置 加起来就是索引值
const nextChild = c2[nextIndex]
// 锚点 => 位置
const anchor = nextIndex + 1 < len2 ? c2[nextIndex + 1].el : null
if (newIndexToOldIndexMap[i] === 0) {
patch(null, nextChild, container, parentComponent, anchor)
} else {
if (i !== increasingNewIndexSequence[j]) {
// 移动位置 调用 insert
hostInsert(nextChild.el, container, anchor)
} else {
j++
}
}
}
我们还可以优化这一步的代码,确定是否需要移动,只要后一个索引值小于前一个,就需要移动。
let moved = false
let maxNewIndexSoFar = 0
...
if (newIndex === undefined) {
hostRemove(prevChild.el)
} else {
// === 改动 ===
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
moved = true
}
// 实际上是等于 i 就可以 因为 0 表示不存在 所以 定义成 i + 1
newIndexToOldIndexMap[newIndex - s2] = i + 1
// 存在就再次深度对比
patch(prevChild, c2[newIndex], container, parentComponent, null)
// patch 完就证明已经遍历完一个新的节点
patched++
}
...
// 获取最长递增子序列
const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : []
...
if (newIndexToOldIndexMap[i] === 0) {
patch(null, nextChild, container, parentComponent, anchor)
} else if (moved) {
if (j < 0 || i !== increasingNewIndexSequence[j]) {
// 移动位置 调用 insert
hostInsert(nextChild.el, container, anchor)
} else {
j++
}
}
...
写在最后
本文与各位讨论了当确定新老节点变动的位置之后,如何才能将性能达到最优。笔者认为框架实现的思路方法值得咱们去深究、借鉴。另外,如果你想学习 Vue3 源码,推荐先入手 mini-vue,带你实现 Vue3 最简模型。