复用组件实例并完成 props 和 attrs 的更新

2025-10-29 11:35:33

上一节实现了组件的异步更新。

这是一个父组件向子组件传递 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 同时完成了两件事:

  1. 让新的 vnode 继续指向原来的组件实例
  2. 让后续更新可以继续使用这个实例

组件实例中还需要保存待更新的 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

  1. 新旧 vnode 任意一个存在 children,就认为组件需要更新
  2. 新旧 props 都不存在,不需要更新
  3. 只有一方存在 props,需要更新
  4. 两边都有 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
  }
}
  1. 取出父组件传入的新 vnode
  2. 更新组件实例上的 vnode
  3. 更新组件的 props 和 attrs
  4. 重新执行子组件的 render
  5. 使用新旧 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++ 为例,组件更新过程如下:

  1. count.value 发生变化
  2. 父组件的 scheduler 将 update 放入异步队列
  3. 父组件重新执行 render
  4. 得到新的 Child vnode
  5. patch 发现新旧 vnode 都是 Child 组件
  6. updateComponent 复用旧的 Child 实例
  7. shouldUpdateComponent 比较新旧 props
  8. instance.next 保存新的 vnode
  9. updateProps 更新 Child 的 props
  10. Child 重新执行 render
  11. patch 比较新旧 subTree
  12. 更新最终的 DOM

props 更新和组件自身状态更新,最终都会触发同一个 componentUpdateFn

区别在于:

  • 组件自身状态更新:直接重新执行组件更新函数
  • 父组件传入 props:先更新 instance.vnode 和 instance.props,再执行组件更新函数