Chapter 03

组件设计:Props、Emits 与插槽

掌握 Vue 3 的组件通信模式,从 defineProps 类型定义到插槽系统,构建可复用的高质量组件

3.1 defineProps:类型安全的 Props

<script setup> 中,使用编译器宏 defineProps 声明组件接受的属性。Vue 3 支持基于 TypeScript 泛型的类型声明(推荐)或运行时声明两种方式。

<script setup lang="ts">
// 方式一:TypeScript 类型声明(编译时类型检查)
const props = defineProps<{
  title: string
  count?: number          // ? 表示可选
  items: string[]
  variant?: 'primary' | 'secondary' | 'danger'
}>()

// 使用 withDefaults 提供默认值
const props2 = withDefaults(defineProps<{
  title: string
  count?: number
  variant?: 'primary' | 'secondary'
}>(), {
  count: 0,
  variant: 'primary'
})
</script>
<script setup>
// 方式二:运行时声明(支持运行时警告)
const props = defineProps({
  title: String,                  // 简写
  count: { type: Number, default: 0 },
  items: { type: Array, required: true },
  variant: {
    type: String,
    validator(val) {              // 自定义验证
      return ['primary', 'secondary'].includes(val)
    }
  }
})
</script>
Props 是单向数据流

永远不要在子组件内部直接修改 props!Props 是从父组件向子组件单向传递的,修改会导致数据流混乱且 Vue 会发出警告。如需修改,应通过 emit 通知父组件更新,或将 prop 复制到本地 ref。

3.2 defineEmits:类型安全的事件

使用 defineEmits 声明组件可以触发的事件,提供完整的 TypeScript 类型支持:

<script setup lang="ts">
// Vue 3.3+ 推荐写法(命名元组语法)
const emit = defineEmits<{
  change: [value: string]          // 事件名: [参数类型]
  submit: [data: { name: string; email: string }]
  close: []                          // 无参数事件
  'update:count': [value: number]  // v-model 事件
}>()

function handleSubmit() {
  emit('submit', { name: 'Alice', email: 'a@b.com' })
}

function increment() {
  emit('update:count', props.count + 1)
}
</script>

3.3 v-model 双向绑定原理

v-model 是语法糖,它在父组件展开为 prop + 事件监听的组合。Vue 3 允许在同一组件上使用多个 v-model

<!-- 父组件 -->
<MyInput v-model="name" />
<!-- 等价于 -->
<MyInput :modelValue="name" @update:modelValue="name = $event" />

<!-- 多个 v-model(Vue 3 特性)-->
<UserForm
  v-model:name="userName"
  v-model:email="userEmail"
/>
<!-- MyInput 子组件 -->
<script setup lang="ts">
const props = defineProps<{
  modelValue: string   // v-model 默认 prop 名
}>()
const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()
</script>
<template>
  <input
    :value="modelValue"
    @input="emit('update:modelValue', $event.target.value)"
  />
</template>

Vue 3.4+ 的 defineModel()

Vue 3.4 引入了 defineModel() 编译器宏,大幅简化了 v-model 的实现:

<script setup lang="ts">
// defineModel 同时声明了 prop 和 emit,并返回可直接修改的 ref
const modelValue = defineModel<string>({ default: '' })

// 多个 v-model
const name  = defineModel<string>('name')
const email = defineModel<string>('email')
</script>
<template>
  <input v-model="modelValue" />  <!-- 直接双向绑定!-->
</template>

3.4 插槽系统

插槽(Slot)允许父组件向子组件传递模板内容,是构建灵活可复用组件的关键机制。

默认插槽与具名插槽

<!-- Card.vue —— 组件定义 -->
<template>
  <div class="card">
    <header class="card-header">
      <slot name="header">
        <h3>默认标题</h3>  <!-- 插槽默认内容 -->
      </slot>
    </header>
    <main>
      <slot />  <!-- 默认插槽 -->
    </main>
    <footer v-if="$slots.footer">  <!-- 条件渲染插槽 -->
      <slot name="footer" />
    </footer>
  </div>
</template>

<!-- 父组件使用 -->
<Card>
  <template #header>
    <h2>自定义标题 <span>徽章</span></h2>
  </template>

  <p>这是默认插槽内容</p>

  <template #footer>
    <button>确认</button>
  </template>
</Card>

作用域插槽:子传父数据

作用域插槽允许子组件向插槽传递数据,父组件可以使用这些数据来定制渲染内容:

<!-- DataTable.vue —— 组件定义 -->
<script setup lang="ts">
const props = defineProps<{ items: any[] }>()
</script>
<template>
  <table>
    <tr v-for="item in items" :key="item.id">
      <!-- 通过 v-bind 将 item 暴露给插槽 -->
      <slot name="row" :item="item" :index="index" />
    </tr>
  </table>
</template>

<!-- 父组件:通过 v-slot 接收数据 -->
<DataTable :items="users">
  <template #row="{ item, index }">
    <td>{{ index + 1 }}</td>
    <td>{{ item.name }}</td>
    <td><button @click="edit(item)">编辑</button></td>
  </template>
</DataTable>

3.5 组件生命周期钩子

在 Composition API 中,生命周期钩子以 on 开头的函数形式调用:

Composition APIOptions API 对应调用时机
setup()beforeCreate + created组件初始化(最早)
onBeforeMountbeforeMount挂载到 DOM 之前
onMountedmounted挂载完成,可访问 DOM
onBeforeUpdatebeforeUpdate响应式数据变化,DOM 更新前
onUpdatedupdatedDOM 更新完成
onBeforeUnmountbeforeDestroy组件卸载前(清理副作用)
onUnmounteddestroyed组件卸载完成
onErrorCapturederrorCaptured捕获子组件错误
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'

const scrollY = ref(0)

function handleScroll() {
  scrollY.value = window.scrollY
}

onMounted(() => {
  // 挂载后才能操作 DOM 和添加事件
  window.addEventListener('scroll', handleScroll)
  console.log('组件已挂载')
})

onUnmounted(() => {
  // 必须在卸载时移除事件,防止内存泄漏
  window.removeEventListener('scroll', handleScroll)
})
</script>

3.6 expose:控制对外暴露

<script setup> 中,组件默认不暴露任何内容给父组件的模板引用。使用 defineExpose 选择性暴露:

<!-- 子组件 MyModal.vue -->
<script setup>
import { ref } from 'vue'
const isOpen = ref(false)
function open()  { isOpen.value = true }
function close() { isOpen.value = false }

// 只暴露 open 和 close,隐藏 isOpen 内部状态
defineExpose({ open, close })
</script>

<!-- 父组件 -->
<script setup>
import { ref } from 'vue'
const modalRef = ref()
</script>
<template>
  <MyModal ref="modalRef" />
  <button @click="modalRef.open()">打开弹窗</button>
</template>
组件设计最佳实践

一个好的组件应该:保持单一职责、通过 props/emits 通信(避免直接操作父组件)、将可复用逻辑提取为 composables、使用插槽提高灵活性、通过 defineExpose 控制公共 API。