本文为原创文章,未获授权禁止转载,侵权必究!
本篇是 Vue3 源码解析系列第 17 篇,关注专栏
前言
前面文章中我们都是讨论了单个子节点的更新,如果新旧子节点为多个,那么它们更新时就存在节点位置的交换、新增、删除、插入等场景。Vue 中主要通过 patchKeyedChildren 方法来实现,也就是我们所说的 diff 算法。
它分为五个步骤,分别是:自前向后、自后向前、新节点多于旧节点、旧节点多于新节点、乱序比对(核心)。针对这五个场景分别做了不同的逻辑处理,其中 乱序比对 是 diff 算法的核心,这块逻辑我们放到下一篇来着重讲解。
下面我们先来讲解第一个场景 自前向后,依旧通过案例的形式来逐一分析。
案例一
首先引入 h 、 render函数,先渲染 vnode1 对象,它包含三个子节点 a b c,key 为 1 2 3,两秒后更新渲染 vnode2 对象,我们将第三个子节点内容修改为 d,key 保持不变。
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="../../../dist/vue.global.js"></script>
</head>
<body>
<div id="app"></div>
<script>
const { h, render } = Vue
const vnode1 = h('ul', [
h('li', { key: 1 }, 'a'),
h('li', { key: 2 }, 'b'),
h('li', { key: 3 }, 'c')
])
render(vnode1, document.querySelector('#app'))
setTimeout(() => {
const vnode2 = h('ul', [
h('li', { key: 1 }, 'a'),
h('li', { key: 2 }, 'b'),
h('li', { key: 3 }, 'd')
])
render(vnode2, document.querySelector('#app'))
}, 2000)
</script>
</body>
</html>
自前向后
diff 算法的触发依旧通过 render 函数的 patchChildren 方法:
ts
const patchChildren: PatchChildrenFn = (
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
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) {
// 省略
}
// children has 3 possibilities: text, array or no children.
// 新节点为 text 节点
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 省略
} else {
// 旧节点为 array 节点
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// prev children was array
// 新节点为 array 节点
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// two arrays, cannot assume anything, do full diff
patchKeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
// 省略
}
} else {
// 省略
}
}
}
当前新旧子节点都为数组,执行 patchKeyedChildren 方法:
ts
const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
parentAnchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
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,
isSVG,
slotScopeIds,
optimized
)
} else {
break
}
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] = optimized
? cloneIfMounted(c2[e2] as VNode)
: normalizeVNode(c2[e2]))
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
break
}
e1--
e2--
}
// 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,
isSVG,
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++
}
}
// 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
else {
const s1 = i // prev starting index
const s2 = i // next starting index
// 5.1 build key:index map for newChildren
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) {
if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {
warn(
`Duplicate keys found during update:`,
JSON.stringify(nextChild.key),
`Make sure keys are unique.`
)
}
keyToNewIndexMap.set(nextChild.key, i)
}
}
// 5.2 loop through old children left to be patched and try to patch
// matching nodes & remove nodes that are no longer present
let j
let patched = 0
const toBePatched = e2 - s2 + 1
let moved = false
// used to track whether any node has moved
let maxNewIndexSoFar = 0
// works as Map<newIndex, oldIndex>
// Note that oldIndex is offset by +1
// and oldIndex = 0 is a special value indicating the new node has
// no corresponding old node.
// used for determining longest stable subsequence
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) {
// all new children have been patched so this can only be a removal
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] as VNode)
) {
newIndex = j
break
}
}
}
if (newIndex === undefined) {
unmount(prevChild, parentComponent, parentSuspense, true)
} else {
newIndexToOldIndexMap[newIndex - s2] = i + 1
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
moved = true
}
patch(
prevChild,
c2[newIndex] as VNode,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
patched++
}
}
// 5.3 move and mount
// generate longest stable subsequence only when nodes have moved
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR
j = increasingNewIndexSequence.length - 1
// looping backwards so that we can use last patched node as anchor
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i
const nextChild = c2[nextIndex] as VNode
const anchor =
nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
if (newIndexToOldIndexMap[i] === 0) {
// mount new
patch(
null,
nextChild,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else if (moved) {
// move if:
// There is no stable subsequence (e.g. a reverse)
// OR current node is not among the stable sequence
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor, MoveType.REORDER)
} else {
j--
}
}
}
}
}
可以看出 Vue 中已经分别做了注释说明,我们先来看第一段逻辑,也就是 自前向后:
ts
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,
isSVG,
slotScopeIds,
optimized
)
} else {
break
}
i++
}
l2 为新子节点的长度,当前为 3;e1、e2 分别为新旧子节点最后一个元素的下标,当前为 2。之后从前向后扫描,执行第一遍历。当前 n1、n2 节点都为 a 且 key 都为 1,根据判断 if (isSameVNodeType(n1, n2)),我们再看下 isSameVNodeType 方法:
ts
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
if (
__DEV__ &&
n2.shapeFlag & ShapeFlags.COMPONENT &&
hmrDirtyComponents.has(n2.type as ConcreteComponent)
) {
// 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 也必须相同才能表示两个节点是相同的,所以这就是为什么 v-for 需要设置 key 的原因。
由于 n1、n2 相同,执行 patch 方法第一次挂载更新完成。之后第二次遍历,当前新旧节点都为 b 且 key 都为 2,再次执行 patch 方法挂载更新。第三次遍历,n1 旧子节点内容为 c,n2 新子节点内容为 d,由于新旧子节点的 type、key 均相同,执行 patch 更新,先看下之前页面呈现:

执行后,页面呈现:

至此,自前向后 逻辑执行完毕,我们大致可以总结为:
- 它会根据
i作为下标获取到新旧节点的元素。 - 如果新旧节点的
type类型和key相同,则会执行patch方法进行挂载更新。 - 如果不相同则会
break跳出循环,结束该逻辑。
我们再来看下第二段逻辑 自后向前。
案例二
首先引入 h 、 render函数,先渲染 vnode1 对象,它包含三个子节点 a b c,key 为 1 2 3,两秒后更新渲染 vnode2 对象,我们将第一个子节点的 key 修改为 4,第三个子节点的内容修改为 d。
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="../../../dist/vue.global.js"></script>
</head>
<body>
<div id="app"></div>
<script>
const { h, render } = Vue
const vnode1 = h('ul', [
h('li', { key: 1 }, 'a'),
h('li', { key: 2 }, 'b'),
h('li', { key: 3 }, 'c')
])
render(vnode1, document.querySelector('#app'))
setTimeout(() => {
const vnode2 = h('ul', [
h('li', { key: 4 }, 'a'),
h('li', { key: 2 }, 'b'),
h('li', { key: 3 }, 'd')
])
render(vnode2, document.querySelector('#app'))
}, 2000)
</script>
</body>
</html>
自后向前
根据案例二,先执行 自前向后 的逻辑,第一遍历,由于 n1、n2 新节点的 key 不同直接 break 跳出循环,所以此时 i、e1、e2 均未变化:

接着执行第二段逻辑即 自后向前 :
ts
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,
isSVG,
slotScopeIds,
optimized
)
} else {
break
}
e1--
e2--
}
可以看出该逻辑是从后向前遍历,执行第一次遍历,此时 n1 为 c,n2 为 d,新旧节点的 type 和 key 相同执行 patch 方法挂载更新,此时页面呈现:

执行第二次遍历,新旧节点 type 和 key 相同,内容都为 b,执行 patch 方法挂载更新,之后执行第三次遍历,新旧节点 key 不同,执行 break 跳出循环,当前 i、e1、e2 均为 0,最终页面呈现:

接下来我们再看下 新节点多于旧节点 逻辑。
案例三
首先引入 h 、 render函数,先渲染 vnode1 对象,它包含两个子节点 a b,key 为 1 2,两秒后更新渲染 vnode2 对象,相比 vnode1 对象多了个子节点 c。
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="../../../dist/vue.global.js"></script>
</head>
<body>
<div id="app"></div>
<script>
const { h, render } = Vue
const vnode1 = h('ul', [
h('li', { key: 1 }, 'a'),
h('li', { key: 2 }, 'b')
])
render(vnode1, document.querySelector('#app'))
setTimeout(() => {
// 在后面新增
const vnode2 = h('ul', [
h('li', { key: 1 }, 'a'),
h('li', { key: 2 }, 'b'),
h('li', { key: 3 }, 'c')
])
// 在前面新增
// const vnode2 = h('ul', [
// h('li', { key: 3 }, 'c'),
// h('li', { key: 1 }, 'a'),
// h('li', { key: 2 }, 'b')
// ])
render(vnode2, document.querySelector('#app'))
}, 2000)
</script>
</body>
</html>
新节点多于旧节点
新节点多于旧节点情况分为两种,一种是在后面增加,一种是在前面增加。我们先看下在后面增加情况,执行第三段逻辑即 新节点多于旧节点:
ts
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,
isSVG,
slotScopeIds,
optimized
)
i++
}
}
}
根据案例三,先执行 自前向后、自后向前 两块逻辑,a、b 节点挂载更新完毕,当前 i、e1、e2、l2 值为:

根据判断 i > e1 且 i <= e2 表示旧节点小于新节点,通过 e2 + 1 获取新节点下一个节点的下标位置。再根据判断获取到锚点 anchor,该锚点也就代表新节点要被插入到哪一个位置上去,当前 parentAnchor 为 null。之后执行 patch 方法挂载更新新节点 c,页面呈现:

我们再看下在前面增加新节点的情况,修改案例三,将 vnode2 设置为:
ts
const vnode2 = h('ul', [
h('li', { key: 3 }, 'c'),
h('li', { key: 1 }, 'a'),
h('li', { key: 2 }, 'b')
])
先执行 自前向后 逻辑,由于第一个节点比较, key 不同直接 break 跳出循环。接着执行 自后向前 逻辑,a、b 节点相同,patch 挂载更新完后,当前 i、e1、e2、l2 值为:

之后同之前逻辑获取锚点 anchor,当前 nextPos < l2,获取下一个节点元素的 el,即 c2[1].el,也就是节点为 a 的 el 元素,最后通过 patch 方法挂载更新 c2[i] 即 c2[0],也就是节点为 c 的元素插入到节点为 a 的元素前,页面呈现:

我们再看下 旧节点多于新节点 的逻辑。
案例四
首先引入 h 、 render函数,先渲染 vnode1 对象,它包含三个子节点 a b c,key 为 1 2 3,两秒后更新渲染 vnode2 对象,相比 vnode1 对象少了个子节点 c。
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="../../../dist/vue.global.js"></script>
</head>
<body>
<div id="app"></div>
<script>
const { h, render } = Vue
const vnode1 = h('ul', [
h('li', { key: 1 }, 'a'),
h('li', { key: 2 }, 'b'),
h('li', { key: 3 }, 'c')
])
// const vnode1 = h('ul', [
// h('li', { key: 3 }, 'c'),
// h('li', { key: 1 }, 'a'),
// h('li', { key: 2 }, 'b')
// ])
render(vnode1, document.querySelector('#app'))
setTimeout(() => {
const vnode2 = h('ul', [
h('li', { key: 1 }, 'a'),
h('li', { key: 2 }, 'b')
])
render(vnode2, document.querySelector('#app'))
}, 2000)
</script>
</body>
</html>
旧节点多于新节点
根据案例四,先执行 自前向后 逻辑,a、b 节点相同,patch 挂载更新,之后执行 自后向前、新节点多于旧节点 逻辑,当前 i、e1、e2、l2 值为:

接着执行第四段逻辑即 旧节点多于新节点:
ts
else if (i > e2) {
while (i <= e1) {
unmount(c1[i], parentComponent, parentSuspense, true)
i++
}
}
根据判断 i > e2 表示 旧节点多于新节点,执行 unmount 方法 卸载,页面呈现:

该场景同样也分为向前向后两种情况,大家不妨可以修改下案例四,重新调试执行,再看下结果是否一致。
下一篇我们来着重分析第五段逻辑,即 乱序比对(核心)。
总结
diff算法主要通过Vue中的patchKeyedChildren方法来实现。patchKeyedChildren方法主要分为五个步骤来处理各场景逻辑,分别是:自前向后、自后向前、新节点多于旧节点、旧节点多于新节点、乱序比对(核心)。自前向后逻辑主要通过i作为下标获取新旧节点元素,再判断新旧节点的type和key是否相同,执行patch方法进行挂载更新,还是break跳出该逻辑。自前向后和自后向前逻辑主要区别在于一个从前向后遍历,一个从后向前遍历。新节点多于旧节点分为向前向后新增两种情况,主要通过判断获取anchor锚点值来决定多余的新节点插入位置。旧节点多于新节点同样也分向前向后删除两种情况,主要通过unmount方法进行多余节点的卸载。
Vue3 源码实现
Vue3 源码解析系列
- Vue3源码解析之 源码调试
- Vue3源码解析之 reactive
- Vue3源码解析之 ref
- Vue3源码解析之 computed
- Vue3源码解析之 watch
- Vue3源码解析之 runtime
- Vue3源码解析之 h
- Vue3源码解析之 render(一)
- Vue3源码解析之 render(二)
- Vue3源码解析之 render(三)
- Vue3源码解析之 render(四)
- Vue3源码解析之 render component(一)
- Vue3源码解析之 render component(二)
- Vue3源码解析之 render component(三)
- Vue3源码解析之 render component(四)
- Vue3源码解析之 render component(五)
- Vue3源码解析之 diff(一)