- 如果需要源码的话,可以点此处查看
软件开发是用来解决一些实际问题的,对于不同的实际问题,可以有不同的解法。将这些解法进行提取和抽象,并应用到软件工程中,就形成了设计模式。
发布/订阅模式是我在看 Vue 双向数据绑定的原理时接触到的,各位大拿在分析 Vue 双向数据绑定的原理时,都会提到发布/订阅模式,由于我此前并未了解学习过这种设计模式,那些文章自然是看不懂了。所以我决定先看一看发布/订阅模式的实现原理,再进行后面的学习,于是也就有了这篇总结。
发布/订阅模式的现实模型
发布/订阅模式在现实生活中最常见的应用就是订阅报刊杂志,以及订阅牛奶鲜花等场景。以订阅报刊杂志为例,如果你对某些期刊感兴趣,就可以到杂志社订阅它们,当你订阅的报刊或杂志出了新的期刊后,杂志社会将最新的期刊为你送货上门。这就是生活中的一个常见的发布/订阅模型,在这个模型中,你就是订阅者,杂志社就是发布者。
在前端开发中,发布/订阅模式可以说是应用最广泛的模式了,只是我们没有意识到而已。最常见的应用莫过于事件处理函数:
document.addEventListener("click",(e) => console.log("memeda~"));
我们对 document
添加了点击事件,可以理解为订阅了点击事件,当点击事件触发时,就会调用我们传入的回调函数。这里的订阅者就是 document
对象,发布者就是浏览器的事件轮询,当浏览器在进行事件轮询时发现 document
对象上的点击事件被触发了,就会通知该对象,然后执行在绑定事件时传入的回调函数。
发布/订阅模式解决了什么问题
以上面订阅报刊为例,当你订阅了某个报刊后,你不需要每天都跑到杂志社看看有没有新出的杂志,而是由杂志社在出了新的杂志后,将杂志送到家门口。设想如果还有很多人跟你一起订阅了这些报刊,如果这些人每天都跑去杂志社询问自己订阅的报刊有没有更新,那对于用户和杂志社都将是一片混乱。
因此,发布/订阅模式解决的第一个问题就是实现了一种主动推送的机制,订阅者不用频繁的询问发布者消息有没有更新,发布者只需在更新后将新消息推送给订阅者。
另外,发布/订阅模式还对订阅者和发布者进行了隔离。比如说,你不需要知道你订阅的报刊是杂志社的具体哪一个部门负责的,甚至如果杂志社换了办公地址,杂志社依旧可以将最新的报刊送到你的门口。
总结一下,发布/订阅模式主要解决了这两个问题:
- 发布者主动推送消息的问题
- 对订阅者和发布者进行隔离解耦
发布/订阅者模式要实现的功能
根据订阅报刊这个现实生活中的例子,不难看出,要实现一个发布/订阅者模式,需要具备以下功能:
- 订阅功能
- 推送功能
- 退订功能
对于订阅功能,我们可以订阅不同杂志社的不同期刊,每个人的情况可能都不一样。对于推送功能,要求发布者针对不同订阅者的订阅信息,推送不同的内容。对于退订功能,要求订阅者可以取消在某个或者某几个杂志社订阅的某个或者某几个期刊。
我们先从最简单的开始,一步一步实现这个发布/订阅者模型。
最简单的发布/订阅者模式
实现发布/订阅者模式,需要两个类:一个发布者类,一个订阅者类。
对于发布者的实例,要求其至少有一个 subs
属性,用来存放订阅者信息,一个 listen
方法,用来向 subs
中添加订阅者,还有一个 trigger
方法,用来推送消息。当然,这些实例或者方法的名字可以自行定义,没有严格的要求。
对于订阅者的实例,要求其至少有一个 update
方法,用来接收发布者推送的消息,并做出响应。当然,该方法的名字也是可以自行定义的。
首先定义发布者和订阅者的接口对象 Publisher
和 Subscriber
:
interface Subscriber{
update();
}
interface Publisher{
subs:Subscriber[];
listen(sub:Subscriber);
trigger();
}
接口定义好之后,再来分别定义实现这两个接口的发布者类和订阅者类:
class SomePublisher implements Publisher{
subs = [];
// 向 subs 中存放订阅者信息
listen(sub:Subscriber){
this.subs.push(sub);
}
// 推送消息
trigger(){
this.subs.forEach(sub => sub.update())
}
}
class SomeSubscriber implements Subscriber{
name:string;
constructor(name){
this.name = name;
}
// 对发布者的消息推送进行响应
update(){
console.log(`${this.name}:我收到消息了~`)
}
}
接下来进行类的实例化和方法调用:
// 创建发布者实例
const pub1 = new SomePublisher();
// 创建订阅者实例
const sub1 = new SomeSubscriber("订阅者1")
const sub2 = new SomeSubscriber("订阅者2")
const sub3 = new SomeSubscriber("订阅者3")
// 保存订阅者信息
pub1.listen(sub1);
pub1.listen(sub2);
pub1.listen(sub3);
// 推送消息
pub1.trigger();
使用 Node 运行编译后的代码,结果如下:
订阅者1:我收到消息了~
订阅者2:我收到消息了~
订阅者3:我收到消息了~
上面,我们就实现了一个最简单的发布/订阅模式。
推送消息时进行区分
上面实现的发布/订阅模式中,当发布者调用 trigger
函数发送通知时,是对所有的订阅者发送通知的,并没有考虑到不同的订阅者可能有不同的订阅,而是一旦有任何的消息更新,就会通知所有的订阅者。下面我们对上面的代码做一些改进,以适应不同的订阅需求。
首先修改 Publisher
接口定义:
interface Publisher{
// Map 集合的键为栏目信息,值为该栏目下的订阅者集合
subs:Map<string,Subscriber[]>;
listen(column:string,sub:Subscriber);
trigger(column:string);
}
然后修改 SomePublisher
类:
class SomePublisher implements Publisher{
subs:Map<string,Subscriber[]>;
constructor(){
this.subs = new Map();
}
// 向 subs 中存放订阅者信息
listen(column:string,sub:Subscriber){
if(!this.subs.has(column)){
this.subs.set(column,[]);
}
this.subs.get(column).push(sub);
}
// 推送消息
trigger(column:string){
if(!this.subs.has(column)) return;
// 只对当前栏目下的订阅者进行推送
const columnSubs = this.subs.get(column);
// 推送时传递栏目参数(或者你想要的其他参数)
columnSubs.forEach(sub => sub.update(column));
}
}
修改 SomeSubscriber
类,改进 update
方法,使其可以接受参数:
class SomeSubscriber implements Subscriber{
name:string;
constructor(name){
this.name = name;
}
// 对发布者的消息推送进行响应
update(column:string){
console.log(`${this.name}:我收到了 ${column} 的新消息~`)
}
}
接下来进行类的实例化和方法调用:
// 创建发布者实例
const pub1 = new SomePublisher();
// 创建订阅者实例
const sub1 = new SomeSubscriber("订阅者1")
const sub2 = new SomeSubscriber("订阅者2")
const sub3 = new SomeSubscriber("订阅者3")
// 保存订阅者信息
pub1.listen("科学",sub1);
pub1.listen("文学",sub2);
pub1.listen("财经",sub3);
// 推送消息
pub1.trigger("科学")
pub1.trigger("文学")
pub1.trigger("财经")
使用 Node 运行编译后的代码,结果如下:
订阅者1:我收到了 科学 的新消息~
订阅者2:我收到了 文学 的新消息~
订阅者3:我收到了 财经 的新消息~
注:由于这里使用了 ES6 的 Map 对象,因此在编译 TypeScript 代码时需要指定 target
属性:
tsc .\main.ts --target ES6
取消订阅
接下来,实现一个取消订阅的 unsubscribe
方法。
首先完善一下 Subscriber
接口,将 name
字段写入,该字段用来在取消订阅时使用,如果担心名字重复的话,你也可以将其设置为 id
等唯一的值,我这里没有进行唯一性处理:
interface Subscriber{
update(column:string);
name:string;
}
完善 Publisher
接口,添加 unsubscribe
方法约束:
interface Publisher{
// Map 集合的键为栏目信息,值为该栏目下的订阅者集合
subs:Map<string,Subscriber[]>;
listen(column:string,sub:Subscriber);
trigger(column:string);
unsubscribe(column:string,sub:Subscriber);
}
完善 SomePublisher
类:
class SomePublisher implements Publisher{
subs:Map<string,Subscriber[]>;
constructor(){
this.subs = new Map();
}
// 向 subs 中存放订阅者信息
listen(column:string,sub:Subscriber){
if(!this.subs.has(column)){
this.subs.set(column,[]);
}
this.subs.get(column).push(sub);
}
// 推送消息
trigger(column:string){
if(!this.subs.has(column)) return;
// 只对当前栏目下的订阅者进行推送
const columnSubs = this.subs.get(column);
// 推送时传递栏目参数(或者你想要的其他参数)
columnSubs.forEach(sub => sub.update(column));
}
// 取消订阅
unsubscribe(column:string,sub:Subscriber){
let columnSubs = this.subs.get(column);
const { name } = sub;
// 对该订阅者取消订阅
columnSubs = columnSubs.filter(sub => sub.name !== name);
this.subs.set(column,columnSubs);
}
}
接下来进行类的实例化和方法调用:
// 创建发布者实例
const pub1 = new SomePublisher();
// 创建订阅者实例
const sub1 = new SomeSubscriber("订阅者1")
const sub2 = new SomeSubscriber("订阅者2")
const sub3 = new SomeSubscriber("订阅者3")
// 保存订阅者信息
pub1.listen("科学",sub1);
pub1.listen("文学",sub2);
pub1.listen("财经",sub3);
// 推送消息
pub1.trigger("科学")
pub1.trigger("文学")
pub1.trigger("财经")
console.log("==================")
// 取消订阅
pub1.unsubscribe("科学",sub1);
// 推送消息
pub1.trigger("科学")
pub1.trigger("文学")
pub1.trigger("财经")
使用 Node 运行编译后的代码,结果如下:
订阅者1:我收到了 科学 的新消息~
订阅者2:我收到了 文学 的新消息~
订阅者3:我收到了 财经 的新消息~
==================
订阅者2:我收到了 文学 的新消息~
订阅者3:我收到了 财经 的新消息~
现在,我们已经实现了一个较为完整的发布/订阅模型。
JavaScript 风格的发布/订阅模式
前面我们实现的发布/订阅模式是类 Java 风格的:在进行订阅时,需要在 listen
方法中传入一个订阅者对象引用,该对象提供一个 update
方法供消息推送时调用。
传入订阅者对象引用目的就是发布者在发布消息时能够调用该对象上的某个方法,因此在 JavaScript 风格中,我们也可以在 listen
时传入某一个函数,发布者在发布消息时再调用这个函数就好了。
例如在进行订阅时我们可以这样传入参数:
pub1.listen("科学",function(column:string){
doSomething();
});
然后在 listen
方法中做相应的更改,在某个栏目的订阅者列表中存入该函数,当某个栏目有新消息时依次调用这些函数,具体的代码这里我就不贴了。
当然采用直接传入函数的方式,可能还需要对 subs
集合的结构和 unsubscribe
做一定程度的修改。我个人更喜欢本文所介绍的传入对象的方式。
完。
网友评论