关于Diff算法,大家可能早就知道Vue和React使用vnode,并且使用diff算法完成更新,最大限度减少浏览器对dom的操作,但是估计知道具体实现原理的人并不多,起码我以前一直是懵懂状态,在面试中也讲不出来。
关于Vue3的Diff,使用双端对比,并且结合了“求最长递增子序列”来减少移动的次数。
双端对比
更新前后的children:
const prevChildren = [
h("p", { key: "A" }, "A"),
h("p", { key: "B" }, "B"),
h("p", { key: "C" }, "C"),
];
const nextChildren = [
h("p", { key: "A" }, "A"),
h("p", { key: "B" }, "B"),
h("p", { key: "D" }, "D"),
h("p", { key: "E" }, "E"),
];
props中可以提供key值,然后把key保存到vnode上:
const vnode = {
type,
props,
children,
// props也可以不传key,此时key === undefined
key: props && props.key,
shapeFlag: getShapeFlags(type),
el: null
};
假设有两个序列,每个元素都是一个vnode,此处的字母代表其key值:
// 指针移动前
A B | C D E Z | F G
| | e1
| |
A B | D C Y E | F G
i e2
一共设置了三个指针,e1指向old children末尾,e2指向new children末尾,i初始化为0。移动指针,目的是找到两个序列中间不一样的部分。
// 指针移后
A B | C D E Z | F G
| e1 |
| |
A B | D C Y E | F G
i e2
先按左侧移动,再按右侧移动。当判断指针指向的两个结点相同时,指针移动,并且要递归patch这两个相同的结点,之后就会比较它们的props、children。
function patchKeyedChildren(
c1,
c2,
container,
parentComponent,
parentAnchor // anchor用于移动操作,之后会说
) {
const l1 = c1.length;
const l2 = c2.length;
let e1 = l1 - 1;
let e2 = l2 - 1;
let i = 0;
// type和key一致,表明结点相同
function isSameVNodeType(n1, n2) {
return n1.type === n2.type && n1.key === n2.key;
}
// 左侧相同
while (i <= e1 && i <= e2) {
const n1 = c1[i];
const n2 = c2[i];
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, parentComponent, parentAnchor);
} else {
break;
}
i++;
}
// 右侧相同
while (i <= e1 && i <= e2) {
const n1 = c1[e1];
const n2 = c2[e2];
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, parentComponent, parentAnchor);
} else {
break;
}
e1--;
e2--;
}
}
两端的增加和删除
新的比老的长,需要新增结点;老的比新的长,需要删除结点。并且新增/删除的结点可能位于左侧,也可能位于右侧。
增加
// 情况一(新增在右侧)
a b
e1
a b c d
i e2 // e2 + 1 >= 4,anchor = null, 插入到末尾
// 情况二(新增在左侧)
a b
e1(-1)
d c a b
i e2 // e2 + 1 === 2,anchor = a
可见,[i, e2]区间内的结点是需要新增的。
第一种情况,插入操作直接用append到末尾没有问题,但是第二种情况得插入在前面,显然不能用append。所以insert需要使用insertBefore:
// 当anchor === null时,插入到末尾,相当于append
function insert(child, parent, anchor) {
parent.insertBefore(child, anchor || null);
}
在进行新增操作时,需要计算此时的anchor结点,从情况二的图可知,anchorIndex = e2 + 1,即会选择e2右边的那个结点作为锚点,锚点始终不变,第一次在a的前面插入d,第二次在a的前面插入c。
如果e2 + 1 >= newChildren.length,说明插入点在末尾,那么anchor赋值为null即可。
if (i > e1) {
if (i <= e2) {
const nextPos = e2 + 1;
// l2就是newChildren.length
const anchor = nextPos < l2 ? c2[nextPos].el : null;
while (i <= e2) {
// 给patch新增参数anchor,之后还要修改一系列函数签名
patch(null, c2[i], container, parentComponent, anchor);
i++;
}
}
}
删除
// 情况一(删除在右侧)
a b c d
e1
a b
e2 i
// 情况二(删除在左侧)
a b c d
e1
c d
e2 i
可见,[i, e1]区间内的结点是需要删除的。
删除的逻辑比较简单。
if (i > e1) {
// 新增操作
} else if (i > e2)
// 删除操作
while (i <= e1) {
hostRemove(c1[i].el);
i++;
}
} else {
// 处理中间部分
}
处理中间部分
当i、e1和e2移动,最终脱离while循环,停在某个位置上,此时[i, e1]和[i, e2]就分别是两个孩子数组的中间部分。
// 指针移后
A B | C D E Z | F G
| e1 |
| |
A B | D C Y E | F G
i e2
比如上面的例子,竖线之内的就是“中间部分”。中间部分会进行增加、移动、删除三种操作。
处理中间部分,涉及到以下数据结构:
- toBePatched:oldChildren中需要处理的中间部分的长度,如上即CDEZ的长度。
- keyToNewIndexMap:是map,key是oldChildren的结点的key,value是该结点在newChildren中的下标。
- newIndexToOldIndexMap:是定长数组,初始化为全0,表示newChildren中的结点在oldChildren中的下标(并且加1),加1是为了和初始化的0值区分开,之后newIndexToOldIndexMap[i] === 0 表示该结点在oldChildren中找不到,需要新增。
- increasingNewIndexSequence:将newIndexToOldIndexMap传入getSequence(计算最长递增子序列的函数),得到的返回值,表示构成最长递归子序列的数对应的下标。比如[5,2,3,4]会求得[1,2,3],表示下标为1、2、3的数构成最长递增子序列。
以下是初始化keyToNewIndexMap和newIndexToOldIndexMap的逻辑,并且也包含删除操作的处理,有以下两种方式:
- 每次确认能在newChildren中找到oldChildren中的结点prevChild的时候,patched就会自增,当patched >= toBePatched时,表示中间区域内所有新结点都被处理过了,那么剩下的老结点就要被删除。
- 在newChildren中找不到prevChild,该结点需要被删除。
// 在else块中
let s1 = i;
let s2 = i;
let patched = 0;
let moved = false;
let maxIndexSoFar = 0;
// 等于newChildren中间部分的长度
const toBePatched = e2 - s2 + 1;
const keyToNewIndexMap = new Map();
const newIndexToOldIndexMap = Array(toBePatched).fill(0);
// 初始化keyToNewIndexMap
for (let i = s2; i <= e2; i++) {
const nextChild = c2[i];
keyToNewIndexMap.set(nextChild.key, i);
}
// 遍历oldChildren
for (let i = s1; i <= e1; i++) {
const prevChild = c1[i];
// patched统计当前patch的次数
// 删除情况二:如果newChildren所有结点都被patch了,那么剩下的老结点肯定要被删除
if (patched >= toBePatched) {
hostRemove(prevChild.el);
continue;
}
// 原数组的结点 在新数组中的下标
let newIndex;
if (prevChild.key !== null) {
newIndex = keyToNewIndexMap.get(prevChild.key);
} else {
// 防止用户没有传key
// 可见,没有key的时候,不能直接靠map拿到下标,增加了时间复杂度
for (let j = s2; j <= e2; j++) {
if (isSameVNodeType(prevChild, c2[j])) {
newIndex = j;
break;
}
}
}
if (newIndex === undefined) {
// 删除情况一:原数组的结点 在新数组中找不到,删除
hostRemove(prevChild.el);
} else {
if (newIndex > maxIndexSoFar) {
maxIndexSoFar = newIndex;
} else {
moved = true;
}
// 原数组的结点 在新数组能找到
patch(prevChild, c2[newIndex], container, parentComponent, null);
patched++;
newIndexToOldIndexMap[newIndex - s2] = i + 1;
}
}
然后是移动和新增,先求出最长递增子序列(优化点:根据moved判断是否需要移动,不需要移动的时候不用求最长递增子序列),然后设置指针j指向该序列的末尾,同时倒序遍历newChildren。
因为increasingNewIndexSequence指明了 newChildren的中间部分 构成最长递增子序列的下标,为了尽可能减少移动次数,位于 被指明的下标 上的结点 无需移动。
ABCD <=> DABC -> [4, 1, 2, 3] -> [1, 2, 3] -> 在DABC中,下标1、2、3的结点无需移动。
所以我们需要遍历newChildren的中间部分,即[i, e2 - i],或者表示为[s2, toBePatched - 1],判断该结点的下标是否位于increasingNewIndexSequence中。由于中间部分的下标和 increasingNewIndexSequence 都是递增序列,只需要使用双指针即可。
又因为要把结点移动到anchor的前面,如果是从左到右遍历中间部分,前一个结点移动时,anchor还没有移动过,它是一个不稳定的结点,所以前面的结点移动时不存在稳定的基准点,会导致错误。所以从右到左遍历中间部分,这样后面的结点处理之后变得稳定了,再处理前面的结点。j指向increasingNewIndexSequence的末尾。
newIndexToOldIndexMap[i] === 0,说明newChildren中的该结点在oldChildren中不存在,需要新增。anchor已经计算好了,直接插入即可。
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: [];
// 指向最长递增子序列的末尾,指针向左移动
let j = increasingNewIndexSequence.length - 1;
for (let i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i;
const nextChild = c2[nextIndex];
const anchor = nextIndex < l2 ? c2[nextIndex + 1].el : null;
if (newIndexToOldIndexMap[i] === 0) {
// 需要新增
patch(null, nextChild, container, parentComponent, anchor);
} else if (moved) {
// j < 0 说明已经遍历完了最长递增子序列,剩下的结点全都是要移动的
if (j < 0 || i !== increasingNewIndexSequence[j]) {
hostInsert(nextChild.el, container, anchor);
}
}
}
现在我们回到一开始举的例子,分析diff的全流程:
先是双端对比,移动i、e1、e2指针:
// 指针移后
A B | C D E Z | F G
| e1 |
| |
A B | D C Y E | F G
i e2
该例子中左侧和右侧没有需要新增或删除的结点,因为 i < e1 && i < e2。
先遍历newChildren的中间部分,得到keyToNewIndexMap:
D -> 2
C -> 3
Y -> 4
E -> 5
然后遍历oldChildren的中间部分,在map中根据key查到下标,获取newIndex。然后构造定长数组newIndexToOldIndexMap。
key newIndex
C 3
D 2
E 5
Z undefined // 删除
// newIndexToOldIndexMap(在oldChildren中的下标 + 1,按newChildren顺序来)
// 0 是初始化的值,表示该结点需要新增
D C Y E 最长递增子序列 increasingNewIndexSequence
[4, 3, 0, 6] -> [1, 3]
求出increasingNewIndexSequence后,开始从右往左遍历:
- E 不移动
- Y 插入到 E 前面
- C 不移动
- D 插入到 C 前面
P.S. 插入操作是直接对newChildren做的,因为newChildren中有些结点在oldChildren中也有,这些vnode上已经设置好了el属性,可以直接访问到dom,可以作为anchor。