大白话JavaScript展示实现简单事件总线的代码示例,分析事件通信机制
前端小伙伴们,有没有被组件通信逼到挠头?父子组件用props
传值还能忍,可跨页面、跨模块的组件通信,难道要一路props
钻到底?或者为了简单通信去引入Redux
,学半天发现“杀鸡用牛刀”?今天咱们就聊一个轻量又好用的方案——事件总线(Event Bus),用JavaScript
手写一个,从此组件通信so easy~
一、组件通信的"三大痛点"
先说说我上周踩的坑:做一个电商页面,需要实现“购物车添加商品→顶部通知栏显示成功”的功能。结果:
- 跨组件通信难:购物车组件和通知栏没父子关系,
props
传值得经过中间组件“中转”,代码乱成一团; - 全局状态太“重”:为了这么个小功能引入
Redux
,得写action
、reducer
、store
,光配置就花半小时; - 耦合度高:组件间直接引用,改个通知栏样式,购物车组件也得跟着调整,维护起来头大。
这些痛点总结起来就一句话:轻量级跨组件通信需要更简单、更解耦的方案,而事件总线就是来解决这个问题的!
二、事件总线的"发布-订阅模式"
事件总线的核心是发布-订阅模式(Pub/Sub),就像小区的“广播系统”:
- 订阅者(Subscriber):在广播系统登记“我想听天气预报”;
- 发布者(Publisher):广播员喊“明天晴天”;
- 事件总线(Event Bus):广播系统负责记录谁想听什么,发布时通知所有登记的人。
1. 三大核心角色:
- 事件中心:存储事件与回调的映射(比如
{ 'add-cart': [fn1, fn2] }
); - 订阅(on):注册事件回调(告诉广播系统“我想听”);
- 发布(emit):触发事件(广播员开始广播);
- 取消订阅(off):移除事件回调(不想听了,取消登记)。
三、代码示例:从0到1实现事件总线
示例1:基础版事件总线(满足90%需求)
先实现一个最基础的事件总线,包含on
(订阅)、emit
(发布)、off
(取消订阅)三个核心方法。
// 事件总线类(ES6语法)
class EventBus {
constructor() {
// 存储事件与回调的映射:{ 事件名: [回调函数1, 回调函数2] }
this.events = Object.create(null);
}
/**
* 订阅事件
* @param {string} eventName 事件名(如:'add-cart')
* @param {function} callback 回调函数(事件触发时执行)
* @returns {function} 取消订阅的函数(方便直接调用)
*/
on(eventName, callback) {
// 如果事件不存在,初始化空数组
if (!this.events[eventName]) {
this.events[eventName] = [];
}
// 将回调添加到事件数组中
this.events[eventName].push(callback);
// 返回取消订阅的函数(闭包保存eventName和callback)
return () => this.off(eventName, callback);
}
/**
* 发布事件(触发事件)
* @param {string} eventName 事件名
* @param {...any} args 传递给回调的参数(如:商品信息)
*/
emit(eventName, ...args) {
// 如果事件不存在,直接返回
if (!this.events[eventName]) return;
// 遍历事件数组,依次执行回调(用slice复制数组,避免回调中off导致的数组长度变化问题)
this.events[eventName].slice().forEach(callback => {
callback(...args);
});
}
/**
* 取消订阅
* @param {string} eventName 事件名
* @param {function} callback 要移除的回调函数
*/
off(eventName, callback) {
// 如果事件不存在,直接返回
if (!this.events[eventName]) return;
// 过滤掉要移除的回调(注意:严格匹配引用)
this.events[eventName] = this.events[eventName].filter(
cb => cb !== callback
);
// 如果事件数组为空,删除该事件键(可选优化)
if (this.events[eventName].length === 0) {
delete this.events[eventName];
}
}
}
// 创建事件总线实例(全局可用)
const eventBus = new EventBus();
示例2:进阶版——支持一次性订阅(once)
有时候我们希望事件只触发一次(如“用户登录成功后只提示一次”),可以添加once
方法:
class EventBus {
// ...(保留之前的on/emit/off方法)
/**
* 一次性订阅(事件触发后自动取消订阅)
* @param {string} eventName 事件名
* @param {function} callback 回调函数
*/
once(eventName, callback) {
// 包装原回调:执行后自动off
const wrapper = (...args) => {
callback(...args); // 执行原回调
this.off(eventName, wrapper); // 执行后取消自己
};
// 订阅包装后的回调
this.on(eventName, wrapper);
}
}
示例3:实战——购物车与通知栏通信
用事件总线实现“添加商品到购物车→通知栏显示成功”的功能:
// ------------------ 购物车组件 ------------------
// 添加商品到购物车的函数
function addToCart(product) {
// 模拟添加逻辑(如调用API)
console.log(`添加商品:${product.name}`);
// 发布事件:通知其他组件商品已添加
eventBus.emit('cart-added', product);
}
// ------------------ 通知栏组件 ------------------
// 订阅cart-added事件,显示通知
const unsubscribe = eventBus.on('cart-added', (product) => {
showNotification(`已添加 ${product.name} 到购物车`);
});
// 如果需要,在组件卸载时取消订阅(避免内存泄漏)
// unsubscribe();
// 辅助函数:显示通知(模拟)
function showNotification(text) {
console.log(`通知:${text}`);
// 实际项目中可能是DOM操作或调用UI库组件
}
// ------------------ 测试 ------------------
// 模拟用户点击添加商品
addToCart({ name: 'iPhone 15', price: 6999 });
// 输出:
// 添加商品:iPhone 15
// 通知:已添加 iPhone 15 到购物车
示例4:处理匿名回调的坑(用Symbol标识)
如果订阅时用了匿名函数,直接off
会失效(因为匿名函数引用不同)。可以用Symbol
作为唯一标识:
// 用Symbol生成唯一事件名(避免命名冲突)
const CART_ADDED = Symbol('cart-added');
// 订阅时用命名函数或Symbol标识
eventBus.on(CART_ADDED, function handleCartAdded(product) {
showNotification(`已添加 ${product.name}`);
});
// 取消订阅时,传递相同的函数引用
eventBus.off(CART_ADDED, handleCartAdded);
四、事件总线 vs 其他通信方式
用表格对比不同方案的优缺点,帮你选最适合的通信方式:
对比项 | 事件总线(Event Bus) | Props传值 | Redux/Vuex | 自定义事件(DOM) |
---|---|---|---|---|
通信复杂度 | 低(全局事件) | 高(需层级传递) | 中(需store/action) | 低(依赖DOM) |
解耦程度 | 高(组件无直接依赖) | 低(强依赖层级) | 高(通过store解耦) | 中(依赖DOM结构) |
适用场景 | 轻量级跨组件通信 | 父子组件通信 | 复杂状态管理 | 同页面DOM元素通信 |
学习成本 | 低(只需掌握on/emit/off) | 低(React/Vue基础) | 高(需学习redux-saga等) | 低(需了解DOM事件) |
内存泄漏风险 | 中(需手动取消订阅) | 低(无额外订阅) | 低(store自动管理) | 中(需移除事件监听) |
五、面试题回答方法
正常回答(结构化):
“事件总线基于发布-订阅模式实现,核心机制是:
- 事件中心:用对象存储事件名与回调函数数组的映射;
- 订阅(on):将回调函数添加到对应事件的数组中;
- 发布(emit):遍历事件数组,执行所有回调并传递参数;
- 取消订阅(off):从事件数组中移除指定回调,避免内存泄漏;
- 一次性订阅(once):通过包装回调函数,触发后自动取消订阅。
适用场景包括轻量级跨组件通信、模块解耦等,需注意及时取消订阅以避免内存泄漏。”
大白话回答(接地气):
“事件总线就像小区的广播系统——
- 你想收快递(订阅事件),就去物业登记(
on
);- 快递到了(发布事件),物业广播通知所有登记的人(
emit
);- 搬家了(组件卸载),得去物业取消登记(
off
),不然快递还会往旧地址送(内存泄漏)。
好处是大家不用互相留电话(解耦),坏处是得自己记着取消登记(手动管理)。”
六、总结:3个核心方法+2个避坑指南
3个核心方法:
on(eventName, callback)
:注册事件回调;emit(eventName, ...args)
:触发事件并传递参数;off(eventName, callback)
:取消事件回调(重要!避免内存泄漏)。
2个避坑指南:
- 避免匿名回调:订阅时尽量用命名函数或
Symbol
标识,否则无法正确off
; - 及时取消订阅:组件卸载时务必取消订阅(如在
useEffect
的清理函数中调用off
),否则回调会一直存在,导致内存泄漏; - 事件命名规范:用
Symbol
或前缀(如'cart.add'
)避免事件名冲突(尤其是大型项目)。
七、扩展思考:4个高频问题解答
问题1:事件总线会导致全局污染吗?
解答:
事件总线本身是一个全局实例(如eventBus
),但只要合理管理事件名和回调,不会导致全局污染。可以通过:
- 模块化:按功能划分不同的事件总线实例(如
cartEventBus
、userEventBus
); - 命名空间:用
'module.event'
格式命名事件(如'cart.add'
、'user.login'
); - 封装成工具库:将事件总线封装为独立模块,避免直接暴露全局变量。
问题2:如何调试事件总线?
解答:
- 添加日志:在
on
/emit
/off
方法中添加console.log
,输出事件名、参数、回调数量; - 使用DevTools:用
window.eventBus
暴露实例,在浏览器控制台手动触发事件测试; - 封装调试工具:添加
listEvents
方法,打印所有事件及回调数量:class EventBus { // ... listEvents() { return Object.keys(this.events).map(eventName => ({ eventName, callbackCount: this.events[eventName].length })); } }
问题3:事件总线和Vue的 o n / on/ on/emit有什么区别?
解答:
Vue的$on/$emit
是事件总线的一种实现,本质相同,但:
- 作用域:Vue的事件总线绑定在Vue实例上(如
this.$on
),而自定义事件总线是独立的全局对象; - 生命周期:Vue的事件会随组件卸载自动清理(需在
beforeUnmount
中$off
),自定义事件总线需手动管理; - 生态集成:Vue的事件总线与组件生命周期深度集成,自定义事件总线更通用(可用于任何JavaScript环境)。
问题4:事件总线适合大型项目吗?
解答:
事件总线适合轻量级通信,但大型项目需注意:
- 复杂度控制:过多事件会导致“事件地狱”(满屏幕
emit
,难以追踪数据流); - 替代方案:复杂状态管理建议用
Redux
/Pinia
(可追踪、可预测); - 结合使用:事件总线可作为补充,处理
Redux
不擅长的“即时通信”(如通知、提示)。
结尾:事件总线——轻量级通信的"瑞士军刀"
事件总线是前端开发中非常实用的工具,尤其适合轻量级跨组件通信。它简单、灵活,能快速解决props
钻取和全局状态库“过重”的问题。记住:合理使用事件总线,及时清理回调,它会成为你开发中的好帮手~
下次遇到跨组件通信的问题,别忘了试试事件总线!如果这篇文章帮你理清了思路,记得点个赞,咱们下期,不见不散!