目的
这次的目的是优化实现diff算法,上次实现的patchChildren实现较为简单,这次优化和实现Vue3的diff算法。
1.React diff 算法
c1: a b c
c2: c b a
面对上述情况,上次实现的patchChildren会逐个 a->b->c 比较,会重新生成c和a但是这个明显只是顺序改变,需要优化。
为了标识前后的节点,所以diff这里给VNode引入了key标识虚拟节点。根据这个key值相等我们只改变节点的位置和属性,而不重新生成或者删除节点。
面对以上的前后节点情况,我们可以根据C2中的节点去查找它是否在C1中,来更新dom属性和位置。
function patchKeyedChildren(c1, c2, container, anchor) {
for (let i = 0; i < c2.length; i++) {
const next = c2[i];
for (let j = 0; j < c1.length; j++) {
const prev = c1[i];
//c1 中是否有key相同节点
if (next.key === prev.key) {
//更新dom
patch(prev, next, container, anchor);
// 插入next.el 的位置 在前一个节点c2[i - 1]的下一个节点之前插入 也就是c2[i - 1].nextSibling
// const curAnchor = c1[0].el; //如果是 第一个的话
// const curAnchor = c2[i - 1].el.nextSibling;
const curAnchor = i === 0 ? c1[0].el : c2[i - 1].el.nextSibling;
container.insertBefore(next.el, curAnchor);
break;
}
}
}
}
上述代码复用了dom节点,但是对于移动节点的判断还是不够灵活
c1: a b c
c2: a b c
面对上述前后节点列表,明显不需要移动节点,但是上述会依次移动3次。需要尽量避免不必要的dom操作开销。
1.这里我们遍历C2中节点, 对于c2中每个节点命名为next,在c1中对应的相同key节点命名为prev
2.如果找到了,记录prev 的 index
3.如果index 呈 升序,不需要移动
4.如果index 不呈升序 需要移动
解决:
设置一个变量maxNewIndexSoFar, 记录当前的next在c1中找到的index的最大值。若新找到的index 大于等于 maxNewIndexSofar,说明index呈升序,不需要移动,并且更新maxNewIndexSofar为index,如果index小于maxNewIndexSofar,说明需要移动。他应该移动到上一个next之后,因此anchor设置为c2[i-1].nextSibling
function patchKeyedChildren(c1, c2, container, anchor) {
let maxNewIndexSoFar = 0;
for (let i = 0; i < c2.length; i++) {
const next = c2[i];
//判断c2中相同key节点是否在c1中找到的标志
let find = false;
for (let j = 0; j < c1.length; j++) {
const prev = c1[i];
if (next.key === prev.key) {
find = true;
patch(prev, next, container, anchor);
//如果小于 说明这里不是升序 需要移动节点 这里插入的位置前面代码位置一样 插入到上一个next之后
if (j < maxNewIndexSoFar) {
const curAnchor = i === 0 ? c1[0].el : c2[i - 1].el.nextSibling;
container.insertBefore(next.el, curAnchor);
} else {
//大于maxNewIndexSofar 说明升序 不需要移动
maxNewIndexSoFar = j;
}
break;
}
}
//c2 节点在c1中没找到相同key 节点 则直接创建节点mount操作
if (!find) {
const curAnchor = i === 0 ? c1[0].el : c2[i - 1].el.nextSibling;
patch(null, next, container, curAnchor);
}
}
//c2中节点遍历完毕 c1[i]中其他节点进行 unmount操作
for (let i = 0; i < c1.length; i++) {
const prev = c1[i];
if (!c2.find((next) => (next.key = prev.key))) {
unmount(prev);
}
}
}
上面代码用了两个for循环。这里我们可以用一个Map来存入c1中的节点,提高查找效率。
function patchKeyedChildren(c1, c2, container, anchor) {
let maxNewIndexSoFar = 0;
//用一个Map 存储c1中的节点 以key => {prev, j}存储信息
const map = new Map();
c1.forEach((prev, j) => map.set(prev.key, { prev, j }));
for (let i = 0; i < c2.length; i++) {
const next = c2[i];
const curAnchor = i === 0 ? c1[0].el : c2[i - 1].el.nextSibling;
//判断是否c1中存在相同key节点
if (map.has(next.key)) {
const { prev, j } = map.get(next.key);
patch(prev, next, container, anchor);
if (j < maxNewIndexSoFar) {
container.insertBefore(next.el, curAnchor);
} else {
maxNewIndexSoFar = j;
}
//删除c1中已遍历节点
map.delete(next.key);
} else {
//不存在map中的话则创建节点
patch(null, next, container, curAnchor);
}
}
//map中多余节点进行unmount操作
map.forEach(({ prev }) => {
unmount(prev);
});
}
0 1 2
c1: a b c
c2: c a b
面对上述情况,可以看出最佳方案只需要移动一次c节点, 但是react算法这里会移动两次。
1.c节点index 是2 此时maxNewIndexSoFar为0,2 > 0, maxNewIndexSofar 更新为2 这里不需要移动
2.a节点index 是0 0 < 2 a节点需要移动操作
3.b节点index 是1 1 < 2 b节点需要移动操作
一共两次移动操作
Vue2中diff算法
vue2 采用的是 双端比较算法,源自snabbdom.
Vue3中diff算法
Vue3 中采用的是另外的核心diff算法,它借鉴于ivi和inferno.
步骤依次是
- 从左至右依次比对
- 从右到左依次比对
3.经过1、2 直接将旧节点比对完, 则剩下的新节点
mount,此时 i > e1的话
经过1、2 直接将新节点对比完,则剩下旧节点unmount 此时 i > e2
- 若不满足3 采用传统diff算法,但是不添加和移动,只做标记和删除
- 创建一个
source数组 用于存储剩下节点在C1中的index
需要移动,采用新的最长上升子序列算法 根据
source数组算出一个最长上升子序列 seq
什么是最长递增子序列,给定一个数值序列,找到他的一个子序列,并且在子序列中的值是递增的,子序列中的元素在原序列中不一定连续
例如[0,8,4,12]
它的最长子序列可以是 [0,8,12] [0,4,12]也是可以的
seq 得到的是 source 最长上升子序列的在source中的下标,他的意义是在seq 中的元素都不需要移动,而没有在seq中的元素都需要移动。 因此得到一下算法:
- 设两个指针分别指向
source和seq,source从后往前遍历。 - 遇到-1 执行
mountsource指针位置减一 - 若
source指针和seq相等,说明不用移动, 两个指针都减一 - 若
source指针和seq指针不想等,执行移动,移动完之后source指针减一 这里anchor的计算 是curAnchor = (c2[nextPos] && c2[nextPos].el) || anchor
特殊情况
c1: a b c
c2: a x b y c
上面的例子 move 值是false 表示不需要移动, 但是仍然有未添加的元素 因此需要一个专门的toMounted去处理这种情况,toMounted存入的是元素的在C2下标
//1 5 3 7
function patchKeyedChildren(c1, c2, container, anchor) {
let i = 0;
let e1 = c1.length - 1;
let e2 = c2.length - 1;
//1.从左至右依次比对
while (i <= e1 && i <= e2 && c1[i].key === c2[i].key) {
patch(c1[i], c2[i], container, anchor);
i++;
}
//2.从右至左 依次对比
while (i <= e1 && i <= e2 && c1[e1].key === c2[e2].key) {
patch(c1[e1], c2[e2], container, anchor);
e1--;
e2--;
}
//c1: a b c
//c2: a d b c
// 旧节点比对完毕 直接mount 新节点
if (i > e1) {
for (let j = i; j <= e2; j++) {
const nextPos = e2 + 1;
const curAnchor = (c2[nextPos] && c2[nextPos].el) || anchor;
patch(null, c2[i], container, curAnchor);
}
//新的节点 比对完成 旧节点 unmount
} else if (i > e2) {
for (let j = i; j <= e1; j++) {
unmount(c1[j]);
}
//不满足上述 采用传统diff 算法 只做标记和删除
} else {
let maxNewIndexSoFar = 0;
// 4.采用传统diff算法,但不真的添加和移动,只做标记和删除
// 将 i 至 e1 需要传统diff 算法处理的 c1 中节点存入Map 方便c2遍历
const map = new Map();
for (let j = i; j <= e1; j++) {
const prev = c1[j];
map.set(prev.key, { prev, j });
}
//source 数组i-e2需要处理的节点的在C1中的index 不存在为 -1
const source = new Array(e2 - i + 1).fill(-1);
//判断是否需要移动
let move = false;
//为C2节点 在C1中相同key 节点恰好是升序时 还存在新增加的节点 存在的话 存入toMounted
const toMounted = [];
for (let k = 0; k < e2 - i + 1; k++) {
//这里从i 开始遍历 直到e2;
const next = c2[k + i];
//c1中存在相同key节点
if (map.has(next.key)) {
const { prev, j } = map.get(next.key);
patch(prev, next, container, anchor);
//不是升序 需要移动
if (j < maxNewIndexSoFar) {
move = true;
} else {
//升序 更新 maxNewIndexSofar
maxNewIndexSoFar = j;
}
//将节点对应的index存入 source中
source[k] = j;
//删除Map 中 C1 和 C2 都有的key 节点
map.delete(next.key);
} else {
// c2 中不存在 c1 中 的节点 防止 c2中节点呈升序时 这些节点不挂载
toMounted.push(i + k);
}
}
//不存在C2中的直接unmount
map.forEach(({ prev }) => {
unmount(prev);
});
if (move) {
//5. 需要移动 则采用新的最长上升子序列算法
const seq = getSequence(source);
let j = seq.length - 1;
//倒序遍历source 数组
for (let k = source.length - 1; k >= 0; k--) {
if (source[k] === -1) {
//mount -1代表不存在 直接创建节点
//pos i + k 代表在c2中的位置 source的长度表示只是需要传统diff算法处理的长度
const pos = i + k;
//查到pos + 1 也就是C2 pos下一个节点之前 因为是source倒序遍历 pos + 1的位置已经处理好了 如果不存在则直接插入最后了 也就是anchor
const nextPos = pos + 1;
const curAnchor = (c2[nextPos] && c2[nextPos].el) || anchor;
patch(null, c2[pos], container, curAnchor);
//是否等于最长子序列 最后一项
} else if (seq[j] === k) {
//不用移动 j--代表向左移动一位 继续匹配
j--;
} else {
//需要移动
const pos = i + k;
const nextPos = pos + 1;
const curAnchor = (c2[nextPos] && c2[nextPos].el) || anchor;
container.insertBefore(c2[pos].el, curAnchor);
}
}
} else if (toMounted.length) {
//c2呈升序时 但是存在新的节点 则需要创建这些节点
for (let k = toMounted.length - 1; k >= 0; k--) {
const pos = toMounted[k];
const nextPos = pos + 1;
const curAnchor = (c2[nextPos] && c2[nextPos].el) || anchor;
patch(null, c2[pos], container, curAnchor);
}
}
}
}
上面的getSequence是计算source上升最长子序列,返回的是在source的下标
这里的相关实现 可以参考leetcode相关题目
//最长上升子序列 算法
function getSequence(nums) {
//记录最长子序列 最大长度 和 利用它记录 对应的 index 位置
const records = [nums[0]];
// 记录 对用nums各个位置 最长子序列 位置
const position = [0];
for (let i = 1; i < nums.length; i++) {
// -1 这里不做处理
if (nums[i] === -1) continue;
if (nums[i] > records[records.length - 1]) {
records.push(nums[i]);
position.push(records.length - 1);
} else {
for (let j = 0; j < records.length; j++) {
if (records[j] > nums[i]) {
records[j] = nums[i];
position.push(j);
break;
}
}
}
// 找出一组最长子序列
let cur = records.length - 1;
for (let i = position.length - 1; i >= 0 && cur >= 0; i--) {
if (position[i] === cur) {
records[cur] = i;
cur--;
}
}
return records;
}
}
完工
看了两遍视频,然后自己跟着写了一遍,然后再写篇博客记录一下,对于相关知识点的认识更深了,以后还需多多学习。