Vue 3 组件契约:Props、Emits、defineModel 与插槽
Vue 3 组件契约:Props、Emits、defineModel 与插槽
系列导航:Vue 3 现代开发指南
上一篇:派生状态与副作用
下一篇:跨层通信与模板引用
组件通信首先是 API 设计。父组件通过 Props 提供输入,子组件通过 Emits 报告事件,v-model 表达受控的双向绑定,插槽把一部分渲染权交还给使用者。把这四种契约设计清楚,通常不需要事件总线或直接操作组件实例。
一、Props 是只读输入
使用类型声明 Props:
<script setup lang="ts">
interface User {
id: number
name: string
}
interface Props {
user: User
compact?: boolean
tags?: string[]
}
const {
user,
compact = false,
tags = [],
} = defineProps<Props>()
</script>
Vue 3.5 的响应式 Props 解构会让 user、compact 和 tags 在父组件更新后继续更新。使用解构默认值时,数组和对象不需要包装成工厂函数。
如果保留整个 Props 对象,也可以继续使用 withDefaults():
const props = withDefaults(defineProps<Props>(), {
compact: false,
tags: () => [],
})
此时可变默认值需要工厂函数,确保每个组件实例得到独立对象。
Props 是单向数据流,子组件不应直接修改:
// 不要这样做
// props.compact = true
对象 Prop 的嵌套属性在 JavaScript 层面仍可能被修改,但这会让数据所有权变得模糊。需要修改时,应发事件让父组件处理,或者在子组件中创建明确的本地副本。
二、用类型安全的 Emits 报告事件
子组件声明自己会发出的事件及参数:
<script setup lang="ts">
interface Draft {
title: string
content: string
}
const emit = defineEmits<{
save: [draft: Draft]
cancel: []
}>()
function submit(draft: Draft) {
emit('save', draft)
}
</script>
父组件监听:
<EditorForm
@save="saveDraft"
@cancel="closeEditor"
/>
模板中的事件名推荐 kebab-case,例如 @update-profile。声明和脚本调用通常使用对应字符串:emit('update-profile', payload)。
不要把回调函数当成普通 Prop 来模拟子传父。Emits 能明确事件契约,也能避免回调被误当作透传属性。
三、Vue 3.4+ 使用 defineModel()
从 Vue 3.4 起,官方推荐通过 defineModel() 实现组件 v-model。
子组件 TextInput.vue:
<script setup lang="ts">
const model = defineModel<string>({ required: true })
</script>
<template>
<input v-model="model" type="text" />
</template>
父组件:
<script setup lang="ts">
import { ref } from 'vue'
import TextInput from './TextInput.vue'
const title = ref('')
</script>
<template>
<TextInput v-model="title" />
</template>
defineModel() 返回一个 ref:父组件传入的值变化时它会更新,子组件修改它时会通知父组件。
底层仍然是一个 modelValue Prop 和一个 update:modelValue 事件:
<TextInput
:model-value="title"
@update:model-value="title = $event"
/>
理解底层协议有助于维护 Vue 3.3 及更早代码,但新代码不必手写这组样板。
四、命名模型与多个 v-model
组件可以暴露多个独立模型:
<!-- UserName.vue -->
<script setup lang="ts">
const firstName = defineModel<string>('firstName', { required: true })
const lastName = defineModel<string>('lastName', { required: true })
</script>
<template>
<input v-model="firstName" aria-label="名" />
<input v-model="lastName" aria-label="姓" />
</template>
<UserName
v-model:first-name="firstName"
v-model:last-name="lastName"
/>
不要随意给 defineModel() 设置默认值。如果父组件没有传值,子组件默认值可能与父组件中的 undefined 不同步。对于真正受控的输入,优先使用 { required: true } 或在父组件初始化状态。
五、默认插槽和具名插槽
默认插槽用于传入主体内容:
<!-- PanelCard.vue -->
<script setup lang="ts">
defineProps<{ title: string }>()
</script>
<template>
<section class="panel-card">
<h2>{{ title }}</h2>
<slot />
</section>
</template>
<PanelCard title="账户信息">
<p>这里由父组件决定。</p>
</PanelCard>
具名插槽适合组件有多个渲染区域:
<!-- ModalDialog.vue -->
<template>
<section role="dialog" aria-modal="true">
<header><slot name="header" /></header>
<div><slot /></div>
<footer><slot name="footer" /></footer>
</section>
</template>
<ModalDialog>
<template #header>删除文章</template>
<p>该操作无法撤销。</p>
<template #footer>
<button type="button">取消</button>
<button type="button">确认删除</button>
</template>
</ModalDialog>
六、作用域插槽把数据交给渲染者
当数据和行为属于子组件,但渲染结构应由父组件决定时,使用作用域插槽:
<!-- DataList.vue -->
<script setup lang="ts" generic="T extends { id: string | number }">
defineProps<{ items: T[] }>()
defineSlots<{
default(props: { item: T; index: number }): unknown
empty(): unknown
}>()
</script>
<template>
<ul v-if="items.length">
<li v-for="(item, index) in items" :key="item.id">
<slot :item="item" :index="index" />
</li>
</ul>
<slot v-else name="empty" />
</template>
使用组件:
<DataList :items="users">
<template #default="{ item, index }">
{{ index + 1 }}. {{ item.name }}
</template>
<template #empty>暂无用户</template>
</DataList>
defineSlots() 主要提供 IDE 和类型检查能力,不改变运行时插槽行为。
七、$attrs 与属性透传
未被 Props 或 Emits 消费的属性和监听器会进入 $attrs。单根组件默认会把它们透传到根元素:
<BaseButton class="primary" aria-label="保存" @click="save" />
如果组件有多个根节点,或要把属性传给特定元素,应显式处理:
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
</script>
<template>
<label>
<span><slot name="label" /></span>
<input v-bind="$attrs" />
</label>
</template>
$attrs 适合属性转发,不适合构造隐蔽的祖孙状态通道。跨层共享依赖应使用 provide/inject 或 Pinia。
八、如何选择组件契约
| 需求 | 首选方式 |
|---|---|
| 父组件传只读数据给子组件 | Props |
| 子组件通知父组件发生了动作 | Emits |
| 父子共同控制一个表单值 | defineModel() / v-model |
| 父组件决定一块 UI 如何渲染 | 插槽 |
| 透传 HTML 属性和监听器 | $attrs |
| 跨多层提供依赖 | provide/inject |
| 跨页面共享业务状态 | Pinia |
小结
把组件看作一个有类型的公开 API:Props 是输入,Emits 是输出,defineModel 是明确的双向绑定,插槽是渲染扩展点。优先使用这些显式契约,可以避免回调 Prop、实例互调和全局事件总线造成的隐式耦合。