导读
在 JavaScript 开发中,观察者(Observer)模式是经常被使用到的设计模式之一,是对应用系统进行抽象的有利手段。而发布订阅(Publish/Subscribe)模式,是观察者模式常见的一种具体实现,它是管理对象及其行为和状态之间关系的得力工具。
观察者(Observer)模式简介
观察者(Observer)模式中存在两个角色:观察者(Observer)和被观察者(Subject) ,通常我们更喜欢称之为发布者(Publisher)和订阅者(Subscriber) 。
具体说来,就是可以利用观察者模式对程序中某个对象的状态进行观察,并在其发生改变时能够得到通知。观察者模式要求希望接收到主题通知的观察者(对象)必须订阅内容改变的事件。如下图所示:
观察者模式在 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(),分别用于(发布者)发布事件、(订阅者)订阅和取消订阅事件。另外,还需要有一个专门用来存储订阅者信息的属性,这里就取名:_subscribers。
_subscribers 属性
_subscribers 属性(对象)是专门用来存储订阅者信息的,它的数据模型很简单,如下图:
实际存储数据示例如下:
json
{
// update:toolbar 是 topic 主题名称,以事件主题作为属性名
// 用数组收集订阅信息是因为一个 topic 主题会有多个订阅者
'update:toolbar': [
{
// handler 是接收到 topic 主题事件后的处理器函数
handler: () => {
// scroll the page to position
},
// 每个订阅者都有一个 token 属性,
// 作为自己的唯一身份标识
token: 'guid-1'
},
{
handler: () => {
// scroll the page to position
},
token: 'guid-2'
}
]
}
_subscribers 的实现代码如下:
js
/**
* 存储订阅者(主题和处理器的)私有对象
* ========================================================================
* @type {{}}
* @private
*/
const _subscribers = {}
export default _subscribers
publish() 方法
publish() 方法用于(在发布者中)发布或者广播事件,它包含特定的主题 topic 和需要传递给订阅者的数据。
js
// 所有的订阅者信息
import _subscribers from './_subscribers'
/**
* 发布主题信息
* ==========================================================
* @method publish
* @param {String} topic - (必须)主题名称
* @param {Object} data - (必须)需要传递给订阅者的数据
*/
const publish = (topic, data) => {
// 获取 topic 对应的订阅者信息
const subscribers = _subscribers[topic];
// 没有找到主题,或者主题没有任何订阅者信息,则不执行
if (!subscribers || subscribers.length < 1) {
return false;
}
// 一个 topic 会有多个订阅者订阅,
// 所以需要遍历执行所有的订阅者信息。
subscribers.forEach((subscriber) => {
return subscriber.handler(data);
});
};
export default publish
subscribe() 方法
subscribe() 方法用于(在订阅者内)订阅事件特定的主题 topic 事件,并指定触发 topic 事件时回调函数处理器。
js
import _subscribers from './_subscribers'
import isFunction from './utils/isFunction'
import guid from './utils/guid'
/**
* 订阅主题,并给出处理器函数
* ==========================================================
* @method subscribe
* @param {String} topic - (必须)主题名称
* @param {Function} handler - (必须)主题的处理器函数
* @return {String|Boolean} - 唯一的 token 字符串,例如:'guid-1'。
*/
const subscribe = (topic, handler) => {
const token = guid();
// handler 不是函数类型则不执行
if (!isFunction(handler)) {
return false;
}
// 如果还没有 topic 主题,则创建一个 topic 主题
if (!_subscribers[topic]) {
_subscribers[topic] = [];
}
// 往 topic 主题添加订阅信息
_subscribers[topic].push({
handler,
token,
});
return token;
};
export default subscribe
unsubscribe() 方法
unsubscribe() 方法用于取消订阅主题 topic 事件。主题 topic 事件触发时,将不再执行任何业务逻辑。
js
import has from './has'
/**
* 取消订阅主题:如果仅传递 topic 则删除整个 topic 主题,如果还传递了
* token,则仅删除 topic 主题下的相应 token 值的单个订阅信息
* ==========================================================
* @method unsubscribe
* @param {String} topic - (必须)订阅的主题 topic 名称
* @param {String} [token] - (可选)订阅主题的处理器函数或者唯一 Id 值
*/
const unsubscribe = (topic, token) => {
let subscribers
if (!has(topic)) {
return false
}
subscribers = _subscribers[topic]
// 传递了 token
if (token) {
// 则仅删除 topic 主题下的相应 token 值的单个订阅信息
subscribers = subscribers.filter((subscriber) => subscriber.token !== token)
} else {
// 删除整个 topic 主题的订阅信息
delete _subscribers[topic]
}
}
export default unsubscribe
其余工具方法
isFunction()
js
/**
* 判断数据是否为函数类型
* ==========================================================
* @method isFunction
* @param {Function} fn - 需要判断的数据
* @return {Boolean} 返回检测结果:是函数类型,返回 true,否则返回false
*/
const isFunction = (fn) => {
return fn && {}.toString.call(fn) === "[object Function]";
};
export default isFunction
guid()
js
/**
* 生成唯一 id 字符串的函数
* ==========================================================
* @method guid
* @param {String} [prefix] - 生成 id 的前缀字符串
* @return {String} 返回一个表示唯一 id 的字符串
*/
const guid = (() => {
let id = 0;
return (prefix = "guid-") => {
id += 1;
return `${prefix + id}`;
};
})();
has()
js
import _subscribers from './_subscribers'
/**
* 判断是否存在包含 topic 主题的订阅者信息
* ==========================================================
* @method has
* @param {String} topic - (必须)主题名称
* @returns {Boolean}
*/
const has = (topic) => {
return !!_subscribers[topic]
}
广而告之: 如果需要功能更加完善的发布/订阅工具库,不妨去看看我的 subscribers.js 项目。
发布/订阅模式的应用实践
以我的开源项目 outline.js 为例,这个是 outline.js 的应用界面:
调整前
点击左侧导航栏,页面主题文章区域会滚动到对应的标题,而滚动页面主题区域,左侧的导航也会根据滚动位置高亮显示当前标题,右侧的工具栏也会根据滚动距离动态显示工具栏的上下滚动按钮。另外点击工具栏的菜单按钮,还会收起和展开左侧菜单。
当然,不仅仅只有这些交互,这是在我项目未引入发布订阅模式前的一个初略的类图:
这个时候,要实现跨对象间的交互,都是通过子对象向 Outline 对象传递数据,然后由 Outline 对象触发另外一个目标子对象的方法实现的联动。这个交互很像 VUE 项目中子对象实现互动的交互过程。这样处理的缺陷就在于 Outline 对象需要管理所有子类之间的状态(交互数据)。这样会出现对 Outline 的紧耦合,而且 Outline 去维护所有这些子类的状态,会使得 Outline 对象变得复杂无比。
而我之所以分离了这个多个子类,就是希望每个子类管理自己的状态,希望降低复杂度,想解耦。不然我就直接 Outline 一个大而全模块搞定全部功能。这个时候我们的发布订阅(Publish/Subscribe)模式就有了用武之地了。各个模块都通过发布订阅模块相互间订阅和发布消息,直接进行通信。
调整后
我的目标是实现发布者与订阅者的隔离,这样发布者不需要知道消息在哪里使用,而订阅者也不需要知道发布者。这有助于提高应用程序的整体安全性,也可以很好的避免订阅者和发布者产生(紧密地)依赖关系。
来看看调整后的类关系图:
现在所有的模块都是通过发布订阅模块实现互动。每个模块都既是订阅者又是发布者,而且现在他们之间任意两个都可以直接进行通信,只要相互都订阅了对方发送的主题。而不再是只能依赖 Outline 对象做中介了。
js
// $emit() 是 publish() 方法的别名
// $on() 是 subscribe() 方法的别名
// Anchors 模块点击标题后,会发布 toolbar:update 的消息
this.$emit('toolbar:update', {
top,
min,
max
})
// Navigator 模块在点击后也会发布 toolbar:update 的消息
this.$emit('toolbar:update', {
top,
min,
max
})
// Toolbar 模块订阅 toolbar:update 的消息
this.$on('toolbar:update', this.onToolbarUpdate)
这样一来,Outline 模块不用再统一管理所有子模块的状态了。模块之间需要的状态(数据)都是在接收到对方发出的消息时直接获得的,各个模块维护自己需要的状态即可。降低了 Outline 维护所有状态的成本和复杂度,而模块间的独立性和松耦合也得到了有效的保证。基本就达成我引入发布订阅模式的目标了。
对,这个发布订阅模块就是 VUE 中 event bus 的一种实现,功能几乎是一样。它解决了跨组件之间的通信问题。而我这里引入发布订阅模式也是为了解决各个模块间的跨模块的通信问题。
问题和解决办法
不过缺陷也是有的,细心的同学应该发现,我们之前实现的发布订阅模块的_subscribers
对象是没有管理状态的,只保存了主题 topic 和回调函数 handler。这个时候如果需要完整的散落在各个模块中的状态信息,就必须向其它所有模块订阅获取状态的消息。并且我们又新引入了一个订阅发布模块,也算是增加了一些复杂度了。
但这几个问题对于 Outline 项目,没有什么影响。因为除了收集到的段落章节数据需要共享外,没有什么需要共享的状态信息。不用像 VUE 项目中那样再引入一个维护状态 VUEX (复杂)模块。outline.js 中提供了一个 getChapters() 的功能函数就解决了数据共享的问题。
总结
对于在项目中引入(发布/订阅)设计模式,在解决一些问题的同时,都是会带来复杂度增加的问题的,没有十全十美的设计模式。
实际上在 outline.js 项目中,我还为 Toolbar 模块引入了命令行模式,额外添加了Commands 和 Command 模块。以使得 Toolbar 模块能够适应各种命令按钮的轻松接入。
在实际的开发应用中,我们需要分析引入设计模式的利弊。如果利大于弊,那就大胆的使用,享受它带来的便利。