前端向架构突围系列模块化 [4 - 4]:前端核心设计模式之观察者与发布订阅

0 阅读6分钟

写在前面

很多前端工程师在面试时能背出观察者模式和发布订阅模式的区别,但在写代码时,却依然写出了满屏的 useEffect 依赖地狱,或者用全局 EventBus 制造了难以调试的数据幽灵。

“耦合”是架构的大敌,而“通信”是耦合的源头。

架构师的核心任务之一,就是建立一套高效的消息分发机制。这套机制决定了你的应用是像多米诺骨牌一样脆弱(牵一发而动全身),还是像人体神经系统一样灵敏且健壮。

本篇我们将揭开 Vue/MobX 响应式的面纱,探讨 React 为什么正在向细粒度更新(Signals)低头,以及如何正确使用发布订阅模式来解耦巨型应用。

unnamed.jpg


一、 观察者 vs 发布订阅,别再傻傻分不清

虽然这两个模式长得很像,但在架构设计中,它们的应用场景截然不同。

1.1 观察者模式 (Observer Pattern)

  • 角色: Subject(被观察者)和 Observer(观察者)。
  • 关系: 松耦合,但依然有联系。 Subject 内部维护了一份 Observer 的清单。当 Subject 变化时,它会直接调用 Observer 的 update 方法。
  • 前端典型: Vue 的响应式系统、MobXDOM 事件监听。
  • 潜台词: “我是数据,谁依赖我,我就通知谁。”

1.2 发布订阅模式 (Publish-Subscribe Pattern)

  • 角色: Publisher(发布者)、Subscriber(订阅者)和 Broker(调度中心/事件通道)
  • 关系: 完全解耦。 发布者根本不知道订阅者的存在,订阅者也不知道发布者是谁。它们只认中间的“邮局”。
  • 前端典型: Node.js EventEmitterMittWebpack TapableRxJS
  • 潜台词: “我是广播电台,我只管发信号,谁爱听谁听。”

二、 从脏检查到 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 往往会演变成 “意大利面条式的数据流”

  1. 链路隐形: 你在 logout 事件上打个断点,根本找不到是哪个文件触发的。
  2. 类型丢失: emit('user-update', data),这个 data 到底长什么样?没人知道。
  3. 命名冲突: 你的模块发了 init,别人的模块也监听 init,结果全乱套了。

3.2 架构师的治理策略

如果你必须使用发布订阅(比如跨 iframe 通信、插件化架构),请遵守以下原则:

  1. 严格的类型约束 (Typed Events): 不要用 string 作为事件名。使用 TypeScript 定义严格的事件映射表。

    type Events = {
      'user:logout': void;
      'toast:show': { message: string; type: 'success' | 'error' };
    };
    const bus = mitt<Events>(); // 强类型约束
    
  2. 局部总线优于全局总线: 不要搞一个 App 级别的 bus。如果是一个复杂的表格组件,可以在组件内部创建一个 TableEventBus,只负责处理行选、排序、筛选等内部通信。

  3. 显式的卸载机制: 在 React useEffect 的 cleanup 或 Vue onUnmounted 中,必须取消订阅。内存泄漏往往就是因为发布订阅模式留下的“僵尸监听器”导致的。


四、 RxJS 与流式思维

如果在观察者模式和发布订阅模式之上还有一个“神”,那就是 RxJS(Reactive Extensions)。

RxJS 将 “一切皆流 (Stream)” 的思想引入前端。它不仅仅是观察数据,它还能对数据流进行变换、过滤、组合

4.1 解决复杂场景

想象一个搜索框的需求:

  1. 用户输入时,实时搜索(监听 Input)。
  2. 防抖 300ms(避免请求过多)。
  3. 如果输入内容没变,不发请求(distinctUntilChanged)。
  4. 如果发起了新请求,旧请求还未返回,由于旧请求结果过时,需要丢弃(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,代码依然会腐烂。 下一节,我们将探讨如何利用设计模式消除逻辑分支。 请看**《第五篇:灵魂(下)——复杂度的克星:策略、适配器与代理模式的前端实践》**。