4、组件通信:父子组件通信(Props/$emit)、兄弟组件通信、跨层级通信(Provide/Inject)、全局状态管理
组件通信,说白了就是让组件之间能「说上话」。
我刚开始写 Vue 的时候,总觉得组件化就是把页面拆碎,然后各自为政。后来发现根本不是这么回事——组件之间要传数据、要通知事件、要共享状态。你想想看,一个电商详情页,商品卡片要告诉购物车「我加了件商品」,购物车要告诉头部导航「我的数量变了」,这中间要是通信没设计好,代码就乱成一锅粥。
今天咱们就把组件通信的几种方式捋清楚。我按使用频率和场景来排,从最基础的到最复杂的,一个一个说。
4.1 父子组件通信:Props 与 $emit
这是最基础、也是最常用的通信方式。父组件通过 props 往下传数据,子组件通过 $emit 往上抛事件。
核心原则:单向数据流。父组件的数据变化会自动流向子组件,但子组件不能直接修改父组件的数据。
先看父组件怎么传:
<!-- ParentComponent.vue -->
<template>
<ChildComponent
:user-name="currentUser.name"
:age="currentUser.age"
@update-age="handleAgeUpdate"
/>
</template>
<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'
const currentUser = ref({
name: '张三',
age: 25
})
const handleAgeUpdate = (newAge) => {
currentUser.value.age = newAge
}
</script>
再看子组件怎么收:
<!-- ChildComponent.vue -->
<template>
<div>
<p>姓名:{{ userName }}</p>
<p>年龄:{{ age }}</p>
<button @click="incrementAge">长大一岁</button>
</div>
</template>
<script setup>
const props = defineProps({
userName: String,
age: Number
})
const emit = defineEmits(['update-age'])
const incrementAge = () => {
emit('update-age', props.age + 1)
}
</script>
我的经验:Props 的命名我习惯用 kebab-case(短横线分隔),但在子组件里接收时用 camelCase。这样模板里看着清晰,JS 里也符合变量命名习惯。
这里有个坑,我踩过好几次——Props 是只读的。子组件里千万别直接 props.age = 30,Vue 会警告你。正确的做法是抛事件让父组件去改。
我曾经遇到过一个 bug:子组件里直接修改了 props 里的对象属性,页面没报错,但数据乱了。因为对象是引用类型,子组件改了属性,父组件的对象也跟着变了。这种「隐式修改」特别难排查。所以我的建议是:任何 props 都不要在子组件里改,哪怕只是改个属性。
4.2 兄弟组件通信
兄弟组件之间没有直接的父子关系,不能直接用 props 和 $emit。常见的方案有两种:
- 通过共同的父组件做中转:A 兄弟抛事件给父组件,父组件再传 props 给 B 兄弟。
- 使用事件总线(Event Bus):创建一个全局的事件中心,兄弟之间直接通信。
先说第一种,虽然绕了点,但胜在清晰:
<!-- Parent.vue -->
<template>
<div>
<SiblingA @send-to-b="handleToB" />
<SiblingB :message="messageForB" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import SiblingA from './SiblingA.vue'
import SiblingB from './SiblingB.vue'
const messageForB = ref('')
const handleToB = (msg) => {
messageForB.value = msg
}
</script>
第二种,事件总线。Vue 3 里官方不推荐了,但老项目里还能见到:
// eventBus.js
import mitt from 'mitt'
export const bus = mitt()
// SiblingA.vue
import { bus } from './eventBus'
bus.emit('custom-event', '你好,兄弟!')
// SiblingB.vue
import { bus } from './eventBus'
bus.on('custom-event', (msg) => {
console.log(msg) // 你好,兄弟!
})
我个人习惯:兄弟通信如果只有一两个场景,用父组件中转就够了。如果场景多,我会考虑用 Provide/Inject 或者全局状态管理,而不是事件总线。事件总线用多了,代码会变得「神出鬼没」,你都不知道谁在监听谁在触发。
4.3 跨层级通信:Provide / Inject
这个 API 解决的是「祖孙组件」之间的通信问题。你想想看,如果有个深层嵌套的组件,要拿到根组件的数据,用 props 一层层传下去?那代码得写成什么样。
Provide / Inject 就是干这个的。祖先组件提供数据,后代组件直接注入,中间跳多少层都无所谓。
<!-- 祖先组件 -->
<script setup>
import { provide, ref } from 'vue'
const theme = ref('dark')
const user = ref({ name: '李四', role: 'admin' })
provide('theme', theme)
provide('user', user)
</script>
<!-- 后代组件(不管嵌套多深) -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
const user = inject('user')
// 也可以设置默认值
const config = inject('config', { pageSize: 20 })
</script>
注意:Provide 提供的是响应式数据,后代组件注入后,数据变化会自动更新。但如果你提供的是一个普通值(不是 ref 或 reactive),那就不是响应式的。
我有个习惯:用 Symbol 作为注入的 key。这样可以避免命名冲突,尤其是在大型项目里:
// keys.js
export const THEME_KEY = Symbol('theme')
export const USER_KEY = Symbol('user')
// 提供方
provide(THEME_KEY, theme)
// 注入方
const theme = inject(THEME_KEY)
我曾经踩过的坑:Provide 的数据在祖先组件里改了,但后代组件没更新。排查了半天,发现是我 provide 了一个普通对象,而不是 ref。记住:要响应式,就用 ref 或 reactive 包一层。
4.4 全局状态管理
当组件通信变得复杂,比如多个不相关的组件要共享同一份数据,或者数据需要在路由切换后保持,那就得上全局状态管理了。
Vue 生态里最常用的是 Pinia(Vue 3 官方推荐)。它比 Vuex 更轻量,TypeScript 支持也更好。
先看一个简单的 Store:
// stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
history: []
}),
getters: {
doubleCount: (state) => state.count * 2
},
actions: {
increment() {
this.count++
this.history.push(`增加至 ${this.count}`)
},
decrement() {
this.count--
this.history.push(`减少至 ${this.count}`)
}
}
})
在组件里使用:
<script setup>
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
// 直接访问 state
console.log(counter.count)
// 调用 action
counter.increment()
// 使用 getter
console.log(counter.doubleCount)
</script>
我的建议:别一上来就用全局状态管理。很多场景下,Props + $emit 或者 Provide/Inject 就够用了。全局状态管理适合那些「跨页面、跨组件、需要持久化」的数据,比如用户登录信息、购物车数据、主题配置等。
什么时候该用全局状态管理?我列了个判断标准:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 父子组件传值 | Props / $emit | 最直接,数据流向清晰 |
| 兄弟组件通信 | 父组件中转 或 Provide/Inject | 避免全局污染 |
| 深层嵌套组件 | Provide / Inject | 跳过中间层,代码简洁 |
| 跨页面共享数据 | Pinia 全局状态 | 数据持久化,路由切换不丢失 |
| 多组件频繁读写同一数据 | Pinia 全局状态 | 统一管理,避免数据不一致 |
最后说一句:通信方式没有绝对的好坏,只有合不合适。我见过有人把所有数据都塞进 Pinia,结果一个页面要引入七八个 store,代码反而更乱了。也见过有人死守 Props,结果组件嵌套了五六层,传参传得自己都晕。
我的原则是:能用局部解决的,就别用全局。先想清楚「谁需要这份数据」,再决定用哪种通信方式。这样代码才能既清晰又灵活。