2026.01.20

组件属性传递 -> Props 与 Attrs 的设计与实现

在上一章中,我们实现了组件的挂载机制和响应式更新。然而,一个完整的组件系统离不开属性传递的支持。组件需要通过 props 接收外部数据,同时也需要处理未声明的 attributes。

在上一章中,我们实现了组件的挂载机制和响应式更新。然而,一个完整的组件系统离不开属性传递的支持。组件需要通过 props 接收外部数据,同时也需要处理未声明的 attributes

本章将深入组件属性系统的设计与实现,理解 Vue 如何优雅地区分和处理 propsattrs

问题场景

让我们先看一个典型的组件属性传递场景:


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

    const Comp = {
        props: {
            msg: String
        },
        setup(props, {attrs}) {
            console.log('props:', props)  // { msg: 'msg' }
            console.log('attrs:', attrs)  // { count: 0, a: 1 }
            return {}
        },
        render() {
            return h('div', 'hello component')
        }
    }

    createApp(Comp, {msg: 'msg', count: 0, a: 1}).mount('#app')
</script>

核心问题:

当我们向组件传递 { msg: 'msg', count: 0, a: 1 } 时,Vue 如何区分哪些属性是 props,哪些是 attrs?

区分逻辑:

  • Props{ msg: 'msg' } —— 组件中声明的属性
  • Attrs{ count: 0, a: 1 } —— 未在组件中声明的属性

这种设计让组件能够:

  1. 明确接收声明的响应式数据(props)
  2. 灵活处理额外的 HTML 属性(attrs,如 class、style、data-* 等)

Props 的标准化处理

多种 Props 声明方式

Vue 支持两种 props 声明语法:

// 1. 对象语法(完整形式)
props: {
  msg: String,
  count: Number
}

// 2. 数组语法(简化形式)
props: ['msg', 'count']

为了统一处理,我们需要将数组形式标准化为对象形式。

实现 normalizePropsOptions

/**
 * 标准化 props 选项
 * @param props - 组件声明的 props(对象或数组)
 * @returns 标准化后的 props 对象
 */
function normalizePropsOptions(props = {}) {
  // 数组形式:['msg', 'count'] -> { msg: {}, count: {} }
  if (isArray(props)) {
    return props.reduce((prev, cur) => {
      prev[cur] = {}
      return prev
    }, {})
  }

  // 对象形式直接返回
  return props
}

在组件实例中保存 propsOptions

export function createComponentInstance(vnode) {
  const { type } = vnode
  const instance = {
    type,
    vnode,
    render: null,
    setupState: null,
    propsOptions: normalizePropsOptions(type.props), // 保存标准化后的 props 声明
    props: {}, // 实际接收的 props 值
    attrs: {}, // 实际接收的 attrs 值
    subTree: null,
    isMounted: false
  }
  return instance
}

核心设计:

  • propsOptions:存储组件声明的 props 结构(作为"白名单")
  • props:存储实际接收的 props 值(响应式对象)
  • attrs:存储未声明的属性值(非响应式对象)

初始化 Props

在 setupComponent 中调用 initProps

现在我们需要在组件初始化时,将传入的属性分离为 props 和 attrs:

export function setupComponent(instance) {
  const { type } = instance

  // 初始化 props 和 attrs
  initProps(instance)

  // 执行 setup,并将 props 作为第一个参数传入
  if (isFunction(type.setup)) {
    const setupResult = proxyRefs(type.setup(instance.props, createSetupContext(instance)))
    instance.setupState = setupResult
  }

  instance.render = type.render
}

/**
 * 创建 setup 的第二个参数 context
 */
function createSetupContext(instance) {
  return {
    get attrs() {
      return instance.attrs
    }
  }
}

实现 initProps

接下来实现 props 初始化的核心逻辑:

/**
 * 初始化组件的 props 和 attrs
 * @param instance - 组件实例
 */
function initProps(instance) {
  const { vnode } = instance
  const rawProps = vnode.props // 用户传递的所有属性

  const props = {}
  const attrs = {}

  // 根据 propsOptions 分离 props 和 attrs
  setFullProps(instance, rawProps, props, attrs)

  // props 需要是响应式的,使用 reactive 包装
  instance.props = reactive(props)
  // attrs 不需要响应式
  instance.attrs = attrs
}

核心设计:

  • vnode.props 获取用户传递的所有属性
  • 调用 setFullProps 进行分离
  • 重要:使用 reactive 包装 props,使其具有响应式能力
  • attrs 保持普通对象,不做响应式处理(性能优化)

实现 setFullProps

/**
 * 将 rawProps 分离到 props 和 attrs
 * @param instance - 组件实例
 * @param rawProps - 用户传递的所有属性
 * @param props - 分离后的 props 对象(引用传递)
 * @param attrs - 分离后的 attrs 对象(引用传递)
 */
function setFullProps(instance, rawProps, props, attrs) {
  const propsOptions = instance.propsOptions

  if (rawProps) {
    for (const key in rawProps) {
      const value = rawProps[key]

      // 判断是否在 propsOptions 中声明
      if (hasOwn(propsOptions, key)) {
        // 声明的属性放入 props
        props[key] = value
      }
      else {
        // 未声明的属性放入 attrs
        attrs[key] = value
      }
    }
  }
}

工作原理:

  1. 遍历用户传递的所有属性
  2. 使用 hasOwn(propsOptions, key) 检查属性是否在组件中声明
  3. 声明的属性放入 props,未声明的放入 attrs
  4. 通过引用传递,直接修改 propsattrs 对象

为什么 props 需要响应式?

// 父组件
const parentCount = ref(0)
createApp(Comp, { msg: parentCount.value })

// 子组件
setup(props) {
  // props.msg 变化时,需要触发子组件重新渲染
  watchEffect(() => {
    console.log(props.msg)
  })
}

代码重构:单一职责原则

为了保持代码的清晰和可维护性,我们将 props 相关的逻辑抽取到独立模块 componentProps.ts 中。

创建 componentProps.ts

packages/runtime-core/src/componentProps.ts 中:

import { reactive } from '@vue/reactivity'
import { hasOwn, isArray } from '@vue/shared'

/**
 * 标准化 props 选项
 * 将数组形式转换为对象形式
 */
export function normalizePropsOptions(props = {}) {
  if (isArray(props)) {
    return props.reduce((prev, cur) => {
      prev[cur] = {}
      return prev
    }, {})
  }

  return props
}

/**
 * 将 rawProps 分离到 props 和 attrs
 */
export function setFullProps(instance, rawProps, props, attrs) {
  const propsOptions = instance.propsOptions
  if (rawProps) {
    for (const key in rawProps) {
      const value = rawProps[key]
      if (hasOwn(propsOptions, key)) {
        props[key] = value
      }
      else {
        attrs[key] = value
      }
    }
  }
}

/**
 * 初始化组件的 props 和 attrs
 */
export function initProps(instance) {
  const { vnode } = instance
  const rawProps = vnode.props

  const props = {}
  const attrs = {}
  setFullProps(instance, rawProps, props, attrs)

  // props 是响应式的,使用 reactive 包装
  instance.props = reactive(props)
  instance.attrs = attrs
}

模块职责:

  • normalizePropsOptions:标准化 props 声明
  • setFullProps:分离 props 和 attrs
  • initProps:初始化组件的 props 和 attrs

更新 component.ts

packages/runtime-core/src/component.ts 中:

import { proxyRefs } from '@vue/reactivity'
import { isFunction } from '@vue/shared'
import { initProps, normalizePropsOptions } from './componentProps'

/**
 * 创建组件实例
 */
export function createComponentInstance(vnode) {
  const { type } = vnode
  const instance = {
    type,
    vnode,
    render: null, // 渲染函数
    setupState: null, // setup 返回的状态
    propsOptions: normalizePropsOptions(type.props), // 用户声明的组件 props
    props: {}, // 实际接收的 props 值(响应式)
    attrs: {}, // 实际接收的 attrs 值(非响应式)
    subTree: null, // 子树(render 的返回值)
    isMounted: false // 是否已挂载
  }
  return instance
}

/**
 * 初始化组件
 */
export function setupComponent(instance) {
  const { type } = instance

  /**
   * 初始化 props 和 attrs
   */
  initProps(instance)

  /**
   * 执行 setup 函数
   */
  const setupContext = createSetupContext(instance)
  if (isFunction(type.setup)) {
    const setupResult = proxyRefs(type.setup(instance.props, setupContext))
    instance.setupState = setupResult
  }

  /**
   * 绑定 render 函数
   */
  instance.render = type.render
}

/**
 * 创建 setup 的第二个参数 context
 */
function createSetupContext(instance) {
  return {
    get attrs() {
      return instance.attrs
    }
  }
}

架构优势:

  1. 清晰的模块划分:props 处理逻辑独立于组件实例管理
  2. 易于测试:可以单独测试 props 的标准化和分离逻辑
  3. 可扩展性:后续添加 props 验证、默认值等特性时有明确的入口

总结

至此,我们完成了 Vue 组件属性系统的核心实现:

1. Props 标准化

  • 支持对象和数组两种声明方式
  • 使用 normalizePropsOptions 统一为对象格式
  • 将标准化结果保存在 instance.propsOptions 中作为"白名单"

2. Props 与 Attrs 分离

  • 通过 hasOwn(propsOptions, key) 区分声明和未声明的属性
  • 声明的属性进入 props(响应式对象)
  • 未声明的属性进入 attrs(普通对象)

3. 响应式设计

  • Props 使用 reactive 包装,支持响应式更新
  • Attrs 保持普通对象,避免不必要的响应式开销

4. Setup Context

  • props 作为 setup 的第一个参数传入
  • 通过 context.attrs 访问未声明的属性
  • 使用 getter 确保访问到最新的 attrs 值

5. 单一职责原则

  • componentProps.ts:处理 props 的标准化、分离和初始化
  • component.ts:处理组件实例的创建和初始化流程

这套机制为组件提供了灵活而强大的属性传递能力,既保证了类型安全(通过 props 声明),又支持透传未知属性(通过 attrs),是 Vue 组件系统的重要基础设施。