发布-订阅模式

发布-订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于他的对象都将得到通知

发布-订阅模式广泛运用于异步编程中,这是一种替代传递回调函数的方案;通过使用发布-订阅模式,我们无需过多关注对象在异步运行期间的内部状态,而只需要订阅感兴趣的事件发生点

发布-订阅模式可以取代对象之间硬编码的通知机制,一个对象不用再显示的调用另一个对象的某个接口,两个对象松耦合在一起

一个例子

在房产销售系统设计时,购房者可以作为订阅者订阅房子开始售卖的信息,而售房处可以作为发布者,向订阅发售信息的订阅者发布信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 售楼处
var salesOffices = {}

// 缓存列表,存放订阅者的回调函数
salesOffices.clientList = []

// 增加订阅者
salesOffices.on = function (fn) {
this.clientList.push(fn)
}

// 发布消息
salesOffices.emit = function () {
this.clientList.forEach(fn => {
fn.apply(this, arguments)
})
}

假设分别有 a 和 b 订阅的售楼信息

1
2
3
4
5
6
7
8
9
10
11
12
// a 订阅
salesOffices.on((price, square) => {
console.log('to a:')
console.log('price: ' + price)
console.log('square: ' + square)
})
// b 订阅
salesOffices.on((price, square) => {
console.log('to b:')
console.log('price: ' + price)
console.log('square: ' + square)
})

有两处房源待售,则发布两条消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 发布两条消息
salesOffices.emit(20000000, 100)
salesOffices.emit(15000000, 80)

// to a:
// price: 20000000
// square: 100
// to b:
// price: 20000000
// square: 100
// to a:
// price: 15000000
// square: 80
// to b:
// price: 15000000
// square: 80

假设 a 只想买 100 平米的房子,b 只想买 80 平米的房子,按原有的代码,a 和 b 都会收到自己不感兴趣的信息,可以在原代码上做一些改动,让订阅者只订阅自己感兴趣的信息

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
// 售楼处
var salesOffices = {}

// 缓存列表,存放订阅者的回调函数
salesOffices.clientList = {}

// 增加订阅者
// 通过 key 标识订阅者感兴趣的信息
salesOffices.on = function(key, fn) {
if (!this.clientList[key]) {
this.clientList[key] = []
}
this.clientList[key].push(fn)
}

// 发布消息
salesOffices.emit =function() {
const key = Array.prototype.shift.call(arguments),
fns = this.clientList[key]
if (!fns || fns.length === 0) {
return false
}
fns.forEach(fn => {
fn.apply(this, arguments)
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
salesOffices.on('square-80', price => {
console.log('to a:')
console.log('price = ' + price)
})

salesOffices.on('square-100', price => {
console.log('to b:')
console.log('price = ' + price)
})


salesOffices.emit('square-80', 1000000)
salesOffices.emit('square-100', 15000000)

// to a:
// price = 1000000
// to b:
// price = 15000000

通用实现

具有基本的订阅发布功能的实现如下

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
class Event {
constructor () {
this._clientList = {}
}

// 订阅
on (key, fn) {
if (!this._clientList[key]) {
this._clientList[key] = []
}
this._clientList[key].push(fn)
}

// 发布
emit () {
let key = Array.prototype.shift.call(arguments),
fns = this._clientList[key]
if (!fns || fns.length === 0) {
return false
}
fns.forEach(fn => {
fn.apply(this, arguments)
})
}
}

在使用上面售楼处的例子测试一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let salesOffices = new Event();

salesOffices.on('square-80', sendToA = price => {
console.log('to a:')
console.log('price = ' + price)
})

salesOffices.on('square-100', sendToB = price => {
console.log('to b:')
console.log('price = ' + price)
})


salesOffices.emit('square-80', 1000000)
salesOffices.emit('square-100', 15000000)

// to a:
// price = 1000000
// to b:
// price = 15000000

有些时候可能会需要取消订阅

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Event {
// code ...
remove (key, fn) {
let fns = this._clientList[key]
if (!fns || fns.length === 0) {
return false;
}
// 如果没有传入回调函数,表示取消 key 对应的所有订阅
if (!fn) {
this._clientList[key] = []
return
}
for (let i = fns.length - 1; i >= 0; i--) {
if (fns[i] === fn) {
fns.splice(i, 1)
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let salesOffices = new Event();

salesOffices.on('square-80', sendToA = price => {
console.log('to a:')
console.log('price = ' + price)
})

salesOffices.on('square-80', sendToB = price => {
console.log('to b:')
console.log('price = ' + price)
})


salesOffices.emit('square-80', 1000000)
salesOffices.remove('square-80', sendToB)
salesOffices.emit('square-80', 1000000)

// to a:
// price = 1000000
// to b:
// price = 1000000
// to a:
// price = 1000000

两个例子

模块间通信

假设有两个模块,a 模块中有一个按钮,每次点击按钮,b 模块中的 span 会显示点击次数

1
2
<button id="count">click me</button>
<div id="show">点击次数:</div>
点击次数:

实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let events = new Event();

(function() {
const button = document.getElementById('count')
let count = 0
button.onclick = () => {
events.emit('add', ++count);
}
})();

(function () {
const span = document.getElementById('show')
events.on('add', count => {
span.innerHTML = `点击次数:${count}`
})
})();

登录模块

开发商城网站时,header、消息列表和购物车等模块都需要先用 ajax 异步请求获取用户登录信息以后在渲染

如果采用回调函数的方式

1
2
3
4
5
6
7
8
login.success(data => {
// 设置 header 头像
header.setAvatar(data.avatar)
// 刷新消息列表
message.refresh()
// 刷新购物车
cart.refresh()
})

可以看出,如果要完成这个模块,那么还需要了解 header 模块的setAvatar方法和消息列表以及购物车的refresh方法,代码耦合性较高;如果新增模块,也需要再次对登录模块增加代码

事实上,登录模块并不关心业务模块的需求,使用发布-订阅模式重构,对用户感兴趣的模块自行订阅,当登录成功时,只需要发布成功的消息,业务模块在收到消息后各自处理自己的业务

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
const login = new Event()

// header 模块
const header = (function () {
// 订阅登录成功消息
login.on('loginSuccess', data => {
header.setAvatar(data.avatar)
})

return {
setAvatar: function (data) {
// code ...
}
}
})

// message 模块
const message = (function () {
// 订阅登录成功消息
login.on('loginSuccess', () => {
message.refresh()
})

return {
refresh: function () {
// code ...
}
}
})

// 其他业务模块

// 登录模块
$.ajax('...', data => {
login.emit('loginSuccess', data)
})