请手写一个 EventBus,让我看看你的代码能力!
EventBus 是事件总线,也可以看成一种事件发布/订阅实现。本文保留原文从“乞丐版”、传参、取消订阅到只执行一次的递进过程,修复回调拼写、空事件报错、事件删除、
$once重入和内存清理问题,并补充 Vue 3 的事件 API、mitt、EventTarget和跨标签页通信说明。
一、什么是 EventBus
事件总线允许发布者和订阅者通过一个中间对象通信:
订阅者 --$on--> EventBus <--$emit-- 发布者
发布者不需要持有订阅者实例,只需要约定事件名和 payload;EventBus 保存监听器,并在事件触发时依次调用它们。

它和 DOM 事件、消息队列有相似之处,但不要混为一谈:
- DOM
EventTarget由浏览器提供,支持事件对象和部分传播机制; - EventBus 通常是应用内存中的对象,只在同一个 JavaScript 上下文中生效;
- 消息队列或
BroadcastChannel可以根据协议实现更广范围的通信; - Vue 组件事件主要用于子组件通知父组件,并不是全局 EventBus。
二、乞丐版 EventBus:只订阅和发布
实现目标:
$on(name, handler)订阅事件;$emit(name, ...args)发布事件。
class SimpleEventBus {
constructor() {
// 使用无原型对象,避免事件名覆盖 toString 等继承属性。
this.eventObj = Object.create(null)
}
$on(name, handler) {
if (typeof handler !== 'function') {
throw new TypeError('handler 必须是函数')
}
if (!this.eventObj[name]) {
this.eventObj[name] = []
}
this.eventObj[name].push(handler)
}
$emit(name, ...args) {
// 未注册事件时安静返回,不应对 undefined 做 for...of。
const eventList = this.eventObj[name] || []
// 快照可以避免某个监听器在派发过程中修改数组导致遍历跳过。
for (const handler of eventList.slice()) {
handler(...args)
}
}
}
const bus = new SimpleEventBus()
bus.$on('key1', () => {
console.info('我是订阅事件 A')
})
bus.$on('key1', () => {
console.info('我是订阅事件 B')
})
bus.$on('key2', () => {
console.info('我是订阅事件 C')
})
bus.$emit('key1')
// 我是订阅事件 A
// 我是订阅事件 B
bus.$emit('key2')
// 我是订阅事件 C
原文的基本思路是正确的:用一个对象保存事件名,用数组保存该事件的回调。但原始代码有两个需要修正的地方:回调变量拼写错误,以及发布一个从未注册的事件时 eventList 可能是 undefined。

三、传参版 EventBus
公众号发布内容时不能总是空 payload,事件总线也应允许 $emit 传递任意参数:
class ArgumentEventBus {
constructor() {
this.events = Object.create(null)
}
$on(name, handler) {
if (typeof handler !== 'function') {
throw new TypeError('handler 必须是函数')
}
const list = this.events[name] || (this.events[name] = [])
list.push(handler)
}
$emit(name, ...args) {
for (const handler of (this.events[name] || []).slice()) {
handler(...args)
}
}
}
const bus = new ArgumentEventBus()
bus.$on('key1', (name, age) => {
console.info('我是订阅事件 A:', name, age)
})
bus.$on('key1', (name, age) => {
console.info('我是订阅事件 B:', name, age)
})
bus.$on('key2', name => {
console.info('我是订阅事件 C:', name)
})
bus.$emit('key1', '小猪课堂', 26)
bus.$emit('key2', '小猪课堂')
使用 ...args 让事件可以传递多个参数;大型项目更推荐传递一个结构稳定的 payload 对象,避免监听器依赖参数位置:
bus.$emit('user:login', {
id: 1,
source: 'dialog',
})

四、取消订阅版 EventBus
只订阅不取消会导致监听器一直保留,组件反复挂载时可能重复执行甚至造成内存泄漏。取消订阅至少需要知道事件名和回调函数;原文使用自增 id,本节保留这个思路。
class RemovableEventBus {
constructor() {
this.events = Object.create(null)
this.nextId = 0
}
$on(name, handler) {
if (typeof handler !== 'function') {
throw new TypeError('handler 必须是函数')
}
const list = this.events[name] || (this.events[name] = [])
const listener = {
id: this.nextId++,
handler,
}
list.push(listener)
return listener.id
}
$emit(name, ...args) {
const list = this.events[name]
if (!list) return
for (const listener of list.slice()) {
listener.handler(...args)
}
}
$off(name, id) {
const list = this.events[name]
if (!list) return
const index = list.findIndex(listener => listener.id === id)
if (index !== -1) {
list.splice(index, 1)
}
if (list.length === 0) {
delete this.events[name]
}
}
}
const bus = new RemovableEventBus()
const id = bus.$on('key1', (name, age) => {
console.info('我是订阅事件 B:', name, age)
})
bus.$emit('key1', '小猪课堂', 26)
bus.$off('key1', id)
bus.$emit('key1', '小猪课堂', 26) // B 不再执行
原文把每个事件的监听器存成 { id: callback } 对象,这可以实现功能,但数组或 Map 更容易保持顺序,也能避免对象 key 自动转换带来的困惑。实际项目还可以让 $off 支持回调函数、返回取消函数,或者一次性移除整个事件:
const stop = bus.$on('message', handler)
// 如果 API 直接返回取消函数,使用体验更好:
// stop()

五、执行一次版 EventBus
$once 表示监听器最多执行一次。不要依赖在数字 id 前拼接 D 再判断字符串,这种方式容易和普通 id、类型转换和删除时机混在一起;可以直接在监听器记录上保存 once 标记。
class EventBus {
constructor() {
this.events = new Map()
this.nextId = 0
}
$on(name, handler) {
return this.add(name, handler, false)
}
$once(name, handler) {
return this.add(name, handler, true)
}
add(name, handler, once) {
if (typeof handler !== 'function') {
throw new TypeError('handler 必须是函数')
}
const listener = {
id: this.nextId++,
handler,
once,
}
const list = this.events.get(name) || []
list.push(listener)
this.events.set(name, list)
return listener.id
}
$emit(name, ...args) {
const list = this.events.get(name)
if (!list) return
// 使用快照;一次性监听器在回调执行前移除,重入 emit 也不会再次执行。
for (const listener of list.slice()) {
if (listener.once) {
this.$off(name, listener.id)
}
listener.handler(...args)
}
}
$off(name, id) {
const list = this.events.get(name)
if (!list) return
const next = list.filter(listener => listener.id !== id)
if (next.length > 0) {
this.events.set(name, next)
} else {
this.events.delete(name)
}
}
}
const bus = new EventBus()
bus.$on('key1', (name, age) => {
console.info('我是订阅事件 A:', name, age)
})
bus.$once('key1', (name, age) => {
console.info('我是订阅事件 B:', name, age)
})
bus.$emit('key1', '小猪课堂', 26)
console.info('再次触发 key1')
bus.$emit('key1', '小猪课堂', 26)
// 第一次 A、B 都执行;第二次只有 A 执行。
一次性监听器在执行前删除,是为了处理这种情况:回调内部再次触发同一个事件。如果执行后才删除,重入调用可能让 $once 回调执行多次。

六、一个更完整、可直接复用的实现
如果希望同时支持按 id、按回调和按事件名取消,可以把 API 设计得更完整:
export class EventBus {
constructor() {
this.events = new Map()
this.nextId = 0
}
on(name, handler) {
if (typeof handler !== 'function') {
throw new TypeError('handler 必须是函数')
}
const listener = {
id: this.nextId++,
handler,
once: false,
}
const list = this.events.get(name) || []
list.push(listener)
this.events.set(name, list)
// 返回取消函数,避免调用方额外保存数字 id。
return () => this.off(name, listener.id)
}
once(name, handler) {
if (typeof handler !== 'function') {
throw new TypeError('handler 必须是函数')
}
const listener = {
id: this.nextId++,
handler,
once: true,
}
const list = this.events.get(name) || []
list.push(listener)
this.events.set(name, list)
return () => this.off(name, listener.id)
}
emit(name, ...args) {
const list = this.events.get(name)
if (!list) return
for (const listener of list.slice()) {
if (listener.once) this.off(name, listener.id)
listener.handler(...args)
}
}
off(name, idOrHandler) {
if (name == null) {
this.events.clear()
return
}
const list = this.events.get(name)
if (!list) return
if (idOrHandler == null) {
this.events.delete(name)
return
}
const next = list.filter(listener => {
return listener.id !== idOrHandler && listener.handler !== idOrHandler
})
if (next.length > 0) this.events.set(name, next)
else this.events.delete(name)
}
}
这里用 on/emit/off/once 命名,避免和 Vue 组件实例 API 产生不必要混淆;如果为了保留原文面试题形式,也可以把方法名改成 $on/$emit/$off/$once。
实现时还要考虑:
- 事件名是否使用常量或命名空间,避免字符串拼写冲突;
- 监听器执行顺序是否需要稳定;
- 一个监听器抛错时是否继续执行其他监听器;
$emit过程中新增/删除监听器的语义;- 是否需要异步派发、错误上报和类型约束;
- 组件卸载时是否一定调用取消函数。
七、EventBus 在 Vue 中的使用
7.1 Vue 2 的历史写法
Vue 2 曾经常见一个空 Vue 实例作为总线:
// bus.js,Vue 2
import Vue from 'vue'
export const bus = new Vue()
// 订阅方
mounted() {
bus.$on('user:login', this.handleLogin)
},
beforeDestroy() {
bus.$off('user:login', this.handleLogin)
}
这种方式依赖 Vue 2 实例的 $on/$off/$once 事件接口。
7.2 Vue 3 的变化
Vue 3 移除了组件实例上的 $on、$off 和 $once,组件实例不再实现事件发射器接口;组件自身的 $emit 仍用于声明的组件事件,但不能拿 app 或任意组件实例当全局 EventBus。
Vue 3 组件之间优先使用:
props+emit:直接父子通信;provide/inject:跨多层依赖注入;- composable:复用有状态逻辑;
- Pinia:跨页面、跨模块的应用状态;
- 一个明确封装的 emitter:确实需要松耦合事件通知时使用。
如果使用 mitt 之类的轻量 emitter,也应在组件卸载时 off:
import mitt from 'mitt'
import { onMounted, onUnmounted } from 'vue'
const emitter = mitt()
function handleLogin(payload) {
console.log(payload)
}
onMounted(() => {
emitter.on('user:login', handleLogin)
})
onUnmounted(() => {
emitter.off('user:login', handleLogin)
})
八、EventTarget 和跨标签页通信
浏览器已有标准事件目标,可以在不写自定义数组的情况下完成事件监听:
const target = new EventTarget()
function handleMessage(event) {
console.log(event.detail)
}
target.addEventListener('message', handleMessage)
target.dispatchEvent(new CustomEvent('message', {
detail: { text: 'hello' },
}))
target.removeEventListener('message', handleMessage)
原文说两个标签页可以直接使用同一个内存 EventBus 通信,这不准确:不同标签页通常属于不同 JavaScript 全局上下文,不能共享同一个普通对象。跨标签页可以考虑:
BroadcastChannel;window.postMessage;storage事件;- Service Worker 或服务器推送。
例如使用 BroadcastChannel:
const channel = new BroadcastChannel('app-events')
channel.onmessage = event => {
console.log('收到其他标签页消息', event.data)
}
channel.postMessage({ type: 'user:login', id: 1 })
// 页面卸载时关闭
channel.close()
九、什么时候不应该使用 EventBus
EventBus 适合少量、低耦合、无需明确响应者的通知,例如全局 toast、简单埋点或跨模块刷新提示。但它也容易带来“隐式数据流”:
- 发布位置和订阅位置相距很远,难以追踪;
- 事件名和 payload 缺少类型约束;
- 忘记取消订阅导致重复回调和内存泄漏;
- 事件顺序和竞态问题难以测试;
- 把状态同步问题伪装成一次性事件,刷新后状态可能丢失。
如果多个组件需要读取和修改同一份持久状态,应优先使用 Pinia 等状态管理方案;如果只是父子组件通知,使用 props/emits 通常更清晰。
总结
- EventBus 是一个保存监听器并转发事件的发布/订阅对象;
- 最小实现需要
$on和$emit,工程实现还应支持参数和取消订阅; $once应在回调执行前移除,避免重入导致重复执行;- 未注册事件应安全返回,派发过程最好遍历监听器快照;
- 组件卸载时必须清理监听器,避免重复订阅和内存泄漏;
- Vue 2 曾可用空 Vue 实例作为总线,Vue 3 已移除
$on/$off/$once实例 API; - Vue 3 优先使用 props/emits、provide/inject、composable 和 Pinia;
- 普通内存 EventBus 不能直接跨浏览器标签页通信,需要 BroadcastChannel 或 postMessage;
- EventBus 适合通知,不应替代有明确状态模型的状态管理。
参考资料
- Vue 3:组件事件
- Vue 3 迁移指南:Events API 移除
- Vue 3:Provide / Inject
- Vue 3:状态管理
- MDN:EventTarget
- MDN:BroadcastChannel
原文作者:小猪课堂。原文关于 EventBus、$on、$emit、参数、$off 和 $once 的递进实现予以保留;回调变量拼写、空事件异常、id 删除逻辑、一次性监听器重入、Vue 3 API 变化和跨标签页通信表述已修正或补充。