观察者模式是我这些年写代码用得最频繁的设计模式之一,也是我跟团队新人聊解耦时第一个会搬出来的例子。但说实话,大部分人对它的理解停留在"定义一个列表,循环调回调"这个层面,真正吃透它怎么用、什么时候用、用了会带来哪些新问题,其实是另一回事。这篇文章不打算抄教科书,我用自己的项目经历和踩坑记录,把观察者模式从定义到实战完整拆一遍,顺便聊聊那些容易翻车的地方。
1. 观察者模式到底是什么:从一次页面卡顿说起
先说一个我印象很深的场景。当时在做一个数据看板项目,页面上有图表、有表格、有统计卡片,所有模块都依赖同一份实时数据。第一版代码写得很直接:数据一变,就在主逻辑里挨个调用各个模块的刷新方法——chart.update()、table.render()、cards.refresh(),一个接一个。一开始模块少,代码还算清爽,后来越加越多,主逻辑变成了一个又臭又长的调用链,每次加新模块都要去改那一段代码,改完还要担心顺序问题。更难受的是,有一次某个模块初始化失败了,后面一整串刷新逻辑直接中断,整个页面卡死在半成品状态。
后来我把这套逻辑重构成了观察者模式,核心思想很简单:数据变化的一方不关心谁会响应这个变化,它只需要发出一个"我变了"的通知;关心这个变化的模块自己去订阅这个通知,收到后再各干各的。上面的调用链变成了发布中心与订阅者之间的松散关系,新增模块只需要写好自己的订阅逻辑,完全不用改动数据源那边。
用更正式的话说,观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象的变化。主题对象状态改变时,所有观察者都会收到通知并自动更新。这套机制的核心参与角色有三个:
- Subject(主题/被观察者):维护自己的状态,并且维护一份观察者列表,状态变化时负责通知。
- Observer(观察者):对主题状态变化感兴趣的一方,收到通知后执行自己的更新逻辑。
- ConcreteSubject / ConcreteObserver:具体的实现类,承载实际业务逻辑。
我刚入行时觉得这个定义特别绕,后来用生活里的例子理解就轻松多了。你订了一份报纸,邮局不需要知道每个订户具体住哪栋楼哪一户,订户也不需要天天跑去邮局问报纸出了没。你订阅了,报纸一出邮局就给你送;你不想订了,取消订阅就不再收到。这中间最重要的是:邮局不关心你拿报纸干什么,你也不关心邮局怎么调度配送,双方通过"订阅"这个契约解耦。
观察者模式适合谁来学,其实范围很宽——前端处理交互事件、后端做消息通知、客户端做界面刷新、游戏开发做状态同步,甚至架构层面的组件通信,都会用到这套思想。硬要说门槛的话,至少得写过一段时间的业务代码,被耦合狠狠折磨过,才能真正领会它的价值。如果你刚接触设计模式,这篇文章我也会从最基础的概念讲起,代码和原理都会覆盖。
2. 为什么非要引入观察者模式:直接依赖带来的连锁困境
在展示代码之前,我想先把"为什么"讲透。因为只看代码很容易陷入一个误区:以为观察者模式只是"把函数调用改成回调列表",却理解不了这层转换究竟解决了什么本质问题。
2.1 直接依赖的痛点在哪
回到我刚才说的数据看板场景。如果没有观察者模式,代码结构大致是这样:
// 数据更新时,手动调用所有依赖方 function onDataChange(newData) { chart.update(newData); table.render(newData); cards.refresh(newData); notifyLog(newData); // 新加的模块 }这个写法有几个非常具体的问题。第一是_扩展困难_,每加一个响应方,都必须打开这个函数改一行,改多了容易漏,而且这个函数会越来越长,慢慢变成所有模块的"万能胶水"。第二是_顺序硬编码_,调用顺序被写死,一旦新模块需要提前执行,就又要调整代码顺序,而调用顺序的调整很容易引入隐形bug。第三是_失败传导_,如果某个模块在更新时抛出异常,后续所有模块都无法执行,一处出错崩一串。第四是_测试困难_,想单测数据源逻辑,却被迫带着所有下游模块一起跑,依赖树越深越痛苦。
2.2 观察者模式如何化解问题
换成观察者模式之后,数据源的代码就干净了:
class DataSource { constructor() { this.observers = []; } subscribe(fn) { this.observers.push(fn); } unsubscribe(fn) { this.observers = this.observers.filter(item => item !== fn); } notify(data) { this.observers.forEach(fn => fn(data)); } }数据变化时只需要调用notify(),它根本不关心列表里是谁、有几个、谁先谁后。新模块进场只需要自己subscribe(),完全不碰数据源代码。老模块下线也只需要unsubscribe(),不会影响别人。
这里的关键区别是:依赖关系从"数据源主动调用所有模块"变成了"各模块主动订阅数据源"。依赖的方向反转了,控制权也从数据源转移到了各个观察者手里。用术语说,这其实暗合了依赖倒置原则的思想——高层模块不再直接依赖底层具体模块的具体方法,而是依赖一个抽象的通知机制。
再举一个真实感更强的例子。一个电商下单流程里,下单成功后可能要发短信、发邮件、更新库存、推送优惠券、记录日志。如果全靠下单服务一个个调用,每次新增一个"下单后要做的事",都要改下单服务的代码,而且这些事彼此之间并不相关——发短信失败不应该阻止库存更新。把这些动作都注册成观察者后,下单服务只负责发出"订单已创建"的事件,各个响应方自己决定怎么处理、处理多久、失败了怎么办,互不干扰。
2.3 什么时候不该用观察者模式
任何工具都有适用范围。观察者模式引入的是异步协作和动态注册机制,它带来的代价也很实在:调用链变得隐式了,代码的执行路径不再一目了然。"谁订阅了谁"这个信息并不会写在主流程里,新人接手代码时得靠搜索subscribe调用来梳理全貌。如果是简单的两三个模块联动,直接函数调用反而更清晰。
我自己的判断标准是:当依赖方的数量可能变化、类型可能增加,或者响应方之间彼此独立时,优先考虑观察者模式;如果依赖方固定、数量很少、顺序要求严格,那直接调用反而是更合适的选择。这个判断标准不光适用于观察者模式,也适用于其他设计模式——模式是工具,不是目的。
3. 动手实现一个观察者模式:从基础版到工程可用版
下面进入正题。我写代码喜欢从最朴素版本开始,先跑通再说优化。这里用 TypeScript 演示,因为类型定义能把每个角色的职责表达得更清楚。
3.1 最简版本:理解核心骨架
观察者模式的基础实现只需要三样东西:观察者接口、主题类、具体观察者实现。
// 观察者接口:所有观察者都必须实现 update 方法 interface Observer { update(data: unknown): void; } // 主题(被观察者):维护观察者列表,提供订阅/取消订阅/通知方法 class Subject { private observers: Observer[] = []; subscribe(observer: Observer): void { this.observers.push(observer); } unsubscribe(observer: Observer): void { this.observers = this.observers.filter(item => item !== observer); } notify(data: unknown): void { this.observers.forEach(observer => observer.update(data)); } }这里我刻意做了几个设计取舍。第一,unsubscribe用filter生成新数组而不是splice,好处是不会在遍历通知的过程中因为数组索引变化导致跳过或重复;第二,notify里用forEach而不是for循环,是为了让后续做异常隔离时有更清晰的改写点;第三,传递的数据统一用unknown,具体类型在观察者内部自己判断,主题不跟具体业务类型耦合。
具体观察者就是一个普通类,实现Observer接口:
class ChartObserver implements Observer { name: string; constructor(name: string) { this.name = name; } update(data: unknown): void { console.log(`[${this.name}] 收到数据:`, data); // 这里写具体的图表刷新逻辑 } } // 使用 const subject = new Subject(); const chartA = new ChartObserver('图表A'); const tableB = new ChartObserver('表格B'); subject.subscribe(chartA); subject.subscribe(tableB); subject.notify({ value: 42 });跑起来你会发现输出顺序就是订阅顺序,逻辑很直观。但说句实话,这个版本离"工程可用"还有距离。真上了生产环境,你还会遇到下面这些情况。
3.2 引入事件类型:别让所有观察者都收到所有通知
一个主题类往往对应多种变化。比如订单模块,既可能有"订单创建"事件,也可能有"订单支付成功"、"订单取消"事件。如果所有观察者都订阅同一个 notify,那每个观察者都得自己判断这个数据是不是自己关心的,回调满天飞,事件多了之后性能也会劣化。
改进思路就是给通知加上类型标识。观察者可以选择自己感兴趣的事件类型订阅,主题通知时也带上事件类型,只有匹配的观察者才会被调用。
type EventType = string; interface TypedObserver { onEvent(eventType: EventType, data: unknown): void; } class TypedSubject { private observersMap: Map<EventType, TypedObserver[]> = new Map(); subscribe(eventType: EventType, observer: TypedObserver): void { if (!this.observersMap.has(eventType)) { this.observersMap.set(eventType, []); } this.observersMap.get(eventType)!.push(observer); } unsubscribe(eventType: EventType, observer: TypedObserver): void { const list = this.observersMap.get(eventType); if (!list) return; this.observersMap.set(eventType, list.filter(item => item !== observer)); } notify(eventType: EventType, data: unknown): void { const list = this.observersMap.get(eventType); if (!list) return; list.forEach(observer => observer.onEvent(eventType, data)); } }这里subscribe和notify都增加了一个事件类型参数,订阅者只对自己关心的事件感兴趣。这种形态其实更接近日常开发中常见的事件总线/EventEmitter样式了。
3.3 通用的 EventEmitter 实现
到了这一步,你会发现我在写的是一个通用的发布订阅工具。与其每个业务场景各写一套,不如封装成一个复用性强的EventEmitter。Node.js 里的EventEmitter、浏览器里的EventTarget都是这种形态的典型代表。我自己常用的版本长这样:
type Listener = (...args: any[]) => void; class EventEmitter { private listenersMap: Map<string, Listener[]> = new Map(); on(event: string, listener: Listener): () => void { if (!this.listenersMap.has(event)) { this.listenersMap.set(event, []); } this.listenersMap.get(event)!.push(listener); // 返回一个取消订阅函数,方便调用方用完即弃 return () => this.off(event, listener); } once(event: string, listener: Listener): () => void { const wrapper: Listener = (...args) => { this.off(event, wrapper); listener(...args); }; return this.on(event, wrapper); } off(event: string, listener: Listener): void { const list = this.listenersMap.get(event); if (!list) return; const index = list.indexOf(listener); if (index !== -1) { list.splice(index, 1); } } emit(event: string, ...args: any[]): void { const list = this.listenersMap.get(event); if (!list) return; // 复制一份再遍历,避免回调中 on/off 影响当前批次 list.slice().forEach(listener => { listener(...args); }); } removeAll(event?: string): void { if (event) { this.listenersMap.delete(event); } else { this.listenersMap.clear(); } } }几个细节我说一下。on方法返回一个取消订阅函数,这是很多现代框架(比如 React 的useEffect清理机制)的做法,调用方拿到返回值随时可以退订,不用非得保存 listener 的引用。once是特殊的一次性订阅,内部用一个 wrapper 包一层,触发后自动退订。emit里先slice()复制一份再遍历,也是防止观察者在回调里改动原始监听器数组导致遍历异常——这个坑我在后文会仔细讲。
3.4 手写时最容易犯的几个低级错误
趁热打铁,把新手写观察者模式时最常踩的几个错误列出来。
错误一:通知时直接遍历原数组,观察者在回调里退订导致跳过。假设 A、B、C 三个观察者,A 的回调里把自己退订了。如果直接for循环原数组,B 会被跳过,行为不可预期。我上面用slice()或者filter重建数组,就是为了避免这个索引漂移问题。
错误二:回调里抛出异常导致通知中断。这个太常见了。一个观察者处理失败,后面所有观察者全军覆没。你要么在通知循环里逐个 try-catch,要么每个观察者自己保证不抛异常。工程上我更推荐后者,因为前者会吞掉错误,排查问题困难;但为了保险,通知方也应该做一层异常隔离,至少保证一个观察者出错不影响别人。
错误三:忘记退订导致内存泄漏和重复通知。尤其在组件化开发里,组件销毁前必须退订,否则组件虽然不可见了,但它注册的观察者还在列表里,下一次通知照样触发,轻则多余计算,重则操作已销毁的 DOM 报错。这个我会在下一章专门展开讲。
4. 观察者模式实战中的坑:泄漏、死循环与逃逸的异常
写到这里,如果你已经能熟练写一个 EventEmitter,那恭喜你,基础版已经稳了。但真到了实际项目里,观察者模式会让你的代码"活"起来,活起来就意味着会出一些静态逻辑里想不到的问题。这一章是我的实战踩坑记录,每一条都是真金白银换来的。
4.1 订阅了却不退订,内存泄漏的经典套路
我见过的最典型内存泄漏场景是这样的:在一个单页应用里,页面组件挂载时订阅了某个全局事件,比如用户登录状态变化,但组件销毁时忘了退订。第一次进入页面没问题,第二次进入,事件触发时旧组件的回调还在执行,新组件又注册了一个,回调越攒越多。页面进出十次,同一个事件触发了十个组件的刷新逻辑,其中九个都是已经销毁的"僵尸组件"。
更隐蔽的是闭包引用。如果订阅回调里引用了组件内部的变量、DOM 节点或者大对象,退订不及时导致这些对象一直被回调持有,垃圾回收根本收不掉它们。这类泄漏往往不直观,等你发现内存占用一路上涨的时候,已经很难定位是哪一次订阅漏了。
我的习惯是:每一个on都要配套一个off,最好用返回的取消订阅函数,把它放进组件/模块的销毁流程里。在 React 里就是useEffect的 cleanup,在 Vue 里就是onUnmounted,在 Node 服务里就是请求结束或进程退出前的清理钩子。哪怕暂时只有一个监听器,也要养成立刻成对写好的习惯,不然后面一定会补漏。
class Component { private unsubscribe: () => void; constructor(eventBus: EventEmitter) { this.unsubscribe = eventBus.on('user.login', this.handleLogin); } destroy() { this.unsubscribe(); // 销毁时退订 } }4.2 回调顺序依赖:看似没问题,改一处崩一片
观察者模式天然是"顺序敏感"的。虽然设计上观察者之间应该是独立的,但现实中总会出现隐式依赖。比如 A 观察者负责更新用户余额,B 观察者负责更新余额变动动画,如果 B 先于 A 执行,就会读到旧余额,动画数据对不上。
这种问题的麻烦在于,它不是每次都能复现,依赖的是订阅顺序这种看不见摸不着的因素。等到某个观察者提前订阅或者延迟订阅,顺序一变,bug 就冒出来了。
我的处理原则是:观察者之间不要共享可变状态,各观察者需要的数据尽量通过事件参数一次性传够。如果确实存在先后关系,那就应该显式地拆成两个不同事件发出,让需要先执行的在"前事件"里处理,后执行的监听"后事件",而不是指望订阅顺序碰巧符合预期。
4.3 异常逃逸:一个观察者抛异常,整个链路中断
前文简单提了一句,这里说一个真实事故。有一年我做一个实时消息推送模块,线上突然出现消息积压,排查了半天,最后发现是某个观察者的回调里对数据处理不当,抛出一个 TypeError,导致这个回调后面所有的观察者都没执行,其中就包括最终确认消息已处理的那个。业务侧表现为消息一直停留在未处理状态,越堆越多。
当时我们通知循环里没有任何 try-catch,一个观察者挂掉,整条通知链瘫痪。后来我在通知循环里做了异常隔离:
emit(event: string, ...args: any[]): void { const list = this.listenersMap.get(event); if (!list) return; list.slice().forEach(listener => { try { listener(...args); } catch (err) { // 记录日志,但不中断其他观察者的执行 console.error(`[EventEmitter] 监听器执行失败:${event}`, err); } }); }注意,我并不主张把所有异常都静默吞掉。这里的 try-catch 只是为了隔离,日志必须记全,错误必须能被追踪到。至于观察者内部逻辑的错误,最好还是让它们自己 try-catch 并上报,不要依赖通知方兜底。
4.4 嵌套触发导致的死循环
这个坑坑过我好几次。观察者 A 监听了事件 X,A 的处理逻辑里又触发了事件 Y,而事件 Y 的观察者里又触发了事件 X——如果两个事件互相监听,或者一个事件在自己的回调里再次触发自己,就会形成递归循环,直到栈溢出。
我在代码里会加一个简单的深度保护,比如用计数器限制同一事件在单次调用链里的触发次数,超过阈值直接抛错。但更根本的解决方法是设计层面避免:事件触发逻辑里不要同步地再触发可能回环的事件,尽量把二次触发放到异步队列里,或者直接明确事件之间的依赖无环。
4.5 事件常量的管理:字符串满天飞的隐患
监听和触发用裸字符串,短时间没什么,项目大了问题就来了:触发方写了个'user-login',监听方手误写了个'user-logged-in',两边都不会报错,但事件永远触发不到,排查时你根本不会怀疑是字符串拼写不一致。我在团队里一般会维护一个独立的事件名常量文件,所有触发和监听都引用常量,至少能借助编译期检查规避大部分低级拼写错误。更进一步,可以直接用 TypeScript 的字符串字面量联合类型约束事件名,把"合法事件"范围限制死。
5. 观察者模式的进阶变体和框架里的身影
观察者模式之所以被称为"设计模式之母",是因为它和很多高级机制都有血缘关系。理解了基础版本,你能在无数框架里看到它的影子,这对读懂源码和架构设计都很有帮助。
5.1 发布订阅模式与观察者模式:别傻傻分不清
先澄清一个老生常谈的区分。观察者模式和发布订阅模式(Pub/Sub)经常被混用,但它们其实有一个关键区别:观察者模式中,被观察者直接持有观察者列表,主题通知观察者是直连的;而发布订阅模式中间多了一个事件通道/消息代理,发布者和订阅者彼此不知道对方存在。
如果用前面邮局的类比,观察者模式像是邮局存了订户名单,直接派件;发布订阅模式像是邮局只负责把信交给一个集中的分拣中心,分拣中心再根据规则投递给订阅者。很多框架里实现的 EventEmitter 其实是"发布订阅"形态,不过因为它们都沿用"订阅-通知"的心智模型,日常沟通时很少有人会较真区分。
工程上真正要关心的是:你的场景需不需要这个中间代理。如果只是单个主题对象和几个观察者直连,观察者模式就够用;如果涉及多个发布者、多个订阅者,而且需要灵活路由和过滤,事件总线形态的发布订阅更合适。
5.2 前端里无处不在的观察者模式
浏览器原生就是观察者模式最大的实践现场。addEventListener就是标准的"订阅-通知":你订阅某个 DOM 元素的 click 事件,元素被点击时浏览器负责通知你。React 的合成事件系统也是在这个基础上做了一层封装。
再说 Vue,它的响应式原理核心就是观察者模式的深度定制。Vue 2 用Object.defineProperty拦截数据读取和写入,每个响应式数据都有一个依赖收集器(Dep),组件渲染时会收集自己的 watcher 作为观察者,数据变化时 dep 通知所有 watcher 重新渲染。这段机制的骨架就是观察者模式:数据是主题,watcher 是观察者,区别只是"如何知道哪些观察者关心这条数据"由依赖收集自动完成。
离前端远一点的,游戏引擎里每帧的状态同步、聊天系统的消息推送、Redux 的 store 订阅更新,本质也都是观察者模式的变体。你掌握了这个模式,再看这些框架的文档和源码,会觉得格外亲切。
5.3 用弱引用优化观察者列表
前面提到退订不及时会导致内存泄漏,除了强调"记得退订",还有一种更优雅的处理方向:观察者列表本身不持有强引用。JavaScript 里的WeakRef和FinalizationRegistry提供了一定的能力,不过说实话,在实际业务代码里直接用WeakRef的并不多,一方面是兼容性,另一方面是回收时机不可控,容易造成"观察者已经被回收但业务逻辑还指望它响应事件"的诡异状态。
我的建议是把弱引用当成一种理念而不是具体技术:设计上默认观察者生命周期比主题短,主题不主动延长观察者生命周期,每个观察者都要有清晰的销毁时机。配合主动退订,绝大多数泄漏问题都能规避。
5.4 调度策略:同步通知和异步通知怎么选
通知触发时,是同步立刻执行所有回调,还是推到微任务/宏任务里异步执行?这直接影响程序的行为和性能。同步通知的好处是时序可控,数据变更后立刻能得到所有副作用的完成结果,适合做状态一致性要求高的场景;坏处是如果某个回调特别重,会阻塞主线程。
异步通知的好处是不阻塞主流程,适合大量观察者、或者观察者执行耗时的场景;坏处是时序难控,调用方没法保证"通知返回时所有观察者都已经处理完"。比如 Vue 的 watcher 就是异步批量更新的:数据变化后不会立刻执行所有 watcher 的回调,而是把它们放进一个队列,在下一个 tick 统一 flush,还附带去重功能。这种设计在框架层面非常优秀,业务代码里要不要用,取决于你对时效性的要求。如果只是简单的界面联动,同步通知完全够用。
6. 业务架构中的实操建议:怎么组织事件,怎么设计接口
模式本身不难,难的是在具体的业务架构里怎么安排它。这一章我不讲理论了,全部是我在项目里摸爬滚打出来的操作建议。
6.1 事件的粒度怎么定
这是最容易被忽视的决策。事件设得太粗,比如笼统定义一个"数据更新",结果所有观察者都来监听,每次触发都是全量调用,性能白白浪费,而且观察者内部全在 if 判断"这次更新的数据关不关我事"。事件设得太细,比如把每一个字段变化都拆成一个事件,事件数量爆炸,订阅关系复杂到难以管理,新同事看一眼头都大。
我的经验是:事件粒度跟业务变化的语义对齐。业务上"订单已创建"是一个完整语义,就发一个事件;"订单金额变了"和"订单状态变了"如果响应方不同,就应该分开。拿不准的时候,从响应方的角度反问自己:谁会关心这个变化?他们需要区分具体变化类型吗?如果会、需要,那就拆细一点。
6.2 回调参数怎么设计
观察者模式里传递的数据叫事件载荷(payload)。我在设计 payload 时遵循一个原则:宁可多传不可少传,但也不要直接把整个领域对象甩出去。多传一点数据,观察者不需要自己去查询上下文,性能更好;但直接把整个对象裸传出去,会造成观察者和领域模型深度耦合,而且对象结构一变,观察者跟着全崩。
比较合理的做法是定义一个轻量的事件数据结构,包含事件发生的时间、类型、核心数据以及必要的原对象引用。有些团队会用 DTO(数据传输对象)来固定这些结构,我很推荐,至少在新项目里值得这么做。
interface OrderCreatedEventPayload { orderId: string; userId: string; amount: number; createdAt: number; source: 'app' | 'web' | 'admin'; }这个结构让观察者拿到的信息既完整又收敛,不会因为领域模型内部重构而牵连一片。
6.3 多个观察者的执行顺序真的没法保证吗
前面我强调过不要依赖顺序,但现实有时绕不开。比如日志观察者肯定想最后一个执行,这样能记录到前面所有观察者完成后的最终状态。这个需求用"保证顺序"去硬杠太脆弱,我的做法是支持prepend和append两种注册方式,同时明确告诉团队:这是例外而非默认,正常情况请勿依赖顺序。
实现上,给 EventEmitter 加一个prepend方法,把监听器插到数组头部即可。如果你发现自己频繁需要这个能力,那就该把顺序敏感的观察者拆成独立的事件,或者显式用流程控制把它们编排起来,潜规则越多,项目越难维护。
6.4 命名规范与代码审查中的观察点
最后聊一点团队协作层面的经验。事件名前缀统一,比如用模块名做前缀:order.created、user.login、payment.refunded。这样事件列表一搜全出来,不会杂乱无章。代码审查时我会专门看订阅的地方:有没有在初始化时注册但销毁时没退订的?回调里有没有访问外部可变状态?事件名是不是引用了常量而不是裸字符串?这几个点排查两分钟,能省下后面无数个加班夜。
7. 结合一个完整案例:把观察者模式落到一个购物车模块里
理论铺垫了这么多,也许你更想看到一个完整案例从零到一的过程。我就用一个模拟项目——某电商 Web 应用的购物车模块,来演示观察者模式如何落地。
需求背景是这样的:购物车数量变化后,(1) 页面顶部的购物车图标角标要更新;(2) 购物车列表组件要刷新总价;(3) 推荐位要根据购物车里最新的商品类别调整推荐策略;(4) 如果购物车为空,需要显示一个空状态引导用户去逛逛。这四个响应点完全独立,而且未来还可能增加新的响应点,比如埋点统计、优惠计算。
我直接用 EventEmitter 来做事件中枢。定义事件常量:
const CartEvents = { CHANGED: 'cart.changed', EMPTIED: 'cart.emptied', } as const;购物车数据中心作为发布者,不直接操作任何 UI:
class CartStore { private emitter = new EventEmitter(); private items: CartItem[] = []; addItem(item: CartItem) { this.items.push(item); this.emitter.emit(CartEvents.CHANGED, { items: this.items, totalCount: this.getTotalCount(), }); } clearCart() { this.items = []; this.emitter.emit(CartEvents.EMPTIED); } getTotalCount() { return this.items.reduce((sum, item) => sum + item.count, 0); } // 暴露给 UI 层订阅 onChange(listener: Listener): () => void { return this.emitter.on(CartEvents.CHANGED, listener); } onEmptied(listener: Listener): () => void { return this.emitter.on(CartEvents.EMPTIED, listener); } }各个 UI 组件负责订阅并各自更新:
class CartIcon { private unsubscribe: () => void; constructor(store: CartStore) { this.unsubscribe = store.onChange((payload) => { // 更新角标数字 this.renderBadge(payload.totalCount); }); } destroy() { this.unsubscribe(); } } class CartRecommender { private unsubscribe: () => void; constructor(store: CartStore) { this.unsubscribe = store.onChange((payload) => { const categories = new Set(payload.items.map(item => item.categoryId)); this.refreshRecommendation(categories); }); } destroy() { this.unsubscribe(); } }这个设计让我在扩展时非常舒服。后来产品要求新增一个"购物车满 200 免运费"的提示条,我再写一个ShippingTip组件,订阅CHANGED事件,自己算是否达到包邮门槛,然后更新 UI。购物车核心逻辑一行没改。
在这个过程中有一个真实教训:一开始CartIcon的订阅回调里,我是直接去读store.items的,没有使用 payload 里传过来的数据。结果某次事件触发时机和数据的更新时机没有完全对齐,导致角标显示慢了一拍。后来我统一改为"事件发生时连同当时的数据快照一起传",从此再没有出现类似问题。这也是为什么我坚持在 payload 里带上核心数据而不是让观察者回头自己查——观察者模式的价值不只是解耦调用,它还给数据流动提供了一条清晰的时间轴。
案例讲完,我想说的是,观察者模式真正厉害的地方不在于它有多复杂,而在于它从根本上改变了你组织代码依赖关系的方式。从"一个函数管所有下游"到"每个模块自己决定关心什么",这是软件设计思维上一个不大不小的转变。看你项目里的代码,如果发现某个核心函数天天被各种模块的刷新逻辑修改,也许就是引入观察者模式的好时机。