发布-订阅模式

发布-订阅模式 可以说是学前端第一个接触的设计模式了,因为只要学到 DOM ,就一定会用到事件监听,事件监听就是一种 发布-订阅模式的应用。

一个简单的发布-订阅例子:

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, squareMeter) {
  console.log(`价格${price}`);
})

salesOffices.listen(function(price, squareMeter) {
  console.log(`价格${price}`)
  console.log(`squareMeter = ${squareMeter}`)
})

salesOffices.trigger(20000, 99);
salesOffices.trigger(23333, 110);

这是一个及其简单的发布-订阅模式,存在的问题是:发布者只要触发事件,订阅者的事件一定会触发,无论订阅者是不是想要监听这个事件。

所以我们可以升级一下,给事件命个名,只有订阅者指定的事件发生了才通知订阅者:

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('squareMeter88', function(price, squareMeter) {
  console.log(`价格${price}`);
})

salesOffices.listen('squareMeter110', function(price, squareMeter) {
  console.log(`价格${price}`)
  console.log(`squareMeter = ${squareMeter}`)
})

salesOffices.trigger('squareMeter88', 2000000);
salesOffices.trigger('squareMeter110', 3000000);

就像 DOM 事件的 click 事件,mousemove事件等,给事件命名,就能触发特定类型的事件。

发布订阅的通用实现

一个通用的 发布-订阅 对象:

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);  // arguments 是 trigger 时带上的参数
    }
  }
}

再定义一个 ``installEvent``` 函数,这个函数可以给所有的对象都动态安装发布-订阅功能:

var installEvent = function(obj) {
  for (var i in event) {
    obj[i] = event[i];
  }
}

现在再测试一番,给salesOffices动态增加订阅-发布功能:

var salesOffices = {};
installEvent(salesOffices);

salesOffices.listen('squareMeter88', function(price, squareMeter) {
  console.log(`价格${price}`);
})

salesOffices.listen('squareMeter110', function(price, squareMeter) {
  console.log(`价格${price}`)
  console.log(`squareMeter = ${squareMeter}`)
})

salesOffices.trigger('squareMeter88', 2000000);
salesOffices.trigger('squareMeter110', 3000000);

取消订阅的事件

订阅的事件应该是允许取消的,因为订阅者有可能不需要接收订阅事件了。
我们给 event 对象增加 remove方法。

event.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.listen('squareMeter88', fn1 = function(price) {  // 函数需要有函数名
  console.log(``价格${price});
})

salesOffices.remove('squareMeter88', fn1);

真实的例子——网站登录

考虑以下场景:假如我们正在开发一个商城的网站,网站里的 header头部,nav导航,消息列表,购物车渲染,都有一个共同的前提条件,就是必须先用 ajax 异步请求获取用户的登录信息。
至于 ajax 什么时候可以请求成功返回用户信息,是没法确定的,虽然发布-订阅模式之外,我们可以在 ajax 的回调里添加处理,但是有一点很重要:我们不知道除了header头部,nav导航,消息列表,购物车之外,会不会以后还有其他模块需要用到用户的信息。这样就会出现 ajax 回调和用户信息的强耦合。比如下面这样:

login.succ(function(data) {
  header.setAvatar(data.avatar);  // header模块的头像设置
  nav.setAvatar(data.avatar);     // 导航模块的头像设置
  message.refresh();              // 刷新消息列表
  cart.refresh();                 // 购物车列表
})

这样会出现一种情况:有新的模块要接收用户信息,又要翻出三个月前写的登录 ajax 函数,往里加一个函数 如果这个函数不是你维护的,而是其他同事写的,你还要联系他让他添加上,这同事还不一定有空。
这就违反了 封闭-开放原则:动到了已经写好的函数。

用发布-订阅模式的话,让用户登录的 ajax 在获取到用户信息后发布登录成功的信息,需要用户信息的模块只要订阅了这个事件,就能接收到通知。登录模块不需要关心业务方究竟要做什么。

改善后的代码:

$.ajax('api.login', function(data) {
  login.trigger('loginSucc', data);
})

各模块监听登录成功的信息:

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

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

这就像 登录的 ajax 暴露出来了的 API,用的人想要就拿来用,ajax 不管你怎么用,业务方也不用关心 ajax 什么时候请求成功。

虽然使用 React、Vue 不需要用这种模式,因为登录成功后,传入组件的 props 会有一个不为空的对象(更新状态),组件拿到对象后只管渲染就行。 jQuery 时期,用命令式编程的时期用这种模式就很好。

全局的发布-订阅对象

在“发布订阅的通用实现”中,我们给一个具体的对象salesOffices注入了clientListlistentriggerremove,等属性和方法,才让salesOffices具有了发布和清除订阅事件的能力,而实际上,发布-订阅 对象可以进一步抽象为一个全局的 Event Bus。因为 trigger也好,订阅也好,都用不到某一个对象的自身属性,所以发布-订阅对象可以是一个公共的对象:

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 l = fns.length - 1; l >= 0; l--) {
        var _fn = fns[l];
        if (_fn === fn) {
          fns.splice(l, 1);
        }
      }
    }
  };

  return {
    listen,
    trigger,
    remove,
  }
})();

Event.listen('squareMeter88', function(price) {
  console.log(`价格${price}`);
})

Event.trigger('squareMeter88', 20000);

一个事件订阅模型就是这么简单。一个监听函数,一个触发函数,一个删除函数,一个监听队列。

模块间的通信

考虑以下需求:有一个div里面是数字0,和一个按钮,按钮每点击一次,div里的数值就➕1。

最直接的思路就是,给 btn 一个点击事件,在回调里直接拿到div的引用,直接改写 div 里的值。
这样做一个值得商榷的地方就是,你让两个元素知道了他们彼此的存在,这其实就是种耦合。有没有办法让两个元素之间彼此不知道对方就能实现这种效果?
就是使用 Event 对象。

必须先订阅后发布吗

前面都是先订阅好事件,等事件可以触发后再执行触发。类似预售,先登记要买,等货备好了再卖给你。 能不能反过来?先发布,再订阅?

听起来,如果先发布了却找不到订阅者,就像一个发出的信号,如果没人回应,最终会消失掉。

所以开发的思路里,就要想方法存储这个发出的信号,等有人订阅的时候,那个人就把这个信号拿出来。

全局事件的命名冲突

在全局事件 Event 中,只有一个 clientList来存放消息和回调函数,如果大家都通过它来订阅和发布各种消息,久而久之就会出现事件名冲突的情况,所以要给 Event 对象提供命名空间的方法。(一开始觉得也可以把 Event 写成构造函数,实例化出来也可以解决,但是显然不行,因为 发布 和 订阅不是在一个作用域里)

你可能感兴趣的:(发布-订阅模式)