☰
手写发布订阅模式与EventBus:从核心原理到Vue/React实战
2026/10/17 14:04:20 网站建设 项目流程

做前端这些年,被问过最多次的底层问题之一就是“手写发布订阅者模式”。发布订阅这件事,本质上就是你先把一个回调登记到某个频道上,等别人往频道里发消息时,回调再被调用。别看它逻辑简单,一旦涉及缓存结构、once 处理、错误隔离、内存回收,里面可以挖的东西非常多。这篇文章我会把一次完整的手写过程记录下来,从 API 设计、基础实现到类型约束、测试验收,再结合 Vue 和 React 项目里的真实接入方式,讲清楚手写一个可用 EventBus 的全过程,也把我踩过的坑一并写出来。

1. 先理清概念:发布订阅模式到底解决什么问题

1.1 观察者模式与发布订阅模式,别再把它们画等号

很多初学者会把观察者模式(Observer)和发布订阅模式(Pub-Sub)当成一回事,面试时被追问区别就容易露馅。观察者模式里,目标(Subject)直接维护一组观察者(Observers),状态变化时逐个调用观察者的更新方法,观察者和主题之间是明确认识彼此的。比如气象站主动调用显示屏的刷新接口,这就是观察者。

发布订阅模式多了一个中间层,一般叫事件通道或事件总线(EventBus)。发布者不管谁会接收消息,订阅者也不知道消息是谁发出来的,只负责在自己关心的频道上等待回调。两者唯一的交接点就是事件名和携带的参数列表。这个中间层带来的最大好处是彻底解耦,模块之间只通过约定好的事件契约交流,不持有对方的引用。

实际项目中我推荐按这个标准区分:如果两个对象有明确关联、需要直接通知,用观察者更轻;如果存在多个模块互不感知、但都要响应同一个状态变化,那就应该把它们搬到发布订阅模型上。EventBus 是发布订阅模式最常见的落地产物,我下面所有代码都在围绕这个做。

1.2 核心 API:on / off / emit / once 的职责边界

手写之前,先把 API 边界定下来,不然写着写着就会过度设计。一个最精简的事件总线,只需要四个方法加一个清理方法:

  • on(event, handler):订阅事件,handler 会在事件被发布时执行。同一个事件可以被订阅多次。
  • once(event, handler):只订阅一次,第一次触发后自动退订。
  • off(event, handler):取消订阅。如果只传 event,就把该事件下所有 handler 清掉;如果 handler 也传了,只移除这一个。
  • emit(event, ...args):发布事件,把 args 作为参数依次传给该事件下的所有 handler。
  • clear():清空所有事件和 handler,通常在模块销毁或测试重置时用。

我看到很多手写版本会把 API 叫成 $on、$emit,那是模仿 Vue 实例事件系统的叫法。自己封装时叫 on、emit 就行,语义更通用。核心难点集中在两个地方:一是用什么数据结构缓存“事件名到 handler 的映射”,二是 once 的包装函数如何进行精确退订。两个问题想清楚了,代码反而很简洁。

1.3 为什么值得自己手写,而不是直接引库

论成熟度,直接用现成的 mitt 或 tiny-emitter 一点毛病没有,源码也就几十行到两百行,稳定性非常好。我依然建议自己动手写一遍,理由有三。

第一,面试和源码阅读需要。Vue 3 的组件事件系统、很多状态管理插件的通知机制,底层都能看到发布订阅的影子,手写过一遍再读这些源码,会顺畅很多。第二,业务定制需要。手写可以对泛型做很严格的控制,可以加全局日志,也可以按自己的习惯扩展命名空间和批量退订,这些在通用库里反而要绕一下。第三,体积控制。一个不依赖任何第三方包的手写 EventBus,压缩后可能不到 1K,在体积敏感的页面场景里很有价值。

当然,不是让你生产环境必须手写。我的态度是:先手写一遍把原理吃透,然后在该用库的地方用库。两者不冲突。

2. 从零手写:核心 API 与第一版 EventBus 实现

2.1 第一版实现:核心代码与结构选型

这是我最常用的一版实现,直接用 Map 加 Set 做缓存。选择 Map 而不是普通对象,是因为事件名可能是动态字符串,Map 不会有原型链污染问题,删除某个 key 也更方便。选择 Set 而不是数组,是因为 Set 做精准删除时是 O(1) 复杂度,而且天然去重,同一个 handler 重复订阅也不会出现两份。

class EventBus { constructor() { this._events = new Map(); } on(type, handler) { if (!this._events.has(type)) { this._events.set(type, new Set()); } this._events.get(type).add(handler); return () => { this.off(type, handler); }; } once(type, handler) { const wrapper = (...args) => { this.off(type, wrapper); handler.apply(this, args); }; wrapper._origin = handler; this.on(type, wrapper); return () => { this.off(type, wrapper); }; } off(type, handler) { const handlers = this._events.get(type); if (!handlers) return false; if (!handler) { this._events.delete(type); return true; } for (const item of handlers) { if (item === handler || item._origin === handler) { handlers.delete(item); } } if (handlers.size === 0) { this._events.delete(type); } return true; } emit(type, ...args) { const handlers = this._events.get(type); if (!handlers || handlers.size === 0) return; [...handlers].forEach((handler) => { try { handler.apply(this, args); } catch (error) { console.error(`[EventBus] event "${type}" handler error:`, error); } }); } clear() { this._events.clear(); } }

这段代码的关键点有三个。第一,on 方法我把 unsubscribe 函数作为返回值,这样调用方可以一行代码完成订阅和退订函数的保存,后面 React 封装里会很好用。第二,emit 里先用[...handlers]做了一次快照,这是因为 handler 在执行过程中可能调用 off 删除自己,不复制的话会导致遍历结构变化,甚至漏掉后续 handler。第三,on 和 off 的匹配不是只有“严格相等”这一条路,off 时还要找一次item._origin === handler,这是为了支持 once 场景。

2.2 emit 用同步还是异步?这里需要做一次取舍

很多人在手写时都会纠结 emit 内部到底该不该用 Promise 把 handler 包成异步。我给的建议是:默认保持同步。理由很实际,同步 emit 的结果可预测,handler 依次执行完,发布者的调用栈才能结束,调试时通过 DevTools 的调用栈能直接看到是哪个位置触发了事件。

框架里的事件系统也大多是同步的。Vue 的组件 emit 默认就是同步把 listeners 执行完,React 的合成事件也是同步派发。只有当你明确遇到“事件触发时必须等下一帧”这个需求,才应该在发布方用 queueMicrotask 或 requestAnimationFrame 做延迟,而不是把异步逻辑内置进总线。如果整个总线改成异步,反而会引入新的问题:异步 handler 里的异常更难捕获,多个事件之间的相对顺序也更难保证。

异步场景我会在事件对象里额外传一个 nextTick 标识,比如emitAsync方法,需要异步派发时单独调用,默认 emit 保持同步。这样的 API 对一个业务系统来说足够清晰,也不会让主线逻辑变得难以推测。

async emitAsync(type, ...args) { await Promise.resolve(); this.emit(type, ...args); }

2.3 once 和 off 的匹配细节,踩过的坑都在这里

once 最常见的坑是:调用once('event', fn)之后,再用off('event', fn)并不能取消订阅。原因很简单,真正被缓存进 Set 的不是 fn,而是我从 fn 包出来的 wrapper,只按 fn 去 off 当然匹配不到。

所以在 wrapper 上挂了一个_origin属性,记录原始 handler。off 遍历时只要发现item === handler || item._origin === handler,就把这个 item 删掉。这样不管传原始函数还是传 wrapper,都能退订成功。

还有一点值得注意,on 返回的退订函数在 once 场景里返回的是退订 wrapper 的函数,所以不要混着用。比如第一次订阅返回退订函数,后来又在闭包里手动调off('event', originalFn),两边配合不好就会留下一个 ghost handler。我后来的习惯是:普通订阅和一次性订阅统一使用 on/once 返回的退订函数,直接 return 出来的函数去清理,不额外记住原始函数。

3. 进阶加固:类型约束、错误隔离和异步边界

3.1 用 TypeScript 泛型约束事件名和参数类型

第一版是纯 JavaScript,跑起来没问题,但在一个中大型项目里,事件名写错或者参数传错这种低级问题,只在运行时才暴露出来。更稳妥的做法是引入 TypeScript,用泛型把事件名和参数列表绑定在一起。

假设有一个用户业务的事件表:

interface UserEvents { 'user:login': [user: { id: string; name: string }]; 'user:logout': []; 'cart:change': [count: number]; }

对应的 EventBus 可以这样写:

type Handler<T extends unknown[]> = (...args: T) => void; class EventBus<Events extends Record<string, unknown[]>> { private listeners = new Map<keyof Events, Set<Handler<Events[keyof Events]>>>(); on<K extends keyof Events>(event: K, handler: Handler<Events[K]>): () => void { // ... } emit<K extends keyof Events>(event: K, ...args: Events[K]): void { // ... } off<K extends keyof Events>(event: K, handler: Handler<Events[K]>): void { // ... } }

使用时的体验就是:事件名只能填'user:login'、'user:logout'、'cart:change'之一,参数列表也自动跟着事件名走。比如bus.on('user:login', (user) => {}),user 会被推导成{ id: string; name: string }类型;写bus.emit('user:login', 'xxx'),编辑器会直接标红,因为参数类型对不上。

大多数开源库不会提供这么严格的类型方案,但手写版完全可以做成强约束,这是自研一个总线的最大优势之一。

3.2 错误隔离:单条回调崩了不能拖垮整条链路

第一版代码里已经加了 try/catch,这是错误隔离的核心。如果不加,某个 handler 里一旦抛出异常,后续相同事件的其它 handler 都会被跳过,严重的还会把异常抛到发布者的调用栈里,导致整个业务流程中断,显然很难接受。

错误隔离之后,单个回调的问题只影响自己,不会影响事件通道本身。但这里要注意一个细节:try/catch 无法捕获 async handler 内部的 Promise rejection。

bus.on('http-error', async () => { throw new Error('request failed'); }); bus.emit('http-error'); // try/catch 捕获不到

出现这种情况时,会在控制台看到 unhandled rejection。业界没有特别通用的解法,我的习惯是:让回调尽量保持同步职责,异步逻辑单独交给专门的异步任务处理,不要在事件回调里直接做重活。如果确实需要,我会要求调用方自己 catch Promise,或者总线里对返回值做统一处理:

const result = handler.apply(this, args); if (result instanceof Promise) { result.catch((error) => { console.error(`[EventBus] async handler error: ${type}`, error); }); }

3.3 嵌套事件与重复订阅:先快照再遍历

事件回调里又去 emit 同一个事件,这种嵌套现象我见过不少次,最严重的结果是递归调用栈溢出。触发循环时错误信息表现为RangeError: Maximum call stack size exceeded,从调用栈里能看到同一个 emit 反复出现。

我常用两个手段兜底。一是前面提到的快照遍历,让 handler 在递归 emit 时不会因为 Set 结构变化而被跳过;二是加一个非常简单的递归深度保护,防止极端情况下的死循环。

emit(type, ...args) { const handlers = this._events.get(type); if (!handlers || handlers.size === 0) return; this._emitDepth = (this._emitDepth || 0) + 1; if (this._emitDepth > maxEmitDepth) { this._emitDepth--; throw new Error(`[EventBus] event "${type}" emits too deep, possible infinite loop`); } try { [...handlers].forEach((handler) => handler.apply(this, args)); } finally { this._emitDepth--; } }

如果项目里没有这种极端需求,可以不加,但对通用型总线来说,这种保护能省下不少排查时间。

4. 实战接入:用 EventBus 解决跨模块与跨组件通信

4.1 Vue 场景:兄弟组件和跨页面模块的消息桥接

Vue 项目中最典型的用法是提供一个独立的 EventBus 实例给业务模块使用。比如用户登录状态变化,很多组件都要响应,但又不想让他们之间形成依赖关系,这时定义一份事件契约最省心。

// bus/user.ts const userBus = new EventBus<{ 'login': [user: { id: string; name: string }]; 'logout': []; }>(); export { userBus };

在 sidebar 组件里监听登录事件:

<script setup lang="ts"> import { onMounted, onUnmounted } from 'vue'; import { userBus } from '@/bus/user'; function handleLogin(user: { id: string; name: string }) { // 更新当前用户名、菜单权限等等 } const unsubscribe = () => { userBus.off('login', handleLogin); }; onMounted(() => { userBus.on('login', handleLogin); }); onUnmounted(unsubscribe); </script>

这里我刻意用了onMounted去订阅、onUnmounted去退订,而不是在 setup 顶层直接订阅。原因很简单,Vue 的 setup 环境并不能保证组件一定被挂载,如果订阅发生在创建阶段,组件没挂载完就被销毁,可能出现 handler 还在来回跳动的情况。组件生命周期挂钩是最稳妥的。

4.2 React 场景:用 Hook 包装订阅和解绑逻辑

React 函数组件里,如果直接裸写订阅,很容易遗漏清理逻辑,于是我会把订阅行为封装成一个 Hook,把 unsubscribe 和 useEffect 绑定在一起。

import { useEffect, useRef } from 'react'; function useEventBus<K extends keyof UserEvents>( event: K, handler: (...args: UserEvents[K]) => void ) { const handlerRef = useRef(handler); handlerRef.current = handler; useEffect(() => { const callback = (...args: UserEvents[K]) => { handlerRef.current(...args); }; bus.on(event, callback); return () => { bus.off(event, callback); }; }, [event]); }

这里用handlerRef.current = handler解决了一个很微妙的闭包问题:如果把 handler 直接放进依赖数组,组件每次渲染时 handler 签名都会变化,如果没有 ref 中转,会发现每渲染一次就重新订阅一次,不仅浪费还容易丢事件。通过 ref 保存最新 handler,useEffect 只在 event 变化时重新订阅,这是比较顺手的一种做法。

严格模式下的 effect 会执行两次挂载,我的做法是用 cleanup 把第二次挂载造成的重复订阅清理掉,不至于造成事件处理函数翻倍执行。

4.3 业务模块设计:登录态、购物车这类高频事件怎么挂

业务系统里最常见的是跨模块状态变更通知。以一个电商后台为例子,购物车数量变化、订单状态更新、图片上传进度这些事件,可能被导航栏、列表页、全局提示组件同时关心。

我习惯把每个大模块单独建一个 bus 文件,而不是整个系统只有一个全局 bus。比如 cartBus 管购物车,orderBus 管订单,userBus 管用户。全局大杂烩 bus 的问题在于事件名容易冲突,设一个login事件,另一个项目模块也设一个login,名字虽然一样,语义完全不同,排查时极其痛苦。

命名上也可以做得更规范,我推荐事件名采用“域:动词”的形式,比如cart:add、cart:clear、order:paid,一来语义清晰,二来未来要做日志和埋点也方便。只要命名规范,跨模块的消息流其实很好看清楚。

cartBus.on('cart:add', () => { updateNavCartCount(); }); cartBus.on('cart:clear', () => { updateNavCartCount(); });

5. 避坑实录:内存泄漏、this 绑定和重复订阅排查

5.1 订阅函数忘记 off:内存里的“隐形引用”

这是 EventBus 最隐蔽的问题之一。假如组件里订阅了一个 handler,组件销毁时却没有 off,那么这个 handler 会被全局 bus 引用着,组件相关的整个作用域包括 DOM 引用、组件实例、闭包数据等都无法被垃圾回收,页面上出现“关掉页面也不掉内存”的怪相。

我自己排查时最常用的方式是用浏览器 DevTools 的 Memory 面板,先做一次堆快照,然后反复创建和销毁组件,再对比快照。如果发现某个字符串类型的事件名对应 handler 数量只增不减,基本就是订阅没清。

手写总线的优势在这里又体现出来了,我可以加一个辅助方法,在事件被 emit 时输出当前 handler 数量,甚至可以统计一个事件上的重复订阅:

listenerCount(type) { return this._events.get(type)?.size ?? 0; }

开发环境里清不清空一眼就能看明白。

5.2 this 指向和闭包过期

opened 的问题:因为 emit 里写的是handler.apply(this, args),普通函数作为 handler 时,函数体内的 this 会指向 EventBus 实例。如果你的 handler 用了 function 关键字,并且在内部用了 this,行为会跟你预期的组件实例完全不一样。

bus.on('test', function () { console.log(this); // 指向 EventBus 实例 console.log(globalThis); // 这才是原本的全局对象,如果没污染的话 });

解决方式很简单,统一用箭头函数订阅,或者在内部完全不依赖 this。事件 handler 应该是纯函数闭包,逻辑上不该依赖动态绑定。

闭包过期场景在 React 更明显,如果 handler 里读的是旧 props、旧 state,就会出现事件触发了但看到的还是上次渲染的数据。这个问题的解法就是我前面写的useEventBus加handlerRef.current = handler,在 useEffect 外部更新 ref,保证每次触发拿到的都是最新闭包。

5.3 组件卸载时如何做批量退订

如果一个组件里订阅了七八个事件,每个都写一次 off 实在繁琐,也容易漏。我后来实现了一个带 scope 的批量退订扩展:订阅时给事件打一个分组标签,组件卸载时统一解除整组订阅。

class EventBus { subscribeWithScope(scope, type, handler) { this.on(type, handler); this._scopeMap = this._scopeMap || new Map(); if (!this._scopeMap.has(scope)) this._scopeMap.set(scope, new Set()); this._scopeMap.get(scope).add({ type, handler }); return () => this.unsubscribeScope(scope); } unsubscribeScope(scope) { const list = this._scopeMap.get(scope) || []; for (const item of list) { this.off(item.type, item.handler); } this._scopeMap.delete(scope); } }

在 Vue 组件里就是onUnmounted(() => bus.unsubscribeScope('user-profile')),再也不用一个个 off 了。实现了这个功能之后,我再也没有在组件销毁时因为漏写 off 而查过内存问题了。

6. 测试验收:用 Vitest 把手写的总线跑一遍

6.1 常见故障速查表:从现象直接定位根因

这是我整理的一份实际排查经验,遇到类似现象可以照方抓药:

现象可能原因排查方向
emit 后 handler 没执行事件名不一致或拼写错误在 emit 前打印 event,和 on 注册的字符串严格比对
handler 执行了多次组件重复挂载,或者同一 handler 订阅了多次没有去重用listenerCount查看数量,再检查 on 逻辑是否被反复调用
off 后 handler 还在执行once 包装后按原始函数 off 没匹配上检查是否用了_origin属性做兜底匹配
一次 handler 抛错影响后续缺少 try/catch 隔离给每条 handler 包一层 try/catch
页面内存只增不减组件销毁前没退订用 Memory 快照对比,检查 scope 退订逻辑
事件触发顺序不对多个 handler 之间依赖了订阅顺序,但顺序不是硬保证事件总线不保证执行顺序,业务侧应该自己加状态判断

6.2 Vitest 用例设计:把核心链路和边界条件都覆盖到

手写完成后一定要补测试,不然改一版就坏一版。我一般会用 Vitest 覆盖下面几个用例:

import { describe, it, expect, vi, beforeEach } from 'vitest'; describe('EventBus', () => { let bus; beforeEach(() => { bus = new EventBus(); }); it('on 和 emit 应该正确传参', () => { const handler = vi.fn(); bus.on('user:login', handler); bus.emit('user:login', { id: '1' }, 'token-123'); expect(handler).toHaveBeenCalledWith({ id: '1' }, 'token-123'); expect(handler).toHaveBeenCalledTimes(1); }); it('once 只能触发一次', () => { const handler = vi.fn(); bus.once('cart:change', handler); bus.emit('cart:change', 2); bus.emit('cart:change', 3); expect(handler).toHaveBeenCalledTimes(1); }); it('off 只移除指定的 handler', () => { const handlerA = vi.fn(); const handlerB = vi.fn(); bus.on('test', handlerA); bus.on('test', handlerB); bus.off('test', handlerA); bus.emit('test', 1); expect(handlerA).not.toHaveBeenCalled(); expect(handlerB).toHaveBeenCalledTimes(1); }); it('off 不传 handler 时清空该事件所有订阅', () => { const handlerA = vi.fn(); const handlerB = vi.fn(); bus.on('test', handlerA); bus.on('test', handlerB); bus.off('test'); bus.emit('test', 1); expect(handlerA).not.toHaveBeenCalled(); expect(handlerB).not.toHaveBeenCalled(); }); it('单个 handler 抛错不影响其他 handler', () => { const errorHandler = vi.fn(() => { throw new Error('boom'); }); const normalHandler = vi.fn(); vi.spyOn(console, 'error').mockImplementation(() => {}); bus.on('test', errorHandler); bus.on('test', normalHandler); bus.emit('test', 1); expect(normalHandler).toHaveBeenCalled(); }); });

测试里最容易被忽略的是 off 与 once 的组合逻辑、以及 handler 抛出异常后的隔离性。这两块案例一旦补上,后面做任何重构都会安心很多。

6.3 性能考量与“别滥用”的建议

从复杂度上来讲,事件总线的核心操作都不慢:Map 查找是 O(1),Set 遍历一次事件的 handler 是 O(n),n 是订阅数量。平时业务系统里单个事件几十个 handler 已经不少了,性能不会有问题。真正需要注意的是高频事件,比如窗口 resize、滚动位置变化、鼠标移动,每帧触发几十上百次,如果每个 handler 里都做复杂计算,必然造成线程卡顿。

这类高频事件我建议不要裸发到总线里,而是在发布方做节流。比如内部用 requestAnimationFrame 合并帧,或者兜底一层 throttle,保证 EventBus 收到的有效通知频率可控。另外也要给自己立一个规矩:一个事件上的 handler 数量如果超过两位数,说明关注这个事件的模块有点多,应该回头看看是不是事件粒度太粗了,或者可以考虑用状态管理把共享数据集中起来,而不是等所有人都来订阅。

我在实际带新人时也一直强调,发布订阅模式是个好模式,但不能把它当成万能胶。两个模块之间有父子关系或明确共享状态时,直接传 props、用 context、用 store 可能更直观。一旦出现“A 通知 B,B 再通知 C,C 又通知 D”的链路,消息流会变得极难追踪,所以我会把 EventBus 定位成“团队定好的约定大于配置的解耦通道”,而不是随便哪里都能用的基础设施。

手写一遍 EventBus 给我最大的收益不是代码量,而是让我彻底明白了为什么事件系统要这么设计、哪些问题要在入口处封死、哪些场景不能交给事件总线。如果你还没自己写过,建议直接把文中的第一版代码抄下来跑一遍,再补上泛型和测试,这个模式就真正变成你自己的了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询