上篇文章我们介绍了组件的渲染流程,本篇文章我们来介绍响应式数据变化后组件的更新渲染流程。最后有不看文章的分析总结图。
案例
为了方便介绍流程,我们这里举一个例子:
App
组件中有一个Hello
组件,并且赋值msg
这个prop值给Hello
组件;- 当
msg
为Vue 3时,App
组件中有li
标签数组显示vue3.feature
,即显示Vue 3
的新特性,当msg
为Vue 2时则不显示; App
组件中有一个按钮切换msg
的值。
App.vue
<template>
<HelloWorld :msg="msg" />
<h1>App 组件显示:</h1>
<ul>
<li v-for="item in vue3.feature" v-bind:key="item">{{ item }}</li>
</ul>
<button @click="changeMsg">切换</button>
</template>
<script lang="ts">
import { defineComponent, reactive, ref } from "vue";
import HelloWorld from "./components/HelloWorld.vue";
export default defineComponent({
name: "App",
components: {
HelloWorld,
},
setup() {
const msg = ref("Vue 2");
const feature3: string[] = ["reactive", "composition api", "setup", "toRef", "Teleport"];
const feature2: string[] = ["reactive", "option api"];
const vue3 = reactive({ feature: feature2});
let current = 0;
const changeMsg = () => {
if (current == 0) {
msg.value = "Vue 3";
vue3.feature = feature3;
current = 1;
} else {
msg.value = "Vue 2";
vue3.feature = feature2;
current = 0;
}
};
return {
msg,
vue3,
changeMsg,
};
},
});
</script>
Hello.vue
<template>
<h1>Hello 组件显示:{{ msg }}</h1>
</template>
<script lang="ts">
import { ref, defineComponent } from "vue";
export default defineComponent({
name: "HelloWorld",
props: {
msg: {
type: String,
required: true,
},
},
setup: () => {
},
});
</script>
效果图如下
副作用渲染函数componentUpdateFn
开启组件重新渲染
我们上篇文章提到过组件挂载的时候会创建一个副作用渲染函数componentUpdateFn
,这个函数在响应式数据变化后则会被调用。
数据变化后为什么就会引发副作用渲染函数的调用?这是
Vue 3.0
响应式系统的相关内容,后续介绍。目前知道是这个逻辑就行。
const componentUpdateFn = () => {
// 1.
if (!instance.isMounted) {
instance.isMounted = true
} else {
let { next, bu, u, parent, vnode } = instance
let originNext = next
let vnodeHook: VNodeHook | null | undefined
// 2.
if (next) {
next.el = vnode.el
updateComponentPreRender(instance, next, optimized)
} else {
next = vnode
}
// 3
const nextTree = renderComponentRoot(instance)
const prevTree = instance.subTree
instance.subTree = nextTree
// 4
patch(
prevTree,
nextTree,
// parent may have changed if it's in a teleport
hostParentNode(prevTree.el!)!,
// anchor may have changed if it's in a fragment
getNextHostNode(prevTree),
instance,
parentSuspense,
isSVG
)
}
}
componentUpdateFn
只有第一次执行的时候执行挂载逻辑,第一次执行后isMounted
被置为true
,后面都是执行更新的逻辑;- 组件自己更新的场景下,
next
为空,将next
指向组件对象自己的vnode
;renderComponentRoot
更新子树VNode
,本例子中主要是将子树VNode
的第一个和第三个子VNode
的数据进行更新;
patch
用来对比新旧子树VNode
,找到合适的方式更新DOM。
patch
更新组件的逻辑
const patch: PatchFn = (
n1,
n2,
container,
anchor = null,
parentComponent = null,
parentSuspense = null,
isSVG = false,
slotScopeIds = null,
optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
) => {
// 1.
if (n1 === n2) {
return
}
// 2.
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1)
unmount(n1, parentComponent, parentSuspense, true)
n1 = null
}
// 3.
const { type, ref, shapeFlag } = n2
switch (type) {
// 省略 ...
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
processComponent(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
}
// 省略 ...
}
}
- 如果新旧
VNode
节点是同一个,则直接返回不做处理;- 如果新旧
VNode
节点的类型不同,那就将旧的VNode
节点卸载,然后将旧的VNode
节点置空,最后走挂载逻辑;- 如果新旧
VNode
节点的类型相同,会根据不同的VNode
类型走不同更新逻辑,譬如组件走processComponent
流程, 普通DOM元素节点走processElement
流程。
本例中第一个子节点是组件VNode
节点走processComponent
,其他几个VNode
节点走processElement
流程。
子组件更新流程updateComponent
App组件对象的子树VNode
的第一个子节点VNode
是Hello组件对象的VNode
,其prop值变化了,所以Hello组件对象需要更新渲染,接下来我们就来看看Hello子组件的更新逻辑processComponent
。
const updateComponent = (n1: VNode, n2: VNode, optimized: boolean) => {
const instance = (n2.component = n1.component)!
// 1.
if (shouldUpdateComponent(n1, n2, optimized)) {
// 2.
instance.next = n2
// 3.
invalidateJob(instance.update)
// 4.
instance.update()
} else {
// 2.
n2.component = n1.component
n2.el = n1.el
instance.vnode = n2
}
}
- 首先使用
shouldUpdateComponent
判断组件是否需要重新渲染,因为有些VNode
值的变化并不需要立即显示更新。更新的条件包括prop
和children
的变化等;- 给组件对象设置了
next
值,也就是说如果是组件自己更新是没有设置next
,如果是父组件触发更新,则子组件对象有设置这个next
值;
- 更新队列中取消子组件对象的更新,避免重复更新;
- 子组件的副作用渲染函数
componentUpdateFn
被调用,进入了又一轮的递归调用;
- 问题:为什么子组件对象重新渲染需要设置
next
值? - 答案:此时子组件对象不知道需要更新到的
VNode
, 所有需要赋值给子组件对象让其知道如何更新渲染。
父组件触发的子组件的副作用渲染函数componentUpdateFn
的和组件自身触发的区别
let { next, bu, u, parent, vnode } = instance
let originNext = next
if (next) {
next.el = vnode.el
updateComponentPreRender(instance, next, optimized)
} else {
next = vnode
}
区别就在于父组件对象触发的子组件的
VNode
有next
值,此时需要执行updateComponentPreRender
,从而在渲染前完成props
,slot
等属性的赋值;
- 问题:组件对象自身触发的渲染为什么不需要执行
updateComponentPreRender
方法? - 答案:组件对象在挂载的时候已经执行过了
updateComponentPreRender
方法,所以自身触发的情景下只需要更新一些属性值就行,要么通过updateComponentPreRender
,要么直接给设置vnode
属性值。
普通元素节点更新入口patchElement
const patchElement = (
n1: VNode,
n2: VNode,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
// 1.
patchProps(el, n2, oldProps, newProps, parentComponent, parentSuspense, isSVG)
// 2.
patchChildren(
n1,
n2,
el,
null,
parentComponent,
parentSuspense,
areChildrenSVG,
slotScopeIds,
false
)
}
这个方法特别的长,功能是通过
patchProps
更新props
,style
,class
,event
等;通过patchChildren
更新子节点。
接下来我们就来重点介绍下子节点的更新逻辑。
普通元素节点的子节点更新patchChildren
const patchChildren: PatchChildrenFn = (
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized = false
) => {
// 1.
const c1 = n1 && n1.children
const prevShapeFlag = n1 ? n1.shapeFlag : 0
const c2 = n2.children
const { patchFlag, shapeFlag } = n2
if (patchFlag > 0) {
// 2.
if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
patchKeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
return
} else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
// unkeyed
patchUnkeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
return
}
}
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 3.
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) {
// 4.
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
patchKeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
unmountChildren(c1 as VNode[], parentComponent, parentSuspense, true)
}
} else {
// 5.
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
hostSetElementText(container, '')
}
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
}
}
}
}
普通元素节点的子节点有三种情况:
子节点类型 例子 数组子节点 <ul><li>1</li><li>1</li><li>1</li></ul>
文本子节点 <div>文本</div>
空子节点 <img />
patchChildren
针对这三种情况进行分别处理, 9种情况:
行-旧节点,列-新节点 数组子节点 文本子节点 空子节点 数组子节点 diff比对 卸载数组节点,设置文本 卸载数组节点 文本子节点 将文本节点替换为数组节点 文本替换 去掉文本节点 空子节点 挂载数组子节点 设置文本
没有v-key数组子节点的比对patchUnkeyedChildren
const patchUnkeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
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,
isSVG,
slotScopeIds,
optimized
)
}
if (oldLength > newLength) {
// remove old
unmountChildren(
c1,
parentComponent,
parentSuspense,
true,
false,
commonLength
)
} else {
// mount new
mountChildren(
c2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized,
commonLength
)
}
}
这个方法逻辑简单:先将两个数组从前往后逐个
patch
,当某个数组对比完成后,如果新的子节点数组还有元素就将剩下的节点进行mountChildren
挂载,如果是旧节点有剩余的则unmountChildren
卸载。
这个方法简单,但是效率比较低,。我们接下来分析高效的比对方法。
有v-key数组子节点的高效比对patchKeyedChildren
这个逻辑很长,我们分拆来分析:
1. 同步头部节点
旧节点 (a b) c
新节点 (a b) d e
先从两个数组的头部开始比对,如果节点是相同的VNode
类型,执行patch
更新节点,否则同步结束。
上面例子中第三个节点的时 同步头部节点这一逻辑结束。
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. 同步尾部节点
// a (b c)
// d e (b c)
先从两个数组的尾部开始比对,如果节点是相同的VNode
类型,执行patch
更新节点,否则同步尾部结束。
上面例子中倒数第三个节点的时 同步尾部部节点这一逻辑结束。
// 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. 新子节点数组有需要添加的新子节点
(a b)
(a b) c
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. 旧子节点数组有需要卸载子节点
(a b) c (d e)
(a b) (d e)
else if (i > e2) {
while (i <= e1) {
unmount(c1[i], parentComponent, parentSuspense, true)
i++
}
}
5. 处理未知子序列
// [i … e1 + 1]: a b [c d j] f g
// [i … e2 + 1]: a b [e d c h] f g
// i = 2, e1 = 4, e2 = 5
- 1.建立新子序列的索引图—未知新子序列的每个节点在新子序列中对应的索引值
// 5.1 build key:index map for newChildren
const keyToNewIndexMap: Map<string | number, 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)
}
}
结果:
{
{"e" => 2},
{"d" => 3},
{"c" => 4},
{"h" => 5}
}
- 2.遍历旧子序列,有相同的key就执行
patch
更新,并且移除不在新子序列中的节点,并且确定序列是否有排列顺序的变化。
- 建一个
newIndexToOldIndexMap
数组,数组长度是未知新子序列的长度,每个元素的初始值为0,当最后处理完还是0,那说明这个节点是新添加的节点;- 正序遍历旧子序列查找旧子序列节点在新子序列中的索引,如果找不到说明新子序列中没有该节点,这个节点需要卸载;如果找到了,就将其在旧子序列中的索引更新到newIndexToOldIndexMap`中,索引加了1;
- 利用
maxNewIndexSoFar
来计算新子节点的顺序是否有更换,如果有更换将moved
设置为true;- 如果新子节点序列已经遍历完成,旧子节点还有元素,直接卸载节点即可。
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++
}
}
结果:
newIndexToOldIndexMap: [0, 4, 3, 0] // d在旧子节点的索引是3,c在旧子节点的所以为2,e,h都是新增的节点
moved: true
- 3.移动和挂载子节点
- 如果
moved
为true, 则求解最大递增子序列increasingNewIndexSequence
,最大递增子序列能够让移动的次数最小化;
本例子中得到的的值为[0, 2]
,表示newIndexToOldIndexMap
对应的0, 3
。- 倒序遍历新子节点,如果newIndexToOldIndexMap对应的索引的值为0,说明新增的节点,进行挂载;
- 倒序遍历新子节点,如果碰到了不是
increasingNewIndexSequence
中的对应索引下元素的值值则需要移动,否则不进行操作;
我们用上面的例子解释下:
循环次数 | 新子节点索引 | 新子节点 | increasingNewIndexSequence的索引 | increasingNewIndexSequence[索引] | newIndexToOldIndexMap[循环次数] | 进行的操作 |
---|---|---|---|---|---|---|
1 | 5 | h | 1 | 3 | 0 | 直接挂载h |
2 | 4 | c | 1 | 3 | 3 | c不进行操作,将increasingNewIndexSequence的索引-1,变为0 |
3 | 3 | d | 0 | 0 | 4 | 取到元素d,移动到c前面 |
4 | 2 | e | 0 | 0 | 0 | 直接挂载e |