写在前面
很多前端工程师在面试时能背出观察者模式和发布订阅模式的区别,但在写代码时,却依然写出了满屏的
useEffect依赖地狱,或者用全局 EventBus 制造了难以调试的数据幽灵。“耦合”是架构的大敌,而“通信”是耦合的源头。
架构师的核心任务之一,就是建立一套高效的消息分发机制。这套机制决定了你的应用是像多米诺骨牌一样脆弱(牵一发而动全身),还是像人体神经系统一样灵敏且健壮。
本篇我们将揭开 Vue/MobX 响应式的面纱,探讨 React 为什么正在向细粒度更新(Signals)低头,以及如何正确使用发布订阅模式来解耦巨型应用。
一、 观察者 vs 发布订阅,别再傻傻分不清
虽然这两个模式长得很像,但在架构设计中,它们的应用场景截然不同。
1.1 观察者模式 (Observer Pattern)
- 角色: Subject(被观察者)和 Observer(观察者)。
- 关系: 松耦合,但依然有联系。 Subject 内部维护了一份 Observer 的清单。当 Subject 变化时,它会直接调用 Observer 的
update方法。 - 前端典型:
Vue的响应式系统、MobX、DOM事件监听。 - 潜台词: “我是数据,谁依赖我,我就通知谁。”
1.2 发布订阅模式 (Publish-Subscribe Pattern)
- 角色: Publisher(发布者)、Subscriber(订阅者)和 Broker(调度中心/事件通道) 。
- 关系: 完全解耦。 发布者根本不知道订阅者的存在,订阅者也不知道发布者是谁。它们只认中间的“邮局”。
- 前端典型:
Node.js EventEmitter、Mitt、Webpack Tapable、RxJS。 - 潜台词: “我是广播电台,我只管发信号,谁爱听谁听。”
二、 从脏检查到 Signals (细粒度响应式)
在前端架构中,观察者模式最核心的战场是 状态管理(State Management) 。
2.1 第一代:粗糙的通知
AngularJS (v1) 使用脏检查(Dirty Checking)。任何数据变化都会触发全量检查,这是一种低效的观察者实现。
2.2 第二代:依赖收集 (Dependency Collection)
Vue 2/3 和 MobX 引入了更智能的观察者。
- Getter (订阅): 当组件渲染时,读取了
state.count,组件自动把自己注册为count的观察者。 - Setter (发布): 当
state.count变化时,它精准通知依赖它的组件重新渲染。
2.3 第三代:Signals 的崛起 (当前趋势)
React 的 useState + useEffect 实际上是一种手动的、依赖数组式的观察机制,这导致了严重的 重渲染(Re-render) 性能问题。
于是,SolidJS, Preact, Vue, Angular, 甚至 Svelte 5 全部拥抱了 Signals。 Signals 是观察者模式的极致形态。
// 伪代码:Signals 的魔力
const [count, setCount] = createSignal(0);
// 这是一个观察者 (Effect)
createEffect(() => {
// 自动订阅:因为这里调用了 count()
console.log("The count is", count());
});
setCount(1); // 控制台输出: The count is 1
架构启示: 为什么 Signals 是趋势?因为它实现了 “值级别的绑定” 而非 “组件级别的绑定” 。 在 Signals 架构下,count 变化时,不需要重新运行整个组件函数,只需要更新绑定了 count 的那个 DOM 文本节点。这是性能优化的终局。
三、 发布订阅的陷阱:EventBus 是解耦神器还是维护噩梦?
发布订阅模式非常诱人,因为它能让两个完全不相关的模块通信。比如:
- Header组件 点击了“退出登录”。
- API模块 收到通知,清除 Token。
- Router模块 收到通知,跳转到登录页。
它们互不引用,通过一个 globalEventBus.emit('logout') 搞定。完美?
3.1 滥用的代价
在大型项目中,全局 EventBus 往往会演变成 “意大利面条式的数据流” 。
- 链路隐形: 你在
logout事件上打个断点,根本找不到是哪个文件触发的。 - 类型丢失:
emit('user-update', data),这个data到底长什么样?没人知道。 - 命名冲突: 你的模块发了
init,别人的模块也监听init,结果全乱套了。
3.2 架构师的治理策略
如果你必须使用发布订阅(比如跨 iframe 通信、插件化架构),请遵守以下原则:
-
严格的类型约束 (Typed Events): 不要用
string作为事件名。使用 TypeScript 定义严格的事件映射表。type Events = { 'user:logout': void; 'toast:show': { message: string; type: 'success' | 'error' }; }; const bus = mitt<Events>(); // 强类型约束 -
局部总线优于全局总线: 不要搞一个 App 级别的
bus。如果是一个复杂的表格组件,可以在组件内部创建一个TableEventBus,只负责处理行选、排序、筛选等内部通信。 -
显式的卸载机制: 在 React
useEffect的 cleanup 或 VueonUnmounted中,必须取消订阅。内存泄漏往往就是因为发布订阅模式留下的“僵尸监听器”导致的。
四、 RxJS 与流式思维
如果在观察者模式和发布订阅模式之上还有一个“神”,那就是 RxJS(Reactive Extensions)。
RxJS 将 “一切皆流 (Stream)” 的思想引入前端。它不仅仅是观察数据,它还能对数据流进行变换、过滤、组合。
4.1 解决复杂场景
想象一个搜索框的需求:
- 用户输入时,实时搜索(监听 Input)。
- 防抖 300ms(避免请求过多)。
- 如果输入内容没变,不发请求(distinctUntilChanged)。
- 如果发起了新请求,旧请求还未返回,由于旧请求结果过时,需要丢弃(switchMap)。
用 Promise 或 EventBus 写,你需要写一堆中间变量来记录状态。用 RxJS,就是一条优雅的管道:
fromEvent(input, 'input').pipe(
debounceTime(300),
map(e => e.target.value),
distinctUntilChanged(),
switchMap(value => ajax(`/api/search?q=${value}`))
).subscribe(results => {
render(results);
});
架构定位: RxJS 学习曲线陡峭,在处理 复杂异步编排(如即时通讯 IM、股票 K 线图、复杂表单联动)时,RxJS 是你的核武器。
结语:模式没有好坏,只有适合
观察者模式(响应式)让我们在组件内部享受了自动更新的便利;发布订阅模式(EventBus)让我们实现了跨模块的解耦;RxJS 让我们掌控了时间的维度。
一个优秀的前端架构,通常是 “内部高内聚(用 Signals/Hooks),外部低耦合(用 Pub/Sub)” 的组合。
不想把文章写的过于干燥,只将框架罗列出来,遍地是AI的情况下,想要在框架内细化某一概念,我想是在简单不过的事了。
Next Step: 搞定了通信,我们解决了模块间的“动态联系”。但面对千变万化的业务规则(比如:不同的用户等级有不同的折扣策略,不同的环境要适配不同的 API),如果全是
if-else,代码依然会腐烂。 下一节,我们将探讨如何利用设计模式消除逻辑分支。 请看**《第五篇:灵魂(下)——复杂度的克星:策略、适配器与代理模式的前端实践》**。