今天是准备面试的第九天。
发布-订阅模式又叫观察者模式,它定义对象间的一对多的关联关系,当一个对象发生改变,与其关联的对象也会得到通知。
一、现实实例
无论是现实世界还是代码世界,观察者模式都有广泛的应用。
小明最近看上了一套房子,到了售楼处才被告知,该楼被售罄,好在该工作人员告诉小明,不久后还有一部分尾盘推出,开发商正在办理相关手续,当手续办理完成就可以购买。但没有人知道手续多久办理完成,于是小明记下了售楼处的电话,小明每天都会询问售楼处是否可以购买。除了小明,还有小红,小刚,小王每天都会向售楼处询问这个问题。在一周过后,售楼处的工作人员集体辞职,因为他们已经厌倦每天回答1000个相同问题的电话。
哈哈哈哈,当然现实生活中没有这么笨的公司。现实是这样的:小明在离开之前把自己的电话号码留给了售楼处的工作人员,工作人员答应他楼盘一推出,马上发消息给小明。小红,小刚,小王也和他一样。他们的电话记录在售楼处的花名册上,楼盘推出的时候,工作人员翻开花名册,遍历上面的电话,依次发一条消息来通知他们。
这就是典型的发布-订阅模式,小明,小红,小刚等购买者都是订阅者,订阅了房子开售的消息,售房处作为发布者,遍历花名册上,依次给购房者发布消息。
使用发布-订阅模式有显而易见的优点:购房者不需要每天给售楼处打电话询问开售时间,在合适的时间,售楼处作为发布者会通知这些消息订阅者。购房者和售楼处不再强耦合在一起。当有购房者出现,只需要将电话留在售楼处,售楼处不关心购房者任何情况,不管购房者是男,是女,还是一个猴子。同时售楼处做任何变动都不影响购买者,比如工作人员离职,售楼处从一楼搬到二楼。这些都和购房者无关,主要售楼处记得发信息就行。
二、代码深入了解实现
1.简单实现
通过上面的例子,我们应该大致了解了发布-订阅者模式,那我们用代码模拟实现以下这个模式:
实现步骤:
- 先指定谁是发布者(售楼处)
- 然后给发布者添加一个缓存列表,用来存放回调函数以便通知订阅者(售楼处的花名册)
- 最后发布消息的时候,发布者遍历这个缓存列表,依次触发里面缓存的订阅函数(遍历花名册,发送售房消息)
另外,还可以在发布消息时,在回调函数里添加一些参数,订阅者可以接受到这些参数。这是非常必要的,比如售楼处可以在发给订阅者短信里加上房子的单价,面积等信息,订阅者可以接受到这个消息是进行各自的处理。
let salesOffice = {};//售楼处
salesOffice.clientList = [];//缓存列表,存放订阅者的回调函数(花名册)
salesOffice.listen = function(fn){//将订阅者添加到缓存列表
this.clientList.push(fn);
}
salesOffice.trigger = function(){//发布消息
if(!this.clientList){
return ;
}
for(let i = 0; i < this.clientList.length; ++i){//遍历缓存列表
let fn = this.clientList[i];
fn.apply(this, arguments);//arguments:发布消息带上的参数
}
}
salesOffice.listen((squareMeter,price)=>{//小明订阅的消息
console.log('面积=' + squareMeter);
console.log('价格=' + price);
})
salesOffice.listen((squareMeter,price)=>{//小红订阅的消息
console.log('面积=' + squareMeter);
console.log('价格=' + price);
})
salesOffice.trigger(88, 10000);//售楼处发布的消息
salesOffice.trigger(66, 20000);//售楼处发布的消息
到这里,我们已经实现了简单的发布-订阅模式。但这里还存在一些问题。订阅者接受到了发布者发布的所有消息,虽然小明只想购买88平米的房子,但发布者依然将66平米的房子消息也推送给了小明。这对小明来说是不必要的困扰,所有我们添加一个key值让订阅者,订阅他所感兴趣的消息。
let salesOffice = {};//售楼处
salesOffice.clientList = {};//缓存列表,存放订阅者的回调函数(花名册)
salesOffice.listen = function(key, fn){//将订阅者添加到缓存列表
if(!this.clientList[key]){
this.clientList[key] = [];
}
this.clientList[key].push(fn);
}
salesOffice.trigger = function(){//发布消息
let key = Array.prototype.shift.call(arguments);//取出参数中的key值
let fns = this.clientList[key];
if(!fns || !fns.length){//没有订阅这个消息就返回
return false;
}
for(let i = 0; i < fns.length; ++i){
let fn = fns[i];
fn(...arguments);
}
}
salesOffice.listen('squareMeter88', (price)=>{
console.log('价格=' + price);
});
salesOffice.listen('squareMeter66', (price)=>{
console.log('价格=' + price);
})
salesOffice.trigger('squareMeter88', 20000);
有没有办法让所有的对象都有发布-订阅功能呢?javascript作为一门解释执行的语言,给对象添加职责是理所当然的事情,将功能提前出来,放在一个单独的对象内。
let Event = {
clientList: {},
listen: function(key, fn){
if(!this.clientList[key]){
this.clientList[key] = [];
}
this.clientList[key].push(fn);
},
trigger: function(){
let key = Array.prototype.shift.call(arguments);
let fns = this.clientList[key];
if(!fns && !fns.length){
return ;
}
for(let i = 0; i < fns.length; ++i){
let fn = fns[i];
fn(...arguments);
}
}
}
let intallEvent = function(obj){
for(let i in Event){
obj[i] = Event[i];
}
return obj;
}
let salesOffice = {};
intallEvent(salesOffice);
salesOffice.listen('squareMeter', (price)=>{
console.log('价格=' + price);
})
salesOffice.trigger('squareMeter', 20000)
有时候也需要取消订阅,当小明突然不想买房子了,避免继续接受到售楼处发过来的信息,小明需要取消之前订阅的消息,现在给Event添加remove方法。
let Event = {
clientList: {},
listen: function(key, fn){
if(!this.clientList[key]){
this.clientList[key] = [];
}
this.clientList[key].push(fn);
},
trigger: function(){
let key = Array.prototype.shift.call(arguments);
let fns = this.clientList[key];
if(!fns || !fns.length){
return ;
}
for(let i = 0; i < fns.length; ++i){
let fn = fns[i];
fn(...arguments);
}
},
remove: function(key, fn){
let fns = this.clientList[key];
if(fns){
if(fn){
for(let i = fns.length - 1; i >= 0; --i){
if(fns[i] === fn){
fns.splice(i, 1);
}
}
}else{
fns.length = 0;
}
}
}
}
let installEvent = function(obj){
for(let i in Event){
obj[i] = Event[i];
}
}
let salesOffice = {};
installEvent(salesOffice);
salesOffice.listen('squareMeter88', (price)=>{
console.log('价格=' + price);
})
salesOffice.trigger('squareMeter88', 20000);
salesOffice.remove('squareMeter88');
salesOffice.trigger('squareMeter88', 20000);
2.全局发布订阅对象
刚刚实现的发布-订阅模式,依然有一些问题:1.给每个发布者对象添加listen,trigger,remove等属性,这其实是一种资源的浪费。2.小明和售楼处依然保持一定的耦合性,小明至少需要知道售楼处的名字(salesOffice),才能订顺利订阅到事件。
salesOffice.listen('squareMeter88', (price)=>{
console.log('价格=' + price);
})
如果小明想订阅200平米的房子,而这个房子的卖家是salesOffice2,这意味这小明需要订阅salesOffice2对象。
salesOffice2.listen('squareMeter88', (price)=>{
console.log('价格=' + price);
})
其实现实中,买房子未必需要亲自去售楼处,只要将订阅的请求交给中介公司,而各大房产公司也只需要通过中介公司发布房子的信息,这样一来,并不用关心消息是来自哪个公司,在意的是是否能顺利的收到消息。为了保证订阅者和发布者能正常通讯,订阅者和发布者必须要知道这个中介公司。
可以使用一个全局的Event对象来实现,订阅者不需要知道消息是来自那个发布者,发布者不需要知道消息推送给了哪个订阅者,Event作为一个类似"中介"的角色,将订阅者和发布者联系在了一起。
let Event = (function(){
let clientList = {},
listen,
trigger,
remove;
listen = function(key, fn){
if(!clientList[key]){
clientList[key] = [];
}
clientList[key].push(fn);
}
trigger = function(){
let key = Array.prototype.shift.call(arguments);
let fns = clientList[key];
if(!fns || !fns.length){
return ;
}
for(let i = 0; i < fns.length; ++i){
let fn = fns[i];
fn(...arguments);
}
}
remove = function(key, fn){
let fns = clientList[key];
if(fns){
if(fn){
for(let i = fns.length - 1; i >= 0; --i){
if(fns[i] === fn){
fns.splice(i, 1);
}
}
}else{
fns.length = 0;
}
}
}
return {
listen: listen,
trigger: trigger,
remove: remove
};
})();
Event.listen('squareMeter88', (price)=>{
console.log('价格=' + price);
});
Event.trigger('squareMeter88', 20000);
Event.remove('squareMeter88');
Event.trigger('squareMeter88', 30000);
模块通讯(高频面试考点)
上面实现了发布-订阅模式,它是基于Event全局对象的,可以利用它将两个封装良好的模块中进行通讯,这两个模块可以完全不知道对方的存在。
比如现在有两个模块,a模块有一个点击按钮,每次点击后,b模块的div中会显示按钮被点击的次数,使用发布-订阅模式,使得a,b模块可以在不改变封装性的条件下进行通讯。
<button class="click">确定</button>
<div id="num">0</div>
let a = (function(){
let count = 0;
let button = document.querySelector('.click');
button.onclick = function(){
Event.trigger('clickButton', ++count);
}
})();
let b = (function(){
let div = document.querySelector('#num');
Event.listen('clickButton', (count)=>{
div.innerHTML = count;
})
})();
3.完整代码
我们这样封装了发布-订阅模式,还有一些问题:
- 有时候我们需要先发布后订阅(比如:当我们聊QQ的时候,你进入离线状态你的朋友给你发消息,你登录时依然可以接受到消息),这种需求在实际项目中是存在的,比如在商城网站中,获取到用户信息之后才能渲染用户导航模块,而获取用户信息的操作是一个ajax异步请求。当ajax请求成功返回之后会发布一个事件,在此之前订阅了此事件的用户导航模块可以接收到这些用户信息
但是这只是理想的状况,因为异步的原因,不能保证ajax请求返回的时间,有时候它返回得比较快,而此时用户导航模块的代码还没有加载好(还没有订阅相应事件),特别是在用了一些模块化惰性加载的技术后,这是很可能发生的事情。也许还需要一个方案,使得的发布—订阅对象拥有先发布后订阅的能力。
为了满足这个需求,要建立一个存放离线事件的堆栈,当事件发布的时候,如果此时还没有订阅者来订阅这个事件,暂时把发布事件的动作包裹在一个函数里,这些包装函数将被存入堆栈中,等到终于有对象来订阅此事件的时候,将遍历堆栈并且依次执行这些包装函数,也就是重新发布里面的事件。当然离线事件的生命周期只有一次,就像QQ的未读消息只会被重新阅读一次,所以刚才的操作只能进行一次 - 全局的发布—订阅对象里只有一个clinetList来存放消息名和回调函数,大家都通过它来订阅和发布各种消息,久而久之,难免会出现事件名冲突的情况,所以还可以给Event对象提供创建命名空间的功能
给你们提供完整的代码,我也看了很久才理解到下面的这部分代码,虽然花了很多时间,但收获颇多。如果有推不通的可以给我留言。
let Event = function(){
let global = this,
Event,
_default = 'default';
Event = function(){
let _listen,
_trigger,
_remove,
_create,
each,
_self = this,
_shift = Array.prototype.shift,
_unshift = Array.prototype.unshift,
namespaceCache = {};
each = function(stack, fn){
for(let i = 0; i < stack.length; ++i){
let n = stack[i];
fn.call(n);
}
}
_listen = function(key, fn, cache){
let fns = cache[key];
if(!fns){
cache[key] = [];
}
cache[key].push(fn)
}
_trigger = function(){
let cache = _shift.call(arguments);
let key = _shift.call(arguments);
let args = arguments;
let _self = this;
let fns = cache[key];
if(!fns || !fns.length){
return false;
}
return each(fns, function(){
return this.apply(_self, args);
})
}
_remove = function(key, cache, fn){
let fns = cache[key];
if(fns){
if(fn){
for(let i = 0; i < fns.length; i++){
if(fns[i] === fn){
fns.splice(i, 1);
}
}
}else{
fns.length = 0;
}
}
}
_create = function(namespace){
namespace = namespace || _default;
let listen,
trigger,
remove,
ret,
_self = this,
offlineStack = [],
cache = {};
ret = {
listen: function(key, fn, last){
_listen(key, fn, cache);
if(!offlineStack || !offlineStack.length){
offlineStack = null;
return ;
}
if(last === 'last'){
}else{
each(offlineStack, function(){
this();
})
}
offlineStack = null;
},
one: function(key, fn, last){
_remove(key, cache);
this.listen(key, fn, last);
},
trigger: function(){
_unshift.call(arguments, cache);
let args = arguments,
_self = this;
fn = function(){
_trigger.apply(_self, args);
}
if(offlineStack){
return offlineStack.push(fn);
}
return fn();
},
remove: function(key, fn){
_remove(key, cache, fn);
}
}
return namespace ? (namespaceCache[namespace] ? namespaceCache[namespace] : namespaceCache[namespace] = ret) : ret;
}
return {
create: _create,
listen: function(key, fn, last){
let event = this.create();
event.listen(key, fn, last);
},
one: function(key, fn, last){
let event = this.create();
event.one(key, fn, last);
},
trigger: function(){
let event = this.create();
event.trigger.apply(this, arguments)
},
remove: function(){
let event = this.create();
event.remove(key, fn);
}
}
}();
return Event;
}();
Event.create('namespace1').listen('click', function(a){
console.log(a)
})
Event.create('namespace1').trigger('click', 1)
Event.create('namespace2').listen('click', function(a){
console.log(a)
})
Event.create('namespace2').trigger('click', 3)
Event.trigger('click',1);
Event.listen('click',function(a){
console.log(a); //输出:1
});
发布—订阅模式,也就是常说的观察者模式,它的优点非常明显,一为时间上的解耦,二为对象之间的解耦。应用也非常广泛,既可以用在异步编程中,也可以帮助完成更松耦合的代码编写。发布—订阅模式还可以用来帮助实现一些别的设计模式,比如中介者模式。从架构上来看,无论是MVC还是MVVM,都少不了发布—订阅模式的参与,而且javascript本身也是一门基于事件驱动的语言
当然,发布—订阅模式也不是完全没有缺点。创建订阅者本身要消耗一定的时间和内存,而且订阅一个消息后,也许此消息最后都未发生,但这个订阅者会始终存在于内存中。另外,发布—订阅模式虽然可以弱化对象之间的联系,但如果过度使用的话,对象和对象之间的必要联系也将被深埋在背后,会导致程序难以跟踪维护和理解。特别是有多个发布者和订阅者嵌套到一起的时候,要跟踪一个bug不是件轻松的事情。