Vue 3 父子组件传值完整指南
本文将系统性讲解 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
解决方案: 如果子组件需要修改数据,应该:
- 使用
emit通知父组件修改 - 使用
v-model实现双向绑定 - 在子组件内部创建局部数据(
computed或ref)
// 推荐:使用 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,保持单向数据流,必要时才使用更高级的通信方式。
更多推荐

所有评论(0)