
观察者模式是我在实际业务里用得最频繁的行为型设计模式之一。很多人第一次接触它是在《Head First 设计模式》里看气象站推送数据那个例子太学院派了真正落到自己项目里反而容易犯嘀咕就一个“通知”而已值得专门学吗其实观察者模式的核心价值从来不是“通知”这个动作本身而是它把代码里最纠缠不清的“依赖方向”给掰正了前端的状态管理、跨组件通信、事件总线、消息订阅背后全是这一套逻辑。这篇东西我会从日常场景切入先讲清楚它到底在解决什么问题再带你从零手写一个能直接用的极简实现然后深入聊一次性订阅、批量聚合、弱引用这类实战细节最后盘点我踩过的高频坑位以及与发布-订阅、中介者这些邻近模式的选型边界。不管是刚接触设计模式的初学者还是已经写过大量业务但想理清设计动机的工程师都能从中拿到可以直接抄作业的东西。1. 观察者模式在解决什么问题核心思想与设计思路拆解1.1 一个场景引发的“通知问题”先看一个特别常见的业务用户往购物车里加商品。页面上需要跟着更新的地方至少有四五处——购物车角标、结算金额、推荐位、库存余量、后台埋点。最粗暴的写法是直接改“加购”这个方法在其中按顺序调用updateBadge()、updateTotal()、refreshRecommend()、updateStock()。功能能跑但问题很早就埋下了每增加一个关注方就要回头改一次加购方法的源码加购逻辑越来越臃肿各个模块之间被硬编码地绑定在一起。观察者模式应对的就是这种场景。它把“加购方法”和“被通知的各方”拆开让加购方法只做一件事——把“用户加购了某件商品”这个事实发布出去。至于谁关心这条消息、关心之后要做什么加购方法完全不知道也不需要知道。这就是观察者模式的核心思想定义对象之间一对多的依赖关系当被观察者Subject的状态发生变化时所有依赖它的观察者Observer都会收到通知并自动更新。一句话概括就是“我变了通知你但我不关心你是谁”。1.2 观察者模式的结构与角色拆解这个模式通常涉及三类角色Subject被观察者也叫主题负责维护一个观察者列表提供订阅、退订、通知三个核心方法。Observer观察者定义一个接收通知的接口通常就是update()方法在业务实现里我更倾向于直接用一个回调函数。Client使用方负责创建和装配 Subject 与 Observer 之间的关系。如果不好理解就拿前端最常用的addEventListener来类比按钮是 Subject监听函数是 ObserveraddEventListener做的事就是“把观察者挂到主题的列表里”click()触发时浏览器内部会把监听列表里所有回调都执行一遍。事件监听机制本质上就是观察者模式的一种工程化实现浏览器底层维护的就是一个监听器列表和你在代码里手写的 Set 结构几乎没什么两样。1.3 为什么订阅式设计比硬编码通知更值得用很多人觉得“不就是回调列表吗”确实底层数据结构不复杂但它带来的结构性收益是巨大的。第一是依赖方向被反转了。之前的代码里加购方法要去依赖具体的 UI 更新函数用观察者模式后主题只依赖“观察者接口”这个抽象而观察者们依赖的是主题发布的“消息格式”。两者之间没有直接引用模块之间彻底解耦。这其实就是依赖倒置原则的一种体现。第二是扩展性变强了。以后要加一个“下单后自动发优惠券”的逻辑只需要在相应的位置额外注册一个观察者被观察者的源码一行都不用动。这个特性在老项目里尤其好用你不需要去翻动那些谁都不敢碰的核心流程代码只需要在入口处注入一个订阅即可。第三是状态变化天然适合异步化。“状态变了”这个动作一旦被抽象成消息就可以被放到异步队列里处理可以节流、可以批量合并、可以跨进程转发这些全是硬编码调用做不到的扩展空间。观察者模式不是银弹但它给后续的架构演进留足了余地。2. 从零手写一个能用的观察者基础实现与关键设计2.1 一个可以直接抄走的极简实现理论讲再多不如看一段能跑的代码。下面是一个极简但可以直接落地的 Subject 实现JavaScript 版十几行搞定class Subject { constructor() { this.observers new Set(); } on(observer) { if (typeof observer ! function) { throw new TypeError(observer must be a function); } this.observers.add(observer); return () this.off(observer); } off(observer) { this.observers.delete(observer); } notify(message) { for (const observer of this.observers) { observer(message); } } }用起来也非常简单const subject new Subject(); const unsubA subject.on((msg) { console.log(订阅者A收到, msg); }); const unsubB subject.on((msg) { console.log(订阅者B收到, msg); }); subject.notify(商品已加入购物车); // 订阅者A收到 商品已加入购物车 // 订阅者B收到 商品已加入购物车 unsubB(); // B 退订 subject.notify(商品数量已更新); // 订阅者A收到 商品数量已更新这段代码虽然短但每一处设计都是有讲究的不是随手写的。2.2 三个关键设计决策背后的“为什么”第一个为什么观察者为什么用函数而不是带update()方法的对象函数天然具备可调用性直接observer(message)就完成了通知省去了方法存在性判断。同时函数可以作为引用被 Set 去重和删除语义上最轻。如果你确实需要对象观察者完全可以在notify里做一个分支判断调用observer.update(message)并不冲突。第二个为什么存储结构为什么用 Set 而不是数组最直观的理由是自动去重。同一个函数重复订阅两次Set 只保留一份不会出现重复收到通知的问题数组则要靠indexOf去手动兜底多了一步不说还容易漏。另一个重要原因是数组在遍历过程中删除元素会引发索引错乱Set 的delete在绝大多数遍历场景下行为更稳。虽然 Set 在边遍历边删除前序元素时也可能有“跳过未遍历元素”的微妙问题但比数组还是省心得多。第三个为什么on为什么要返回一个退订函数而不是让调用方拿着原来的函数去调off因为调用方很可能在闭包或组件内部持有的是经过包装的回调让它保全原始引用非常反人类。直接返回一个“取消订阅”的函数语义清晰也方便在生命周期销毁时统一执行// React 组件里配合 useEffect 用 useEffect(() { const unsubscribe subject.on(handleUpdate); return unsubscribe; // 组件卸载时自动退订 }, []);这样一个on返回unsubscribe的习惯能省掉无数个内存泄漏事故。2.3 从极简版升级成可复用的 EventBus单例的 Subject 在真实业务里不太够用我们通常会基于它扩展出一个支持命名事件的 EventBus——这也是 Node 内置EventEmitter的极简原型class EventBus { constructor() { this.handlers new Map(); } on(event, handler) { if (!this.handlers.has(event)) { this.handlers.set(event, new Set()); } this.handlers.get(event).add(handler); return () this.off(event, handler); } off(event, handler) { this.handlers.get(event)?.delete(handler); } emit(event, payload) { this.handlers.get(event)?.forEach((handler) { handler(payload); }); } } // 使用 const bus new EventBus(); const unsub bus.on(ADD_TO_CART, (product) { console.log(购物车通知, product.name); }); bus.emit(ADD_TO_CART, { id: 1, name: 机械键盘 });用 Map 来管理“事件名到监听器集合”的映射这个结构基本就是前端事件总线的鼻祖形态。你会发现Node.js的EventEmitter、浏览器端的事件派发机制核心思路都跟这段代码一致。写清楚它的结构之后再去看任何事件类库的源码都会轻松一大截。3. 进阶玩法一次性订阅、批量聚合、弱引用边界3.1 一次性订阅 once 的三种实现与取舍业务里经常会碰到“只关心一次通知”的场景比如等待某个异步任务完成任务完成后只需要执行这一次回调。直接给 EventBus 加一个once方法once(event, handler) { const wrapper (payload) { this.off(event, handler); handler(payload); }; // 这里要注意wrapper 里 off 的是原始 handler // 但 on 方法注册到 Set 里的却是 wrapper wrapper.origin handler; this.handlers.get(event).add(wrapper); return () this.off(event, wrapper); }这里隐藏了一个很经典的坑因为在Set里注册的实际是wrapper包装函数所以off(event, handler)并不会生效必须对包装函数做退订或者维护一个origin - wrapper的映射关系。很多同学写完once后发现退订失效原因就在这。另一种实现思路是在emit内部做标记遍历时收集需要删除的项遍历结束后统一清理。这种做法可以避免“边遍历边删除”的隐患代码更安全代价是多维护一个待删除队列。如果你的项目对可靠性要求高我建议用“收集后统一删除”的方案而不是依赖包装函数。3.2 批量聚合1 秒触发 50 次UI 只想更新 1 次再来看一个性能场景。拖动滑块、鼠标移动、实时搜索、WebSocket 推送这些高频事件如果每次notify都同步触发 UI 渲染页面一定会卡。观察者模式并不要求每次通知都立刻同步执行我们完全可以在内部做一层批量聚合。思路很简单收到第一个通知时不立刻执行监听器而是把一个 flush 函数放进微任务队列同一轮事件循环中如果又来了好几次通知只更新“最近的 payload”微任务执行时只跑一次真正的通知逻辑class BatchedSubject { constructor() { this.observers new Set(); this.pendingPayload null; this.scheduled false; } on(fn) { this.observers.add(fn); return () this.off(fn); } off(fn) { this.observers.delete(fn); } notify(payload) { this.pendingPayload payload; if (this.scheduled) return; this.scheduled true; queueMicrotask(() { this.scheduled false; const payload this.pendingPayload; this.pendingPayload null; this.observers.forEach((fn) fn(payload)); }); } }为什么用queueMicrotask而不是setTimeout因为微任务在当前同步代码执行完之后立即执行延迟更小而且不会产生多余的事件循环 tick。如果你的更新目标是 UI也可以用requestAnimationFrame把通知合到浏览器的下一帧渲染之前效果更加丝滑。这个技巧在图表库、实时大屏、拖拽交互里非常实用。3.3 弱引用监听器会不会把组件钉死在内存里这是观察者模式最“臭名昭著”的问题全局总线上挂了组件的方法组件销毁后忘记退订主题对象就会一直强引用着这个回调进而把整个组件实例钉在内存里造成泄漏。最可靠的解决办法还是“显式退订”所有订阅都在useEffect里搭配return unsubscribe这是常规武器的天花板。再进一步JS 还提供了WeakRef可以让主题不阻止观察者被垃圾回收class WeakRefSubject { constructor() { this.observers new Set(); // 存 WeakRef } on(fn) { const ref new WeakRef(fn); this.observers.add(ref); return () this.observers.delete(ref); } notify(msg) { for (const ref of this.observers) { const fn ref.deref(); if (fn) { fn(msg); } else { this.observers.delete(ref); // 对象已被回收顺手清理 } } } }但这里我必须明确泼一盆冷水WeakRef的回收时机完全由引擎决定具有很强的不确定性用它实现的“自动取消订阅”不一定是什么时候失效调试起来极其痛苦。它在浏览器里的支持也比较有限生产环境我一般不建议大规模使用。真要用也只是作为“防漏兜底”不能替代显式退订。我的个人经验是写任何事件类代码第一个习惯就应当是on返回取消函数第二个习惯是销毁时无条件调用它。4. 高频坑位与排查技巧实录4.1 问题速查表观察者模式看着简单一旦进入真实业务坑位一个不少。我把这些年遇到的高频问题整理成了一张速查表建议收藏问题现象原因解决方式回调异常中断第一个观察者抛错后面的全不执行notify 里没有 try/catch在 notify 循环里 try/catch 并 console.error重复触发一个事件被触发 2 次以上构造函数里注册了一遍某个方法里又注册了一遍用 Set 去重并在注册时做唯一性判断退订失效明明调了 off回调仍然执行注册的是包装函数off 用的是原始函数引用once 场景务必退订包装函数或维护映射表无限循环A 状态变化通知 BB 的变化又通知 A观察者之间形成环形依赖加更新锁/version 字段或避免双向订阅时序错乱先订阅的 B 反而后收到通知依赖了消息通知的执行顺序需要严格顺序就用责任链别靠注册顺序消息错过emit 先于 on 执行后订阅的观察者收不到订阅晚于消息产生on 时立即补发最近一次快照或改为状态存储模型4.2 三个我踩过的真实事故现场第一个事故和内存泄漏有关。之前做一个实时数据大屏图表组件订阅了数据推送服务组件里用 setState 更新图表数据。图表页切换走后忘了退订数据推送还在继续旧组件被唤醒 setState控制台直接报出“对已卸载组件的警告”。排查时我用内存快照看了一眼发现 listenerCount 在几个小时内只增不减才定位到是订阅泄漏。后来把所有订阅都收进一个 Set随组件卸载统一清理问题彻底消失。第二个事故更隐蔽和通知顺序有关。做多语言切换时某个组件先收到了语言变更通知立刻尝试更新页面上的翻译文案但另一个更底层的基础组件还没收到通知、尚未完成初始化导致文案一直渲染不出来界面一片空白。那次排查花了大半天最后发现问题的根源不是“通知没发出去”而是“执行顺序不对”。观察者模式本身不保证回调顺序把它当作可靠的执行顺序依赖本身就是一种误用。遇到严格的顺序需求应该考虑责任链或者中介者方案而不是赌注册顺序。第三个事故发生在热更新调试场景。开发环境下模块被 HMR 反复加载EventBus 的单例没有做全局挂载每次模块 reload 都会重新 new 出一个新的总线旧页面上还挂着旧总线的订阅新总线也注册了一批两批订阅同时被触发页面行为完全错乱。后来我把 bus 挂载到window上做了全局单例并配合模块更新后的状态清理问题才稳定下来。4.3 排查工具与调试思路观察者模式的代码链路比较散排查起来不像普通函数调用栈那么直观。我习惯在每个on和off里包一层调试标记开发环境打印订阅时间与调用栈这样能很快定位到“谁在什么时候注册的”。如果用了 Node 的EventEmitter可以直接调用emitter.listenerCount(event)和emitter.listeners(event)来查看当前事件下挂了多少回调。浏览器侧也可以用getEventListeners这个 DevTools API 检查 DOM 事件监听器。更土但更有效的办法是给所有通知消息带上一个自增 ID日志里串联一下订阅与触发的链路基本都能快速定位到问题。5. 模式对比与选型判断观察者、发布-订阅、中介者到底怎么选5.1 三者的区别面试也爱考观察者模式、发布-订阅模式、中介者模式很多人觉得它们是同义词其实边界完全不同。观察者模式里Subject 直接持有一份 Observer 列表主题知道观察者是谁耦合度低但不算零耦合。发布-订阅模式则多出一个“消息通道”的角色发布者和订阅者之间完全不认识一个消息发到通道里由通道分发给所有订阅者跨模块解耦能力更强。中介者模式则把一组对象之间的网状交互集中到中介者对象中对象之间不再直接通信而是都去找中介者。画个简化的认知模型观察者是“房东直接告诉每个租客”发布-订阅是“房东把公告贴到公告栏”中介者是“租房问题统一去找中介房东和租客不见面”。三者各有适用场景没有绝对优劣。我个人的选型标准是这样的模式耦合度通信方式适用场景观察者模式低但主题持有观察者1 对多直接通知单个对象状态变化多个模块需要响应发布-订阅极低完全通过消息通道发布者与订阅者完全解耦跨模块、跨系统的事件通信中介者模式集中化对象只依赖中介者网状交互转星型交互多个对象交互规则复杂比如表单校验前端里常说的 EventBus本质上是发布-订阅模型的实现但它内部的数据结构和真实实践又大量复用了观察者模式的代码思路。面试时如果能把这个层次讲清楚是个很加分的点。5.2 哪些场景千万不要用观察者模式观察者模式不是万能膏药下面几种情况我会明确劝退。一对一的调用场景不要用。两个模块之间固定且唯一的调用直接函数调用是最清晰、最好追踪的引入订阅反而让代码链路过长心智负担增加。对错误处理有严格要求的场景不要用。观察者的通知通常是“广播出去就不管了”如果某个观察者必须保证执行成功、并且需要返回值给被观察者这种模式根本满足不了那时候应该考虑责任链或者显式的消息队列。管道式的数据处理流程不要用。比如读文件、解析、写库这种线性流水线每一步的输入是上一步的输出这种依赖关系用观察者模式来表达会很拧巴直接用管道或者 Promise 链更自然。还有一个反模式是“用订阅解决所有模块通信”。项目里 EventBus 一旦被乱用你会发现在任何地方都找不到一条数据流的完整链路代码变得不可追踪。观察者模式适合“一对多广播”的场景但当你发现系统里出现大量“多对多”“一对一的复杂交互”时就该考虑中介者模式或者状态管理工具了。5.3 主流事件库选型建议业务中直接用内置工具往往比手写更稳妥。Node 环境优先用内置的EventEmitter。API 非常成熟on、once、off、emit、listenerCount一应俱全能应对绝大多数场景。唯一要特别注意的一点是EventEmitter对error事件有特殊处理如果发出error事件但没有监听器Node 会直接抛出异常并导致进程退出。这个隐藏行为曾经让不少新项目在线上吃过亏。前端轻量级总线建议用mitt。它压缩后大约 200B支持通配符事件API 干净源码几百行值得直接读一遍。对小型中后台项目来说用它做跨组件通信比引一个完整状态管理库划算得多。复杂异步流场景再考虑RxJS。它的Observable不只是观察者模式而是完整的响应式编程库支持map、filter、debounceTime、switchMap这些管道操作能做自动取消、错误恢复、时间调度。前端项目里处理接口轮询、搜索防抖、复杂异步编排很强但学习成本也高不建议只为了用观察者模式而全项目引入它。聊到这儿关于观察者模式的主体内容就都覆盖了。最后再分享一点我的个人体会在实际工程里我很少专门去为一个类写一个 Subject 类更多时候是直接封装一个局部 EventBus 来用。但比实现更重要的是判断什么时候该用、什么时候该忍住不用。观察者模式真正的价值是把代码里的依赖方向从“靠改源码”变成“靠订阅关系”这个结构上的转变才是它存续几十年的根本原因。同时我特别建议你养成一个习惯任何on方法签名都要设计成返回取消函数。这个习惯看起来微不足道但它能成倍降低内存泄漏的概率也让团队里的新人在写订阅时更有安全感。再配合“组件销毁时统一执行所有退订函数”这条铁律观察者模式在实际业务里的坑位基本就都堵上了。