前言
身为一个Vue技术栈的前端人,想必在面试的时候多多少少都会被问到一些框架的底层原理,而在众多的问题中,diff算法就是其中的一个比较高频的考点。
这一节,我们就从源码出发,看一看Vue3 的diff算法 到底是怎么样的,Vue3 的diff算法 和Vue2的相比它的优化具体在什么地方。
初识diff算法
无论是Vue还是React,这些框架将数据呈现在界面上的方式都采用将模板结合数据生成虚拟DOM,然后再将虚拟DOM转换为真实的DOM结构呈现在页面上。
那么,为什么这些框架要在数据 和DOM 之间插入了一个虚拟DOM 的环节呢?那是因为在我们浏览器在渲染过程中,直接对DOM进行的操作开销都是巨大的 ,特别是当我们有一些不是很合理或者没有进行过任何优化措施的DOM操作,都将给浏览器带较大的负担,进而可能会影响用户的体验。
而虚拟DOM 就是一个对象,一个包含了DOM信息 的对象。像Vue 这些的框架,在数据和DOM之间插入了一个虚拟DOM 环节,当我们的数据发生更新,则会进行比对新旧虚拟DOM树 ,看一看它们的差异 在哪里,然后再做具体的更新 ,如果有能够复用DOM结构,则直接复用,不能复用,再进行新建或者删除DOM的操作,这个比对的过程其实就是运用diff算法 的过程。我们知道,js的运行效率 远远大于直接操作DOM的效率 ,而整个的比对过程是发生在js端,而不是直接对DOM进行操作,所以这也是Vue这类框架相比较于原生的优势所在。
说了这么多,想必此时大家对于diff算法 也有了一个初步的认识,接下来,就从源码层面来去探究一下Vue3的diff算法。
patch函数
在正式开始diff算法之前,我想先引入一下patch函数 的概念。我们从patch函数说起:
patch ,翻译为"补丁 ",这个函数的作用也正如它的名字,就是给我们的旧数据打补丁,做差异化的更新操作 。而具体到Vue中,就是通过patch函数 来对新旧虚拟DOM进行比对。
js
const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, slotScopeIds = null, optimized = isHmrUpdating ? false : !!n2.dynamicChildren) => {
// 如果新旧vnode节点相同,则无须patch
if (n1 === n2) {
return;
}
// 如果新旧vnode节点,type类型不同,则直接卸载旧节点
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1);
unmount(n1, parentComponent, parentSuspense, true);
n1 = null;
}
...
const { type, ref: ref2, shapeFlag } = n2;
// 根据新节点的类型,采用不同的函数进行处理
switch (type) {
// 处理文本
case Text:
processText(...);
break;
// 处理注释
case Comment:
processCommentNode(...);
break;
// 处理静态节点
case Static:
if (n1 == null) {
mountStaticNode(...);
} else {
patchStaticNode(...);
}
break;
// 处理Fragment
case Fragment:
// Fragment
processFragment(...)
break;
default:
if (shapeFlag & 1 /* ELEMENT */) {
// element类型
processElement(...);
} else if (shapeFlag & 6 /* COMPONENT */) {
// 组件
processComponent(...)
} else if (shapeFlag & 64 /* TELEPORT */) {
// teleport
...
} else if (shapeFlag & 128 /* SUSPENSE */) {
// suspense
...
} else if (true) {
warn2("Invalid VNode type:", type, `(${typeof type})`);
}
}
...
};
当我们进行组件更新 ,进入到patch函数 ,首先会进行的操作就是,通过isSameVNodeType函数
判断新旧虚拟DOM 是否指向的是同一个节点,如果不是同一个节点,那么直接将旧节点卸载 ,再进行后续挂载新节点 的操作;如果是同一个节点,则再去处理节点里面的内容,如果有内部的子元素,则对子元素进行递归的patch。
我们看一下,isSameVNodeType函数
是如何区分新旧虚拟DOM是否是指向同一个节点的。
js
function isSameVNodeType(n1, n2) {
return n1.type === n2.type && n1.key === n2.key;
}
可以看到,判断新旧虚拟DOM是否指向同一个节点 的依据就是判断它们的 type 和 key 是否相同。
举个例子:
html
<div key="xxx">
{{x}}
</div>
上面的模板编译为vnode之后如下:
可以看到type 就是它的标签名 ,而它的key则是我们在编码阶段为它赋予的key。
至此,我们了解了patch阶段 是如何判断两个vnode是否指向同一个节点,可以更加明确的认识到 key 的重要性。
(了解了这个,其实我们就可以搞一个骚操作,比如有时候我们希望一个组件在进行了一些操作之后不要复用,就是给我来一个新的 ,特别是在用了组件库的时候,我们想对它做一个组件层面的刷新操作,那该怎么办呢?这时就可以给这个组件加一个key ,比如将这个key设定为0,之后每次需要它刷新的时候,将它的key++,对key进行递增的操作 ,那么在Vue底层进行patch 的时候,就会将这个节点对应的新旧vnode判断为指向了不同的节点,直接将旧节点卸载,对新节点进行挂载,从而实现了组件的刷新,就很骚操作~~~ 当然,能不用还是不要用了,不然人家Vue底层对性能的优化就白做了,哈哈)
当我们判断完新旧节点是否为同一节点后 ,进入到了分类讨论 的阶段,根据节点类型不同进行不同的操作。
深入diff
了解完patch函数 之后,我们正式接触diff算法 。diff算法 主要发生在新旧子节点同为数组 的情况,其具体在源码中的patchKeyedChildren函数体现。
patchKeyedChildren函数的源码比较长,我们分段一点点去探究。
我们将下面例子结合代码一起看会更清晰一些:
js
<template>
<div v-for="item in list" :key="item">{{ item }}</div>
</template>
<script setup>
import { reactive } from "vue";
let list = reactive(['a','b','c','d','e'])
setTimeout(() => {
list.splice(2,1)
}, 1000)
</script>
这个例子实现的效果是:
首先我们会创建a、b、c、d、e 五个div节点,在代码中我们同时开了定时器,过一秒钟之后会删除掉下标为2的元素,也就是c ,最终的DOM结构应该是a、b、d、e四个节点,这个过程我们来分析一下。
头头比对
js
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) => {
// 指针
let i = 0;
// 新节点数组长度
const l2 = c2.length;
// 旧节点数组尾部指针
let e1 = c1.length - 1;
// 新节点数组尾部指针
let e2 = l2 - 1;
while (i <= e1 && i <= e2) {
const n1 = c1[i];
const n2 = c2[i] = optimized ? cloneIfMounted(c2[i]) : normalizeVNode(c2[i]);
// 如果是相同节点,则直接进行Patch操作,否则跳出循环
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
} else {
break;
}
// 指针后移
i++;
}
...
}
进入patchKeyedChildren函数
,首先进行的是头头比较 ,如果新旧虚拟DOM节点为同一节点,则对其进行patch 操作,将指针后移;一旦遇到不同的节点,则结束头头比对。
经过头头比较操作之后:
到这里,我们的头头比较 结束,已经确定了两个节点(a、b节点 )可以复用,可以对其直接进行patch操作。
尾尾比较
js
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) => {
// 指针
let i = 0;
// 新节点数组长度
const l2 = c2.length;
// 旧节点数组尾部指针
let e1 = c1.length - 1;
// 新节点数组尾部指针
let e2 = l2 - 1;
// 头头比较
...
//尾尾比较,此时移动的是指针e1,e2
while (i <= e1 && i <= e2) {
const n1 = c1[e1];
const n2 = c2[e2] = optimized ? cloneIfMounted(c2[e2]) : normalizeVNode(c2[e2]);
if (isSameVNodeType(n1, n2)) {
// 如果新旧数组节点尾指针指向的vnode为同一个节点,则进行patch操作
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
} else {
// 跳出尾尾比对循环
break;
}
// 将尾指针左移
e1--;
e2--;
}
...
}
头头比对 之后,接下来进入尾尾比对 ,我们前面已经获取过新旧虚拟DOM数组的尾部指针 ,那么这个阶段就是不断的比较尾指针所指向的虚拟DOM 是否为同一节点,如果是同一节点,则直接进行patch更新,并且将尾指针左移;否则,跳出尾尾比对。
经过尾尾比对:
由于此时新节点数组的尾指针e2
小于了i
指针,所以退出尾尾比对 。经过尾尾比对,我们也确定了两个尾部元素d、e是可以复用的。
非复杂情况处理
js
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) => {
// 指针
let i = 0;
// 新节点数组长度
const l2 = c2.length;
// 旧节点数组尾部指针
let e1 = c1.length - 1;
// 新节点数组尾部指针
let e2 = l2 - 1;
// 头头比较
...
//尾尾比较
...
if (i > e1) {
// 旧节点数组头指针超过了尾指针
if (i <= e2) {
// 新节点数组头指针不超过尾指针,说明有新增的节点
const nextPos = e2 + 1;
const anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor;
while (i <= e2) {
// 通过patch函数,第一个参数传为null,将新元素挂载到容器
patch(
null,
c2[i] = optimized ? cloneIfMounted(c2[i]) : normalizeVNode(c2[i]),
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
i++;
}
}
} else if (i > e2) {
// 旧节点数组头指针不超过尾指针
// 同时,新节点数组头指针超过了尾指针,说明有一些多余的旧节点,需要将其卸载
while (i <= e1) {
// 卸载旧节点数组
unmount(c1[i], parentComponent, parentSuspense, true);
i++;
}
} else {
...
}
}
经过了头头比对,尾尾比对,此时新旧节点数组对应的首尾指针会有以下几种情况:
- 旧节点数组的头指针超过 了尾指针(i > e1 ),同时,新节点数组的头指针没有超过 尾指针(i <= e2 ),说明有需要新增的元素。
- 旧节点数组的头指针没有超过 尾指针(i <= e1 ),同时,新节点数组的头指针超过 了尾指针(i > e2 ),说明有需要卸载的元素。
- 新旧节点数组的头指针都没有超过 尾指针(i <= e1,i <= e2),后面单独讨论这种情况。
还是用上面的例子先来看一下:
上面例子中,我们可以看到满足了i <= e1 && i > e2
这一情况,通过图示,我们也能很清晰的看到,经过了头头比对,尾尾比对之后,我们将a、b、d、e 节点都完成了复用,而此时旧节点数组还有一个 c节点 剩余,但新节点数组已完成遍历,那么 c节点 就是一个多余出来的节点,直接将其进行卸载操作。
上面例子体现了卸载元素 的情况,那么对于新增元素 的情况,也是同理,我们直接将例子反过来就好了(旧节点为a、b、d、e,我们要做的操作是,往b和d节点之间插入一个c节点):
此时我们可以看到,它满足的情况就是 i > e1 && i <= e2
,那么新节点数组中的 c节点 就是一个新增的节点,我们对其进行挂载操作即可。
复杂情况处理
做完了前面的简单工作,如果仍有节点需要处理,那么指针对应的情况就是i <= e1 && i <= e2
,也就是新旧节点数组都还有节点需要进行比对 ,那么就进入了复杂情况的处理,我们再看看Vue3这里是怎么操作的吧,这里也是diff算法的关键所在。
js
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) => {
...
// 定义指针,头头比较,尾尾比较
...
if (i > e1) {
// 旧节点数组头指针超过了尾指针,新增新节点
...
} else if (i > e2) {
// 旧节点数组头指针不超过尾指针,卸载旧节点
...
} else {
// 定义旧节点数组头指针
const s1 = i;
// 定义新节点数组头指针
const s2 = i;
// 创建保存 key - index 的容器
const keyToNewIndexMap = /* @__PURE__ */ new Map();
// 遍历剩余新节点,保存节点key对应的下标至容器keyToNewIndexMap中
for (i = s2; i <= e2; i++) {
const nextChild = c2[i] = optimized ? cloneIfMounted(c2[i]) : normalizeVNode(c2[i]);
if (nextChild.key != null) {
keyToNewIndexMap.set(nextChild.key, i);
}
}
let j;
// 已经处理的节点数
let patched = 0;
// 需要处理的节点数
const toBePatched = e2 - s2 + 1;
// 是否需要移动节点
let moved = false;
// 记录与旧节点对应的节点在新节点数组中出现的最大坐标(用于判断是否发生移动)
let maxNewIndexSoFar = 0;
// 以还需要比对的新节点数组长度创建数组,默认每一项均为0
// 当经过后续操作之后,该数组中值仍为0的节点,说明在旧节点中没有出现过,是需要新创建的
const newIndexToOldIndexMap = new Array(toBePatched);
for (i = 0; i < toBePatched; i++)
newIndexToOldIndexMap[i] = 0;
// 遍历旧节点数组还未处理的节点
for (i = s1; i <= e1; i++) {
const prevChild = c1[i];
// 已处理节点数超过了需要处理的节点数
// 说明新节点数组已全部处理完毕,旧节点剩余的都是多余的节点,直接卸载
if (patched >= toBePatched) {
unmount(prevChild, parentComponent, parentSuspense, true);
continue;
}
let newIndex;
// 获取该节点在新节点数组中的下标
if (prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key);
} else {
for (j = s2; j <= e2; j++) {
if (newIndexToOldIndexMap[j - s2] === 0 && isSameVNodeType(prevChild, c2[j])) {
newIndex = j;
break;
}
}
}
if (newIndex === void 0) {
// 如果该节点在新节点中不存在,直接卸载
unmount(prevChild, parentComponent, parentSuspense, true);
} else {
// 记录新节点对应旧节点数组中的位置
// 这里做i + 1操作,是因为,我们用0来表示旧节点数组中不存在该节点
newIndexToOldIndexMap[newIndex - s2] = i + 1;
if (newIndex >= maxNewIndexSoFar) {
// 更新处理过的节点中在新节点数组的最远坐标
maxNewIndexSoFar = newIndex;
} else {
// 一旦出现比newIndex < maxNewIndexSoFar的情况,说明有节点需要移动
moved = true;
}
patch(
prevChild,
c2[newIndex],
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
// 将已处理节点数+1
patched++;
}
}
// 获取最长递增子序列的
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--;
}
}
}
}
}
这一部分逻辑比较多,在文中已给详细注释,这里再来捋一下:
- 首先,定义了一个用于保存新节点下标 的容器keyToNewIndexMap ,它的形式是
key - index
。遍历还未处理的新节点,将它们的下标维护进容器keyToNewIndexMap中。 - 然后,定义了一个和未处理新节点个数同样大小的数组newIndexToOldIndexMap ,默认每一项均为0,用于后面记录对应节点在旧节点中出现的位置 (后续求最长递增序列 可以用到),如果节点在旧节点数组中不存在,则记录为0,之后再进行挂载操作。
- 之后,遍历旧节点数组中还未处理的节点 :
- 判断已处理节点数 是否达到了总共需要处理的节点数 ,如果已经达到,说面后续再遍历到的旧节点,都是不需要的,直接卸载即可。
- 结合我们第一步中维护好的存储节点下标的容器
newIndexToOldIndexMap
,通过key
去获取该节点对应新节点数组的下标位置newIndex
(如果比对的节点没有key属性,则通过前面提到的函数去判断新旧节点是否为同节点),如果该节点不存在 于新节点中,则直接将其卸载 ,否则将它在旧节点中的坐标+1 维护进newIndexToOldIndexMap
数组中,同时,判断是否需要发生节点的移动。
- 结合前面维护的数组
newIndexToOldIndexMap
,获取最长递增子序列的increasingNewIndexSequence
,遍历新节点数组中未处理的节点 :- 如果该节点对应
newIndexToOldIndexMap
数组中的值为0,说明该节点在旧节点中不存在 ,执行挂载操作;如果节点存在,并且根据前面判断,需要发生节点的移动操作 ,然后根据该节点是否是位于最长递增子序列中的节点来决定是否进行移动节点的操作,如果是,则移动节点,否则节点无须移动。
- 如果该节点对应
还是举个例子吧:
旧节点: ['a','b','c','d','e','f','g']
新节点: ['a','f','b','d','h','e','g']
初始新旧虚拟DOM节点数组:
经过了头头比对:
经过了尾尾比对:
此时,新旧节点的首尾指针都没有相遇,进入我们所说的复杂情况。接下来,我们就结合着源码来走后面的整个的流程:
遍历新节点数组中未处理的节点,维护key-index
容器,创建数组newIndexToOldIndexMap
:
遍历旧节点数组中未比对的节点:
经过上述处理后,我们得到最终的newIndexToOldIndexMap数组:
我们可以看到newIndexToOldIndexMap
数组是[6,2,4,0,5]
,其中0代表的是旧节点中不存在该节点,我们排除0不考虑,只看其他的节点,然后根据这些节点排出最长递增序列为[2, 4, 5]
,而这些节点对应在新节点数组中的下标为[1, 2, 4]
,我们前面在维护newIndexToOldIndexMap
数组时有做过下标+1 的操作,这样一个求最长递增序列下标 的操作刚好又把前面的 +1 操作还原回来了,这么一来也就得到了数组increasingNewIndexSequence
。
倒序遍历新节点数组中未比对的节点:
继续比对,因为h节点
在newIndexToOldIndexMap
对应的值为0,说明它不存在可复用的旧节点 ,直接将其卸载。
后续,遍历d,b节点 ,因为它们都存在于最长递增序列 中,所以直接执行j--
。
当遍历到f节点
时:
至此,我们就算完成了整个的diff过程。
对于最后这一部分的比对,Vue3采用的是求取最长递增子序列索引 的方式来决定哪些节点需要移动,哪些节点保持位置不变。当然,整体的宗旨就是希望能够开销最小,而采用这种最长递增序列的方式 ,是能够确保移动 比较少节点的。比如上面的例子,我们通过最长递增子序列的方式进行patch ,会发现最后只需要移动一个节点------ f节点
。
Vue3和Vue2的diff算法的不同
这里对于Vue2的算法思路总结就是:
- 头头比对
- 尾尾比对
- 旧头和新尾比对
- 旧尾和新头比对
- 都没有命中的对比
Vue3 的diff算法比对过程和Vue2 前两步是没有变化的,但Vue3 中diff算法区别于Vue2 的关键是后三步。在 Vue3 中,基于最长递增子序列进行新增,删除和移动的diff更新 ,已经涵盖了Vue2的后三步骤,而且性能是最优解,可以最大程度的减少DOM的移动。
最后
这一节,我们从源码层面对Vue3的diff算法 做了比较系统的认识,其中求取最长递增子序列的方式也正是该算法的亮点所在。感兴趣的朋友,也可以尝试结合文中代码注释自己去调试一下源码,相信一定收获颇丰~