这一篇我们延续之前的风格,用故事的形式讲解设计模式。
小凯蒂一家生活在美国海岸线旁边一个小镇上,在她的小镇,没有楼房,每家每户都是漂亮的小房子。她的爸妈是给小镇里送牛奶的,一到凌晨五点钟,她爸妈就起床开始给每家每户送牛奶,送好牛奶之后,她们就回到家里,使用取牛奶通知软件,告诉大家牛奶送到了,要趁还没变质之前赶紧取走。
小手一按,订牛奶的家庭就会收到取牛奶的通知。
订牛奶的家庭会在客厅安装一个显示屏,每次凯蒂家通知他们的时候,显示屏就会告诉他们要去取牛奶。
当时给小凯蒂家安装取牛奶机的大叔很忙,就随手给他们设计了一下,他们的牛奶机目前是这样子的:
interface Milk {
kind: string;
ml: number;
}
class MinkCenter {
milkList: Milk[]
constructor() {
this.milkList = []
}
receiveMilk(milkList: Milk[]) {
console.log('收到供奶站送来的牛奶,装车')
this.milkList = milkList
}
sendMilkToPeople() {
console.log("把牛奶送到各家各户")
this.notifyPeopleToTakeMilk()
this.milkList = []
}
notifyPeopleToTakeMilk() {
console.log("通知各家各户取牛奶")
const dainel = new Family('dainel')
const jeinny = new Family('jeinny')
const wayne = new Family('wayne')
dainel.update(); // 通知各位取牛奶
jeinny.update()
wayne.update()
}
}
在 notifyPeopleToTakeMilk
内部,我们会通知订牛奶的家庭去取牛奶,每个家庭的类也很简单:
class Family {
name: string;
constructor(name: string) {
this.name = name
}
update() {
console.log(this.name + '您好,牛奶送到了,快去取吧')
}
}
最后,我们的运行这个取牛奶机的代码长这样:
const milkCenter = new MilkCenter();
const milkList = [
{ kind: "纯牛奶", ml: 200 },
{ kind: "纯牛奶", ml: 300 },
{ kind: "纯牛奶", ml: 500 },
];
milkCenter.receiveMilk(milkList);
milkCenter.sendMilkToPeople();
milkCenter.notifyPeopleToTakeMilk();
运行这段代码,我们的结果是这样子的:
MilkCenter
就是小凯蒂家的取牛奶通知机。一个个 Family
对象就是放在订牛奶的家里显示屏。
目前来说,订牛奶的家庭都使用代码写在了 MilkCenter
内部的 notifyPeopleToTakeMilk
函数里面。
由于订牛奶的家庭是硬编码(hardcode)进 MilkCenter
的,就没法在运行的时候改。每次有新的家庭要来订牛奶了,小凯蒂的爸妈要把取牛奶机停下来,把新订牛奶的家庭加进来;有的家庭因为出去旅游要暂停订牛奶了,也要先把取牛奶机停下来,把不订牛奶的家庭暂时移除掉。
看下面这个示例,有个叫 padim 的伙计的要订牛奶啦:
notifyPeopleToTakeMilk() {
console.log("通知各家各户取牛奶");
// ...
const padim = new Family('padim'); // 这个是新加的
// ...
padim.update();
}
刚开始人少的时候用起来还不错,隔几天才有几个人来更改一下,随着他们一家的牛奶生意越做越大,几乎每天都人来改,他们家从早忙到晚,拆牛奶机,安牛奶机,连觉都睡不好。
小凯蒂看着爸妈这么累,有一天她就坐在窗前许愿:神奇的魔法师,我给你一个我最爱的棒棒糖,有办法能让我爸妈的工作轻松一点吗?
当她许完愿,睁开眼睛,一只带着白帽子的猫头鹰先生慢悠悠的飞了过来。
猫头鹰先生双脚站在窗户边上:"听到你的许愿了,小事一桩嘛!"
说着,从口袋里掏出一个图纸:"可爱的小凯蒂,你就照着这个做就好啦!好用的话,记得给我棒棒糖呀!" 说完猫头鹰先生就摇摇翅膀飞走了。
小凯蒂拿起那张图纸:"咦,这是什么,上面写着「观察者模式」,好奇怪的名字,有用吗?"
小凯蒂半信半疑的按照图纸做了起来。
上面写着,首先要定义两个接口,一个叫做 Subject
,一个叫做 Observer
,这个 Subject
是给小凯蒂家用的,Observer
是给订牛奶的家庭用的,快来看看他们的样子:
interface Observer {
update: () => void;
}
interface Subject {
registerObserver: (o: Observer) => void;
removeObserver: (o: Observer) => void;
notifyObservers: () => void;
}
小凯蒂建好这两个接口后,发现还不算难,然后就去改造了家庭类,发现,也几乎不需要修改什么,只要把原先的类实现一下 Observer
这个接口就好了,她特别开心:
class Family implements Observer {
name: string;
constructor(name: string) {
this.name = name;
}
update() {
console.log(this.name + "您好,牛奶送到了,快去取吧");
}
}
接下来,她来改造会有一丢丢麻烦的 MinkCenter
类。她增加了一个变量 observers
维护所有订牛奶的家庭,还实现了 Subject
接口的三个方法。
class MilkCenter implements Subject {
milkList: Milk[]
observers: Observer[]
constructor() {
this.milkList = []
this.observers = []
}
registerObserver(o: Observer) {
this.observers.push(o)
}
removeObserver(o: Observer){
const targetIndex = this.observers.findIndex(family => family === o)
this.observers.splice(targetIndex, 1)
}
notifyObservers(){
this.observers.forEach(family => {
family.update()
})
}
receiveMilk(milkList: Milk[]) {
console.log("收到供奶站送来的牛奶,装车");
this.milkList = milkList
}
sendMilkToPeople() {
console.log("把牛奶送到各家各户")
this.milkList = []
}
notifyPeopleToTakeMilk() {
console.log("通知各家各户取牛奶")
this.notifyObservers()
}
}
小凯蒂写完这段代码,开始意识到,猫头鹰先生的图纸真是一个好东西。之前那个很长的notifyPeopleToTakeMilk
函数不见了,取而代之的是直接调用 notifyObservers
方法。
当有了新的家庭加入进来,我们就调用 registerObserver
去把家庭加到订购列表中去,由于我们
的家庭都实现了 Observer
接口,这就让我们添加、删除、遍历都能统一的操作。
经过这样的改进,小凯蒂的爸妈再也不用停机修改牛奶机了,他们在牛奶机上加了一个输入框和按钮,点击这个按钮,就会把输入框的值作为家庭的名字,新建一个家庭对象,然后再调用 registerObserver
方法把家庭对象加入到观察者列表。
每当有新的家庭来订牛奶,只需要在牛奶机运行的时候调用 registerObserver
就好了,下次通知取牛奶的时候就会通知到新的家庭!
const milkCenter = new MilkCenter()
const dainel = new Family("dainel")
const jeinny = new Family("jeinny")
const wayne = new Family("wayne")
milkCenter.registerObserver(dainel)
milkCenter.registerObserver(jeinny)
milkCenter.registerObserver(wayne)
const milkList = [
{ kind: "纯牛奶", ml: 200 },
{ kind: "纯牛奶", ml: 300 },
{ kind: "纯牛奶", ml: 500 },
];
milkCenter.receiveMilk(milkList)
milkCenter.sendMilkToPeople()
milkCenter.notifyPeopleToTakeMilk()
不仅如此,如果有一天通知取牛奶的机器坏掉了,换一个就好了,但是之前的就不行,因为订阅用户信息都存在牛奶机里面(也就是 MilkCenter
内部)。但是经过我们的改造,我们的主题(MilkCenter
)和诸多观察者(各个 Family
的实例)耦合度降到了最低,坏了,换!
我们知道,有的净水机上有童锁按钮,为的就是避免小孩子误触后接到了热水。由于小凯蒂家牛奶机很新颖,经常有小调皮来捣乱。于是小凯蒂还打算加一个「童锁」功能,不让每次点击通知按钮都通知各个家庭。
怎么做呀?巧了,猫头鹰先生给的图纸上也有!
原来要加一个 setChanged
方法就好了,来看看怎么用吧:
class MilkCenter implements Subject {
hasChanged: boolean
constructor() {
// ... 省略不重要的 ...
this.hasChanged = false
}
setChanged() {
this.hasChanged = true
}
// ... 省略不重要的 ...
notifyObservers(){
if (!this.hasChanged) {
return
}
this.observers.forEach(family => {
family.update()
})
this.hasChanged = false
}
}
加了 setChanged
之后,我们要在送完奶之后,调用一下它才能让订奶的家庭收到通知,被误触的问题就轻松解决啦。观察者模式的一个缺点就是有时候通知太灵活,使用刚刚那种方案就能一定程度上减少这个问题的发生。
milkCenter.setChanged()
milkCenter.notifyPeopleToTakeMilk()
小凯蒂很开心,她的愿望都实现了,第二天,猫头鹰先生如愿得到了他的棒棒糖,含在嘴里,悠哉悠哉的走了 ~
上面就是观察者模式,是不是觉得比较简单呢?
今天和你讨论的模式估计是在前端范围最广的设计模式了,我相信你也肯定使用过。毕竟,当你写下第一个事件监听函数的时候就代表你已经使用过了:
document.addEventListener('#app', () => {
console.log('click me')
})
我们来看一下官方一点的解释:
观察者模式是软件设计模式的一种。在此种模式中,一个目标对象管理所有相依于它的观察者对象,并且在它本身的状态改变时主动发出通知。这通常通过呼叫各观察者所提供的方法来实现。此种模式通常被用来实时事件处理系统。
传统的观察者模式我们可能比较少使用,在前端,我们可能更多的是使用它的变种之一:发布订阅模式。
实现发布订阅的库也很多,比如 mitt。
import mitt from 'mitt'
const emitter = mitt()
emitter.on('foo', e => console.log('foo', e) )
emitter.emit('foo', { a: 'b' })
传统的观察者模式和发布-订阅机制也略微有一点不同。前者倾向于一对多,后者更倾向于一对一。在传统的观察者模式中,我们要在主题(Subject
)中去显示的注册观察者(Observer
),并且我们的观察者要实现特定的接口。但是发布-订阅机制就松散的多了,emitter.on
的接受的回调比较随意,只要是一个回调函数就好,具体处理就自己酌情而定,这也导致了,它在 TypeScript 中不是那么好写类型。
好了,这就是本文的全部内容啦,我们重点讲解了如何实现一个观察者模式,接下来简单对比了一下观察者模式和发布-订阅模式。希望看得还算开心 :)