3、组件通信(下):兄弟组件通信(EventBus)、跨级通信(provide/inject)、$attrs 与 $listeners
好,咱们接着聊组件通信。上一节我们把父子组件那点事儿捋清楚了,这一节来点更刺激的——兄弟之间怎么传话?爷爷怎么直接跟孙子对话?还有那些「中间人」怎么优雅地隐身?
说实话,这些场景我在项目里几乎天天遇到。尤其是跨级通信,你要是用 props 一层层往下传,那代码写出来自己看着都头疼。嗯,咱们一个一个来。
3.1 兄弟组件通信:EventBus
先说说兄弟组件。两个平级的组件,没有直接的父子关系,怎么通信?
最朴素的办法是「通过父组件中转」——A 兄弟把数据 emit 给父组件,父组件再通过 props 传给 B 兄弟。但这样父组件就变成了一个「传话筒」,代码越来越臃肿。
我个人习惯用 EventBus。说白了,就是搞一个全局的事件中心,谁想发消息就发,谁想收消息就收,跟广播似的。
3.1.1 创建 EventBus
在 Vue 2 里,EventBus 就是一个空的 Vue 实例:
// event-bus.js
import Vue from 'vue'
export const EventBus = new Vue()
然后在需要通信的组件里,分别引入这个实例。
3.1.2 发送事件
// ComponentA.vue
import { EventBus } from './event-bus.js'
export default {
methods: {
sendMessage() {
EventBus.$emit('message', 'Hello from A!')
}
}
}
3.1.3 接收事件
// ComponentB.vue
import { EventBus } from './event-bus.js'
export default {
created() {
EventBus.$on('message', (msg) => {
console.log(msg) // 'Hello from A!'
})
},
beforeDestroy() {
EventBus.$off('message') // 别忘了销毁!
}
}
我曾经在项目里忘了在 beforeDestroy 里调用 $off,结果组件销毁了,事件监听还活着。用户来回切换页面几次,事件被重复绑定,消息发一次能收到好几遍。排查了半天,最后发现是内存泄漏。
记住:用 EventBus,一定要成对出现——$on 和 $off 必须一起用。
3.1.4 什么时候用 EventBus?
说实话,EventBus 适合小范围、低频次的兄弟通信。比如两个平级的表单组件,一个输入框变了,另一个要同步更新。这种场景用 EventBus 很清爽。
但你要是整个项目到处都用 EventBus,那代码就变成「蜘蛛网」了——事件满天飞,你根本不知道谁发了谁收了。项目大了,我建议还是上 Vuex 或者 Pinia。
| 场景 | 推荐方案 |
|---|---|
| 少量兄弟组件通信 | EventBus |
| 大量组件共享状态 | Vuex / Pinia |
| 仅父子通信 | props + $emit |
3.2 跨级通信:provide / inject
接下来聊聊跨级通信。你想想看,一个爷爷组件要传数据给孙子组件,中间隔了一层父组件。如果用 props,父组件就得「过一手」——明明自己用不到,还得帮着传。代码里全是这种「中间人」逻辑,看着就烦。
Vue 提供了 provide / inject 来解决这个问题。爷爷提供数据,孙子直接注入,中间组件完全不用管。
3.2.1 基本用法
// 爷爷组件 GrandParent.vue
export default {
provide() {
return {
theme: 'dark',
user: this.user
}
},
data() {
return {
user: { name: '张三' }
}
}
}
// 孙子组件 GrandChild.vue
export default {
inject: ['theme', 'user'],
created() {
console.log(this.theme) // 'dark'
console.log(this.user.name) // '张三'
}
}
你看,父组件完全不用动。孙子直接拿到了爷爷的数据,干净利落。
provide 可以写成对象形式,也可以写成函数形式。如果 provide 里的数据依赖组件实例(比如 this),就必须用函数形式。我习惯统一用函数,省得后面改的时候踩坑。
3.2.2 响应式问题
这里有个坑,我得重点说一下。provide 默认不是响应式的!
什么意思?就是爷爷把数据传下去了,但爷爷的数据变了,孙子那边不会自动更新。你想想看,这多坑啊——孙子拿到的永远是初始值。
解决办法有两个:
- 传对象引用:如果 provide 的是一个对象,对象的属性变化了,孙子能感知到(因为引用没变)。
- 传一个方法:让孙子通过方法去获取最新值。
// 爷爷组件 - 传对象引用
provide() {
return {
userInfo: this.userInfo // userInfo 是对象
}
}
// 孙子组件 - 直接使用对象属性
inject: ['userInfo'],
computed: {
userName() {
return this.userInfo.name // 响应式
}
}
如果你 provide 的是基本类型(字符串、数字),那孙子那边永远不会更新。我在项目中遇到过这个问题——用户改了主题色,孙子组件死活不变。排查了半天,发现是 provide 了一个字符串。改成传对象就好了。
3.3 $attrs 与 $listeners
最后聊聊 $attrs 和 $listeners。这俩东西,说白了就是「透传」——让组件像透明人一样,把属性和事件原封不动地传下去。
你想想看,有时候我们写一个「包装组件」,比如一个自定义的输入框,里面包了一个原生的 input。我们希望外部传进来的所有属性和事件,都能自动落到里面的 input 上。一个个手动绑定?太累了。
3.3.1 $attrs 是什么
$attrs 包含了父组件传递的所有 props(除了组件自身声明过的)。
// 父组件
<custom-input placeholder="请输入" type="text" />
// CustomInput.vue
export default {
props: ['placeholder'], // 只声明了 placeholder
mounted() {
console.log(this.$attrs) // { type: 'text' }
}
}
你看,placeholder 被 props 拿走了,剩下的 type 就留在了 $attrs 里。
3.3.2 $listeners 是什么
$listeners 包含了父组件传递的所有事件监听器。
// 父组件
<custom-input @focus="handleFocus" @input="handleInput" />
// CustomInput.vue
export default {
mounted() {
console.log(this.$listeners) // { focus: fn, input: fn }
}
}
3.3.3 实战:透传属性和事件
最经典的用法,就是用 v-bind="$attrs" 和 v-on="$listeners" 把属性和事件一股脑传给子元素。
// CustomInput.vue
<template>
<div>
<label>自定义输入框</label>
<input v-bind="$attrs" v-on="$listeners" />
</div>
</template>
<script>
export default {
inheritAttrs: false // 防止外层属性落到根元素上
}
</script>
这里有个关键点:inheritAttrs: false。默认情况下,Vue 会把 $attrs 里的属性自动挂到组件的根元素上。如果你不想这样(比如你想让属性只落到 input 上),就把它关掉。
写通用组件时,我几乎必用 $attrs + $listeners。比如封装一个按钮组件、输入框组件、弹窗组件,用这种方式可以让组件非常灵活——外部传什么属性,内部就透传什么属性,完全不用一个个声明。
3.3.4 Vue 3 的变化
如果你用的是 Vue 3,注意 $listeners 被移除了。事件监听器直接合并到了 $attrs 里。所以 Vue 3 里只需要 v-bind="$attrs" 就够了。
| 版本 | 属性透传 | 事件透传 |
|---|---|---|
| Vue 2 | v-bind="$attrs" | v-on="$listeners" |
| Vue 3 | v-bind="$attrs" | v-bind="$attrs"(已合并) |
小结
好,这一节的内容就这些。咱们总结一下:
- 兄弟通信:EventBus 轻量好用,但别忘了销毁监听。
- 跨级通信:provide/inject 省去中间人,但注意响应式问题。
- 透传:$attrs + $listeners 让组件更灵活,适合封装通用组件。
下一节咱们聊聊「插槽」——组件内容分发的终极武器。到时候我会分享一个我在项目中用插槽实现「万能表格」的案例,保证让你大开眼界。
通信方式没有银弹,根据场景选最合适的。小范围用 EventBus,跨层级用 provide/inject,通用组件用 $attrs。别死磕一种。