Vue.js: 组件通信:props/emits
最后更新:2026-08-26
组件通信是 Vue 的核心机制——父组件用 props 传数据给子组件,子组件用 emits 触发事件通知父组件。理解双向数据流是写好 Vue 应用的关键。
Vue 3 的 <script setup> 提供了 defineProps 和 defineEmits 两个编译时宏,无需导入即可使用,且能完美支持 TypeScript 类型推断。
1. 你将学到
- props 完整语法:类型、必填、默认值、验证器
- 多种 props 声明方式(运行时 / 类型 / 简化)
- emits 事件声明和验证
- TypeScript 类型推断(
defineProps<T>()) - props 透传(inheritAttrs / useAttrs)
- 单向数据流原则
- 5 个常见错误和最佳实践
2. 一个电商购物车的"双向绑定"陷阱
(1) 痛点:子组件改 prop,父组件报错
Alice 实现了一个带数量输入的购物车:
VUE
<!-- CartItem.vue 子组件 -->
<template>
<input :value="quantity" @input="quantity = $event.target.value">
</template>
<script setup>
// ❌ 翻车版:直接修改 prop
const props = defineProps({ quantity: Number })
function update() {
props.quantity++ // ❌ Vue 警告:避免直接修改 prop
}
</script>
Vue 报警告:
[Vue warn] Set operation on key "quantity" failed: target is readonly.
Charlie 调试 1 小时后发现:
"Alice,我改数量时父组件的购物车总价没更新。Vue 在修改 prop,但父组件不知道。"
(2) Vue 单向数据流 + emit 解法
VUE
<!-- CartItem.vue 子组件 -->
<template>
<div>
<input :value="quantity" @input="$emit('update:quantity', +$event.target.value)">
</div>
</template>
<script setup>
const props = defineProps({ quantity: Number })
const emit = defineEmits(['update:quantity'])
</script>
VUE
<!-- Cart.vue 父组件 -->
<template>
<CartItem
v-for="item in items"
:key="item.id"
:quantity="item.quantity"
@update:quantity="(val) => item.quantity = val"
/>
</template>
现在数据流正确了:子组件 emit → 父组件 receive → 父组件更新 data → 重新传给子组件。
(3) 收益
修复数据流后:
- Vue 警告:100 次/天 → 0
- 数据一致性:父组件实时同步
- 可调试性:数据流单向,便于追踪
- 可测试性:组件可独立测试 props/emit
3. props 完整语法
(1) 7 种 props 类型声明
VUE
<script setup>
defineProps({
// 1. 基础类型
name: String,
age: Number,
active: Boolean,
// 2. 复杂类型
user: Object,
items: Array,
// 3. 多类型(任一)
id: [String, Number],
// 4. 必填
title: { type: String, required: true },
// 5. 默认值
pageSize: { type: Number, default: 20 },
// 6. 自定义验证
email: {
type: String,
validator: (val) => val.includes('@')
},
// 7. 函数默认值(默认值需要是函数)|
createdAt: {
type: Date,
default: () => new Date()
}
})
</script>
(2) 详细示例
VUE
<script setup>
defineProps({
// 字符串
title: String,
// 数字 + 默认值 + 验证
pageSize: {
type: Number,
default: 20,
validator: (val) => val > 0 && val <= 100
},
// 布尔(注意:默认值的 falsy 值要用函数形式)
isVip: {
type: Boolean,
default: false
},
// 数组默认值(必须用函数返回)
tags: {
type: Array,
default: () => ['vue', 'javascript']
},
// 对象默认值(必须用函数返回)
user: {
type: Object,
default: () => ({ name: 'Guest', age: 0 })
},
// 必填 + 自定义验证
email: {
type: String,
required: true,
validator: (val) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)
}
})
</script>
(3) 6 大默认值规则
| 规则 | 示例 |
|---|---|
| 基本类型 | default: 20 |
| 数组/对象 | default: () => [] |
| null | default: null |
| undefined | 不用写 default |
| 函数引用 | 不用 default: fn,用 () => fn() |
| Symbol / BigInt | 工厂函数返回 |
4. TypeScript 强类型 props(Vue 3 推荐)
(1) 基础类型声明
VUE
<script setup lang="ts">
interface User {
id: number
name: string
email: string
role: 'admin' | 'user'
}
defineProps<{
user: User
size?: 'small' | 'medium' | 'large'
showEmail?: boolean
}>()
</script>
(2) 必填 vs 可选(?)
TS
defineProps<{
// 必填(默认)
user: User
pageSize: number
// 可选(加 ?)
variant?: 'primary' | 'secondary'
showIcon?: boolean
}>()
(3) withDefaults 提供默认值
VUE
<script setup lang="ts">
interface Props {
user: User
size?: 'small' | 'medium' | 'large'
showEmail?: boolean
}
const props = withDefaults(defineProps<Props>(), {
size: 'medium',
showEmail: true
})
</script>
(4) 运行时声明 vs 类型声明对比
| 维度 | 运行时 defineProps({}) |
类型 defineProps<T>() |
|---|---|---|
| 类型推断 | 弱 | 强(IDE 自动补全) |
| 默认值 | 直接 default | withDefaults 包装 |
| 验证 | validator 函数 | TypeScript 编译时检查 |
| 推荐 | 小项目/纯 JS | 大项目/TS 项目 |
5. emit 完整语法
(1) 5 种事件声明
VUE
<script setup>
// 1. 简单声明
const emit = defineEmits(['click', 'submit', 'cancel'])
// 2. 带参数验证
const emit = defineEmits({
addToCart: (productId) => typeof productId === 'number',
remove: (id) => typeof id === 'string'
})
// 3. TypeScript 类型
const emit = defineEmits<{
'add-to-cart': [productId: number]
'remove-item': [id: string, reason: string]
'update': [id: number, data: object]
}>()
</script>
(2) 5 种触发方式
VUE
<script setup>
const emit = defineEmits(['click', 'submit', 'add-to-cart'])
// 1. 简单触发
emit('click')
// 2. 传参
emit('add-to-cart', 123)
// 3. 传多个参数
emit('submit', { name: 'Alice' }, 2026)
// 4. 条件触发
if (isValid) emit('submit')
// 5. 函数式触发
function handleAdd() {
emit('add-to-cart', props.product.id)
}
</script>
(3) 父组件监听
VUE
<template>
<ChildComponent
@click="handleClick"
@add-to-cart="handleAdd"
@submit="handleSubmit"
/>
</template>
<script setup>
function handleClick() { /* 无参数 */ }
function handleAdd(productId) { /* 1 个参数 */ }
function handleSubmit(data, year) { /* 多个参数 */ }
</script>
(4) emit 类型验证
VUE
<script setup>
const emit = defineEmits({
// ✅ 验证:id 必须是 number
remove: (id) => typeof id === 'number' || 'Validation failed',
// ❌ 抛错:参数不匹配时
// remove: 'invalid', // 错:必须是函数
})
</script>
6. 单向数据流原则
(1) 核心原则
graph LR
A[父组件 data] -->|props| B[子组件]
B -->|emit| A
style A fill:#42b883
style B fill:#42b883
- 数据只能从父流向子(props 向下)
- 事件只能从子流向父(emit 向上)
- 子组件不能直接修改 props
(2) 5 个例外情况
VUE
<script setup>
const props = defineProps({ user: Object })
// ❌ 错:直接修改 prop
props.user.name = 'Bob' // Vue 警告!
// ✅ 例外 1:prop 是引用类型时,可以用新对象替换
// 父组件传的是引用,子组件不能改引用,但可以 emit 让父改
// ✅ 例外 2:prop 初始值可以保存到本地
const localUser = ref({ ...props.user })
// 然后修改 localUser(不影响父)
// ✅ 例外 3:用 computed 派生
const userName = computed(() => props.user.name)
// ✅ 例外 4:用 v-model(语法糖)
// <Child v-model="value" /> 等同于 :value + @update:value
// ✅ 例外 5:provide/inject 跨层级
// 父 provide,子 inject(Phase 2.5 学)
</script>
(3) 5 个反模式
JS
// ❌ 反模式 1:直接修改 prop
props.user.name = 'Bob'
// ❌ 反模式 2:在子组件用 watch 修改 prop
watch(() => props.value, (val) => { props.value = val * 2 })
// ❌ 反模式 3:双向绑定的 ref 透传(不推荐)
const localRef = ref(props.value)
watch(localRef, (val) => emit('update', val))
// ❌ 反模式 4:使用 v-model 时不遵循命名
// v-model 期望 update:xxx 事件,自定义事件名要配套
emit('change', val) // ❌ 父组件 @change 不会触发
emit('update:value', val) // ✅ v-model 配套
// ❌ 反模式 5:在子组件中直接管理父组件的数据
emit('update', { ...props.user, name: 'Bob' }) // 错:应该只传 ID,父组件自己更新
7. props 透传:inheritAttrs 和 useAttrs
(1) 默认行为
VUE
<!-- Parent.vue -->
<template>
<ChildComponent class="parent-class" :title="title" />
</template>
<!-- ChildComponent.vue - 默认会自动接收 class="parent-class" -->
<template>
<div> <!-- 自动有 class="parent-class" -->
<h3>{{ title }}</h3>
</div>
</template>
(2) 关闭自动透传
VUE
<script setup>
defineOptions({ inheritAttrs: false })
</script>
<template>
<!-- class 不会自动应用到根元素 -->
<div class="my-class">
<h3>Title</h3>
</div>
</template>
(3) useAttrs 显式使用
VUE
<script setup>
const { useAttrs } = Vue
defineOptions({ inheritAttrs: false })
const attrs = useAttrs()
</script>
<template>
<div>
<input v-bind="attrs" />
<!-- 把父组件传的 class/style/其他属性手动绑到 input -->
</div>
</template>
(4) 5 个使用场景
| 场景 | 做法 |
|---|---|
| 默认 | 不用管,class 自动应用到根 |
| 自定义根元素 | useAttrs() 显式绑定 |
| 透传到非根元素 | inheritAttrs: false + v-bind="$attrs" |
| 多根组件(Fragment) | 必须 inheritAttrs: false + 显式绑定 |
| 性能优化 | 关闭透传避免不必要的 DOM 属性 |
8. 完整示例:电商商品卡 props/emits
▶ 示例:ProductCard 完整 props/emits
HTML
📖 仅展示
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<style>
.product-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 1rem; margin: 0.5rem 0; transition: all 0.2s; }
.product-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.out-of-stock { opacity: 0.6; }
.badge { background: #f59e0b; color: white; padding: 2px 8px; border-radius: 4px; font-size: 0.85rem; }
.price { color: #42b883; font-weight: bold; }
button { background: #42b883; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; }
button:disabled { background: #ccc; cursor: not-allowed; }
.fav-btn { background: #fff; color: #333; border: 1px solid #ddd; }
</style>
<div id="app">
<product-card
v-for="product in products"
:key="product.id"
:product="product"
:show-stock-badge="true"
@add-to-cart="handleAddToCart"
@quick-view="handleQuickView"
@toggle-favorite="handleToggleFavorite"
></product-card>
<p>购物车商品数: {{ cartCount }} | 收藏: {{ favorites.length }}</p>
</div>
<script>
const { createApp, ref, computed } = Vue
// 子组件:ProductCard
const ProductCard = {
props: {
product: {
type: Object,
required: true,
validator: (val) => val.id && val.name && val.price !== undefined
},
showStockBadge: { type: Boolean, default: true }
},
emits: {
'add-to-cart': (productId) => typeof productId === 'number',
'quick-view': (productId) => typeof productId === 'number',
'toggle-favorite': (productId, isFavorite) =>
typeof productId === 'number' && typeof isFavorite === 'boolean'
},
setup(props, { emit }) {
const inStock = computed(() => props.product.stock > 0)
const lowStock = computed(() => props.product.stock > 0 && props.product.stock < 10)
function handleAddToCart() {
if (inStock.value) emit('add-to-cart', props.product.id)
}
return { inStock, lowStock, handleAddToCart }
},
template: `
<div :class="['product-card', { 'out-of-stock': !inStock }]">
<h3>{{ product.name }}</h3>
<p class="price">${{ product.price }}</p>
<span v-if="showStockBadge && lowStock" class="badge">Only {{ product.stock }} left</span>
<button :disabled="!inStock" @click="handleAddToCart">
{{ inStock ? 'Add to Cart' : 'Out of Stock' }}
</button>
<button class="fav-btn" @click="$emit('toggle-favorite', product.id, true)">
❤️ Favorite
</button>
<button class="fav-btn" @click="$emit('quick-view', product.id)">👁 Quick View</button>
</div>
`
}
const App = {
components: { ProductCard },
setup() {
const products = ref([
{ id: 1, name: 'iPhone', price: 999, stock: 50 },
{ id: 2, name: 'MacBook', price: 2499, stock: 0 }
])
const cartCount = ref(0)
const favorites = ref([])
function handleAddToCart(id) { cartCount.value++; console.log('Added:', id) }
function handleQuickView(id) { console.log('Quick view:', id) }
function handleToggleFavorite(id, isFav) {
if (isFav) favorites.value.push(id)
console.log('Favorites:', favorites.value)
}
return { products, cartCount, favorites, handleAddToCart, handleQuickView, handleToggleFavorite }
}
}
createApp(App).mount('#app')
</script>
▶ 示例:v-model 双向绑定
HTML
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<style>
input { padding: 6px; border: 1px solid #ddd; border-radius: 4px; margin: 4px; }
</style>
<div id="app">
<!-- 父组件使用 v-model -->
<custom-input v-model="searchQuery" placeholder="搜索..."></custom-input>
<p>实时显示: {{ searchQuery || '(空)' }}</p>
</div>
<script>
const { createApp, ref } = Vue
// 子组件:CustomInput(用 v-model 语法糖)
const CustomInput = {
props: ['modelValue'],
emits: ['update:modelValue'],
template: `
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
:placeholder="placeholder"
>
`
}
const App = {
components: { CustomInput },
props: ['placeholder'],
setup() {
const searchQuery = ref('')
return { searchQuery }
}
}
createApp(App, { placeholder: '搜索...' }).mount('#app')
</script>
▶ 示例:useAttrs 透传(多根组件)
HTML
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<style>
input { padding: 6px; border: 1px solid #ddd; border-radius: 4px; margin: 4px; }
.input-field { border-color: #42b883; }
</style>
<div id="app">
<!-- 父组件传 class 和 placeholder -->
<custom-input v-model="name" label="Name:" placeholder="Enter name..." class="input-field"></custom-input>
<p>输入值: {{ name }}</p>
</div>
<script>
const { createApp, ref } = Vue
// 多根组件必须用 inheritAttrs: false
const CustomInput = {
inheritAttrs: false,
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
template: `
<div>
<label>{{ label }}</label>
<!-- 把 attrs(class、placeholder 等)手动绑到 input -->
<input
v-bind="$attrs"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
>
</div>
`
}
const App = {
components: { CustomInput },
setup() {
const name = ref('')
return { name }
}
}
createApp(App).mount('#app')
</script>
▶ 示例:5 个常见错误速查
| 错误 | 现象 | 解决 |
|---|---|---|
| 直接修改 prop | Vue 警告 | 用 emit 让父改 |
| 数组/对象 prop 默认值是引用 | 多个组件共享 | default: () => [] 工厂 |
| v-model 事件名不匹配 | 父收不到 | 用 update:xxx |
| validator 返回字符串 | Vue 不报错 | 返回 true/false |
| 透传到多根组件 | Vue 警告 | inheritAttrs: false |
▶ 示例:props 5 大验证场景
| 场景 | 验证 |
|---|---|
| 邮箱 | val => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val) |
| URL | val => /^https?:\/\//.test(val) |
| 枚举值 | val => ['a', 'b', 'c'].includes(val) |
| 范围 | val => val >= 0 && val <= 100 |
| 自定义对象 | val => val.id && val.name |
❓ 常见问题
Q props 能直接修改吗?
A 不能。Vue 单向数据流原则,props 只读。子组件要改数据必须 emit 事件让父组件改。直接修改会触发 Vue 警告。
Q 怎么传递复杂数据(嵌套对象)?
A 直接传引用,Vue 自动深响应。修改对象内部属性父组件能感知(深响应),但子组件不应直接修改,应 emit 让父改整个对象或属性。
Q defineProps 和 props 选项区别?
A
<script setup> 中用 defineProps() 宏(无需 import),Options API 中用 props: {} 选项。两种等价,推荐 setup + defineProps。Q emit 能在子组件中异步触发吗?
A 能。emit 是同步函数调用,但可以放在 setTimeout/Promise 中:
setTimeout(() => emit('done'), 1000)。Q v-model 的本质是什么?
A 是 props + emit 的语法糖。
<Child v-model="x" /> 等同于 :modelValue="x" @update:modelValue="x = $event"。本课示例 3 详解。Q 怎么在子组件中 watch props 变化?
A 用
watch(() => props.xxx, (newVal, oldVal) => {...})。注意不要在 watch 中修改 props,否则无限循环。Q useAttrs 什么时候用?
A 3 个场景:(1) 多根组件(Fragment)必须用;(2) 想把 attribute 透传到非根元素;(3) 想要显式控制 attribute 绑定位置。普通单根组件不需要用。
Q props 验证失败会怎样?
A 开发环境 Vue 警告,生产环境静默失败。建议 validator 返回 false(错误)或返回字符串(错误消息)。
📖 小节
- props 父传子用
defineProps(),子传父用defineEmits() - 7 种 props 类型:基础 / 复杂 / 多类型 / 必填 / 默认值 / 验证 / 工厂
- TypeScript 用
defineProps<T>()强类型 +withDefaults()配默认值 - emit 5 种触发方式 + 类型验证
- 单向数据流原则:数据向下,事件向上
- 5 个反模式:直接改 prop / watch 改 prop / 双向 ref 透传 / v-model 事件名不匹配 / 子管理父数据
- inheritAttrs / useAttrs 处理 props 透传
- v-model 是 props + emit 的语法糖
📝 作业
-
基础题(难度⭐) 实现一个简单的 Button 组件:
- props:
text(String),variant(String, 'primary'/'success'/'danger'),disabled(Boolean) - 3 种 variant 对应 3 种 CSS class
- emit
click事件(无参数) - 在父组件中测试 3 种 variant
- props:
-
进阶题(难度⭐⭐) 实现一个双向绑定的 Input 组件:
- 子组件 CustomInput.vue:props:
modelValue,label - 用 v-model 语法糖
- emit
update:modelValue - 父组件:实时显示用户输入
- 子组件 CustomInput.vue:props:
-
挑战题(难度⭐⭐⭐) 实现完整的"商品卡 + 收藏"系统:
- ProductCard.vue:完整 props(product/showStockBadge/maxStock)+ emits(add-to-cart/toggle-favorite)
- App.vue:商品列表 + 收藏夹状态
- 收藏按钮:emit toggle-favorite,父组件更新收藏状态
- 用 useAttrs + inheritAttrs 实现多根组件(不破坏练习)
- 至少 3 个 validator 验证(email/url/enum)
- TypeScript 强类型 props