组件通信噩梦:我是如何用 50 行代码"手搓"一个事件总线解决的?
嘿,各位同学,今天咱们聊一个纯粹的、经典的、能解决大问题的设计模式------事件总线(Event Bus)。这玩意儿,在我还是个新手的时候,帮我摆平了一个至今记忆犹新的"组件通信噩梦",而且实现它的核心代码,可能比你想象的还要简单。
噩梦的开始:一个"各自为政"的后台布局
故事发生在一个典型的后台管理系统中。它的布局很常见:一个顶部的 Header
,一个左侧的 Sidebar
,和一个右侧的主内容区 MainContent
。它们三者是兄弟组件 ,都被挂在 App.vue
下。
很快,需求来了:
- 用户在
Header
里点击了"切换项目"的下拉菜单,Sidebar
的菜单列表和MainContent
的数据看板都需要立即刷新,以展示新项目的数据。 - 用户在
Sidebar
深处的一个设置菜单里,切换了"主题模式"(比如日间/夜间),Header
和MainContent
的背景色和字体颜色都得跟着变。
我当时就头大了 🤯。Header
和 Sidebar
怎么直接通信?难道要用 this.$parent.$emit
再 this.$on
?那代码就跟意大利面一样搅和在一起了!或者为了这么点事就上 Vuex?用它来管理"项目ID"还行,但"主题模式"这种纯 UI 状态也放进去,感觉有点杀鸡用牛刀,而且 store 会变得很杂乱。
我的组件之间,就像隔着好几堵墙的人在喊话,通信效率极低,而且姿势极其难看。
柳暗花明:几行代码的"中央广播站"
就在我准备硬着头皮写"面条代码"的时候,我想到了一个古老又优雅的模式:事件总线。
把它想象成一个"中央广播站 "。任何组件都可以向这个广播站 $emit
(发射)一个带有特定频道(事件名)的信号。而其他任何组件,只要提前 $on
(收听)了该频道,就能接收到这个信号并做出反应。
于是,我花了十分钟,"手搓"了下面这个 event-bus.js
:
事件总线:
- 提供监听某个事件的接口
- 提供取消监听的接口
- 触发事件的接口(可传送数据)
- 触发事件后会自动通知监听者
javascript
// event-bus.js
const listeners = {};
// 事件总线
export default {
// 监听某一个事件
$on(eventName, handler) {
if (!listeners[eventName]) {
// 关键点1: 使用 Set 而不是数组!
listeners[eventName] = new Set();
}
listeners[eventName].add(handler);
},
// 取消监听
$off(eventName, handler) {
if (!listeners[eventName]) {
return;
}
listeners[eventName].delete(handler);
},
// 触发事件
$emit(eventName, ...args) {
if (!listeners[eventName]) {
return;
}
for (const handler of listeners[eventName]) {
handler(...args);
}
},
};
第一次恍然大悟 💡:为什么用 new Set()
而不是用数组 []
来存储事件的处理函数(handler)?
- 唯一性 :
Set
结构天然不允许重复值。这意味着同一个组件的同一个处理函数,即使不小心$on
了好几次,也只会存一份。完美避免了"一个事件触发,函数执行多次"的诡异bug! - 性能 :
Set
的delete()
操作比数组的splice()
或filter()
来删除指定元素要快得多。对于频繁注册和注销的场景,性能更优。
就这短短几行不到的代码,构建起了一个强大的、解耦的通信系统。
在Vue里面也可以这样写:
js
import Vue from "vue";
export default new Vue({});
原因: 每个 Vue 实例都是一个天生的事件派发器,new Vue()创建了一个全新的、独立的 Vue 根实例。它没有 template,没有 data(只有一个空的 _data 对象),没有挂载到任何 DOM 元素上。
这里面就包括我们的三个核心方法(其实是四个): vm.$on(eventName, callback):监听当前实例上的自定义事件。
vm.$off(eventName, callback):移除自定义事件监听器。
vm.$emit(eventName, ...args):触发当前实例上的事件。
vm.$once(eventName, callback):监听一个自定义事件,但只触发一次,在第一次触发后监听器就会被移除。
new Vue() 作为事件总线,是 Vue 2.x 时代一个极其优雅的方案。但是,请注意,这个技巧在 Vue 3 中已经完全失效了! Vue 3 为了追求更轻量、更纯粹的组合式 API,从组件实例上移除了 <math xmlns="http://www.w3.org/1998/Math/MathML"> o n , on, </math>on,off, <math xmlns="http://www.w3.org/1998/Math/MathML"> o n c e 这几个方法,只保留了 once 这几个方法,只保留了 </math>once这几个方法,只保留了emit。官方的理由是,他们不希望大家过度依赖事件总线这种模式去处理跨组件通信,而是鼓励使用更明确的数据流(如 provide/inject)或更专业的全局状态管理(如 Pinia)。
实战演练:当组件学会了"收听广播"
有了这个广播站,我们来解决之前的难题。
场景一:切换项目
在 Header.vue
里,当用户切换项目时,我们向外"广播"一个信号。
javascript
// Header.vue
import eventBus from '@/utils/event-bus.js';
export default {
methods: {
onProjectChange(newProjectId) {
// ...处理 Header 自己的逻辑...
// 发射信号!频道是 'project-changed',附带的数据是 newProjectId
eventBus.$emit('project-changed', newProjectId);
}
}
}
现在,Sidebar.vue
和 MainContent.vue
只需要"收听"这个频道就行了。
javascript
// Sidebar.vue
import eventBus from '@/utils/event-bus.js';
export default {
created() {
// 组件创建时,开始收听 'project-changed' 频道
eventBus.$on('project-changed', this.handleProjectChange);
},
methods: {
handleProjectChange(projectId) {
console.log('Sidebar 收到!新项目ID:', projectId);
// ...执行刷新菜单列表的逻辑...
}
},
// ...别忘了后面要讲的销毁监听!
}
MainContent.vue
的逻辑也几乎一样。你看,组件之间完全不知道对方的存在,却通过事件总线实现了完美协作,代码优雅得一塌糊涂。✅
最大的"坑":那个被遗忘的监听器 🐛
正当我为自己的杰作沾沾自喜时,我发现了测试时的一个问题:当我反复切换路由,离开再回到有监听的页面时,有时一个事件会导致好几次重复的响应!
💡有 $on
,就必须有 $off
!
当一个组件(比如 Sidebar.vue
)被销毁时,它在 created
里注册的 handleProjectChange
监听函数,依然存在于 eventBus
的 listeners
里!它就像一个幽灵 👻,赖着不走。当你再次创建这个组件时,又会注册一个新的监听。日积月累,listeners
里的"幽灵"越来越多,导致一次 $emit
,多个"幽灵"和一个"活人"同时响应,不仅造成性能浪费,还可能引发各种奇怪的 bug。
正确的姿势是在组件销毁前,把自己的监听器给清理掉:
javascript
// Sidebar.vue (修正版)
import eventBus from '@/utils/event-bus.js';
export default {
created() {
eventBus.$on('project-changed', this.handleProjectChange);
},
beforeDestroy() {
// 组件销毁前,取消监听,还 eventBus 一个干净!
eventBus.$off('project-changed', this.handleProjectChange);
},
methods: {
handleProjectChange(projectId) {
// ...
}
}
}
记住这个铁律:$on
和 $off
必须成对出现,通常是在 created
/mounted
和 beforeDestroy
这两个生命周期钩子中配对。
总结
这个小小的事件总线,虽然不是什么高深的技术,但它体现了一种重要的编程思想------发布-订阅模式。
- 何时用它 :当你需要在两个或多个没有直接父子关系 的组件之间传递消息,且这个通信不那么频繁、不涉及复杂的全局状态管理时,事件总线是一个轻量级且极度优雅的选择。
- 何时不用它:如果你的应用有大量、复杂、高频的全局状态共享,那还是老老实实用 Vuex 或 Pinia 吧。它们提供了更强大的状态管理、dev-tools 支持和更规范的数据流。
希望我这次从"噩梦"到"解脱"的经历,能帮你把"事件总线"这个利器收入囊中。下次再遇到棘手的组件通信问题,你也能自信地"手搓"一个来解决它!