发布订阅和观察者模式

概念说明

在说明概念之前,我们先来解决一个业务场景:有一位同事写了一个 todo-list 模块,可以添加删除一个任务,另一个同学写了一个过滤器模块,可以过滤出 todo-list 里已完成的和未完成的。这两个模块在相互独立的情况下怎么去对接,很多人应该已经想到了在 vue 中我们可以在过滤器组件里去 emit 一个自定义事件,在 list 组件里监听自定义事件去进行操作。其实这里就用到了发布订阅模式。

我们来看它的概念,发布订阅模式(Publish–subscribe pattern)是一种消息范式,也称事件机制,它定义了一种依赖关系,解决了多个主体之间功能的耦合。

JS 原生支持自定义事件,就是发布订阅模式的一种实现,能让我们更好的维护两个系统的沟通:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function cb(e) {
console.log(e.detail)
}

// 监听一个自定义 build 事件
document.addEventListener('build', cb);

// 手动触发 build 事件
var event = new CustomEvent('build', {
detail: elem.dataset.time
});
document.dispatchEvent(event);

// 手动移除自定义 build 事件
document.removeEventListener('build', cb);

Vue 中也集成了完整的发布订阅模式,来辅助组件之间的通信,官方案例中的 EventBus:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// event-bus.js
import Vue from 'vue'
export const EventBus = new Vue()

// A.vue
import { EventBus } from "./event-bus.js";
EventBus.$emit("aMsg", '来自A页面的消息');

// B.vue
import { EventBus } from "./event-bus.js";
EventBus.$on("aMsg", (msg) => {
// A发送来的消息
console.log(msg);
});

我们现在可以总结如下:发布订阅模式是解决两个主体之间的信息沟通问题,能最大程度上保障高内聚、低耦合,订阅者可以订阅(on)某个主题(事件),发布者可以在合适的时机发布这个主题(fire),当然也可以移除这个主题(off)。

代码实现

我们根据它的功能描述先搭起整体框架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class EventHub {
constructor() {
// 维护订阅的事件
this.eventMap = {};
}
// 订阅某个主题,注册回调函数
on(event, cb) {}
// 取消订阅
off(event, cb) {}
// 发布某个主题
fire(event) {}
// 只订阅一次,执行之后立即销毁
once(event, cb) {}
}
  1. 我们来依次实现上面的方法,其中 on 和 fire 是比较简单的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class EventHub {
constructor() {
this.eventMap = {};
}
on(event, cb) {
// 向 eventMap 加入一个事件,并维护一个数组,放入注册的回调函数
(this.eventMap[event] || (this.eventMap[event] = [])).push(cb);
return this;
}
fire(event, ...args) {
(this.eventMap[event] || []).forEach(cb => {
cb(...args);
})
return this;
}
}
  1. 接下来实现 off 函数,功能上需要满足:
    • 如果没有传 event,即表示移除所有回调函数;
    • 传入 event,但没有传 cb,则移除指定 event 的回调;
    • 传入 event 和 cb,表示只移除指定 cb;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class EventHub {
constructor() {
this.eventMap = {};
}
off(event, cb) {
// 没有传入参数,移除所有
if (arguments.length === 0) {
this.eventMap = {};
return this;
}
// 传入 event,没有传入 cb
if (event && !cb) {
this.eventMap[event] = null;
return this;
}
// 传入 event 和 cb,我们把它从数组中移除
const cbs = this.eventMap[event] || [];
let i = cbs.length;
while (i--) {
if (cbs[i] === cb) {
cbs.splice(i, 1);
}
}
return this;
}
}
  1. 最后我们实现 once,once 的意思是注册的回调函数只会执行一次,其实也很简单,我们调用 cb 的同时进行销毁就可以了。下面用了一个临时函数 temp 将回调函数的执行和销毁进行包装,这样就可以简单实现了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class EventHub {
constructor() {
this.eventMap = {};
}
once(event, cb) {
// 用 temp 函数包装(执行 cb 的同时,取消它的订阅)
const temp = () => {
this.off(event, temp);
cb(...arguments);
}
this.on(event, temp);
return this;
}
}

这里其实还有个小问题,因为我们对注册的 cb 用 temp 函数进行了包装,
那么在使用 off(event, cb) 进行移除注册的 cb 时,就会失败,因为 temp 和 cb 是完全的两个函数,无法判断相等。这里我们只需要简单改造一下就可以啦。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class EventHub {
once(event, cb) {
const temp = () => {
this.off(event, temp);
cb(...arguments);
}
// 1. 将原本的 cb 函数挂在 temp 函数属性上
temp.origin_cb = cb;
this.on(event, temp);
return this;
}
off(event, cb) {
const cbs = this.eventMap[event] || [];
let i = cbs.length;
while (i--) {
// 2. 移除的时候加一个判断,判断挂在回调函数上的 origin_cb 是否等于 cb
if (cbs[i] === cb || cbs[i].origin_cb === cb) {
cbs.splice(i, 1);
}
}
return this;
}
}

这样 once 的功能就完整了。

完整代码和测试用例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class EventHub {
constructor() {
this.eventMap = {};
}
on(event, cb) {
(this.eventMap[event] || (this.eventMap[event] = [])).push(cb);
return this;
}
fire(event, ...args) {
(this.eventMap[event] || []).forEach(cb => {
cb(...args);
})
return this;
}
off(event, cb) {
if (arguments.length === 0) {
this.eventMap = {};
return this;
}
if (event && !cb) {
this.eventMap[event] = null;
return this;
}
const cbs = this.eventMap[event] || [];
let i = cbs.length;
while (i--) {
if (cbs[i] === cb || cbs[i].origin_cb === cb) {
cbs.splice(i, 1);
}
}
return this;
}
once(event, cb) {
const temp = (...args) => {
this.off(event, temp);
cb(...args);
}
temp.origin_cb = cb;
this.on(event, temp);
return this;
}
}

编写测试用例进行验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const event = new EventHub();
// 1. 测试订阅功能
// 我们订阅 test1 消息,注册了两个函数;
// 我们订阅 test2 消息,注册了一个函数
const testFn1 = (msg) => { console.log("fn1", msg) };
const testFn2 = (msg) => { console.log("fn2", msg) };
event.on("test1", testFn1)
.on("test1", testFn2)
.on("test2", testFn2);

event.fire("test1", "触发 test1")
.fire("test2", "触发 test2");

// 2. 测试 off 功能
// 我们取消了 test1 中的 testFn2 回调函数;
// 我们取消了 test2 中所有回调;
event.off("test1", testFn2)
.off("test2");

// 3. 测试 once 功能
// 我们通过 once 订阅一个 test3 消息,注册一个函数
const testFn3 = (msg) => { console.log("fn3", msg) };
event.once("test3", testFn3);
event.fire("test3", "触发 test3,执行第一次");
event.fire("test3", "触发 test3,执行第二次");

// 4. 测试 once 的取消功能
// 我们通过 once 订阅一个 test4 消息,注册一个函数
// 我们移除 test4 消息
const testFn4 = (msg) => { console.log("fn4", msg) };
event.once("test4", testFn4)
.off("test4");
event.fire("test4", "触发 test4");

相关问题

发布订阅模式和观察者模式的区别?

这是一个老生常谈的问题,网上也众说纷纭,我们先从维基百科上来看看它俩的官方定义:

发布-订阅是一种消息范式,消息的发送者(称为发布者)不会将消息直接发送给特定的接收者(即订阅者)。而是将发布的消息分为不同的类别,无需了解哪些订阅者(如果有的话)可能存在。同样的,订阅者可以表达对一个或多个类别的兴趣,只接收感兴趣的消息,无需了解哪些发布者(如果有的话)存在。

观察者模式是软件设计模式的一种。在此种模式中,一个目标对象管理所有相依于它的观察者对象,并且在它本身的状态改变时主动发出通知。这通常透过呼叫各观察者所提供的方法来实现,此种模式通常被用来实时事件处理系统。

两者的区别可以用下面两个图简单表示:

我们大体上可以总结如下:

  • 发布订阅模式和观察者模式都是处理依赖和被依赖的两个对象之间关系的一种模式,发布-订阅模式是观察者模式的一种变体。
  • 在观察者模式中,观察者是知道 Subject 的,Subject 也记录了所有的观察者。在发布订阅模式中,发布者和订阅者不知道对方的存在,它们只有通过消息代理进行通信;
  • 观察者模式大多表示的是一对多的关系;发布订阅模式大多表示的是多对多的关系
  • 观察者模式大多数时候是同步的,比如当事件触发,Subject 就会去调用观察者的方法。而发布-订阅模式大多数时候是异步的(使用消息队列)

举一个生活中的例子。观察者模式就像是在街边摆摊卖炒粉的商贩,他要记录都有哪些人定了餐(记录观察者),等到炒好了(自身状态变化)就会给这些人打包并通知他们。

而发布订阅模式更像是在餐厅里订餐,我们(订阅者)会扫餐桌上的二维码点各种餐(订阅不同的主题),对于后厨人员来说,他们并不知道谁点了餐,只需要关注订餐系统(Event Channel)推给他们的清单进行制作,等做好了也同样是去点击订餐系统去通知到顾客。

我们再来实现一个简单的观察者模式,在观察者模式中,Subject 对象拥有添加、删除和通知一系列 Observer 的方法等等,而 Observer 对象拥有更新方法等等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class Subject {
constructor() {
this.observers = [];
}
add(observer) {
this.observers.push(observer);
}
remove(observer) {
var observers = this.observers;
let i = observers.length;
while (i--) {
if (observers[i] === observer) {
observers.splice(i, 1);
}
}
}
notify() { // 通知
this.observers.forEach(observer => {
observer.update();
})
}
}

class Observer {
constructor(cb) {
this.cb = cb;
}
update(){
this.cb(arguments);
}
}

// 我们来使用
var sub = new Subject();

var obs1 = new Observer(() => { console.log("test1"); });
var obs2 = new Observer(() => { console.log("test2"); });

sub.add(obs1);
sub.add(obs2);
sub.notify(); // test1; test2

添加 namespace

熟悉 jQuery 的人知道,它的事件绑定还提供了命名空间的功能,这在一些场景下为我们提供了很多方便,比如下面例子:

1
2
3
4
5
6
7
8
9
// 我们通过 jq 绑定了两个事件:
$(document).on("click", () => {
console.log("click 1");
})
$(document).on("click", () => {
console.log("click 2");
})
// 移除 click 回调函数
$(document).off("click");

如果我们只想取消 click 2 的回调,此时必须要将 click 2 的回调函数抽离出来,因为低层是通过判断两个回调函数的引用是否相等的。

1
2
3
4
5
6
7
8
9
10
// 我们通过 jq 绑定了两个事件:
$(document).on("click", () => {
console.log("click 1");
})
function click2() {
console.log("click 2");
}
$(document).on("click", click2);
// 此时我们就可以只移除 click2 回调函数
$(document).off("click", click2);

但这种方式明显变的复杂,所以 jQuery 为我们提供了另一种方式,命名空间:

1
2
3
4
5
6
7
8
9
// 我们通过 jq 命名空间的方式绑定了两个事件:
$(document).on("click.click1", () => {
console.log("click 1");
})
$(document).on("click.click2", () => {
console.log("click 2");
})
// 此时我们只移除 click2 回调函数就变的很简单
$(document).off("click.click2");

关于命名空间这里的实现也有很多方法,这里大家可以自行实现一下,就不赘述了。

0%