发布-订阅模式又称观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象状态发生改变时,所有依赖它的对象都将得到通知。

生活中的发布-订阅模式

小明最近看上一套房子,到了售楼处后才被告知,该楼盘的房子已经售罄了,但是售楼处的 MM 告诉小明,不久后还会有尾盘推出,但是具体是什么时候还不确定。

于是小明记下了售楼处的电话,以后每天都会打电话询问是否已经到了购买时间,除了小明,还有小红、小虎、小强也会向售楼处咨询这个问题,一个星期过后,售楼 MM 决定辞职,因为厌倦了每天回答这么多重复的问题。

但是生活中肯定不会这样,而是小明把手机号留在售楼处,售楼 MM 答应他,当楼盘推出的时候就会发短信告知小明,小红、小虎、小强也是如此,他们的电话号码都被记在售楼处的花名册上,当新楼盘推出时,售楼MM 便会打开花名册,遍历上面的电话号码,一次发送短信来通知。

发布-订阅的作用

从上面的例子中,小明、小红、小虎就是订阅者,他们订阅了房子开售的消息,售楼处就是发布者,他们会在合适的时机去通知购房者。

在上面例子中使用发布-订阅有一下有点:

  1. 购房者不用每天都打电话询问售楼处是否有房,而是售楼处在合适的时机通知购房者。这一点可以说明发布-订阅模式可以广泛用于异步编程中,用于代替传统的回调函数。

  2. 购房者和售楼处不再强耦合在一起,当有新的购房者出现时,他只需要把手机号留在售楼处,售楼处不用关心购房者的任何情况,售楼处的变动也不会影响购房者,比如销售 MM 离职,这些情况都跟购房者无关,只要售楼处记得发短信这件事即可。这一点可以说明发布-订阅模式可以取代对象之间硬编码的通知机制,一个对象不用再显式的调用另一个对象的某个接口。

实现一个发布-订阅

  1. class SalesOffices {
  2. clientList = {};
  3. listen(key, fn) {
  4. if (this.clientList[key]) {
  5. this.clientList[key].push(fn);
  6. } else {
  7. this.clientList[key] = [fn];
  8. }
  9. }
  10. notify(key, ...args) {
  11. const fns = this.clientList[key];
  12. if (!fns || fns.length === 0) {
  13. return false;
  14. }
  15. fns.forEach((fn) => {
  16. fn(...args);
  17. });
  18. }
  19. remove(key, fn) {
  20. const fns = this.clientList[key];
  21. if (!fns || fns.length === 0) return false;
  22. if (!fn) {
  23. // 移除所有订阅
  24. fns.length = 0;
  25. } else {
  26. // 移除指定订阅
  27. for (let i = 0, len = fns.length; i < len; i++) {
  28. if (fns[i] === fn) {
  29. fns.splice(i, 1);
  30. break;
  31. }
  32. }
  33. }
  34. }
  35. }
  36. // 售楼处
  37. const salesOffices = new SalesOffices();
  38. // 小明订阅
  39. const mingFn = (type, price) => {
  40. console.log('小明-价格:' + type + price);
  41. };
  42. salesOffices.listen('TypeA', mingFn);
  43. // 小强订阅
  44. salesOffices.listen('TypeB', (type, price) => {
  45. console.log('小强-价格:' + type + price);
  46. });
  47. // 售楼处通知
  48. salesOffices.notify('TypeA', '户型A ', 2000);
  49. salesOffices.notify('TypeB', '户型B ', 2000);
  50. // 小明取消订阅
  51. salesOffices.remove('TypeA', mingFn);
  52. // 售楼处通知
  53. salesOffices.notify('TypeA', '户型A ', 4000);

上面代码中我们通过售楼处例子实现了一个发布-订阅模式,且购房者可以订阅自己想要的户型,当售楼处有房时,也只会通知给订阅了对应户型的购房者,同时购房者还可以取消订阅。

全局的发布-订阅

在上面的例子中存在一个问题,购房者和售楼处还存在一定的耦合性,购房者至少要知道售楼处对象的名称叫 salesOffices, 才能顺利订阅到事件,如果有其他售楼处的时候,购房者还需要知道它对象的名称。

其实在现实中,买房子未必可以去售楼处,也可以找中介公司,而各大房产公司只需要通知中介公司来发布房子信息,购房者也只需要接受中介公司的消息,不必再关系消息是来自那个房产公司了。

在程序中,发布-订阅可以用一个全局的 Event对象来实现,订阅者不需要了解消息来自那个发布者,发布者也不知道消息会推送给那些订阅者,Event作为一个类似 “中介者” 的角色,将其联系在一起:

  1. class Event {
  2. clientList = {};
  3. listen(key, fn) {
  4. if (this.clientList[key]) {
  5. this.clientList[key].push(fn);
  6. } else {
  7. this.clientList[key] = [fn];
  8. }
  9. }
  10. notify(key, ...args) {
  11. const fns = this.clientList[key];
  12. if (!fns || fns.length === 0) {
  13. return false;
  14. }
  15. fns.forEach((fn) => {
  16. fn(...args);
  17. });
  18. }
  19. remove(key, fn) {
  20. const fns = this.clientList[key];
  21. if (!fns || fns.length === 0) return false;
  22. if (!fn) {
  23. // 移除所有订阅
  24. fns.length = 0;
  25. } else {
  26. // 移除指定订阅
  27. for (let i = 0, len = fns.length; i < len; i++) {
  28. if (fns[i] === fn) {
  29. fns.splice(i, 1);
  30. break;
  31. }
  32. }
  33. }
  34. }
  35. }
  36. const event = new Event();
  37. // 小强订阅
  38. event.listen('TypeB', (type, price) => {
  39. console.log('小强-价格:' + type + price);
  40. });
  41. // 售楼处通知
  42. event.notify('TypeB', '户型B ', 6000);

Vue 中的发布-订阅

在 Vue2.x 中我们有时会使用 Event Bus 来实现组件之间的通信,如下面的代码:

  1. // main.js
  2. Vue.prototype.EventBus = new Vue();
  3. // 组件A
  4. this.EventBus.$on('eventName', (param1) => {});
  5. // 组件B
  6. this.$EventBus.$emit('eventName', 'data');

Vue 的 Event Bus 就是发布-订阅的实现,现在我们来看下它的源码:

  1. // src/core/instance/events.js
  2. export function eventsMixin (Vue: Class<Component>) {
  3. Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {
  4. const vm: Component = this
  5. if (Array.isArray(event)) {
  6. // 多个事件绑定一个回调
  7. for (let i = 0, l = event.length; i < l; i++) {
  8. vm.$on(event[i], fn)
  9. }
  10. } else {
  11. (vm._events[event] || (vm._events[event] = [])).push(fn)
  12. }
  13. return vm
  14. }
  15. Vue.prototype.$once = function (event: string, fn: Function): Component {
  16. const vm: Component = this
  17. function on () {
  18. // 事件触发后,先用 $off 将事件关闭掉
  19. vm.$off(event, on)
  20. // 然后再触发回调函数
  21. fn.apply(vm, arguments)
  22. }
  23. // 将 fn 缓存到 on 上,这会在 off 的时候用到,因为在 off 中需要使用 fn 进行判断,而这里将 fn 替换成了 on
  24. // 所以需要使用这个缓存 fn 来进行判断
  25. on.fn = fn
  26. // 使用 $on 来注册事件
  27. vm.$on(event, on)
  28. return vm
  29. }
  30. Vue.prototype.$off = function (event?: string | Array<string>, fn?: Function): Component {
  31. const vm: Component = this
  32. // 如果没有传递任何参数的话,则关闭所有注册的事件
  33. if (!arguments.length) {
  34. // 重置 _events
  35. vm._events = Object.create(null)
  36. return vm
  37. }
  38. // 如果 event 是一个数组的话,就遍历关闭对应事件,(不同事件名对应相同的回调)
  39. if (Array.isArray(event)) {
  40. for (let i = 0, l = event.length; i < l; i++) {
  41. // 关闭
  42. vm.$off(event[i], fn)
  43. }
  44. return vm
  45. }
  46. // 到这里 event 就是一个字符串了
  47. // 在 _events 中找到当前当前 event 对应的回调函数数组
  48. const cbs = vm._events[event]
  49. // 没有的话就结束执行
  50. if (!cbs) {
  51. return vm
  52. }
  53. // 判断是否传递了 fn,没有传递则清空当前 event 下注册的所有事件回调
  54. if (!fn) {
  55. vm._events[event] = null
  56. return vm
  57. }
  58. // 传递了 fn,则在 events 中找到这个对应的回调事件,然后再 events 中删除
  59. let cb
  60. let i = cbs.length
  61. while (i--) {
  62. cb = cbs[i]
  63. // 判断寻找对应的 fn
  64. // cb.fn === fn 这个判断使用查找 $once 注册的事件,因为再 $once 中重写了传递的 fn
  65. if (cb === fn || cb.fn === fn) {
  66. cbs.splice(i, 1)
  67. break
  68. }
  69. }
  70. return vm
  71. }
  72. Vue.prototype.$emit = function (event: string): Component {
  73. const vm: Component = this
  74. // 找到 emit 事件的回调函数数组
  75. let cbs = vm._events[event]
  76. if (cbs) {
  77. cbs = cbs.length > 1 ? toArray(cbs) : cbs
  78. // 获取 emit 传递的参数
  79. const args = toArray(arguments, 1)
  80. const info = `event handler for "${event}"`
  81. // 循环触发每一个回调,并传递参数
  82. for (let i = 0, l = cbs.length; i < l; i++) {
  83. invokeWithErrorHandling(cbs[i], vm, args, vm, info)
  84. }
  85. }
  86. return vm
  87. }
  88. }

发布-订阅的缺点

发布-订阅的优点非常明显,可以在时间上解耦(异步编程),可以为对象之间解耦(一个对象不用显式的调用另一个对象的接口),但它同样存在缺点,比如当我们订阅了一个消息,这个消息也许到最后都没有发生,但这个订阅者会始终存在内存中,另外发布-订阅虽然可以弱化对象之间的关系,但也将对象于对象之间的必要联系,深埋在了背后,导致程序难以跟踪维护和理解。