事件总线|前端组件通信|核心原理与实战实现

事件总线|前端组件通信|核心原理与实战实现 一

文章目录CloseOpen

事件总线的核心原理:从发布-订阅模式到通信解耦

要搞懂事件总线,得先明白它背后的”灵魂”——发布-订阅模式。你可以把它想象成小区的公告栏:有人(发布者)贴通知(事件),感兴趣的人(订阅者)看到了就去做相应的事(回调函数)。事件总线就是这个”公告栏”,所有组件都能在这里贴通知或看通知,不用互相认识,就能隔空”对话”。

从生活场景到代码逻辑:发布-订阅模式怎么工作?

去年帮一个电商项目重构时,我算真切体会到这模式的好处。他们原来的商品详情页和购物车组件通信,要经过”详情页→商品模块→页面容器→导航栏→购物车”五层传递,就像你想告诉小区门口的超市打折,得先告诉家人,家人告诉邻居,邻居告诉保安,保安再告诉超市老板,中间任何一个环节忘了传,信息就断了。后来我们用事件总线重构,让这两个组件直接通过全局事件中心通信,就像详情页直接在公告栏贴”商品A已加入购物车”,购物车组件每天看公告栏,看到这条就更新数量,代码量少了40%,改需求时再也不用一层层找传值逻辑了。

那事件总线具体怎么实现这个”公告栏”?核心就三件事:存事件、发事件、删事件。对应到代码里,就是三个核心方法:on(订阅事件,在公告栏登记”我要关注XX通知”)、emit(发布事件,在公告栏贴XX通知)、off(取消订阅,告诉公告栏”我不再关注XX通知了”)。有些场景还需要once(只看一次通知,看完自动取消关注)。

核心方法拆解:为什么这三个方法能实现通信?

先看on方法——它的作用是把事件和对应的回调存起来。就像你在公告栏登记”我叫小明,关注’快递到了’的通知,看到请打我电话123″,公告栏需要一个本子记录:事件名”快递到了”对应一个列表,里面存着{姓名:小明,联系方式:123}。在代码里,这个”本子”就是一个对象,键是事件名,值是回调函数数组,比如:

const eventBus = {

events: {}, // 事件存储中心,键:事件名,值:[回调1, 回调2,...]

on(eventName, callback) {

// 如果事件名不存在,先初始化一个空数组

if (!this.events[eventName]) {

this.events[eventName] = [];

}

// 把回调函数放进数组

this.events[eventName].push(callback);

}

};

这里有个坑我之前踩过:如果同一个组件重复订阅同一个事件,会导致回调执行多次。比如购物车组件不小心调用了两次eventBus.on('add-to-cart', updateCount),那每次发布事件,updateCount会执行两次,购物车数量就会多更一次。后来我在on方法里加了判断,检查回调是否已经存在,避免重复添加,就像公告栏登记时先看看”小明是不是已经登记过’快递到了’通知”,有的话就不再重复记了。

再看emit方法——发布事件时,就是遍历这个事件对应的回调数组,把数据传给每个回调。比如你在公告栏贴”快递到了,单号123″,公告栏管理员就翻到”快递到了”那一页,给每个登记的人打电话:”小明,你的快递单号123到了”。代码里就是:

emit(eventName, ...args) {

// 如果事件名不存在,直接返回(没人关注这个通知)

if (!this.events[eventName]) return;

// 遍历回调数组,逐个执行,把参数传进去

this.events[eventName].forEach(callback => {

callback(...args); // ...args是ES6的展开运算符,把传进来的参数都传给回调

});

}

这里要注意参数传递——比如发布事件时传了商品ID和数量,订阅的回调要能收到这两个值,所以用...args接收所有参数,再传给回调,就像打电话时把”单号123、放门卫室”这些信息都告诉对方。

最后是off方法——取消订阅,避免内存泄漏。就像你搬家了,告诉公告栏”我小明不再关注’快递到了’通知了”,管理员就把本子上你的记录删掉。如果不删,组件都销毁了(比如页面跳转了),事件回调还在,就会导致”僵尸回调”,占用内存。代码实现时,要根据事件名和回调找到对应的记录删除:

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];

}

}

这里有个细节:如果只传eventName不传callback,应该取消该事件的所有订阅(比如”所有人都别关注’快递到了’通知了”),所以可以加个判断:if (!callback) { delete this.events[eventName]; return; }

为什么事件总线能解耦?看看传统方案的对比

你可能会问:Vuex、Redux这些状态管理库不也能通信吗?为什么要用事件总线?其实它们适用场景不同——状态管理库适合管理全局共享状态(比如用户信息、购物车数据),而事件总线适合”一次性通信”(比如”商品加入购物车后通知导航栏更新数量”)。就像你不会用快递站(状态管理库)传递”今晚聚餐”的临时消息,而是直接在群里发(事件总线)。

为了让你更清楚,我整理了几种通信方式的对比:

通信方式 适用场景 优点 缺点
props/emit(Vue) 父子组件直接通信 简单直观,框架原生支持 跨层级传递繁琐,兄弟组件无法直接用
回调函数(React) 子组件向父组件传值 逻辑清晰,适合简单交互 多层传递时嵌套深(”回调地狱”)
事件总线 跨层级、兄弟组件通信(一次性消息) 轻量灵活,解耦效果好 全局事件易冲突,需手动清理避免内存泄漏
Vuex/Redux 全局共享状态(如用户信息、购物车) 状态管理规范,适合复杂应用 配置复杂,小项目用着”杀鸡用牛刀”

MDN在讲解自定义事件时提到:”事件机制允许对象之间通过松耦合的方式通信,发送者不需要知道接收者的存在,接收者也不需要知道发送者的身份”,这正是事件总线的核心优势——组件只需要和事件中心打交道,不用关心谁在发事件、谁在收事件,极大降低了代码耦合度。

从零实现事件总线:从基础代码到框架适配

光懂原理不够,自己动手写一个才记得牢。我带过几个实习生,都是看十遍原理不如写一遍代码。下面我们从0到1实现一个可用的事件总线,再解决实际开发中最容易踩的坑,最后看看在Vue和React里怎么用。

第一步:构建基础版事件总线(50行代码搞定核心功能)

我们先实现最基础的onemitoffonce方法。就像搭积木,先把骨架搭起来:

class EventBus {

constructor() {

this.events = Object.create(null); // 用Object.create(null)避免原型链污染

}

// 订阅事件

on(eventName, callback, context = this) { // context可选,指定回调的this指向

if (typeof callback !== 'function') {

throw new Error('回调必须是函数');

}

if (!this.events[eventName]) {

this.events[eventName] = [];

}

// 存的时候带上context,确保回调执行时this正确

this.events[eventName].push({ callback, context });

}

// 发布事件

emit(eventName, ...args) {

if (!this.events[eventName]) return;

// 复制一份回调数组再遍历,避免执行中修改原数组导致问题(比如off移除事件)

const callbacks = [...this.events[eventName]];

callbacks.forEach(({ callback, context }) => {

callback.apply(context, args); // 用apply绑定context和参数

});

}

// 取消订阅

off(eventName, callback) {

if (!this.events[eventName]) return;

if (!callback) {

// 如果没传callback,取消该事件的所有订阅

delete this.events[eventName];

return;

}

// 过滤掉要取消的回调

this.events[eventName] = this.events[eventName].filter(item =>

item.callback !== callback

);

// 清空后删除事件名

if (this.events[eventName].length === 0) {

delete this.events[eventName];

}

}

// 订阅一次事件

once(eventName, callback, context = this) {

// 包装一个新回调,执行后自动取消订阅

const onceCallback = (...args) => {

callback.apply(context, args);

this.off(eventName, onceCallback); // 注意这里要传onceCallback,不是原callback

};

this.on(eventName, onceCallback, context);

// 返回这个包装后的回调,方便后续off(虽然一般once用不到off)

return onceCallback;

}

}

这段代码看似简单,但有几个细节是我踩过坑才加上的:比如context参数——之前有个实习生写的事件总线,回调里的this总是指向window,后来发现是没绑定上下文,加上context参数让用户可以指定this指向,解决了这个问题;还有emit时复制回调数组——如果发布事件时,某个回调里调用了off移除事件,原数组会变化,遍历就可能漏执行或多执行,复制一份就安全了。

第二步:解决三大痛点问题(新手最容易栽的坑)

  • 事件名冲突:用命名空间给事件”分类”
  • 去年做一个后台管理系统,我和另一个同事同时开发两个模块,他在用户管理组件用了emit('save', data),我在角色管理组件也用了emit('save', data),结果测试时一点”保存角色”,用户管理的保存逻辑也触发了——这就是事件名冲突。后来我们用”命名空间”解决:给事件名加前缀,比如user:saverole:save,就像把文件放进不同文件夹,再也不会混了。

    实现命名空间不用改核心代码,只需要约定事件名格式:模块名:事件名。进阶一点,可以在onemit时支持通配符,比如on('user:', callback)订阅所有user模块的事件,但新手 先从基础命名空间开始,简单不易错。

  • 内存泄漏:组件销毁时一定要取消订阅!
  • 这是我见过最多的问题——很多人用事件总线只记得onemit,忘了off,导致组件销毁后回调还在,越积越多,页面越来越卡。之前帮一个朋友看他的Vue项目,列表页用eventBus.on('refreshList', this.loadData),每次进列表页都会订阅一次,来回切换10次页面,refreshList事件就有10个回调,一点刷新按钮执行10次请求,服务器都快被刷爆了。

    怎么避免?组件销毁时必须off掉自己订阅的事件。在Vue里,可以在beforeDestroy钩子中取消:

    export default {
    

    mounted() {

    eventBus.on('cart:update', this.updateCart);

    },

    beforeDestroy() {

    eventBus.off('cart:update', this.updateCart); // 必须传回调,否则会取消所有订阅该事件的回调

    },

    methods: {

    updateCart() { / ... / }

    }

    };

    React则在useEffect的清理函数里处理:

    function CartComponent() {
    

    useEffect(() => {

    const handleUpdate = () => { / ... */ };

    eventBus.on('cart:update', handleUpdate);

    // 清理函数:组件卸载时取消订阅

    return () => {

    eventBus.off('cart:update', handleUpdate);

    };

    }, []);

    // ...

    }

    验证是否有内存泄漏的方法很简单:用Chrome开发者工具的”内存”标签,先在组件挂载后拍个内存快照,搜索事件总线的events对象,记录回调数量;然后销毁组件,再拍一次快照,如果回调数量没减少,就是有泄漏。

  • 重复订阅:加个判断避免回调执行多次
  • 就像前面说的”小明重复登记快递通知”,如果同一个组件多次调用on订阅同一个事件和回调,会导致emit时执行多次。解决办法是在on方法里检查是否已存在相同的回调和context:

    on(eventName, callback, context = this) {
    

    // ... 前面的代码 ...

    // 检查是否已存在相同的回调和context

    const isDuplicate = this.events[eventName].some(item =>

    item.callback === callback && item.context === context

    );

    if (!isDuplicate) {

    this.events[eventName].push({ callback, context });

    }

    }

    这样即使不小心调用多次on,也只会存一次回调。

    第三步:框架适配:Vue和React里怎么用得更顺手?

    不同框架有不同的最佳实践。Vue里可以直接把事件总线挂在原型上,方便所有组件访问:

    // main.js
    

    import { EventBus } from './eventBus';

    Vue.prototype.$bus = new EventBus();

    // 组件里用

    this.$bus.on('cart:add', this.handleAdd);

    this.$bus.emit('cart:add', productId);

    但Vue 3移除了$on$emit实例方法,官方 用第三方库如mitt(轻量级事件总线库),不过我们自己写的这个EventBus类完全可以替代,API基本一致。

    React里没有原型链可以挂,通常创建一个单例实例导出:

    // eventBus.js
    

    export const eventBus = new EventBus();

    // 组件里引入

    import { eventBus } from './eventBus';

    eventBus.on('user:login', handleLogin);

    React Hooks项目还可以封装一个自定义Hook


    你知道吗?很多前端开发者刚开始都会搞混——事件总线和Vuex/Redux明明都是解决组件通信的,到底啥时候用哪个?其实核心区别就一句话:一个是“对讲机”,一个是“笔记本”。事件总线像对讲机,你喊一句“楼下超市打折了”,听到的人当下回应,但说完就完了,不存记录;Vuex/Redux像笔记本,你把“超市打折信息”写下来,谁需要随时翻来看,还能更新“折扣结束了”,信息能长期存着。

    我去年帮一个社区项目做优化时,就遇到过选错工具的坑。他们把用户登录状态用事件总线传递,结果页面刷新后状态就没了——因为事件总线根本不存数据啊!后来改成用Pinia(Vue3的状态管理库)存登录状态,问题立马解决。但反过来,商品详情页通知导航栏“商品已加入购物车”,这种临时通知就没必要用状态管理库,事件总线喊一声就行,代码还少写十几行。简单说,如果只是“告诉别人一件事”,用事件总线;如果是“大家都要知道这件事,还可能经常问起”,就用状态管理库


    事件总线和Vuex/Redux有什么区别?什么时候该用事件总线?

    事件总线和Vuex/Redux的核心区别在于适用场景:事件总线是轻量级的“即时通信工具”,适合一次性、跨组件的简单消息传递(比如“商品加入购物车后通知导航栏更新数量”),不需要复杂的状态管理逻辑;而Vuex/Redux是“全局状态仓库”,适合管理需要共享、频繁变更的复杂状态(比如用户信息、购物车列表)。简单说,临时通知用事件总线,长期存数据用状态管理库。

    使用事件总线时如何避免内存泄漏?

    内存泄漏的主要原因是组件销毁后,订阅的事件回调没被移除,导致回调函数一直占用内存。避免方法很简单:组件销毁时必须调用off方法取消订阅。比如Vue中在beforeDestroy钩子中执行eventBus.off(‘事件名’, 回调函数),React中在useEffect的清理函数里执行相同操作。 开发时可用Chrome内存快照检查事件总线的events对象,确保组件销毁后对应事件的回调数组为空。

    事件名冲突怎么办?如何规范事件命名?

    事件名冲突是多人协作或大型项目的常见问题,解决关键是“命名空间”——给事件名加模块前缀,比如用户模块的事件用user:开头(user:login、user:logout),商品模块用goods:开头(goods:add、goods:delete),就像给文件分类存放。进阶做法是在团队中约定命名规范,比如“模块名:操作名:对象”(如cart:update:count),确保事件名唯一且语义清晰。

    事件总线支持传递多个参数吗?回调函数如何接收?

    支持。事件总线的emit方法通过…args(扩展运算符)接收多个参数,订阅时的回调函数可以直接通过参数列表接收。比如发布事件时执行eventBus.emit(‘goods:add’, id, name, price),订阅时用eventBus.on(‘goods:add’, (id, name, price) => { … }),回调函数的参数会按发布时的顺序对应接收。实际开发中 参数不超过3个,过多时可封装成对象传递(如{ id, name, price }),更易维护。

    在Vue3或React函数组件中,如何正确引入和使用事件总线?

    Vue3中可创建单例事件总线实例并导出,在组件中通过import引入后使用:先在eventBus.js中定义class EventBus并实例化export const bus = new EventBus(),然后在组件中用import { bus } from ‘./eventBus’引入,在setup函数或onMounted中调用bus.on(),onUnmounted中调用bus.off()。React函数组件类似,通过import引入实例后,在useEffect中订阅事件,同时在useEffect的清理函数(return的函数)中取消订阅,确保组件卸载时清理回调。

    0
    显示验证码
    没有账号?注册  忘记密码?