发布-订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知
1. 现实中的发布-订阅模式
小明最近看上一套房子,到了售楼处之后才被告知,楼盘房子早已售空。好在售楼处告诉小明,不久后还有一些尾盘推出,但到底是什么时候,目前还不知道。
于是小明记下了售楼处的电话,每天都会打电话询问有没有开盘。同样,除了小明,还有小红、小强也每天都会像售楼处打电话。一个星期后,售楼处电话已厌倦了每天的电话回答相同的内容。
当然,现实中显然不是这样的,实际上是:售楼处会把意向的客户的手机号码留在售楼处,也就是小红,小明,小强的电话,当开盘时,会依次发送短信来通知他们。
2. 发布-订阅模式的作用
在刚刚的例子中,发短信通知就是一个典型的发布-订阅模式。小明,小红他们都是订阅者,他们订阅了房子开盘的消息。售楼处作为发布者,会在合适的时候依次发送给所有订阅者发送消息。
优点:
- 购房者不用天天打电话给售楼处,在合适的时间,售楼处会作为发布者通知给所有的订阅者
- 购房者和售楼处之前不再强耦合的在一起,当有新的购房者出现时,只需要留下手机号码,售楼处不需要关系购房者的任何情况,只需要知道谁订阅了购房消息即可。
第一条说明发布订阅模式可以广泛引用于异步编程中,这是一种替代传递回调函数的方案。比如,订阅ajax请求的success,error事件。或者如果想在动画的每一帧完成之后做一些事情,那我们可以订阅一个事件,然后在动画的每一帧完成之后去发布这个事件。在异步变成中使用发布-订阅模式,我们就无序过多关注对象在异步运行中的内部状态,而只需要订阅感兴趣的事件发生点。
第二条说明发布-订阅模式可以取代对象之间硬编码的通知机制,一个对象不再显试的嗲用另外一个对象的某个接口。发布-订阅模式让两个对象松耦合的联系在一起,虽然不太清除彼此的细节,但不影响他们互相通信。当有新的订阅者出现,发布者的代码不需要做任何改变,同样发布者需要改变,订阅者也不会影响。只要之前约定的事件名没有变化。
3.DOM事件
实际上,只要我们曾经在DOM节点上绑定过事件函数,那我们就曾经使用过发布-订阅模式。
document.body.addEventListener('click', function(){
alert(1);
}, false);
document.body.click(); // 模拟用户点击
在这里我们需要监控用户点击body的动作,但我们没法预知用户点击的时间。所以我们订阅了document.body上的click事件,当body节点被点击时,body节点便会像订阅者发布这个消息。
4.自定义事件
如何一步步实现发布-订阅模式
- 首先制定好谁是发布者
- 然后给发布者添加一个缓存列表,用于存放回调函数以便通知订阅者。
- 最后发布消息的时候,发布者会遍历这个缓存列表,依次触发里面存放的订阅者回调函数。
另外,可以往回调函数里填入一些参数,订阅者可以接受这些参数。这是很有必要的,比如售楼处可以在发给订阅者消息里面添加房子面积,单价等信息,订阅者可以接受到消息做各自的处理
var salesOffices = {} // 定义售楼处
salesOffices.clientList = [] // 缓存列表,存放订阅者的回调函数
salesOffices.listen = function (fn) { // 增加订阅者
this.clientList.push(fn) // 订阅的消息添加进缓存列表
}
salesOffices.trigger = function () { // 发布消息
for (var i = 0; i < this.clientList.length; i++) {
var fn = this.clientList[i];
fn.apply(this, arguments) // arguments 是发布消息时带上的参数
}
}
// 接下来进行简单的测试
salesOffices.listen(function (price, squareMeter) { // 小明
console.log('价格' + price)
console.log('面积' + squareMeter)
})
salesOffices.listen(function (price, squareMeter) { // 小红
console.log('价格' + price)
console.log('面积' + squareMeter)
})
salesOffices.trigger(200000, 88)
salesOffices.trigger(300000, 99)
至此,我们实现了一个最简单的发布-订阅模式,但是这里有一些问题,我们看到订阅者接收到了发布者的每个消息,虽然小明只想买88平方米的房子,但是同时也接受到了99平方米的信息,所以我们需要标识一个key,让订阅者制定粤自己感兴趣的消息,改写后代码如下:
var salesOffices = {} // 定义售楼处
salesOffices.clientList = {} // 缓存列表,存放订阅者的回调函数
salesOffices.listen = function (key, fn) { // 增加订阅者
if (!this.clientList[key]){ // 如果还没有订阅过此类的消息,给该类消息创建一个缓存列表
this.clientList[key] = []
}
this.clientList[key].push(fn) // 订阅的消息添加到缓存列表
}
salesOffices.trigger = function () { // 发布消息
var key = Array.prototype.shift.call(arguments) // 取出消息类型,也就是key
var fns = this.clientList[key] // 取出该类型所有的回调函数集合
if (!fns || fns.length === 0) return false; // 没有订阅过该消息就返回
for (var i = 0; i < fns.length; i++) {
var fn = fns[i]
fn.apply(this, arguments)
}
}
salesOffices.listen('m88', function (price) {
console.log(price);
})
salesOffices.listen('m99', function (price) {
console.log(price);
})
salesOffices.trigger('m88', 200000)
salesOffices.trigger('m99', 300000)
很明显,订阅者可以只订阅自己感兴趣的事件了。
5.发布-订阅模式的通用实现
小明又想去其他售楼处买房,那么就需要重新写一个salesOffices,所以需要做如下改进,把发布订阅的功能提取出来,单独放在一个对象内:
var event = {
clientList: {},
listen: function (key, fn) {
if (!this.clientList[key]){
this.clientList[key] = []
}
this.clientList[key].push(fn)
},
trigger: function () {
var key = Array.prototype.shift.call(arguments) // 取出消息类型,也就是key
var fns = this.clientList[key] // 取出该类型所有的回调函数集合
if (!fns || fns.length === 0) return false; // 没有订阅过该消息就返回
for (var i = 0; i < fns.length; i++) {
var fn = fns[i]
fn.apply(this, arguments)
}
}
}
// 定义一个installEvent函数,这个函数可以给所有对象都动态安装发布-订阅功能:
var installEvent = function (obj) {
for (var i in event) {
obj[i] = event[i]
}
}
var salesOffices = {}
installEvent(salesOffices)
salesOffices.listen('ss88', function (price) {
console.log(price);
})
salesOffices.listen('ss99', function (price) {
console.log(price)
})
salesOffices.listen('ss88', 200000)
salesOffices.listen('ss99', 300000)
6. 取消订阅的事件
有时候我们有这种需求,小明突然不想买房了,为了避免售楼处推送来的信息,可以取消之前订阅的事件
event.remove = function (key, fn) {
var fns = this.clientList[key]
if (!fns) return false; // 如果key对应得消息没有被人订阅,则返回
if (!fn){
fns && (fns.length = 0) // 如果没有传入具体的回调函数,表示需要取消key对应消息的所有订阅
}else {
for (var i = fns.length - 1; i > 0; i--) { // 反向遍历订阅的回调函数列表
var _fn = fns[i]
if (_fn === fn){
fns.splice(i, 1) // 删除对应得回调函数
}
}
}
}
var salesOffices = {}
installEvent(salesOffices)
salesOffices.listen('ss88', fn1 = function (price) {
console.log(price);
})
salesOffices.listen('ss99', fn2 = function (price) {
console.log(price)
})
salesOffices.remove('ss88', fn1)
salesOffices.trigger('ss88', 200000)
7.真实的例子--网站登录
加入我们正在开发一个网站商场,网站里面有header,nav,购物车等各模块。这几个模块的渲染有一个共同的前提条件,就是必须先用ajax异步请求获取用户的信息。
至于ajax请求什么时候能成功返回用户信息,这点我们没法确定。更重要的一点是,我们不知道除了购物车,nav,header之外,还有哪些地方需要用到用户信息。如果他们和用户之间信息产生了强耦合,比如下面这样的形式:
login.succ(function(data){
header.setAcatar(data.avatar) // 设置header模块的头像
nav.setAvatar(data.avatar) // 设置导航模块的头像
message.refresh() // 消息列表刷新
cart.refresh() // 购物车刷新
})
现在登录模块是我们负责编写的,我们必须还了解header模块设置头像的方法叫setAvatar,购物车刷新的方法叫refresh.等等各种方法,如果后面需要加上收货地址的模块
login.succ(function(data){
header.setAcatar(data.avatar) // 设置header模块的头像
nav.setAvatar(data.avatar) // 设置导航模块的头像
message.refresh() // 消息列表刷新
cart.refresh() // 购物车刷新
adress.refresh() // 增加
})
这时候,又要重构代码。
当我们用发布订阅模式重写后,对用户感兴趣的业务模块将自定订阅登录成功的消息事件。登录成功后,登录模块只需要发布登录信息,而业务方接受消息之后,可以自己做各自的业务处理。
$.ajax('http://xxx.xx.com/login',function(data){
login.trigger('loginsuccess', data)
})
各模块监听登录成功的消息
var header = (function () {
login.listen('loginsuccess', function (data) {
header.setAvator(data.avator)
});
return {
setAvator: function(data) {
console.log('设置header的头像')
}
}
})()
这时候,如果添加一个刷新收货地址的行为,就可以让收货地址模块开发人员去订阅登录成功这个事件
var adderss = (function () {
login.listen('loginsuccess', function (data) {
adderss.refresh(data)
});
return {
refresh: function (data) {
console.log('刷新地址')
}
}
})()
8. 全局的发布-订阅对象
用一个全局的Event对象来实现,订阅者不需要了解消息来自哪个发布者,发布者也不需要消息会推送给订阅者,Event作为一个中介者的角色来吧发布者和订阅者联系到一起,代码如下:
var Event = (function () {
var clientList = {},
listen,
trigger,
remove;
listen = function (key, fn) {
if (!clientList[key]) {
clientList[key] = [];
}
clientList[key].push(fn);
}
trigger = function() {
var key = Array.prototype.shift.call(arguments) // 取出消息类型,也就是key
var fns = clientList[key] // 取出该类型所有的回调函数集合
if (!fns || fns.length === 0) return false; // 没有订阅过该消息就返回
for (var i = 0; i < fns.length; i++) {
var fn = fns[i]
fn.apply(this, arguments)
}
}
remove = function (key, fn) {
var fns = clientList[key]
if (!fns) return false; // 如果key对应得消息没有被人订阅,则返回
if (!fn){
fns && (fns.length = 0) // 如果没有传入具体的回调函数,表示需要取消key对应消息的所有订阅
}else {
for (var i = fns.length - 1; i > 0; i--) { // 反向遍历订阅的回调函数列表
var _fn = fns[i]
if (_fn === fn){
fns.splice(i, 1) // 删除对应得回调函数
}
}
}
}
return {
listen: listen,
trigger: trigger,
remove: remove
}
})()
Event.listen('ss88', function (data) {
console.log(data);
})
Event.trigger('ss88', 222)