Vue/React 中的 Event Bus 如何实现?
原文从组件通信面试题延伸到发布订阅模式,并尝试仿照 Node.js
EventEmitter实现事件库。本文保留这条主线,修正 Vue/React 通信方式、事件总线的适用范围、监听器移除和一次性监听等问题。
一、先回答组件通信问题
Vue
- 父传子:props;
- 子传父:emits /
$emit; - 跨层级:provide/inject;
- 兄弟或跨模块:共同父状态、composable、Pinia 或明确的事件通道;
- 复杂业务状态:Pinia,历史 Vue 2 项目也可能使用 Vuex。
Vue 2 曾经常用一个 Vue 实例作为 Event Bus,并调用 $on/$emit/$off。Vue 3 已移除组件实例的 $on、$off、$once,不能继续 new Vue() 作为总线。mitt 等第三方库可以实现类似能力,但它们不是 Vue 内置 API。
React
React 没有内置的 Event Bus。常见方式是:
- 父组件通过 props 传递数据和回调;
- 子组件调用回调通知父组件;
- Context 传递跨层级依赖;
- 外部 store、订阅库或自定义事件通道处理跨模块状态。
Event Bus 可以用在确实需要“事件通知、发送方不关心接收方”的场景,但它不是所有组件通信的默认方案。状态需要被读取、派生、持久化或调试时,明确的 store 往往更合适。
二、Event Bus 的本质
Event Bus 是发布/订阅模式的一种实现:
publisher -- publish(type, payload) --> bus
subscriber <-- subscribe(type, handler) -- bus
一个可靠的事件库至少需要处理:
- 一个事件对应多个监听器;
- 监听器按注册顺序调用;
off能准确删除指定函数;once只执行一次;- 组件卸载时取消订阅;
- 监听器异常如何处理;
- 重复订阅、监听器上限和内存泄漏;
- SSR 中是否会把不同请求的状态混在一起。
事件监听器通常同步调用。需要异步执行时,应由 listener 自己使用 queueMicrotask、setTimeout 或业务调度器,而不是让 bus 隐式改变所有事件的时序。
三、从零实现一个简易 EventEmitter
下面的实现使用 Map<PropertyKey, Function[]>。使用数组而不是单个函数,可以支持同一事件的多个监听器;使用 PropertyKey 可以支持字符串和 Symbol 事件名。
export class EventEmitter {
constructor(options = {}) {
this._events = new Map()
this._maxListeners = options.maxListeners ?? 10
}
_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
// 允许用原始函数移除 once 包装器
const index = [...listeners].reverse().findIndex(item => {
return item === listener || item.listener === listener
})
if (index >= 0) {
listeners.splice(listeners.length - 1 - index, 1)
}
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 事件的特殊约定;浏览器业务 bus
// 也可以选择改成 console.error 或返回 false。
if (type === 'error') {
throw args[0] instanceof Error
? args[0]
: new Error(String(args[0] || 'Unhandled error event'))
}
return false
}
// 使用副本,避免某个 listener 在 emit 期间 off 影响本轮遍历。
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 handleMessage(message) {
console.log('收到消息:', message)
}
emitter.on('message', handleMessage)
emitter.on('message', message => {
console.log('第二个监听器:', message)
})
emitter.emit('message', 'hello')
// 收到消息: hello
// 第二个监听器: hello
emitter.off('message', handleMessage)
emitter.emit('message', 'world')
// 只触发第二个监听器
原文的第一个版本只保存一个函数,因此第二次 addListener 会覆盖或无法触发;上面的数组实现解决了多个监听器问题。
2. once
let count = 0
emitter.once('ready', () => {
count++
})
emitter.emit('ready')
emitter.emit('ready')
console.log(count) // 1
once 必须保存包装函数与原函数的关系,否则调用 off('ready', originalHandler) 时无法移除包装器。示例中的 wrapper.listener 就是为此服务的。
3. 为什么不能用匿名函数直接移除
emitter.on('message', () => {})
// ❌ 这是另一个函数引用,无法匹配第一次注册的函数
emitter.off('message', () => {})
正确做法是保存函数引用:
const handler = message => console.log(message)
emitter.on('message', handler)
emitter.off('message', handler)
原文 Map.delete(type, fn) 也是错误的:Map.delete 只接收 key,不会根据 value 删除监听器;同时原文遍历数组时不断覆盖 position,可能删错元素。应保存精确函数引用并按引用删除。
四、在 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', '来自组件 A')
}
</script>
<template>
<button @click="sendMessage">发送</button>
</template>
订阅方必须在卸载时取消:
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { bus } from './bus'
function handleMessage(message: string) {
console.log(message)
}
onMounted(() => {
bus.on('message', handleMessage)
})
onUnmounted(() => {
bus.off('message', handleMessage)
})
</script>
如果在 setup() 顶层直接注册,也可以利用 Vue 的 effect scope 生命周期,但显式写 onUnmounted 更容易让资源边界清晰。不要在每次 render 中重复 on,否则监听器会不断增长。
更推荐的 Vue 组件通信:
<!-- 父子组件:props + emits -->
<Child :value="value" @update:value="value = $event" />
跨层级的主题、表单上下文等使用 provide/inject;需要持久共享状态、派生状态和调试时使用 Pinia。Event Bus 更适合一次性的解耦事件,例如“通知某个区域刷新”或“插件发出生命周期事件”。
五、在 React 中使用
React 组件本身没有 $emit 或内置 Event Bus。可以把上面的 emitter 作为外部对象使用,但要注意订阅生命周期:
import { useEffect } from 'react'
import { bus } from './bus'
function MessagePanel() {
useEffect(() => {
const handleMessage = message => {
console.log(message)
}
bus.on('message', handleMessage)
return () => bus.off('message', handleMessage)
}, [])
return <div>消息面板</div>
}
如果外部事件源需要驱动 React 渲染,React 18+ 更适合使用 useSyncExternalStore 把订阅和快照接入 React 的更新模型:
import { useSyncExternalStore } from 'react'
function useMessageStore(store) {
return useSyncExternalStore(
listener => store.subscribe(listener),
() => store.getSnapshot(),
)
}
普通一次性通知可以使用 Event Bus;需要让组件根据可观察状态稳定渲染,则应该实现 subscribe/getSnapshot 或使用成熟状态库,而不是在事件回调里到处手动改 DOM。
六、Event Bus 的问题和边界
1. 隐式依赖
事件名是字符串时,发布者和订阅者之间没有类型约束,也无法从组件 props 直接看出数据流:
bus.emit('update-user', user)
bus.on('update-user', handler)
大型项目应集中定义事件类型,或使用 TypeScript 泛型约束事件表:
type Events = {
'user:updated': { id: string }
'toast': { message: string; type: 'success' | 'error' }
}
2. 内存泄漏
总线通常比组件生命周期长。如果组件卸载后没有 off,总线仍然持有组件闭包,可能导致内存无法回收或重复响应。Vue 的 onUnmounted、React 的 effect cleanup 都是必需的。
3. 时序和异常
本实现同步调用 listeners,某个 listener 抛错时,后续 listener 是否继续执行取决于调用者是否捕获异常。Node 的 EventEmitter 对没有监听器的 'error' 事件有特殊行为;浏览器业务事件通常应使用统一错误处理策略,不能无条件照搬 Node 语义。
4. SSR 和请求隔离
Nuxt 4 或其他 SSR 应用中,不要把带有用户数据的可变 Event Bus 作为服务端进程级单例,否则不同请求可能互相收到事件。请求相关状态应按请求创建,跨页面客户端事件则在客户端插件中注册,并确保服务端不访问 window。
5. maxListeners 不是安全限制
Node 的默认监听器数量警告主要用于发现可能的内存泄漏,不是硬性上限,也不是权限控制。示例中的 _maxListeners 只提供提醒,不应通过把它调成很大来掩盖重复订阅。
七、和 Node.js EventEmitter 的差异
Node.js 的 EventEmitter 还提供:
on/addListener、off/removeListener;once、prependListener;removeAllListeners、eventNames、listenerCount;newListener、removeListener生命周期事件;'error'特殊事件;captureRejections和监听器上限警告。
本文实现的是浏览器/面试教学版,不应该宣称完全兼容 Node。生产项目可以直接使用 node:events 或成熟的浏览器库,而不是重复维护一个不完整实现。
总结
- Vue 3 已移除组件实例 Event Bus API,React 也没有内置 Event Bus;
- Event Bus 的核心是事件名到监听器集合的映射,以及
emit/on/off/once生命周期; - 多监听器、精确移除、once 包装、同步时序和异常处理都不能遗漏;
- 组件卸载时必须取消订阅,SSR 中必须考虑请求隔离;
- 父子数据优先 props/emits,跨层级依赖使用 provide/inject,复杂共享状态使用 Pinia 或其他 store;
- Event Bus 适合解耦通知,不适合替代所有状态管理和组件数据流。
参考资料
原文作者:寻找海蓝 96。原文关于 Vue/React 组件通信、发布订阅模式、Map 存储、多个监听器和移除监听的主线予以保留;错误的 EventEmeitter 实现、单监听器覆盖、Map.delete 用法、错误的移除索引、重复推广链接和 Vue 3 API 混淆已清理或修正。