发布-订阅模式

发布-订阅模式 可以说是学前端第一个接触的设计模式了,因为只要学到 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 写成构造函数,实例化出来也可以解决,但是显然不行,因为 发布 和 订阅不是在一个作用域里)

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,406评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,732评论 3 393
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,711评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,380评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,432评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,301评论 1 301
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,145评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,008评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,443评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,649评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,795评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,501评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,119评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,731评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,865评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,899评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,724评论 2 354

推荐阅读更多精彩内容

  • 发布/订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都...
    风铭阅读 2,659评论 0 1
  • 发布订阅模式 发布/订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依...
    自度君阅读 1,974评论 0 2
  • 又叫做观察者模式,定义对象之中一种一对多的依赖关系,当一个对象的状态发生改变时候,所有依赖于它的对象都将得到通知。...
    寿_司阅读 3,817评论 0 1
  • 摘自《JavaScript设计模式与开发实践》 发布 - 订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关...
    小小的白菜阅读 2,898评论 1 8
  • 什么是发布订阅模式? 发布订阅模式又叫做观察者模式,它定义了一种一对多的关系,让多个观察者对象同时监听某一个主题对...
    DCbryant阅读 1,401评论 0 0