本文将系统性讲解 Vue 3 中父子组件之间的所有通信方式,从最基础的 Props/Emits 到高级的 v-model 和 Slot (插槽)通信。


1. 组件通信概述

在 Vue 3 的组件化开发中,组件之间的数据流动遵循 单向数据流 原则:

父组件  ──Props──▶  子组件
  ▲                    │
  └─────Emits─────────┘

Vue 3 提供了多种父子组件通信方式,适用于不同场景:

通信方式 方向 适用场景
Props 父 → 子 向子组件传递数据
Emits 子 → 父 子组件通知父组件、传递数据
v-model 双向 表单类组件的双向绑定
defineExpose / ref 父 → 子(方法调用) 父组件主动调用子组件方法
Slots 父 → 子(内容分发) 向子组件注入 HTML/组件

2. Props:父组件向子组件传值

2.1 基础用法

Props 是 Vue 中最基础、最常用的父子通信方式。父组件通过属性绑定传值,子组件通过 defineProps 接收。

<!-- 父组件 Parent.vue -->
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

const message = ref('Hello from Parent')
const count = ref(42)
</script>

<template>
  <Child :message="message" :count="count" />
</template>

<!-- 子组件 Child.vue -->
<script setup>
// 数组形式(简单,无类型校验)
// const props = defineProps(['message', 'count'])

// 对象形式(推荐,有类型校验和默认值)
const props = defineProps({
  message: {
    type: String,
    required: true
  },
  count: {
    type: Number,
    default: 0
  }
})
</script>

<template>
  <div class="child">
    <p>收到父组件消息:{{ message }}</p>
    <p>收到数字:{{ count }}</p>
  </div>
</template>

2.2 <script setup> 中的 TypeScript 写法

在 TypeScript 环境下,可以使用更简洁的泛型写法:

<!-- 子组件 Child.vue(TypeScript) -->
<script setup lang="ts">
interface Props {
  message: string
  count?: number
  items?: string[]
}

const props = withDefaults(defineProps<Props>(), {
  count: 0,
  items: () => []
})
</script>

2.3 重要规则

Props 是只读的!
子组件 不能 直接修改 Props。如果试图修改,Vue 会在控制台发出警告。

//  错误:不要直接修改 Props
props.count = 100

//  正确:使用 computed 创建派生值
const doubled = computed(() => props.count * 2)

//  正确:如果需要修改,应该通过 emits 通知父组件
</script>

2.4 Props 响应性原理

当父组件传递的是 ref 或 reactive 对象 时,传递的是 响应式引用,子组件中 Props 的变化会自动反映到视图上。但如果是 普通对象/基本类型,则需要在父组件中修改才能真正触发更新。


3. Emits:子组件向父组件传值

3.1 基础用法

子组件通过 defineEmits 定义事件,使用 emit() 触发事件并传递数据给父组件。

<!-- 子组件 Child.vue -->
<script setup>
const emit = defineEmits(['submit', 'update:count'])

function handleClick() {
  // 触发事件,并携带参数
  emit('submit', { value: 100, text: '提交成功' })
}

function handleIncrement() {
  emit('update:count', 1)
}
</script>

<template>
  <button @click="handleClick">提交给父组件</button>
  <button @click="handleIncrement">+1</button>
</template>

<!-- 父组件 Parent.vue -->
<script setup>
import Child from './Child.vue'

function handleSubmit(data) {
  console.log('收到子组件数据:', data) // { value: 100, text: '提交成功' }
}

function handleUpdateCount(delta) {
  console.log('增量:', delta) // 1
}
</script>

<template>
  <Child @submit="handleSubmit" @update:count="handleUpdateCount" />
</template>

3.2 对象形式验证(高级)

Emits 也可以使用对象形式进行参数验证:

const emit = defineEmits({
  // 无验证
  submit: null,
  
  // 带验证函数
  'update:count': (value) => {
    if (typeof value !== 'number') {
      console.warn('update:count 事件参数必须是数字')
      return false
    }
    return true
  }
})

3.3 事件命名规范

  • 推荐使用 kebab-case(短横线命名):@update-count
  • 对应 defineEmits 中使用同名字符串:defineEmits(['update-count'])
  • Vue 会自动将 camelCase 转换为 kebab-case

4. v-model:双向绑定通信

4.1 基础 v-model 原理

v-model 本质上是 :modelValue + @update:modelValue 的语法糖:

<!-- 这两者是等价的 -->
<Child v-model="searchText" />
<Child :modelValue="searchText" @update:modelValue="searchText = $event" />

子组件实现:

<!-- 子组件 SearchInput.vue -->
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])

function onInput(e) {
  emit('update:modelValue', e.target.value)
}
</script>

<template>
  <input
    :value="modelValue"
    @input="onInput"
    class="search-input"
  />
</template>

4.2 Vue 3 多 v-model 支持

Vue 3 支持在单个组件上绑定 多个 v-model,这是 Vue 2 无法实现的:

<!-- 父组件 -->
<Child
  v-model:title="pageTitle"
  v-model:content="pageContent"
/>

<!-- 子组件 Child.vue -->
<script setup>
const props = defineProps({
  title: String,
  content: String
})
const emit = defineEmits(['update:title', 'update:content'])
</script>

<template>
  <input :value="title" @input="emit('update:title', $event.target.value)" />
  <textarea :value="content" @input="emit('update:content', $event.target.value)" />
</template>

4.3 自定义 v-model 修饰符

Vue 3 支持自定义 v-model 修饰符:

<Child v-model.capitalize="name" />

子组件中通过 modelModifiers Prop 访问修饰符:

const props = defineProps(['modelValue', 'modelModifiers'])

function onInput(e) {
  let value = e.target.value
  if (props.modelModifiers?.capitalize) {
    value = value.charAt(0).toUpperCase() + value.slice(1)
  }
  emit('update:modelValue', value)
}

5. defineExpose:父组件访问子组件实例

5.1 基础用法

<script setup> 中,组件默认是 关闭的,父组件无法通过 ref 访问子组件内部内容。需要使用 defineExpose 显式暴露。

<!-- 子组件 Counter.vue -->
<script setup>
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}

function reset() {
  count.value = 0
}

// 显式暴露给父组件
defineExpose({
  count,
  increment,
  reset
})
</script>

<template>
  <div class="counter">
    <p>当前计数:{{ count }}</p>
  </div>
</template>

<!-- 父组件 Parent.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import Counter from './Counter.vue'

// 创建模板 ref
const counterRef = ref(null)

onMounted(() => {
  console.log(counterRef.value.count) // 0
  counterRef.value.increment()
  console.log(counterRef.value.count) // 1
})
</script>

<template>
  <Counter ref="counterRef" />
  <button @click="counterRef?.reset()">重置</button>
  <button @click="counterRef?.increment()">+1</button>
</template>

5.2 使用场景

什么时候使用 defineExpose

  • 父组件需要主动调用子组件的方法(如:触发校验、重置表单、聚焦输入框)
  • 需要获取子组件的内部状态

不推荐的场景:

  • 能用 Props/Emits 解决的场景,优先使用 Props/Emits
  • defineExpose 破坏了组件的封装性,应谨慎使用

6. Slots:插槽通信

Slots 是一种 内容分发 机制,允许父组件向子组件传递 HTML 内容和组件。

6.1 默认插槽

<!-- 子组件 Card.vue -->
<template>
  <div class="card">
    <div class="card-header">
      <slot name="header">默认标题</slot>
    </div>
    <div class="card-body">
      <!-- 默认插槽 -->
      <slot>默认内容</slot>
    </div>
  </div>
</template>

<!-- 父组件 -->
<template>
  <Card>
    <template #header>
      <h2>自定义标题</h2>
    </template>
    
    <!-- 默认插槽内容 -->
    <p>这是卡片的正文内容</p>
  </Card>
</template>

6.2 作用域插槽(Scoped Slots)

作用域插槽允许 子组件向父组件传递数据,是一种反向的数据流:

<!-- 子组件 List.vue -->
<script setup>
import { ref } from 'vue'
const items = ref([
  { id: 1, name: 'Apple', price: 5 },
  { id: 2, name: 'Banana', price: 3 },
  { id: 3, name: 'Orange', price: 4 }
])
</script>

<template>
  <ul>
    <li v-for="(item, index) in items" :key="item.id">
      <!-- 将 item、index 传递给父组件 -->
      <slot :item="item" :index="index" :is-even="index % 2 === 0">
        <!-- 默认内容(父组件不提供 slot 时显示) -->
        {{ item.name }} - ¥{{ item.price }}
      </slot>
    </li>
  </ul>
</template>

<!-- 父组件 -->
<template>
  <List>
    <!-- 使用 #default 接收作用域数据 -->
    <template #default="{ item, index, isEven }">
      <span :style="{ color: isEven ? 'green' : 'red' }">
        #{{ index + 1 }} {{ item.name }} - ¥{{ item.price }}
      </span>
    </template>
  </List>
</template>

6.3 插槽 vs Props

对比项 Slots Props
传递内容 HTML/组件 数据(任何 JS 类型)
渲染位置 由子组件决定 由子组件决定
适用场景 布局组件、容器组件 数据驱动组件

7. 最佳实践与常见误区

7.1 单向数据流原则

 正确:父组件拥有数据,子组件只负责展示和通知
 错误:子组件直接修改父组件传入的 Props

解决方案: 如果子组件需要修改数据,应该:

  1. 使用 emit 通知父组件修改
  2. 使用 v-model 实现双向绑定
  3. 在子组件内部创建局部数据(computedref
//  推荐:使用 computed 创建可写的派生值
const props = defineProps(['count'])
const localCount = computed({
  get: () => props.count,
  set: (val) => emit('update:count', val)
})

7.2 Props 解构丢失响应性

//  错误:直接解构 Props 会丢失响应性
const { message } = defineProps(['message'])
// message 不再是响应式的!

//  正确:使用 toRefs
const props = defineProps(['message'])
const { message } = toRefs(props)
// 或者使用 props.message 直接访问

7.3 Emits 事件命名规范

//  推荐:使用 kebab-case
defineEmits(['submit-form', 'update:count'])

//  不推荐:camelCase(虽然在技术上可用)
defineEmits(['submitForm'])

7.4 避免过度使用 defineExpose

defineExpose 应作为 最后手段,因为它:

  • 破坏了组件的封装性
  • 使组件之间的耦合度增加
  • 使数据流难以追踪

1. 简单数据传递 → Props
2. 子通知父 → Emits
3. 表单输入 → v-model
4. 需要调用子组件方法 → defineExpose(谨慎使用)
5. 内容分发/布局组件 → Slots
6. 列表项自定义渲染 → 作用域插槽

记住最核心的原则:优先使用 Props 和 Emits,保持单向数据流,必要时才使用更高级的通信方式。

更多推荐