上篇文章vue3组件渲染成DOM,分析了组件如何渲染成DOM,挂载到页面上,这篇文章讲一下,组件如何更新?
setupRenderEffect 执行render,收集副作用函数effect
还记得在组件初始挂载的时候,执行了mountComponent
:
js
const mountComponent = (initialVNode, container, anchor, parentComponent) => {
// 1. 先创建一个 component instance ,同时放一份在vnode的component属性上
const instance = (initialVNode.component = createComponentInstance(
initialVNode,
parentComponent,
parentSuspense
));
// 2. 设置组件实例
setupComponent(instance);
// 3. 设置并运行带副作用的渲染函数 组件的更新逻辑
setupRenderEffect(instance, initialVNode, container, anchor);
};
组件更新的秘密就在这个setupRenderEffect
函数中:
js
const setupRenderEffect = (instance, initialVNode, container, anchor) => {
const componentUpdateFn = () => {
if (!instance.isMounted) {
// 挂载
const subTree = (instance.subTree = renderComponentRoot(instance))
} else {
// 更新
}
};
// 创建响应式的副作用渲染函数
const effect = (instance.effect = new ReactiveEffect(
componentUpdateFn,
() => queueJob(update),
instance.scope
));
const update= (instance.update = () => effect.run());
update();
};
首先,调试一个组件挂载的例子看一下
js
// App.vue
<script setup>
import { ref } from '@vue/reactivity'
const msg = ref('vue2')
console.log('msg:', msg)
</script>
<template>
<div class="App">
<h1 class="App-text">App组件</h1>
<span>{{ msg }}</span>
</div>
</template>
在App组件中定义一个响应式数据,然后在模板中使用它
通过源码调试,我们看到,执行update()
,就会执行 effect.run()
,
js
export class ReactiveEffect {
run() {
try {
return this.fn();
} finally {
}
}
}
可以看到,执行run
,就是执行 ReactiveEffect类中传入的回调componentUpdateFn
,componentUpdateFn
在挂载的时候,不是执行了一句const subTree = (instance.subTree = renderComponentRoot(instance))
,这一行代码在上一篇文章中分析过,会执行组件实例 instance
的render
属性函数,render函数执行读取到里面的响应式数据msg,就会收集相应的副作用函数,其实收集的就是上面的effect
,ReactiveEffect的实例
等响应式数据更新,effect
被拿出来再次执行它的 run
方法,componentUpdateFn
这个函数也会执行,这个时候就会走componentUpdateFn
里面else更新逻辑了
组件更新逻辑
componentUpdateFn
js
const componentUpdateFn = () => {
if (!instance.isMounted) {
// 初始化组件
} else {
// 更新组件
let { next, vnode } = instance
// next 新的组件vnode
if (next) {
next.el = vnode.el
updateComponentPreRender(instance, next, optimized)
} else {
next = vnode
}
// 组件里面所有DOM节点新的vnode
const nextTree = renderComponentRoot(instance)
const prevTree = instance.subTree
instance.subTree = nextTree
patch(
prevTree,
nextTree,
// parent may have changed if it's in a teleport // 处理 teleport 相关
hostParentNode(prevTree.el!)!,
// anchor may have changed if it's in a fragment // 处理 fragment 相关
getNextHostNode(prevTree),
instance,
parentSuspense,
isSVG
)
next.el = nextTree.el // 缓存更新后的 DOM 节点
}
}
首先,next变量代表是否有新的组件vnode,有的话,通过updateComponentPreRender
,更新组件实例instance,组件vnode,slot,props等信息,为下一次组件更新提供最新的值;
然后通过renderComponentRoot
来获取组件里面DOM节点新的vnode:nextTree
,后面就叫组件的子树vnode,它和和组件vnode是不一样的;
最后把新旧组件的子树vnode进行patch
patch
js
const patch = (n1, n2, container, anchor = null, parentComponent = null) => {
const { type, ref, shapeFlag } = n2;
switch (type) {
// ...
default:
// 进行按位与运算
if (shapeFlag & ShapeFlags.ELEMENT) {
// 处理普通 DOM 元素
processElement(n1, n2, container, anchor, parentComponent);
} else if (shapeFlag & ShapeFlags.COMPONENT) {
// 处理 component
processComponent(n1, n2, container, anchor, parentComponent);
}
}
};
调试一个更新的例子:
js
// App.vue
<script setup>
import { ref } from '@vue/reactivity'
import Child from './Child.vue'
const msg = ref('vue2')
</script>
<template>
<div class="App">
<h1 class="App-text" @click="msg = 'vue3'">App组件</h1>
<span>{{ msg }}</span>
<p>--------------------------</p>
<Child :msg="msg" />
</div>
</template>
// Child.vue
<script setup>
import { ref } from '@vue/reactivity'
defineProps({ msg: String })
const count = ref(10)
</script>
<template>
<div class="child">
<h2>Child组件</h2>
<p>{{ msg }}</p>
<p>{{ count }}</p>
<button @click="count++">改变子组件数据</button>
</div>
</template>
点击更新App.vue中的msg,App组件会进行更新,进入componentUpdateFn
,
接下来进入对比App的新旧子树vnode patch(prevTree,nextTree,...)
processElement -> patchElement
js
const processElement = (n1, n2, container, anchor, parentComponent) => {
if (n1 == null) {
// 挂载
} else {
// 更新
patchElement(
n1,
n2,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
}
};
js
const patchElement = (n1, n2, parentComponent) => {
const el = (n2.el = n1.el!);
let { patchFlag, dynamicChildren, dirs } = n2;
const oldProps = n1.props;
const newProps = n2.props;
if (dynamicChildren) {
// 优化,动态节点different,后续再看
} else if (!optimized) {
// full diff 全量diff
patchChildren(
n1,
n2,
el,
null,
parentComponent,
parentSuspense,
areChildrenSVG,
slotScopeIds,
false
);
}
// patch props
patchProps(
el,
n2,
oldProps,
newProps,
parentComponent,
parentSuspense,
isSVG
);
};
patchChildren 全量diff
js
const patchChildren = (n1, n2, container, anchor, parentComponent) => {
const c1 = n1 && n1.children; // c1 代表旧节点的子节点元素
const prevShapeFlag = n1 ? n1.shapeFlag : 0; // 旧节点类型标识
const c2 = n2.children; // c2 代表新节点的子节点元素
const { patchFlag, shapeFlag } = n2;
// children has 3 possibilities: text, array or no children.
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// text children fast path
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) {
// prev children was 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 {
// no new children, just unmount old
// 新节点空 旧节点是数组
unmountChildren(c1 as VNode[], parentComponent, parentSuspense, true); // 卸载旧节点
}
} else {
// prev children was text OR null
// new children is array OR null
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
//新节点是数组或空 旧节点是文本
hostSetElementText(container, ""); // 清空旧节点
}
// mount new if array
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 新节点是数组 旧节点是空
mountChildren(
// 挂载新节点
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
}
}
}
};
回到上面的例子,由于App的新旧子树vnode:nextTree和prevTree里面的children都是数组,所以走到 patchKeyedChildren
diff,这里先知道,就是循环children继续调用patch对比, 当循环到children数组中最后一个vnode,就是Child组件vnode
,进入patch -> processComponent -> updateComponent
updateComponent 更新子组件
js
const updateComponent = (n1: VNode, n2: VNode, optimized: boolean) => {
const instance = (n2.component = n1.component)!;
// 根据新老节点判断是否需要更新子组件
if (shouldUpdateComponent(n1, n2, optimized)) {
// normal update
instance.next = n2; // 如果需要更新,则将新节点 vnode 赋值给 next
// in case the child component is also queued, remove it to avoid
// double updating the same child component in the same flush.
invalidateJob(instance.update);
// instance.update is the reactive effect.
// 执行前面定义在 instance 上的 update 函数。
instance.update();
} else {
// no update needed. just copy over properties
n2.el = n1.el;
instance.vnode = n2;
}
};
shouldUpdateComponent
函数的内部,主要是通过对比组件vnode中的dirs, props、chidren等属性,来决定子组件是否需要更新。
通过上面的调试,看以看到child组件vnode里面的props更新了,所以肯定是应该更新App的子组件Child,instance.next = n2
,Child组件实例intance的next属性赋值最新的组件vnode;
invalidateJob(instance.update)
防止重复更新,如果出现子组件的响应式数据变化,也会导致子组件更新,而这里父组件的状态数据变化也引起子组件更新,就重复更新了,这里就是如果更新队列中有了instance.update
就删除它;
instance.update()
就相当于Child组件主动更新自己,调用自己的副作用渲染函数,然后调用componentUpdateFn
,又会进入patch
。
可以看到,Child组件实例的next是有值的,就是Child组件vnode
那么如果是Child组件自己的响应式数据变化,会怎么样,点击Child组件的button:
next是null,因为此时App组件不会更新,也就不会走到判断子组件Child是否需要更新的逻辑里面,此时Child的更新跟前面App组件更新原理一样,因为模版里面使用了响应式数据,Child组件的副作用渲染函数被收集了
普通DOM的vnode -> 真实DOM
patch会被反复递归调用,但是不会无限递归,组件vnode是抽象的,最终还是要变成普通DOM元素的vnode,最后转换成真正的DOM。
比如App的子树vnode的children里前3个子元素的普通DOM元素vnode,最后还是会执行到processElement -> patchElement -> patchChildren,patchProps
,该更新属性更新属性,该更新文本更新文本;
最后一个是Child组件vnode,继续patch它,Child的子树vnode的children里面都是普通DOM元素vnode,没有组件vnode,把他们该更新的更新了,也就结束了