本文为原创文章,未获授权禁止转载,侵权必究!
本篇是 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(一)