1、发布订阅模式
发布-订阅模式不同于观察者模式,之前经常容易将两者统一起来, 它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖它的对象都将得到通知。在 JS 开发中,我们一般用事件模型来替代传统的发布-订阅模式。
1.发布-订阅模式可以广泛应用于异步编程中,这是一种替代传递回调函数的方案。比如我们可以订阅 ajax 请求的 error ,success 等事件。或者如果想在动画的每一帧完成之后做一些事情,那我们可以订阅一个事件,然后在动画的每一帧完成之后发布这个事件。在异步编程中使用发布-订阅模式,我们就无需过多关注对象在异步运行期间的内部状态,而只需要订阅感兴趣的事件发生点。
2.发布-订阅模式可以取代对象之间的硬编码的通知机制,一个对象不用再显式的调用另一个对象的某个接口。发布-订阅模式让两个对象松耦合的联系在一起,虽然不太清楚彼此的细节,但这不影响他们之间的相互通信。当有新的订阅者出现时,发布者的代码不需要任何修改;同样发布者需要修改时,也不会影响到之前的订阅者。只要之前约定的事件名没有变化,就可以自由的改变它们。
1.DOM 事件
我们使用的 DOM 节点上绑定事件函数,就是发布-订阅模式
document.body.addEventListener('click',function(){
alert(123);
});
document.body.click();
这里需要监控用户点击 document.body 的动作,但是我们没办法预知用户将在什么时候点击。所以我们订阅 document.body 上的 click 事件,当 body 节点被点击时,body 节点便会向订阅者发布这个消息。这很像购房例子,购房者不知道房子什么时候开售,于是他在订阅消息后等待售楼处发布消息。
当然我们可以随意增加或者删除订阅者,增加任何订阅者都不会影响发布者,例如:
document.body.addEventListener('click',function(){
alert(345);
});
document.body.addEventListener('click',function(){
alert(456);
})
2.自定义事件
除了 DOM 事件,我们经常会实现一些自定义事件,这种依靠自定义事件完成的发布-订阅模式可以用于任何 JavaScript 代码中。实现步骤:
- 首先要指定谁充当发布者(比如售楼处);
- 然后给发布者添加一个缓存列表,用于存放回调函数以便通知订阅者(售楼处的花名册);
- 最后发布消息,发布者会遍历这个缓存列表,一次触发里面存放的订阅者回调函数(遍历花名册,挨个给客户发短信);
另外,我们还可以往毁掉函数里面填入一些参数,订阅者可以接收这些参数。这是很必要的,比如售楼处可以在发给订阅者的短信里加上房子的单价、面积、容积率等信息,订阅者接收到这些信息后可以进行各自的处理:
var salesOffices = {}; //定义售楼处
salesOffices.clientList = []; //缓存列表,存放订阅者的回调函数
salesOffices.listen = function(fn){ //增加订阅者
this.clientList.push(fn); //订阅的消息添加进缓存列表
}
salesOffices.trigger = function(){ //发布消息
for(var i = 0,fn;fn = this.clientList[i++];){
fn.apply(this,arguments); //arguments 是发布消息时带上的参数
}
}
测试代码:
salesOffices.listen(function(price,squareMeter){//小明订阅消息
console.log('价格:'+ price);
console.log('squareMeter:'+ squareMeter);
})
salesOffices.listen(function(price,squareMeter){//小红订阅消息
console.log('价格:'+ price);
console.log('squareMeter:'+ squareMeter);
})
salesOffices.trigger(20000,88); //输出两次:200万,88平
salesOffices.trigger(30000,110); //输出两次:300万,110平
至此我们实现了一个简单的发布-订阅模式,但这里还存在一些问题,我们看到订阅者接收到了发布者无差别发布的每个消息,虽然小明只想买 88 平的房子,但是发布者把小红需要的 110 平信息也发送给了小明,这对小明来说是不必要的困扰(骚扰短信)。所以我们有必要增加一个标识 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 = [].shift.call(arguments); //取出消息的类型 key
var fns = this.clientList[key]; //取出该消息对应的回调函数集合
if(!fns || fns.length === 0){ //如果没有订阅该消息,则返回
return false;
}
for(var i=0,fn;fn = fns[i++];){
fn.apply(this,arguments); //arguments 是发布消息时传递的参数
}
}
//测试
salesOffices.listen('sm88',function(price){//小明订阅的88平消息
console.log('price:'+price);
})
salesOffices.listen('sm110',function(price){//小红订阅的110平消息
console.log('price:'+price);
})
//发布消息
salesOffices.trigger('sm88',20000);//发布后,只有只有小明接收到这个88消息:price:2000000
salesOffices.trigger('sm110',30000);//发布后,只有只有小红接收到这个110消息:price:3000000
3.发布-订阅模式的通用实现
现在我们看到了如何让售楼处拥有接受订阅和发布事件的功能。假设现在小明又要去另一个售楼处买房子,那么是否必须在另一个售楼处对象上重写一次呢,有没有办法让所有的对象都拥有发布-订阅功能呢?答案是有的,JS 作为一门解释执行的语言,给对象添加职责是理所当然的,我们把发布-订阅的功能提取出来,放到一个单独的对象内:
var psEvent = {
clientList:{},
listen:function(key,fn){
if(!this.clientList[key]){
this.clientList[key] = [];
}
this.clientList[key].push(fn); //订阅的消息添加进对应类型的缓存列表
},
trigger:function(){
var key = [].shift.call(arguments);
var fns = this.clientList[key];
if(!fns || fns.length === 0){ //如果没有绑定对应的消息
return false;
}
for(var i = 0,fn; fn = fns[i++];){
fn.apply(this,arguments);//arguments 是 trigger 时传入的参数
}
}
}
再定义一个 installEvent 函数,这个函数可以给所有的对象都动态安装发布-订阅功能:
var installEvent = function(obj){
for(var i in psEvent){
obj[i] = psEvent[i];
}
};
测试:
var salesOffices = {}; //生命一个新的对象
installEvent(salesOffices);//给 salesOffices 对象安装发布订阅功能
//测试
salesOffices.listen('sm88',function(price){//小明订阅的88平消息
console.log('price:'+price);
})
salesOffices.listen('sm110',function(price){//小红订阅的110平消息
console.log('price:'+price);
})
//发布消息
salesOffices.trigger('sm88',20000);//发布后,只有只有小明接收到这个88消息:price:2000000
salesOffices.trigger('sm110',30000);//发布后,只有只有小红接收到这个110消息:price:3000000
4.取消订阅的事件
有时候,我们也许需要取消订阅事件的功能。例如小明突然不想买房子了,为了避免继续接收到售楼处的短信,小明需要取消之前订阅的事件。现在我们给 psEvent 对象增加 remove 方法:
psEvent.remove = function(key,fn){
var fns = this.clientList[key];
if(!fns){ //如果 key 对应的消息没有被人订阅,则直接返回
return false;
}
if(!fn){ //如果没有传入具体的回调函数,表示需要取消 key 对应消息的所有订阅
fns && (fns.length = 0);
}else{
for(var l = fns.length-1;l>=0;l--){ //反向遍历订阅的回调函数列表
var _fn = fns[l];
if(_fn === fn){
fns.splice(l,1); //删除订阅者的回调函数
}
}
}
}
测试:
var salesOffices = {}; //生命一个新的对象
installEvent(salesOffices);//给 salesOffices 对象安装发布订阅功能
//测试
salesOffices.listen('sm88',fn1 = function(price){//小明订阅的88平消息
console.log('price:'+price);
})
salesOffices.listen('sm110',fn2 = function(price){//小红订阅的110平消息
console.log('price:'+price);
})
//删除小明的订阅
salesOffices.remove('sm88',fn1); //删除后将不再打印小明的消息
//发布消息
salesOffices.trigger('sm110',30000);//发布后,只有只有小红接收到这个110消息:price:3000000
我们将其封装到了两个全局变量中,一个是 psEvent,一个是 installEvent,现在我们将其合并到一起,当传入一个参数对象时,我们把 psEvent 安装到这个传入的参数对象上,如果没有传入参数,则返回一个带有发布-订阅功能的对象。
function installEvent(initObj){
var psEvent = {
clientList:{},
listen:function(key,fn){
if(!this.clientList[key]){
this.clientList[key] = [];
}
//订阅的消息添加进相应的缓存列表
this.clientList[key].push(fn);
},
trigger:function(){
var key = [].shift.call(arguments);
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);//arguments 是调用时传入的参数列表
}
},
remove:function(key,fn){
var fns = this.clientList[key];
if(!fns || fns.length === 0) return false;
if(!fn){
fns && (fns.length = 0);
}else{
for(var i=0;i<fns.length;i++){
var _fn = fns[i];
if(_fn === fn){
fns.splice(i,1);
}
}
}
}
}
var EventObj = initObj ? initObj : Object.create(null);
for(let i in psEvent){
EventObj[i] = psEvent[i];
}
return EventObj;
}
5.全局的发布-订阅对象
刚刚我们实现的发布-订阅模式还存在了两个小问题:
1. 我们给每个发布者对象都添加了 listen 和 trigger 方法,以及一个缓存列表 clientList,这其实是一种资源浪费。
2. 小明跟售楼处对象还是存在一定的耦合性,小明至少要知道售楼处对象的名字是 salesOffices,才能顺利的订阅事件。代码:
salesOffices.listen('sm88',fn1 = function(price){//小明订阅的88平消息
console.log('price:'+price);
})
如果小明还关心 300 平的房子,而这套房子的卖家是 salesOffices2,这意味着小明要开始订阅 salesOffices2 对象,代码:
salesOffices2.listen('sm300',fn1 = function(price){//小明订阅的300平消息
console.log('price:'+price);
})
其实在现实中,买房子未必需要亲自去售楼处,我们只要把订阅的请求交给中介公司,而各大房产公司也只需要通过中介来发布房子信息。这样一来,我们不用关系消息是来自那个房产公司,我们在意的是能否顺利收到消息。当然,为了保证订阅者和发布者能顺利通信,订阅者和发布者都必须知道这个中介公司。
同样在程序中,发布-订阅模式可以用一个全局的 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),
fns = clientList[key];
if(!fns || fns.length===0){
return false;
}
for(var i=0,fn;fn=fns[i++];){
fn.apply(this,arguments);
}
};
remove = function(key,fn){
var fns = clientList[key];
if(!fns){
return false;
}
if(!fn){
fns && (fns.length=0);
}else{
for(var len = fns.length-1;len>=0;len--){
var _fn = fns[len];
if(_fn === fn){
fns.splice(len,1);
}
}
}
};
return {
listen:listen,
trigger:trigger,
remove:remove
}
})();
//测试:
Event.listen('sm88',function(price){//订阅消息
console.log(price,'price88');
});
Event.trigger('sm88',20000);//发布消息
6.模块间通信
上面我们实现的发布-订阅模式,是基于一个全局的 Event 对象,我们利用它可以在两个封装良好的模块中进行通信,这两个模块可以完全不知道对方的存在。就如果有了中介公司之后,我们不再需要知道房子开售的消息来自哪个售楼处。
比如现在有两个模块,a 模块里面有一个按钮,每次点击按钮后,b 模块里的 div 中会显示按钮的总点击次数,我们用全局发布-订阅模式完成下面的代码,使得 a 模块和 b 模块可以在保持封装性的前提下进行通信。
<!DOCTYPE html>
<html lang="en">
<body>
<button id="count">click me</button>
<div id="show"></div>
<script>
var a = (function(){
var count = 0;
var button = document.getElementById('count');
button.onclick = function(){
Event.trigger('add',count++);
}
})();
var b = (function(){
var div = document.getElementById('show');
Event.listen('add',function(count){
div.innerHTML = count;
})
})()
</script>
</body>
</html>
但是这里我们要留意另一个问题,模块之间如果用了太多的全局发布-订阅模式来通信,那么模块与模块之间的联系就被隐藏到了背后。我们最终会搞不清楚消息来自哪个模块,或者消息会流向哪个模块,这又会给我们的维护带来一些麻烦,也许某个模块的作用就是暴露一些接口给其他模块调用。
2.观察者模式
观察者模式定义了对象间的一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于他的对象都将得到通知,并自动更新。观察者模式属于行为型模式,行为型模式关注的时对象之间的通讯,观察者模式就是观察者与被观察者之间的通讯。
代码:简单实现就是被观察者里面存储了观察者的对象列表,当被观察者发生某种行为的时候,会调用观察者的方法。
class Observed {
constructor () {
this.state = 0
// 观察者队列
this.observers = []
}
getState () {
// 返回当前值
return this.state
}
setState (val) {
this.state = val
// 当值发生改变的时候通知所有的观察者
this.notify()
}
// 添加观察者到队列中
attach (observer) {
this.observers.push(observer)
}
notify () {
this.observers.forEach(observer => {
// 所有的观察者实现一个update方法来实现观察者的业务逻辑
observer.update(this.state)
})
}
}
// 观察者
class Observer {
constructor (name, observed) {
this.name = name
// 拿到被观察者
this.observed = observed
// 把当前的观察者添加到观察者队列中
this.observed.attach(this)
// 当前观察者要做的事情列表
this.effects = []
}
update (val) {
// 观察者监听到被观察着发生变化后所有的副作用在这里呈现
this.effects.forEach(effect => {
effect.call(this, val)
})
}
addEffects (effect) {
this.effects.push(effect)
}
}
// test
// 初始化一个被观察者对象
let observed = new Observed()
// 初始化一个观察者对象,传入name 和 被观察者
let observer1 = new Observer('ob1', observed)
observer1.addEffects(function(){console.log(this, `${this.name} updated, getState:${this.observed.getState()}`)})
let observer2 = new Observer('ob2', observed)
observer2.addEffects(function(val){console.log(this.name + 'val:', val)})
let observer3 = new Observer('ob3', observed)
observer3.addEffects(function(val){console.log(this.name + 'val:', val)})
// 改变被观察者的值
observed.setState(888)
3.观察者模式与发布订阅模式的区别
先从图片中看看区别:
可以看出,发布订阅模式相比观察者模式多了一个事件通道,事件通道作为调度中心,管理事件的订阅和发布工作,彻底隔绝了订阅者和发布者的依赖关系。即订阅者在订阅事件的时候,只关注事件本身,而不关心谁会发布这个事件;发布者在发布事件的时候,只关注事件本身,而不关心谁订阅了这个事件。
观察者模式有两个重要的角色,即目标和观察者,在目标和观察者之间没有事件通道的。一方面,观察者想要订阅目标事件,由于没有事件通道,因此必须将自己添加到目标中进行管理;另一方面,目标在触发事件的时候,也无法将通知操作委托给事件通道,因此只能亲自去通知所有的观察者。
差异总结:
- 角色角度来看,发布订阅模式需要三种角色,发布者、事件中心、订阅者。而观察者模式需要两种角色,目标和观察者,无事件中心负责通信。
- 从耦合度上来看,发布订阅模式是一个中心调度模式,订阅者和发布者是没有直接关联的,通过事件中心尽心关联,两者是解耦的。而观察者模式中的目标和观察者是直接关联的,耦合在一起(有些观念说观察者是解耦,解耦的是业务代码,不是目标和观察者本身)
4.观察者模式和发布订阅模式的区别
发布订阅模式:
优点:灵活,由于发布订阅模式的发布者和订阅者是解耦的,只要引入事件中心,无论在何处都可以发布订阅。同时发布者和订阅者相互之间不影响。
发布订阅模式在使用不当的情况下,容易造成数据流混乱,所以才有了React提出的单向数据流思想,就是为了解决数据流混乱的问题。
缺点:
1.容易导致代码不好维护,灵活是优点,同时也是缺点,使用不当的情况下就会造成数据流混乱,导致代码不好维护。
2.性能消耗更大,订阅发布模式需要维护事件队列,订阅的事件越多,内存消耗就越大。
观察者模式:
优点:响应式,目标变化就会通知观察者,这是观察者模式最大的优点,也是因为这个优点观察者模式才会在前端这么出名。
缺点:不灵活,相比发布订阅模式,由于目标和观察者是耦合在一起的,所以观察者模式需要同时引入目标和观察者才能达到响应式的效果。而发布订阅模式只需要因为事件中心,订阅者和发布者可以不在一处。