逐步深入发布-订阅模式

时间:2020-9-6 作者:admin


今天是准备面试的第九天。
发布-订阅模式又叫观察者模式,它定义对象间的一对多的关联关系,当一个对象发生改变,与其关联的对象也会得到通知。

一、现实实例

无论是现实世界还是代码世界,观察者模式都有广泛的应用。
小明最近看上了一套房子,到了售楼处才被告知,该楼被售罄,好在该工作人员告诉小明,不久后还有一部分尾盘推出,开发商正在办理相关手续,当手续办理完成就可以购买。但没有人知道手续多久办理完成,于是小明记下了售楼处的电话,小明每天都会询问售楼处是否可以购买。除了小明,还有小红,小刚,小王每天都会向售楼处询问这个问题。在一周过后,售楼处的工作人员集体辞职,因为他们已经厌倦每天回答1000个相同问题的电话。

哈哈哈哈,当然现实生活中没有这么笨的公司。现实是这样的:小明在离开之前把自己的电话号码留给了售楼处的工作人员,工作人员答应他楼盘一推出,马上发消息给小明。小红,小刚,小王也和他一样。他们的电话记录在售楼处的花名册上,楼盘推出的时候,工作人员翻开花名册,遍历上面的电话,依次发一条消息来通知他们。

这就是典型的发布-订阅模式,小明,小红,小刚等购买者都是订阅者,订阅了房子开售的消息,售房处作为发布者,遍历花名册上,依次给购房者发布消息。

使用发布-订阅模式有显而易见的优点:购房者不需要每天给售楼处打电话询问开售时间,在合适的时间,售楼处作为发布者会通知这些消息订阅者。购房者和售楼处不再强耦合在一起。当有购房者出现,只需要将电话留在售楼处,售楼处不关心购房者任何情况,不管购房者是男,是女,还是一个猴子。同时售楼处做任何变动都不影响购买者,比如工作人员离职,售楼处从一楼搬到二楼。这些都和购房者无关,主要售楼处记得发信息就行。

二、代码深入了解实现

1.简单实现

通过上面的例子,我们应该大致了解了发布-订阅者模式,那我们用代码模拟实现以下这个模式:
实现步骤:

  1. 先指定谁是发布者(售楼处)
  2. 然后给发布者添加一个缓存列表,用来存放回调函数以便通知订阅者(售楼处的花名册)
  3. 最后发布消息的时候,发布者遍历这个缓存列表,依次触发里面缓存的订阅函数(遍历花名册,发送售房消息)

另外,还可以在发布消息时,在回调函数里添加一些参数,订阅者可以接受到这些参数。这是非常必要的,比如售楼处可以在发给订阅者短信里加上房子的单价,面积等信息,订阅者可以接受到这个消息是进行各自的处理。

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.完整代码

我们这样封装了发布-订阅模式,还有一些问题:

  1. 有时候我们需要先发布后订阅(比如:当我们聊QQ的时候,你进入离线状态你的朋友给你发消息,你登录时依然可以接受到消息),这种需求在实际项目中是存在的,比如在商城网站中,获取到用户信息之后才能渲染用户导航模块,而获取用户信息的操作是一个ajax异步请求。当ajax请求成功返回之后会发布一个事件,在此之前订阅了此事件的用户导航模块可以接收到这些用户信息
      但是这只是理想的状况,因为异步的原因,不能保证ajax请求返回的时间,有时候它返回得比较快,而此时用户导航模块的代码还没有加载好(还没有订阅相应事件),特别是在用了一些模块化惰性加载的技术后,这是很可能发生的事情。也许还需要一个方案,使得的发布—订阅对象拥有先发布后订阅的能力。
      为了满足这个需求,要建立一个存放离线事件的堆栈,当事件发布的时候,如果此时还没有订阅者来订阅这个事件,暂时把发布事件的动作包裹在一个函数里,这些包装函数将被存入堆栈中,等到终于有对象来订阅此事件的时候,将遍历堆栈并且依次执行这些包装函数,也就是重新发布里面的事件。当然离线事件的生命周期只有一次,就像QQ的未读消息只会被重新阅读一次,所以刚才的操作只能进行一次
  2. 全局的发布—订阅对象里只有一个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不是件轻松的事情。

声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。