前言
这是vue3系列源码的第十一章,使用的vue3版本是3.4.15
。
背景
这一节中,我们看一下vue3diff 的实现,以及key 对diff过程的影响
前置
js
// app.vue
<template>
<div>
<button @click="check">点击</button>
</div>
<div>
<textCom :data="item" v-for="(item, index) in aa"/>
</div>
</template>
<script setup>
import { ref} from 'vue'
import textCom from './text.vue'
const aa = ref([1,2,3])
const check = () => {
aa.value = [2,1,3]
}
</script>
app组件中对一个数组进行循环,渲染子组件。
这里的text 组件只需要接收一个传进来的props,并渲染一下
js
// text.vue
<template>
<div>{{ data }}</div>
</template>
<script setup>
defineProps({
data: Number
})
</script>
没有key
首先我们按照上面的写法,就是循环的时候没有设置key, 看一下,这个时候的更新过程。
patchChildren
js
const patchChildren: PatchChildrenFn = (
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
namespace: ElementNamespace,
slotScopeIds,
optimized = false,
) => {
const c1 = n1 && n1.children
const prevShapeFlag = n1 ? n1.shapeFlag : 0
const c2 = n2.children
const { patchFlag, shapeFlag } = n2
// fast path
if (patchFlag > 0) {
if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
// this could be either fully-keyed or mixed (some keyed some not)
// presence of patchFlag means children are guaranteed to be arrays
patchKeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
namespace,
slotScopeIds,
optimized,
)
return
} else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
// unkeyed
patchUnkeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
namespace,
slotScopeIds,
optimized,
)
return
}
}
...
}
在patchChildren 函数中,主要是针对了不同的情况,进行了不同的patch
处理。
在我们的这个例子中,它会先根据有没有绑定key 进行判断,我们这里没有绑定key ,进入patchUnkeyedChildren函数。
patchUnkeyedChildren
js
const patchUnkeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
namespace: ElementNamespace,
slotScopeIds: string[] | null,
optimized: boolean,
) => {
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(
c1[i],
nextChild,
container,
null,
parentComponent,
parentSuspense,
namespace,
slotScopeIds,
optimized,
)
}
if (oldLength > newLength) {
// remove old
unmountChildren(
c1,
parentComponent,
parentSuspense,
true,
false,
commonLength,
)
} else {
// mount new
mountChildren(
c2,
container,
anchor,
parentComponent,
parentSuspense,
namespace,
slotScopeIds,
optimized,
commonLength,
)
}
}
这个函数其实就干了两件事情:
- 依次按顺序遍历新老节点,进行patch
- 对多出来的老节点进行unmount ,对多出来的新节点进行mount
所以我们可以看见,当我们不绑定key 的时候,其实更新过程没有涉及到diff算法,就是一种暴力的更新过程。
那么下面,我们看看绑定了key的过程。
key 为 index
我们首先看一下我们在开发中可能的一种写法,就是把index 绑定为key
我们先改造一下app.vue
。
js
<textCom :data="item" v-for="(item, index) in aa" :key="index"/>
这次,我们绑定了key ,在patchChildren 函数中,走到了patchKeyedChildren函数中。
patchKeyedChildren
这个函数就是diff 过程的核心函数,vue3的diff
算法就是这个函数。
js
// can be all-keyed or mixed
const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
parentAnchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
namespace: ElementNamespace,
slotScopeIds: string[] | null,
optimized: boolean,
) => {
let i = 0
const l2 = c2.length
let e1 = c1.length - 1 // prev ending index
let e2 = l2 - 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] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
namespace,
slotScopeIds,
optimized,
)
} else {
break
}
i++
}
...
}
diff过程针对不同的情况,做了不同的处理,我们一点一点看。
isSameVNodeType
这里我们要先看一下工具函数,判断新旧节点是否相同类型。
js
function isSameVNodeType(n1: VNode, n2: VNode): boolean {
if (
__DEV__ &&
n2.shapeFlag & ShapeFlags.COMPONENT &&
hmrDirtyComponents.has(n2.type as ConcreteComponent)
) {
// #7042, ensure the vnode being unmounted during HMR
// bitwise operations to remove keep alive flags
n1.shapeFlag &= ~ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
n2.shapeFlag &= ~ShapeFlags.COMPONENT_KEPT_ALIVE
// HMR only: if the component has been hot-updated, force a reload.
return false
}
return n1.type === n2.type && n1.key === n2.key
}
这里主要的判断依据就是type 和key,
那么type是什么:

type其实是一个包含了几个属性的对象,基本上描述了一个组件的全部内容。
那么key 就是我们绑定的key.
回到我们这个例子中,当我们用index 来绑定key 的时候,我们会发现,虽然数组变了,但是新旧节点的key是没有变化的。
所以,在isSameVNodeType 函数的判断中,新旧节点是符合条件的,那么就会直接进入patch。
一直到遍历完新旧节点数量少的那个为止。
js
// 3. common sequence + mount
// (a b)
// (a b) c
// i = 2, e1 = 1, e2 = 2
// (a b)
// c (a b)
// i = 0, e1 = -1, e2 = 0
if (i > e1) {
if (i <= e2) {
const nextPos = e2 + 1
const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
while (i <= e2) {
patch(
null,
(c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i])),
container,
anchor,
parentComponent,
parentSuspense,
namespace,
slotScopeIds,
optimized,
)
i++
}
}
}
// 4. common sequence + unmount
// (a b) c
// (a b)
// i = 2, e1 = 2, e2 = 1
// a (b c)
// (b c)
// i = 0, e1 = 0, e2 = -1
else if (i > e2) {
while (i <= e1) {
unmount(c1[i], parentComponent, parentSuspense, true)
i++
}
}
最后,就和没有绑定key的过程一样,新节点少的,进行unmount ,新节点多出来的,进行patch。
那么,我们可以得出一个结论:
当我们用数组的index 作为key 的时候,在遇到像排序这种变化的时候,和没有绑定key是一样的效果。
id 为 key
下面我们再看看,我们不用index 为key ,用唯一标志符作为key ,比如id ,diff过程是什么样的。
这里,我们再次改造一下app.vue
文件
js
<textCom :data="item" v-for="(item, index) in aa" :key="item"/>
...
const check = () => {
aa.value = [3,1,2,4]
}
这里,我们用item 本身代替id。
此时,由于第一项的key 不一样,不满足isSameVNodeType 的条件,所以直接break出来,进入下一个判断。
js
// 2. sync from end
// a (b c)
// d e (b c)
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)) {
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
namespace,
slotScopeIds,
optimized,
)
} else {
break
}
e1--
e2--
}
上面是第二个判断,从两边的尾部开始对比,如果是是sameVnode ,进行patch操作, 这里仍然不符合
js
// 3. common sequence + mount
// (a b)
// (a b) c
// i = 2, e1 = 1, e2 = 2
// (a b)
// c (a b)
// i = 0, e1 = -1, e2 = 0
if (i > e1) {
if (i <= e2) {
const nextPos = e2 + 1
const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
while (i <= e2) {
patch(
null,
(c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i])),
container,
anchor,
parentComponent,
parentSuspense,
namespace,
slotScopeIds,
optimized,
)
i++
}
}
}
第三个和第四个判断,就是上面key 为index的时候,提到的,当新旧节点数量不相同的时候,就会进行挂载或者卸载操作
如果上面的四个判断都经过了,还是有没有处理掉的节点,那么此时就到了diff算法的核心部分,对更加一般情况的处理。
- 将存在于旧节点,但是不存在于新节点的节点卸载,新旧都有的节点进行更新
- 得到一个数组,数组是按照新节点的顺序,保存了老节点的索引+1
- 找到最长公共子序列
- 从剩余节点的最后一个往最前一个遍历,遇到新的节点就进行增加
- 以最长子序列的位置为基准,移动其他节点,做到最大程度的复用
最长公共子序列
这里最难的部分是最长公共子序列
的寻找。
这里会首先得到一个数组。
newIndexToOldIndexMap:
newIndexToOldIndexMap[newIndex - s2] = i + 1
其中i 代表老节点中的索引。也就是说,这个数组存下了老节点
在新节点
中的顺序。
那么要找到最小的变化,尽可能多的复用节点,就是要找到新老节点之间的最多的连续节点,节点的连续反映到索引上就是索引的递增,所以其实就是在寻找这个数组的最长递增子序列。
这里的核心算法采用了动态规划 + 二分法
可以参考300.最长递增子序列,但是这里得到的数组只有长度是准确的,实际的元素并不准确
所以源码做了一些修改,保存了真实的元素。
总结
以上就是diff 过程的全部内容。我们看了一下diff的各个过程
回到标题,key 对diff过程的影响是很大的。
我们讨论了在例子情况下,用index 当做key 的弊端,有时候和没有加key 的效果是一样,这样会加大diff 过程的开销,不符合复用原则,而已在有的情况下还会发生错误。
所以一个唯一且固定的key对于vue3来说是很重要的,能很大提升源码运行的效率。