或者用yarn
Vue3全局事件总线还能用吗?替代mitt/tiny-emitter的最佳方案?
先别急着查第三方库或者否定,先回忆下Vue2和3在组件通信这块的官方变化——确实,Vue3把全局实例的$on、$emit、$off这几个方法彻底删了,甚至连$once都没留,官方文档里提都没提这茬,只在社区FAQ或者Vue2迁移指南里带过一句“不推荐全局事件总线,易导致代码耦合,建议用provide/inject、pinia/vuex状态管理或者props/emit(层级浅的时候)”,那问题来了,真的完全不能用原生实现全局事件总线吗?用第三方库的话,除了mitt还有没有更贴合Vue3特性的?遇到复杂跨组件(比如多层嵌套弹窗、兄弟组件深嵌套孙子组件、第三方组件库封装组件和业务组件通信)场景时,状态管理会不会太“重”?这些都是我踩过Vue2转3坑之后,反复测试和对比过的问题,今天就一次性掰扯清楚。
原生Vue3实现全局事件总线靠谱吗?
先给答案:能做,但体验不如原生Vue2,也不如适配好的第三方库,甚至容易踩响应式边界和清理内存的坑,只适合临时应急的小项目、原型或者不想装依赖的极简场景。
怎么用原生实现?
原理其实很简单,不管Vue2还是Vue3,全局事件总线本质上就是个「发布-订阅模式」的单例对象——有一个存储事件名和对应回调数组的容器,有三个核心方法:
- emit:传入事件名和参数,遍历容器里对应事件的所有回调并执行;
- on:传入事件名和回调,把回调塞进对应数组;
- off:可以清空所有事件、清空某个事件、或者清空某个事件下的某个回调(这个是Vue3原生没有但最好加的,不然很容易内存泄漏);
- once:可选加,执行一次就自动off掉。
具体写的话,一般分两种方式:
第一种是创建一个单独的.js文件导出单例,比如叫eventBus.js:
// 原生事件总线单例实现
class EventBus {
constructor() {
// 事件容器:key是事件名,value是回调数组
this.events = {};
}
// 订阅事件
on(eventName, callback) {
// 先检查这个事件名有没有对应的数组,没有就初始化
if (!this.events[eventName]) {
this.events[eventName] = [];
}
// 把回调存进去,注意这里的顺序!后面可能会有清理的坑
this.events[eventName].push(callback);
}
// 订阅单次事件
once(eventName, callback) {
// 这里要包一个匿名函数,执行完之后自动off自己
const onceCallback = (...args) => {
callback(...args);
this.off(eventName, onceCallback);
};
this.on(eventName, onceCallback);
}
// 发布事件
emit(eventName, ...args) {
const callbacks = this.events[eventName];
if (!callbacks || callbacks.length === 0) return;
// 这里最好用slice复制一份数组遍历,避免在回调里调用off修改原数组导致漏执行
callbacks.slice().forEach(callback => {
// 加个try-catch,防止某个回调报错导致后面的都执行不了
try {
callback(...args);
} catch (e) {
console.error(`全局事件${eventName}的回调执行出错:`, e);
}
});
}
// 取消订阅
off(eventName, callback) {
const callbacks = this.events[eventName];
if (!callbacks) return;
if (callback) {
// 传入了具体回调,就删掉这个
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
} else {
// 没传具体回调,或者想清空整个事件,就直接删key
delete this.events[eventName];
}
}
}
// 导出单例
export default new EventBus();
然后在需要用的组件里导入,比如发布方A.vue:
<script setup>
import eventBus from './eventBus.js'
const handleClick = () => {
eventBus.emit('updateUserInfo', { name: '张三', age: 25 })
}
</script>
订阅方B.vue:
<script setup>
import { onMounted, onUnmounted } from 'vue'
import eventBus from './eventBus.js'
const updateInfo = (user) => {
console.log('收到用户信息啦:', user)
}
// 记得在组件挂载时订阅,卸载时取消!!!
onMounted(() => {
eventBus.on('updateUserInfo', updateInfo)
})
onUnmounted(() => {
eventBus.off('updateUserInfo', updateInfo)
})
</script>
第二种方式是利用Vue3的provide/inject把单例注入到根组件,这样不用每个文件都单独导入,直接在子孙组件里inject就行:
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import EventBus from './eventBus.js'
const app = createApp(App)
// 注入到全局
app.provide('globalEventBus', EventBus)
app.mount('#app')
然后B.vue里这样用:
<script setup>
import { inject, onMounted, onUnmounted } from 'vue'
const eventBus = inject('globalEventBus')
// 剩下的订阅和取消和第一种一样
</script>
原生实现的坑有哪些?
刚才写代码的时候其实已经埋了伏笔,原生实现踩坑概率特别高,尤其是小萌新转3的时候:
必须手动清理回调,否则内存泄漏
Vue2的全局实例绑定的组件事件,虽然也建议手动off,但有时候页面刷新或者组件销毁(尤其是动态组件)可能会自动释放一部分?不对不对,其实Vue2的全局$on$off也一样需要手动清理,但很多人可能因为没遇到过大规模组件销毁的场景,或者用了keep-alive+activated/deactivated的组合,所以忽略了,但到了Vue3,如果是自己写的原生事件总线,忘记在onUnmounted里off回调,那这个回调会一直留在内存里,下次emit还会执行,而且次数会随着组件挂载次数叠加——比如一个弹窗组件打开了5次,那下次emit对应的事件,就会触发5次回调,想想都可怕,不仅占内存,还可能导致业务逻辑混乱。
没有响应式关联,调试起来麻烦
状态管理工具比如Pinia有devtools,可以看到状态的变化历史、触发来源,但原生事件总线完全没有,你不知道某个回调是哪个组件触发的,什么时候触发的,有多少个组件订阅了这个事件,调试的时候只能靠console.log一个个找,尤其是跨文件、跨模块的项目,简直是噩梦。
无法直接和Vue3的响应式系统结合
比如你想在事件总线上存一些临时的小数据?没问题,但这些数据不是响应式的,不能直接在模板里用,得自己转成ref或者reactive,还有,如果回调里用到了组件的响应式数据,那这个回调会持有这个响应式数据的引用,组件销毁时如果没off,不仅回调不会被释放,响应式数据也不会被释放,内存泄漏会更严重。
代码耦合度高
官方不推荐全局事件总线的核心原因就是这个——事件名是全局的,很容易重名,比如你在登录模块定义了一个showModal事件,支付模块也定义了一个showModal事件,那触发其中一个的时候,两个模块的弹窗都会打开,改的时候还要到处找哪里订阅了这个事件,哪里修改了事件名,维护成本极高。
mitt/tiny-emitter是目前最通用的第三方库吗?
先给结论:是的,这两个库都是非常轻量、成熟、专门做发布-订阅模式的单例工具,完全适配Vue3,是目前社区最推荐的全局事件总线替代方案。
先对比一下这两个库的核心数据:
- mitt:压缩后只有200字节左右,没有任何依赖,是Preact团队维护的,专门用于跨组件通信,支持TypeScript,支持通配符事件(),就是订阅所有的emit事件,调试的时候可以用;
- tiny-emitter:压缩后也只有1KB左右,没有依赖,支持TypeScript,支持once、off多个回调,功能比mitt稍微全一点,但体积也稍微大一点;
- 还有一个叫eventemitter3的库,压缩后大概3KB,功能最全面,支持事件优先级、多个事件名用空格分隔、移除所有同名事件的某个回调实例(不管存了多少次),适合复杂的跨组件场景,但体积比前两个大很多,小项目可能没必要用。
怎么用mitt?(新手推荐用这个,简单又轻量)
首先安装mitt:
npm install mittyarn add mitt # 或者用pnpm(推荐) pnpm add mitt
然后同样分两种方式使用:单独导出单例或者用provide/inject。
单独导出单例的方式(最常用)
创建一个mittBus.js文件:
import mitt from 'mitt' // 直接导出单例 export default mitt()
然后在组件里用,和原生类似,但mitt的API稍微有点不一样:
- mitt的事件容器叫
all,是一个Map对象,不是普通对象; - mitt的方法名是
on、emit、off,没有once,但可以自己实现; - mitt的off方法有个小技巧:如果不传回调,会清空整个事件的所有订阅;如果不传事件名,会清空所有事件的所有订阅;
- mitt支持通配符,可以订阅所有emit事件。
比如发布方A.vue:
<script setup>
import mittBus from './mittBus.js'
const handleClick = () => {
mittBus.emit('updateUserInfo', { name: '李四', age: 26 })
mittBus.emit('orderCreated', { orderId: '123456' })
}
</script>
订阅方B.vue:
<script setup>
import { onMounted, onUnmounted } from 'vue'
import mittBus from './mittBus.js'
const updateInfo = (user) => {
console.log('收到用户信息啦:', user)
}
const logAllEvents = (eventName, data) => {
console.log('全局事件触发了:', eventName, '数据是:', data)
}
// 实现once的方式(mitt官方推荐)
const handleOnce = (order) => {
console.log('只执行一次的订单创建回调:', order)
mittBus.off('orderCreated', handleOnce)
}
onMounted(() => {
mittBus.on('updateUserInfo', updateInfo)
mittBus.on('orderCreated', handleOnce)
// 调试用的通配符订阅
mittBus.on('*', logAllEvents)
})
onUnmounted(() => {
mittBus.off('updateUserInfo', updateInfo)
// handleOnce已经自动off了,不用再写
mittBus.off('*', logAllEvents)
// 也可以直接清空这个组件订阅的所有事件,但前提是你把订阅的回调都存起来了
})
</script>
用provide/inject的方式(适合大型项目,统一管理)
在main.js里:
import { createApp } from 'vue'
import App from './App.vue'
import mitt from 'mitt'
const app = createApp(App)
// 创建mitt实例
const mittBus = mitt()
// 注入到全局,也可以注入到app.config.globalProperties上,但官方不推荐直接操作全局属性
app.provide('mittBus', mittBus)
// 如果非要用全局属性的话(Vue3也保留了$属性的挂载方式,但建议少用)
app.config.globalProperties.$mittBus = mittBus
app.mount('#app')
然后在setup语法糖的组件里用inject:
<script setup>
import { inject, onMounted, onUnmounted } from 'vue'
const mittBus = inject('mittBus')
// 剩下的和单独导出的方式一样
</script>
如果是在选项式API的组件里用(比如老项目转3还保留了选项式),可以直接用this.$mittBus:
<script>
export default {
mounted() {
this.$mittBus.on('updateUserInfo', this.updateInfo)
},
beforeUnmount() {
this.$mittBus.off('updateUserInfo', this.updateInfo)
},
methods: {
updateInfo(user) {
console.log('收到用户信息啦:', user)
}
}
}
</script>
mitt相比原生实现的优势有哪些?
刚才已经提到了一些,现在总结得更全面一点:
无需手动写发布-订阅逻辑,代码更简洁
不用自己写EventBus类,不用处理事件容器的初始化、匿名函数的执行顺序、回调里的try-catch(虽然try-catch加了更好,但mitt内部好像没加?不对不对,刚才查了下,mitt的源码里确实没加try-catch,所以如果怕某个回调报错影响后面的,还是要自己加一下,或者用eventemitter3,eventemitter3内部有try-catch)。
用Map对象存事件,性能更高
原生实现用普通对象存事件,当事件名很多的时候,查找和删除的效率不如Map对象——Map对象的key可以是任意类型,查找、插入、删除的时间复杂度都是O(1),而普通对象的key如果是字符串或者Symbol,也是O(1),但如果是其他类型就不行了,而且Map对象还有size属性,可以直接知道某个事件有多少个订阅者,普通对象得用Object.keys或者Object.values才能知道。
支持通配符事件,调试更方便
通配符订阅可以捕获所有的emit事件,打印事件名和数据,调试的时候不用一个个加console.log,非常好用。
支持TypeScript,类型安全
如果你的项目用了TypeScript,mitt可以给事件名和对应的数据类型做类型注解,防止传错事件名或者参数类型,
// mittBus.ts
import mitt from 'mitt'
// 定义事件类型接口
type Events = {
updateUserInfo: { name: string; age: number }
orderCreated: { orderId: string; totalAmount: number }
// 通配符事件
'*': [string, any]
}
// 创建带类型的mitt实例
export default mitt<Events>()
然后在组件里用的时候,TypeScript会自动提示事件名和参数类型,如果传错了会报错,大大减少了bug的出现概率。
有没有比mitt/tiny-emitter更贴合Vue3特性的方案?
刚才说了,官方不推荐全局事件总线,推荐用provide/inject、状态管理或者props/emit,但如果真的遇到了状态管理太“重”,props/emit层级太深,provide/inject传递函数太麻烦的场景,有没有更贴合Vue3的方案?答案是有,用组合式API封装一个带自动清理功能的useEventBus Hook!
什么是自动清理功能的useEventBus Hook?
原生实现和mitt都需要手动在onMounted里订阅,onUnmounted里取消,很容易忘记,那能不能把这个逻辑封装到一个Hook里,让它自动在组件挂载时订阅,组件卸载时取消?当然可以!而且可以结合Vue3的响应式系统,让事件总线的使用更安全、更方便。
怎么封装这个Hook?
我们可以基于mitt来封装,这样既保留了mitt的轻量和成熟,又增加了自动清理的功能,还可以支持TypeScript:
// useEventBus.ts
import { inject, onMounted, onUnmounted, getCurrentInstance } from 'vue'
import mitt from 'mitt'
import type { Emitter, EventType } from 'mitt'
// 注入Key,用Symbol防止和其他注入的Key冲突
const EVENT_BUS_KEY = Symbol('eventBus')
// 定义事件类型接口(这里可以根据自己的项目需求修改,也可以让Hook支持泛型)
type Events = {
updateUserInfo: { name: string; age: number }
orderCreated: { orderId: string; totalAmount: number }
'*': [string, any]
}
// 创建带类型的mitt实例
const emitter = mitt<Events>()
// 安装插件的函数,在main.js里调用
export const installEventBus = (app: any) => {
app.provide(EVENT_BUS_KEY, emitter)
// 也可以挂载到全局属性上,方便选项式API使用
app.config.globalProperties.$eventBus = emitter
}
// Hook函数
export const useEventBus = <T extends EventType>(eventName?: T) => {
// 获取当前组件实例,如果没有,说明不是在setup里调用的,抛出错误
const instance = getCurrentInstance()
if (!instance) {
throw new Error('useEventBus must be called inside setup()')
}
// 注入mitt实例,如果没有注入,抛出错误
const bus = inject<Emitter<Events>>(EVENT_BUS_KEY)
if (!bus) {
throw new Error('EventBus not installed, please call installEventBus first')
}
// 存储当前组件订阅的所有回调,方便自动清理
const subscriptions: Array<{
eventName: EventType
callback: (...args: any[]) => void
}> = []
// 订阅事件的函数
const on = (name: T, callback: (...args: Events[T][]) => void) => {
bus.on(name, callback)
subscriptions.push({ eventName: name, callback })
}
// 订阅单次事件的函数
const once = (name: T, callback: (...args: Events[T][]) => void) => {
const onceCallback = (...args: Events[T][]) => {
callback(...args)
// 执行完之后自动从subscriptions里移除
const index = subscriptions.findIndex(
(sub) => sub.eventName === name && sub.callback === onceCallback
)
if (index > -1) {
subscriptions.splice(index, 1)
}
bus.off(name, onceCallback)
}
bus.on(name, onceCallback)
subscriptions.push({ eventName: name, callback: onceCallback })
}
// 发布事件的函数
const emit = (name: T, ...args: Events[T][]) => {
bus.emit(name, ...args)
}
// 手动取消订阅的函数(可选)
const off = (name?: T, callback?: (...args: Events[T][]) => void) => {
if (name && callback) {
// 取消指定事件的指定回调
bus.off(name, callback)
const index = subscriptions.findIndex(
(sub) => sub.eventName === name && sub.callback === callback
)
if (index > -1) {
subscriptions.splice(index, 1)
}
} else if (name) {
// 取消指定事件的所有回调
bus.off(name)
// 从subscriptions里移除所有对应事件名的回调
let i = subscriptions.length
while (i--) {
if (subscriptions[i].eventName === name) {
subscriptions.splice(i, 1)
}
}
} else {
// 取消当前组件订阅的所有回调
subscriptions.forEach((sub) => {
bus.off(sub.eventName, sub.callback)
})
subscriptions.length = 0
}
}
// 自动在组件卸载时取消所有订阅
onUnmounted(() => {
off()
})
// 如果Hook传入了eventName,就返回on、once、emit、off四个函数
// 如果没有传入,就返回bus实例,方便通配符订阅等操作
if (eventName) {
return {
on: (callback: (...args: Events[T][]) => void) => on(eventName, callback),
once: (callback: (...args: Events[T][]) => void) => once(eventName, callback),
emit: (...args: Events[T][]) => emit(eventName, ...args),
off: (callback?: (...args: Events[T][]) => void) => off(eventName, callback)
}
} else {
return {
bus,
on,
once,
emit,
off
}
}
}
怎么用这个Hook?
首先在main.js里调用installEventBus安装插件:
import { createApp } from 'vue'
import App from './App.vue'
import { installEventBus } from './useEventBus.ts'
const app = createApp(App)
// 安装插件
installEventBus(app)
app.mount('#app')
然后在组件里用,分两种情况:
只订阅/发布单个事件
比如订阅方B.vue只订阅updateUserInfo事件:
<script setup>
import { useEventBus } from './useEventBus.ts'
// 传入事件名,返回针对这个事件的四个函数
const { on, emit } = useEventBus('updateUserInfo')
// 直接调用on订阅,不用手动在onUnmounted里取消!
on((user) => {
console.log('收到用户信息啦:', user)
})
// 也可以在这个组件里调用emit发布
const handleSelfClick = () => {
emit({ name: '王五', age: 27 })
}
</script>
是不是超级简单?不用写onMounted和onUnmounted了,完全不用担心忘记清理回调导致内存泄漏!
需要订阅/发布多个事件,或者用通配符订阅
比如订阅方C.vue需要订阅updateUserInfo和orderCreated两个事件,还要用通配符调试:
<script setup>
import { useEventBus } from './useEventBus.ts'
// 不传事件名,返回bus实例和四个通用函数
const { bus, on } = useEventBus()
// 订阅多个事件
on('updateUserInfo', (user) => {
console.log('C收到用户信息啦:', user)
})
on('orderCreated', (order) => {
console.log('C收到订单信息啦:', order)
})
// 通配符订阅
bus.on('*', (eventName, data) => {
console.log('C调试用的全局事件:', eventName, data)
})
// 注意:通配符订阅是直接操作bus实例的,不会自动清理!!!如果需要自动清理,要用通用的on函数!
// 哦对,刚才封装的Hook里的通用on函数是支持通配符的吗?刚才看了下类型定义,Events里有'*',参数是[string, any],所以应该可以:
on('*', (eventName, data) => {
console.log('C自动清理的通配符事件:', eventName, data)
})
</script>
什么时候用全局事件总线(mitt/tiny-emitter/useEventBus)?什么时候用状态管理(Pinia)?什么时候用provide/inject/props/emit?
最后给大家一个清晰的场景划分,避免滥用:
优先用props/emit
- 场景:父子组件通信,层级不超过3层;
- 优点:单向数据流清晰,类型安全,调试方便,代码耦合度低;
- 缺点:层级深的话会出现“prop drilling”( props层层传递)的问题,代码冗余。
其次用provide/inject
- 场景:跨多层父子组件通信,比如爷爷组件->孙子组件->曾孙子组件,数据和方法不需要在全局共享,只在某个组件树里共享;
- 优点:不需要props层层传递,代码简洁,Vue3.3之后支持defineProvide和defineInject,类型安全更好;
- 缺点:没有响应式关联调试(provide/inject的数据如果是ref/reactive,devtools可以看到,但函数看不到),调试比props/emit麻烦一点,代码耦合度比全局事件总线低,但比props/emit高。
然后用useEventBus/mitt/tiny-emitter
- 场景:兄弟组件通信、跨模块组件通信、第三方组件库封装组件和业务组件通信、需要临时传递一些小数据或者触发一些小逻辑(不需要持久化存储,不需要在多个地方共享状态);
- 优点:轻量,代码简洁,不需要安装状态管理工具,适合临时应急的小场景;
- 缺点:代码耦合度高,调试不如状态管理方便,容易出现事件重名的问题。
最后用Pinia(或者Vuex,但Pinia更推荐)
- 场景:需要持久化存储的全局状态(比如用户登录信息、购物车信息、主题设置)、需要在多个地方共享和修改的状态、需要复杂的状态逻辑(比如异步请求、计算属性)、需要统一的状态管理和调试;
- 优点:有devtools,调试超级方便,可以看到状态的变化历史、触发来源,支持TypeScript,类型安全,代码耦合度低,支持插件(比如持久化插件pinia-plugin-persistedstate);
- 缺点:稍微有点“重”,需要安装依赖,需要定义store,适合大型项目。
Vue3虽然删除了原生的全局事件总线,但我们可以用原生实现(只适合临时应急)、mitt/tiny-emitter(社区最推荐的通用方案)、或者自己封装的带自动清理功能的useEventBus Hook(最贴合Vue3特性的方案)来替代,官方还是不推荐滥用全局事件总线,建议根据场景优先选择props/emit、provide/inject或者Pinia。
最后提醒大家一句:不管用哪种全局事件总线方案,都要注意事件名的命名规范,最好用前缀区分模块,比如user/updateInfo、order/created,这样可以避免事件重名的问题,降低代码耦合度,方便维护。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



