Skip to content
Robert Yao edited this page Jul 11, 2023 · 18 revisions

在 JavaScript 开发中,观察者(Observer)模式是经常被使用到的设计模式之一,是对应用系统进行抽象的有利手段。在观察者模式中存在两个角色:观察者(Observer)和被观察者(Subject),通常我们更喜欢称之为发布者(Publisher)和订阅者(Subscriber)。它是管理对象及其行为和状态之间关系的得力工具。具体说来,就是可以利用观察者模式对程序中某个对象的状态进行观察,并在其发生改变时能够得到通知。

实现方式

观察者模式要求希望接收到主题通知的观察者(对象)必须订阅内容改变的事件。如下图所示:

observer.png

这种模式在 JavaScript 中有不同的实现方式,subscribers.js 的实现是基于 topic(主题) 的发布订阅(Publish/Subscribe)模式。它使用了一个主题/事件通道,这个通道介于(订阅者)的对象和激活事件(发布者)的对象之间。借助它可以定义应用程序的特定(自定义)事件,这些事件可以传递自定义的参数,参数中包含订阅者所需要的值。它可以发布者与订阅者隔离,这样发布者不需要知道消息在哪里使用,而订阅者也不需要知道发布者。这有助于有机地提高应用程序的整体安全性,也可以很好的避免订阅者和发布者产生(紧密地)依赖关系。

适用场景

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

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

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

优点

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

解耦/松耦合组件

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

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

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

更大的系统范围可见性

发布/订阅模式的简单性意味着用户可以轻松理解应用程序的流程。

该模式还允许创建解耦组件,帮助我们鸟瞰信息流。我们可以准确地知道信息来自哪里以及传递到哪里,而无需在源代码中明确定义来源或目的地。

易于开发

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

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

提高可扩展性和可靠性

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

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

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

可测试性改进

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

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

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

缺点

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

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

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

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

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

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

subscribers.js 的核心实现

subscribers.js 的核心实现实际上主要是3个方法:emit()、on() 和 off(),分别用于发布、订阅和取消订阅。

_subscribers 属性

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

data-mode.png

实际存储数据示例如下:

_subscribers = {
     // 以 topic 主题名称作为 topicName 的实际属性名
     'scrooll:to:method': [
         // 一个 topic 主题下会有多个不同的订阅者信息 
         {
           topic: 'scroll:to:method',
           // callback 是接收到 topic 主题事件后的处理器函数
           callback: () => {
             // scroll the page to position
           },
           // 每个订阅者都有一个 token 属性,
           // 作为自己的唯一身份标识
           token: 'guid-1'
         },
         {
           topic: 'scroll:to:method',
           callback: () => {
             // scroll the page to position
           },
           token: 'guid-2'
         }
     ],
     'sync:anchor': [
         {
             topic: 'sync:anchor',
             callback: () => {
                // hightlight clicked anchor
             },
            token: 'guid-3'
         }
     ]
}

emit() 方法

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

// 所有的订阅者信息
import _subscribers from './_subscribers'
import has from './has'
import _hasDirectSubscribersFor from './_hasDirectSubscribersFor'
import isTypedArray from './utils/isTypedArray'

/**
 * (异步)发布订阅主题信息
 * ========================================================================
 * 主题默认是异步发布的。确保在消费者处理主题时,主题的发起者不会被阻止。
 * ========================================================================
 * @method emit
 * @param {String} topic - (必须)主题名称
 * @param {Object} data - (必须)数据对象
 * @param {Boolean} async - (可选) 是否异步发布
 */
const emit = (topic, data, async = true) => {
  const execute = (topic) => {
    if (!_hasDirectSubscribersFor(topic)) {
      return false
    }

    _subscribers[topic].forEach((subscriber) => {
      // 针对 mqtt 消息服务返回的 Uint8Array 类似的 typed arrays 格式的数据
      // 采用 toString() 方法转化为普通(JSON)字符串
      const message = isTypedArray(data) ? data.toString() : data
      subscriber.callback(message)
    })
  }
  const deliver = () => {
    let subscriber = topic
    let position = topic.lastIndexOf('.')

    while (position !== -1) {
      subscriber = subscriber.substring(0, position)
      position = subscriber.lastIndexOf('.')

      execute(subscriber)
    }

    // 执行 topic 对应的处理器
    execute(topic)
    // 执行特殊 topic:'*'(监听全部消息的发布)
    execute('*')
  }

  if (!has(topic)) {
    return false
  }

  if (async) {
    setTimeout(deliver, 10)
  } else {
    deliver()
  }
}

export default emit

on() 方法

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

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

/**
 * 订阅主题,并给出处理器函数
 * ========================================================================
 * @method on
 * @param {String} topic - (必须)主题名称
 * @param {Function} handler - (必须)主题的处理器函数
 * @return {String|Boolean} - 唯一的 token 字符串,例如:'guid-1'。
 */
const on = (topic, handler) => {
  const token = guid()
  let subject = typeof topic === 'symbol' ? topic.toString() : topic

  if (!isFunction(handler)) {
    return false
  }

  /* istanbul ignore else */
  if (!_subscribers[subject]) {
    _subscribers[subject] = []
  }

  _subscribers[subject].push({
    topic: subject,
    callback: handler,
    token
  })

  return token
}

export default on

off() 方法

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

import has from './has'
import _removeSubscriber from './_removeSubscriber'
import _removeSubscriberByToken from './_removeSubscriberByToken'

/**
 * 取消订阅主题
 * ========================================================================
 * @method off
 * @param {String} topic - (必须)订阅的主题
 * @param {Function|String} [token] - (可选)订阅主题的处理器函数或者唯一 Id 值
 */
const off = (topic, token) => {
  if (!has(topic)) {
    return false
  }

  if (token) {
    _removeSubscriberByToken(token)
  } else {
    _removeSubscriber(topic)
  }
}

export default off