学习笔记:前端发布订阅模式 (Event Bus)
本文稿的核心是讲解前端开发者必须掌握的设计模式之一——发布订阅模式,并通过从零开始实现一个 EventBus 来深入理解其原理和应用。
1. 核心概念与应用场景
是什么? 发布订阅模式是一种消息范式,其中发布者(Publishers)不会将消息直接发送给特定的订阅者(Subscribers),而是通过一个称为“事件总线”或“消息中心”的第三方组件进行通信。发布者发布事件,订阅者订阅事件,两者之间相互解耦。
为什么重要? 它是前端面试的高频考点,也是解决复杂组件通信问题的利器。
应用场景: 在 Vue.js (或其他前端框架) 中,用于兄弟组件或两个无直接关联的组件之间的传值和通信。
- 示例:
HelloWorld组件(发布者)中有一个按钮,点击该按钮会触发一个事件。Child组件(订阅者)监听这个事件,并根据接收到的数据更新自身的count状态。
- 示例:
2. 从零实现一个 EventBus 类
我们将创建一个 EventBus 类,它包含订阅、发布、取消订阅和一次性订阅的核心功能。
2.1. 基础结构
typescript
class EventBus {
// 用于存储事件和对应回调函数的容器
private events: { [key: string]: Set<Function> };
constructor() {
this.events = {};
}
// ... methods
}events属性:一个对象(字典),键是事件名称 (string),值是一个Set集合,用于存储所有订阅了该事件的回调函数。- 为什么用
Set而不是数组?- 自动去重:
Set数据结构不允许重复值。如果同一个回调函数对同一个事件多次订阅,Set会自动保证只存储一次,避免了不必要的重复执行。 - 删除方便:使用
Set.delete()方法可以快速移除指定回调,而数组则需要通过filter或splice等相对复杂的方式来操作。
- 自动去重:
2.2. on(eventName, callback) - 订阅事件
此方法用于将一个回调函数 callback 订阅到指定的 eventName 上。
typescript
on(eventName: string, callback: Function) {
// 如果事件名不存在,则使用空值合并运算符(??)初始化一个新的 Set
this.events[eventName] = this.events[eventName] ?? new Set();
this.events[eventName].add(callback);
}- 逻辑解析:
- 检查
this.events中是否存在eventName对应的Set。 - 如果不存在(即为
null或undefined),则创建一个新的Set并赋值给它。 - 将
callback添加到对应的Set中。
- 检查
2.3. emit(eventName, ...args) - 发布事件
当事件发生时,调用此方法来执行所有已订阅的回调函数,并可以传递参数。
typescript
emit(eventName: string, ...args: any[]) {
const callbacks = this.events[eventName];
if (!callbacks) {
return; // 如果没有订阅者,则直接返回
}
callbacks.forEach(callback => {
callback(...args);
});
}- 逻辑解析:
- 根据
eventName找到对应的Set。 - 如果存在,则遍历
Set中的所有callback。 - 使用剩余参数 (
...args) 将emit接收到的所有参数原封不动地传递给每个callback并执行。
- 根据
2.4. off(eventName, callback) - 取消订阅
用于移除对特定事件的某个指定回调函数的监听。
typescript
off(eventName: string, callback: Function) {
const callbacks = this.events[eventName];
if (callbacks) {
callbacks.delete(callback);
}
}- 逻辑解析:
- 找到
eventName对应的Set。 - 如果
Set存在,直接调用其delete方法移除特定的callback。这比数组操作更高效。
- 找到
2.5. once(eventName, callback) - 一次性订阅
订阅一个事件,但对应的回调函数在执行一次后会自动被移除。
typescript
once(eventName: string, callback: Function) {
// 创建一个临时的包装函数
const wrapper = (...args: any[]) => {
// 先执行原始的回调
callback(...args);
// 然后立即将自身从订阅中移除
this.off(eventName, wrapper);
};
// 使用 on 方法订阅这个包装函数
this.on(eventName, wrapper);
}- 逻辑解析(核心技巧):
- 不直接订阅原始的
callback。 - 创建一个新的
wrapper函数,这个函数是真正被订阅的。 - 当事件被
emit时,wrapper函数被执行。 wrapper函数内部首先调用原始的callback,完成其任务。- 紧接着,调用
this.off()方法,将wrapper函数自身从事件监听中移除。 - 这样,下一次同样的事件被
emit时,wrapper已不存在,从而实现“只执行一次”的效果。 - 注意:在原始文稿中,提到了
this指向问题,如果wrapper是一个普通function,其内部的this可能不是EventBus实例。使用箭头函数可以完美解决这个问题,因为它会捕获定义时所在的上下文的this。
- 不直接订阅原始的
3. 实践应用(Vue 示例)
步骤 1:创建并导出单例
通常 EventBus 在整个应用中是唯一的(单例模式),以确保所有组件共享同一个事件中心。
typescript
// eventBus.ts
class EventBus {
// ... 上述所有代码
}
// 导出的是类的实例,而不是类本身
export default new EventBus();步骤 2:订阅方 (Child 组件)
vue
<script setup>
import { ref, onUnmounted, watchEffect } from 'vue';
import bus from './eventBus';
const count = ref(0);
// 定义处理函数
const incrementHandler = (value) => {
count.value += value;
};
// 订阅事件 (once 示例)
bus.once('increment', incrementHandler); // 只会触发一次,count 增加到 2 后失效
/*
// 订阅事件 (on 示例)
bus.on('increment', incrementHandler);
// 在组件销毁时,最好取消订阅,防止内存泄漏
onUnmounted(() => {
bus.off('increment', incrementHandler);
});
// 条件性取消订阅 (off 示例)
watchEffect(() => {
if (count.value >= 10) {
console.log('Count is >= 10, removing listener.');
bus.off('increment', incrementHandler);
}
});
*/
</script>步骤 3:发布方 (HelloWorld 组件)
vue
<template>
<button @click="handleClick">Increment Child's Count</button>
</template>
<script setup>
import bus from './eventBus';
const handleClick = () => {
// 发布事件,并传递参数 2
bus.emit('increment', 2);
};
</script>4. 总结
发布订阅模式通过一个中央 EventBus 解耦了组件之间的直接依赖,使得非父子组件间的通信变得简单、可维护。虽然在日常业务开发中可能更多地使用框架自带的状态管理工具(如 Pinia/Vuex),但理解并能手写一个 EventBus 是衡量前端工程师基础是否扎实的重要标准,尤其在面试中。