Vue 基础体系 · 第 6/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 组件契约:Props、Emits、v-model、透传属性与边界
Vue 组件之间的通信,本质上是一个契约问题:
- 父组件可以向子组件提供哪些输入?
- 子组件可以向父组件发出哪些输出?
- 输入是否允许被子组件直接修改?
- 未声明的属性和事件应该落到哪里?
- 一个看起来像原生输入框的组件,究竟由谁持有真实状态?
- TypeScript 检查通过后,运行时是否仍可能接收到错误数据?
在 Vue 3 中,props、emits、v-model 和透传属性共同决定了组件边界。理解它们不能只记住语法,因为同一段模板可能同时涉及三种不同的数据流:
父组件状态
│
│ props / v-model
▼
子组件内部逻辑 ───── emits / update:modelValue ───► 父组件状态更新
│
│ 未声明属性、class、style、监听器
▼
根 DOM 元素或显式指定的内部元素
下面以 Vue 3、Composition API、TypeScript 和 Vite 常见配置为背景,分别说明这些机制的语义、实现方式和边界。
一、先建立组件契约:输入、输出与透传
可以把一个组件抽象成一个函数,但它不是普通的纯函数:
其中:
Props是父组件明确声明的输入;Slots是父组件提供的内容分发入口;Context包括组件实例上下文、attrs等;DOM是组件渲染出的界面;Emits是子组件主动通知父组件的输出。
更实用的工程模型是:
Props:父 → 子,声明式输入
Emits:子 → 父,离散事件通知
v-model:Props + Emits 的双向绑定约定
$attrs:未被组件消费的外部属性,默认向根节点透传
Slots:父提供渲染内容,子决定放置位置
这里的“契约”不是单纯的 TypeScript 类型。一个可靠的组件契约至少包含四层:
- 名称契约:属性名、事件名、
v-model参数名; - 值契约:类型、默认值、是否允许为空;
- 时序契约:什么时候发事件,是否可能重复发出;
- 结构契约:属性和事件最终落到哪个 DOM 节点。
例如,一个输入组件可能约定:
type Props = {
modelValue: string
disabled?: boolean
}
type Emits = {
'update:modelValue': [value: string]
blur: [event: FocusEvent]
}
这表示:
- 父组件通过
modelValue提供当前值; - 子组件不能把
modelValue当作自己的可变存储; - 子组件通过
update:modelValue请求父组件更新; blur事件可以把原生事件对象通知出去;- 没有声明的属性,例如
aria-label、name、data-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 可以在运行时检查 String、Number 等构造器 |
复杂类型需要额外表达,容易与 TS 类型重复 |
| 两者混用 | 可同时获得部分能力 | 容易出现两份声明不一致 |
TypeScript 只检查编译期代码。例如父组件中写:
<UserCard :user="123" />
如果 UserCard 的类型信息被正确导出,编辑器和 vue-tsc 通常可以报告错误;但如果数据来自接口、any 或 JavaScript,运行时仍可能收到错误值。因此:
TypeScript 检查 ≠ 运行时数据验证
生产环境若需要防御外部 JSON、插件输入或跨边界数据,应在进入组件之前进行运行时解析,例如使用项目选定的 schema 工具,或在 props 的运行时声明中提供基础校验。
2.4 默认值不是“空值兜底”的全部含义
以下几种值必须区分:
type Props = {
size?: number
}
- 属性缺失:
undefined; - 显式传入
undefined:通常会触发默认值; - 显式传入
null:不是缺失,不应自动等同于默认值; 0、false、'':都是有意义的显式值。
因此不能随意写:
const size = props.size || 12
因为 0 会被替换成 12。更准确的表达通常是:
const size = props.size ?? 12
?? 只在 null 或 undefined 时使用右侧默认值。
对于对象和数组默认值,运行时声明中应使用工厂函数:
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>
这里有两个关键事实:
input的当前值来自props.modelValue;- 用户输入后,子组件只发出更新事件,不直接改 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 不是魔法意义上的共享变量,而是:
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 之前版本的组件库时,显式声明 defineProps 和 defineEmits 更稳妥。
使用默认值时还要注意同步语义:
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 什么是透传属性
当父组件向子组件传入一个没有被 props 或 emits 声明的属性时,这个属性称为 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 class 与 style 的合并
透传并不是简单地覆盖所有属性。对于 class 和 style,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 属性都能正确表达业务语义。组件可能内部已经固定了 type、role、tabindex 或事件处理器,外部值是否允许覆盖应成为明确契约。
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>
autocomplete 和 aria-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-*;id、name、autocomplete;- 组件不需要解释、但内部目标元素需要接收的原生属性。
如果组件需要理解一个属性的业务含义,它通常应该升级为显式 prop,而不是继续藏在 $attrs 中。
六、一个完整的受控输入组件
下面实现一个可运行的 BaseInput.vue,包含:
v-model;label和errorprops;disabled传递;aria-*、id、name等 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
label、error、disabled 同样由组件逻辑消费,不会自动透传。
name、type、autocomplete 和 placeholder 未声明为 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 class 和 style 的归属可能有设计冲突
父组件写:
<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 是一种“最新请求获胜”的保护机制:
- 每次提交都产生递增 ID;
- 异步操作完成时检查自己的 ID;
- 不是最新请求的响应不能覆盖当前 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 父组件监听了事件,但回调没有触发
检查三层:
- 子组件是否真的执行了
emit; - 事件名是否一致;
- 事件是否被声明为
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:未声明的外部属性按边界规则透传
动态组件、KeepAlive、Teleport 和异步组件会改变组件的渲染位置、缓存生命周期或加载过程,但不会改变 props、emits 和 v-model 的基本契约。组件被缓存后,内部状态可能保留;组件被异步加载时,加载失败又会增加新的错误边界。这些能力不应被误认为会自动解决受控状态、事件命名或 attrs 去向问题。
十四、规范保证、实现行为与工程建议的区分
Vue 的规范性能力
以下是组件 API 的核心约定:
- props 从父组件流向子组件;
- props 不应由子组件重新赋值;
v-model默认对应modelValue和update: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能将输入值同步到父组件;name、type、autocomplete出现在内部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 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue Composition API:setup、生命周期、作用域和逻辑组织
- 下一篇:Vue Slots 与动态组件:内容分发、KeepAlive、Teleport 和异步组件
- 延伸:Vue 表单工程:受控输入、校验、异步提交、错误与可访问性
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论