复用组件实例并完成 props 和 attrs 的更新
上一节实现了组件的异步更新。
这是一个父组件向子组件传递 props 的例子:
import { createApp, h, ref } from '../dist/vue.esm.js'
const Child = {
props: ['age'],
setup(props) {
return () => {
console.count('child render')
return h('div', `child: ${props.age}`)
}
}
}
const Com = {
setup() {
const count = ref(0)
btn.onclick = () => {
count.value++
count.value++
count.value++
}
return () => {
console.count('parent render')
return h(Child, { age: count.value })
}
}
}
createApp(Com, { msg: '消息' }).mount('#app')
点击按钮后,父组件中的 count 发生变化,父组件会重新执行 render,并得到一个新的子组件 vnode:
h(Child, { age: 3 })
组件更新的流程可以概括为:
父组件更新
-> 得到新的 Child vnode
-> 复用旧的组件实例
-> 判断 props 是否发生变化
-> 更新子组件 props
-> 重新执行子组件 render
处理组件更新
组件和普通元素一样,也需要在 patch 时区分首次挂载和后续更新。
function processComponent(n1, n2, container, anchor) {
if (n1 == null) {
mountComponent(n2, container, anchor)
}
else {
updateComponent(n1, n2)
}
}
首次挂载时调用 mountComponent,后续更新时调用 updateComponent。
保存组件实例
组件首次挂载时,将创建出来的实例保存到组件 vnode 上:
function mountComponent(vnode, container, anchor) {
const instance = createComponentInstance(vnode)
vnode.component = instance
setupComponent(instance)
// ...
}
这样,下一次更新时就可以从旧的 vnode 上取出之前的实例:
function updateComponent(n1, n2) {
const instance = n2.component = n1.component
// ...
}
这里的 n2.component = n1.component 同时完成了两件事:
- 让新的
vnode继续指向原来的组件实例 - 让后续更新可以继续使用这个实例
组件实例中还需要保存待更新的 vnode 和更新函数:
export function createComponentInstance(vnode) {
const { type } = vnode
const instance = {
type,
vnode,
next: null,
update: null,
render: null,
setupState: null,
props: {},
attrs: {},
subTree: null,
isMounted: false
}
return instance
}
上一节使用 ReactiveEffect 创建组件更新函数时,还需要把 update 保存到实例上:
const effect = new ReactiveEffect(componentUpdateFn)
const update = effect.run.bind(effect)
effect.scheduler = () => {
queueJob(update)
}
instance.update = update
update()
父组件更新子组件时,就可以调用 instance.update() 重新执行子组件的更新逻辑。
是否需要更新
父组件每次重新渲染时,都会创建新的子组件 vnode。
但新的 vnode 不一定真的需要更新。比如父组件重新渲染时,子组件收到的 props 和插槽都没有变化,就可以直接复用旧内容。
所以先实现 shouldUpdateComponent:
function updateComponent(n1, n2) {
const instance = n2.component = n1.component
if (shouldUpdateComponent(n1, n2)) {
instance.next = n2
instance.update()
}
else {
// props 和 children 都没有变化,直接复用旧组件
n2.el = n1.el
instance.vnode = n2
}
}
需要更新时,先把新的 vnode 保存到 instance.next,再执行组件更新。
不需要更新时,直接复用旧的根节点和组件实例。
实现 shouldUpdateComponent
- 新旧
vnode任意一个存在 children,就认为组件需要更新 - 新旧
props都不存在,不需要更新 - 只有一方存在
props,需要更新 - 两边都有
props时,比较具体的值
export function shouldUpdateComponent(n1, n2) {
const prevChildren = n1.children
const nextChildren = n2.children
if (prevChildren || nextChildren) {
return true
}
const prevProps = n1.props
const nextProps = n2.props
if (!prevProps && !nextProps) {
return false
}
if (!prevProps || !nextProps) {
return true
}
return hasPropsChanged(prevProps, nextProps)
}
先比较两个 props 对象的 key 数量,再逐个比较每个 key 对应的值:
function hasPropsChanged(prevProps, nextProps) {
const nextKeys = Object.keys(nextProps)
if (nextKeys.length !== Object.keys(prevProps).length) {
return true
}
for (const key of nextKeys) {
if (nextProps[key] !== prevProps[key]) {
return true
}
}
return false
}
const prevVNode = h(Child, { age: 1 })
const nextVNode = h(Child, { age: 2 })
执行 shouldUpdateComponent(prevVNode, nextVNode) 时,最终会比较:
prevVNode.props.age !== nextVNode.props.age
结果为 true,所以需要继续更新子组件。
更新组件 props
调用 instance.update() 后,组件更新函数会重新执行。
但如果是父组件传入的 props 发生变化,子组件自身的响应式状态并没有变化。此时需要先把新的 vnode 和 props 同步到组件实例中,再执行 render。
function updateComponentPreRender(instance, nextVNode) {
instance.vnode = nextVNode
updateProps(instance, nextVNode)
}
在组件更新函数中处理 instance.next:
function componentUpdateFn() {
if (!instance.isMounted) {
// ...
}
else {
let { next } = instance
const { vnode } = instance
if (next) {
updateComponentPreRender(instance, next)
}
else {
next = vnode
}
const preeTree = instance.subTree
const subTree = instance.render.call(instance.proxy)
patch(preeTree, subTree, container, anchor)
next.el = subTree.el
instance.subTree = subTree
}
}
- 取出父组件传入的新
vnode - 更新组件实例上的
vnode - 更新组件的
props和attrs - 重新执行子组件的
render - 使用新旧
subTree进行patch
实现 updateProps
// packages/runtime-core/src/componentProps.ts
import { hasOwn } from '@vue/shared'
export function updateProps(instance, nextVNode) {
const { props, attrs } = instance
const nextProps = nextVNode.props || {}
setFullProps(instance, nextProps, props, attrs)
// 删除之前存在、现在已经移除的参数
for (const key in props) {
if (!hasOwn(nextProps, key)) {
delete props[key]
}
}
for (const key in attrs) {
if (!hasOwn(nextProps, key)) {
delete attrs[key]
}
}
}
小结
以 count.value++ 为例,组件更新过程如下:
- count.value 发生变化
- 父组件的 scheduler 将 update 放入异步队列
- 父组件重新执行 render
- 得到新的 Child vnode
- patch 发现新旧 vnode 都是 Child 组件
- updateComponent 复用旧的 Child 实例
- shouldUpdateComponent 比较新旧 props
- instance.next 保存新的 vnode
- updateProps 更新 Child 的 props
- Child 重新执行 render
- patch 比较新旧 subTree
- 更新最终的 DOM
props 更新和组件自身状态更新,最终都会触发同一个 componentUpdateFn
区别在于:
- 组件自身状态更新:直接重新执行组件更新函数
- 父组件传入
props:先更新instance.vnode和instance.props,再执行组件更新函数