发布-订阅模式
发布-订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于他的对象都将得到通知
发布-订阅模式广泛运用于异步编程中,这是一种替代传递回调函数的方案;通过使用发布-订阅模式,我们无需过多关注对象在异步运行期间的内部状态,而只需要订阅感兴趣的事件发生点
发布-订阅模式可以取代对象之间硬编码的通知机制,一个对象不用再显示的调用另一个对象的某个接口,两个对象松耦合在一起
一个例子
在房产销售系统设计时,购房者可以作为订阅者订阅房子开始售卖的信息,而售房处可以作为发布者,向订阅发售信息的订阅者发布信息
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
| salesOffices.on((price, square) => { console.log('to a:') console.log('price: ' + price) console.log('square: ' + square) })
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)
|
假设 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 = {}
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)
|
通用实现
具有基本的订阅发布功能的实现如下
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)
|
有些时候可能会需要取消订阅
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| class Event { remove (key, fn) { let fns = this._clientList[key] if (!fns || fns.length === 0) { return false; } 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)
|
两个例子
模块间通信
假设有两个模块,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.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()
const header = (function () { login.on('loginSuccess', data => { header.setAvatar(data.avatar) })
return { setAvatar: function (data) { } } })
const message = (function () { login.on('loginSuccess', () => { message.refresh() })
return { refresh: function () { } } })
$.ajax('...', data => { login.emit('loginSuccess', data) })
|