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 解构会让 usercompacttags 在父组件更新后继续更新。使用解构默认值时,数组和对象不需要包装成工厂函数。

如果保留整个 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、实例互调和全局事件总线造成的隐式耦合。

官方资料

系列导航:目录 · 上一篇 · 下一篇