Vue3中组件间通信详解
- [一、 父子组件通信(最基础、最高频)](#一、 父子组件通信(最基础、最高频))
-
- [1. Props:父传子](#1. Props:父传子)
- [2. Emits:子传父](#2. Emits:子传父)
- [3. v-model / defineModel:双向绑定](#3. v-model / defineModel:双向绑定)
- [4. Ref + DefineExpose:父调用子方法](#4. Ref + DefineExpose:父调用子方法)
- [二、 跨层级/祖孙组件通信](#二、 跨层级/祖孙组件通信)
-
- [1. Provide / Inject](#1. Provide / Inject)
- [三、 兄弟组件/任意组件通信](#三、 兄弟组件/任意组件通信)
-
- [1. Mitt 事件总线](#1. Mitt 事件总线)
- [四、 全局状态管理](#四、 全局状态管理)
- [五、 其他特殊场景](#五、 其他特殊场景)
-
- [1. Slots(插槽):内容分发与定制](#1. Slots(插槽):内容分发与定制)
-
- [场景 A:默认插槽与具名插槽](#场景 A:默认插槽与具名插槽)
- [场景 B:作用域插槽(Scoped Slots)](#场景 B:作用域插槽(Scoped Slots))
- [2. attrs:属性透传(Attribute Inheritance)](#2. attrs:属性透传(Attribute Inheritance))
-
- [场景 A:禁用默认继承并手动控制绑定位置](#场景 A:禁用默认继承并手动控制绑定位置)
- [场景 B:多层组件透传(高阶组件)](#场景 B:多层组件透传(高阶组件))
- 六、总结与选型建议
在 Vue3 开发中,组件通信是构建复杂应用的核心技能。根据组件之间的关系(父子、兄弟、跨层级、全局),Vue3 提供了多种高效且规范的通信方案。
一、 父子组件通信(最基础、最高频)
父子组件通信遵循单向数据流原则:父组件通过属性向下传递数据,子组件通过事件向上传递消息。
1. Props:父传子
这是最基础的通信方式,数据由父组件流向子组件,子组件只能读取,不能直接修改。
- 父组件:通过属性绑定传递数据。
- 子组件:使用
defineProps接收数据。支持类型校验、默认值和必填项设置。
js
<!-- 父组件 Father.vue -->
<template>
<Child :msg="message" :count="10" />
</template>
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const message = ref('Hello Vue3')
</script>
js
<!-- 子组件 Child.vue -->
<script setup>
// 接收 props,支持类型校验
const props = defineProps({
msg: {
type: String,
required: true
},
count: {
type: Number,
default: 0
}
})
// 使用 props.msg
</script>
2. Emits:子传父
当子组件需要通知父组件更新数据或执行操作时,使用自定义事件。
- 子组件:使用
defineEmits定义事件,并通过emit触发。 - 父组件:监听子组件触发的事件并处理。
js
<!-- 子组件 Child.vue -->
<template>
<button @click="handleClick">发送数据给父组件</button>
</template>
<script setup>
const emit = defineEmits(['update-data'])
const handleClick = () => {
emit('update-data', '来自子组件的数据')
}
</script>
js
<!-- 父组件 Father.vue -->
<template>
<Child @update-data="receiveData" />
</template>
<script setup>
const receiveData = (payload) => {
console.log('收到:', payload)
}
</script>
3. v-model / defineModel:双向绑定
用于实现父子组件数据的双向同步。Vue 3.4+ 推荐使用 defineModel 宏简化写法。
- 原理:本质上是
props+emit('update:xxx')的语法糖。 - 用法:父组件使用
v-model,子组件使用defineModel。
js
<!-- 父组件 -->
<template>
<Child v-model:title="pageTitle" />
</template>
<!-- 子组件 Child.vue (Vue 3.4+) -->
<script setup>
const title = defineModel('title')
// 直接修改 title.value 即可同步到父组件
</script>
4. Ref + DefineExpose:父调用子方法
当父组件需要直接调用子组件内部的方法或访问其状态时使用。注意:这会破坏组件封装性,应谨慎使用。
- 子组件:使用
defineExpose暴露需要被访问的方法或属性。 - 父组件:通过
ref获取子组件实例。
js
<!-- 子组件 Child.vue -->
<script setup>
const sayHi = () => console.log('Hi!')
// 暴露方法
defineExpose({ sayHi })
</script>
js
<!-- 父组件 Father.vue -->
<template>
<Child ref="childRef" />
<button @click="callChild">调用子组件方法</button>
</template>
<script setup>
import { ref } from 'vue'
const childRef = ref(null)
const callChild = () => {
childRef.value?.sayHi()
}
</script>
二、 跨层级/祖孙组件通信
当组件嵌套过深(如 A -> B -> C -> D),使用 Props 逐层传递(Prop Drilling)非常繁琐,此时可采用以下方案:
1. Provide / Inject
祖先组件提供数据,后代组件无论嵌套多深都可以注入使用。适合主题配置、用户信息等深层共享数据。
- 祖先组件:使用
provide提供数据。 - 后代组件:使用
inject注入数据。
js
<!-- 祖先组件 -->
<script setup>
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme-key', theme)
</script>
js
<!-- 深层后代组件 -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme-key')
</script>
三、 兄弟组件/任意组件通信
1. Mitt 事件总线
Vue3 移除了原生的 $bus,推荐使用轻量级第三方库 mitt 实现发布-订阅模式。适合非父子、非全局状态的简单通信。
- 注意:需在组件卸载时手动移除监听,防止内存泄漏。
javascript
// utils/bus.js
import mitt from 'mitt'
export const emitter = mitt()
js
<!-- 组件 A (发送) -->
<script setup>
import { emitter } from '@/utils/bus'
const send = () => {
emitter.emit('my-event', 'data')
}
</script>
js
<!-- 组件 B (接收) -->
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { emitter } from '@/utils/bus'
const handler = (data) => console.log(data)
onMounted(() => {
emitter.on('my-event', handler)
})
onUnmounted(() => {
emitter.off('my-event', handler) // 务必清理
})
</script>
四、 全局状态管理
对于大型项目,涉及多个组件共享复杂状态(如登录态、购物车、权限等),建议使用状态管理库。
1.Pinia (推荐)
Pinia 是 Vue 官方推荐的状态管理库,替代了 Vuex。它去除了 Mutation,仅保留 State、Getter、Action,对 TypeScript 支持更好,且体积更小。
- 特点:模块化设计,无需嵌套模块,调试方便。
- 用法:创建 Store,在组件中直接调用 store 的状态和方法。
javascript
// stores/user.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
const name = ref('Admin')
function setName(newName) {
name.value = newName
}
return { name, setName }
})
js
<!-- 任意组件 -->
<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 直接使用 userStore.name 或 userStore.setName()
</script>
五、 其他特殊场景
1. Slots(插槽):内容分发与定制
插槽允许父组件向子组件传递模板结构,实现"结构由子组件定,内容由父组件定"的解耦。
场景 A:默认插槽与具名插槽
适用于布局组件(如卡片、弹窗),其中部分区域固定,部分区域需自定义。
子组件 Card.vue
js
<template>
<div class="card">
<!-- 具名插槽:头部 -->
<header class="card-header">
<slot name="header">默认标题</slot>
</header>
<!-- 默认插槽:主体内容 -->
<main class="card-body">
<slot>默认主体内容</slot>
</main>
<!-- 具名插槽:底部操作区 -->
<footer class="card-footer">
<slot name="footer">
<button>默认按钮</button>
</slot>
</footer>
</div>
</template>
<style scoped>
.card { border: 1px solid ddd; padding: 10px; }
.card-header { font-weight: bold; margin-bottom: 10px; }
.card-footer { margin-top: 10px; text-align: right; }
</style>
父组件 Parent.vue使用
js
<template>
<Card>
<!-- 1. 具名插槽写法:v-slot:name 或简写 name -->
<template header>
<h2>自定义标题</h2>
</template>
<!-- 2. 默认插槽:直接写内容,无需 template 包裹(也可用 default) -->
<p>这是父组件传入的动态正文内容。</p>
<!-- 3. 具名插槽:底部 -->
<template footer>
<button @click="save">保存</button>
<button @click="cancel">取消</button>
</template>
</Card>
</template>
<script setup>
import Card from './Card.vue'
const save = () => console.log('保存')
const cancel = () => console.log('取消')
</script>
场景 B:作用域插槽(Scoped Slots)
适用于列表、表格等组件,子组件提供数据,父组件决定如何渲染这些数据。
子组件 UserList.vue
js
<template>
<ul>
<li v-for="user in users" :key="user.id">
<!--
通过 :user="user"将数据暴露给父组件
父组件接收时可以使用任意变量名,如 slotProps
-->
<slot :user="user" :index="user.id">
<!-- 默认渲染方式 -->
{{ user.name }}
</slot>
</li>
</ul>
</template>
<script setup>
import { ref } from 'vue'
const users = ref([
{ id: 1, name: 'Alice', role: 'Admin' },
{ id: 2, name: 'Bob', role: 'User' }
])
</script>
父组件 Parent.vue 使用
js
<template>
<UserList>
<!--
v-slot:default="slotProps"
slotProps 包含子组件暴露的所有属性: { user, index }
-->
<template default="{ user, index }">
<div class="custom-item">
<span>{{ index }}.</span>
<strong>{{ user.name }}</strong>
<span v-if="user.role === 'Admin'" class="badge">管理员</span>
</div>
</template>
</UserList>
</template>
<script setup>
import UserList from './UserList.vue'
</script>
<style scoped>
.badge { background: red; color: white; padding: 2px 5px; font-size: 12px; }
</style>
2. $attrs:属性透传(Attribute Inheritance)
$attrs 包含父组件传递给子组件但未被声明为 props 且未被监听为 emits 的所有属性(包括 class, style, 事件监听器等)。常用于高阶组件或封装第三方库组件。
场景 A:禁用默认继承并手动控制绑定位置
默认情况下,未声明的属性会自动绑定到组件的根元素。如果希望绑定到内部特定元素(如 input),需禁用默认继承。
子组件 CustomInput.vue
js
<template>
<div class="input-wrapper">
<label>标签:</label>
<!--
1. defineOptions({ inheritAttrs: false }) 禁用了自动绑定到根 div
2. v-bind="$attrs" 手动将属性绑定到 input 上
这样父组件传的 placeholder, @focus, class 等都会生效在 input 上
-->
<input v-bind="$attrs" />
</div>
</template>
<script setup>
// Vue 3.3+ 推荐写法
defineOptions({
inheritAttrs: false
})
// 注意:这里没有定义任何 props,所以所有传入属性都会进入 $attrs
</script>
父组件 Parent.vue 使用
js
<template>
<!--
placeholder, @focus, class 均未在子组件 props 中声明
它们会通过 $attrs 透传到内部的 input 元素上
-->
<CustomInput
placeholder="请输入内容"
@focus="onFocus"
class="custom-style"
/>
</template>
<script setup>
import CustomInput from './CustomInput.vue'
const onFocus = () => console.log('输入框获得焦点')
</script>
场景 B:多层组件透传(高阶组件)
当组件只是作为中间层,需要将属性继续传递给更深层的子组件时。
中间组件 Wrapper.vue
js
<template>
<div class="wrapper-border">
<!-- 将接收到的所有未声明属性透传给 BaseButton -->
<BaseButton v-bind="$attrs">
<!-- 如果需要透传插槽,也可以显式写出 -->
<slot></slot>
</BaseButton>
</div>
</template>
<script setup>
import BaseButton from './BaseButton.vue'
// Wrapper 不声明任何 props,所有属性直接透传
</script>
底层组件 BaseButton.vue
js
<template>
<button v-bind="$attrs">
<slot></slot>
</button>
</template>
<script setup>
// 同样不声明 props,确保属性最终绑定到 button 元素
defineOptions({ inheritAttrs: false })
</script>
父组件使用
js
<template>
<!-- type, disabled, @click 等属性会穿过 Wrapper,最终绑定到 BaseButton 的 button 标签上 -->
<Wrapper type="submit" disabled @click="handleSubmit">
提交
</Wrapper>
</template>
六、总结与选型建议
| 场景 | 推荐方案 | 备注 |
|---|---|---|
| 父传子 | props |
单向数据流,最基础 |
| 子传父 | emits |
保持单向流,解耦 |
| 父子双向同步 | v-model / defineModel |
表单、输入框常用 |
| 父调用子方法 | ref + defineExpose |
慎用,破坏封装 |
| 跨多层级传递 | provide / inject |
避免 Prop Drilling |
| 兄弟/任意组件 | Mitt 事件总线 |
轻量级,需手动清理监听 |
| 大型全局状态 | Pinia |
官方推荐,替代 Vuex |
| 内容布局定制 | Slots |
特别是作用域插槽 |
在实际开发中,应优先使用 Props/Emits 和 Pinia,仅在特定场景下使用 Provide/Inject 或 Mitt,以保持代码的可维护性和清晰的数据流向。