[JavaScript]设计模式 -- 观察者模式 and 发布订阅模式

212 阅读11分钟

观察者模式介绍

定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。

图1 观察者模式逻辑示意图(图片来源:【原理】851- 从观察者模式到响应式的设计原理_pingan8787的博客-CSDN博客

观察者模式应用场景

前端应用场景 -- Vue 响应式

Vue 框架中采用的响应式原理其中在监听数据变化的过程中会执行其被依赖的所有“副作用”,这就是一种观察者模式(其实不只是 Vue ,当前主流的前端框架其响应式设计原理都采用到了这一模式)。

图2 Vue 响应式原理示意图(图片来源:www.jianshu.com/p/40f4149e1…
以Vue为例,在 Vue 3 中采用了 Proxy 来创建响应式对象,仅将 getter / setter 用于 ref。以下为其实现的伪代码:(来源:深入响应式系统 | Vue.js

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      track(target, key)
      return target[key]
    },
    set(target, key, value) {
      target[key] = value
      trigger(target, key)
    }
  })
}

function ref(value) {
  const refObject = {
    get value() {
      track(refObject, 'value')
      return value
    },
    set value(newValue) {
      value = newValue
      trigger(refObject, 'value')
    }
  }
  return refObject
}

其中在追踪数据的getter时调用了track()方法,在track()内部,会检查当前是否有正在运行的副作用。如果有,我们会查找到一个存储了所有追踪了该属性的订阅者 Set,然后将当前这个副作用作为新订阅者添加到该 Set 中。副作用订阅将被存储在一个全局的WeakMap<target, Map<key, Set<effect>>>数据结构中。如果在第一次追踪时没有找到对相应属性订阅的副作用集合,它将会在这里新建。
【存储的副作用集合都是由数据主体统一管理的,这些副作用都依赖于数据主体,这些副作用就是观察者,数据对象就是被观察主体】

// 这会在一个副作用就要运行之前被设置
// 我们会在后面处理它
let activeEffect

function track(target, key) {
  if (activeEffect) {
    const effects = getSubscribersForProperty(target, key)
    effects.add(activeEffect)
  }
}

其中在追踪数据的setter时调用了trigger()方法,在trigger()中,我们会再查找到该属性的所有订阅副作用,并执行它们。
【存储的副作用在观察到数据对象变化时(setter),即会接收通知被立即触发】

function trigger(target, key) {
  const effects = getSubscribersForProperty(target, key)
  effects.forEach((effect) => effect())
}

观察者模式优缺点

优点

  • 观察者和被观察者是抽象耦合的。
  • 建立一套触发机制。

缺点

  • 如果一个被观察者对象有很多的直接和间接的观察者的话,将所有的观察者都通知到会花费很多时间。
  • 如果在观察者和观察目标之间有循环依赖的话,观察目标会触发它们之间进行循环调用,可能导致系统崩溃。

发布-订阅模式介绍

发布-订阅模式是指应用程序(发布者,publisher)能够以异步方式向多个感兴趣的接收者(订阅者,subscriber)公布消息/事件,而无需将发布者与订阅者耦合。发布者不会将消息直接发送给特定的订阅者,而是通过消息通道广播出去,让订阅者能够接收订阅。

图3 发布订阅模式逻辑示意图(图片来源:发布者-订阅者模式 - Azure Architecture Center

发布-订阅模式适用场景

分布式的应用程序中,在发生事件时,系统组件通常需要向其它组件提供信息。此时,系统组件作为发布者通过输入一个消息传送通道的方式发布信息,其它组件作为订阅者都有一个信息输出通道用来接收信息。
在以下情况下使用此模式:

  • 应用程序需要将信息广播到大量的使用者。
  • 应用程序需要与一个或多个独立开发的、可能使用不同平台、编程语言和通信协议的应用程序或服务通信。
  • 应用程序无需使用者的实时响应,即可将信息发送到使用者。
  • 集成的系统旨在支持其数据的最终一致性模型。
  • 应用程序需要向多个使用者传送信息,而这些使用者的可用性要求或运行时间计划可能与发送者不同。

在以下情况下,此模式可能不起作用:

  • 应用程序只有少量的几个使用者,而这些使用者所需的信息与生成方应用程序截然不同。
  • 应用程序需要与使用者进行近实时的交互。

前端应用场景1 -- DOM事件

在经常使用的 DOM 事件的过程中就采用到了发布-订阅模式。例如下面的document.body.click()事件,在此前需要订阅者(handleClick)提前订阅该事件,在其内部,由事件调度中心统一管理这些订阅函数,可以通过addEventListenerremoveEventListener来添加和移除订阅函数,订阅函数与发布者是完全解耦合的状态。在click事件触发时,document.body作为发布者会将该事件消息(ClickEvent)发布出去,此时订阅函数handleClick将订阅到此次事件消息。

const handleClick = (event: ClickEvent) => {
	console.log(event)
}
// 增加订阅者`handleClick`,在`document.body`触发点击事件时,将订阅到其发布的事件消息
document.body.addEventListener('click', handleClick)
// 点击事件触发时,`document.body`将事件消息发布出去
document.body.click()
// 移除订阅者`handleClick`,之后handleClick将不再订阅其消息
document.body.removeEventListener('click', handleClick)

基于TypeScript手动实现一个事件监听器

利用这种发布-订阅模式,可以很好的实现对一些异步事件的监听。在([TypeScript]实践--手动封装一个事件监听器(基于对发布订阅模式的理解) - 掘金)这篇文章中本人基于TypeScript手动实现了一个简易的事件监听器,感兴趣的可以前往。

前端应用场景2 -- Vue 数据绑定

在前面提到的 Vue响应式 的基础上,Vue数据绑定v-bind其实现的原理实际上是Compiler通过订阅数据变化而异步地更新视图的过程。Vue数据绑定视图更新的实现过程如下:

  1. 监听数据变化触发 Dep 对象 notify 方法执行;
  2. 继而执行 Watcher 对象的 update 方法将对象保存到异步任务队列 Queue 中;
  3. 调用 nextTick 方法,执行异步任务;
  4. 在异步任务的回调中,对 Queue 中的 Watcher 进行排序,然后执行对应的 DOM 更新。

在这其中,异步任务队列作为一个调度中心,将监测 dep 对象 notify 的触发,收集其所有相关的 watcher 执行 update 方法并将 watcher 存储在任务队列中,在进入下一次事件循环开始时发布给订阅者(watcher),组织 watcher 的订阅顺序,并触发这些 watcher 来执行 DOM 更新(watcher.run())。
源码传送门👉 Vue observer 源码

图4 Vue响应式视图更新原理(即数据绑定)示意图(图片来源:www.jianshu.com/p/40f4149e1…

前端应用场景3 -- Vue 的父子组件通信 on/on/emit

这个实现方式和事件监听器类似,父组件通过$on来增加事件订阅者,子组件通过$emit来发布事件消息。
源码传送门👉 Vue instance events 源码

发布-订阅模式的优势

  • 解耦仍然需要通信的子系统。 可以独立管理子系统,即使一个或多个接收者处于脱机状态,也能正确管理消息。
  • 提高可伸缩性,改善发送者的响应能力。 发送者可以快速将一条消息发送到输入通道,然后恢复其核心处理责任。 消息传送基础结构负责确保将消息传送到感兴趣的订阅者。
  • 它提高了可靠性。 异步消息传送可以帮助应用程序在负载增大的情况下继续保持平稳运行,并更有效地处理间歇性故障。
  • 它允许延迟或计划的处理。 订阅者可以等到在非高峰期拾取消息,或者可以根据特定的计划路由或处理消息。
  • 这样可以简化使用不同平台、编程语言或通信协议的系统之间的集成,以及本地系统与云中运行的应用程序之间的集成。
  • 它简化了整个企业中的异步工作流。
  • 它改善了可测试性。 在执行整个集成测试策略过程中,可以监视通道,并可以检查或记录消息。
  • 它为应用程序提供关注点分离。 每个应用程序可以注重自身的核心功能,而消息传送基础结构可以处理所需的一切工作来可靠地将消息路由到多个使用者。

发布-订阅模式的问题和注意事项

  • 订阅处理。 消息传送基础结构必须提供相应的机制,让使用者通过可用的通道订阅或取消订阅。
  • 安全性。 连接到任何消息通道必须受安全策略的限制,以防止未经授权的用户或应用程序窃听。
  • 消息子集。 订阅者通常只对发布者分发的消息的子集感兴趣。 消息传送服务通常允许订阅者按以下各项缩小接收的消息集范围:
    • 主题。 每个主题都有一个专用输出通道,每个使用者可以订阅所有相关主题。
    • 内容筛选。 根据每个消息的内容检查和分发消息。 每个订阅者可以指定其感兴趣的内容。
  • 通配符订阅者。 考虑允许订阅者通过通配符订阅多个主题。
  • 双向通信。 发布-订阅系统中的通道被认为是单向的。 如果特定订阅者需要向发布者发回确认或通信状态,请考虑使用请求/回复模式。 此模式使用一个通道向订阅者发送消息,并使用一个独立的回复通道来与发布者通信。
  • 消息排序。 不能保证使用者实例接收消息的顺序,且不一定反映创建消息的顺序。 精心设计系统以确保消息处理是幂等的,以帮助消除对消息处理顺序的任何依赖。
  • 消息优先级。 某些解决方案可能要求按特定的顺序处理消息。 优先级队列模式提供一种机制用于确保按顺序传送特定的消息。
  • 有害消息。 格式不正确的消息或需要访问不可用资源的任务可能会导致服务实例失败。 系统应阻止将此类消息返回到队列。 应该捕获这些消息的详细信息并将其存储在其他位置,以便可按需要对其进行分析。
  • 重复消息。 同一条消息可能会发送多次。 例如,在发布某条消息后,发送者可能会发生故障。 然后,该发送者的新实例可能会启动并重复发送该消息。 消息传送基础结构应该实施基于消息 ID 的重复消息检测和删除(也称为重复项删除),以便最多只传送消息一次。
  • 消息过期。 消息可能带有有限的生存期。 如果在此期限内未处理该消息,则它不再有用,应该将其丢弃。 发送者可以在消息数据中指定过期时间。 接收者可以检查此信息,然后决定是否要执行与该消息关联的业务逻辑。
  • 消息计划。 可以暂时禁止传送某条消息,在特定的日期和时间之前,不应处理该消息。 在此时间之前,不应将该消息提供给接收者。

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

image.png
图5 观察者模式和发布订阅模式(图片来源:medium.com/@huytrongng…

相似点

  • 都是定义一个一对多的依赖关系,有关状态发生变更时执行相应的通知。可以说发布-订阅模式是观察者模式的一种变体。

不同点

  • 在观察者模式中,主体维护观察者列表,因此主体知道当状态发生变化时如何通知观察者。然而,在发布者-订阅者中,发布者和订阅者不需要相互了解。它们只需在中间层消息代理(或消息队列)的帮助下进行通信。
  • 在发布者-订阅者模式中,发布者与观察者模式完全分离。在观察者模式中,主题和观察者松散耦合。
  • 观察者模式主要是以同步方式实现的,即当发生某些事件时,主题调用其所有观察者的适当方法。发布-订阅模式主要以异步方式实现(使用消息队列)。
  • 发布者-订阅者模式更像是一种跨应用程序模式。发布服务器和订阅服务器可以驻留在两个不同的应用程序中。它们中的每一个都通过消息代理或消息队列进行通信。

🍻以上基于本人查阅的资料和一些个人理解,希望对大家有帮助。如有理解错误的地方,还望大神指出!

📃参考资料
learn.microsoft.com/zh-cn/azure…
cloud.tencent.com/developer/a…
cn.vuejs.org/guide/extra…
juejin.cn/post/684490…
blog.csdn.net/qq_36380426…
www.jianshu.com/p/3e3451708…
www.jianshu.com/p/40f4149e1…
juejin.cn/post/686173…