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

实现方式

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

pub-sub

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

适用场景

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

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

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

优点

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

解耦/松耦合组件

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

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

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

更大的系统范围可见性

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

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

易于开发

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

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

提高可扩展性和可靠性

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

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

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

可测试性改进

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

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

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

缺点

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

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

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

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

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

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

JavaScript 代码实现

发布订阅模式的核心实现实际上只需要3个方法:emit()on()off(),分别用于发布、订阅和取消订阅。当然除了3个核心方法外,我们还需要一个用来存储订阅者信息的对象,这里我们给它起名叫:_subscribers。

_subscribers 属性

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

_subscribers

实际存储数据示例如下:

emit() 方法

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

这里的 $emit() 方法实现,默认是采用异步方式发布消息的。这是为了确保在消费者处理主题时,主题的发起者不会被阻止。 当然 emit() 方法也支持同步方式(浏览器环境下比较适合)发布主题。

on() 方法

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

on() 方法会返回一个唯一的 token 值,一遍通过它来查询这条订阅者信息,或者取消订阅。

off() 方法

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

可以看到,off() 方法就提供了通过 token 信息取消订阅的处理逻辑。

实际应用

这里我就以最近才重构的 outline.js 为例子。以下是重构前后的一个对比图:

重构前

在重构前 outline.js(2.0.1) 是典型的大而全的构造函数。将 Anchors 生成,侧滑弹窗、文章导航菜单和工具栏4大功能都集成到了一起。这导致 Outline 一个类有 1000 多行代码。模块间的状态更新,也都是基于 DOM 事件驱动的。就导致那4大功能模块,存在严重的依赖(耦合)。而且这么一个超级大的类几乎无法编写单侧代码。

重构后

采用发布订阅模式重构后的 Outline 对象被拆分成了 5 个独立模块:Outline、Anchors、Drawer、Chapters 和 Toolbar。Outline 由 1 个大类分解成了5个模块,可以看到,Outline 已经不再管理子模块的状态了,转而由子模块各自管理各自的状态。各个模块间的状态更新,全部通过发布订阅模式的订阅消息,并在监听到订阅主题事件后,各自根据消息传递状态变更信息调整各自的状态。

这样调整后的优势很明显:解耦/松耦合组件,同时提高可扩展性和可靠性。而每个子模块的功能更单一,代码也越少,从而可测试性改进的优势也很明显。当然,较小系统中不必要的复杂性也是会出现的,因为要维护更多的类(模块)。

实现细节

前面从整体介绍了 outline.js 中应用发布订阅模式的设计方案。下面来看看具体是如何实现的。这里我以最典型的 Toolbar 模块为例介绍具体的实现细节。

Toolbar 模块发布按钮指令

outline.js 重构后,Toolbar 模块的职责就很清晰,它只负责绘制工具栏,控制工具栏的隐藏显示和具体某个按钮的隐藏显示和是否可用。具体的每个按钮点击后做什么,则不用关心。它通过发布每个按钮的命令指令,让命令指令的订阅模块执行具体的业务逻辑。

_renderToolbar() {
    const placement = this.attr('placement')
    const homepage = this.attr('homepage')
    const count = this.count()
    const UP = {
      name: 'up',
      icon: 'up',
      size: 20,
      action: {
        type: 'click',
        handler: 'toolbar:action:up'
      }
    }
    const HOME = {
      name: 'homepage',
      icon: 'homepage',
      size: 20,
      link: homepage
    }
    const MENU = {
      name: 'menu',
      icon: 'menu',
      size: 18,
      action: {
        type: 'click',
        handler: 'toolbar:action:toggle'
      }
    }
    const DOWN = {
      name: 'down',
      icon: 'down',
      size: 20,
      action: {
        type: 'click',
        handler: 'toolbar:action:down'
      }
    }
    const buttons = []

    buttons.push(UP)
    if (homepage) {
      buttons.push(HOME)
    }
    if (count > 0) {
      buttons.push(MENU)
    }
    buttons.push(DOWN)

    this.toolbar = new Toolbar({
      placement,
      buttons: buttons
    })

    return this
  }

可以看到,outline.js 在初始化 Toolbar 模块的时候,按钮的已经没有具体的执行方法了,都是 ‘toolbar:action:down’ 这样的指令名(消息主题)。当然,Toolbar 模块在给每个按钮绑定事件的时候,也没有了执行具体操作的事件处理器,转而只是对外发布在初始化时配置好的消息指令了。

import publish from './utils/observer/emit'

addListeners() {
    const buttons = this.attr('buttons')
    const $el = this.$el

    if (!buttons || buttons.length < 1) {
      return this
    }

    buttons.forEach((button) => {
      const action = button.action
      const disabled = this.disabled
      let type
      let listener
      let context
      let command

      if (disabled) {
        return false
      }

      if (action) {
        listener = action.handler
        if (isString(listener)) {
          // 获取配置的指令
          command = listener
          // 事件处理器
          action.handler = function () {
            // 没有实际的操作,只是通过 publish(emit)方法发布消息(指令) 
            publish(command, button.name)
          }
          listener = action.handler
        }

        type = action.type || 'click'
        context = action.context
      }

      if (isFunction(listener)) {
        // 指令的发布,还是通过每个按钮配置的 DOM 事件处理器触发的
        on($el, `.${button.name}`, type, listener, context || this, true)
      }
    })

    return this
  }

可以看到,Toolbar 模块内的任何按钮都没有任何实际的业务逻辑操作,完全是通过发布订阅模式发布按钮的操作指令,让具体的消息订阅者来执行具体的业务逻辑操作。这就是前文提到的“发布/订阅模式可以用作桥梁,通过管理组件间通信来实现使用不同语言构建的组件之间的通信。”。

Outline 模块订阅按钮指令

Toolbar 模块如何发布命令指令我们已经知道了,再来看看谁来订阅这些指令,以及接受到指令后都做了些什么?在 outline.js 这个小小的系统中,我设计的是由 Outline 模块负责订阅 Toolbar 模块如何发布命令指令。

import subscribe from './utils/observer/on'

addListeners() {
  subscribe('toolbar:update', this.onToolbarUpdate, this)
  subscribe('toolbar:action:up', this.onScrollTop, this)
  subscribe('toolbar:action:toggle', this.onToggle, this)
  subscribe('toolbar:action:down', this.onScrollBottom, this)
  return this
}

来看看 onScrollTop() 都做了些什么?

onScrollTop() {
    this.toTop()
    return this
}

toTop() {
    const toolbar = this.toolbar
    const chapters = this.chapters
    const count = this.count()
    const afterTop = () => {
      toolbar.hide('up')
      toolbar.show('down')

      if (count > 0) {
        chapters.highlight(0)
      }
      chapters.playing = false
    }

    chapters.playing = true
    this.scrollTo(0, afterTop)

    return this
}


通过代码我们可以看到,在点击向上滚动按钮后,会执行 scrollTo(0, afterTop) 方法,让页面滚动到顶端。并且在滚动完成后,会让文章导航菜单的第一个标题链接高亮(显示选中状态),然后还会控制向上和向下按钮是否可见。

在这一系列的业务逻辑中,Toolbar 模块能够做的只有控制向上和向下按钮是否可见。而且设想以下,一个工具栏会有多个按钮,我们不可能一次性的将所有按钮的业务逻辑都在初始化的时候都实现了或者收集所有命令的具体执行模块,然后触发模块的具体方法实现按钮的任务。这样很明显会让 Toolbar 模块与其它模块产生严重的依赖(紧耦合)或者让 Toolbar 变成全能的“上帝”。这是不现实的。

借助发布订阅模式,就很好的解决上面的问题。Toolbar 只要发布消息,剩下的事就交给可以完成任务的模块去处理。让我们可以创建更加模块化、健壮且安全的软件组件或模块。

总结

没有一个设计模式是完美的,发布订阅模式也一样,不能期待应用一个设计模式就能够解决开发中的所有问题。我们需要根据项目的实际情况选择使用合适的设计模式,尽量发挥使用设计模式带来的好处。让我们的程序或者系统变得更加健壮。