2026.01.26

组件 Props 更新 -> 变更检测与更新渲染的实现

在之前的章节中,我们已经实现了组件的挂载机制。当父组件传递给子组件的 Props 发生变化时,子组件需要进行相应的更新渲染。本节将详细分析 Props 更新的完整流程。

在之前的章节中,我们已经实现了组件的挂载机制。当父组件传递给子组件的 Props 发生变化时,子组件需要进行相应的更新渲染。本节将详细分析 Props 更新的完整流程。

问题场景

让我们先通过一个示例来观察 Props 更新的场景:

<body>
<div id="app"></div>
<button id="btn">age++</button>

<script type="module">
  import { h, createApp, ref } from '../dist/vue.esm.js'

  const Child = {
    props: ['age'],
    setup(props) {
      return () => {
        return h('div', {}, `子组件的 props age: ${props.age}`)
      }
    }
  }

  const Comp = {
    setup() {
      const age = ref(0)
      btn.onclick = () => {
        age.value++
        console.log(age.value)
      }
      return () => {
        return h(Child, { age: age.value })
      }
    }
  }

  createApp(Comp).mount('#app')
</script>
</body>

核心问题:

当点击按钮时,age.value 会发生变化,但子组件并没有相应更新。这是因为我们之前的 processComponent 还没有处理更新的逻辑。

实现 updateComponent

1. 分离组件挂载与更新逻辑

首先,将组件的挂载和更新逻辑分离开来:

/**
 * 处理组件的挂载和更新
 */
function processComponent(n1, n2, container, anchor) {
  if (n1 == null) {
    mountComponent(n2, container, anchor)
  }
  else {
    updateComponent(n1, n2)
  }
}

设计要点:

  • n1 == null:表示首次挂载,调用 mountComponent
  • n1 != null:表示更新,调用 updateComponent

2. 组件实例复用

之前元素更新是复用 DOM 元素(el),而组件更新需要复用组件实例。为了让更新时能够访问到组件实例,我们需要在挂载时将实例保存到虚拟节点上:

function mountComponent(vnode, container, anchor) {
  // 创建组件实例
  const instance = createComponentInstance(vnode)

  // 将组件实例保存到虚拟节点上,方便后续复用
  vnode.component = instance

  // 初始化组件状态
  setupComponent(instance)
  setupRenderEffect(instance, container, anchor)
}

核心设计:

  • vnode.component:存储组件实例
  • 更新时通过 n1.component 获取旧实例,复用到新的虚拟节点 n2.component

3. 挂载 el 属性

在实现组件更新之前,我们需要确保虚拟节点上正确挂载了 el 属性:

function setupRenderEffect(instance, container, anchor) {
  const componentUpdateFn = () => {
    if (!instance.isMounted) {
      const { vnode } = instance
      const subTree = render.call(instance.proxy)

      patch(null, subTree, container, anchor)
      vnode.el = subTree.el
      instance.isMounted = true
    }
    else {
      const { vnode, render } = instance
      const prevSubTree = instance.subTree
      const subTree = render.call(instance.proxy)

      patch(prevSubTree, subTree, container, anchor)
      vnode.el = subTree.el
      instance.subTree = subTree
    }
  }

  // ...
}

设计要点:

  • 首次挂载时,vnode.el = subTree.el 将子树的根元素挂载到组件虚拟节点
  • 更新时同样同步 el 属性
  • 这样虚拟节点就拥有了 DOM 元素的引用,便于后续复用

4. Props 变更检测

现在来实现 updateComponent,首先需要判断 Props 是否发生变化:

/**
 * 检测 Props 是否发生变化
 */
function hasPropsChanged(prevProps, nextProps) {
  const nextKeys = Object.keys(nextProps)

  // Props 数量不同,说明有变化
  if (Object.keys(prevProps).length !== nextKeys.length) {
    return true
  }

  // Props 值不同,说明有变化
  for (const key of nextKeys) {
    if (nextProps[key] !== prevProps[key]) {
      return true
    }
  }

  return false
}

/**
 * 判断组件是否需要更新
 */
export function shouldComponentUpdate(n1, n2) {
  const { props: prevProps, children: prevChildren } = n1
  const { props: nextProps, children: nextChildren } = n2

  /**
   * 任意一个存在 slot 就需要更新
   */
  if (prevChildren || nextChildren) {
    return true
  }

  /**
   * 旧的 props 为空,新的 props 不为空
   */
  if (!prevProps) {
    return !!nextProps
  }

  /**
   * 旧的 props 不为空,新的 props 为空
   */
  if (!nextProps) {
    return true
  }

  /**
   * 两者都存在,检测 props 值是否变化
   */
  return hasPropsChanged(prevProps, nextProps)
}

检测逻辑:

  1. 插槽变化:任意一个存在插槽,就需要更新
  2. Props 数量变化:新旧 Props 数量不同,需要更新
  3. Props 值变化:逐个比较 Props 的值,不同则更新
  4. Props 从无到有/从有到无:都视为需要更新

5. 组件更新逻辑

/**
 * 更新组件
 */
function updateComponent(n1, n2) {
  const instance = (n2.component = n1.component)

  /**
   * Props、slots 发生改变的时候才更新
   * 不发生变化时,复用元素即可
   */
  if (shouldComponentUpdate(n1, n2)) {
    instance.next = n2
    instance.update()
  }
  else {
    /**
     * 没有任何属性发生变化时,进行元素复用
     */
    n2.el = n1.el
    instance.vnode = n2
  }
}

设计要点:

  • n2.component = n1.component:复用旧的组件实例
  • instance.next = n2:保存新的虚拟节点,用于后续更新
  • 不需要更新时,直接复用 el 和更新 vnode 引用

6. 更新前预渲染处理

调用 instance.update() 后会进入 componentUpdateFn,需要处理新的虚拟节点:

function componentUpdateFn() {
  if (!instance.isMounted) {
    // 首次挂载逻辑
  }
  else {
    let { vnode, render, next } = instance

    if (next) {
      /**
       * 有 next 说明是更新,复用组件实例完成 Props 的更新
       */
      updateComponentPreRender(instance, next)
    }
    else {
      /**
       * 如果没有 next,还是使用之前的 vnode
       */
      next = vnode
    }

    const prevSubTree = instance.subTree
    const subTree = render.call(instance.proxy)

    patch(prevSubTree, subTree, container, anchor)
    next.el = subTree.el
    instance.subTree = subTree
  }
}

工作流程:

  1. 检查是否存在 next(新的虚拟节点)
  2. 如果存在,调用 updateComponentPreRender 更新组件实例
  3. 重新渲染子树
  4. Patch 更新新旧子树

7. Props 更新实现

/**
 * 更新组件前的预处理
 */
function updateComponentPreRender(instance, nextVNode) {
  instance.vnode = nextVNode
  instance.next = null
  updateProps(instance, nextVNode)
}

/**
 * 更新 Props
 */
export function updateProps(instance, nextVNode) {
  const { props, attrs } = instance
  const rawProps = nextVNode.props

  /**
   * 设置新的 Props 和 Attrs
   */
  setFullProps(instance, rawProps, props, attrs)

  /**
   * 删除之前有但现在没有的属性
   */
  for (const key in props) {
    if (!hasOwn(rawProps, key)) {
      delete props[key]
    }
  }

  for (const key in attrs) {
    if (!hasOwn(rawProps, key)) {
      delete attrs[key]
    }
  }
}

更新逻辑:

  1. 更新 vnode 引用instance.vnode = nextVNode
  2. 清空 nextinstance.next = null,避免重复更新
  3. 设置新 Props:调用 setFullProps 更新 Props 和 Attrs
  4. 删除旧 Props:遍历旧的 Props,删除新 Props 中不存在的属性

总结

至此,我们完成了组件 Props 更新的完整机制:

1. 组件实例复用

  • 通过 vnode.component 存储组件实例
  • 更新时复用旧的组件实例到新的虚拟节点

2. Props 变更检测

  • 检测 Props 数量和值的变化
  • 检测插槽的变化
  • 检测 Props 的有无变化

3. 组件更新流程

  • updateComponent:判断是否需要更新
  • componentUpdateFn:处理新的虚拟节点并重新渲染
  • updateComponentPreRender:更新组件实例的 Props

4. Props 更新实现

  • 设置新的 Props 和 Attrs
  • 删除旧的 Props 和 Attrs 中不存在的属性

5. 优化策略

  • 不需要更新时,直接复用 DOM 元素和虚拟节点
  • 只在 Props 或 Slots 真正变化时才触发更新

这套机制确保了组件在 Props 变化时能够正确更新,同时通过组件实例复用和变更检测优化了性能,是 Vue 响应式系统的重要组成部分。