本篇定位:组件之间的数据传递是 Vue 应用开发的核心课题。本篇将深入讲解 Vue 3 中最基础也是最常用的通信方式——Props(父传子)和Emit(子传父),并探索如何通过
v-model在组件间实现双向绑定。读完本篇,你将能够熟练地在任何组件层级间传递数据和事件。
一、回顾 Props:从基础到进阶
在第 7 篇中,我们学习了 Props 的基本用法。本篇将深入 Props 的更多细节和高级特性。
1.1 Props 的运行时校验
在<script setup>中,除了 TypeScript 类型声明,我们还可以使用运行时声明来实现更丰富的校验:
<script setup lang="ts"> // 混合使用 TypeScript 类型和运行时校验 const props = defineProps({ // 基础类型检查 title: { type: String, required: true }, // 多种类型 size: { type: [String, Number], default: 'medium' }, // 自定义校验函数 status: { type: String, validator: (value: string) => { return ['pending', 'success', 'error', 'warning'].includes(value) }, default: 'pending' }, // 对象类型,默认值使用工厂函数 config: { type: Object, default: () => ({ theme: 'light', language: 'zh-CN' }) }, // 数组类型,默认值使用工厂函数 tags: { type: Array, default: () => [] } }) </script>关键规则:
type可以是String、Number、Boolean、Array、Object、Date、Function、Symbol或自定义构造函数default对于对象/数组必须使用工厂函数返回新实例,避免引用共享validator函数返回true表示通过,否则在开发环境下会发出警告required为true时,如果父组件未传递该 prop,会在控制台输出警告
1.2 Props 的类型推断与 IDE 支持
当使用 TypeScript 类型声明时,Vue 能够自动推导出 Props 的类型,并提供良好的 IDE 智能提示:
<script setup lang="ts"> // 使用接口定义 Props 类型 interface UserProps { id: number name: string email?: string role: 'admin' | 'editor' | 'viewer' metadata?: Record<string, any> } const props = defineProps<UserProps>() // 在 setup 中使用 props 时,有完整的类型提示 console.log(props.name) // string console.log(props.role) // 'admin' | 'editor' | 'viewer' console.log(props.metadata) // Record<string, any> | undefined </script>结合运行时声明的好处:
<script setup lang="ts"> interface Props { count?: number label?: string } // 保留 TypeScript 类型,同时提供默认值 const props = withDefaults(defineProps<Props>(), { count: 0, label: '默认标签' }) </script>1.3 只读 Props 与响应式转换
Props 是只读的,但有时我们需要将它们转换为可变的本地状态或计算属性。
转换为本地数据(使用 ref):
<script setup> import { ref, watch } from 'vue' const props = defineProps<{ initialValue: number }>() // 创建一个本地 ref,初始值为 prop const localValue = ref(props.initialValue) // 如果需要同步外部 prop 的变化,可以使用 watch watch(() => props.initialValue, (newVal) => { localValue.value = newVal }) </script>转换为计算属性:
<script setup> import { computed } from 'vue' const props = defineProps<{ firstName: string; lastName: string }>() // 基于 props 计算派生数据 const fullName = computed(() => `${props.firstName} ${props.lastName}`) // 可写的计算属性(配合 emit) const editableName = computed({ get: () => props.firstName, set: (val) => emit('update:firstName', val) }) </script>1.4 Props 的命名与传递最佳实践
① 保持 Prop 名称与变量一致
<!-- 好:清晰的命名 --> <BlogPost :title="post.title" :author="post.author" /> <!-- 坏:过于简略 --> <BlogPost :t="post.title" :a="post.author" />② 使用 kebab-case 传递,camelCase 接收
<!-- 父组件 --> <ChildComponent :user-name="name" :user-age="age" /> <!-- 子组件 --> <script setup> defineProps<{ userName: string // camelCase userAge: number }>() </script>③ 传递对象时,使用展开运算符
<script setup> const post = { title: 'Vue 3 指南', author: 'Evan', date: '2024-01-01' } </script> <template> <BlogPost v-bind="post" /> <!-- 等价于 --> <BlogPost :title="post.title" :author="post.author" :date="post.date" /> </template>1.5 Props 的深层响应式
当传递对象或数组时,子组件能够响应式地跟随父组件中该对象的属性变化:
<!-- 父组件 --> <script setup> import { reactive } from 'vue' const user = reactive({ name: '张三', age: 25 }) function updateUser() { user.age = 30 // 子组件的 props 也会更新 } </script> <template> <Child :user="user" /> </template> <!-- 子组件 --> <script setup> const props = defineProps<{ user: { name: string; age: number } }>() // props.user.age 会随着父组件改变而改变 </script>注意:虽然子组件能响应变化,但不能直接修改 props(单向数据流)。
二、Emit:子传父的事件机制
Emit(事件触发)是子组件向父组件发送消息的标准方式。父组件通过监听子组件触发的事件来响应。
2.1 Emit 的基本用法
<!-- 子组件:Child.vue --> <script setup lang="ts"> // 定义可触发的事件 const emit = defineEmits<{ (e: 'update', value: string): void (e: 'delete', id: number): void }>() function handleUpdate() { // 触发事件,可传递任意数量的参数 emit('update', '新的数据') } function handleDelete() { emit('delete', 123) } </script> <template> <button @click="handleUpdate">更新</button> <button @click="handleDelete">删除</button> </template><!-- 父组件:Parent.vue --> <script setup> import Child from './Child.vue' import { ref } from 'vue' const data = ref('') function onUpdate(value: string) { data.value = value console.log('收到更新:', value) } function onDelete(id: number) { console.log('删除 id:', id) } </script> <template> <Child @update="onUpdate" @delete="onDelete" /> <p>当前数据:{{ data }}</p> </template>核心特征:
- 使用
defineEmits声明事件,可以带类型(TypeScript) - 通过
emit('eventName', ...args)触发事件 - 父组件使用
@eventName="handler"监听 - 事件名建议使用kebab-case(如
@update-value)或camelCase(如@updateValue),Vue 会自动转换
2.2 Emit 的类型定义
在 TypeScript 中,defineEmits支持多种类型定义方式:
① 函数签名方式(推荐):
<script setup lang="ts"> const emit = defineEmits<{ (e: 'change', value: string): void (e: 'submit', data: { id: number; name: string }): void (e: 'close'): void }>() </script>② 数组方式(无类型):
<script setup> // 简单声明,无类型推断 const emit = defineEmits(['change', 'submit', 'close']) </script>③ 运行时声明 + 类型推断:
<script setup lang="ts"> const emit = defineEmits({ change: (value: string) => { // 校验参数(可选) if (typeof value !== 'string') { console.warn('change 事件的参数必须是字符串') return false } return true }, submit: (data: { id: number; name: string }) => { return true }, close: null }) </script>最佳实践:使用 TypeScript 函数签名方式,既能获得类型安全,又能有良好的 IDE 提示。
2.3 Emit 的事件验证
与 Props 的validator类似,Emit 也支持在运行时对事件参数进行验证:
<script setup> const emit = defineEmits({ // 无验证 click: null, // 验证参数 'update-value': (value: string) => { if (value.length < 3) { console.warn('update-value 的参数长度至少为 3') return false } return true }, 'submit-form': (data: { name: string; age: number }) => { if (!data.name || data.age < 0) { console.warn('submit-form 参数不合法') return false } return true } }) </script>如果验证返回false,Vue 会在控制台发出警告(仅在开发环境),但事件仍会触发。验证主要用于开发时帮助调试。
2.4 Emit 与 v-model 的结合
v-model在组件上的本质是modelValueprop +update:modelValueevent:
<!-- 父组件 --> <CustomInput v-model="text" /> <!-- 等价于 --> <CustomInput :modelValue="text" @update:modelValue="text = $event" /><!-- 子组件:CustomInput.vue --> <script setup lang="ts"> defineProps<{ modelValue: string }>() const emit = defineEmits<{ (e: 'update:modelValue', value: string): void }>() // 输入时触发更新 function onInput(event: Event) { const target = event.target as HTMLInputElement emit('update:modelValue', target.value) } </script> <template> <input :value="modelValue" @input="onInput" /> </template>2.5 多个 v-model 绑定(Vue 3.4+)
Vue 3.4 支持为v-model指定名称,实现多个双向绑定:
<!-- 父组件 --> <script setup> import { ref } from 'vue' const firstName = ref('张') const lastName = ref('三') </script> <template> <UserForm v-model:first-name="firstName" v-model:last-name="lastName" /> </template><!-- 子组件:UserForm.vue --> <script setup lang="ts"> defineProps<{ firstName: string lastName: string }>() const emit = defineEmits<{ (e: 'update:firstName', value: string): void (e: 'update:lastName', value: string): void }>() function updateFirstName(event: Event) { const target = event.target as HTMLInputElement emit('update:firstName', target.value) } function updateLastName(event: Event) { const target = event.target as HTMLInputElement emit('update:lastName', target.value) } </script> <template> <div> <input :value="firstName" @input="updateFirstName" placeholder="姓" /> <input :value="lastName" @input="updateLastName" placeholder="名" /> </div> </template>2.6 使用 .sync 修饰符(Vue 3 中已弃用)
在 Vue 2 中,.sync修饰符用于实现双向绑定。Vue 3 中,.sync已被移除,统一使用v-model:propName代替。
<!-- Vue 2 写法(已废弃) --> <Child :title.sync="pageTitle" /> <!-- Vue 3 写法 --> <Child v-model:title="pageTitle" /> <!-- 子组件中 --> <script setup> defineProps<{ title: string }>() const emit = defineEmits<{ (e: 'update:title', value: string): void }>() </script>2.7 Emit 的事件命名规范
| 推荐 | 不推荐 | 说明 |
|---|---|---|
@update:model-value | @updateModelValue | 使用 kebab-case 更符合 HTML 属性 |
@close | @closeModal | 保持简洁,无需重复组件名 |
@item-click | @click | 避免与原生事件混淆 |
在 JavaScript 中定义事件名时,可以使用 camelCase 或 kebab-case,Vue 会自动转换:
<script setup> // 两种写法都可 const emit = defineEmits(['updateModelValue', 'closeModal']) </script> <template> <!-- 模板中统一使用 kebab-case --> <Child @update-model-value="handler" @close-modal="handler" /> </template>三、组件通信的标准模式
3.1 父子组件通信完整流程
父组件数据 → (Props) → 子组件展示 子组件事件 → (Emit) → 父组件处理步骤拆解:
- 父传子:父组件通过
:propName="data"传递数据 - 子展示:子组件通过
defineProps接收并使用 - 子发事件:子组件在适当时机调用
emit('eventName', payload) - 父处理:父组件通过
@eventName="handler"监听并处理
3.2 完整的计数组件示例
<!-- Counter.vue - 子组件 --> <script setup lang="ts"> interface Props { initialValue?: number step?: number min?: number max?: number } const props = withDefaults(defineProps<Props>(), { initialValue: 0, step: 1, min: -Infinity, max: Infinity }) // 本地状态(使用 props 初始化) import { ref, computed } from 'vue' const count = ref(props.initialValue) // 计算属性:是否达到边界 const canDecrement = computed(() => count.value > props.min) const canIncrement = computed(() => count.value < props.max) // 定义事件 const emit = defineEmits<{ (e: 'update:modelValue', value: number): void (e: 'change', value: number): void (e: 'reach-limit', direction: 'min' | 'max'): void }>() // 方法 function increment() { if (!canIncrement.value) { emit('reach-limit', 'max') return } count.value += props.step emit('update:modelValue', count.value) emit('change', count.value) } function decrement() { if (!canDecrement.value) { emit('reach-limit', 'min') return } count.value -= props.step emit('update:modelValue', count.value) emit('change', count.value) } function reset() { count.value = props.initialValue emit('update:modelValue', count.value) emit('change', count.value) } </script> <template> <div class="counter"> <button @click="decrement" :disabled="!canDecrement">-</button> <span>{{ count }}</span> <button @click="increment" :disabled="!canIncrement">+</button> <button @click="reset">重置</button> </div> </template> <style scoped> .counter { display: inline-flex; align-items: center; gap: 12px; } .counter button { width: 32px; height: 32px; border-radius: 6px; border: 1px solid #ccc; background: #f5f5f5; cursor: pointer; font-size: 16px; } .counter button:disabled { opacity: 0.4; cursor: not-allowed; } .counter span { min-width: 30px; text-align: center; font-size: 18px; font-weight: bold; } </style>父组件使用:
<script setup> import Counter from './Counter.vue' import { ref } from 'vue' const value = ref(0) function onCounterChange(newValue: number) { console.log('计数器变化:', newValue) } function onReachLimit(direction: 'min' | 'max') { alert(`已达${direction === 'min' ? '最小' : '最大'}值`) } </script> <template> <div> <Counter v-model="value" :step="2" :min="0" :max="10" @change="onCounterChange" @reach-limit="onReachLimit" /> <p>当前值:{{ value }}</p> </div> </template>3.3 传递事件的常见模式
① 透传事件:子组件直接转发原生事件
<script setup> const emit = defineEmits(['click', 'focus']) </script> <template> <button @click="emit('click', $event)" @focus="emit('focus', $event)"> <slot /> </button> </template>② 包装事件:子组件附加额外逻辑后触发
<script setup> const emit = defineEmits(['submit']) function handleSubmit(event: Event) { // 阻止默认行为 event.preventDefault() // 收集表单数据 const formData = new FormData(event.target as HTMLFormElement) // 触发自定义事件 emit('submit', { data: formData, timestamp: Date.now() }) } </script> <template> <form @submit="handleSubmit"> <slot /> </form> </template>③ 条件触发事件:仅在满足条件时触发
<script setup> const emit = defineEmits(['action']) function handleAction() { if (isValid()) { emit('action', { success: true }) } else { emit('action', { success: false, error: '验证失败' }) } } </script>四、Props 与 Emit 的协作——双向绑定模式
除了v-model,我们还可以使用props + emit手动实现双向绑定:
<!-- 父组件 --> <script setup> import { ref } from 'vue' const text = ref('Hello') function updateText(value: string) { text.value = value } </script> <template> <Child :value="text" @update:value="updateText" /> </template><!-- 子组件 --> <script setup> defineProps<{ value: string }>() const emit = defineEmits<{ (e: 'update:value', value: string): void }>() </script> <template> <input :value="value" @input="emit('update:value', $event.target.value)" /> </template>这与v-model的效果完全相同。实际上,v-model就是这种模式的语法糖。
手动实现的优势:
- 更清晰地表达意图(
@update:value明确表示更新) - 可以在事件处理中做额外操作
使用v-model的优势:
- 代码更简洁
- 符合 Vue 官方推荐风格
- 支持多个双向绑定(
v-model:xxx)
五、常见陷阱与注意事项
5.1 直接修改 Props 会触发警告
<script setup> const props = defineProps<{ count: number }>() function increment() { props.count++ // ❌ 警告:不能修改 props } </script>正确做法:使用emit通知父组件修改。
5.2 将 Props 赋值给本地变量会失去响应式
<script setup> const props = defineProps<{ count: number }>() // ❌ 错误:localCount 只是初始值,后续 props 更新不会反映 let localCount = props.count </script>正确做法:使用ref+watch或computed。
5.3 对象/数组的默认值必须使用工厂函数
<script setup> // ❌ 错误:所有实例共享同一个数组引用 defineProps({ tags: { type: Array, default: ['默认标签'] // ❌ 引用共享 } }) // ✅ 正确:使用工厂函数 defineProps({ tags: { type: Array, default: () => ['默认标签'] } }) </script>5.4 在 v-for 中传递 Props 需要唯一标识
<template> <!-- 确保 key 唯一,避免渲染错误 --> <Child v-for="item in items" :key="item.id" :item="item" @update="handleUpdate" /> </template>5.5 Emit 事件名与原生事件名冲突
<script setup> // ❌ 避免使用 'click'、'change' 等原生事件名 const emit = defineEmits(['click', 'change']) // 可能与原生事件混淆 </script> <template> <button @click="emit('click')">点击</button> <!-- 同时触发原生和自定义事件 --> </template>解决方案:使用更具体的名称,如@btn-click、@item-change。
六、综合实战:商品购物车组件
让我们综合运用 Props 和 Emit,构建一个商品购物车交互组件:
<!-- CartItem.vue - 购物车项组件 --> <script setup lang="ts"> interface Product { id: number name: string price: number image?: string } interface Props { product: Product quantity: number maxQuantity?: number readonly?: boolean } const props = withDefaults(defineProps<Props>(), { maxQuantity: 99, readonly: false }) const emit = defineEmits<{ (e: 'update:quantity', id: number, quantity: number): void (e: 'remove', id: number): void }>() function increase() { if (props.quantity < props.maxQuantity) { emit('update:quantity', props.product.id, props.quantity + 1) } } function decrease() { if (props.quantity > 1) { emit('update:quantity', props.product.id, props.quantity - 1) } } function removeItem() { emit('remove', props.product.id) } // 计算总价 const totalPrice = computed(() => (props.product.price * props.quantity).toFixed(2) ) </script> <template> <div class="cart-item" :class="{ readonly }"> <div class="item-info"> <span class="item-name">{{ product.name }}</span> <span class="item-price">¥{{ product.price.toFixed(2) }}</span> </div> <div class="item-controls"> <template v-if="!readonly"> <button @click="decrease" :disabled="quantity <= 1">−</button> <span class="quantity">{{ quantity }}</span> <button @click="increase" :disabled="quantity >= maxQuantity">+</button> <button @click="removeItem" class="remove-btn">移除</button> </template> <template v-else> <span class="quantity">× {{ quantity }}</span> <span class="total">¥{{ totalPrice }}</span> </template> </div> </div> </template> <style scoped> .cart-item { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid #e2e8f0; background: white; } .item-info { display: flex; gap: 16px; } .item-name { font-weight: 500; } .item-price { color: #2d3748; } .item-controls { display: flex; align-items: center; gap: 8px; } .item-controls button { width: 28px; height: 28px; border: 1px solid #cbd5e0; background: #f7fafc; border-radius: 4px; cursor: pointer; font-size: 16px; } .item-controls button:disabled { opacity: 0.4; cursor: not-allowed; } .quantity { min-width: 24px; text-align: center; font-weight: 500; } .remove-btn { background: #fed7d7 !important; border-color: #feb2b2 !important; color: #c53030; width: auto !important; padding: 0 12px; font-size: 13px !important; } .readonly .item-controls { color: #718096; } .total { font-weight: 600; color: #2b6cb0; } </style><!-- 父组件:购物车页面 --> <script setup> import { ref, computed } from 'vue' import CartItem from './CartItem.vue' // 购物车数据 const cartItems = ref([ { id: 1, name: 'Vue 3 完全指南(书籍)', price: 99.00, quantity: 1 }, { id: 2, name: '机械键盘(青轴)', price: 399.00, quantity: 2 }, { id: 3, name: '4K 显示器', price: 2999.00, quantity: 1 } ]) // 更新数量 function updateQuantity(id: number, quantity: number) { const item = cartItems.value.find(item => item.id === id) if (item) { item.quantity = quantity } } // 移除商品 function removeItem(id: number) { cartItems.value = cartItems.value.filter(item => item.id !== id) } // 计算总价 const total = computed(() => { return cartItems.value.reduce((sum, item) => sum + item.price * item.quantity, 0 ).toFixed(2) }) // 清空购物车(触发父级事件,可自定义) function clearCart() { if (confirm('确定要清空购物车吗?')) { cartItems.value = [] } } </script> <template> <div class="cart-page"> <h2>🛒 购物车</h2> <p v-if="cartItems.length === 0" class="empty">购物车是空的</p> <template v-else> <CartItem v-for="item in cartItems" :key="item.id" :product="item" :quantity="item.quantity" :max-quantity="10" @update:quantity="updateQuantity" @remove="removeItem" /> <div class="cart-summary"> <span>总计:</span> <span class="total-price">¥{{ total }}</span> <button @click="clearCart" class="clear-btn">清空</button> </div> </template> </div> </template> <style scoped> .cart-page { max-width: 600px; margin: 20px auto; padding: 20px; background: #f7fafc; border-radius: 12px; } .empty { text-align: center; color: #a0aec0; padding: 40px 0; } .cart-summary { display: flex; justify-content: flex-end; align-items: center; gap: 20px; padding: 16px 0; margin-top: 12px; border-top: 2px solid #e2e8f0; font-size: 18px; font-weight: 600; } .total-price { color: #2b6cb0; font-size: 22px; } .clear-btn { background: #fc8181; color: white; border: none; padding: 8px 20px; border-radius: 6px; cursor: pointer; font-weight: 500; } .clear-btn:hover { background: #f56565; } </style>七、本篇小结
| 知识点 | 核心内容 | 关键要点 |
|---|---|---|
| Props 深入 | 运行时校验、默认值、validator | 对象/数组默认值用工厂函数 |
| Props 类型 | TypeScript 类型声明 + 运行时声明 | withDefaults提供默认值 |
| 单向数据流 | Props 只读,子组件不修改 | 通过 Emit 请求父组件修改 |
| Emit 定义 | defineEmits<{ (e: ...): ... }>() | 支持类型安全 |
| Emit 触发 | emit('event', payload) | 可传递多个参数 |
| v-model 组件 | modelValue+update:modelValue | 支持多个v-model:xxx |
| 事件验证 | 运行时验证函数 | 开发环境警告 |
| 命名规范 | Props 用 camelCase,事件用 kebab-case | 保持一致性 |
八、思考与练习
基础练习:修改计数组件,增加一个
stepprop,并添加一个@change事件,在值变化时返回当前值和旧值。实践任务:创建一个
TodoItem组件,接收todo对象(包含 id、text、completed),支持切换完成状态(触发@toggle)和删除(触发@delete)。陷阱排查:分析以下代码的问题:
<!-- 子组件 --> <script setup> const props = defineProps<{ list: string[] }>() function addItem() { props.list.push('new') // 尝试修改 props } </script>深度思考:为什么 Vue 要区分 Props 和 Emit,而不是直接允许双向绑定?这种设计对大型应用的维护有什么好处?
扩展任务:在购物车示例中,增加一个
@total-change事件,在购物车总价变化时触发,并传递新的总价。
下篇预告
第 9 篇我们将继续深入组件通信的其他方式,包括:
- Provide / Inject 跨层级通信
- 事件总线(mitt)的使用
- 组合式函数中的通信模式
- 组件通信的最佳实践与架构建议
准备好掌握更强大的组件间协作能力!📡