或者用yarn/pnpm
Vue3官方把$on/$off/$once删了?那跨组件传值还能怎么用事件总线?
Vue2到Vue3,事件总线到底变了啥?
刚接触Vue3的老Vue2开发者,第一个踩的坑大概率就是事件总线,之前Vue2写全局传值超简单:new一个Vue实例挂到原型上,this.$bus.$emit发消息,this.$bus.$on/$once收消息,this.$bus.$off卸载时清理,三步搞定,可翻Vue3文档找遍原型链、实例方法,都看不到这三个API的影子——没错,官方直接把它们完全移除了。
为什么要删?很多人一开始会觉得官方是故意为难开发者,其实不然,Vue2的原型挂载事件总线有两个核心问题:一是原型污染风险,你把$bus挂到Vue.prototype上,所有组件实例都能直接访问,万一团队里有人误改了这个实例的data、methods,那整个项目的跨组件通信都要崩;二是类型支持缺失,用TypeScript写Vue2时,原型上的$bus没法自动推断类型,emit和on的参数全靠猜,报错只能等到运行时;三是事件管理混乱,多人协作的项目里,没人记得全项目有多少个事件名、谁在发谁在收,卸载时经常漏$off,导致内存泄漏——比如反复打开关闭的弹窗组件,每次打开都注册一次$on,组件销毁了还留着监听,下次打开弹窗就会触发两次甚至多次回调。
那Vue3官方推荐的替代方案是什么?看官方文档,跨组件传值分场景给了三个方向:父子/爷孙用props/emit + provide/inject,兄弟/无关联用Pinia(或者Vuex4),非响应式的一次性大组件传参用mitt/tiny-emitter这种第三方库,不过很多小项目不想引入状态管理库,更不想重写复杂的provide/inject层级,还是习惯用“事件总线”这种“哪里需要点哪里”的轻量方案,所以接下来的重点就是讲——
Vue3环境下,如何从零手写一个轻量、安全、支持TS的事件总线?
手写事件总线其实不难,核心逻辑就是用一个对象当事件注册表,key存事件名,value存对应的回调函数数组,然后封装四个基础方法:on(注册)、once(注册一次性)、emit(触发)、off(卸载),但要做到“符合Vue3开发习惯、安全、支持TS”,得加不少细节。
先讲基础版(纯JS,无TS,无Vue结合),这个版本逻辑最清晰,能让你彻底明白事件总线的原理。
// src/utils/eventBus.js
class EventBus {
constructor() {
// 事件注册表:key是事件名,value是回调函数数组
this.events = {};
}
// 注册事件:可以接收多个事件名,但参数和回调要对应
on(eventName, callback) {
// 先检查事件名和回调是否合法
if (typeof eventName !== 'string' || typeof callback !== 'function') {
console.warn('EventBus on方法参数错误:eventName必须是字符串,callback必须是函数');
return;
}
// 如果事件名不存在,就创建一个空数组
if (!this.events[eventName]) {
this.events[eventName] = [];
}
// 把回调函数推到数组里
this.events[eventName].push(callback);
}
// 注册一次性事件:触发一次后自动卸载
once(eventName, callback) {
if (typeof eventName !== 'string' || typeof callback !== 'function') {
console.warn('EventBus once方法参数错误:eventName必须是字符串,callback必须是函数');
return;
}
// 定义一个包装函数,触发后调用off卸载自己
const wrapper = (...args) => {
callback(...args);
this.off(eventName, wrapper);
};
// 把原回调挂载到wrapper上,方便后续精确卸载
wrapper.originalCallback = callback;
this.on(eventName, wrapper);
}
// 触发事件:可以传任意数量的参数给回调
emit(eventName, ...args) {
if (typeof eventName !== 'string') {
console.warn('EventBus emit方法参数错误:eventName必须是字符串');
return;
}
// 如果事件名不存在,直接返回
if (!this.events[eventName]) return;
// 遍历回调数组,依次调用,注意要用slice()复制一份,防止卸载回调时遍历出错
this.events[eventName].slice().forEach(callback => {
callback(...args);
});
}
// 卸载事件:分三种情况
off(eventName, callback) {
if (typeof eventName !== 'string') {
console.warn('EventBus off方法参数错误:eventName必须是字符串');
return;
}
// 情况1:只传了事件名,卸载该事件的所有回调
if (!callback) {
delete this.events[eventName];
return;
}
// 情况2:传了事件名和回调,检查是否合法
if (typeof callback !== 'function') {
console.warn('EventBus off方法参数错误:callback必须是函数或不传');
return;
}
// 情况3:如果事件名不存在,直接返回
if (!this.events[eventName]) return;
// 过滤数组:保留不是目标回调的函数,同时要考虑once注册的wrapper
this.events[eventName] = this.events[eventName].filter(cb =>
cb !== callback && cb.originalCallback !== callback
);
// 如果过滤后数组为空,删除该事件名,释放内存
if (this.events[eventName].length === 0) {
delete this.events[eventName];
}
}
}
// 导出单例
export default new EventBus();
这个基础版已经能解决大部分小项目的需求了,但纯JS用起来还是没安全感,TypeScript用户肯定得加类型支持,而且最好能结合Vue3的组合式API,比如在组件卸载时自动调用off,不用手动写,这样就不会漏清理导致内存泄漏了——没错,接下来讲进阶版(TS + 组合式API自动卸载)。
// src/utils/eventBus.ts
// 先定义事件类型映射:把所有事件名和对应的回调函数参数类型、返回值类型写在这里
// 这样在使用emit和on时,TS会自动提示和检查,防止写错事件名、参数数量或类型
type EventMap = {
// 示例1:弹窗显示事件,没有参数
'show-modal': () => void;
// 示例2:表单提交成功事件,参数是提交的数据对象
'form-submit-success': (data: { id: number; name: string; email: string }) => void;
// 示例3:购物车数量变化事件,参数是新的数量
'cart-count-change': (count: number) => void;
};
class EventBus {
// 事件注册表的类型限制:key必须是EventMap的键,value是对应的回调函数数组
private events: Map<keyof EventMap, Set<(...args: any[]) => void>> = new Map();
// 注册事件
on<K extends keyof EventMap>(eventName: K, callback: EventMap[K]) {
if (typeof eventName !== 'string' || typeof callback !== 'function') {
console.warn('EventBus on方法参数错误:eventName必须是事件映射表中的字符串,callback必须是对应的函数');
return;
}
if (!this.events.has(eventName)) {
this.events.set(eventName, new Set());
}
this.events.get(eventName)!.add(callback);
}
// 注册一次性事件
once<K extends keyof EventMap>(eventName: K, callback: EventMap[K]) {
if (typeof eventName !== 'string' || typeof callback !== 'function') {
console.warn('EventBus once方法参数错误:eventName必须是事件映射表中的字符串,callback必须是对应的函数');
return;
}
const wrapper = (...args: any[]) => {
callback(...args);
this.off(eventName, wrapper);
};
// 这里不用挂载原回调了,因为用了Set,后续精确卸载可以直接用once返回的wrapper
this.on(eventName, wrapper as EventMap[K]);
}
// 触发事件
emit<K extends keyof EventMap>(eventName: K, ...args: Parameters<EventMap[K]>) {
if (typeof eventName !== 'string') {
console.warn('EventBus emit方法参数错误:eventName必须是事件映射表中的字符串');
return;
}
if (!this.events.has(eventName)) return;
// Set遍历更安全,不用slice()复制
this.events.get(eventName)!.forEach(callback => {
callback(...args);
});
}
// 卸载事件
off<K extends keyof EventMap>(eventName: K, callback?: EventMap[K]) {
if (typeof eventName !== 'string') {
console.warn('EventBus off方法参数错误:eventName必须是事件映射表中的字符串');
return;
}
if (!this.events.has(eventName)) return;
if (!callback) {
this.events.delete(eventName);
return;
}
if (typeof callback !== 'function') {
console.warn('EventBus off方法参数错误:callback必须是对应的函数或不传');
return;
}
this.events.get(eventName)!.delete(callback);
if (this.events.get(eventName)!.size === 0) {
this.events.delete(eventName);
}
}
}
// 导出单例
const eventBus = new EventBus();
export default eventBus;
// 导出结合Vue3组合式API的自动卸载钩子
import { onUnmounted } from 'vue';
export function useEventBus<K extends keyof EventMap>(
eventName: K,
callback: EventMap[K],
isOnce: boolean = false
) {
// 根据isOnce选择注册方式
isOnce ? eventBus.once(eventName, callback) : eventBus.on(eventName, callback);
// 组件卸载时自动调用off,清理当前注册的回调
onUnmounted(() => {
eventBus.off(eventName, callback);
});
}
这个进阶版是不是完美?首先用了TypeScript的类型映射,所有事件名、参数都有类型检查,比如你想触发'show-modal'事件却传了个参数,TS会立刻报错;其次用了Map和Set替代普通对象和数组,Map的key可以是任意类型(不过这里我们限制了只能是EventMap的字符串键),Set的遍历更安全,而且添加和删除的时间复杂度都是O(1),普通数组的filter是O(n),性能更好;最后封装了useEventBus组合式API钩子,只要在组件里调用这个钩子注册事件,组件卸载时就会自动调用off,完全不用担心漏清理导致内存泄漏——这可是Vue2事件总线最大的痛点之一。
手写事件总线之外,还有哪些成熟的第三方轻量事件总线库可选?
如果你不想自己手写,觉得维护类型映射表麻烦,或者想要更多功能,比如事件名的命名空间、事件队列、事件取消等,那可以用成熟的第三方库,目前Vue3生态里最常用的轻量事件总线库有两个:mitt和tiny-emitter。
mitt:Vue3官方文档推荐的第三方事件总线库
先提一下,Vue3官方文档在“移除的API”章节里,明确说了“如果你需要事件总线,可以使用mitt或tiny-emitter这样的库”,所以mitt是“官方认证”的替代品。
mitt有多轻?看npm上的介绍,它的压缩后体积只有200字节左右,几乎可以忽略不计,而且支持TypeScript,支持命名空间,支持事件取消,API和我们手写的进阶版几乎一样,非常容易上手。
那mitt怎么用?简单举个例子:
首先安装mitt:
npm install mittyarn add mitt pnpm add mitt
然后封装成和进阶版类似的单例+组合式API钩子:
// src/utils/eventBus.ts
import mitt from 'mitt';
import { onUnmounted } from 'vue';
// 定义事件类型映射
type EventMap = {
'show-modal': () => void;
'form-submit-success': (data: { id: number; name: string; email: string }) => void;
'cart-count-change': (count: number) => void;
};
// 创建单例
const eventBus = mitt<EventMap>();
export default eventBus;
// 封装自动卸载钩子
export function useEventBus<K extends keyof EventMap>(
eventName: K,
callback: EventMap[K]
) {
eventBus.on(eventName, callback);
onUnmounted(() => {
eventBus.off(eventName, callback);
});
}
使用起来和手写的进阶版完全一样,这里就不重复举例了。
tiny-emitter:另一个常用的轻量事件总线库
tiny-emitter也是一个非常轻量的库,压缩后体积和mitt差不多,不过它没有官方推荐,但社区用的人也不少,它的API和Vue2的$on/$off/$once/$emit更像,off可以同时卸载多个事件名的回调,用正则表达式匹配事件名等——如果你习惯了Vue2的API写法,tiny-emitter可能更适合你。
安装tiny-emitter:
npm install tiny-emitteryarn add tiny-emitter pnpm add tiny-emitter
封装成TS+组合式API钩子的版本:
// src/utils/eventBus.ts
import TinyEmitter from 'tiny-emitter';
import { onUnmounted } from 'vue';
// 定义事件类型映射
type EventMap = {
'show-modal': () => void;
'form-submit-success': (data: { id: number; name: string; email: string }) => void;
'cart-count-change': (count: number) => void;
};
// 创建单例
const eventBus = new TinyEmitter() as Omit<TinyEmitter, 'on' | 'once' | 'emit' | 'off'> & {
on<K extends keyof EventMap>(eventName: K, callback: EventMap[K]): this;
once<K extends keyof EventMap>(eventName: K, callback: EventMap[K]): this;
emit<K extends keyof EventMap>(eventName: K, ...args: Parameters<EventMap[K]>): this;
off<K extends keyof EventMap>(eventName: K | RegExp, callback?: EventMap[K]): this;
};
export default eventBus;
// 封装自动卸载钩子
export function useEventBus<K extends keyof EventMap>(
eventName: K,
callback: EventMap[K],
isOnce: boolean = false
) {
isOnce ? eventBus.once(eventName, callback) : eventBus.on(eventName, callback);
onUnmounted(() => {
eventBus.off(eventName, callback);
});
}
注意这里用了类型断言,因为tiny-emitter本身没有很好的TypeScript支持,需要我们自己重写on/once/emit/off的类型。
什么情况下适合用事件总线?什么情况下不适合?
很多开发者不管什么场景都用事件总线,这其实是不对的,事件总线虽然轻量,但也有缺点:比如事件流不透明,很难追踪谁在发谁在收;比如没有响应式数据,不能直接在模板里用;比如多人协作时容易冲突事件名,所以我们要根据场景选择合适的跨组件传值方案。
适合用事件总线的场景
- 非响应式的一次性通知:比如弹窗显示/关闭、表单提交成功/失败的提示、消息通知的弹出——这些场景不需要保存状态,只需要触发一次回调,事件总线比Pinia轻量多了。
- 无直接关联的兄弟/跨层级组件传值:比如侧边栏的购物车图标需要更新数量,而购物车图标在App.vue的侧边栏,购物车操作在商品详情页的底部按钮——这两个组件没有父子/爷孙关系,provide/inject要层层传递太麻烦,Pinia又有点大材小用,事件总线就很合适。
- 非Vue组件之间的通信:比如Vue组件和原生JS插件、和Web Worker、和WebSocket的通信——这些场景不能用props/emit、provide/inject、Pinia,事件总线是最好的选择。
不适合用事件总线的场景
- 需要保存状态的场景:比如用户的登录状态、购物车的商品列表、全局的主题色——这些场景需要数据持久化、响应式更新,必须用Pinia(或者Vuex4)。
- 直接关联的父子/爷孙组件传值:比如父组件给子组件传数据用props,子组件给父组件传数据用emit,爷孙组件传数据用provide/inject——这些场景用原生的API更清晰,类型支持更好,追踪更方便。
- 复杂的业务逻辑场景:比如多个组件需要根据同一个数据执行不同的业务逻辑,或者需要异步操作(比如请求接口)后更新多个组件——这些场景用Pinia的actions和getters更合适,逻辑更集中,更容易维护。
如何避免事件总线带来的问题?
虽然事件总线有缺点,但只要我们遵循一些规范,就能把这些缺点降到最低。
规范1:统一管理事件名
不管是手写的事件总线还是用第三方库,都要统一在一个文件里定义所有的事件名(比如TypeScript的事件类型映射表),禁止在组件里随便写事件名——这样可以避免事件名冲突,也方便后续维护和查找。
规范2:尽量用组合式API自动卸载
不管是手写的useEventBus还是封装第三方库的useEventBus,都要尽量用这个钩子注册事件,不要手动在组件里写on和onUnmounted——这样可以完全避免漏清理导致的内存泄漏。
规范3:事件名要语义化、带前缀
事件名最好语义化,show-user-modal'比'show-modal'更清楚是哪个弹窗;最好带前缀,user:'、'cart:'、'form:'——这样可以避免不同模块的事件名冲突,也方便用命名空间(mitt支持命名空间,tiny-emitter支持正则表达式匹配)批量卸载事件。
规范4:尽量少用全局事件总线
如果不是必须跨整个项目传值,可以考虑用局部事件总线——比如在一个模块的根组件里provide一个事件总线实例,子组件里inject使用——这样可以把事件流限制在一个模块里,不会污染全局,也更容易追踪。
规范5:事件回调里尽量不要写复杂的业务逻辑
事件回调里最好只写简单的操作,比如更新组件的局部状态、显示提示、跳转路由——如果有复杂的业务逻辑,最好抽成一个函数,或者用Pinia的actions。
事件总线和Pinia该怎么选?
很多开发者会纠结这个问题,其实很简单:看是否需要保存状态,如果需要保存状态,不管多小的项目,都用Pinia;如果不需要保存状态,只是简单的通知,就用事件总线。
举个例子:购物车数量变化,如果只是想让侧边栏的购物车图标更新数量,那可以用事件总线;但如果还想让商品详情页的“加入购物车”按钮变成“已加入”,让购物车页面的列表更新,让结算页面的总金额更新,那必须用Pinia——因为这些场景都需要保存购物车的商品列表这个状态。
再举个例子:用户登录成功,如果只是想显示一个“登录成功”的提示,那可以用事件总线;但如果还想让导航栏显示用户的头像和昵称,让个人中心页面显示用户的信息,让某些需要登录的页面跳转逻辑改变,那必须用Pinia——因为这些场景都需要保存用户的登录状态和信息这个状态。
事件总线是Vue3生态里一个非常有用的轻量跨组件传值方案,虽然官方移除了Vue2的$on/$off/$once,但我们可以手写一个,或者用mitt/tiny-emitter这样的第三方库,但要记住,事件总线不是万能的,要根据场景选择合适的方案,遵循规范,避免带来问题。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网

