发布订阅模式与观察者模式的区别:从事件总线到生产实践

发布时间:2026/8/29 9:32:31
发布订阅模式与观察者模式的区别:从事件总线到生产实践 聊发布订阅模式之前先讲个我自己的经历。有次面试官上来就问“讲讲发布订阅模式”我当时还能背出“发布订阅是一种一对多的依赖关系当一个对象状态发生改变时所有依赖它的对象都会得到通知……”然后面试官追了一句“那它跟观察者模式有什么区别”我当场有点愣住因为在我的记忆里这两个概念经常被混着用好像都差不多但又说不上差在哪。后来真正在项目里写过事件总线、排过一个事件重复触发的线上bug我才把这两者的边界彻底搞清楚。这篇“不背八股”系列的文章我不想给你列一堆教科书式的定义而是想跟你聊聊发布订阅模式到底是解决什么问题的、它在代码里长什么样、面试里怎么聊才显得你真懂以及生产环境里最常踩的那些坑。适合正在准备面试的朋友也适合项目里用过事件但没系统梳理过的开发者同样适合刚接触设计模式的初学者——不靠背靠理解和动手。1. 先搞懂它解决什么问题从奶茶取号到跨模块通知1.1 没有发布订阅的时候代码是怎么写的我们先不看模式定义先还原一个特别真实的场景。假设你现在写一个登录模块登录成功之后需要做一堆事情更新右上角的用户头像、刷新购物车角标、跳转到首页、给埋点系统发送用户信息。很多没经验的同学会直接在登录成功的回调里这么写async function login(username, password) { const user await request(/api/login, { username, password }); // 登录成功处理一堆后续逻辑 updateHeader(user); refreshCart(user.id); router.push(/home); trackEvent(login_success, user); }这段代码在初期没问题但随着业务增长会越来越难受。比如产品说“登录后还要弹一个新人优惠券弹窗”你就得回到这个函数里再加一行再比如有个模块是团队A负责的团队B改了登录逻辑直接动这个函数就可能引发冲突。登录模块渐渐变成了一个谁都要改的公共函数它认识太多不该认识的人。这种写法的问题本质上是调用方和依赖方被硬编码在了一起。登录模块要清楚地知道“谁关心登录成功”这件事并且要亲自挨个通知它们。1.2 发布订阅带来的改变登录模块只负责发消息现在把同样的问题用发布订阅重写。登录模块不再关心有哪些模块关心登录成功它只负责一件事——登录成功之后往“事件通道”里发一条消息// auth.js import eventBus from ./eventBus; async function login(username, password) { const user await request(/api/login, { username, password }); // 只发消息不关心谁在听 eventBus.emit(user:login, user); }其他模块各自去订阅这个消息自己愿意处理就处理不愿意就拉倒// header.js import eventBus from ./eventBus; eventBus.on(user:login, (user) { updateHeader(user); });// cart.js import eventBus from ./eventBus; eventBus.on(user:login, (user) { refreshCart(user.id); });这样一来登录模块完全不再依赖那些业务模块。以后新增“登录后发优惠券”的功能只需要在优惠券模块里加一个订阅即可登录模块一行都不用改。这就是发布订阅模式最核心的收益——解耦。1.3 用生活场景把发布订阅串起来发布订阅不是程序员拍脑袋发明的概念它在我们日常生活里到处都是。你去奶茶店点单店员给你一个取餐号。你没有站在操作台旁边盯着做奶茶的小哥哥而是可以找个座位坐下来玩手机。奶茶做好之后店员通过叫号器喊号你听到自己的号再过去取。这个过程里做奶茶的店员根本不认识你也不知道你长什么样他做的就是“叫号”这个动作而你作为顾客不需要知道后厨现在做的是哪一杯只需要关注叫号器的声音。这个“叫号器”就是事件通道它是发布者和订阅者之间的中间层。再比如你发了一条朋友圈你的好友们可以通过点赞评论来回应但你不需要知道具体谁点了赞你只需要把动态发布到朋友圈这个平台。以后有人取关、有人新加好友都不需要你再去挨个维护。这些例子的共同点都很一致消息的产生方和消息的消费方互不认识中间有一个东西负责把他们连接起来。而这就是发布订阅模式的本质。2. 别踩面试的坑观察者模式与发布订阅模式的真正边界2.1 为什么总会有人把这两个模式搞混说到发布订阅就绕不开观察者模式。面试里十个问发布订阅的至少有八个会追问“和观察者模式有什么区别”。很多人搞混的原因其实是这两个模式在代码形态上看起来高度相似都是“一个对象维护一组回调状态变了就逐个调用回调”。那到底差在哪我直接给结论观察者模式是耦合的发布订阅模式是解耦的。关键区别在于中间是否存在一个独立的“事件通道”。观察者模式里被观察对象Subject直接持有观察者Observer并且直接调用观察者的方法。它们彼此是知道对方存在的。发布订阅模式里发布者Publisher和订阅者Subscriber互不认识中间有一个事件通道Event Channel来做消息转发。2.2 用代码一眼看穿两者的区别先看观察者模式最简单的实现长这样// 被观察者 class Subject { constructor() { this.observers []; } addObserver(observer) { this.observers.push(observer); } removeObserver(observer) { this.observers this.observers.filter((obs) obs ! observer); } notify(data) { this.observers.forEach((observer) observer.update(data)); } } // 观察者 class Observer { constructor(name) { this.name name; } update(data) { console.log(${this.name} 收到了数据, data); } } const subject new Subject(); const observerA new Observer(观察者A); subject.addObserver(observerA); subject.notify(hello);在这段代码里subject直接维护了一份observers列表它知道每一个观察者的存在并且直接调用它们的update方法。这种直接的联系就是观察者模式的特征。再看发布订阅模式它的核心是事件通道// 事件通道Event Bus class EventBus { constructor() { this.events {}; } on(type, listener) { if (!this.events[type]) { this.events[type] []; } this.events[type].push(listener); } emit(type, data) { if (this.events[type]) { this.events[type].forEach((listener) listener(data)); } } } const bus new EventBus(); // 模块A订阅 bus.on(user:login, (user) { console.log(模块A收到登录用户, user); }); // 模块B订阅 bus.on(user:login, (user) { console.log(模块B收到登录用户, user); }); // 某个地方统一发布 bus.emit(user:login, { id: 1, name: 张三 });看到没发布者和订阅者之间没有任何直接引用。emit的那一行代码根本不知道有模块A、模块B的存在它们之间隔着一个bus事件通道。我习惯用一个类比来记这件事观察者模式像是你打电话给你的朋友你拨号给指定的人你们之间是点对点的联系发布订阅模式像是你在广播电台发了一条消息谁收音机调到这个频道谁就能听到你完全不关心谁在听。2.3 面试时怎么聊这个区别才显专业如果你只是说“发布订阅比观察者多了一个中间层”面试官可能只是点点头。但如果补充下面这些角度效果会明显不一样耦合程度不同观察者模式中 Subject 和 Observer 互相知晓属于松耦合但仍有依赖发布订阅模式中发布者和订阅者完全解耦通过事件通道隔离。通信方式的自由度不同观察者模式通常是同步调用Subject 发出通知后逐个调用 Observer发布订阅模式可以通过事件通道实现异步、延迟、甚至跨进程的消息传递。扩展行为不同观察者模式添加一个新观察者还是要告诉 SubjectaddObserver发布订阅模式下新订阅者只需要自己on一次发布者完全无感知。典型实现不同观察者模式典型代表是 Vue 2 的响应式系统Dep 和 Watcher 的关系发布订阅模式典型代表是 DOM 的addEventListener、Node.js 的EventEmitter、Vue 2 的$bus。我遇到过不少同学在面试时把 Vue 的响应式说成发布订阅严格来说这是不对的。Vue 2 响应式里每个属性有一个 DepDep 收集 Watcher数据变化时 Dep 直接dep.notify()通知 Watcher这是典型的观察者模式。而 Vue 2 里的$emit/$on事件系统才是发布订阅模式。3. 动手实现一个靠得住的事件总线3.1 为什么我建议你亲手写一遍理解设计模式最好的方式不是背类图而是亲手实现一遍。如果你能不看任何库的源码从零写一个支持on、off、once、emit的事件总线那么你对发布订阅模式的理解就已经超过了八成只会背定义的人。项目里经常使用现成的EventEmitter或者mitt但自己实现一遍能让你更清楚地知道这些库的内部机制也更容易在出错时排查。下面我写一个比较完整、适合用在真实项目里的版本。class EventBus { constructor() { // 使用 Map 存储事件类型和监听器集合 // 这里不用数组而用 Set是为了自动去重防止同一个函数被重复注册 this.events new Map(); // 最大监听器数量超过这个数量就打印警告方便提前发现内存泄漏 this.maxListeners 10; } // 订阅事件 on(type, listener) { if (typeof listener ! function) { throw new TypeError(listener 必须是函数); } if (!this.events.has(type)) { this.events.set(type, new Set()); } const listeners this.events.get(type); listeners.add(listener); // 超过最大监听器数量时给出警告注意只警告不阻止 if (listeners.size this.maxListeners) { console.warn( 事件 ${type} 的监听器数量已达到 ${listeners.size} 可能存在内存泄漏请检查是否需要调用 off 解绑 ); } return this; } // 订阅一次性事件 once(type, listener) { // 关键思路包一层 wrapper在回调执行后立即取消订阅 const wrapper (...args) { listener.apply(this, args); this.off(type, wrapper); }; // 保存原始 listener 的引用方便外部使用原函数进行解绑 wrapper.original listener; return this.on(type, wrapper); } // 取消订阅 off(type, listener) { if (!this.events.has(type)) { return this; } const listeners this.events.get(type); if (!listener) { // 如果只传了事件名则清空该事件的所有监听器 this.events.delete(type); return this; } // 先尝试直接删除 listeners.delete(listener); // 如果没删掉可能是 once 包装过的函数尝试通过 original 匹配删除 for (const item of listeners) { if (item.original listener) { listeners.delete(item); } } // 事件下没有监听器了就把这个事件类型也清理掉 if (listeners.size 0) { this.events.delete(type); } return this; } // 发布事件 emit(type, ...args) { if (!this.events.has(type)) { return false; } // 重要先拷贝一份监听器列表防止在回调中修改原列表导致遍历异常 const listeners [...this.events.get(type)]; listeners.forEach((listener) { listener.apply(this, args); }); return true; } }3.2 事件总线里容易被忽略的细节上面这段代码看起来简单但里面每个细节都值得单独说。为什么用 Set 而不是数组因为 Set 天然去重。某些场景下同一个回调函数可能因为代码执行逻辑问题被反复on多次如果没有去重回调就会执行多次而且很难排查。用 Set 的话同一个函数的重复注册会被自动忽略。当然有些场景你可能故意想重复注册同一个函数来执行多次但那种需求非常少见大部分情况下重复注册都是 bug。once 为什么要包一层 wrapper因为once的语义是“只执行一次”执行完必须自动解绑。但如果直接对原始 listener 做处理就需要在 listener 执行后找到它的引用再删掉。包一层 wrapper 的好处是让 wrapper 来做“先执行、再解绑”的逻辑。同时为了支持外部用原始函数解绑我们给 wrapper 上加了一个original属性保存原始函数引用off时可以通过item.original listener找到它。emit 时为什么要拷贝一份监听器列表这里有一个非常经典的 JavaScript 坑。假设监听器 A 在回调里通过off把监听器 B 移除掉了如果我们直接遍历this.events.get(type)这个 Set遍历过程中删除元素会导致 B 可能被跳过或者出现意外行为。先拷贝一份快照再遍历可以保证当前这次 emit 完整执行不会被回调中的修改影响。listener.apply(this, args)里的 this 指向谁这里指向 EventBus 实例。有些库会让this指向发布时传入的 context保持简单的话指向 EventBus 实例也没问题关键是行为要一致。3.3 用起来的效果写完之后你可以简单跑一遍验证const bus new EventBus(); function handleLogin(user) { console.log(处理登录, user.name); } bus.on(user:login, handleLogin); bus.emit(user:login, { name: 张三 }); // 输出处理登录张三 bus.off(user:login, handleLogin); bus.emit(user:login, { name: 李四 }); // 无输出因为已经解绑 bus.once(app:init, () { console.log(这个只会执行一次); }); bus.emit(app:init); bus.emit(app:init); // 只会输出一次实际项目里我一般还会加两个额外的能力事件名常量管理和批量解绑。事件名常量管理很好理解就是不要到处写字符串user:login而是抽成一个对象// events.js export const Events { USER_LOGIN: user:login, USER_LOGOUT: user:logout, CART_UPDATE: cart:update, };这样写的好处是避免拼写错误而且 IDE 自动补全友好后期改名只动一个文件。批量解绑的思路是在 EventBus 里维护一个context维度的注册表方便整体解绑某个组件注册的所有事件。很多框架里都有类似的实现。4. 生产环境踩过的坑重复监听、内存泄漏与事件风暴4.1 排查链路实录事件为什么执行了两次我在一个后台管理项目里遇到过一个问题用户点击一次按钮请求发出去了两次。第一次看到这个现象我以为是按钮被重复点击加了个 loading 禁用后发现没用。后来在请求拦截器里打点才发现是某个模块的事件监听器被绑定了两次。还原一下当时的场景。有一个orderList组件在created里监听了全局的order:refresh事件created() { eventBus.on(order:refresh, this.fetchOrderList); }问题是这个组件所在的页面是一个 Tab 页用户反复切换 Tab 时组件被销毁又重新创建但销毁时没有解绑事件。于是每次进入这个页面order:refresh监听器就多一个事件触发时回调自然就重复执行。排查链路大致是这样的症状确认点击一次刷新接口请求发出两次。怀疑事件重复绑定在eventBus.emit(order:refresh)的地方加了一条日志打印当前监听器的数量。确认组件重复注册在orderList组件的created里打印eventBus这个事件当前的监听器数量发现从 1 变成 2、3每次切换 Tab 都加 1。定位根因组件销毁时没有执行eventBus.off(order:refresh, this.fetchOrderList)。修复在destroyedVue2或onBeforeUnmountVue3里解绑事件。// Vue3 import { onBeforeUnmount } from vue; onBeforeUnmount(() { eventBus.off(order:refresh, fetchOrderList); });这个案例特别典型值得记下来。几乎所有事件总线相关的生产事故都跟“注册了没注销”有关。4.2 内存泄漏监听器把不该留的都留住了第二个坑更隐蔽不容易立刻暴露但一旦出现就会非常难查——内存泄漏。事件总线的内存泄漏通常长这样某个全局的、会长期存在的对象比如 EventBus 实例本身被你的组件、模块监听而监听器的回调函数又是一个闭包闭包里引用了一个大对象或者一个 DOM 元素。因为 EventBus 一直存活它持有的监听器引用就一直存在垃圾回收机制永远无法回收那些被引用的大对象。举个例子const el document.getElementById(big-chart); const chartInstance createChart(el); eventBus.on(data:update, (data) { // 这个回调引用了 chartInstance chartInstance.update(data); }); // 即使后来页面切换了chartInstance 被销毁了 // 但只要 eventBus 还在这个回调就还在chartInstance 就永远无法被回收这个问题在控制台用Performance面板录制堆内存快照非常明显你能看到大量 Detached DOM 节点或者是某个组件的实例数量只增不减。修复思路其实就一条确保监听器在不需要的时候及时解绑。还有一条更保险的实践不要用一个全局单例 EventBus 到处挂载各种业务监听尽量把事件总线的生命周期绑定到组件或模块的上下文中。如果你确实需要全局事件要注意在模块销毁时统一清理。4.3 事件风暴高频触发把页面搞卡第三个坑发生在高频事件场景。比如有个实时数据推送页面后端通过 WebSocket 每 100ms 推一次行情数据前端每次收到数据都会emit(market:tick, data)然后多个图表组件同时更新。一开始数据量小没事后来数据量上来图表组件重绘频繁页面直接卡成幻灯片。这个问题的本质是发布频率大于消费者处理能力事件通道本身没有问题问题出在消费者端。解决办法我一般从两个方向考虑节流限制事件的派发频率比如每 200ms 最多派发一次market:tick。更细粒度的事件拆分不要所有消费者都监听同一个高频事件可以把“原始数据到达”和“图表需要更新”拆成两个不同粒度的事件让不同模块订阅自己需要的那一个。// 节流派发示例 let lastEmitTime 0; const THROTTLE_MS 200; socket.on(message, (data) { const now Date.now(); if (now - lastEmitTime THROTTLE_MS) { lastEmitTime now; eventBus.emit(market:tick, data); } });4.4 我建议纳入项目规范的事件管理约定踩过这些坑之后我在自己的项目里会强制要求几件事事件命名必须带命名空间。不要用login、refresh这种泛化名称建议统一使用模块:动作的格式比如user:login、cart:refresh、order:status-change。这样可以避免不同模块之间事件名冲突排查的时候也能一眼看出是哪个模块的事件。所有订阅都必须有对应的取消订阅逻辑。这不是喊口号而是可以落地的。我会要求团队在代码审查时看到on就必然问一句“在哪个生命周期取消”。也可以写一个小的 ESLint 规则或者工具函数辅助追踪当前所有活跃的监听器。emit 内部要做错误隔离。一个监听器抛了异常不应该影响其他监听器执行。现场改进版本emit(type, ...args) { if (!this.events.has(type)) { return false; } const listeners [...this.events.get(type)]; listeners.forEach((listener) { try { listener.apply(this, args); } catch (error) { // 单独捕获每个监听器的异常避免影响后续监听器 console.error(事件 ${type} 的监听器执行出错, error); } }); return true; }5. 发布订阅在浏览器、Node.js 与框架里的真实生态位5.1 浏览器原生早就内置了这套机制你其实不引入任何第三方库浏览器本身就自带了一个发布订阅系统那就是EventTarget。在浏览器环境下addEventListener、removeEventListener、dispatchEvent基本就是一个成熟的事件总线原生产品。DOM 元素本身就是事件通道你不需要自己实现事件中心。// 原生发布订阅示例 const button document.getElementById(btn); button.addEventListener(click, () { console.log(按钮被点击了); }); button.dispatchEvent(new Event(click));除了 DOM 元素的事件我们还可以在任意对象上生成自定义事件。比如// 创建一个全局事件发布器 const globalBus new EventTarget(); globalBus.addEventListener(user:login, (event) { console.log(收到自定义登录事件, event.detail); }); // 发布事件 globalBus.dispatchEvent( new CustomEvent(user:login, { detail: { id: 1, name: 张三 }, }) );CustomEvent的detail属性特别方便可以携带任意自定义数据类似于我们手写 EventBus 时emit的第二个参数。浏览器原生支持的好处是零依赖、生命周期跟着页面走但缺点也明显没有once的便捷写法需要自己包一层没有批量解绑事件类型约束也很弱。5.2 Node.js 社区的事实标准EventEmitter在 Node.js 里发布订阅模式的地位就更核心了。Node.js 内置的EventEmitter就是这套模式的标准实现Stream、HTTP 模块、进程通信都建立在它之上。const { EventEmitter } require(events); class OrderService extends EventEmitter { createOrder(order) { // 业务逻辑... this.emit(order:created, order); } } const orderService new OrderService(); orderService.on(order:created, (order) { console.log(订单创建成功, order.id); }); orderService.createOrder({ id: 1001, amount: 299 });Node.js 的EventEmitter在底层考虑了很多实际生产问题比如error事件如果没有人监听Node.js 会直接抛出未捕获异常这是一个很有意的设计决策避免错误被静默吞掉默认最大监听器数是 10超过后会打印MaxListenersExceededWarning这个数量还可以通过setMaxListeners调整。前几年流行的第三方库eventemitter3还有现在轻量级的mitt本质上都是在这个思路上做优化。mitt的特点是体积小、API 简洁、移除了一些EventEmitter里冷门的方法适合前端项目直接用。5.3 前端框架里的应用从 Vue 到 ReactVue 体系对发布订阅的支持最直白。Vue 2 里$on、$off、$emit直接挂在组件实例上你可以通过一个空的 Vue 实例做全局事件总线实现跨组件通信。这也是很多人最早接触事件总线的地方。Vue 3 之后官方把$on、$off从组件实例中移除了推荐使用mitt或tiny-emitter这类第三方库做事件总线。为什么官方移除一方面是实现细节变了另一方面也是希望开发者优先使用props、provide/inject这类数据流更清晰的方案而不是到处挂事件总线导致维护困难。React 官方并没有提供事件总线的概念理由是 React 的数据流是单向的推荐通过状态管理库如 Redux、Zustand来共享状态。但在某些场景下比如组件之间无直接关联且需要通信、或者与第三方非 React 代码如纯 JS 模块集成时mitt依然是非常好用的选择。// React 组件中使用 mitt import mitt from mitt; // 创建一个共享的全局事件总线 export const bus mitt(); // 组件A中发布 function handleLogin(user) { bus.emit(user:login, user); } // 组件B中订阅 useEffect(() { const handler (user) { console.log(用户登录了, user); }; bus.on(user:login, handler); return () { bus.off(user:login, handler); }; }, []);5.4 发布订阅是银弹吗它有哪些明显的短板前面讲了很多发布订阅的优点但作为一个合格的工程师心里必须清楚它不是一个银弹过度使用会带来非常现实的问题。调试困难。如果你在代码里看到一行eventBus.emit(order:create, order)你无法直接知道这会触发哪些逻辑因为订阅可能在项目的任何地方。你得全局搜索order:create才能找到所有监听方。如果项目大了、事件多了搜索链路会非常痛苦。事件风暴与顺序问题。多个监听器订阅同一事件时执行顺序通常不透明。一旦业务对执行顺序有要求事件总线就成了一个隐形的定时炸弹。命名空间污染。如果大家随意起事件名项目迭代半年之后你根本数不清有多少个事件、哪些事件已经废弃、哪些事件名重叠了。所以在实际项目里我对团队的推荐是分层对待页面内部的状态联动优先用组件的props、computed等原生机制跨模块、跨页面的低频率事件比如登录、退出、消息通知适合用发布订阅高频数据的传递优先考虑状态管理或专门的流式处理方案而不是把所有内容都塞进事件总线。我个人在实际项目里还会留一个后手在开发环境里给 EventBus 加一层“事件注册表”全局记录哪些事件被订阅过、每个事件有多少监听器、最近一次派发时间是什么。排查线上问题的时候把这些信息打印出来比盲猜“是不是事件没解绑”要快得多。这也是我为什么一直强调说理解发布订阅模式不能只停留在背定义真正动手实现一遍、踩一遍坑你才能在各种场景里自如地用好它。