Vue.js: 组件通信:props/emits
最后更新:2026-10-20
组件通信是 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 报警告:
[Vue warn] Set operation on key "quantity" failed: target is readonly.
Charlie 调试 1 小时后发现:
"Alice,我改数量时父组件的购物车总价没更新。Vue 在修改 prop,但父组件不知道。"
(2) Vue 单向数据流 + emit 解法
现在数据流正确了:子组件 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) 父组件监听
(4) emit 类型验证
VUE
<script setup>
const emit = defineEmits({
// ✅ 验证:id 必须是 number
remove: (id) => typeof id === 'number' || 'Validation failed',
// ❌ 抛错:参数不匹配时
// remove: 'invalid', // 错:必须是函数
})
</script>
▶ 示例:v-model 双向绑定
▶ 试一试输出:
TEXT
初始输入框显示占位符「搜索...」,下方文字「实时显示: (空)」。
输入 vue 时下方逐字同步,最终显示「实时显示: vue」;清空输入框又回到「(空)」。
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) 默认行为
(2) 关闭自动透传
(3) useAttrs 显式使用
(4) 5 个使用场景
| 场景 | 做法 |
|---|---|
| 默认 | 不用管,class 自动应用到根 |
| 自定义根元素 | useAttrs() 显式绑定 |
| 透传到非根元素 | inheritAttrs: false + v-bind="$attrs" |
| 多根组件(Fragment) | 必须 inheritAttrs: false + 显式绑定 |
| 性能优化 | 关闭透传避免不必要的 DOM 属性 |
▶ 示例:useAttrs 透传(多根组件)
▶ 试一试输出:
TEXT
显示标签「Name:」和一个输入框(占位符「Enter name...」,边框为绿色 .input-field),下方「输入值: 」为空。
输入 Alice 后,下方实时变为「输入值: Alice」——class 与 placeholder 都已通过 $attrs 绑到 input 上。
(1) 📊 5 个常见错误速查
| 错误 | 现象 | 解决 |
|---|---|---|
| 直接修改 prop | Vue 警告 | 用 emit 让父改 |
| 数组/对象 prop 默认值是引用 | 多个组件共享 | default: () => [] 工厂 |
| v-model 事件名不匹配 | 父收不到 | 用 update:xxx |
| validator 返回字符串 | Vue 不报错 | 返回 true/false |
| 透传到多根组件 | Vue 警告 | inheritAttrs: false |
(2) 📊 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 |
8. 完整示例:电商商品卡 props/emits
▶ 示例:ProductCard 完整 props/emits
▶ 展开运行示例(83 行)
输出:
TEXT
两张商品卡:iPhone 显示 $999 和可点击的「Add to Cart」;MacBook 显示 $2499、卡片半透明、按钮置灰并显示「Out of Stock」。
底部一行「购物车商品数: 0 | 收藏: 0」。
点击 iPhone 的 Add to Cart → 「购物车商品数: 1」;再点 ❤️ Favorite → 「收藏: 1」。
❓ 常见问题
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