兄弟组件(同一个父组件下的两个子组件)彼此之间没有直接的注入关系,无法通过 props 或 provide/inject 直接通信。无关联组件(比如两个不同页面下的组件)更是如此。解决这类通信问题,核心思路是找一个双方都能访问到的中间人。根据场景复杂度,有三种主流方案。
方案一:状态提升(通过共同父组件中转)
如果兄弟组件有同一个直接父组件,最自然的做法是把共享状态提升到父组件中,然后通过 props 向下传递,通过 emits 向上通知。
<!-- Parent.vue -->
<template>
<ChildA :selectedId="selectedId" @select="handleSelect" />
<ChildB :selectedId="selectedId" />
</template>
<script setup>
import { ref } from 'vue'
const selectedId = ref(null)
function handleSelect(id) {
selectedId.value = id
}
</script>
ChildA 触发 select 事件 → 父组件更新 selectedId → 通过 props 流向 ChildB,完成兄弟通信。
适用场景:组件关系紧密、共享状态简单、父组件本身不臃肿的情况。
优点:数据流清晰,符合单向数据流,易于调试。
缺点:如果组件嵌套很深,父组件会变成“数据中转站”,大量 props 和 emits 让代码啰嗦(即 props drilling)。此时可以考虑 provide/inject 或者全局状态管理。
方案二:事件总线(EventBus)
事件总线本质上是一个全局的发布/订阅中心。任何组件都可以向它发送事件,任何组件都可以监听事件,实现完全解耦的通信。
在 Vue 2 中,可以直接创建一个空的 Vue 实例作为事件总线,使用 $on、$emit、$off。Vue 3 移除了这些实例方法,官方推荐使用第三方库 mitt 来实现相同效果。
// eventBus.js
import mitt from 'mitt'
export const bus = mitt()
<!-- ComponentA.vue (发送方) -->
<script setup>
import { bus } from './eventBus'
bus.emit('user-updated', { id: 1, name: 'Alice' })
</script>
<!-- ComponentB.vue (接收方) -->
<script setup>
import { bus } from './eventBus'
import { onMounted, onUnmounted } from 'vue'
const handler = (data) => {
console.log('收到更新:', data)
}
onMounted(() => bus.on('user-updated', handler))
onUnmounted(() => bus.off('user-updated', handler)) // 务必解绑,防止内存泄漏
</script>
适用场景:简单的跨组件通知(如全局消息提示、界面主题切换),且不适合引入完整状态库的小项目。
优点:实现简单,组件解耦彻底。
缺点:
- 追踪困难:事件流向是隐式的,调试时很难快速找到“是谁触发了这件事”。
- 类型不安全:事件的名称和载荷都是字符串和 any,容易写错,且重构时 lint 不会提示。
- 容易导致混乱:大量使用后,事件调用关系如同蜘蛛网,后续维护者难以理清。
建议:除非是几个极度简单且相隔很远的通知场景,否则应优先考虑 Pinia 或 Vue 3 的 provide/inject,后者在现代组合式 API 中也支持响应式传递。
方案三:全局状态库(Pinia / Vuex)
当共享状态不仅涉及多个兄弟组件,还跨越路由页面、模块、甚至全局,就需要一个集中式的状态容器。它既是数据存储中心,也是修改数据的唯一入口,确保状态可预测、可追踪。
目前 Vue 官方推荐 Pinia,相比 Vuex,API 更简洁,对 TypeScript 支持极好,且不需要手动区分 mutation 和 action。
// stores/user.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({ name: 'Alice', login: false }),
actions: {
login(username) {
this.name = username
this.login = true
}
}
})
<!-- ComponentA.vue -->
<script setup>
import { useUserStore } from '@/stores/user'
const user = useUserStore()
function doLogin() {
user.login('Bob') // 直接调用 action
}
</script>
<!-- ComponentB.vue -->
<script setup>
import { useUserStore } from '@/stores/user'
const user = useUserStore()
// user.name 和 user.login 在任何组件中读取的值始终保持同步
</script>
适用场景:跨页面、跨模块的共享状态(如用户信息、购物车、主题配置、权限数据),或需要 DevTools 历史追踪的中大型应用。
优点:
- 数据流清晰:所有状态修改都在 Store 中完成,可预知、可调试。
- 自动响应式:Store 中的 state 本身就是响应式的,任何组件引用后自动同步。
- 扩展性强:支持插件、持久化、模块拆分等高级功能。
- 工具链完善:Pinia 完美集成 Vue DevTools,能查看状态快照、时间旅行调试。
与事件总线的根本区别:全局状态库管理的是数据,事件总线管理的是通知。大部分兄弟组件的同步需求本质上都是“想要同样的数据”,所以天然适合用状态管理方案,而不是去发事件。
方案选择建议
| 场景 | 推荐方案 |
|------|----------|
| 同一个父组件下,状态简单,嵌套不深 | 状态提升(父组件中转) |
| 少数几个无关联组件之间的轻量通信(如通知) | mitt 事件总线(确保解绑) |
| 跨页面、跨模块、需要共享和持久化的复杂状态 | Pinia(或 Vuex) |
| provide/inject 可以兼顾跨层级和响应式,但更适合“祖先传后代”的一家子,兄弟通信仍需通过祖先 | 配合状态提升一起用 |
一句话总结:不要为了“避免 props 传递”就立刻上全局状态库,也不要为了“简单”就到处撒事件总线。从数据流和组件关系出发,选择最自然、最易追踪的方案。