2、组件通信(上):父传子(Props)、子传父($emit)、v-model 组件通信
组件通信,说白了就是组件之间怎么「说话」。
我刚带团队的时候,发现很多新人写 Vue,一个组件里塞了几百行代码,问他为什么不拆,他说「拆了不知道怎么传数据」。嗯,这确实是入门的一道坎。
今天我们先搞定三种最基础的通信方式:父传子、子传父、以及v-model 的组件封装。
2.1 父传子:Props 的正确打开方式
父组件给子组件传数据,最直接的方式就是 Props。我个人习惯把 Props 理解为「组件的入参」——就像函数参数一样,你传什么,子组件就接收什么。
核心原则:单向数据流
父组件更新 Props,子组件自动更新。但子组件绝对不能直接修改 Props。为什么?因为这样会导致数据流混乱,调试起来想砸电脑。
来看一个实际例子。我在项目中做过一个用户卡片组件:
// 父组件
<template>
<UserCard
:user="currentUser"
:show-avatar="true"
@click="handleClick"
/>
</template>
// 子组件 UserCard.vue
<script setup>
// 方式一:简单声明
const props = defineProps(['user', 'showAvatar'])
// 方式二:带类型校验(我强烈推荐)
const props = defineProps({
user: {
type: Object,
required: true,
default: () => ({ name: '未知用户', age: 0 })
},
showAvatar: {
type: Boolean,
default: false
}
})
</script>
我的小习惯:只要涉及对象类型的 default,一定要用箭头函数返回新对象。否则所有组件实例会共享同一个引用,改一个全变了——我曾经因为这个 bug 排查了整整一下午。
2.2 子传父:$emit 的优雅实践
子组件怎么告诉父组件「我这边发生了什么事」?用 $emit 发射事件。
你想想看,这就像子组件喊一嗓子:「爸,我这边按钮被点了!」父组件听到后决定怎么处理。
// 子组件
<script setup>
const emit = defineEmits(['update', 'delete'])
function handleUpdate() {
emit('update', { id: props.user.id, time: Date.now() })
}
function handleDelete() {
emit('delete', props.user.id)
}
</script>
// 父组件监听
<UserCard
@update="onUserUpdate"
@delete="onUserDelete"
/>
避坑指南:我曾经见过有人直接在子组件里用 $parent 调用父组件方法。千万别这么干!这会让组件耦合度极高,换个父组件就崩了。老老实实用 $emit,这是 Vue 设计的标准通道。
2.3 v-model 组件通信:双向绑定的进阶玩法
v-model 本质上就是「props + emit」的语法糖。说白了,它帮你省去了手动绑定和监听的过程。
在 Vue 3 中,v-model 的实现更加灵活。我们来看一个自定义输入框组件:
// 子组件 MyInput.vue
<script setup>
const props = defineProps({
modelValue: String // 默认的 v-model 属性名
})
const emit = defineEmits(['update:modelValue'])
function onInput(e) {
emit('update:modelValue', e.target.value)
}
</script>
<template>
<input
:value="modelValue"
@input="onInput"
class="custom-input"
/>
</template>
// 父组件使用
<MyInput v-model="searchText" />
Vue 3 新特性:多个 v-model
一个组件可以绑定多个 v-model,这在复杂表单中特别实用:
// 子组件
defineProps({
firstName: String,
lastName: String
})
const emit = defineEmits(['update:firstName', 'update:lastName'])
// 父组件
<UserForm
v-model:first-name="user.firstName"
v-model:last-name="user.lastName"
/>
2.4 三种方式的对比与选择
| 通信方式 | 数据流向 | 适用场景 | 注意事项 |
|---|---|---|---|
| Props | 父 → 子 | 展示数据、配置项 | 不要修改 Props |
| $emit | 子 → 父 | 事件通知、数据提交 | 事件名用 kebab-case |
| v-model | 双向 | 表单控件、输入类组件 | 适合「值 + 修改」场景 |
我的选择原则:
- 如果只是展示数据,用 Props 就够了
- 如果子组件要触发父组件的动作,用 $emit
- 如果子组件本质上是一个「可输入/可修改」的控件,用 v-model
2.5 实战中的常见坑
嗯,这里要重点说说我踩过的坑:
- Props 的响应式丢失:如果你把 Props 赋值给子组件的 data,后续 Props 变了,data 不会跟着变。解决方案是用 computed 或者直接使用 Props。
- 事件名大小写:Vue 3 推荐用 kebab-case(短横线命名),比如
@user-update而不是@userUpdate。我见过因为大小写不匹配导致事件没触发,排查了半天。 - v-model 的修饰符:Vue 3 支持自定义修饰符,比如
v-model.trim。如果你封装组件,记得用modelModifiers来处理。
好了,这三种基础通信方式搞明白了,你已经能解决 80% 的组件通信场景。下一章我们聊更复杂的跨层级通信——provide/inject 和事件总线。