Vue 基础体系 · 第 6/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 组件契约:Props、Emits、v-model、透传属性与边界

Vue 组件之间的通信,本质上是一个契约问题

  • 父组件可以向子组件提供哪些输入?
  • 子组件可以向父组件发出哪些输出?
  • 输入是否允许被子组件直接修改?
  • 未声明的属性和事件应该落到哪里?
  • 一个看起来像原生输入框的组件,究竟由谁持有真实状态?
  • TypeScript 检查通过后,运行时是否仍可能接收到错误数据?

在 Vue 3 中,propsemitsv-model 和透传属性共同决定了组件边界。理解它们不能只记住语法,因为同一段模板可能同时涉及三种不同的数据流:

父组件状态
   │
   │ props / v-model
   ▼
子组件内部逻辑 ───── emits / update:modelValue ───► 父组件状态更新
   │
   │ 未声明属性、class、style、监听器
   ▼
根 DOM 元素或显式指定的内部元素

下面以 Vue 3、Composition API、TypeScript 和 Vite 常见配置为背景,分别说明这些机制的语义、实现方式和边界。


一、先建立组件契约:输入、输出与透传

可以把一个组件抽象成一个函数,但它不是普通的纯函数:

Component=(Props,Slots,Context)DOM+EmitsComponent = (Props, Slots, Context) \rightarrow DOM + Emits

其中:

  • Props 是父组件明确声明的输入;
  • Slots 是父组件提供的内容分发入口;
  • Context 包括组件实例上下文、attrs 等;
  • DOM 是组件渲染出的界面;
  • Emits 是子组件主动通知父组件的输出。

更实用的工程模型是:

Props:父 → 子,声明式输入
Emits:子 → 父,离散事件通知
v-model:Props + Emits 的双向绑定约定
$attrs:未被组件消费的外部属性,默认向根节点透传
Slots:父提供渲染内容,子决定放置位置

这里的“契约”不是单纯的 TypeScript 类型。一个可靠的组件契约至少包含四层:

  1. 名称契约:属性名、事件名、v-model 参数名;
  2. 值契约:类型、默认值、是否允许为空;
  3. 时序契约:什么时候发事件,是否可能重复发出;
  4. 结构契约:属性和事件最终落到哪个 DOM 节点。

例如,一个输入组件可能约定:

type Props = {
  modelValue: string
  disabled?: boolean
}

type Emits = {
  'update:modelValue': [value: string]
  blur: [event: FocusEvent]
}

这表示:

  • 父组件通过 modelValue 提供当前值;
  • 子组件不能把 modelValue 当作自己的可变存储;
  • 子组件通过 update:modelValue 请求父组件更新;
  • blur 事件可以把原生事件对象通知出去;
  • 没有声明的属性,例如 aria-labelnamedata-testid,是否透传到内部元素,需要另外定义。

二、Props:单向数据流中的输入契约

2.1 Props 的方向是父到子

父组件传入:

<!-- Parent.vue -->
<script setup lang="ts">
import UserCard from './UserCard.vue'

const user = {
  id: 1,
  name: 'Lin',
  role: 'admin',
}
</script>

<template>
  <UserCard
    :user="user"
    title="成员信息"
  />
</template>

子组件声明:

<!-- UserCard.vue -->
<script setup lang="ts">
type User = {
  id: number
  name: string
  role: 'admin' | 'member'
}

const props = defineProps<{
  user: User
  title?: string
}>()
</script>

<template>
  <article>
    <h2>{{ props.title ?? '用户' }}</h2>
    <p>{{ props.user.name }}</p>
    <p>{{ props.user.role }}</p>
  </article>
</template>

这里的关系是:

Parent.user ──传入──► UserCard.props.user

子组件可以读取 props.user,但不能通过重新赋值改变父组件的绑定:

// 错误:props 本身是只读的
props.user = {
  id: 2,
  name: 'Other',
  role: 'member',
}

Vue 会在开发环境报告类似“attempting to mutate prop”的警告。

2.2 Props 只读不等于深度不可变

需要区分两个概念:

  • props.user = newUser:修改了 props 属性本身;
  • props.user.name = 'New Name':修改了 props 指向的对象内部。

第二种情况通常不会被 Vue 阻止,因为 JavaScript 对象仍然是可变引用:

props.user.name = 'New Name'

这可能直接改变父组件持有的同一个对象:

父组件 user ─────┐
                  ├── 同一个对象
子组件 props.user ┘

子组件修改 props.user.name
        ↓
父组件 user.name 也改变

这不是推荐的数据流,而是引用共享导致的副作用。

如果子组件需要编辑对象,应复制出本地草稿:

import { reactive } from 'vue'

const draft = reactive({
  name: props.user.name,
  role: props.user.role,
})

保存时通过事件把完整结果交还父组件:

const emit = defineEmits<{
  save: [user: User]
}>()

function submit() {
  emit('save', {
    ...props.user,
    name: draft.name,
    role: draft.role,
  })
}

此时数据流是:

父 user ──props──► 子 draft
子 draft ──save──► 父 user 更新

这比直接修改 props.user.name 更清晰,因为“编辑中的草稿”和“已提交的数据”是两个不同状态。

2.3 Props 的类型声明方式

<script setup lang="ts"> 中,常用的是基于类型的声明:

interface Props {
  label: string
  count?: number
  variant?: 'primary' | 'secondary'
}

const props = withDefaults(defineProps<Props>(), {
  count: 0,
  variant: 'primary',
})

withDefaults 的作用是为可选 props 提供默认值。此时:

  • label 是必需输入;
  • count 在组件内部可以按有默认值的方式使用;
  • variant 默认是 'primary'

Vue 3.3 及之后也可以使用响应式解构的相关能力,但具体行为受 Vue 版本影响。为了在不同项目中保持明确,直接使用 props.xxx 通常更容易辨认依赖来源。

基于运行时声明的写法如下:

const props = defineProps({
  label: {
    type: String,
    required: true,
  },
  count: {
    type: Number,
    default: 0,
  },
})

两种声明方式的关注点不同:

方式 主要收益 局限
类型声明 TypeScript 类型表达能力强,适合复杂联合类型和元组 运行时类型校验信息有限
运行时声明 Vue 可以在运行时检查 StringNumber 等构造器 复杂类型需要额外表达,容易与 TS 类型重复
两者混用 可同时获得部分能力 容易出现两份声明不一致

TypeScript 只检查编译期代码。例如父组件中写:

<UserCard :user="123" />

如果 UserCard 的类型信息被正确导出,编辑器和 vue-tsc 通常可以报告错误;但如果数据来自接口、any 或 JavaScript,运行时仍可能收到错误值。因此:

TypeScript 检查 ≠ 运行时数据验证

生产环境若需要防御外部 JSON、插件输入或跨边界数据,应在进入组件之前进行运行时解析,例如使用项目选定的 schema 工具,或在 props 的运行时声明中提供基础校验。

2.4 默认值不是“空值兜底”的全部含义

以下几种值必须区分:

type Props = {
  size?: number
}
  • 属性缺失:undefined
  • 显式传入 undefined:通常会触发默认值;
  • 显式传入 null:不是缺失,不应自动等同于默认值;
  • 0false'':都是有意义的显式值。

因此不能随意写:

const size = props.size || 12

因为 0 会被替换成 12。更准确的表达通常是:

const size = props.size ?? 12

?? 只在 nullundefined 时使用右侧默认值。

对于对象和数组默认值,运行时声明中应使用工厂函数:

const props = defineProps({
  options: {
    type: Array,
    default: () => [],
  },
})

这样每个组件实例得到独立数组,避免多个实例共享同一个可变默认对象。


三、Emits:子组件向外发出的事件契约

3.1 Emit 不是直接修改父状态

子组件没有权限直接设置父组件的局部变量:

// 子组件不能这样修改父组件变量
parentCount = parentCount + 1

子组件应该发出事件:

const emit = defineEmits<{
  change: [value: number]
}>()

function increment() {
  emit('change', 1)
}

父组件监听:

<Counter :value="count" @change="count += $event" />

完整的数据流是:

父 count ──value──► 子
子 ──change(1)──► 父
父重新计算 count ──value──► 子

事件表达的是“发生了某件事”或“请父组件更新”,而不是把父状态的所有权转交给子组件。

3.2 类型化 Emits

推荐使用元组语法描述事件参数:

const emit = defineEmits<{
  submit: [payload: {
    name: string
    email: string
  }]
  cancel: []
  error: [reason: Error]
}>()

这样可以得到以下检查:

emit('submit', {
  name: 'A',
  email: 'a@example.com',
})

emit('cancel')

// 错误:submit 缺少 payload
emit('submit')

// 错误:事件名不存在
emit('success')

也可以用函数签名形式:

const emit = defineEmits<{
  (event: 'submit', payload: FormData): void
  (event: 'cancel'): void
}>()

元组写法更紧凑,函数签名在需要条件或重载表达时更灵活。

3.3 Emits 声明不仅是类型信息

在 Vue 3 中,声明 emits 还有一个重要运行时意义:它会把这些事件从“未消费的属性”中区分出来。

例如:

const emit = defineEmits<{
  click: [event: MouseEvent]
}>()

父组件使用:

<MyButton @click="handleClick" />

这个 click 监听器属于组件事件,而不是需要自动透传到根 DOM 的普通 $attrs

如果不声明 click,在单根组件中它可能作为 fallthrough attribute 继续落到根元素。这会造成边界含义不清,甚至在组件内部同时手动绑定原生 click 时产生重复调用风险。

因此,组件如果主动发出一个事件,应把它列入 emits

const emit = defineEmits<{
  click: [event: MouseEvent]
}>()

function onClick(event: MouseEvent) {
  emit('click', event)
}

3.4 事件名与 DOM 事件的边界

自定义事件名建议使用明确的语义,例如:

emit('submit')
emit('remove')
emit('update:modelValue', value)

不要把一个组件内部实现细节暴露为不稳定事件,例如:

emit('input-element-keydown')

如果父组件真正关心的是“提交”或“值发生改变”,就应该暴露语义事件,而不是暴露内部 DOM 结构。

同时,组件事件不会自动冒泡。父组件监听:

<Child @submit="onSubmit" />

只会监听这个子组件实例发出的 submit,不会因为子组件内部某个深层 DOM 元素触发了同名原生事件就自动获得事件冒泡语义。


四、v-model:一个有固定约定的双向绑定

4.1 默认 v-model 的展开形式

在组件上写:

<MyInput v-model="email" />

默认等价于:

<MyInput
  :modelValue="email"
  @update:modelValue="email = $event"
/>

因此,组件要支持默认 v-model,至少需要:

const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

模板实现:

<template>
  <input
    :value="props.modelValue"
    @input="emit(
      'update:modelValue',
      ($event.target as HTMLInputElement).value
    )"
  />
</template>

这里有两个关键事实:

  1. input 的当前值来自 props.modelValue
  2. 用户输入后,子组件只发出更新事件,不直接改 props。

父组件:

<script setup lang="ts">
import { ref } from 'vue'
import MyInput from './MyInput.vue'

const email = ref('')
</script>

<template>
  <MyInput v-model="email" />
  <p>{{ email }}</p>
</template>

执行过程是:

初始:
email = ''

父传入:
modelValue = ''

用户输入 a:
子组件读取 DOM value = 'a'

子组件发出:
update:modelValue('a')

父组件执行:
email = 'a'

父组件重新渲染:
modelValue = 'a'

子组件 DOM 重新与 modelValue 对齐

因此 v-model 不是魔法意义上的共享变量,而是:

v-model=prop+update eventv\text{-}model = prop + update\ event

4.2 带参数的多个 v-model

组件可以同时支持多个模型:

<UserNameForm
  v-model:first-name="firstName"
  v-model:last-name="lastName"
/>

展开为:

<UserNameForm
  :first-name="firstName"
  @update:first-name="firstName = $event"
  :last-name="lastName"
  @update:last-name="lastName = $event"
/>

子组件:

<script setup lang="ts">
const props = defineProps<{
  firstName: string
  lastName: string
}>()

const emit = defineEmits<{
  'update:firstName': [value: string]
  'update:lastName': [value: string]
}>()
</script>

<template>
  <input
    :value="props.firstName"
    @input="
      emit(
        'update:firstName',
        ($event.target as HTMLInputElement).value
      )
    "
  />

  <input
    :value="props.lastName"
    @input="
      emit(
        'update:lastName',
        ($event.target as HTMLInputElement).value
      )
    "
  />
</template>

模板中的参数使用短横线:

v-model:first-name

而 JavaScript 事件名按照 Vue 的事件命名规则通常写成:

'update:firstName'

这使组件可以把不同字段作为独立受控状态管理,而不必把整个表单对象绑定为一个模型。

4.3 受控组件与非受控组件

一个组件是“受控”的,通常意味着它把显示值完全建立在外部输入上:

显示值 = props.modelValue
用户操作 = emit(update:modelValue)

如果子组件同时维护:

const internalValue = ref(props.modelValue)

就出现了两个值:

外部值:props.modelValue
内部值:internalValue

如果没有同步策略,它们可能分叉:

父组件异步重置 modelValue = 'server value'
子组件仍显示 internalValue = 'old value'

这会形成“看起来绑定成功,但界面不更新”的故障。

若组件确实需要内部草稿,例如输入防抖、异步校验或编辑取消,应明确同步规则:

import { ref, watch } from 'vue'

const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

const draft = ref(props.modelValue)

watch(
  () => props.modelValue,
  (value) => {
    draft.value = value
  },
)

function onInput(value: string) {
  draft.value = value
  emit('update:modelValue', value)
}

但这里仍有并发边界:如果父组件在用户输入后因为服务端响应又传入旧值,watch 会把草稿覆盖掉。是否接受这种行为,取决于组件契约。对于复杂编辑器,应进一步定义“外部值变化时覆盖、提示冲突还是忽略”的策略,不能仅靠 watch 猜测。

4.4 defineModel:Vue 3.4 及之后的语法

Vue 3.4 引入了 defineModel 宏,用于简化组件 v-model 的声明:

<script setup lang="ts">
const model = defineModel<string>()
</script>

<template>
  <input v-model="model" />
</template>

它相当于声明了默认的:

modelValue: string
update:modelValue: (value: string) => void

带参数的模型:

<script setup lang="ts">
const title = defineModel<string>('title', {
  required: true,
})
</script>

<template>
  <input v-model="title" />
</template>

父组件:

<MyTitleEditor v-model:title="title" />

defineModel 是编译器宏,不是普通运行时函数,必须在支持该能力的 Vue 版本和构建工具链中使用。维护需要兼容 Vue 3.4 之前版本的组件库时,显式声明 definePropsdefineEmits 更稳妥。

使用默认值时还要注意同步语义:

const model = defineModel<number>({
  default: 1,
})

如果父组件没有提供对应模型,子组件可能拥有默认值,而父组件的 ref 仍是 undefined。这意味着子、父两端初始状态不一定相同。对于要求父子严格一致的受控组件,应谨慎使用模型默认值,或者让父组件显式初始化。

4.5 v-model 修饰符

原生输入支持:

<input v-model.trim="name" />
<input v-model.number="age" />
<input v-model.lazy="value" />

自定义组件也可以接收修饰符,但组件必须自行定义处理逻辑。

在显式 modelValue 写法中,可以声明:

const props = defineProps<{
  modelValue: string
  modelModifiers?: {
    trim?: boolean
  }
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

function updateValue(value: string) {
  emit(
    'update:modelValue',
    props.modelModifiers?.trim ? value.trim() : value,
  )
}

父组件:

<MyInput v-model.trim="name" />

在 Vue 3.4 及之后,defineModel 可以直接读取修饰符:

<script setup lang="ts">
const [model, modifiers] = defineModel<string, 'trim'>()

function setValue(value: string) {
  model.value = modifiers.trim ? value.trim() : value
}
</script>

<template>
  <input
    :value="model"
    @input="setValue(($event.target as HTMLInputElement).value)"
  />
</template>

修饰符不是自动为任意组件实现业务语义的机制。.trim.number.lazy 对自定义组件是否有意义,必须由组件作者明确实现。


五、透传属性:未声明输入的默认去向

5.1 什么是透传属性

当父组件向子组件传入一个没有被 propsemits 声明的属性时,这个属性称为 fallthrough attribute,通常翻译为“透传属性”或“透传 attrs”。

父组件:

<MyButton
  aria-label="删除用户"
  data-testid="delete-user"
  class="danger"
  @focus="onFocus"
/>

如果 MyButton 是单根组件,且根节点是:

<template>
  <button>
    <slot />
  </button>
</template>

Vue 默认会把这些未声明内容透传到根节点,效果接近:

<button
  aria-label="删除用户"
  data-testid="delete-user"
  class="danger"
>
  删除
</button>

透传内容可能包括:

  • 普通 HTML 属性;
  • class
  • style
  • 未声明的事件监听器;
  • 某些组件上下文属性。

但是,已经声明为 props 的属性不会出现在 $attrs 中;已经声明为 emits 的事件监听器也不会作为未消费的 attrs 处理。

5.2 classstyle 的合并

透传并不是简单地覆盖所有属性。对于 classstyle,Vue 会进行合并。

组件:

<template>
  <button class="button-base">
    <slot />
  </button>
</template>

父组件:

<MyButton class="button-danger" />

最终通常得到:

<button class="button-base button-danger">
  ...
</button>

这与普通属性的处理不同。例如,如果组件根节点有:

<button type="button">

父组件传入:

<MyButton type="submit" />

最终的 type 通常会使用父组件传入的值。

对于组件库来说,不能仅凭透传就假设所有 DOM 属性都能正确表达业务语义。组件可能内部已经固定了 typeroletabindex 或事件处理器,外部值是否允许覆盖应成为明确契约。

5.3 多根节点时不会自动透传

以下组件有多个根节点:

<template>
  <header>标题</header>
  <main>内容</main>
</template>

此时父组件传入:

<MyPanel class="wide" />

Vue 无法判断 class 应该放到 header 还是 main,因此不会自动决定透传目标。开发环境可能给出相关警告。

应显式绑定:

<template>
  <header>标题</header>
  <main v-bind="$attrs">内容</main>
</template>

这时属性被明确发送到 main

如果同时要阻止默认继承,可以使用:

<script setup lang="ts">
defineOptions({
  inheritAttrs: false,
})
</script>

<template>
  <div class="panel-shell">
    <section v-bind="$attrs">
      <slot />
    </section>
  </div>
</template>

inheritAttrs: false 只关闭“自动继承到根节点”,不会删除 $attrs,也不会阻止父组件传入属性。它的意义是把控制权交给组件代码。

在 Vue 3.3 及之后,defineOptions 可用于 <script setup> 中声明组件选项。更早版本可以使用普通 <script> 导出 inheritAttrs: false,但不应在一个组件中无意间维护互相冲突的两套声明。

5.4 为什么需要手动绑定 $attrs

考虑一个输入组件:

<template>
  <label class="field">
    <span>{{ label }}</span>
    <input class="input" />
  </label>
</template>

父组件:

<FieldInput
  id="email"
  name="email"
  autocomplete="email"
  aria-describedby="email-error"
/>

如果不做处理,Vue 无法把这些属性自动放到内部 input,因为组件根节点是 label。结果可能是:

<label
  id="email"
  name="email"
  autocomplete="email"
  aria-describedby="email-error"
>
  <span>...</span>
  <input class="input">
</label>

autocompletearia-describedby 应该属于输入框,而不是 label

正确实现:

<script setup lang="ts">
defineOptions({
  inheritAttrs: false,
})

defineProps<{
  label: string
}>()
</script>

<template>
  <label class="field">
    <span>{{ label }}</span>
    <input class="input" v-bind="$attrs" />
  </label>
</template>

这样:

组件外部属性
        │
        ▼
$attrs
        │ v-bind="$attrs"
        ▼
内部 input

这也是组件封装中最重要的边界选择之一:根节点是视觉容器,还是实际交互元素?

5.5 useAttrs()$attrs 的区别

<script setup> 中可以读取:

import { useAttrs } from 'vue'

const attrs = useAttrs()

模板中也可以直接使用 $attrs

<input v-bind="$attrs" />

useAttrs() 返回的是组件的 attrs 对象,但它不是普通的响应式状态。不要把它当作可以随意 watch 的响应式源来设计业务逻辑。

如果逻辑依赖某个明确属性,应把它声明为 prop:

const props = defineProps<{
  readonly?: boolean
}>()

而不是依赖:

const attrs = useAttrs()
// 通过 attrs.readonly 驱动核心业务逻辑

未声明 attrs 更适合承载:

  • aria-*
  • data-*
  • idnameautocomplete
  • 组件不需要解释、但内部目标元素需要接收的原生属性。

如果组件需要理解一个属性的业务含义,它通常应该升级为显式 prop,而不是继续藏在 $attrs 中。


六、一个完整的受控输入组件

下面实现一个可运行的 BaseInput.vue,包含:

  • v-model
  • labelerror props;
  • disabled 传递;
  • aria-*idname 等 attrs 定向透传;
  • blur 事件;
  • 明确的无障碍属性关联。
<!-- BaseInput.vue -->
<script setup lang="ts">
import { computed } from 'vue'

defineOptions({
  inheritAttrs: false,
})

const props = withDefaults(
  defineProps<{
    modelValue: string
    label: string
    error?: string
    disabled?: boolean
  }>(),
  {
    error: '',
    disabled: false,
  },
)

const emit = defineEmits<{
  'update:modelValue': [value: string]
  blur: [event: FocusEvent]
}>()

const inputId = computed(() => {
  const id = typeof $attrs.id === 'string' ? $attrs.id : undefined
  return id ?? `input-${Math.random().toString(36).slice(2)}`
})

const describedBy = computed(() => {
  const external = typeof $attrs['aria-describedby'] === 'string'
    ? $attrs['aria-describedby']
    : ''

  const errorId = props.error ? `${inputId.value}-error` : ''

  return [external, errorId].filter(Boolean).join(' ') || undefined
})

function onInput(event: Event) {
  const target = event.target as HTMLInputElement
  emit('update:modelValue', target.value)
}

function onBlur(event: FocusEvent) {
  emit('blur', event)
}
</script>

<template>
  <div class="field">
    <label :for="inputId">
      {{ label }}
    </label>

    <input
      v-bind="$attrs"
      :id="inputId"
      :value="modelValue"
      :disabled="disabled"
      :aria-invalid="error ? 'true' : undefined"
      :aria-describedby="describedBy"
      @input="onInput"
      @blur="onBlur"
    />

    <p
      v-if="error"
      :id="`${inputId}-error`"
      class="field-error"
    >
      {{ error }}
    </p>
  </div>
</template>

父组件使用:

<script setup lang="ts">
import { ref } from 'vue'
import BaseInput from './BaseInput.vue'

const email = ref('')
const emailError = ref('')

function validateEmail() {
  emailError.value = email.value.includes('@')
    ? ''
    : '请输入有效的邮箱地址'
}

function handleBlur(event: FocusEvent) {
  console.log('input blurred:', event.target)
}
</script>

<template>
  <form @submit.prevent="validateEmail">
    <BaseInput
      v-model="email"
      label="邮箱"
      name="email"
      type="email"
      autocomplete="email"
      placeholder="name@example.com"
      :error="emailError"
      aria-describedby="external-help"
      @blur="handleBlur"
    />

    <p id="external-help">
      我们只使用该邮箱发送通知。
    </p>

    <button type="submit">
      校验
    </button>
  </form>
</template>

这段组件的每个边界

modelValue 是显式 prop,因此不会进入 $attrs

modelValue ──组件逻辑消费──► :value

labelerrordisabled 同样由组件逻辑消费,不会自动透传。

nametypeautocompleteplaceholder 未声明为 props,因此通过:

v-bind="$attrs"

传给内部 input

@blur 已经在 emits 中声明,因此它表示组件主动发出的组件事件:

内部 input blur
        ↓
onBlur
        ↓
emit('blur', event)
        ↓
父组件 @blur

如果不声明 blur,它可能同时被当作要透传给根节点的监听器。由于根节点是外层 div,这与父组件“监听输入框失焦”的预期不一定一致。

一个需要修正的实现细节

上例用 Math.random() 生成默认 id,是为了展示“label 需要关联 input”的概念,但在 SSR 场景中,服务端和客户端随机值可能不同,从而导致 hydration 不一致。

更稳妥的做法是:

  • 要求调用方传入稳定的 id
  • 使用项目已有的 SSR 安全唯一 ID 方案;
  • 或在 Vue 版本和运行环境支持时使用合适的实例级 ID 生成方式。

不要把随机数生成的 ID 当作 SSR 组件的通用实现。


七、Props、Emits 与透传属性的判定顺序

对于父组件传入的一项内容,可以按以下方式分析:

父组件传入属性或监听器
          │
          ▼
是否声明为 prop?
   ├─ 是:进入 props
   └─ 否
       │
       ▼
是否声明为 emit 事件?
   ├─ 是:作为组件事件处理
   └─ 否
       │
       ▼
是否存在自动继承目标?
   ├─ 单根组件:默认落到根节点
   ├─ 多根组件:不自动决定
   └─ inheritAttrs: false:保留在 $attrs,等待手动绑定

假设组件:

<script setup lang="ts">
defineOptions({
  inheritAttrs: false,
})

defineProps<{
  value: string
}>()

defineEmits<{
  change: [value: string]
}>()
</script>

<template>
  <div class="wrapper">
    <input v-bind="$attrs" />
  </div>
</template>

父组件:

<MyComponent
  :value="name"
  @change="onChange"
  @blur="onBlur"
  aria-label="名称"
/>

结果:

父组件内容 去向
value props.value
@change 组件事件监听器,因为已声明 change
@blur 未声明监听器,进入 $attrs
aria-label $attrs
class $attrs,因为关闭了自动继承

因此如果 $attrs 绑定到 input@blur 会成为 input 的原生监听器,而 @change 不会自动绑定到 input。这可能正是需要的结果,也可能是 bug,关键在于是否有明确契约。


八、透传事件的重复触发陷阱

考虑这个组件:

<script setup lang="ts">
const emit = defineEmits<{
  click: [event: MouseEvent]
}>()

function handleClick(event: MouseEvent) {
  emit('click', event)
}
</script>

<template>
  <button v-bind="$attrs" @click="handleClick">
    <slot />
  </button>
</template>

如果 click 已经声明,父组件的 @click 会作为组件事件监听器,不会因为 $attrs 再绑定一次。这种实现是清晰的。

但如果去掉 defineEmits

<script setup lang="ts">
function handleClick(event: MouseEvent) {
  // 内部处理
}
</script>

<template>
  <button v-bind="$attrs" @click="handleClick">
    <slot />
  </button>
</template>

父组件的 @click 可能通过 $attrs 直接绑定到 button,而组件模板又有自己的 @click。这里不一定自动导致两次父回调,但组件的事件边界已经变得依赖实现细节;如果组件又手动调用 $attrs.onClick,就很容易重复触发。

应遵循一个清晰选择:

方案 A:组件事件

const emit = defineEmits<{
  click: [event: MouseEvent]
}>()

组件负责捕获内部点击并发出 click

方案 B:原生事件透传

不声明 click,只把 attrs 绑定到真正的按钮节点:

<button v-bind="$attrs">
  <slot />
</button>

组件不再包装这个事件语义。

不要同时把同一个事件当作“组件事件”和“原生事件”处理,除非有非常明确的兼容需求。


九、属性透传不是无限制的“万能代理”

透传适合传递通用 DOM 属性,但存在几个重要边界。

9.1 组件可能有多个内部交互节点

一个日期选择器可能包含:

输入框
打开按钮
弹层
日历网格

父组件传入:

<DatePicker
  aria-label="出生日期"
  @keydown="onKeydown"
/>

这些属性应该落在哪个元素上并不天然明确。把所有 $attrs 绑定到外层 div,通常无法满足键盘操作和无障碍要求。

这类组件应显式划分:

type Props = {
  inputAttrs?: Record<string, unknown>
  triggerAttrs?: Record<string, unknown>
}

或者提供明确的插槽、配置和事件,而不是假设“所有 attrs 自动转发到正确元素”。

9.2 classstyle 的归属可能有设计冲突

父组件写:

<MyCard class="compact" />

它可能想调整整个卡片外框;组件作者也可能把 class 绑定到了内部内容区。透传机制只解决技术去向,不解决视觉语义。

组件库应定义:

  • 根节点是否是公开样式边界;
  • 外部 class 作用于哪个节点;
  • 内部节点是否提供独立的 class prop 或插槽。

9.3 安全属性不能无条件覆盖

如果组件必须保持:

<button type="button">

以避免默认提交表单,就不应让透传属性随意覆盖为 type="submit",除非这是公开契约。

同理,组件内部的:

  • role
  • aria-expanded
  • tabindex
  • disabled
  • 事件处理器;

可能由组件状态控制。若完全使用:

<button v-bind="$attrs" :aria-expanded="open">

属性顺序和绑定策略会影响最终值。组件需要明确哪些属性由外部控制,哪些属性由内部状态控制,而不是把安全性和可访问性寄托在绑定顺序上。


十、组件状态机:输入、提交和异步失败

对于表单组件,契约还包括状态变化。以一个受控输入和异步保存为例,可以定义:

idle
  │ 用户修改
  ▼
dirty
  │ 提交
  ▼
submitting
  ├─ 成功 ─► saved
  └─ 失败 ─► error

如果提交期间再次输入,需要明确策略:

submitting + 新输入
   ├─ 禁止输入
   ├─ 更新草稿但不覆盖当前请求
   └─ 取消旧请求,只保留最新请求

组件不能只写一个 loading 布尔值就自动获得正确并发行为。

一个简单的父组件提交示例:

<script setup lang="ts">
import { ref } from 'vue'
import BaseInput from './BaseInput.vue'

const username = ref('')
const saving = ref(false)
const error = ref('')

let requestId = 0

async function save() {
  const currentRequest = ++requestId

  saving.value = true
  error.value = ''

  try {
    const response = await fetch('/api/profile', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        username: username.value,
      }),
    })

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`)
    }

    // 只有最新请求可以结束当前状态
    if (currentRequest === requestId) {
      saving.value = false
    }
  } catch (cause) {
    if (currentRequest !== requestId) return

    saving.value = false
    error.value = cause instanceof Error
      ? cause.message
      : '保存失败'
  }
}
</script>

<template>
  <form @submit.prevent="save">
    <BaseInput
      v-model="username"
      label="用户名"
      name="username"
      :disabled="saving"
      :error="error"
    />

    <button
      type="submit"
      :disabled="saving"
    >
      {{ saving ? '保存中…' : '保存' }}
    </button>
  </form>
</template>

这里的 requestId 是一种“最新请求获胜”的保护机制:

  1. 每次提交都产生递增 ID;
  2. 异步操作完成时检查自己的 ID;
  3. 不是最新请求的响应不能覆盖当前 UI 状态。

这属于父组件的业务状态,而不是 BaseInput 的职责。输入组件只负责输入契约;保存请求、错误归属和并发策略由更高层组件决定。

如果需要取消请求,可以结合 AbortController

let controller: AbortController | undefined

async function save() {
  controller?.abort()
  controller = new AbortController()

  try {
    await fetch('/api/profile', {
      method: 'POST',
      signal: controller.signal,
    })
  } catch (cause) {
    if (cause instanceof DOMException && cause.name === 'AbortError') {
      return
    }

    throw cause
  }
}

但“取消请求”和“忽略旧响应”是两件事。取消并不保证服务器端已经撤销操作,因此仍应根据业务语义决定是否需要请求序列号、幂等键或服务端版本控制。


十一、常见失败表现与诊断路径

11.1 写了 v-model,输入框却不更新

首先检查组件是否实现了完整约定:

defineProps<{
  modelValue: string
}>()

defineEmits<{
  'update:modelValue': [value: string]
}>()

常见错误是只声明了 prop:

const props = defineProps<{
  modelValue: string
}>()

然后在内部直接改:

// 错误:无法更新父组件状态
props.modelValue = value

或者发出了错误事件:

emit('change', value)

但父组件使用的是 v-model,它等待的是:

update:modelValue

11.2 父组件监听了事件,但回调没有触发

检查三层:

  1. 子组件是否真的执行了 emit
  2. 事件名是否一致;
  3. 事件是否被声明为 emits,还是误当成 $attrs 透传。

例如:

emit('update:modelValue', value)

与:

@update:model-value="handler"

在模板中通常可以匹配,但不要在组件 API 中随意混用大小写、下划线和语义不同的命名。事件名最好从声明到调用保持一致。

11.3 aria-label 落到了错误元素

检查组件是否:

  • 使用了多根节点;
  • 设置了 inheritAttrs: false
  • 绑定了 $attrs
  • $attrs 绑定到真正可交互的元素。

例如根节点是:

<div class="wrapper">
  <input />
</div>

但外部传入的 aria-label 应在 input 上,则必须:

<div class="wrapper">
  <input v-bind="$attrs" />
</div>

11.4 父组件传入的 class 不在预期位置

先判断这个 class 是:

  • 组件根节点样式;
  • 内部输入框样式;
  • 触发按钮样式;
  • 组件状态样式。

如果组件包含多个候选节点,不能继续依赖隐式透传,应使用显式 API:

<MySelect
  root-class="..."
  trigger-class="..."
/>

或者通过 :class 对象、插槽和 CSS 变量定义稳定边界。

11.5 TypeScript 没报错,但运行时仍崩溃

可能的数据来源是:

  • fetch 返回的 JSON;
  • 第三方 JavaScript;
  • any
  • 类型断言:
const data = response as User

类型断言不会验证数据。应在组件边界或请求边界进行运行时检查。尤其不要把“接口返回的数据类型声明”误认为“接口返回的数据已经符合该类型”。


十二、组件契约的设计边界

12.1 什么时候应该用 Prop

适合用 prop 的信息具有以下特点:

  • 子组件渲染或逻辑需要读取;
  • 父组件拥有它;
  • 它是当前状态或配置,而不是一次性动作。

例如:

disabled
placeholder
modelValue
error
options

12.2 什么时候应该用 Emit

适合用 emit 的信息具有以下特点:

  • 子组件内部发生了一个离散动作;
  • 父组件需要决定如何处理;
  • 事件可能包含动作上下文或结果。

例如:

submit
cancel
remove
blur
update:modelValue

不要把所有状态变化都包装成无语义的:

emit('change', ...)

对于公共组件,change 是否表示每次输入、失焦后的确认,还是选择完成,必须明确。原生控件中不同事件的时机也不同,组件不应只因为名字常见就复用含义。

12.3 什么时候应该用 $attrs

适合透传的内容通常是组件不需要解释的 DOM 能力:

id
name
autocomplete
aria-*
data-*
title
tabindex

如果组件需要根据它进行业务分支:

if (attrs.mode === 'remote') {
  // ...
}

那么 mode 应当声明为 prop:

const props = defineProps<{
  mode: 'local' | 'remote'
}>()

否则类型、默认值和运行时语义都隐藏在未声明对象中。

12.4 什么时候不应该封装

如果一个组件只是把所有属性、事件和方法原样代理给一个原生元素:

<input v-bind="$attrs" />

但没有增加稳定语义、样式、校验、可访问性或状态管理,它可能只是增加了一个没有价值的包装层。

反过来,如果组件确实提供了业务语义,就不应把内部 DOM 结构完全暴露出去。组件边界的价值在于:

外部依赖稳定契约
内部可以替换实现

十三、与 Slots 和动态组件的边界

Slots 是另一种输入,但它不是 props 的替代品。

Props 传递数据:

<UserCard :user="user" />

Slot 传递渲染内容:

<UserCard>
  <template #actions>
    <button>编辑</button>
  </template>
</UserCard>

子组件决定插槽出现的位置:

<template>
  <article>
    <div class="content">
      <slot />
    </div>

    <footer>
      <slot name="actions" />
    </footer>
  </article>
</template>

可以这样区分:

Props:子组件理解数据并参与逻辑
Slots:子组件提供位置,父组件决定内容
Emits:子组件通知父组件动作
Attrs:未声明的外部属性按边界规则透传

动态组件、KeepAliveTeleport 和异步组件会改变组件的渲染位置、缓存生命周期或加载过程,但不会改变 propsemitsv-model 的基本契约。组件被缓存后,内部状态可能保留;组件被异步加载时,加载失败又会增加新的错误边界。这些能力不应被误认为会自动解决受控状态、事件命名或 attrs 去向问题。


十四、规范保证、实现行为与工程建议的区分

Vue 的规范性能力

以下是组件 API 的核心约定:

  • props 从父组件流向子组件;
  • props 不应由子组件重新赋值;
  • v-model 默认对应 modelValueupdate:modelValue
  • defineEmits 声明组件可以发出的事件;
  • 单根组件默认会发生 attrs 继承;
  • 多根组件不会自动决定 attrs 的落点;
  • inheritAttrs: false 后可以通过 $attrs 手动绑定。

依赖版本或工具链的能力

以下能力需要确认项目版本:

  • defineModel:Vue 3.4 及之后;
  • defineOptions:Vue 3.3 及之后;
  • <script setup> 的类型宏转换能力:依赖 Vue 编译器和构建工具版本;
  • vue-tsc 对模板类型的检查:依赖 Vue Language Tools 和项目配置。

工程经验

以下不是 Vue 强制规定,但通常能减少边界问题:

  • 组件主动发出的事件都声明在 emits 中;
  • 业务输入声明为 props,不长期藏在 $attrs
  • 复杂组件显式决定 attrs 的目标节点;
  • 受控组件只有一个外部真值来源;
  • 编辑草稿与提交结果分离;
  • 对异步请求定义旧响应、取消和失败策略;
  • 对 SSR 组件避免使用服务端与客户端不一致的随机初始值;
  • 对公共组件测试属性、事件、v-model 和 attrs 的最终落点。

十五、一个可执行的自检示例

可以使用 Vite 创建 Vue TypeScript 项目:

npm create vite@latest component-contract-demo -- --template vue-ts
cd component-contract-demo
npm install
npm run dev

在项目中放入前面的 BaseInput.vue,然后运行:

npm run build

预期结果是:

  • TypeScript 和 Vue 模板编译通过;
  • v-model 能将输入值同步到父组件;
  • nametypeautocomplete 出现在内部 input
  • aria-describedby 同时关联外部帮助文本和内部错误文本;
  • blur 由子组件显式发出;
  • disabled 由 props 控制,而不是依赖不透明的 $attrs

若要验证契约失败,可以暂时修改父组件:

<BaseInput v-model="email" :disabled="'yes'" />

正确配置的 TypeScript 检查应报告 disabled 不是字符串。再修改子组件:

emit('update:value', target.value)

此时组件的默认 v-model 不再更新,因为父组件监听的是 update:modelValue。这个失败不是 Vue 的随机行为,而是契约名称不匹配。


结语:组件边界是数据流和责任的边界

一个 Vue 组件是否可靠,不在于它是否拥有很多 props,而在于它是否明确区分了不同类型的责任:

Props:
我接受什么输入?

Emits:
我会发出什么通知?

v-model:
我如何与外部共同维护一个受控值?

透传属性:
哪些外部 DOM 能力可以继续交给内部节点?

边界:
哪些状态、事件和实现细节不应该被外部直接控制?

v-model 不是绕过单向数据流,而是把“输入 prop”和“更新事件”组合成一个约定。$attrs 不是任意属性的垃圾桶,而是组件未声明属性的转交机制。emits 也不只是给 TypeScript 看,它还参与事件是否被组件消费、是否继续透传的判定。

当组件明确声明输入、输出、状态所有权和 DOM 落点时,父子组件之间的关系就从“模板碰巧能工作”变成了可检查、可维护、可替换的技术契约。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。