发布-订阅设计模式

700 阅读16分钟

概念

发布订阅模式(观察者模式):是一个一对多的概念,一个对象状态发生改变的时候,所以依赖于它的对象都将会收到通知

生活中的例子

🌰 小红,小明,小雪,小王 都想要买一栋楼,但是在售楼中心,这一期的楼已经卖完了,现在的情况是。有一期新的楼盘将要开盘,如果小红,小明,小雪,小王还想买楼的话,应该怎么办?

方法一:

小红,小明,小雪,小王每一天给售楼中心的管理人打电话,询问新楼盘的进展,但是一旦售楼处的人离职了,或是有工作上的变动,这一条路就走不通了。

方法二:(常规想法) 发布订阅的模式

小红,小明,小雪,小王给售楼中心留下自己的电话,然后一旦售楼中心有了消息就会一个个的打电话通知这些人。小红,小明,小雪,小王就不需要每一天去关注售楼处的进度。只需要等待通知就好了。

订阅者:小红,小明,小雪,小王

发布者:售楼中心

从例子看

  • 购房者不用天天给售楼处打电话咨询开售的时间,在合适的时间点,售楼中心会作为发布者通知这些消息的订阅者
  • 购房者和售楼部不是强耦合在一起,当有新的够购房者出现的时候,只需要留一下电话号码在售楼处,售楼处并不关心购房者的任何情况,比如说:购房者是男是女,而且售楼中心的任何变更也不会影响购买者,比如说某个工作人员离职啦,或是售楼中心从一楼搬到二楼,这些改变和购房者也是没有关系,售楼中心只需要记得发短信这件事情。

发布订阅模式的优势

  • 订阅者不需要实时的关注发布者的消息的状态,在合适的时间点,发布者都会发布消息,然后通知订阅者
  • 发布者和订阅者不存在强耦合关系,发布者和订阅者只是关注消息的情况,二者各自的修改并不会影响到对方,只要双方约定的事件名没有发生改变,可以自由的各自修改他们。也就是说,虽然双方虽然不是很了解对方的细节,但是这并不影响它们之间的通信。
  • 当有新的订阅出现的时候,发布者的代码不需要有任何的改变;当发布者有需要改变的时候,也不会影响到之前的订阅者,只要双方约定的事件的名称没有发生改变,就可以自由的改变他们。

DOM事件

document.body.addEventListener('click',()=>{
    console.log('哈哈哈')
},false)

document.body.click() // 模拟用户的点击

我们只需要监控用户点击document.body的动作,但是我们是没有办法知道用户会什么时候点击。所以我们的操作就是订阅document.body上面的click事件。

类比一下上面的例子🌰 :

购房者不知道什么时候房子开售,于是乎他在订阅消息后等待售楼处发布消息

我们还可以随意的增加和删除订阅者,增加和删除任何的订阅者都不会影响发布者的代码的编写:

document.body.addEventListener('click',()=>{
    console.log('哈哈哈')
},false)

document.body.addEventListener('click',()=>{
    console.log('呵呵呵')
},false)

document.body.addEventListener('click',()=>{
    console.log('哦哦哦')
},false)

document.body.click() // 模拟用户的点击

自定义的事件

我们想要实现自定义的订阅发布事件,应该如何一步步的实现呢

  • 首先要先指定好谁充当发布者(售楼处)
  • 给发布者添加一个缓存列表,用于存放列表函数以便可以通知订阅者
  • 在发布消息的时候,发布者会遍历这个缓存的列表,依次触发存放在订阅者的回调函数

举一个🌰

     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); // 发布消息的时候带上的参数
       }
     };

     // 小明订阅消息
     salesOffices.listen(function (price, size) {
       console.log("小明价格:" + price);
       console.log("房间的大小:" + size);
     });
     
     // 小红订阅消息
     salesOffices.listen(function (price, size) {
       console.log("小红价格:" + price);
       console.log("房间的大小:" + size);
     });

     salesOffices.trigger(2000, 100);
     salesOffices.trigger(3000, 200);

结果

上面的例子是一个最简单的发布-订阅模式的例子,其实设计模式也没有我们想象中那么复杂,但是在这个例子中我们发现还是存在一定的问题。比如说小明志向买100平方米的房子,但是发布者会将200平方米的信息也会推送给小明,这个会对小明造成不必要的困扰,所以我们有必要增加一个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 = Array.prototype.shift.call(arguments), //  取出消息的回调函数集合
         fns = this.clientList[key];
       if (!fns || fns.length === 0) {
         // 如果没哦呦订阅过消息,就返回
         return false;
       }

       for (var i = 0, fn; (fn = fns[i++]); ) {
         fn.apply(this, arguments);
       }
     };

     // 小明订阅消息
     salesOffices.listen("100", function(price) {
       // 小明订阅了88平方米的房子的消息
       console.log("小明价格:" + price);
     });

     // 小红订阅消息
     salesOffices.listen("200", function(price) {
       // 小红订阅了100平方米的消息的信息
       console.log("小红价格:" + price);
     });

     salesOffices.trigger("100", 100);
     salesOffices.trigger("200", 200);
     
     // 打出的结果
     // 小明价格:100
    // 小红价格:200

发布-订阅模式的通用实现

我们现在通过上面的例子看到了如何让售楼处拥有接受订阅和发布事件的功能,假设现在小明现在想要去另一家售楼中心,那么这段订阅发布的代码是否需要在另外一个售楼处重新写一次呢?有没有让办法让所有的对象都拥有发布订阅的功能呢?

答案显然是有的,JavaScript作为世界上最好的编程语言(解释执行语言),给对象动态的添加职责是理所当然的事情。

我们需要把发布订阅的功能提取出来,放在一个单独的对象中:

 // 把发布订阅的功能提取出来,放在一个单独的对象内
     var event = {
       clientList: [],
       listen: function(key, fn) {
         if (!this.clientList[key]) {
           // 如果还没有订阅过此类消息,给该消息创建一个缓存列表
           this.clientList[key] = [];
         }
         this.clientList[key].push(fn); // 订阅的消息添加进消息列表
       },
       trigger: function() {
         var key = Array.prototype.shift.call(arguments), //  取出消息的回调函数集合
           fns = this.clientList[key];
         if (!fns || fns.length === 0) {
           // 如果没订阅过消息,就返回
           return false;
         }

         for (var i = 0, fn; (fn = fns[i++]); ) {
           fn.apply(this, arguments);
         }
       },
     };
     
     // 这个函数可以给所有的对象都安装动态安装发布订阅的功能
     var installEvent = (obj) => {
       for (var i in event) {
         obj[i] = event[i];
       }
     };

     var salesOffices = {}; // 定义售楼处
     installEvent(salesOffices);

     // 小明订阅了88平方米的房子的消息
     salesOffices.listen("100", function(price) {
       console.log("小明价格:" + price);
     });

     // 小红订阅消息
     salesOffices.listen("200", function(price) {
       console.log("小红价格:" + price);
     });

     salesOffices.trigger("100", 100);
     salesOffices.trigger("200", 200);

取消发布订阅事件

有的时候我们也需要取消发布订阅的事件,比如说小明突然就不想买房子了,为了避免每一天都收到售楼处推送过来的短信,小明需要取消之前的发布订阅事件,现在我们给event对象增加一个remove的方法。

     // 把发布订阅的功能提取出来,放在一个单独的对象内
      var event = {
        clientList: [],
        listen: function(key, fn) {
          if (!this.clientList[key]) {
            // 如果还没有订阅过此类消息,给该消息创建一个缓存列表
            this.clientList[key] = [];
          }
          this.clientList[key].push(fn); // 订阅的消息添加进消息列表
        },
        trigger: function() {
          var key = Array.prototype.shift.call(arguments), //  取出消息的回调函数集合
            fns = this.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 = this.clientList[key];
          // 如果这个key对应的消息没有被订阅过,就直接返回
          if (fns) {
            return false;
          }
          // 如果没有传入具体的回调函数,表述取消key对应的所有的订阅
          if (!fn) {
            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 installEvent = (obj) => {
        for (var i in event) {
          obj[i] = event[i];
        }
      };

      var salesOffices = {}; // 定义售楼处
      installEvent(salesOffices);

      // 小明订阅了88平方米的房子的消息
      salesOffices.listen(
        "100",
        (f1 = function(price) {
          console.log("小明价格:" + price);
        })
      );

      // 小红订阅消息
      salesOffices.listen(
        "200",
        (f2 = function(price) {
          console.log("小红价格:" + price);
        })
      );

      salesOffices.remove("100", f1); // 删除小明的订阅
      salesOffices.trigger("200", 200); // 小红价格:200

实战的例子

假设我们现在要开发一个商城的网站,现在网站里面有header,nav,消息列表,购物车这些模块,这些的渲染有一个条件,就是用户已经登陆了,在header中要显示用户信息,这个信息是我们从后端获取的。但是ajax请求什么时候可以成功的返回用户的信息我们是没有办法获取到的。这就和上面的例子很像,小明也不知道什么时候楼盘开售。但是这个场景还不足以说明我们要使用发布订阅模式,因为异步我们可以使用回调来实现上面的诉求,但是我们不知道除了header头部,nav导航,消息列表,购物车之外还有上面地方需要这些用户信息。

login.success(function(data){
	header.setAvatar(data.avatar); // 设置header的头像
    nav.setAvatar(data.avatar); // 设置nav的头像
    message.refresh();// 属性消息列表
    cart.refresh(); // 刷新购物车的列表
})

现在我们复杂登陆模块的编写,但是这个时候我们必须了解header模块设置头像的方法是setAvatar,购物车和消息中心的刷新方法是refresh,这样的耦合性会让程序变得僵硬,header模块不能任意的修改setAvatar方法的名字,header的名字也不可以任意的改动为header1,header2。这种面向实现的编程是十分的不合适的。 等有一天,项目中又新增了一个收货地址管理模块,这个模块是另外一个人写的,但是这个时候他要询问:“hi,我需要在登陆之后刷新一下收货地址”,然后你又要翻开之前的代码,加上上面的一行代码:

login.success(function(data){
	header.setAvatar(data.avatar); // 设置header的头像
    nav.setAvatar(data.avatar); // 设置nav的头像
    message.refresh();// 属性消息列表
    cart.refresh(); // 刷新购物车的列表
    address.refresh(); // 刷新收货地址
})

这个时候我们需要使用发布-订阅模式的方法来重构代码。对用户信息感兴趣的业务将自行订阅成功的消息的事件,登录模块并不关心也业务方要做些什么,登录模块只需要在成功后发布成功的消息,在业务方接受到消息之后,进行各自业务的处理,登录模块不关心业务究竟长成什么样子,也不用去了解内部实现的细节。

$.ajax('http://xxx.com'function(data){
	login.trigger('loginSuccess',data)
})

当各模块监听到成功的消息之后:

var header =(function(){
	log.listen('loginSuccess',function(data){
    	header.setAvatar(data.avatar);
    });
    return {
    setAvatar:function(data){
    	console.log('设置header的头像')
        }
    }
})()

var nav =(function(){
	log.listen('loginSuccess',function(data){
    	nav.setAvatar(data.avatar);
    });
    return {
   	 setAvatar:function(data){
    	console.log('设置nav的头像')
        }
    }
})()

我们在自己的模块中可以随时修改setAvatar方法名,如果有一天又增加了一个收货地址的行为,我们只需要在收货地址中加上监听消息的方法就可以啦,这部分的功能可以让这部分模块的负责人去做。登录模块不需要再关心这些行为啦。

var address =(function(){
	log.listen('loginSuccess',function(data){
    	header.refresh(data);
    });
    return {
    refresh:function(data){
    	console.log('刷新收货地址')
        }
    }
})()

全局的发布-- 订阅对象

我们刚刚实现的发布-订阅模式,我们给售楼处对象和登录对象添加订阅和发布功能的时候,还存在两个小小的问题:

  • 每一个发布对象都添加了listen和trigger方法,也都注入了一个缓存列表clientList,这是一种资源的浪费。
  • 小明跟售楼处还是存在一定的耦合性,小明至少要知道售楼处的名字是saleOffices,才可以顺利的订阅事件
     // 小明订阅消息
    salesOffices.listen("100", function(price) {
      console.log("小明价格:" + price);
    });

如果小明还关心300平方米的房子,而这套房子的卖家是saleOffices2对象,我们需要再订阅一次

    // 小明订阅消息
   salesOffices2.listen("300", function(price) {
     console.log("小明价格:" + price);
   });

在现实中,买房子不一定要去售楼处,我们只需要吧订阅的请求交给中介公司,而各国房产公司也需要通过中介公司来发布房子的消息。这样一来我们就不需要关心消息是来自哪个房产公司,我们只在意是否可以顺利的收到消息。为了保证二者可以顺利的通信,订阅者和发布者都必须知道中介公司。

我们在程序中,发布订阅模式可以用一个全局的event对象来实现,订阅者不需要了解是哪个发布者发布的,发布者也不知道推送给那些订阅这,现在event就是一个类似“中介者”的角色,把发布者和订阅者联系起来。

 var Event = (function() {
       var clientList = {};
       var listen;
       var trigger;
       var 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];
         // 如果这个key对应的消息没有被订阅过,就直接返回
         if (fns) {
           return false;
         }
         // 如果没有传入具体的回调函数,表述取消key对应的所有的订阅
         if (!fn) {
           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);
             }
           }
         }
       };
       return { listen: listen, trigger: trigger, remove: remove };
     })();

     Event.listen("100", function(price) { // 小明订阅消息
       console.log("小明价格:" + price);
     });
     Event.trigger("100", 888888); // 售楼处发布消息

模块中的通信

上一节中实现了发布-订阅模式的实现,是基于全局Event对象,我们利用它可以在两个封装良好的模块中进行通信,这两个模块完全不知道对方的存在,就如同有了中介公司,我们不再需要知道房子开售的消息是来自哪个售楼处。

比如现在有两个模块,a模块里面有一个按钮,每次点击按钮之后,b模块的div中会现实按钮点击的总次数,我们用全局发布-订阅模式完成下面的代码。使得a模块和b模块可以保持封装性的前提下进行通信。

<!DOCTYPE html>
<html lang="en">
 <head>
   <meta charset="UTF-8" />
   <meta name="viewport" content="width=device-width, initial-scale=1.0" />
   <meta http-equiv="X-UA-Compatible" content="ie=edge" />
   <title>Static Template</title>
 </head>
 <body>
   <button id="count">点一点</button>
   <div id="show"></div>
 </body>
 <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>
</html>

但是这里我们需要留意另外一个问题。模块之间如果用了太多的全局订阅-发布模式来通信,那么模块与模块之间的联系就会被隐藏在背后,我们最后会搞不清楚消息是来自于哪个模块,或者是消息会流向哪个模块。这就会给我们的维护带来一定的麻烦,也许某个模块的作用就是暴露一些接口给其他的模块调用。

必须先订阅再发布嘛?

我们了解到订阅-发布事件,必须是订阅者先订阅一个消息,随后才可以接受到发布者发布的消息,但是如果把顺序调换过来,发布者先发布一条消息。而在这之前没有对象来订阅它,这条消息就会悄悄消失了。

在一些情况下,我们需要将这条消息保存下来,等到有对象来订阅它的时候,再重新把消息发布给订阅者。就好像qq中的离线消息一样,离线消息会被保存在服务起中,接受人下次登录上线之后,可以重新接受到这一条消息。

上面我们加载导航模块的功能,我们不能保证ajax的请求返回的事件,有的时候它会返回的比较快,而在这个时候导航模块的代码还没有加载完成(还没有相对应的订阅事件),特别是现在使用一些模块化的懒加载技术后,这是十分可能发生的事情,也许我们还需要一个方案,让我们发布-订阅对象拥有先发布后订阅的能力

为了满足这个需求,我们需要建立一存放离线事件的堆栈,当事件发布的时候,如果此时还没有订阅者来订阅这个事件,我们暂时把发布事件的动作包裹在一个函数中,这些包装函数将被存放在堆栈中,等代终于有对象来订阅这个事件的时候,我们遍历堆栈并且依次执行这些包装函数,也就是重新发布里面的事件,当然离线事件的生命周期只有依次,就像是QQ的未读消息会被重新阅读依次,所以我们的操作我们只能进行一次。

总结

优点:时间上解耦,对象之间实现解耦。 缺点: 创建订阅者本身就要消耗一定的时间和内存,而且当你订阅一个消息后,也许这个消息最终都没有发生,那这个订阅这就一直留在内存中,另外,发布-订阅模式可以弱化对象之间的联系,但是如果过度使用的,对象和对象之间的必要连也会被深埋在背后,导致程序的难以维护和理解。尤其是当多个发布者和订阅者嵌套在一起的时候,要跟踪一个bug并不是十分轻松的事情。