『面试的底气』—— 详细说明订阅-发布模式在下面场景的应用,设计模式之发布-订阅模式
定义
发布-订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。在 JavaScript 开发中,我们一般用事件模型来替代传统的发布—订阅模式。
理解
用一个生活中的例子来生动介绍一下发布-订阅模式。
小王最近看上一款手机,而这款手机只有在线下体验店才有卖,于是小王跑到线上体验店去购买时,销售员小敏告诉小王,这款手机昨天已经卖完了。但是店长已经去进货了,然后什么时候能进到货就不太清楚了。最后小王记下售货员小敏的电话就离开了。
在随后的日子中,小王每天都会打电话给小敏,咨询手机到货了没有。然后除了小王,还是小林、小李、小赵等等也会打电话给小敏,咨询手机到货了没。就这样过了一个礼拜,小敏辞职了,为啥呢?因为每天都忙着接电话,都没有空卖其他手机,没有业绩赚不到钱。
当然现实中没傻的销售员,一般销售员只会告诉顾客,如何你想购买的话,留下你的电话号码,手机一到货就马上通知你。于是小王、小林、小李、小赵的名字和电话号码都被销售员记在体验店中一个本子上,,当手机到货时,销售员就会翻开本子,一个个发短信过来通知他们过来买手机。
作用
在上面的例子中,发送短信通知就是一个典型的发布-订阅模式。小王、小林、小李、小赵等购买者都是订阅者,他们订阅了手机到货的消息,体验店作为发布者,体验店中的销售员会在手机到货的时候遍历本子上记录的电话号码,依次给购买者发布消息(发送短信通知)。
我们在这个例子中可以使用发布—订阅模式有着显而易见的优点:
购买者不用再天天给销售员打电话咨询手机到货时间,只要手机到货了,体验店作为发布者会通知这些消息订阅者。
购买者和体验店之间不再强耦合在一起,当有新的购买者出现时,他只需把手机号码留在体验店中的本子上,体验店不关心的任何情况,不管购买者是男是女。 而体验店的任何变动也不会影响购买者,比如销售员离职,体验店搬到顶一个地方,这些改变都跟购买者无关,只要体验店记得发短信这件事情。
在这里可以大概明确发布-订阅模式的作用:
发布—订阅模式可以广泛应用于异步编程中,这是一种替代传递回调函数的方案。在异步编程中使用发布—订阅模式,无需过多关注对象在异步运行期间的内部状态,只需要订阅想要获取的对象出现点即可。
发布—订阅模式可以取代对象之间硬编码的通知机制,一个对象不用再显式地调用另外一个对象的某个接口。发布—订阅模式让两个对象松耦合地联系在一起,虽然不太清楚彼此的细节,但这不影响它们之间相互通信。当有新的订阅者出现时,发布者的代码不需要任何修改;同样发布者需要改变时,也不会影响到之前的订阅者。只要之前约定的事件名没有变化,就可以自由地改变它们。
JavaScript中的发布-订阅模式
在上面介绍一个生活中的发布-订阅模式,接下来介绍一个JavaScript中非常常见的发布-订阅模式,那就是在 DOM 节点上面绑定事件函数。
document.body.addEventListener('click', function () {
console.log(1);
}, false);
document.body.click();
假如我们要监控用户点击document.body
的动作,但是我们没办法预知用户将在什么时候点击。所以我们用addEventListener
订阅document.body
上的click
事件,当 body 节点被点击时,body 节点便会向订阅
者发布这个消息。
当然我们还可以随意增加或者删除订阅者,增加任何订阅者都不会影响发布者代码的编写:
document.body.addEventListener('click', function () {
console.log(1);
}, false);
document.body.addEventListener('click', function () {
console.log(2);
}, false);
document.body.addEventListener('click', function () {
console.log(3);
}, false);
document.body.click();
其中document.body.click()
就是发布者,document.body.addEventListener('click',function () {},false)
就是订阅者,订阅者随便增加或删除,对于发布者没有任何影响。
小结
本文用一个买手机生活的例子来生动形象介绍了什么是发布-订阅模式,并从中提取出一些发布-订阅模式的优点和作用,最后介绍了一个JavaScript中一个现成的发布-订阅模式(节点绑定DOM事件)
初步实现发布-订阅模式
首先要指定好谁充当发布者(体验店);
然后给发布者添加一个缓存列表(体验店的本子),用于存放回调函数以便通知订阅者;
最后发布消息的时候,发布者会遍历这个缓存列表,依次触发里面存放的订阅者回调函数(遍历本子,挨个发短信)。
const store = {}; // 定义发布者(体验店)
store.clientList = []; // 定义缓存列表(体验店的本子),存放订阅者(购买者)的回调函数
store.listen = function (fn) { // 增加订阅者(购买者)
this.clientList.push(fn); // 订阅的消息添加进缓存列表
};
store.trigger = function () { // 发布消息
for (var i = 0, fn; fn = this.clientList[i++];) {
fn.apply(this, arguments); // arguments 是发布消息时带上的参数
}
};
添加订阅者,把小王和小李的电话号码记在本子上。
store.listen(function (price, color) { // 小王订阅消息
console.log(`手机到货了,颜色是${color},价格是${price}元,快来购买吧!`)
});
store.listen(function (price, color) { // 小李订阅消息
console.log(`手机到货了,颜色是${color},价格是${price}元,快来购买吧!`)
})
发布者发布消息,手机到货了,遍历本子上的购买者信息,发短信。
salesOffices.trigger( 9999, '土豪金' ); // 输出:手机到货了,颜色是土豪金,价格是9999元,快来购买吧!
salesOffices.trigger( 9668, '天空蓝' ); // 输出:手机到货了,颜色是天空蓝,价格是9668元,快来购买吧!
不必要的推送
上面已经实现了一个最简单的发布-订阅模式,但是其中有一个很严重的问题,小王只订阅了土豪金的手机到货的消息,却一直收到天空蓝的手机到货的消息,这就让小王很不爽了,所以我们有必要增加一个标示key
,让订阅者只订阅自己感兴趣的消息。改造一下上面的代码:
const store = {}; // 定义发布者(体验店)
store.clientList = {}; // 定义缓存列表(体验店的本子),存放订阅者(购买者)的回调函数
store.listen = function (key, fn) {
if (!this.clientList[key]) { // 如果还没有订阅过此类消息,给该类消息创建一个缓存列表
this.clientList[key] = [];
}
this.clientList[key].push(fn); // 订阅的消息添加进消息缓存列表
};
store.trigger = function () { // 发布消息
const key = Array.prototype.shift.call(arguments); // 取出消息类型
const fns = this.clientList[key]; // 取出该消息对应的回调函数集合
if (!fns || fns.length === 0) { // 如果没有订阅该消息,则返回
return false;
}
for (let i = 0, fn; fn = fns[i++];) {
fn.apply(this, arguments); // (2) // arguments 是发布消息时附送的参数
}
};
store.listen('土豪金', function (price) { // 小王订阅土豪金手机到货的消息
console.log(`手机到货了,颜色是土豪金,价格是${price}元,快来购买吧!`)
});
store.listen('天空蓝', function (price) { // 小李订阅天空蓝手机到货的消息
console.log(`手机到货了,颜色是${color},价格是${price}元,快来购买吧!`)
});
store.trigger('土豪金', 9999); // 输出:手机到货了,颜色是土豪金,价格是9999元,快来购买吧!
store.trigger('天空蓝', 9668); // 输出:手机到货了,颜色是天空蓝,价格是9668元,快来购买吧!
取消订阅
假如,小王不爽后,先取消订阅这家体验店手机到货的通知,结果发现根本没有取消手机到货的通知,小王更加恼火了,所以加急做一下取消订阅的功能。
store.remove = function (key, fn) {
const fns = this.clientList[key];
if (!fns) { // 如果 key 对应的消息没有被人订阅,则直接返回
return false;
}
if (!fn) { // 如果没有传入具体的回调函数,表示需要取消 key 对应消息的所有订阅
fns && (fns.length = 0);
} else {
for (let l = fns.length - 1; l >= 0; l--) { // 反向遍历订阅的回调函数列表
let _fn = fns[l];
if (_fn === fn) {
fns.splice(l, 1); // 删除订阅者的回调函数
}
}
}
}
store.remove('土豪金', function (price) { // 小王取消订阅土豪金手机到货的消息
console.log(`手机到货了,颜色是土豪金,价格是${price}元,快来购买吧!`)
});
小结
在上面实现了一个非常简单的发布-订阅模式,虽然功能都挺齐全了,但是还是有两点不好的地方。
要给每一个体验店对象添加
listen
、trigger
、remove
,还有一个缓存列表clientList
,造成内存浪费。小王和体验店存在一定的耦合性,小王至少要指定体验店在哪里,才能把名字、电话号码、想要的手机留在体验店才能订阅手机到货的通知。如果小王还想要一台玫瑰红的手机,还得跑一次体验店。
估计发布者和订阅者需要一个中介,订阅者不需要了解发布者的信息,发布者也不需要会把消息推送给那个订阅者。这样才能使发布者和订阅者直接解耦。
所以发布者和订阅者之间得需要一个中介,本文就来实现这个中介,这样发布者可以不必拥有添加订阅者 、发布消息、移除订阅者的方法、缓存订阅者的列表,把这些都交给中介。订阅者不需要了解消息来自哪个发布者,发布者也不知道消息会推送给哪些订阅者,中介会把订阅者和发布者联系起来。
基于中介的发布-订阅
var Event = (function () {
let clientList = {};// 缓存订阅者列表
const listen = function (key, fn) {// 添加订阅者 其中key为订阅者的标识
if (!clientList[key]) {
clientList[key] = [];
}
clientList[key].push(fn);
};
const trigger = function () {
// 通过`trigger`发布方法的第一参数来获取订阅者的标识
const key = Array.prototype.shift.call(arguments);
const fns = clientList[key];
if (!fns || fns.length === 0) {
return false;
}
for (let i = 0, fn; fn = fns[i++];) {
fn.apply(this, arguments);
}
};
const remove = function (key, fn) {
const fns = clientList[key];
if (!fns) {
return false;
}
if (!fn) {
fns && (fns.length = 0);
} else {
for (let l = fns.length - 1; l >= 0; l--) {
const _fn = fns[l];
if (_fn === fn) {
fns.splice(l, 1);
}
}
}
};
return {
listen: listen,
trigger: trigger,
remove: remove
}
})();
订阅者通过Event.listen
来订阅消息:
Event.listen('土豪金', function (price) { // 小王订阅消息
console.log(`手机到货了,颜色是土豪金,价格是${price}元,快来购买吧!`)
});
发布者通过Event.trigger
来发布消息:
Event.trigger('土豪金', 9999); // 输出:手机到货了,颜色是土豪金,价格是9999元,快来购买吧!
JavaScript中的发布-订阅模式
在JavaScript中,可以用注册回调函数的形式来简单地实现的发布-订阅模式,假如用类的方式来实现发布-订阅模式,还要把订阅者对象传入发布者对象中,同时订阅者对象还要提供一个更新 updata 的方法,供发布者在某个时候调用。
另外发布-订阅模式,还有推模型还是拉模型的区分,推模型是指在事件发生时,发布者一次性把所有更改的状态和数据都推送给订阅者。拉模型是指发布者仅仅通知订阅者我已经发布了,提供一些公开的接口供订阅者来主动拉取数据,模型的好处是可以让订阅者按需获取信息。
而在JavaScript中,arguments可以很方便地表示函数所接收的参数,所以在JavaScript中一般都会选择推模型,使用apply
方法把所有参数都推送给订阅者。
优点和缺点
发布-订阅模式的优点非常明显,一为时间上的解耦,二为对象之间的解耦。
发布-订阅模式也有缺点,创建订阅者本身要消耗一定的时间和内存,而且当你订阅一个消息后,也许此消息最后都未发生,但这个订阅者会始终存在于内存中。另外,发布—订阅模式虽然可以弱化对象之间的联系,但如果过度使用的话,对象和对象之间的必要联系也会被隐藏起来,当多个发布者和多个订阅者互相关联时,出现BUG将会很难跟踪和修复。