news 2026/10/22 12:44:43

Vue3——第 8 篇:组件通信全攻略(上)——Props 与 Emit

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Vue3——第 8 篇:组件通信全攻略(上)——Props 与 Emit

本篇定位:组件之间的数据传递是 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) → 父组件处理

步骤拆解:

  1. 父传子:父组件通过:propName="data"传递数据
  2. 子展示:子组件通过defineProps接收并使用
  3. 子发事件:子组件在适当时机调用emit('eventName', payload)
  4. 父处理:父组件通过@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保持一致性

八、思考与练习

  1. 基础练习:修改计数组件,增加一个stepprop,并添加一个@change事件,在值变化时返回当前值和旧值。

  2. 实践任务:创建一个TodoItem组件,接收todo对象(包含 id、text、completed),支持切换完成状态(触发@toggle)和删除(触发@delete)。

  3. 陷阱排查:分析以下代码的问题:

    <!-- 子组件 --> <script setup> const props = defineProps<{ list: string[] }>() function addItem() { props.list.push('new') // 尝试修改 props } </script>
  4. 深度思考:为什么 Vue 要区分 Props 和 Emit,而不是直接允许双向绑定?这种设计对大型应用的维护有什么好处?

  5. 扩展任务:在购物车示例中,增加一个@total-change事件,在购物车总价变化时触发,并传递新的总价。

下篇预告

第 9 篇我们将继续深入组件通信的其他方式,包括:

  • Provide / Inject 跨层级通信
  • 事件总线(mitt)的使用
  • 组合式函数中的通信模式
  • 组件通信的最佳实践与架构建议

准备好掌握更强大的组件间协作能力!📡

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 12:40:10

【机械制图与CAD实战(十八)】组合体的尺寸标注

1、组合体的尺寸分类2、尺寸基准1、尺寸基准&#xff1a;标注尺寸的起点。2、在三维空间中应有长、宽、高三个方向的尺寸基准。3、一般把组合体的重要端面、对称面、轴线作为尺寸基准。1、组合体的尺寸分类1&#xff09;定形尺寸&#xff1a;确定图形中各几何元素&#xff08;各…

作者头像 李华
网站建设 2026/10/22 12:20:51

PJ85718DM+STM32F100ZE双通道测温在暖通控制中的应用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华