JS发布-订阅模式 和 观察者模式

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.性能消耗更大,订阅发布模式需要维护事件队列,订阅的事件越多,内存消耗就越大。

观察者模式:

优点:响应式,目标变化就会通知观察者,这是观察者模式最大的优点,也是因为这个优点观察者模式才会在前端这么出名。

缺点:不灵活,相比发布订阅模式,由于目标和观察者是耦合在一起的,所以观察者模式需要同时引入目标和观察者才能达到响应式的效果。而发布订阅模式只需要因为事件中心,订阅者和发布者可以不在一处。

参考:发布订阅模式和观察者模式区别_sky_100的博客-CSDN博客

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值