零、为什么组件通信是 Vue3 的核心难点?
想象一下这个场景:你正在开发一个复杂的后台管理系统,有一个需求是在 5 层嵌套的组件中,需要把最底层的表单数据提交到最顶层的页面。这时候你怎么办?一层层 props 传上去?还是用 $emit 冒泡?
更头疼的是:面试必问!
“Vue3 中有哪些组件通信方式?分别适用于什么场景?”
今天,我们就用一篇文章,把 Vue3 的 8 种组件通信方式讲透,附代码 + 场景 + 选型建议。
一、组件关系图谱
先搞清楚组件之间的关系,不同关系选择不同的通信方式:
┌──────────────────────────────────────────┐
│ App (根组件) │
│ ┌─────────────────────────────────────┐ │
│ │ Parent (父组件) │ │
│ │ ┌───────────┐ ┌───────────┐ │ │
│ │ │ Child │ │ Child │ │ │
│ │ │ ┌───────┐ │ └───────────┘ │ │
│ │ │ │Grand- │ │ │ │
│ │ │ │Child │ │ │ │
│ │ │ └───────┘ │ │ │
│ │ └───────────┘ │ │
│ └─────────────────────────────────────┘ │
└──────────────────────────────────────────┘
通信关系:
- 父子:props / emit / ref / v-model
- 祖孙:provide / inject
- 兄弟:mitt / eventBus
- 全局:Pinia / Vuex
二、父子组件通信(4 种方式)
2.1 Props / Emit — 最基础的通信
Parent.vue
<!-- Parent.vue -->
<template>
<div class="parent">
<h2>父组件</h2>
<p>收到子组件消息:{{ childMessage }}</p>
<Child
:message="parentMessage"
@reply="handleReply"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'
// 传递给子组件的数据
const parentMessage = ref('我是父组件的数据')
// 接收子组件的消息
const childMessage = ref('')
const handleReply = (msg: string) => {
childMessage.value = msg
}
</script>
Child.vue
<!-- Child.vue -->
<template>
<div class="child">
<h3>子组件</h3>
<p>收到父组件消息:{{ message }}</p>
<button @click="sendReply">回复父组件</button>
</div>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue'
// 接收 props
const props = defineProps<{
message: string
}>()
// 定义 emit
const emit = defineEmits<{
(e: 'reply', msg: string): void
}>()
const sendReply = () => {
emit('reply', '你好,父组件!我是子组件')
}
</script>
TypeScript 类型提示:Vue3 的 defineProps 和 defineEmits 提供了完整的类型推导。
2.2 v-model — 双向绑定的优雅写法
Parent.vue
<!-- Parent.vue -->
<template>
<div>
<h2>父组件</h2>
<p>用户名:{{ username }}</p>
<!-- v-model 语法糖 -->
<Child v-model="username" />
<!-- 等价于 -->
<!-- <Child :modelValue="username" @update:modelValue="username = $event" /> -->
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'
const username = ref('张三')
</script>
Child.vue
<!-- Child.vue -->
<template>
<div>
<h3>子组件 - 编辑用户名</h3>
<input
:value="modelValue"
@input="updateValue"
placeholder="输入用户名"
/>
</div>
</template>
<script setup lang="ts">
defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const updateValue = (e: Event) => {
const target = e.target as HTMLInputElement
emit('update:modelValue', target.value)
}
</script>
多个 v-model:Vue3 支持绑定多个值
<template>
<Child v-model:name="name" v-model:age="age" />
</template>
<!-- Child.vue -->
<script setup>
defineProps<{
name: string
age: number
}>()
const emit = defineEmits<{
(e: 'update:name', value: string): void
(e: 'update:age', value: number): void
}>()
</script>
2.3 ref / defineExpose — 父组件调用子组件方法
Parent.vue
<!-- Parent.vue -->
<template>
<div>
<h2>父组件</h2>
<button @click="callChildMethod">调用子组件方法</button>
<button @click="getChildData">获取子组件数据</button>
<Child ref="childRef" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'
// 获取子组件实例
const childRef = ref<InstanceType<typeof Child>>()
const callChildMethod = () => {
// 调用子组件暴露的方法
childRef.value?.sayHello()
}
const getChildData = () => {
// 获取子组件暴露的数据
console.log('子组件内部计数:', childRef.value?.count)
alert(`子组件计数: ${childRef.value?.count}`)
}
</script>
Child.vue
<!-- Child.vue -->
<template>
<div>
<h3>子组件</h3>
<p>内部计数: {{ count }}</p>
<button @click="count++">+1</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const sayHello = () => {
alert('你好,我是子组件!')
}
// 暴露给父组件的方法和数据
defineExpose({
count,
sayHello
})
</script>
2.4 透传 Attributes — $attrs 的妙用
Parent.vue
<!-- Parent.vue -->
<template>
<div>
<h2>父组件</h2>
<Child
class="custom-class"
data-id="123"
@click="handleClick"
:custom-prop="'hello'"
/>
</div>
</template>
<script setup>
const handleClick = () => {
console.log('点击事件')
}
</script>
Child.vue
<!-- Child.vue -->
<template>
<div>
<h3>子组件</h3>
<!-- 透传所有属性到内部元素 -->
<button v-bind="$attrs">透传按钮</button>
</div>
</template>
<script setup lang="ts">
import { useAttrs } from 'vue'
// 获取透传属性(不包含 props 和 emit)
const attrs = useAttrs()
console.log('透传属性:', attrs)
// 输出: { class: 'custom-class', 'data-id': '123', onClick: fn, customProp: 'hello' }
</script>
三、祖孙组件通信(2 种方式)
3.1 provide / inject — 跨层级传递数据
GrandParent.vue
<!-- GrandParent.vue -->
<template>
<div>
<h2>祖组件</h2>
<p>主题色: {{ themeColor }}</p>
<button @click="changeTheme">切换主题</button>
<Parent />
</div>
</template>
<script setup lang="ts">
import { ref, provide } from 'vue'
import Parent from './Parent.vue'
const themeColor = ref('#409eff')
// 提供数据给所有后代组件
provide('themeColor', themeColor)
// 提供方法
const changeTheme = () => {
themeColor.value = themeColor.value === '#409eff' ? '#67c23a' : '#409eff'
}
provide('changeTheme', changeTheme)
</script>
Parent.vue
<!-- Parent.vue -->
<template>
<div>
<h3>父组件(中间层)</h3>
<Child />
</div>
</template>
<script setup>
import Child from './Child.vue'
// 注意:中间组件不需要做任何事
</script>
Child.vue
<!-- Child.vue -->
<template>
<div>
<h4>孙组件</h4>
<p>当前主题色: <span :style="{ color: themeColor }">{{ themeColor }}</span></p>
<button @click="changeTheme">改变主题(调用祖先方法)</button>
</div>
</template>
<script setup lang="ts">
import { inject } from 'vue'
// 注入祖先组件提供的数据
const themeColor = inject<Ref<string>>('themeColor', ref('#409eff'))
// 注入祖先组件提供的方法
const changeTheme = inject<() => void>('changeTheme', () => {})
// 也可以注入响应式数据直接修改(但不推荐,单向数据流)
// const updateTheme = inject<(color: string) => void>('updateTheme')
</script>
响应式 provide:使用 ref 或 reactive 实现响应式传递
// 推荐:提供响应式数据
const state = reactive({
user: null,
theme: 'light'
})
provide('appState', state)
// 后代组件中可以修改(需要约定)
const appState = inject('appState')
appState.user = { name: '张三' } // 会影响祖先
3.2 插槽 (Slot) — 模板分发的高级用法
GrandParent.vue
<!-- GrandParent.vue -->
<template>
<div>
<h2>祖组件</h2>
<Parent>
<!-- 作用域插槽:将数据传递给父组件 -->
<template #default="{ user }">
<div class="user-card">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
</div>
</template>
</Parent>
</div>
</template>
<script setup>
import Parent from './Parent.vue'
// 数据在祖组件中
const users = [
{ id: 1, name: '张三', email: 'zhangsan@example.com' },
{ id: 2, name: '李四', email: 'lisi@example.com' }
]
</script>
Parent.vue
<!-- Parent.vue -->
<template>
<div>
<h3>父组件(传递数据)</h3>
<Child>
<!-- 将数据通过插槽传递给上级 -->
<template #default="{ user }">
<slot :user="user" />
</template>
</Child>
</div>
</template>
<script setup>
import Child from './Child.vue'
</script>
Child.vue
<!-- Child.vue -->
<template>
<div>
<h4>孙组件(渲染数据)</h4>
<div v-for="user in users" :key="user.id">
<slot :user="user" />
</div>
</div>
</template>
<script setup>
const users = [
{ id: 1, name: '张三', email: 'zhangsan@example.com' },
{ id: 2, name: '李四', email: 'lisi@example.com' }
]
</script>
四、兄弟组件通信(1 种方式)
mitt — 轻量级事件总线
npm install mitt
utils/eventBus.ts
// utils/eventBus.ts
import mitt from 'mitt'
import type { Emitter } from 'mitt'
// 定义事件类型
type Events = {
'user:login': { username: string; token: string }
'user:logout': void
'cart:update': number // 购物车数量
'notification': string
}
const emitter: Emitter<Events> = mitt<Events>()
export default emitter
ComponentA.vue (发送方)
<!-- ComponentA.vue (发送方) -->
<template>
<div>
<h3>组件 A</h3>
<button @click="handleLogin">登录</button>
<button @click="addToCart">添加购物车</button>
</div>
</template>
<script setup lang="ts">
import eventBus from '@/utils/eventBus'
const handleLogin = () => {
// 发送登录事件
eventBus.emit('user:login', {
username: '张三',
token: 'xxx-token'
})
}
const addToCart = () => {
// 发送购物车更新事件
eventBus.emit('cart:update', 5)
}
</script>
ComponentB.vue (接收方)
<!-- ComponentB.vue (接收方) -->
<template>
<div>
<h3>组件 B</h3>
<p>购物车数量: {{ cartCount }}</p>
<p>当前用户: {{ username || '未登录' }}</p>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import eventBus from '@/utils/eventBus'
const cartCount = ref(0)
const username = ref('')
// 监听事件
onMounted(() => {
// 监听登录事件
eventBus.on('user:login', (data) => {
username.value = data.username
console.log('用户登录:', data)
})
// 监听购物车更新
eventBus.on('cart:update', (count) => {
cartCount.value = count
console.log('购物车更新:', count)
})
})
// 组件卸载时取消监听
onUnmounted(() => {
eventBus.off('user:login')
eventBus.off('cart:update')
})
</script>
全局注册为 Vue 插件(可选)
plugins/eventBus.ts
// plugins/eventBus.ts
import mitt from 'mitt'
import type { App } from 'vue'
const emitter = mitt()
export default {
install(app: App) {
app.config.globalProperties.$bus = emitter
}
}
// main.ts
import eventBusPlugin from './plugins/eventBus'
app.use(eventBusPlugin)
// 在组件中使用
const { $bus } = getCurrentInstance()?.appContext.config.globalProperties
$bus.emit('event', data)
五、全局状态管理(1 种方式)
Pinia — 官方推荐的状态管理
stores/user.ts
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// state
const token = ref<string | null>(null)
const userInfo = ref<{ name: string; role: string } | null>(null)
// getters
const isLoggedIn = computed(() => !!token.value)
const userName = computed(() => userInfo.value?.name || '游客')
// actions
function login(username: string, password: string) {
// 模拟登录请求
token.value = 'fake-token'
userInfo.value = { name: username, role: 'admin' }
localStorage.setItem('token', token.value)
}
function logout() {
token.value = null
userInfo.value = null
localStorage.removeItem('token')
}
return {
token,
userInfo,
isLoggedIn,
userName,
login,
logout
}
})
任何组件都可以直接使用
<!-- 任何组件都可以直接使用 -->
<template>
<div>
<div v-if="userStore.isLoggedIn">
<p>欢迎回来,{{ userStore.userName }}</p>
<button @click="userStore.logout">退出登录</button>
</div>
<div v-else>
<button @click="userStore.login('张三', '123456')">登录</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
</script>
六、其他通信方式
6.1 路由传参
发送方
<!-- 发送方 -->
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
// query 传参(刷新页面不丢失)
router.push({
path: '/user/detail',
query: { id: 123, name: '张三' }
})
// params 传参(刷新页面会丢失)
router.push({
name: 'UserDetail',
params: { id: 123, name: '张三' }
})
</script>
接收方
<!-- 接收方 -->
<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
// 获取 query
const userId = route.query.id
const userName = route.query.name
// 获取 params
const userId = route.params.id
const userName = route.params.name
</script>
6.2 浏览器存储
发送方
// 发送方
localStorage.setItem('userInfo', JSON.stringify(userInfo))
sessionStorage.setItem('tempData', '临时数据')
// 接收方
const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}')
// 监听 storage 变化(跨标签页)
window.addEventListener('storage', (e) => {
if (e.key === 'userInfo') {
console.log('用户信息已更新', e.newValue)
}
})
七、实战对比:不同场景选型建议
7.1 场景分析表
| 场景 | 推荐方案 | 原因 | 代码量 |
|---|---|---|---|
| 父子组件简单数据传递 | Props / Emit | 最直接,符合单向数据流 | 少 |
| 父子组件双向绑定 | v-model | 语法糖,简洁优雅 | 少 |
| 父组件调用子组件方法 | ref / defineExpose | 需要直接操作子组件实例 | 中 |
| 祖孙组件跨层级传递 | provide / inject | 避免 props 逐层传递(props drilling) | 中 |
| 兄弟组件通信 | mitt / Pinia | 无直接关系,需要中间人 | 中 |
| 全局共享状态 | Pinia | 数据复杂,多处使用 | 中 |
| 页面间通信 | Vue Router | 符合路由语义 | 少 |
| 跨标签页通信 | localStorage + storage 事件 | 浏览器原生支持 | 少 |
7.2 选型决策树
开始
│
├─ 组件关系?
│ │
│ ├─ 父子
│ │ │
│ │ ├─ 数据单向传递 → props
│ │ ├─ 事件向上传递 → emit
│ │ ├─ 双向绑定 → v-model
│ │ └─ 调用子组件方法 → ref
│ │
│ ├─ 祖孙
│ │ │
│ │ ├─ 数据复杂 → provide/inject
│ │ └─ 需要灵活性 → 作用域插槽
│ │
│ ├─ 兄弟
│ │ │
│ │ ├─ 简单事件 → mitt
│ │ └─ 复杂状态 → Pinia
│ │
│ └─ 任意组件
│ │
│ ├─ 全局状态 → Pinia
│ ├─ 路由跳转 → Vue Router
│ └─ 跨页面持久化 → localStorage
7.3 性能考虑
// 1. props 传递大量数据时考虑使用 provide/inject
// ❌ 不好:层层传递
<GrandParent :data="bigData" />
<Parent :data="bigData" />
<Child :data="bigData" />
// ✅ 好:直接注入
provide('bigData', bigData)
const bigData = inject('bigData')
// 2. 频繁更新的事件使用节流/防抖
import { debounce } from 'lodash-es'
const handleInput = debounce((value) => {
emit('update', value)
}, 300)
// 3. Pinia 的 store 使用 storeToRefs 避免失去响应性
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
// 保持响应性
const { userInfo, token } = storeToRefs(userStore)
// actions 可以直接解构
const { login, logout } = userStore
八、面试高频题
Q1: props 和 emit 的底层原理是什么?
// 简单来说:
// props 是单向数据流,父组件通过 render 函数将数据传递给子组件
// emit 是通过事件系统,子组件触发父组件绑定的事件
// 源码层面:
// 子组件的 props 会挂载到组件实例的 props 属性上
// emit 本质是调用父组件传递下来的回调函数
Q2: provide/inject 如何实现响应式?
// 响应式原理:
// 1. 提供 ref 或 reactive 对象
const state = reactive({ count: 0 })
provide('state', state)
// 2. 后代注入的是同一个响应式对象
const state = inject('state')
// 修改会同步到所有地方
state.count++
// 注意:不要直接 provide 普通值(会失去响应性)
// ❌ provide('count', 0)
// ✅ provide('count', ref(0))
Q3: 兄弟组件通信除了 mitt 还有什么方式?
// 1. 通过父组件中转(不推荐,会破坏组件独立性)
// ChildA emit → Parent → props → ChildB
// 2. Pinia/Vuex 全局状态
// 3. 事件总线(mitt 就是这种)
// 4. 浏览器存储 + 事件监听
Q4: 如何设计一个跨组件的主题切换功能?
// 方案一:provide/inject
const theme = ref('light')
provide('theme', theme)
provide('toggleTheme', () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
})
// 方案二:Pinia 全局状态
export const useThemeStore = defineStore('theme', {
state: () => ({ theme: 'light' }),
actions: {
toggle() {
this.theme = this.theme === 'light' ? 'dark' : 'light'
}
}
})
// 方案三:CSS 变量 + 根元素类名
document.documentElement.classList.toggle('dark-theme')
九、实战案例:购物车联动
场景描述
- 商品列表页:展示商品,点击「加入购物车」
- 悬浮购物车:实时更新数量和总价
- 购物车页面:展示详细列表
- 导航栏:显示购物车徽标
stores/cart.ts
// stores/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface CartItem {
id: number
name: string
price: number
quantity: number
image: string
}
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
// 从 localStorage 恢复
const saved = localStorage.getItem('cart')
if (saved) {
items.value = JSON.parse(saved)
}
const totalCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
function addItem(product: Omit<CartItem, 'quantity'>, quantity = 1) {
const existing = items.value.find(item => item.id === product.id)
if (existing) {
existing.quantity += quantity
} else {
items.value.push({ ...product, quantity })
}
saveToLocal()
}
function updateQuantity(id: number, quantity: number) {
const item = items.value.find(item => item.id === id)
if (item) {
item.quantity = Math.max(0, quantity)
if (item.quantity === 0) {
items.value = items.value.filter(i => i.id !== id)
}
}
saveToLocal()
}
function removeItem(id: number) {
items.value = items.value.filter(item => item.id !== id)
saveToLocal()
}
function clearCart() {
items.value = []
saveToLocal()
}
function saveToLocal() {
localStorage.setItem('cart', JSON.stringify(items.value))
}
return {
items,
totalCount,
totalPrice,
addItem,
updateQuantity,
removeItem,
clearCart
}
})
ProductCard.vue (商品卡片)
<!-- ProductCard.vue (商品卡片) -->
<template>
<div class="product-card">
<img :src="product.image" :alt="product.name">
<h4>{{ product.name }}</h4>
<p>¥{{ product.price }}</p>
<button @click="addToCart">加入购物车</button>
</div>
</template>
<script setup lang="ts">
import { useCartStore } from '@/stores/cart'
const props = defineProps<{
product: { id: number; name: string; price: number; image: string }
}>()
const cartStore = useCartStore()
const addToCart = () => {
cartStore.addItem(props.product, 1)
// 可以添加提示
ElMessage.success('已加入购物车')
}
</script>
NavBar.vue (导航栏徽标)
<!-- NavBar.vue (导航栏徽标) -->
<template>
<div class="navbar">
<span>商城</span>
<div class="cart-badge" @click="showCart = true">
<el-badge :value="cartStore.totalCount" :hidden="cartStore.totalCount === 0">
<el-icon><ShoppingCart /></el-icon>
</el-badge>
</div>
</div>
</template>
<script setup lang="ts">
import { useCartStore } from '@/stores/cart'
const cartStore = useCartStore()
</script>
FloatingCart.vue (悬浮购物车)
<!-- FloatingCart.vue (悬浮购物车) -->
<template>
<el-drawer v-model="visible" title="购物车" size="400px">
<div v-if="cartStore.items.length === 0" class="empty-cart">
<el-empty description="购物车是空的" />
</div>
<div v-else>
<div v-for="item in cartStore.items" :key="item.id" class="cart-item">
<img :src="item.image" class="item-img">
<div class="item-info">
<div class="item-name">{{ item.name }}</div>
<div class="item-price">¥{{ item.price }}</div>
</div>
<el-input-number
v-model="item.quantity"
:min="1"
size="small"
@change="(val) => cartStore.updateQuantity(item.id, val)"
/>
<el-button type="danger" :icon="Delete" link @click="cartStore.removeItem(item.id)" />
</div>
<div class="cart-footer">
<div class="total">
<span>总计:</span>
<span class="total-price">¥{{ cartStore.totalPrice.toFixed(2) }}</span>
</div>
<el-button type="primary" @click="goToCart">去结算</el-button>
</div>
</div>
</el-drawer>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useCartStore } from '@/stores/cart'
const router = useRouter()
const cartStore = useCartStore()
const visible = ref(false)
const goToCart = () => {
visible.value = false
router.push('/cart')
}
</script>
十、总结
10.1 8 种通信方式速查表
| 方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| props | 父→子 | 简单直观,类型安全 | 只能单向,嵌套深时传递麻烦 |
| emit | 子→父 | 事件驱动,解耦 | 需要逐层传递 |
| v-model | 表单双向绑定 | 语法糖,简洁 | 只能绑定一个值(Vue3支持多个) |
| ref | 父调用子方法 | 直接操作子组件 | 耦合度高,破坏组件独立性 |
| provide/inject | 跨层级传递 | 避免 props drilling | 数据流向不清晰 |
| mitt | 兄弟/任意组件 | 轻量,解耦 | 需要手动管理监听 |
| Pinia | 全局复杂状态 | DevTools支持,响应式 | 学习成本 |
| router | 页面间 | 符合路由语义 | 仅限页面跳转 |
10.2 最佳实践建议
- 优先使用 props/emit:保持组件独立性
- 复杂状态用 Pinia:统一管理,易于调试
- 跨层级传递用 provide/inject:避免 props 地狱
- 临时事件用 mitt:注意手动清理监听
- 表单用 v-model:简洁优雅
- 避免滥用 ref:除非必要,不要直接操作子组件
10.3 进阶思考
当你掌握了这些通信方式,就可以思考:
- 如何封装一个通用的弹窗组件(props + emit + v-model)
- 如何设计一个权限系统(provide/inject + Pinia)
- 如何实现一个多标签页应用(Pinia + Vue Router)
记住:选择正确的通信方式,能让代码更优雅、更易维护。希望这篇文章能帮你彻底掌握 Vue3 组件通信!🎉