上一篇写完了 Element 的 props 更新,这次来实现 Element 的 children 更新了。
Element 的 children 变化,有以下四种情况:
- 数组变文本
- 文本变文本
- 文本变数组
- 数组变数组
其中,前3种比较好处理,第四种数组对数组的比较,需要使用到 diff 算法去比较。
更新 Element children 的逻辑也是在 patchElement
函数里面开始。
patchElement
ts
function patchElement(n1, n2, container, parentComponent, anchor) {
// 更新 props:
const oldProps = n1.props || EMPTY_OBJ;
const newProps = n2.props || EMPTY_OBJ;
const el = (n2.el = n1.el);
// 更新 Element 的 children
patchChildren(n1, n2, el, parentComponent, anchor);
// 更新 Element 的 props,上次已经写了
patchProps(el, oldProps, newProps);
}
patchChildren
ts
function patchChildren(n1, n2, parentComponent, anchor) {
const prevShapeFlag = n1.shapeFlag;
const { shapeFlag } = n2;
const c1 = n1.children;
const c2 = n2.children;
// 新-text
if(shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 1. 新-text & 旧-array
if(prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 把老的 children 清空
unmountChildren(n1.children);
}
// 2. 新-text & 旧-text
if(c1 !== c2) {
// 更新文本
hostSetElementText(container, c2);
}
// 新-array
} else {
// 3. 新-array & 旧-text
if(prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 先清空旧文本
hostSetElementText(container, '');
// 挂载新的 children
mountChildren(c2, container, parentComponent, anchor);
// 4. 新-array & 旧-array
} else {
// array diff array
patchKeyedChildren(c1, c2, container, parentComponent, anchor);
}
}
}
patchKeyedChildren
数组 children 对比数组 children,使用的是双端对比 diff 算法,也就是使用双指针,分为老节点数组双指针、新节点数组双指针,同步移动指针,对新旧节点两两比对。
左侧比对
左侧比对,遇到相同的节点,同时移动新旧节点左指针,直到遇到不同的节点,停止移动指针。
相同的节点,是指 type
key
都相同。
左指针都是从旧 children、新 children 头部 i = 0
开始,右指针 e1
e2
从 旧 children、新 children 的尾部开始
ts
let i = 0;
let e1 = c1.length - 1; // c1 为 旧节点数组
let e2 = c2.length - 1; // c2 为新节点数组
// 左侧比对
while(i <= e1 && i <= e2) {
const oldVNode = c1[i];
const newVNode = c2[i];
if(isSameVNodeType(oldVNode, newVNode)) {
// 如果相同位置 i 的新旧节点一致,则继续对这两个节点比较
patch(oldVNode, newVNode, container, parentComponent, parentAnchor);
} else {
break; // 如果不同,就立即退出,指针 i 停止移动;i 刚好指向不同的位置
}
i++; // 左指针右移
}
右侧比对
右侧比对也一样,遇到相同的节点,同时移动新旧节点右指针,直到遇到不同的节点,停止移动指针。
ts
// 右侧比对
while(i <= e1 && i <= e2) {
const oldVNode = c1[e1];
const newVNode = c2[e2];
if(isSameVNodeType(oldVNode, newVNode)) {
patch(oldVNode, newVNode, container, parentComponent, parentAnchor);
} else {
break; // 如果不同,就立即退出,指针 e1 e2 停止移动;e1 e2 刚好指向不同的位置
}
// 右指针左移
e1--;
e2--;
}
不论是左指针从左到右,还是右指针从右往左,都有可能遇到新节点、旧节点个数不一样的情况。
新的比老的多 - 创建
新节点数比旧节点数多的时候:
- 只有左侧比对,也就是只有左侧有相同节点,右侧没有
- 或者只有右侧比对,也就是只有右侧有相同节点,左侧没有
- 两侧都比对
可以发现,一定有 i > e1
。新节点里面, e1 < i <= e2
,这个范围内的都需要新增。
ts
// 退出左侧、右侧循环后,有:
const l2 = c2.length; // 新节点个数
if(i > e1) {
if(i <= e2) {
// 插入点索引
const nextPos = e2 + 1;
// 插入锚点
const anchor = nextPos < l2
? c2[nextPos].el
: null;
// e1 < i <= e2 范围内的,需要新增
while(i <= e2) {
// 第一个参数传 null,创建
patch(null, c2[i], container, parentComponent, anchor);
i++;
}
}
}
上面的代码,l2
是新节点数组的长度, 新节点会在此 e2 + 1
位置(锚点)之前插入。
这里很巧妙的是,无论是左侧比对,还是右侧比对,都有:
如果是从左往右移动 i
,则一定会有 e2 + 1 === l2
如果是从右往左移动 e1
e2
,则一定会有 e2 + 1 < l2
通过比较 e2 + 1
l2
的大小,可以确定是在头部插入还是尾部插入新节点,也就是确定插入锚点的位置。
插入多个节点也是同样的锚点,因为是挨着锚点插入的。
ts
// 插入点索引
const nextPos = e2 + 1;
// 插入锚点
const anchor = nextPos < l2 ? c2[nextPos].el : null;
// e1 < i <= e2 范围内的,需要新增
while(i <= e2) {
// 第一个参数传 null,创建
patch(null, c2[i], container, parentComponent, anchor); // anchor 是同一个
i++;
}
(1)左侧比对的插入
(2)右侧比对的插入
老的比新的多 - 删除
老节点数比新节点数多的时候:
- 只有左侧比对,也就是只有左侧有相同节点,右侧没有
- 或者只有右侧比对,也就是只有右侧有相同节点,左侧没有
- 双侧都比对
可以发现,一定有 i > e2
。旧节点里面,e2 < i <= e1
这个范围内的都需要移除。
ts
// 新的比老的长
if(i > e1) {
...
// 老的比新的长
} else if (i > e2) {
while(i <= e1) {
hostRemove(c1[i].el); // 移除节点
i++;
}
}
中间比对
- 假如一开始左右都有相同节点,因为在一开始就把左侧相同的节点都比对完了,左指针来到不同节点的位置;接着处理右侧相同节点,右指针也来到了不同节点的位置。
- 假如一开始左右侧都没有相同的节点,那就是直接来到中间比对了。
所以,中间比对的条件是 i <= e1 && i <= e2
。
中间比对存在以下几种情况:
1. 在老的里面存在,新的里面不存在 - 删除
- 在老的里面存在,新的里面不存在
- 已经
patched
完,还有多的旧节点,需删除
ps: 还有一种情况,在老的、新的里面都存在,但是节点没有
key
也会被删除,但该节点后续会再被创建,所以该节点还是没变。
ts
// 新的比旧的多
if(i > e1) {
// ...(省略代码)
// 新的比旧的少
} else if (i > e2) {
// ...(省略代码)
// 中间比对
} else {
let s1 = i; // 旧节点开始位置
let s2 = i; // 新节点开始位置
/**
* keyToNewIndexMap key-newIndex
* key 新节点的 key
* value 新节点的索引
*/
const keyToNewIndexMap = new Map();
let toBePatched = e2 - s2 + 1; // 中间部分需要 patch 的节点数
let patched = 0; // 中间部分已经 patch 的数量
// 遍历新节点
for(let i = s2; i <= e2; i++) {
const nextChild = c2[i];
keyToNewIndexMap.set(nextChild.key, i);
}
let newIndex;
// 遍历旧节点
for(let i = s1; i <= e1; i++) {
const prevChild = c1[i];
// 2. 如果应该 patch 的已经 patch 完了,还有剩余的就删除
if(patched >= toBePatched) {
hostRemove(prevChild.el);
}
if(prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key);
}
// 1. 在新节点中找不到对应老节点的索引,删除
if(newIndex === undefined) {
hostRemove(prevChild.el);
// 找到,就 patch 新旧节点
} else {
patch(prevChild, c2[newIndex], container, parentCompoent, null);
patched++;
}
}
}
在老的里面不存在,新的里面存在 - 创建
- 没有
key
的节点,由于newIndex
为undefined
已被移除,但会来到这里新创建,也就是没有 key 的节点,经历了移除、创建两个步骤 - 有
key
但不存在于老节点中
判断新节点在老节点里面是否存在,使用了 newIndexToOldIndexMap
映射表,是个数组。
-
index 表示
toBePatched
的序号(从0到toBePatched); -
value,如果新节点存在于旧节点,值为该节点对应的
旧节点索引值 + 1
;如果新节点不存在,值为0
。所以,可以判断值是否为0
,以判断是否存在于旧节点中,只有值为0
的,创建。
diff
if(i > e1) {
} else if(i > e2) {
} else {
const s1 = i; // 旧节点开始位置
const s2 = i; // 新节点开始位置
const keyToNewIndexMap = new Map();
let toBePatched = e2 - s2 + 1; // 中间部分需要 patch 的节点数
let patched = 0; // 中间部分已经 patch 的数量
+ // 中间部分,toBePatched 的索引与旧索引的关系表
+ // index - (0 ~ (toBePatched - 1))
+ // value - 旧节点的索引值
+ const newIndexToOldIndexMap = new Array(toBePatched);
+ // 初始化 newIndexToOldIndexMap 各项 value = 0
+ for(let i = 0; i < toBePatched; i++) {
+ newIndexToOldIndexMap[i] = 0;
+ }
// 遍历新节点
for(let i = s2; i <= e2; i++) {
const nextChild = c2[i];
keyToNewIndexMap.set(nextChild.key, i);
}
let newIndex;
// 遍历旧节点
for(let i = s1; i <= e1; i++) {
const prevChild = c1[i];
if(prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key);
}
// 当前老节点在新的里面不存在,删除
if(newIndex === undefined) {
hostRemove(prevChild.el);
// 当前老节点在新的里面存在,更新
} else {
+ // 当前老节点的索引赋给在 newIndexToOldIndexMap 列表中的值
+ // i + 1 是为了规避初始的 0
+ newIndexToOldIndexMap[newIndex - s2] = i + 1;
patch(prevChild, c2[newIndex], container, parentComponent, null);
}
}
+ // 创建新节点
+ for(let i = 0; i < toBePatched; i++) {
+ // newIndexToOldIndexMap[i] === 0 表示经过上面遍历老节点,没有改变值,
+ // 所以说明老节点里面不存在该新节点,需要创建
+ // 1. 上面没有 key 的节点,由于 newIndex 为 undefined 已被移除,但会来到这里新创建
+ // 也就是没有 key 的节点,经历了移除、创建两个步骤
+ // 2. 有 key 但不存在于老节点中
+ if(newIndexToOldIndexMap[i] === 0) {
+ const nextIndex = s2 + i;
+ const nextChild = c2[nextIndex];
+ const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : null;
+ patch(null, nextChild, container, parentComponent, anchor);
+ }
+ }
}
在老的和新的里面存在,只是位置变了 - 移动
上面代码,在遍历老节点时,对存在于老节点,也存在于新节点的节点进行了 pacth
更新,但是我们除了需要更新节点,还需要考虑节点的位置是否发生了变化,也就是需要考虑移动节点的情况。
移动节点,用到的是最长递增子序列算法,在 ToBePatched
即中间部分需要 pacth
的节点数组 newIndexToOldIndexMap
中,找到最长的递增子序列。
最长递增子序列,在 Diff 算法中的作用是,找到一组前后顺序不变的节点子集,前后顺序不变,指的就是在一组新节点中,对应的旧索引值是递增的,即原本该靠前的靠前,该靠后的还是原来的靠后。
移动节点时,把最长递增子序列排除掉,只移动不在这个最长递增子序列里的索引对应的节点即可。
根据上面对 newIndexToOldIndexMap
的初始化及在遍历老节点时赋值的操作,我们应该已经清楚它各项的数值表示的是旧索引。
分析新旧节点,旧节点数组索引是递增的,那如果原本靠前的节点索引变得比原来大,那么说明该节点肯定是需要移动到靠后去的。
而如果这组旧节点数组对应在新节点中,原本靠前的节点还是靠前,原本靠后的节点还是靠后,位置不用发生变化。
而我们就是要在 newIndexToOldIndexMap
中找到最长递增的子序列,这些子序列对应的节点不用移动,那么把这些子序列排除掉,只移动不在最长递增子序列里的索引对应的节点即可。
使用 maxNewIndexSoFar
记录历史最大的 newIndex
,协助判断是否有需要移动的节点。
使用 moved
标记是否移动。
diff
if(i > e1) {
} else if(i > e2) {
} else {
const s1 = i; // 旧节点开始位置
const s2 = i; // 新节点开始位置
const keyToNewIndexMap = new Map();
let toBePatched = e2 - s2 + 1; // 中间部分需要 patch 的节点数
let patched = 0; // 中间部分已经 patch 的数量
// 中间部分,toBePatched 的索引与旧索引的关系表
// index - (0 ~ (toBePatched - 1))
// value - 节点的旧索引
const newIndexToOldIndexMap = new Array(toBePatched);
// 初始化 newIndexToOldIndexMap 各项 value = 0
for(let i = 0; i < toBePatched; i++) {
newIndexToOldIndexMap[i] = 0;
}
// 遍历新节点
for(let i = 0; i <= e2; i++) {
const nextChild = c2[i];
keyToNewIndexMap.set(nextChild.key, i);
}
let newIndex;
+ // 协助判断 moved,遍历老节点,如当前对应的 newIndex 更大,更新 maxNewIndexSoFar
+ // 理应当前旧节点的对应的 newIndex 比上一个节点大,否则表明有移动
+ let maxNewIndexSoFar = 0;
+ let moved = false;
// 遍历旧节点
for(let i = 0; i <= e1; i++) {
const prevChild = c1[i];
// 中间部分,老的比新的多,那么多出来的直接可以被干掉
if(patched >= toBePatched) {
hostRemove(prevChild.el);
continue;
}
if(prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key);
}
// 当前老节点在新的里面不存在,删除
if(newIndex === undefined) {
hostRemove(prevChild.el);
// 当前老节点在新的里面存在
} else {
+ // 随着老节点往下遍历,老节点对应的 newIndex 更大的话
+ if(newIndex >= maxNewIndexSoFar) {
+ // 更新 maxNewIndexSoFar 值
+ maxNewIndexSoFar = newIndex; // 相当于记录上一个最大的 newIndex 的值
+ // 如果上一个 newIndex (maxNewIndexSoFar) 比当前 newIndex 小,表明有移动
+ } else {
+ moved = true;
+ }
// 当前老节点的索引赋给在 toBePatched 中的值
// i + 1 是为了规避初始的 0
newIndexToOldIndexMap[newIndex - s2] = i + 1;
patch(prevChild, c2[newIndex], container, parentComponent, null);
patched++;
}
}
+ // 求得 newIndexToOldIndexMap 的最长递增子序列
+ const increasingSequence = getSequence(newIndexToOldIndexMap);
+ let j = increasingSequence.length - 1;
for(let i = 0; i < toBePatched; i++) {
const nextIndex = s2 + i; // 待插入节点索引
const nextChild = c2[nextIndex]; // 待插入节点
const anchor = nextIndex + 1 < l2
? c2[nextIndex + 1].el
: null; // 插入锚点
// 经过上面遍历老节点,没有改变值,所以说明老节点里面不存在该新节点,创建
if(newIndexToOldIndexMap[i] === 0) {
patch(null, nextChild, container, parentComponent, anchor);
}
+ // 从需要 patch 的节点中,找出不在最长递增子序列中的索引 i,该索引 i 对应的节点才需要移动
+ if(moved) {
+ if(j < 0 || i !== increasingSequence[j]) {
+ // 移动节点
+ hostInsert(nextChild.el, container, anchor);
+ } else {
+ 如果 i 包含在最长递增子序列内,则移动指针,判断下一个
+ j--;
+ }
+ }
}
}
完整的 patchKeyChildren
这里的注释可能更清晰点。
ts
function patchKeyedChildren(c1, c2, container, parentComponent, parentAnchor) {
const l2 = c2.length;
let i = 0;
let e1 = c1.length - 1;
let e2 = l2 - 1;
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--;
}
// 新的比老的多 创建
if(i > e1) {
if(i <= e2) {
const nextPos = e2 + 1;
const anchor = nextPos < l2 ? c2[nextPos].el : null;
while(i <= e2) {
patch(null, c2[i], container, parentComponent, anchor);
i++;
}
}
// 老的比新的长 删除
/**
*
*/
} else if(i > e2) {
while(i <= e1) {
hostRemove(c1[i].el);
i++;
}
} else {
// 中间对比
let s1 = i; // 旧节点开始位置
let s2 = i; // 新节点开始位置
/**
* keyToNewIndexMap key-newIndex
* key 新节点的 key
* value 新节点的索引
*/
const keyToNewIndexMap = new Map();
// 新节点中间那些需要patch的个数,newIndexToOldIndexMap 的 length
const toBePatched = e2 - s2 + 1;
let patched = 0; // 开始的patch数
/**
* newIndexToOldIndexMap 对应中间那些待 patch 的新节点,
* 索引 index 为 0 ~ (toBepatched-1)
* 值为老节点索引
*/
const newIndexToOldIndexMap = new Array(toBePatched);
let moved = false;
// 协助判断 moved,遍历老节点,如当前对应的 newIndex 更大,更新 newIndex
// 理应当前的对应的 newIndex 比上一个节点大,否则表明有移动
let maxNewIndexSoFar = 0;
// 初始化 newIndexToOldIndexMap,处于没被标记为有对应的老节点的状态
for(let i = 0; i < toBePatched; i++) {
newIndexToOldIndexMap[i] = 0;
}
// 遍历新节点
for(let i = s2; i <= e2; i++) {
const nextChild = c2[i];
keyToNewIndexMap.set(nextChild.key, i);
}
// 遍历老节点
for(let i = s1; i <= e1; i++) {
const prevChild = c1[i];
// 中间部分,老的比新的多,那么多出来的直接可以被干掉
if(patched >= toBePatched) {
hostRemove(prevChild.el);
continue;
}
let newIndex;
if(prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key);
} else {
for(let j = s2; j <= e2; j++) {
if(isSameVNodeType(prevChild, c2[j])) {
newIndex = j;
break;
}
}
}
// 老的节点不存在于新节点中
if(newIndex === undefined) {
hostRemove(prevChild.el);
// 老的节点存在于新节点中
} else {
if(newIndex >= maxNewIndexSoFar) {
// 随着老节点往下遍历,老节点对应的 newIndex 更大的话,更新 maxNewIndexSoFar 值
maxNewIndexSoFar = newIndex;
} else {
// 只要有 newIndex 变小了,就说明有移动
moved = true;
}
// 可以看作是需要 patch 的新节点数组的索引与老节点索引之间的映射关系
// i + 1 是为了规避 0
// 0 在 newIndexToOldIndexMap 中表示还没开始找映射关系的一种状态
newIndexToOldIndexMap[newIndex - s2] = i + 1;
patch(prevChild, c2[newIndex], container, parentComponent, null);
patched++;
}
}
// increasingNewIndexSequence 是最长递增子序列
// 项代表需要 patch 的节点的索引
// 在 increasingNewIndexSequence 序列中的元素不需要移动
const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : [];
// 相当于一个指针指向最长递增子序列数组尾部
let j = increasingNewIndexSequence.length - 1;
// 遍历需要 patch 的新节点找出不在最长递子序列中的节点索引,进行节点的移动,
// 之所以 for 遍历 toBePatched + 移动 j 指针 可行 ,是因为它们都是升序的
// 从后往前遍历,性能更优
for(let i = toBePatched - 1; i >= 0; i--) {
const nextIndex = i + s2; // 当前需要被移动的节点位置
const nextChild = c2[nextIndex]; // 当前需要被移动的节点
const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : null; // 插入锚点
// 当前节点不存在于老节点中,创建
// 1. 上面没有 key 的节点,由于 newIndex 为 undefined 已被移除,但会来到这里新创建
// 也就是没有 key 的节点,经历了移除、创建两个步骤
// 2. 有 key 但不存在于老节点中
if(newIndexToOldIndexMap[i] === 0) {
patch(null, nextChild, container, parentComponent, anchor);
}
if(moved) {
// 从需要 patch 的节点中,找出不在最长递增子序列中的索引 i,
// 该索引 i 对应的节点才需要移动
if(j < 0 || i !== increasingNewIndexSequence[j]) {
console.log('移动');
hostInsert(nextChild.el, container, anchor);
} else {
// 如果 i 包含在最长递增子序列内,则移动指针,判断下一个
j--;
}
}
}
}
}