Vue3源码-diff算法-patchKeyChildren流程浅析
本文基于
Vue 3.2.30版本源码进行分析
为了增加可读性,会对源码进行删减、调整顺序、改变部分分支条件的操作,文中所有源码均可视作为伪代码
由于ts版本代码携带参数过多,不利于展示,大部分伪代码会取编译后的js代码,而不是原来的ts代码
整体概述
从Vue 3.2.30的源码可以知道,源码中有对patchKeyedChildren()方法进行了核心步骤的注释,摘出核心步骤的注册如下面代码块所示,可以分为5个步骤:
步骤1:从头->尾,处理相同的前置元素步骤2:从尾->头,处理相同的后置元素步骤3:旧vnode已经处理完毕,但是新vnode还有元素,处理新增元素,直接进行mount步骤4:旧vnode还有剩余,但是新vnode已经处理完毕,处理已经废弃元素,直接进行unmount步骤5:最复杂的情况,处理相同的前置元素+处理相同的后置元素后,剩下的元素有新增、废弃、乱序的情况,需要复杂处理
const patchKeyedChildren = (c1, c2, container, ...args) => {
// 1. sync from start
// (a b) c
// (a b) d e
// 2. sync from end
// a (b c)
// d e (b c)
// 3. common sequence + mount
// (a b)
// (a b) c
// 4. common sequence + unmount
// (a b) c
// (a b)
// 5. unknown sequence
// [i ... e1 + 1]: a b [c d e] f g
// [i ... e2 + 1]: a b [e d c h] f g
}
步骤1:从头->尾,处理相同的前置元素
直接从i=0开始遍历,如果找到相同类型的元素,直接进行patch()进行数据的更新,然后递增i
const patchKeyedChildren = (c1, c2, container, ...args) => {
let i = 0;
const l2 = c2.length;
let e1 = c1.length - 1; // prev ending index
let e2 = c2.length - 1; // next ending index
// 1. sync from start
// (a b) c
// (a b) d e
while (i <= e1 && i <= e2) {
const n1 = c1[i];
const n2 = c2[i];
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, null, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
} else {
break;
}
i++;
}
}
步骤2:从尾->头,处理相同的后置元素
从end indedx开始向前遍历,如果找到相同类型的元素,直接进行patch()进行数据的更新,然后递减e1和e2
const patchKeyedChildren = (c1, c2, container, ...args) => {
let i = 0;
const l2 = c2.length;
let e1 = c1.length - 1; // prev ending index
let e2 = c2.length - 1; // next ending index
// 步骤1:从头->尾,patch()处理相同的前置元素,递增i
// 2. sync from end
// a (b c)
// d e (b c)
while (i <= e1 && i <= e2) {
const n1 = c1[e1];
const n2 = c2[e2];
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, null, ...args1);
} else {
break;
}
e1--;
e2--;
}
}
步骤3:旧children已经处理完毕,但是新children还有元素,处理新增元素,直接进行mount
// (a b)
// (a b) c
// i = 2, e1 = 1, e2 = 2
// (a b)
// c (a b)
// i = 0, e1 = -1, e2 = 0
从上面代码块可以知道新增元素有两种情况,都满足i>e1 && i<=e2:
- 一种是新增元素在末尾,那么
patch()传入parentAnchor作为参照,将新增元素添加到container子元素的末尾 - 一种是新增元素在头部,那么
patch()传入c2[e2+1].el,即上面示例元素a所对应的el作为参照,将新增元素添加到元素a的前面
const patchKeyedChildren = (c1, c2, container, ...args) => {
let i = 0;
const l2 = c2.length;
let e1 = c1.length - 1; // prev ending index
let e2 = c2.length - 1; // next ending index
// 步骤1:从头->尾,patch()处理相同的前置元素,递增i
// 步骤2:从尾->头,patch()处理相同的后置元素,递减e1和e2
// 3. common sequence + mount
if (i > e1) {
if (i <= e2) {
const nextPos = e2 + 1;
const anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor;
while (i <= e2) {
patch(null, c2[i], container, anchor, ...args1);
i++;
}
}
}
}
步骤4:旧children还有剩余,但是新children已经处理完毕,处理已经废弃元素,直接进行unmount
// (a b) c
// (a b)
// i = 2, e1 = 2, e2 = 1
// a (b c)
// (b c)
// i = 0, e1 = 0, e2 = -1
从上面代码块可以知道有两种情况,都满足i>e2 && i<=e1:
- 一种是废弃元素在末尾,直接调用
unmount()进行[i, e1]的元素清除 - 一种是废弃元素在头部,直接调用
unmount()进行[i, e1]的元素清除
const patchKeyedChildren = (c1, c2, container, ...args) => {
let i = 0;
const l2 = c2.length;
let e1 = c1.length - 1; // prev ending index
let e2 = c2.length - 1; // next ending index
// 步骤1:从头->尾,patch()处理相同的前置元素,递增i
// 步骤2:从尾->头,patch()处理相同的后置元素,递减e1和e2
if (i > e1) {
// 步骤3:旧children已经处理完毕,但是新children还有元素,处理新增元素,直接进行mount
} else if (i > e2) {
// 4. common sequence + unmount
while (i <= e1) {
unmount(c1[i], parentComponent, parentSuspense, true);
i++;
}
}
}
步骤5:最复杂的情况,处理相同的前置元素+处理相同的后置元素后,剩下的元素有新增、废弃、乱序的情况,需要复杂处理
从Vue 3.2.30的源码可以知道,源码中对步骤5也进行了核心步骤的注释,下面的解析也将围绕着源码注释的步骤而讲解,主要步骤为:
步骤5.1:为新vnode的children建立索引[key: vnode.key, value: index],这样就可以通过key=vnode.key快速找到对应的索引位置步骤5.2:- 移除废弃的
旧vnode - 使用
旧vnode去找对应的新vnode,进行vnode的复用。如果找到可复用的vnode,进行patch()更新,同时使用newIndexToOldIndexMap[newIndex - s2] = i + 1为新vnode存储对应旧vnode的index(所有index递增了1位,比如0变成1,1变成2) - 使用
newIndex < maxNewIndexSoFar判断该diff是否需要移动节点
- 移除废弃的
步骤5.3:移动/新增元素处理
const patchKeyedChildren = (c1, c2, container, ...args) => {
// 步骤1:从头->尾,patch()处理相同的前置元素,递增i
// 步骤2:从尾->头,patch()处理相同的后置元素,递减e1和e2
if (i > e1) {
// 步骤3:旧children已经处理完毕,但是新children还有元素,处理新增元素,直接进行mount
} else if (i > e2) {
// 步骤4:旧children还有剩余,但是新children已经处理完毕,处理已经废弃元素,直接进行unmount
} else {
// 5. unknown sequence
// [i ... e1 + 1]: a b [c d e] f g
// [i ... e2 + 1]: a b [e d c h] f g
// i = 2, e1 = 4, e2 = 5
// 5.1 build key:index map for newChildren
// 5.2 loop through old children left to be patched and try to patch
// 5.3 move and mount
}
}
步骤5.1:为newChildren建立索引
const s1 = i; // prev starting index
const s2 = i; // next starting index
// 5.1 build key:index map for newChildren
const keyToNewIndexMap = new Map();
for (i = s2; i <= e2; i++) {
const nextChild = c2[i];
if (nextChild.key != null) {
keyToNewIndexMap.set(nextChild.key, i);
}
}
步骤5.2:移除废弃的旧vnode + 更新能复用的旧vnode + newIndexToOldIndexMap和move的构建为下一步骤做准备
初始化变量
let j;
let patched = 0;
const toBePatched = e2 - s2 + 1;
let moved = false;
let maxNewIndexSoFar = 0;
const newIndexToOldIndexMap = new Array(toBePatched);
for (i = 0; i < toBePatched; i++)
newIndexToOldIndexMap[i] = 0;
逻辑1:移除废弃的旧vnode
如果
prevChild.key缺失,会进行[s2, e2]的所有newChild遍历,使用isSameVNodeType去找可以复用的vnode节点,如果还找不到对应的newIndex,那就说明是废弃vnode了
- 情况1:
newIndex === undefined代表新vnode已经找不到目前的旧vnode,说明旧vnode已经废弃,直接unmount()移除 - 情况2:
patched >= toBePatched代表新vnode已经循环完成,不需要再找复用的旧vnode,说明剩下还没复用的旧vnode都已经废弃,直接unmount()移除,一般发生在旧vnode末尾存在废弃节点
for (i = s1; i <= e1; i++) {
const prevChild = c1[i];
if (patched >= toBePatched) { // 情况2
unmount(prevChild, parentComponent, parentSuspense, true);
continue;
}
let newIndex;
if (prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key);
} else {
// key-less node, try to locate a key-less node of the same type
for (j = s2; j <= e2; j++) {
if (newIndexToOldIndexMap[j - s2] === 0 &&
isSameVNodeType(prevChild, c2[j])) {
newIndex = j;
break;
}
}
}
if (newIndex === undefined) {// 情况1
unmount(prevChild, parentComponent, parentSuspense, true);
} else {
patched++;
}
}
逻辑2:更新能复用的旧vnode
for (i = s1; i <= e1; i++) {
const prevChild = c1[i];
if (patched >= toBePatched) {
// 逻辑1:移除废弃的旧vnode的情况1
unmount(prevChild, parentComponent, parentSuspense, true);
continue;
}
let newIndex;
// -------- 寻找能够复用的vnode的newIndex --------------
if (prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key);
} else {
//...如果prevChild.key缺失,会进行[s2, e2]的所有newChild遍历,使用isSameVNodeType去找可以复用的vnode节点
}
// -------- 寻找能够复用的vnode的newIndex --------------
if (newIndex === undefined) {
// 逻辑1:移除废弃的旧vnode的情况2
unmount(prevChild, parentComponent, parentSuspense, true);
} else {
// 逻辑2:更新能复用的旧vnode
patch(prevChild, c2[newIndex], container, null, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
patched++;
}
}
逻辑3:newIndexToOldIndexMap和move的构建为步骤5.3做准备
move的构建:用旧vnode进行遍历,去找新vnode对应的index,使用maxNewIndexSoFar不断更新找到新vnode对应的index,如果newIndex < maxNewIndexSoFar说明目前的旧vnode的位置应该移动到它前面旧vnode的前面去,即目前的旧vnode是需要移动的,此时将move=truenewIndexToOldIndexMap的构建:用新vnode的index(从0开始)作为key,用旧vnode的index+1(原来oldChildrenArray的index+1,不是从0开始)作为value,构建一个Array对象,为步骤5.3做准备
for (i = s1; i <= e1; i++) {
const prevChild = c1[i];
if (patched >= toBePatched) {
// ...省略代码 逻辑1:移除废弃的旧vnode的情况1
continue;
}
let newIndex;
// ...省略代码:寻找能够复用的vnode的newIndex
if (newIndex === undefined) {
// ...省略代码 逻辑1:移除废弃的旧vnode的情况2
} else {
// ========逻辑3:newIndexToOldIndexMap和move的构建为下一步骤做准备========
newIndexToOldIndexMap[newIndex - s2] = i + 1;
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex;
} else {
moved = true;
}
// ========逻辑3:newIndexToOldIndexMap和move的构建为下一步骤做准备========
// ...省略代码 逻辑2:更新能复用的旧vnode
}
}
步骤5.3:移动/新增处理
由步骤5.2可以知道,该步骤会移除所有废弃的旧vnode,因此剩余的逻辑只有 移动可复用的vnode到正确的位置 + 插入之前没有过的新vnode
新增:判断目前新vnode是之前没有过的新vnode
由步骤5.2 逻辑3可以知道,newIndexToOldIndexMap是以newChildrenArray建立的初始化都为0的数组,然后在步骤5.2 逻辑3进行oldChildrenArray的遍历,找到可以复用的新vnode,进行newIndexToOldIndexMap[xxx]=旧vnode的index+1的赋值,因此newIndexToOldIndexMap[xxx]=0就代表这个新vnode是之前没有过的,直接进行patch(null, nextChild),即mount()插入新的元素
// 从尾->头进行新vnode的遍历
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i;
const nextChild = c2[nextIndex];
const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : parentAnchor;
if (newIndexToOldIndexMap[i] === 0) {
patch(null, nextChild, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
} else if (moved) {
}
}
移动:判断目前新vnode对应的可复用的旧vnode是否需要移动位置
流程1: 构建最长递增子序列
Vue3源码关于最长递增子序列算法的构建可以参考leetcode高赞题解(推导流程详细),在本文中将直接抛弃算法细节,直接使用最长递增子序列的构建方法getSequence()
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR;
j = increasingNewIndexSequence.length - 1;
increasingNewIndexSequence:使用newIndexToOldIndexMap数组构建最长递增子序列,如下图所示,我们有newIndexToOldIndexMap=[4, 1, 0, 5, 3],最长递增子序列的意思为item是保持递增的一个序列,即[index=1,index=5]/[index=1,index=3]等等都是递增的值,我们通过getSequence(newIndexToOldIndexMap)获取到的值为newChildren中对应的递增子序列的index数组,通过手动触发getSequence(),我们得到increasingNewIndexSequence=[1,4]
我们初始化
newIndexToOldIndexMap时,每一个item都赋值为0,获取oldChildren的index都主动加上了1,因此newIndexToOldIndexMap[xxx]=0代表是之前旧children没有过的vnode,不参与构建最长递增子序列的逻辑中

流程2: 根据increasingNewIndexSequence进行节点的移动
使用倒序遍历新的
children数组是因为anchor,如果没有相同的后置元素,则添加到container子元素的末尾,如果有相同的后置元素,则以最前面一个后置元素作为anchor
patch流程中会触发n2.el=n1.el,move()方法本质还是拿到了新vnode对应的el,然后进行hostInsert(el, container, anchor)移动操作
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR;
j = increasingNewIndexSequence.length - 1;
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i;
const nextChild = c2[nextIndex];
const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : parentAnchor;
if (newIndexToOldIndexMap[i] === 0) {
patch(null, nextChild, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
} else if (moved) {
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor, 2 /* REORDER */);
} else {
j--;
}
}
}
const move = (vnode, container, anchor, moveType, parentSuspense = null) => {
const { el, type, transition, children, shapeFlag } = vnode;
//...多种类型的数据进行不同的move处理,包括组件、Comment、Static
if (needTransition) {
//...
} else {
hostInsert(el, container, anchor);
}
};
- 如果
increasingNewIndexSequence.length=0,即构建不出来最长递增子序列的时候,按照上面代码块的逻辑,我们从末尾开始,使用当前新vnode后面的index作为参照,不停将旧vnode移动插入到c2[nextIndex+1]的前面 - 如果
increasingNewIndexSequence.length>0,那么遇到i === increasingNewIndexSequence[j]时,代表目前的nextChild是最长递增子序列的一个元素,由于最长递增子序列代表旧vnode的相关位置在新vnode的相关位置仍然保持递增,因此这些位于最长递增子序列的元素可以不进行move操作,直接进行j--即可
increasingNewIndexSequence最长递增子序列的作用:获取旧的children在新的children的相对位置顺序仍然递增的最长子序列,减少move的次数,提升性能

步骤5.3整体流程图解





Vue系列其它文章
转载自:https://juejin.cn/post/7181693272710971447