携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第17天,点击查看活动详情
这一期讲核心diff
算法,其实就是patchKeyedChildren
,即是对有 key 存在的两组arrayChildren
做 diff
操作
基础实现
接上一节
接着来实现上一节的 patchArrayChildren
,简单的作法是比较新旧子节点数量,得到节点数量小的,用这个长度按顺序去对比两个节点的子节点,若旧节点子节点多,则删除多出的节点,若少,则新渲染多的节点,但这种方法太暴力
function patchUnkeyedChildren(c1, c2, container, anchor) {
const oldLength = c1.length;
const newLength = c2.length;
const commonLength = Math.min(oldLength, newLength);
for (let i = 0; i < commonLength; i++) {
patch(c1[i], c2[i], container, anchor);
}
if (newLength > oldLength) {
mountChildren(c2.slice(commonLength), container, anchor);
} else if (newLength < oldLength) {
unmountChildren(c1.slice(commonLength));
}
}
举个例子:
c1: a b c
c2: x a b c
这样会导致a改成x,b改成a,c改成b,渲染c,明明只要在a前面加个x就行了。复用性太低
所以推出diff算法,让真实dom的更新操作做到最小,但前提是带key,好判断是否同一节点
所以改了一下 patchChildren
function patchChildren(n1, n2, container, anchor) {
const { shapeFlag: prevShapeFlag, children: c1 } = n1;
const { shapeFlag, children: c2 } = n2;
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
。。。
} else {
// c2 is array or null
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// c1 was array
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// c2 is array
// 简单认为头一个元素有key就都有key
if (c1[0] && c1[0].key != null && c2[0] && c2[0].key != null) {
patchKeyedChildren(c1, c2, container, anchor);
} else {
patchUnkeyedChildren(c1, c2, container, anchor);
}
} else {
// c2 is null
unmountChildren(c1);
}
} else {
。。。
}
}
}
在react的实现
有个核心的思想,依次找到的新节点在旧节点的位置是升序的话,说明不需要移动
zhuanlan.zhihu.com/p/553744711
旧:a b c
新:a c b
遍历a时,在旧中找到index为0,更新即可,
遍历c,index为2,更新max为2,更新c节点
遍历到b,index为1,比max小,需要移动b到c后边,也就是插入到c后边元素前边,
insertBefore (b,newVnodeArray[i-1].nextSibling)
设置max为0,遍历c2元素 从c1中找key相等的元素,记录找到的index,如果大于max,更新max,如果小于,则将当前元素移动到c2前一个元素后(也就是 c2前一个元素的后一位元素(c2[i-1].el.nextSibling)的前面)
如果c2比c1数量多,需要添加挂载
如果少,需要遍历找到c2不存在的c1,删掉
function patchKeyedChildren(c1, c2, container, anchor) {
let maxNewIndexSoFar = 0;
for (let i = 0; i < c2.length; i++) {
const next = c2[i];
let find = false;
for (let j = 0; j < c1.length; j++) {
const prev = c1[j];
if (prev.key === next.key) {
find = true;
patch(prev, next, container, anchor);
if (j < maxNewIndexSoFar) {
const curAnchor = c2[i - 1].el.nextSibling;
container.insertBefore(next.el, curAnchor);
} else {
maxNewIndexSoFar = j;
}
break;
}
}
if (!find) {
const curAnchor = i === 0 ? c1[0].el : c2[i - 1].el.nextSibling;
patch(null, next, container, curAnchor);
}
}
for (let i = 0; i < c1.length; i++) {
const prev = c1[i];
if (!c2.find((next) => next.key === prev.key)) {
unmount(prev);
}
}
}
- 优化,用map代替for遍历查找
用map代替for,用map存储c1的key, 节点和index
遍历c2,找不到c1就添加。找到就patch,然后看着插入,把map相应key去掉,最后map存在的节点都是新节点中没的,都删掉。
function patchKeyedChildren(c1, c2, container, anchor) {
const map = new Map();
c1.forEach((prev, j) => {
map.set(prev.key, { prev, j });
});
let maxNewIndexSoFar = 0;
for (let i = 0; i < c2.length; i++) {
const next = c2[i];
const curAnchor = i === 0 ? c1[0].el : c2[i - 1].el.nextSibling;
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;
}
map.delete(next.key);
} else {
patch(null, next, container, curAnchor);
}
}
map.forEach(({ prev }) => {
unmount(prev);
});
}
缺点
举个例子
a b c
c a b
肉眼看只需要移动一次
react算法则需要移动两次,
遍历新节点时,c取到index为2,遍历a,得0,那么a要移动到c后边,遍历b为1,移动到a后边。移动了两次。
vue2的diff
四个指针,旧头旧尾,新头新尾
while 新头<=新尾
if 两头元素相等,两头指针后移
else if 两尾元素相等,两尾指针前移
else if 新头等于旧尾,新头指针后移,旧尾前移
else if 新尾等于旧头。。。
else if 遍历旧节点中有没有新头,有则把元素拉到旧头指针前,没有则新建元素,放旧头指针前
一直循环
最后删掉 旧头尾间的元素
vue3
1 从左到右依次对比,相同就继续前进
2 从右到左依次对比,相同就继续后退
3 如果旧节点比完了,说明其他的都是新节点,挂载上去
4 如果新节点比完了,说明多了一些旧节点,要删掉
5 如果都没比完,采用传统
diff
算法,但不真的添加和移动,只做标记和删除。建数组fill 0,存新节点在旧节点位置,查找过程中比对找到的位置,如果有降序,说明要移动,找出最长上升子序列,得出移动最少的步骤从右到左遍历,如果是-1,则挂载上去,如果当前元素是序列末位,则说明存在序列中,不用移动,序列末位前移;若不是,则要移动到新节点中该节点下一位节点之前。或者直接说移动到当前位置。
6 查找过程中比对找到的位置,如果没降序,说明是单纯的插入。 a b c=>a x b c
1-2 从左到右依次对比,相同就继续前进;从右到左依次对比,相同就继续后退
3 如果旧节点比完了,说明其他的都是新节点,挂载上去
4 如果新节点比完了,说明多了一些旧节点,要删掉
5 如果都没比完,采用传统 diff
算法,但不真的添加和移动,只做标记和删除。建数组fill 0,存新节点在旧节点位置,查找过程中比对找到的位置,如果有降序,说明要移动,找出最长上升子序列,得出移动最少的步骤
此时最长序列是 【2,3】 相应的索引组成的数组的【0,1】
从右到左遍历 sources,如果等于0(源码用的0,代码用的-1),说明要插入;如果当前索引等于序列【0,1】的末位,那么说明不需要移动,末位前移;如果不等于序列末位,说明要移动这个节点,b要移动到g前面,g为当前元素索引+1.
6 也有经过1-2,但不需要移动的情况,也是插入到当前元素索引后面元素前
c1: a b c
c2: a x b y c
source: [1,-1,2,-1,3]
seq: [1,2,3]
旧 d b c a
新 a b d c ,得【3,1,0,2】
最长序列 【1,2】[b,c] 在新中索引为 【1,3】
从右到左比3-0对,3一样,不挪,缩,2d不一样,挪,1b一样,0a不一样挪。
代码
function patchKeyedChildren(c1, c2, container, anchor) {
// anchor来源于fragment的尾节点。为了避免插入节点插到末位去了
// anchor没有的话,那就是undefined,insertBefore插入undefined不会报错,就是插入末尾
let i = 0,
e1 = c1.length - 1,
e2 = c2.length - 1;
// 1.从左至右依次比对
// key的判断可能要换成isSameVNodetype
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--;
}
// a b c
// a d b c
// 像这种情况
if (i > e1) {
// 3.经过1、2直接将旧结点比对完,则剩下的新结点直接mount,就是d到插到b前
// 取到b的位置,在b的前面mount元素。
// 如果从右开始没有匹配到,那么e2+1的元素是undefined,那还是用anchor
const nextPos = e2 + 1;
const curAnchor = (c2[nextPos] && c2[nextPos].el) || anchor;
for (let j = i; j <= e2; j++) {
patch(null, c2[j], container, curAnchor);
}
} else if (i > e2) {
// 3.经过1、2直接将新结点比对完,则剩下的旧结点直接unmount
for (let j = i; j <= e1; j++) {
unmount(c1[j]);
}
} else {
// 4.采用传统diff算法,但不真的添加和移动,只做标记和删除
const map = new Map();
for (let j = i; j <= e1; j++) {
const prev = c1[j];
map.set(prev.key, { prev, j });
}
// used to track whether any node has moved
let maxNewIndexSoFar = 0;
let move = false; // 用来校验是否需要移动 情况6
const toMounted = [];
const source = new Array(e2 - i + 1).fill(-1); //占位-1,找到就赋值
// 前后被匹配完后,遍历中间没被匹配的节点
for (let k = 0; k < e2 - i + 1; k++) {
const next = c2[k + i];
if (map.has(next.key)) {
const { prev, j } = map.get(next.key);
patch(prev, next, container, anchor);
// 如果一直都是升序的,那就不需要移动
if (j < maxNewIndexSoFar) {
move = true;
} else {
maxNewIndexSoFar = j;
}
source[k] = j;
map.delete(next.key);
} else {
// 将待新添加的节点放入toMounted,情况6
toMounted.push(k + i);
}
}
// 先刪除多余旧节点
map.forEach(({ prev }) => {
unmount(prev);
});
if (move) {
// 5.需要移动,则采用新的最长上升子序列算法
const seq = getSequence(source); // [0,1]
let j = seq.length - 1;
for (let k = source.length - 1; k >= 0; k--) {
if (k === seq[j]) {
// 不用移动,从后到前遍历,如果等于最长上升子序列里的末位,说明存在序列里
j--;
} else {
// 找出要插入的位置,
// 也就是 i 前面删掉的相同的点 + 当前的source的位置 得到当前的位置
// 在当前位置的下一位前插入
const pos = k + i;
const nextPos = pos + 1;
const curAnchor = (c2[nextPos] && c2[nextPos].el) || anchor;
if (source[k] === -1) {
// mount
patch(null, c2[pos], container, curAnchor);
} else {
// 移动 当前节点移动到下一节点前
container.insertBefore(c2[pos].el, curAnchor);
}
}
}
} else if (toMounted.length) {
// 6.不需要移动,但还有未添加的元素
// c1: a b c
// c2: a x b y c
// y 需要插入到 c 前
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);
}
}
}
}
最长上升子序列
dp 版 O(n^2)
1 2 5 3 4 6 4
遍历元素时,前面找比他小的元素,以它为结尾的最长上升子序列是 比他小的元素的序列+1 里面找最大的就行
比如3 找了1和2 和2合作比较大,结果为3
ps:所有比他小的都要比较,比如6不能只找5去比
var lengthOfLIS = function (nums) {
let dp = new Array(nums.length).fill(1);
let max = 1;
for (let i = 1; i < nums.length; i++) {
for (let j = 0; j < i; j++) {
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
max = Math.max(max, dp[i]);
}
return max;
};
贪心算法 O(n^2)
维护一个数组,遍历,如果大于数组末位,加进去,如果小,找位置插进去。
ps:插入后,后面的数据不用管,反正数组长度不会撑大。
var lengthOfLIS = function (nums) {
let arr = [nums[0]];
for (let i = 1; i < nums.length; i++) {
if (nums[i] > arr[arr.length - 1]) {
arr.push(nums[i]);
} else {
for (let j = 0; j < arr.length; j++) {
if (nums[i] <= arr[j]) {
arr[j] = nums[i];
break;
}
}
}
}
return arr.length;
};
贪心算法+二分 O(nlogn)
由于数组是升序,所以 插入的时候用二分去插
var lengthOfLIS = function (nums) {
let arr = [nums[0]];
for (let i = 1; i < nums.length; i++) {
if (nums[i] > arr[arr.length - 1]) {
arr.push(nums[i]);
} else {
let l = 0,
r = arr.length - 1;
while (l <= r) {
let mid = ~~((l + r) / 2);
if (nums[i] > arr[mid]) {
l = mid + 1;
} else if (nums[i] < arr[mid]) {
r = mid - 1;
} else {
l = mid;
break;
}
}
arr[l] = nums[i];
}
}
return arr.length;
};
最终版,要求返回索引。略过-1,返回子序列
新建数组position,存储元素插入序列时在序列的位置。得到最终子序列长度后,从右到左在position数组找出元素的值与子序列长度-1相等,说明这个是子序列最大值,取出它的索引。子序列最大值-1,继续找。
例子:
1 2 5 3 4 0
1 2 3 4// 最长子序列
0 1 2 2 3 0 // 索引数组position
0 1 3 4 //最终结果 arr
长度是4,要找索引为3的,从右到左找position元素,取出3,3的索引是4,arr[3] = 4
然后长度-1,找2,。。。再找1
function getSequence(nums) {
let arr = [];
let position = [];
for (let i = 0; i < nums.length; i++) {
if (nums[i] === -1) { //源码是0不参与
continue;
}
// arr[arr.length - 1]可能为undefined,此时nums[i] > undefined为false
if (nums[i] > arr[arr.length - 1]) {
arr.push(nums[i]);
position.push(arr.length - 1);
} else {
let l = 0,
r = arr.length - 1;
while (l <= r) {
let mid = ~~((l + r) / 2);
if (nums[i] > arr[mid]) {
l = mid + 1;
} else if (nums[i] < arr[mid]) {
r = mid - 1;
} else {
l = mid;
break;
}
}
arr[l] = nums[i];
position.push(l);
}
}
let cur = arr.length - 1;
// 这里复用了arr,它本身已经没用了
for (let i = position.length - 1; i >= 0 && cur >= 0; i--) {
if (position[i] === cur) {
arr[cur--] = i;
}
}
return arr;
}
源码
源码优化了索引的取值。我是一言难尽。
function getSequence(arr: number[]): number[] {
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
}
u = 0
v = result.length - 1
while (u < v) {
c = (u + v) >> 1
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
}
}
}
u = result.length
v = result[u - 1]
while (u-- > 0) {
result[u] = v
v = p[v]
}
return result
}