JavaScript 设计模式 - 发布/订阅模式

199 阅读11分钟

导读

在 JavaScript 开发中,观察者(Observer)模式是经常被使用到的设计模式之一,是对应用系统进行抽象的有利手段。而发布订阅(Publish/Subscribe)模式,是观察者模式常见的一种具体实现,它是管理对象及其行为和状态之间关系的得力工具。

观察者(Observer)模式简介

观察者(Observer)模式中存在两个角色:观察者(Observer)和被观察者(Subject) ,通常我们更喜欢称之为发布者(Publisher)和订阅者(Subscriber)

具体说来,就是可以利用观察者模式对程序中某个对象的状态进行观察,并在其发生改变时能够得到通知。观察者模式要求希望接收到主题通知的观察者(对象)必须订阅内容改变的事件。如下图所示:

1.png

观察者模式在 JavaScript 中有2种常见的不同的实现方式:观察者(Observer)模式和发布/订阅(Publish/Subscribe)模式(本文主要介绍的就是发布/订阅模式)。

发布/订阅模式简介

发布/订阅模式使用了一个主题/事件通道,这个通道介于(订阅者)的对象和激活事件(发布者)的对象之间。借助它可以定义应用程序的特定(自定义)事件,这些事件可以传递自定义的参数,参数中包含订阅者所需要的值。

它可以实现发布者与订阅者的隔离,这样发布者不需要知道消息在哪里使用,而订阅者也不需要知道发布者。这有助于提高应用程序的整体安全性,也可以很好的避免订阅者和发布者产生(紧密地)依赖关系。

发布/订阅模式的优点

发布/订阅模式鼓励我们努力思考应用程序不同部分之间的关系。帮助我们识别包含直接关系的层,并可以用目标集和观察者进行替换。

解耦/松耦合组件

发布/订阅模式允许你轻松分离通信和应用程序逻辑,从而创建隔离的组件。它地优势就是:

  • 创建更加模块化、健壮且安全的软件组件或模块
  • 提高代码质量和可维护性

使用发布/订阅模式背后的一个重要原因是我们可以有效地保证相关对象之间的一致性,而无需使对象之间产生紧密地耦合。这大大提高了程序的灵活性,它是 JavaScript 开发中用于设计解耦合性系统的最佳工具之一。

更大的系统范围可见性

发布/订阅模式的简单性意味着用户可以轻松理解应用程序的流程。该模式还允许创建解耦组件,帮助我们鸟瞰信息流。我们可以准确地知道信息来自哪里以及传递到哪里,而无需在源代码中明确定义来源或目的地。

易于开发

由于发布/订阅模式不依赖于编程语言、协议或特定技术,因此可以使用任何编程语言轻松地将任何受支持的消息代理集成到其中。此外,发布/订阅模式可以用作桥梁,通过管理组件间通信来实现使用不同语言构建的组件之间的通信。

这使得它可以轻松地与外部系统集成,而无需创建促进通信的功能或担心安全隐患。我们可以简单地向某个主题发布消息,并让外部应用程序订阅该主题,从而无需与底层应用程序直接交互。

提高可扩展性和可靠性

这种消息传递模式被认为是有弹性的——我们不必预先定义一定数量的发布者或订阅者。可以根据用途将它们添加到所需的主题中。

通信和逻辑之间的分离还使故障排除变得更加容易,因为开发人员可以专注于特定组件,而不必担心它会影响应用程序的其余部分。

发布/订阅还允许更改消息代理架构、过滤器和用户而不影响底层组件,从而提高了应用程序的可扩展性。对于发布/订阅模式,如果消息格式兼容,即使复杂的架构更改,新的消息传递实现也只需更改主题即可。

可测试性改进

通过整个应用程序的模块化,可以针对每个模块进行测试,从而创建更加简化的测试管道。通过针对应用程序的每个组件进行测试,大大降低了测试用例的复杂性。

发布/订阅模式还有助于轻松了解数据和信息流的来源和目的地。它对于测试与以下相关的问题特别有帮助:

  • 数据损坏
  • 格式化
  • 安全

发布/订阅模式的缺点

发布订阅模式虽然有很多优点,但它并不是满足所有要求的最佳选择。接下来,我们简单看一下这种模式的一些缺点。

订阅者和发布者之间的动态关系过于松散

发布订阅模式的缺点也原至于它的有点,通过从订阅者中解耦发布者,它有时很难保证应用程序的特定部分按照我么预期的情况运行。例如,订阅者在接收到通知后,执行一些非常复杂的业务逻辑导致执行崩溃而无法正常运行,由于系统的解耦合性,发布者是无法得知订阅者的执行情况的。

另外,由于订阅者非常忽视彼此的存在,并对变化发布者的成本视而不见(创建发布者对象是有性能损耗的)。订阅者和发布者之间的动态关系,导致也很难跟踪依赖更新。

较小系统中会产生不必要的复杂性

发布/订阅需要正确配置和维护。如果可扩展性和解耦性不是应用程序的重要因素,那么实施发布订阅模式将浪费资源,并导致小型系统不必要的复杂性。

发布/订阅模式的适用场景

发布订阅模式非常适用于 JavaScript 生态系统,特别是在浏览器这种环境。如果你希望可以将人的行为应用程序的行为分开,创建基于事件驱动的应用或系统,发布订阅模式正可以派上用场。

JavaScript 中跨模块通信

这里解释一下什么是人的行为?它指的是用户操作 DOM 触发的行为。在浏览器(JavaScript )环境下,实现的也是事件驱动。但它是将 DOM 事件作为脚本编程的主要交互 API。即便 DOM3 中实现了 CustomEvent(自定义事件),也是被限制在 DOM 上使用,对于对象之间的事件互动则无能为力。因为 JavaScript 中并没有提供(核心)对象之间的(自定义)事件系统。

前文提到,发布/订阅模式实现了一个主题/事件通道,这个通道介于(订阅者)的对象和激活事件(发布者)的对象之间。允许程序代码定义应用程序的特定(自定义)事件,也就是它可以帮助我们实现应用程序的行为(自定义事件) 在 JavaScript 中实现跨模块通信。从而摆脱只能通过 DOM 触发事件的束缚,创建基于事件驱动的应用或系统。

VUE 项目中跨组件通信

另外一个我们常用到发布/订阅模式的场景就是我们希望在 VUE 项目中实现跨组件间的通信。通常情况下,在 VUE 项目中,我们只能通过父组件向子组件分发状态信息,实现组件逐级通信。

而子组件如果要将它的状态传递给其它的组件,则只能通过自定义事件逐级向上传递状态信息,直到到达可以向其它子组件分发状态的父组件,然后再由父组件逐级向下分发状态。这样这个父组件需要管理各个子组件的状态。因此对父组件有较强的依赖耦合,并且管理各个子组件的状态也是增加了复杂度。

这个时候就可以借助发布/订阅模式实现,在 VUE 中就是我们熟知的 Event Bus 来实现跨组件通信甚至是跨模块通信。

发布/订阅模式的核心 API 实现

根据前文介绍的发布/订阅模式的特征,可以总结出其核心实现实际上主要是3个方法:publish()、subscribe() 和 unsubscribe(),分别用于(发布者)发布事件、(订阅者)订阅和取消订阅事件。另外,还需要有一个专门用来存储订阅者信息的属性,这里就取名:observers。

observers 属性

observers 属性(对象)是专门用来存储订阅者信息的,它的数据模型很简单,如下图:

model.png

实际存储数据示例如下:

{
     // update:toolbar 是 topic 主题名称,以事件主题作为属性名
     // 用数组收集订阅信息是因为一个 topic 主题会有多个订阅者 
     'update:toolbar': [
         {
           // handler 是接收到 topic 主题事件后的处理器函数
           handler: () => {
             // scroll the page to position
           },
           // 每个订阅者都有一个 token 属性,
           // 作为自己的唯一身份标识
           token: 'guid-1',
           // 某个模块的执行上下文
           context: module
         },
         {
           handler: () => {
             // scroll the page to position
           },
           token: 'guid-2',
           // 某个模块的执行上下文
           context: module
         }
     ]
}

observers 的实现代码如下:

/**
 * 存储订阅者(主题和处理器的)私有对象
 * ========================================================================
 * @type {{}}
 * @private
 */
const observers = {}

export default observers

publish() 方法

publish() 方法用于(在发布者中)发布或者广播事件,它包含特定的主题 topic 和需要传递给订阅者的数据。

// 所有的订阅者信息
import observers from './observers'

/**
 * 发布主题信息
 * ==========================================================
 * @method publish
 * @param {String} topic - (必须)主题名称
 * @param {Object} data - (必须)需要传递给订阅者的数据
 */
const publish = (topic, data) => {
  // 获取 topic 对应的订阅者信息
  const observer = observers[topic];

  // 没有找到主题,或者主题没有任何订阅者信息,则不执行
  if (!observer || observer.length < 1) {
    return false;
  }

  // 一个 topic 会有多个订阅者订阅,
  // 所以需要遍历执行所有的订阅者信息。
  observer.forEach((subscription) => {
    return subscription.handler.call(subscription.context || subscription, data);
  });
};

export default publish

subscribe() 方法

subscribe() 方法用于(在订阅者内)订阅事件特定的主题 topic 事件,并指定触发 topic 事件时回调函数处理器。

import observers from './observers'
import isFunction from './utils/isFunction'
import guid from './utils/guid'

/**
 * 订阅主题,并给出处理器函数
 * ==========================================================
 * @method subscribe
 * @param {String} topic - (必须)主题名称
 * @param {Function} handler - (必须)主题的处理器函数```
 * @param {Object} [context] - (可选)指定 this 执行上下文
 * @return {String|Boolean} - 唯一的 token 字符串,例如:'guid-1'。
 */
const subscribe = (topic, handler, context) => {
  const token = guid();

  // handler 不是函数类型则不执行
  if (!isFunction(handler)) {
    return false;
  }

  // 如果还没有 topic 主题,则创建一个 topic 主题
  if (!observers[topic]) {
    observers[topic] = [];
  }

  // 往 topic 主题添加订阅信息
  observers[topic].push({
    handler,
    token,
    context
  });

  return token;
};

export default subscribe

unsubscribe() 方法

unsubscribe() 方法用于取消订阅主题 topic 事件。主题 topic 事件触发时,将不再执行任何业务逻辑。

import observers from './observers'
import has from './has'

/**
 * 取消订阅主题:如果仅传递 topic 则删除整个 topic 主题,如果还传递了
 * token,则仅删除 topic 主题下的相应 token 值的单个订阅信息 
 * ==========================================================
 * @method unsubscribe
 * @param {String} topic - (必须)订阅的主题 topic 名称
 * @param {String} [token] - (可选)订阅主题的处理器函数或者唯一 Id 值
 */
const unsubscribe = (topic, token) => {
  let observer
  
  if (!has(topic)) {
    return false
  }
  
  observer = observers[topic]

  // 传递了 token 
  if (token) {
    // 则仅删除 topic 主题下的相应 token 值的单个订阅信息
    observers[topic] = observer.filter((subscription) => subscription.token !== token)
  } else {
    // 删除整个 topic 主题的订阅信息
    delete observers[topic]
  }
}

export default unsubscribe

其余工具方法

isFunction()

/**
 * 判断数据是否为函数类型
 * ==========================================================
 * @method isFunction
 * @param {Function} fn - 需要判断的数据
 * @return {Boolean} 返回检测结果:是函数类型,返回 true,否则返回false
 */
const isFunction = (fn) => {
  return fn && {}.toString.call(fn) === "[object Function]";
};

export default isFunction

guid()

/**
 * 生成唯一 id 字符串的函数
 * ==========================================================
 * @method guid
 * @param {String} [prefix] - 生成 id 的前缀字符串
 * @return {String} 返回一个表示唯一 id 的字符串
 */
const guid = (() => {
  let id = 0;

  return (prefix = "guid-") => {
    id += 1;

    return `${prefix + id}`;
  };
})();

has()

import observers from './observers'

/**
 * 判断是否存在包含 topic 主题的订阅者信息
 * ==========================================================
 * @method has
 * @param {String} topic - (必须)主题名称
 * @returns {Boolean}
 */
const has = (topic) => {
  return !!observers[topic]
}

广而告之: 如果需要功能更加完善的发布/订阅工具库,不妨去看看我的 subscribers.js 项目。

总结

对于在项目中引入(发布/订阅)设计模式,在解决一些问题的同时,都是会带来复杂度增加的问题的,没有十全十美的设计模式。

实际上在 outline.js 项目中,我还为 Toolbar 模块引入了命令行模式,额外添加了Commands 和 Command 模块。以使得 Toolbar 模块能够适应各种命令按钮的轻松接入。

在实际的开发应用中,我们需要分析引入设计模式的利弊。如果利大于弊,那就大胆的使用,享受它带来的便利。