既然 React/Vue 可以用 Event Bus 进行组件通信,你可以实现吗?
原文是一篇以面试追问为线索的 Event Bus 实现文章。本文尽量保留“组件通信 → 发布订阅 → 仿照 Node.js EventEmitter 实现”的结构,并修正 Vue/React 现状、监听器覆盖、
Map.delete误用、移除索引错误和资源清理问题。
一、组件之间如何通信
Vue
| 场景 | 常用方式 | 说明 |
|---|---|---|
| 父传子 | props | 子组件通过声明接收只读输入 |
| 子传父 | emits / $emit | 子组件发出事件,父组件监听 |
| 跨层级依赖 | provide / inject | 适合主题、表单上下文、服务对象等 |
| 多个模块共享状态 | Pinia / composable | 状态、派生状态和更新动作更明确 |
| 一次性解耦通知 | Event Bus | 需要自己管理订阅和生命周期 |
Vue 2 中曾经有人创建一个 Vue 实例作为总线,然后调用 $on、$emit、$off。这不是推荐的长期架构;Vue 3 已移除组件实例的 $on、$off、$once,也不能继续把 new Vue() 当作 Vue 3 的 Event Bus。第三方库如 mitt 或下面的自定义 emitter 可以提供类似能力。
$dispatch 和 $broadcast 更是 Vue 1 时代的 API,在 Vue 2 就已经移除。大型项目不应通过隐式全局事件替代清晰的数据流。
React
React 没有内置的 Event Bus,也不存在一个官方的“Event 模块”用于组件通信。常见方式是:
- 父组件通过 props 传递值和回调;
- 子组件调用回调通知父组件;
- Context 传递跨层级依赖;
- 外部 store 或订阅源管理共享状态。
Event Bus 可以作为独立的发布/订阅工具使用,但不能把它说成 React 的内置通信机制。
二、发布订阅模式
Event Bus 的基本数据结构是:
事件名 → 监听器列表
发布者只负责:
bus.emit('user:updated', user)
订阅者负责:
bus.on('user:updated', user => {})
发布者和订阅者互相不知道对方的对象引用,这就是解耦的地方,但也会带来事件名难追踪、类型不明确和生命周期容易遗漏等成本。
Node.js 的 EventEmitter 支持一个事件绑定多个监听器,监听器按注册顺序同步调用;on 是 addListener 的别名,off 是 removeListener 的别名,once 用于只执行一次。它还有 'error' 特殊事件、监听器数量警告和更多生命周期 API。下面实现的是浏览器/面试教学版,不宣称与 Node 完全兼容。
三、原文代码中的问题
原文的核心思路是对的,但抓取后的代码和简化实现有几个实际错误:
EventEmeitter是拼写错误,标准名称通常为EventEmitter;_events的 value 先保存一个函数,后续又改成数组,导致 API 和边界分支复杂;addListener只处理第一次注册,原实现的第二个监听器不会被保存;- 没有监听器时直接
handler.apply会抛出undefined is not a function; Map.delete(type, fn)是错误用法,Map.delete只接收 key,不能按 value 删除;- 遍历寻找位置时每一轮都覆盖
position,最后一个不匹配项可能把之前找到的位置改回-1; - 没有
once、removeAllListeners、参数校验和卸载清理; - 组件卸载后若仍被总线持有,会产生重复回调或闭包无法回收。
因此,下面统一让每个事件对应一个监听器数组,代码更容易理解。
四、实现一个简易 EventEmitter
export class EventEmitter {
constructor({ maxListeners = 10 } = {}) {
this._events = new Map()
this._maxListeners = maxListeners
}
_assertListener(listener) {
if (typeof listener !== 'function') {
throw new TypeError('listener 必须是函数')
}
}
on(type, listener) {
this._assertListener(listener)
const listeners = this._events.get(type) || []
listeners.push(listener)
this._events.set(type, listeners)
if (
this._maxListeners > 0
&& listeners.length > this._maxListeners
) {
console.warn(
`${String(type)} 的监听器超过 ${this._maxListeners} 个,` +
'请检查是否重复订阅或忘记取消订阅。',
)
}
return this
}
addListener(type, listener) {
return this.on(type, listener)
}
off(type, listener) {
this._assertListener(listener)
const listeners = this._events.get(type)
if (!listeners) return this
// 从后往前删除一个,行为接近 Node EventEmitter。
// once 包装器会把原始函数挂在 listener 属性上。
for (let i = listeners.length - 1; i >= 0; i--) {
if (
listeners[i] === listener
|| listeners[i].listener === listener
) {
listeners.splice(i, 1)
break
}
}
if (listeners.length === 0) {
this._events.delete(type)
}
return this
}
removeListener(type, listener) {
return this.off(type, listener)
}
once(type, listener) {
this._assertListener(listener)
const wrapper = (...args) => {
this.off(type, wrapper)
return listener.apply(this, args)
}
wrapper.listener = listener
return this.on(type, wrapper)
}
emit(type, ...args) {
const listeners = this._events.get(type)
if (!listeners || listeners.length === 0) {
// Node EventEmitter 对没有监听器的 error 事件会抛错。
// 浏览器业务总线也可以改成统一上报或仅返回 false。
if (type === 'error') {
throw args[0] instanceof Error
? args[0]
: new Error(String(args[0] || 'Unhandled error event'))
}
return false
}
// 快照保证本轮 emit 中的监听器集合稳定。
for (const listener of listeners.slice()) {
listener.apply(this, args)
}
return true
}
removeAllListeners(type) {
if (arguments.length === 0) {
this._events.clear()
} else {
this._events.delete(type)
}
return this
}
listeners(type) {
const listeners = this._events.get(type) || []
return listeners.map(listener => listener.listener || listener)
}
listenerCount(type) {
return (this._events.get(type) || []).length
}
eventNames() {
return [...this._events.keys()]
}
setMaxListeners(value) {
if (!Number.isInteger(value) || value < 0) {
throw new RangeError('maxListeners 必须是非负整数')
}
this._maxListeners = value
return this
}
}
1. 基本监听和触发
const emitter = new EventEmitter()
function handleArson(man) {
console.log(`expel ${man}`)
}
emitter.addListener('arson', handleArson)
emitter.on('arson', man => {
console.log(`save ${man}`)
})
emitter.on('arson', man => {
console.log(`kill ${man}`)
})
emitter.emit('arson', 'low-end')
// expel low-end
// save low-end
// kill low-end
原文第一次实现把一个事件只映射到一个函数,所以第二次监听会被忽略;数组实现后才可以按注册顺序触发全部监听器。
2. 移除监听
emitter.off('arson', handleArson)
emitter.removeListener('arson', handleArson)
off 需要拿到原来的函数引用:
const handler = value => console.log(value)
emitter.on('message', handler)
emitter.off('message', handler) // ✅
emitter.on('message', () => console.log('anonymous'))
emitter.off('message', () => console.log('anonymous')) // ❌ 不是同一个函数
如果注册了同一个函数两次,调用一次 off 只删除一个实例;要全部移除可以调用 removeAllListeners(type)。
3. 一次性监听
let count = 0
function onReady() {
count++
}
emitter.once('ready', onReady)
emitter.emit('ready')
emitter.emit('ready')
console.log(count) // 1
once 通过包装器在第一次调用前执行 off。保存 wrapper.listener = listener 后,也可以使用原始函数引用取消尚未触发的 once 监听:
emitter.once('ready', onReady)
emitter.off('ready', onReady)
4. 事件不存在和错误
emitter.emit('not-found') // false,不抛错
try {
emitter.emit('error', new Error('request failed'))
} catch (error) {
console.error(error.message)
}
是否对 error 采用 Node 语义,应由项目约定决定。不要让一个没有监听器的业务事件因为拼写错误直接导致页面崩溃,也不要静默吞掉真正需要处理的错误。
五、Vue 组件中的使用方式
// bus.ts
import { EventEmitter } from './EventEmitter'
export const bus = new EventEmitter()
发送方:
<script setup lang="ts">
import { bus } from './bus'
function sendMessage() {
bus.emit('message', { text: '来自组件 A' })
}
</script>
<template>
<button @click="sendMessage">发送</button>
</template>
订阅方必须和组件生命周期绑定:
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { bus } from './bus'
function handleMessage(payload: { text: string }) {
console.log(payload.text)
}
onMounted(() => {
bus.on('message', handleMessage)
})
onUnmounted(() => {
bus.off('message', handleMessage)
})
</script>
不要在 render、computed 或每次响应式更新中重复调用 on。如果事件表达的是可持续状态,而不是一次性通知,应优先使用 props、provide/inject、composable 或 Pinia。
六、React 中的使用方式
React 没有 useEventBus 内置 Hook。可以把 emitter 作为外部对象,但必须在 useEffect 的清理函数中取消订阅:
import { useEffect } from 'react'
import { bus } from './bus'
function MessagePanel() {
useEffect(() => {
const handleMessage = payload => {
console.log(payload.text)
}
bus.on('message', handleMessage)
return () => bus.off('message', handleMessage)
}, [])
return <div>消息面板</div>
}
如果事件源代表需要驱动 React 渲染的外部状态,React 18+ 可以通过 useSyncExternalStore 接入订阅和快照;仅仅在 Event Bus 回调里修改普通变量并不会自动让 React 组件更新。
七、类型安全的事件表
TypeScript 项目可以给事件名和 payload 建立约束,减少字符串事件拼写错误:
type Events = {
'user:updated': { id: string; name: string }
'toast': { message: string; type: 'success' | 'error' }
}
class TypedEmitter<E extends object> {
private events = new Map<keyof E, Set<(...args: any[]) => void>>()
on<K extends keyof E>(type: K, listener: (payload: E[K]) => void) {
const listeners = this.events.get(type)
|| new Set<(...args: any[]) => void>()
listeners.add(listener)
this.events.set(type, listeners)
return () => this.off(type, listener)
}
off<K extends keyof E>(type: K, listener: (payload: E[K]) => void) {
this.events.get(type)?.delete(listener)
}
emit<K extends keyof E>(type: K, payload: E[K]) {
for (const listener of this.events.get(type) || []) {
listener(payload)
}
}
}
实际项目也可以采用成熟库提供的类型定义,不必为了少量事件重复造完整基础设施。
八、Event Bus 的边界
1. 它会隐藏数据流
bus.emit('refresh') 的接收方不在调用点可见,事件名和 payload 也可能缺少类型约束。组件关系清楚时,props/emits 更容易阅读、测试和重构。
2. 它可能造成内存泄漏
总线通常比组件活得久。Vue 的 onUnmounted、React 的 effect cleanup 和手动 off 都不是可选项。把 on 返回取消订阅函数也是一种更安全的 API 设计:
const stop = emitter.on('message', handler)
// 如果 on 返回 emitter,也可以封装 subscribe 返回 stop
stop()
本文为了贴近 Node API 让 on 返回 emitter;如果用于组件库,建议另提供 subscribe 返回 unsubscribe。
3. SSR 要隔离请求
在 Nuxt 4 或其他 SSR 应用中,不要用带用户数据的进程级可变单例总线跨请求共享状态。请求级数据应在请求上下文中创建;客户端总线则在客户端插件或应用实例内注册,并避免在服务端访问浏览器 API。
4. Event Bus 不是状态管理
Event Bus 适合“某件事发生了,请感兴趣的模块处理”的通知;它不天然提供当前值、派生值、持久化、时间旅行和可追踪状态。需要这些能力时,应使用 Pinia、Redux、Context 配合状态,或其他明确的 store。
总结
- Vue 3 已移除组件实例的
$on/$off/$once,React 也没有内置 Event Bus; - EventEmitter 的核心是事件名到监听器列表的映射;
on、emit、off、once和清理是最重要的生命周期;Map.delete只能按 key 删除,不能拿它直接删除某个回调;- 事件触发通常是同步的,监听器会按注册顺序执行;
- 父子通信优先 props/emits,复杂共享状态优先 store,Event Bus 只用于确有必要的解耦通知。
参考资料
原文作者:寻找海蓝 96。原文关于组件通信、发布订阅、Map 存储、多个监听器和移除监听的主线予以保留;拼写错误、损坏代码、单监听器覆盖、Map.delete 误用、错误索引逻辑、缺失清理和过时 Vue API 已修正。