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 和事件总线。