Vue3 已经发布很久很久很久了,今天就来一起了解一下 Vue3 最新的 diff 算法,Vue2 的 diff 算法可以看一下之前发的文章《Vue2源码系列-9张图搞懂diff算法》。
今天没有段子
准备工作
版本
本文使用 Vue3 的 3.3.8
版本
示例
为了更直观理解与解读,使用以下示例代码配合讲解
html
<div id="demo">
<ul>
<li v-for="item in items" :key="item">{{item}}</li>
</ul>
<button @click="changeOrder">塔塔开</button>
</div>
<script>
Vue.createApp({
data: () => ({
items: ["a", "b", "c", "d"],
}),
methods: {
changeOrder() {
this.items = ["b", "d", "e", "c"];
},
},
}).mount("#demo");
</script>
名词解释
虚拟 DOM
虚拟 DOM (Virtual DOM,简称 VDOM) 是一种编程概念,意为将目标所需的 UI 通过数据结构"虚拟"地表示出来,保存在内存中,然后将真实的 DOM 与之保持同步。
这里所说的 vnode 即一个纯 JavaScript 的对象 (一个"虚拟节点"),它代表着一个<div>
元素。
虚拟 DOM 树
顾名思义,也就是一个虚拟 DOM 作为根节点,包含有一个或多个的子虚拟 DOM。
diff
在 Vue 中,我们改变原来的数据,例如示例中的数组 items,那么 Vue 中会再生成一份虚拟 DOM 树,现在我们就有了两份虚拟 DOM 树。
这时候渲染器将会找出它们之间的区别,并将其中的变化应用到真实的 DOM 上。这个过程被称为 更新 (patch)
,又被称为 比对(diffing)
或 协调(reconciliation)
。
流程概述
不带 key 的新虚拟 DOM 树
- 从前往后遍历新旧虚拟 DOM 树,将旧虚拟 DOM 更新为新虚拟 DOM
- 比较新旧虚拟 DOM 树的长度,处理新增与删除的节点
带 key 的新虚拟 DOM 树
- 从前往后遍历新旧虚拟 DOM 树,寻找可复用节点,遇到不可复用节点跳出循环
- 从后往前遍历新旧虚拟 DOM 树,寻找可复用节点,遇到不可复用节点跳出循环
- 对比前两次遍历的索引
- 识别出两侧与中间的新增与删除的节点
- 处理未识别出的节点
- 遍历新节点,生成新节点的 key 与 index 对应的 Map
- 创建将要 patch 的节点数组 newIndexToOldIndexMap(下标为新节点索引,值为旧节点索引+1)
- 遍历剩余节点,寻找新节点 key 值对应的旧节点索引
- 旧节点有 key 值,直接在 Map 中寻找
- 旧节点不带 key 值,遍历剩余新节点,判断是否可复用
- 找到新节点索引,更新 newIndexToOldIndexMap;没有找到就是不存在,直接卸载
- 根据 newIndexToOldIndexMap 生成最长稳定序列
- 从后往前遍历需要 patch 的节点
- 在 newIndexToOldIndexMap 中的值为 0 的,是新节点
- 不为 0 的,进一步判断是否在最长稳定序列中,不在就移动
不喜欢看源码的,看到这里就可以了
源码详解
还往下翻?看来是觉得只看概述不过瘾?
那就一起来扒一下源码,加深一下印象吧
Patch
一切的一切还要从 patch
方法讲起。
ts
const patch: PatchFn = (...) => {
// patch 方法首先判断传入的两个虚拟 DOM 是否完全一致,完全一致直接跳出 patch
if (n1 === n2) {
return
}
// 进一步判断新旧虚拟节点的 type 与 key 值是否一致,不一致将直接卸载旧虚拟节点
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1)
unmount(n1, parentComponent, parentSuspense, true)
n1 = null
}
// 非编译器生成,不走 optimized 模式
if (n2.patchFlag === PatchFlags.BAIL) {
optimized = false
n2.dynamicChildren = null
}
const { type, ref, shapeFlag } = n2
// 根据 新虚拟节点 的不同类型(type)使用不同的处理函数
switch (type) {
// 1. 文本:处理方式简单粗暴,直接往容器追加文本节点
case Text:
processText(n1, n2, container, anchor)
break
// 2. 组件节点:处理方式是先创建该组件,再往容器内追加
case Comment:
processCommentNode(n1, n2, container, anchor)
break
// 3. 静态节点:处理方式直接挂载节点
case Static:
if (n1 == null) {
mountStaticNode(n2, container, anchor, isSVG)
} else if (__DEV__) {
patchStaticNode(n1, n2, container, isSVG)
}
break
// 4. Fragment:Vue3 新组件,走 `processFragment` 处理函数
case Fragment:
processFragment(...)
break
default:
// 如果都不是上述的类型,那就需要再分,这一次不根据 type(Symbol 标记),而是 shapeFlag(位运算)继续细分类型:
if (shapeFlag & ShapeFlags.ELEMENT) {
// 对应真实 DOM 节点,处理方式挂载节点(初始化)或走 patchElement 函数(处理 hook,若存在子节点也将走到 `patchChildren`)
processElement(...)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
// vue 组件,处理方式就是挂载或更新组件
processComponent(...)
} else if (shapeFlag & ShapeFlags.TELEPORT) {
// vue3 新组件,可自行了解 ☞ https://cn.vuejs.org/guide/built-ins/teleport.html
;(type as typeof TeleportImpl).process(...)
} else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
// vue3 新组件,可自行了解 ☞ https://cn.vuejs.org/guide/built-ins/suspense.html
;(type as typeof SuspenseImpl).process(...)
} else if (__DEV__) {
warn('Invalid VNode type:', type, `(${typeof type})`)
}
}
// 挂 ref
if (ref != null && parentComponent) {
setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
}
}
以我们的示例代码为例,在我们按下按钮后,发生变化的是节点 li;在 Vue3 中一个组件如果有多个子节点会为其创建一个包裹,也即 Fragment 组件
同样的,v-for
也会为其创建一个包裹;因此,这里的 patch 类型会是 Fragment,将走到 processFragment 函数。
还是以我们的示例代码为例,processFragment 函数在节点更新时会走到 patchChildren
函数,这里就不贴代码了。
patchChildren
处理新旧两份虚拟子节点
ts
const patchChildren: PatchChildrenFn = (...) => {
// 取一哈新旧虚拟节点的子节点
const c1 = n1 && n1.children
const prevShapeFlag = n1 ? n1.shapeFlag : 0
const c2 = n2.children
const { patchFlag, shapeFlag } = n2
if (patchFlag > 0) {
// patchFlag > 0 就表示子节点含有动态属性,如:动态style、动态class、动态文案等
if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
// 子节点带 key
patchKeyedChildren(...)
return
} else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
// 子节点不带 key
patchUnkeyedChildren(...)
return
}
}
// 子节点存在3种可能的情况:文本、数组、没有子节点
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 新虚拟节点的子节点是文本
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 对应的旧虚拟节点的子节点是数组
// 卸载旧虚拟节点的数组子节点
unmountChildren(c1 as VNode[], parentComponent, parentSuspense)
}
// 再挂载新虚拟节点的文本子节点
if (c2 !== c1) {
hostSetElementText(container, c2 as string)
}
} else {
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 旧虚拟节点的子节点是数组
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 新虚拟节点的子节点也是数组,做全量diff
patchKeyedChildren(...)
} else {
// 能走到这就说明新虚拟节点没有子节点,这里只需要卸载久虚拟节点的子节点
unmountChildren(c1 as VNode[], parentComponent, parentSuspense, true)
}
} else {
// 走到这就说明
// 旧虚拟节点的子节点要么是文本要么也没有子节点
// 新虚拟节点的子节点要么是数组要么就没有子节点
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 旧虚拟节点的子节点是文本,更新
hostSetElementText(container, '')
}
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 新虚拟节点的子节点是数组,挂载
mountChildren(...)
}
}
}
}
patchUnkeyedChildren
这里我们先看一下不带 key 是怎么处理的
ts
const patchUnkeyedChildren = (...) => {
c1 = c1 || EMPTY_ARR;
c2 = c2 || EMPTY_ARR;
const oldLength = c1.length;
const newLength = c2.length;
const commonLength = Math.min(oldLength, newLength);
let i;
// 依次从头遍历,将旧的虚拟节点更新为新节点
for (i = 0; i < commonLength; i++) {
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]));
patch(...);
}
if (oldLength > newLength) {
// 旧虚拟节点的子节点长度更大,说明被删除了
// 卸载多余的旧虚拟节点
unmountChildren(...);
} else {
// 反之,存在新增的节点,挂载
mountChildren(...);
}
};
patchKeyedChildren
我们示例中的 li 都带了 key,因此下一步直接走到 patchKeyedChildren
函数,也是 diff 算法优化的重点
ts
const patchKeyedChildren = (...) => {
let i = 0;
const l2 = c2.length;
let e1 = c1.length - 1; // 旧虚拟 DOM 树的末尾节点索引
let e2 = l2 - 1; // 新虚拟 DOM 树的末尾节点索引
/**
* 第一步
* 从前往后遍历
* 索引 i 递增
*/
while (i <= e1 && i <= e2) {
// 新旧虚拟节点同时遍历
const n1 = c1[i];
const n2 = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]));
if (isSameVNodeType(n1, n2)) {
// 存在类型相同,且key值相同的节点就 patch
patch(...);
} else {
// 存在不一致的节点,立即退出循环
break;
}
i++;
}
/**
* 第二步
* 从后往前遍历
* 索引 e1、e2 递减少
*/
while (i <= e1 && i <= e2) {
// 新旧虚拟节点同时遍历
const n1 = c1[e1];
const n2 = (c2[e2] = optimized
? cloneIfMounted(c2[e2] as VNode)
: normalizeVNode(c2[e2]));
if (isSameVNodeType(n1, n2)) {
// 存在类型相同,且key值相同的节点就patch
patch(...);
} else {
// 存在不一致的节点,立即退出循环
break;
}
e1--;
e2--;
}
/**
* 第三步
* 对比索引大小
* 处理新增或被删除节点
*/
if (i > e1) {
if (i <= e2) {
// 存在新增节点
/**
* 1. 左侧新增
* (a b)
* c (a b)
* i = 0, e1 = -1, e2 = 0
*/
/**
* 2. 中间新增
* (a b)
* (a) c d (b)
* i = 1, e1 = 0, e2 = 2
*/
/**
* 3. 右侧新增
* (a b)
* (a b) c
* i = 2, e1 = 1, e2 = 2
*/
// nextPos 作为新节点的后一位节点的索引,当作插入锚点
const nextPos = e2 + 1;
const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor;
while (i <= e2) {
/**
* i 到 e2 之间的节点即为新增节点
* 直接挂载
* oldvnode 置为 null,复用 path 函数进行挂载
*/
patch(
null,
(c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i])),
...
);
i++;
}
}
} else if (i > e2) {
// 存在被删除节点
/**
* 1. 左侧被删除
* a (b c)
* (b c)
* i = 0, e1 = 0, e2 = -1
*/
/**
* 2. 中间被删除
* (a) c d (b)
* (a) (b)
* i = 1, e1 = 2, e2 = 0
*/
/**
* 3. 右侧被删除
* (a b) c
* (a b)
* i = 2, e1 = 2, e2 = 1
*/
while (i <= e1) {
/**
* i 到 e1 之间的节点即为被删除节点
* 直接卸载
*/
unmount(c1[i], parentComponent, parentSuspense, true);
i++;
}
}
/**
* 第四步
* 处理其它特殊情况
* 我们的示例也将走到这里
*/
else {
// 新旧虚拟 DOM 树不一致的节点起始索引
const s1 = i;
const s2 = i;
/**
* 遍历新节点
* 生成map,键值即新节点的key值,值为节点的索引
* 伪代码:keyToNewIndexMap<key, index>
*/
const keyToNewIndexMap: Map<string | number | symbol, number> = new Map();
for (i = s2; i <= e2; i++) {
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]));
if (nextChild.key != null) {
keyToNewIndexMap.set(nextChild.key, i);
}
}
let j;
// 记录已经 patch 的节点数
let patched = 0;
// 计算新节点中还有多少节点需要被 patch
const toBePatched = e2 - s2 + 1;
// 标记是否需要移动
let moved = false;
// 记录这次对比的旧节点到新节点最长可复用的索引
let maxNewIndexSoFar = 0;
/**
* 创建将要patch的节点数组
* 新节点对应旧节点的数组
* 数组下标为新节点的索引,值为旧节点的索引+1
*/
const newIndexToOldIndexMap = new Array(toBePatched);
/**
* 默认置为 0
* 表示新增节点
* 这也是为什么值是 旧节点索引+1 而不直接存索引的原因了
* 后续判断是否存在可复用的旧节点再重新赋值
*/
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0;
// 遍历 i 到 e2 之间需要处理的节点
for (i = s1; i <= e1; i++) {
// 获取一下旧虚拟节点
const prevChild = c1[i];
if (patched >= toBePatched) {
// 已经patch的节点数量大于或等于需要被patch的节点数
// 说明当前节点是需要被删除的
unmount(prevChild, parentComponent, parentSuspense, true);
continue;
}
// 获取当前旧节点对应的新节点索引
let newIndex;
if (prevChild.key != null) {
/**
* 旧节点存在key值
* 直接在 keyToNewIndexMap 查找
* 获取新节点的索引(newIndex)
*/
newIndex = keyToNewIndexMap.get(prevChild.key);
} else {
// 旧节点不存在 key 值
for (j = s2; j <= e2; j++) {
// 遍历新节点剩余索引(s2 即新旧虚拟DOM树存在不同节点的位置)
if (
newIndexToOldIndexMap[j - s2] === 0 &&
isSameVNodeType(prevChild, c2[j] as VNode)
) {
/**
* 判断当前索引是不是还没 patch(newIndexToOldIndexMap[j - s2] === 0)
* 同时判断当前新旧节点是否 key、type 都一致
* 一致就获取当前新节点索引(newIndex = j)
* 跳出当前循环
*/
newIndex = j;
break;
}
}
}
if (newIndex === undefined) {
/**
* newIndex 为 undefined
* 说明当前旧节点在新的虚拟 DOM 树中被删了
* 直接卸载
*/
unmount(prevChild, parentComponent, parentSuspense, true);
} else {
/**
* newIndex有值
* 说明当前旧节点在新节点数组中还存在,可能只是挪了位置
*/
/**
* 记录一下 newIndexToOldIndexMap
* 表明当前新旧节点需要 patch
*/
newIndexToOldIndexMap[newIndex - s2] = i + 1;
if (newIndex >= maxNewIndexSoFar) {
// 新节点索引大于或等于最长可复用索引,重新赋值
maxNewIndexSoFar = newIndex;
} else {
/**
* 反之
* 说明新节点在最长可复用节点的左侧
* 需要移动(左移)
*/
moved = true;
}
// 直接复用,patch(处理可能存在的孙子节点、更新一下属性等)
patch(
prevChild,
c2[newIndex] as VNode,
...
);
patched++;
}
}
/**
* 根据 newIndexToOldIndexMap 数组
* 生成最长稳定序列
* 最长稳定序列在这里存的就是不需要移动的节点索引
*/
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR;
// 最长稳定序列末尾节点索引
j = increasingNewIndexSequence.length - 1;
// 从后往前遍历需要 patch 的节点数
for (i = toBePatched - 1; i >= 0; i--) {
// 新虚拟节点索引
const nextIndex = s2 + i;
// 新虚拟节点
const nextChild = c2[nextIndex] as VNode;
// 将新节点的真实 DOM 作为后续插入的锚点
const anchor =
nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor;
if (newIndexToOldIndexMap[i] === 0) {
/**
* 为 0 的话就是新增的节点
* 直接挂载新节点
*/
patch(
null,
nextChild,
container,
anchor,
...
);
} else if (moved) {
// 需要移动
if (j < 0 || i !== increasingNewIndexSequence[j]) {
/**
* 当前索引不在最长递增序列中
* 移动当前索引对应的新节点
* 移动到锚点节点之前
*/
move(nextChild, container, anchor, MoveType.REORDER);
} else {
j--;
}
}
}
}
};
再补充一点,在 Vue 中,节点移动通常是左移,也就是通过 insertBefore
这个 API 实现
到这里,这个 diff 流程就走完了;还有很多针对不同节点类型的特殊处理,因为与 diff 的主流程没有太大关系也就没有列出。
小试牛刀
你学费了吗?
在我们的示例中
- 旧虚拟 DOM 树对应的就是:
['a', 'b', 'c', 'd']
- 新虚拟 DOM 树对应的就是:
['b', 'd', 'e', 'c']
可以先自己试着梳理一下 diff 过程
可以直观地看出,无论是从前往后遍历,还是从后往前遍历,都找不到可复用的节点,也无法通过对比前两次索引得到新增或被删除的节点是谁;直接进入核心算法。
整棵树都是无法处理的节点,因此生成的 keyToNewIndexMap
长这样:
js
{'b' => 0, 'd' => 1, 'e' => 2, 'c' => 3}
旧虚拟 DOM 树都是带 key 的,所以可以轻松地从 keyToNewIndexMap 中得到新节点的索引,得到的 newIndexToOldIndexMap
如下:
js
[2, 4, 0, 3];
注意,数组内的值对应的旧节点的索引+1的值,数组的下标才是新节点的索引
从 newIndexToOldIndexMap 就可以看出,下标是 2 的新节点是新增的(值为 0)
这时候还有最后一不,判断旧节点的顺序是不是对的,这里就要生成最长稳定序列了
,结果如下:
js
[0, 3]
因此,稳定的是索引为 0 与索引为 3 的新节点;需要移动的便是索引为 1 的新节点,对应的就是索引为 3(4 - 1)的旧节点,直接将新节点插入到索引为 2 的新节点前面。
小结
来个思维导图解解闷儿
与 Vue2 对比
Vue2 的 diff 算法,在一个大循环中处理所有可复用情况,最后再判断是否还有新增或被删除的节点;相比之下,Vue3 将大循环拆开了,更加细化节点的类型,针对不同类型做优化。
Vue2 在一开始就直接在大循环中,对比新旧虚拟 DOM 树的首尾节点,发现可复用节点但位置不对,直接移动(操作真实节点);而在 Vue3 中,移动节点的操作放到了最后,同时借助最长稳定序列减少节点操作次数。
在 Vue2 同样也生成了个 Map,不过这个 Map 是旧节点的 key 与索引的对应关系;而 Vue3 中的 Map 则是新节点的 key 与索引的对应关系。