观察者模式vs发布-订阅模式

147 阅读7分钟

前言

作者在学习webpack的过程中,看到了这样的一句话:

网上不少资料将 webpack 的插件架构归类为“事件/订阅”模式,我认为这种归纳有失偏颇。

并且在工作之中,翻看某SDK源码的过程中,也发现了订阅-发布的代码结构,因此决定彻底弄懂观察者模式订阅-发布模式等一系列内容。如果你也有以下疑问,本篇文章会进行解答。

  1. 什么是观察者模式发布-订阅模式)? 观察者模式发布-订阅模式)的出现解决了什么问题?
  2. 观察者模式发布-订阅模式有什么关系?
  3. webpack的插件系统算是观察者模式发布-订阅模式)吗?
  4. 如何去实现一个Event Bus/ Event Emitter

作者在学习技术的时候会比较关注技术的发展历程,它的出现是为了解决什么问题。通过理解技术的发展历程和它要解决的核心问题,不仅能避免「学完就忘」的虚无感,还能建立起对技术本质的认知。

概念

观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个目标对象,当这个目标对象的状态发生变化时,会通知所有观察者对象,使它们能够自动更新。 —— Graphic Design Patterns

在这里我们先把观察者模式发布-订阅模式看作等同,后续会详细讲解区别。

Before

从定义中可以看到观察者模式的核心在于建立对象间一对多的动态依赖关系:目标对象(Subject)负责维护观察者(Observer)列表,并在状态变化时自动触发通知机制,而观察者通过订阅目标对象来实现对状态变化的响应式处理,最终达成高效的松耦合通信。在观察者模式出现之前模块之间的通信可能类似于以下的方式:

// 模块A直接调用模块B的方法
class ModuleA {
  notifyModuleB(data: string) {
    const moduleB = new ModuleB();
    moduleB.handleData(data); // 强依赖ModuleB的存在和接口
  }
}

class ModuleB {
  handleData(data: string) {
    console.log("处理数据:", data);
  }
}
  1. ModuleA中引入ModuleB,首先破坏了ModuleA的封闭性,二者模块**强耦合**。
  2. 如果出现了ModuleC,在ModuleA通知ModuleB的时候也通知一下ModuleC,那么还需要修改ModuleA的代码
  3. 如果观察者较多的话,需要在ModuleA中全部加进来,影响代码可维护性。

总结下来就是:不符合“高内聚低耦合”的设计原则。

在项目的实际开发中,需要在可维护性和开发效率之中找到折中点,因此在开发效率的角度来看,上述类型的代码在业务代码中是比较常见的。

After

经过观察者模式改造之后的代码如下:

// 1. 定义观察者接口 (规范行为)
interface DataObserver {
  onDataReceived(data: string): void;
}

// 2. 事件中心(管理观察者与通知)
class EventCenter {
  private static observers: Map<string, DataObserver[]> = new Map();

  // 订阅事件类型
  static subscribe(eventType: string, observer: DataObserver) {
    if (!this.observers.has(eventType)) {
      this.observers.set(eventType, []);
    }
    this.observers.get(eventType)?.push(observer);
  }

  // 发布事件
  static publish(eventType: string, data: string) {
    const observers = this.observers.get(eventType) || [];
    observers.forEach(observer => observer.onDataReceived(data));
  }
}

// 3. 改造ModuleA为事件发布者
class ModuleA {
  // 不再直接依赖ModuleB,只需触发事件
  notify(data: string) {
    EventCenter.publish("DATA_EVENT", data);
  }
}

// 4. ModuleB实现观察者接口
class ModuleB implements DataObserver {
  constructor() {
    // 主动订阅感兴趣的事件
    EventCenter.subscribe("DATA_EVENT", this);
  }

  // 实现接口定义的方法
  onDataReceived(data: string) {
    this.handleData(data);
  }

  // 原处理方法保持私有
  private handleData(data: string) {
    console.log("处理数据:", data);
  }
}

// 5. 使用示例
const moduleA = new ModuleA();
const moduleB = new ModuleB(); // 自动注册监听

moduleA.notify("测试数据"); // ModuleB会自动处理

经过改造之后的代码,我们可以看出有几点提升:

  1. 模块之间的强依赖 转变为 只依赖事件接口,模块之间的耦合度降低了。
  2. ModuleA的职责是业务逻辑 + ModuleB的实例管理 转变为 业务逻辑+触发事件,模块的内聚性提升了。
  3. 依赖关系固化,修改关系需要改动模块内部编码 转变为 可以通过EventCenter动态管理观察者列表,依赖关系更加灵活
    是什么解决了什么问题这两个问题搞清楚了,接下来就要区分观察者模式发布-订阅模式有什么关系了。

观察者模式 与 发布-订阅模式

GOF23种设计模式有:
创建型模式,共五种:工厂方法模式、抽象工厂模式、单例模式、建造者模式、原型模式。
结构型模式,共七种:适配器模式、装饰器模式、代理模式、外观模式、桥接模式、组合模式、享元模式。
行为型模式,共十一种:策略模式、模板方法模式、观察者模式、迭代子模式、责任链模式、命令模式、备忘录模式、状态模式、访问者模式、中介者模式、解释器模式。

GOF23种设计模式可以看作是设计模式的“一代目”。也就是在初版设计模式中是没有包含发布-订阅模式的,只有观察者模式

异同

观察者模式发布-订阅模式的核心是一样的,都是模块之间通信的解决方案。

在观察者模式下,模块与模块之间仍然在弱耦合,观察者模式通过接口抽象避免了直接依赖具体类(松耦合),但因观察者必须实现特定通知方法(如update),仍存在方法签名的约定式耦合。

发布-订阅发布则是相对于观察者模式在解耦方向上更进一步。通过引进消息中介,将观察者模式的约定式耦合彻底解放,无需依赖Observer接口(约定update方法),通过消息中介将事件进行广播,完全不感知对方的存在。

总结观察者模式发布-订阅模式的内核是一样的,只是后者在解耦方面进行了升级。
从实现上来看,二者存在的差别是发布-订阅模式引入了消息中介进行解耦,观察者模式则是依赖Observer接口。 从核心思想来看,二者的思想本质是一样的。

Webpack插件系统

订阅模式是一种松耦合架构,发布器只是在特定时机发布事件消息,订阅者并不或者很少与事件直接发生交互,举例来说,我们平常在使用 HTML 事件的时候很多时候只是在这个时机触发业务逻辑,很少调用上下文操作。

webpack 的钩子体系是一种强耦合架构,它在特定时机触发钩子时会附带上足够的上下文信息,插件定义的钩子回调中,能也只能与这些上下文背后的数据结构、接口交互产生 side effect,进而影响到编译状态和后续流程。所以把钩子系统认为是发布-订阅模式是有失偏颇的。

EventBus实现

实现其实非常简单,但是有几个点需要格外关注一下,很容易写错:

  1. 常规的onemit方法没啥好说的,要定义一个容器,on是将回调函数放到容器中,emit方法则是根据eventName调用方法,容器根据不同的情况可以有不同的数据结构。
  2. 回调函数传参:一般回调函数都是支持多个参数的,所以应该使用...args
  3. off注销实现:实际上就是注册的时候需要返回id,注销根据id进行注销。
  4. once单次调用实现:在注册的时候通过id区分出持续调用和单次调用的函数,在调用的时候,将单次调用的函数,执行完就从容器中删除掉。

具体代码实现可参考这一篇博客:面试官:请手写一个EventBus,让我看看你的代码能力!EventBus是事件总线的意思,可不是什么事件车。 事件总线 - 掘金

总结

观察者模式通过接口松耦合,发布-订阅引入中介彻底解耦。二者核心为解决通信问题,但解耦层级不同。