观察者模式又叫发布—订阅模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。
生活中的观察者模式
李雷最近看上了一套房子,到了售楼处之后才被告知,该楼盘的房子早已售罄。好在售楼韩梅梅告诉李雷,不久后还有一些尾盘推出,开发商正在办理相关手续,手续办好后便可以购买。但到底是什么时候,目前还没有人能够知道。
于是李雷记下了售楼处的电话,以后每天都会打电话过去询问是不是已经到了购买时间。除了李雷,还有林涛、魏华也会每天向售楼处咨询这个问题。一个星期过后,售楼韩梅梅决定辞职,因为厌倦了每天回答1000个相同内容的电话。
当然现实中没有这么笨的销售公司,实际上故事是这样的:李雷离开之前,把电话号码留在了售楼处。售楼韩梅梅答应他,新楼盘一推出就马上发信息通知李雷。林涛和魏华也是一样,他们的电话号码都被记在售楼处的花名册上,新楼盘推出的时候,售楼韩梅梅会翻开花名册,遍历上面的电话号码,依次发送一条短信来通知他们。
在刚刚的例子中,发送短信通知就是一个典型的观察者模式,李雷、林涛等购买者都是订阅者,他们订阅了房子开售的消息。售楼处作为发布者,会在合适的时候遍历花名册上的电话号码,依次给购房者发布消息。
观察者模式让两个对象松耦合地联系在一起,虽然不太清楚彼此的细节,但这不影响它们之间相互通信。当有新的订阅者出现时,发布者的代码不需要任何修改;同样发布者需要改变时,也不会影响到之前的订阅者。只要之前约定的事件名没有变化,就可以自由地改变它们。
观察者模式的实践
在观察者模式里,至少应该有两个关键角色是一定要出现的——发布者和订阅者。用面向对象的方式表达的话,那就是要有两个类。
首先我们来看这个代表发布者的类,我们给它起名叫Publisher。这个类应该具备哪些“基本技能”呢?大家回忆一下上文中的韩梅梅,韩梅梅的基本操作是什么?首先是增加订阅者,然后是通知订阅者,这俩是最明显的了。此外韩梅梅还具有移除订阅者的能力。发布者类的三个基本能力齐了,下面我们开始写代码:
// 定义发布者类
class Publisher {
constructor() {
this.observers = []
}
add(observer) {
console.log('添加订阅者!')
this.observers.push(observer)
}
remove(observer) {
console.log('移除订阅者!')
this.observers.forEach((item, i) => {
if (item === observer) {
this.observers.splice(i, 1)
}
})
}
notify() {
console.log('通知所有订阅者!')
this.observers.forEach((observer) => {
observer.update(this)
})
}
}
搞定了发布者,我们一起来想想订阅者能干啥——其实订阅者的能力非常简单,作为被动的一方,它的行为只有两个——被通知、去执行。既然我们在Publisher中做的是方法调用,那么我们在订阅者类里要做的就是方法的定义:
// 定义订阅者类
class Observer {
update() {
console.log("收到通知!")
}
}
下面我们来看看李雷、林涛和韩梅梅是如何实现观察者模式的吧:
// 创建订阅者:购房李雷
const liLei = new Observer();
// 创建订阅者:购房林涛
const linTao = new Observer();
const hanMeiMei = new Publisher();
// 韩梅梅记录购房者
hanMeiMei.add(liLei);
hanMeiMei.add(linTao);
// 新楼盘开售,通知所有购房者
hanMeiMei.notify()
相信走到这一步,大家对观察者模式的核心思想、基本实现模式都有了不错的掌握。
全局的观察者对象
回想下刚刚实现的观察者模式,我们给售楼处对象和登录对象都添加了订阅和发布的功能,李雷跟韩梅梅还是存在一定的耦合性,李雷至少要知道售楼处对象的名字,才能顺利的订阅到事件。如果李雷还关心另外一个售楼部,这意味着李雷需要开始订阅另一个对象,这其实是一种资源浪费。
其实在现实中,买房子未必要亲自去售楼处,我们只要把订阅的请求交给中介公司,而各大房产公司也只需要通过中介公司来发布房子信息。这样一来,我们不用关心消息是来自哪个房产公司,我们在意的是能否顺利收到消息。当然,为了保证订阅者和发布者能顺利通信,订阅者和发布者都必须知道这个中介公司。
同样在程序中,可以用一个全局的EventEmitter对象来实现,订阅者不需要了解消息来自哪个发布者,发布者也不知道消息会推送给哪些订阅者,EventEmitter作为一个类似“中介者”的角色,把订阅者和发布者联系起来。下面,我们就一起来实现一个Event Bus:
class EventEmitter {
constructor() {
// handlers是一个map,用于存储事件与回调之间的对应关系
this.handlers = {}
}
// on方法用于安装事件监听器,它接受目标事件名和回调函数作为参数
on(eventName, cb) {
// 先检查一下目标事件名有没有对应的监听函数队列
if (!this.handlers[eventName]) {
// 如果没有,那么首先初始化一个监听函数队列
this.handlers[eventName] = []
}
// 把回调函数推入目标事件的监听函数队列里去
this.handlers[eventName].push(cb)
}
// emit方法用于触发目标事件,它接受事件名和监听函数入参作为参数
emit(eventName, ...args) {
// 检查目标事件是否有监听函数队列
if (this.handlers[eventName]) {
// 这里需要对 this.handlers[eventName] 做一次浅拷贝,主要目的是为了避免通过 once 安装的监听器在移除的过程中出现顺序问题
const handlers = this.handlers[eventName].slice()
// 如果有,则逐个调用队列里的回调函数
handlers.forEach((callback) => {
callback(...args)
})
}
}
// 移除某个事件回调队列里的指定回调函数
off(eventName, cb) {
const callbacks = this.handlers[eventName]
const index = callbacks.indexOf(cb)
if (index !== -1) {
callbacks.splice(index, 1)
}
}
// 为事件注册单次监听器
once(eventName, cb) {
// 对回调函数进行包装,使其执行完毕自动被移除
const wrapper = (...args) => {
cb(...args)
this.off(eventName, wrapper)
}
this.on(eventName, wrapper)
}
}
Event Bus(Vue、Flutter 等前端框架中有出镜)和 Event Emitter(Node中有出镜)出场的“剧组”不同,但是它们都对应一个共同的角色——全局事件总线。
全局事件总线,严格来说不能说是观察者模式,而是发布-订阅模式。Event Bus/Event Emitter 作为全局事件总线,它起到的是一个沟通桥梁的作用。我们可以把它理解为一个事件中心,我们所有事件的订阅/发布都不能由订阅方和发布方“私下沟通”,必须要委托这个事件中心帮我们实现。
总结
观察者模式的优点非常明显,一为时间上的解耦,二为对象之间的解耦。它的应用非常广泛,既可以用在异步编程中,也可以帮助我们完成更松耦合的代码编写。观察者模式还可以用来帮助实现一些别的设计模式,比如中介者模式。
当然,观察者模式也不是完全没有缺点。创建订阅者本身要消耗一定的时间和内存,而且当你订阅一个消息后,也许此消息最后都未发生,但这个订阅者会始终存在于内存中。另外,观察者模式虽然可以弱化对象之间的联系,但如果过度使用的话,对象和对象之间的必要联系也将被深埋在背后,会导致程序难以跟踪维护和理解。