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 API | Options API 对应 | 调用时机 |
|---|---|---|
| setup() | beforeCreate + created | 组件初始化(最早) |
| onBeforeMount | beforeMount | 挂载到 DOM 之前 |
| onMounted | mounted | 挂载完成,可访问 DOM |
| onBeforeUpdate | beforeUpdate | 响应式数据变化,DOM 更新前 |
| onUpdated | updated | DOM 更新完成 |
| onBeforeUnmount | beforeDestroy | 组件卸载前(清理副作用) |
| onUnmounted | destroyed | 组件卸载完成 |
| onErrorCaptured | errorCaptured | 捕获子组件错误 |
<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。