含 provide/inject、EventBus、Pinia/Vuex:看场景选方案,少走弯路很多 Vue 项目一开始只是父子传值。 页面越拆越细之后,问题就来了:一个字段要穿过五六层组件;一个弹窗开关,不知道是谁触发;一个全局状态,既在 props 里传,又在 bus 里发,还在 store 里存。 问题往往不在于 Vue 通信方式不够多,而在于没有按场景选方案。 本文把 8 种常见通信方式放到同一张工程视角里看:Props、Emits、Provide/Inject、EventBus、Pinia/Vuex、$attrs、$refs/$parent、Slot。 重点不是背 API,而是判断:什么时候该用,什么时候别用。
通信方式的选择,本质是架构边界的选择。
1. Props & Emits:父子通信的默认答案
Props 向下传数据,Emits 向上通知变化。
这是 Vue 最基础、也最推荐的父子通信方式。
它的优点是数据流清晰:父组件负责状态,子组件负责展示和触发事件。
问题也常常出在这里:props 不写类型,emit 不声明事件,子组件直接改 props。
这些写法短期省事,长期会让组件变得不可预测。
父子通信的核心原则:子组件不要直接修改 props。
如果子组件需要改变值,应该 emit 事件,让父组件更新。
vue
<script setup>
const props = defineProps({
title: {
type: String,
required: true,
validator: (value: string) => value.length <= 20
},
count: {
type: Number,
default: 0
}
})
const emit = defineEmits<{
(e: 'update:count', value: number): void
(e: 'submit', payload: { title: string }): void
}>()
function add() {
emit('update:count', props.count + 1)
}
</script>
<template>
<button @click="add">{{ title }}:{{ count }}</button>
</template>
父组件使用:
vue
<CounterCard
title="库存"
v-model:count="stock"
@submit="handleSubmit"
/>
这里的 `v-model:count`,本质就是 `:count + @update:count`。
工程建议:
- 父子关系明确时,优先使用 Props/Emits。
- props 一定写类型,复杂值建议配合 TypeScript。
- emit 事件名要稳定,不要随意改。
- 子组件只表达“发生了什么”,不要替父组件决定业务。
2. Provide/Inject:跨级传值,不必层层转发
当组件层级很深时,Props 会变得痛苦。
例如:页面组件有用户信息,真正使用它的是第五层按钮。中间几层组件并不关心这个数据,却必须一层层传下去。
这就是典型的 props drilling。
Provide/Inject 就是为跨级传值准备的。
祖先组件 provide,后代组件 inject,中间组件不需要参与。
vue
<!-- Parent.vue -->
<script setup>
import { ref, computed, provide } from 'vue'
const user = ref({ name: 'Ada', role: 'admin' })
provide('currentUser', computed(() => user.value))
</script>
vue
<!-- DeepChild.vue -->
<script setup>
import { inject, type ComputedRef } from 'vue'
const currentUser = inject<ComputedRef<{ name: string; role: string }>>(
'currentUser'
)
if (!currentUser) {
throw new Error('currentUser is not provided')
}
</script>
<template>
<span>{{ currentUser.value.name }}</span>
</template>
如果不希望报错,可以提供默认值:
ts
const theme = inject('theme', 'light')
Provide/Inject 适合“稳定、低频、跨层级”的上下文数据。
比如主题、语言、表单上下文、用户基础信息、权限上下文。
但它也有代价:数据来源不如 props 直观。
组件看起来没有入参,实际却依赖了上层注入。
所以,不要把所有业务状态都塞进 provide。
工程建议:
- provide 响应式数据时,使用 `ref`、`reactive` 或 `computed`。
- inject 必须处理默认值或异常情况。
- key 建议使用 Symbol,避免字符串冲突。
- 高频业务状态优先考虑 Pinia。
3. EventBus:能解耦,也容易失控
EventBus 的思路很简单:所有组件都往一个事件中心发消息,也从这个事件中心监听消息。
它能让任意组件通信,尤其适合兄弟组件、非直接关系组件。
Vue 2 时代,很多人用一个 Vue 实例做 bus。
Vue 3 不再推荐这种方式,一般使用 `mitt`。
安装:
创建 bus:
ts
// eventBus.ts
import mitt from 'mitt'
type Events = {
'modal:open': { id: string }
'user:refresh': void
}
export const eventBus = mitt<Events>()
组件 A 触发:
ts
import { eventBus } from './eventBus'
eventBus.emit('modal:open', { id: 'create-user' })
组件 B 监听:
ts
import { onMounted, onUnmounted } from 'vue'
import { eventBus } from './eventBus'
function handleOpen(payload: { id: string }) {
console.log(payload.id)
}
onMounted(() => {
eventBus.on('modal:open', handleOpen)
})
onUnmounted(() => {
eventBus.off('modal:open', handleOpen)
})
这里最容易踩的坑,是忘记 `off`。
组件反复挂载后,监听器会重复注册,导致事件触发多次,甚至内存泄漏。
EventBus 最大的问题不是不能用,而是难追踪。
你很难从代码里一眼看出:事件是谁发的,谁接的,数据流向哪里。
工程建议:
- 小项目、低频事件可以用。
- 弹窗打开、全局提示、一次性通知可以考虑。
- 复杂业务状态不要用 EventBus 管。
- 事件名统一命名,最好有类型约束。
- 必须在卸载时移除监听。
4. Pinia vs Vuex:全局状态要可追踪
当状态被多个页面、多个组件共享时,就应该考虑状态管理。
Vue 3 里,优先选择 Pinia。
相比 Vuex,Pinia 概念更少:没有 mutations,天然支持模块化,TypeScript 体验也更自然。
一个基础 store:
ts
// stores/user.ts
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
token: '',
profile: null as null | { name: string; role: string }
}),
getters: {
isLogin: (state) => Boolean(state.token)
},
actions: {
setToken(token: string) {
this.token = token
},
async fetchProfile() {
// const res = await api.getUser()
this.profile = { name: 'Ada', role: 'admin' }
}
}
})
组件中使用:
vue
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const { token, profile, isLogin } = storeToRefs(userStore)
function logout() {
userStore.setToken('')
}
</script>
注意一个常见坑:
ts
const { token } = userStore // 可能丢失响应性解构 store 的 state/getters 时,用 `storeToRefs`。
actions 可以直接解构,因为它们已经绑定好上下文。
全局共享、可追踪、需要调试的业务状态,交给 Pinia。
适合放进 Pinia 的数据:
- 用户登录态。
- 权限信息。
- 多页面共享筛选条件。
- 购物车。
- 全局配置。
不适合放进 Pinia 的数据:
- 单个组件内部开关。
- 只在父子之间使用的临时字段。
- 表单局部输入过程状态。
Vuex 在老项目里仍然常见。
新项目如果没有历史包袱,建议直接 Pinia。
5. $attrs、$refs、$parent、Slot:特殊通道要少而准
除了常规方案,Vue 还有一些“特殊通道”。
它们不是主力通信方式,但在特定场景很有用。
先说 `$attrs`。
它适合封装高阶组件。
比如你封装了一个 `BaseInput`,希望父组件传入的原生属性继续透传到底层 input。
vue
<script setup>
defineOptions({ inheritAttrs: false })
</script>
<template>
<label>
<span>名称</span>
<input v-bind="$attrs" />
</label>
</template>
再说 `$refs`。
它可以让父组件直接调用子组件方法。
vue
<!-- Child.vue -->
<script setup>
function focus() {
// do something
}
defineExpose({ focus })
</script>
vue
<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const childRef = ref<InstanceType<typeof Child> | null>(null)
function handleClick() {
childRef.value?.focus()
}
</script>
<template>
<Child ref="childRef" />
</template>
`$refs` 适合调用明确的命令式能力,比如 focus、reset、open。
但不要用它读取子组件内部状态。
`$parent` 可以访问父实例,但强耦合非常严重,基本不建议在业务代码里使用。
Slot 则是另一种优雅的通信方式。
子组件通过作用域插槽把数据暴露给父组件,由父组件决定如何渲染。
vue
<!-- ListProvider.vue -->
<script setup>
const list = [
{ id: 1, name: 'Vue' },
{ id: 2, name: 'Pinia' }
]
</script>
<template>
<slot :list="list" />
</template>
vue
<!-- Parent.vue -->
<ListProvider v-slot="{ list }">
<ul>
<li v-for="item in list" :key="item.id">
{{ item.name }}
</li>
</ul>
</ListProvider>
这些方式适合边界场景,不适合作为主通信模型。
6. 选型指南:不是都会用,而是会取舍
真正的工程能力,不是知道 8 种方式,而是知道什么时候不用某一种。
可以按下面这张表判断:
| 通信方式 | 适用关系 | 优点 | 风险 | 推荐级别 |
|---|---|---|---|---|
| Props | 父传子 | 显式、简单、可维护 | 层级深会冗余 | 高 |
| Emits | 子传父 | 数据流清晰 | 事件设计混乱会难维护 | 高 |
| Provide/Inject | 跨级组件 | 避免层层传递 | 依赖隐式化 | 中高 |
| EventBus | 任意组件 | 快速、灵活 | 难追踪、易泄漏 | 低中 |
| Pinia | 全局/跨页面 | 可追踪、可调试 | 滥用会全局污染 | 高 |
| Vuex | 老项目全局状态 | 生态成熟 | 样板代码多 | 中 |
| $attrs | 包装组件透传 | 封装友好 | 属性来源不明显 | 中 |
| $refs/$parent | 命令式调用 | 直接 | 强耦合 | 低 |
| Slot | 子向父暴露渲染数据 | 灵活、解耦展示 | 嵌套复杂会难读 | 中高 |
简单记:
父子用 Props/Emits。
跨级上下文用 Provide/Inject。
全局业务状态用 Pinia。
命令式能力用 refs。
事件通知慎用 EventBus。
架构上还要看三个维度。
第一,数据生命周期。
如果数据只存在于一个组件,就不要提升到全局。
第二,数据影响范围。
如果多个页面都依赖,Pinia 比 props 更合适。
第三,调试成本。
如果一个状态变化需要排查链路,显式方案优先。
通信方式越隐式,越要控制使用范围。
父子通信不要绕路,全局状态不要硬传。
能显式表达数据流,就不要依赖隐式魔法。
组件通信不是“会几种 API”就够了,关键是根据组件关系、数据生命周期和维护成本做选择。 父子优先 Props/Emits,跨级少量共享用 Provide/Inject,全局业务状态交给 Pinia。 EventBus、$refs、$parent 这类方案可以用,但要克制,只放在边界场景。把通信边界设计清楚,后续迭代才不会被隐式依赖拖垮。如果你正在整理 Vue 项目的组件通信方案,欢迎关注,后面继续聊更多 Vue 工程化实践。