设计模式实践之发布-订阅模式

发布-订阅模式又叫观察者模式,在平时的javascript程序编写里其实这种模式是非常常见的,比如我们曾经给DOM节点绑定过事件,就是典型的发布-订阅模式:

document.body.addEventListener('click', function() {
  alert('clicked!')
}, false)

document.body.click()

这种模式广泛的应用于异步编程中,事件监听就是一种简单的实现。如果我们把事件理解为一种信号,某个任务完成后就向信号中心发布一个信号,其他任务订阅这个信号,就可以知道自己什么时候开始执行,来看一个简单的例子:

var center = {}; // 定义信号中心

center.subscribeList = []; // 缓存列表

center.subscribe = function(fn) { // 增加订阅
  this.subscribeList.push(fn); // 订阅的消息放入缓存列表
};

center.publish = function() { // 发布消息
  for (var i = 0, fn; fn = this.subscribeList[i++];) {
    fn.apply(this, arguments);  // 发布时带上参数
  }
}

// 下面进行测试,先订阅消息
center.subscribe(function(signal, action) {
  console.log('触发信号:' + signal);
  console.log('执行动作:' + action);
});

// 发布时输出:
// 触发信号:click
// 执行动作:write something
center.publish('click', 'write something');

上面就是一个最简单的发布-订阅模式,但是我们可以发现,发布的时候实际上所有人都会收到这条发布的消息,因此我们需要增加一个标示,来让订阅者只订阅自己感兴趣的消息:

var center = {}; // 定义中心
center.subscribeList = {};  // 缓存列表

center.subscribe = function(signal, fn) {
  if (!this.subscribeList[signal]) {
    this.subscribeList[signal] = [];  // 如果没有存放过此类消息才创建一个针对此类消息的缓存列表
  }
  this.subscribeList[signal].push(fn);
};

center.publish = function() {
  var signal = Array.prototype.shift.call(arguments);
  var fns = this.subscribeList[signal];

  if (!fns || fns.length === 0) {
    return false;
  }

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

center.subscribe('click', function() {
  console.log('ok someone just clicked something...');
});

center.subscribe('scroll', function() {
  console.log('lol stop scrolling, dude!');
})

center.publish('click');
center.publish('scroll');

我们把发布中心的订阅列表换成了一个对象,现在它的结构是这样的:

{
  'click': [a, b, c, ...],
  'scroll': [d, e, f, ...],
  ...
  ...
}

如此,就可以订阅只针对这一类的消息了。

现在我们再加入取消订阅事件的方法:

center.unsub = function(signal, fn) {
  var fns = this.subscribeList[signal];

  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(1, 1); // 删除回调函数
      }
    }
  }
}

具体效果如下图:

https://res.cloudinary.com/dxmlgmzb7/image/upload/v1494767085/blog/subpub_iyksth.gif

最后我们把这一段代码封装,并添加一个安装的函数,这样就可以给所有的对象动态安装发布-订阅的功能:

var event = {
  subscribeList: {},

  subscribe: function(signal, fn) {
    if (!this.subscribeList[signal]) {
      this.subscribeList[signal] = [];
    }
    this.subscribeList[signal].push(fn);
  },

  publish: function() {
    var signal = Array.prototype.shift.call(arguments);
    var fns = this.subscribeList[signal];

    if (!fns || fns.length === 0) {
      return false;
    } 

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

  unsub: function(signal, fn) {
    var fns = this.subscribeList[signal];

    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(1, 1);
        }
      }
    }
  },

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

现在似乎一切都很不错了,但是还有一个小小的瑕疵,我们给每一个对象都添加这么一个功能的时候会给每个对象都添加上各种方法和一个缓存的列表对象,这其实是一种资源浪费。另外如果需要订阅的信号位于其他的中心,那么我们就必须要订阅多个中心的信号,因此我们需要一个全局的Event对象就可以实现了,不需要了解信号来自哪一个中心,中心发布时也不需要了解推送给哪一个订阅者。

这种思路其实就是Vue的文档中提到的Event Bus,简单的模块间通信时,通过一个创建一个新的空Vue对象作为全局的Event Bus,实现这种订阅和发布模式。当然如果是特别复杂的情况,这种模式维护起来就很麻烦了。

最后,有心的人可能会注意到我们之前所有的例子,都需要先订阅一个信号,然后才能接受到发布的消息,如果反过来,那么发布出来的消息在没有人订阅的情况下,就消失了再也无法看到。

某些时候我们需要把这条消息先保存下来,等到有人来订阅的时候,再重新发布给订阅者。为了满足这个需求,我们需要建立一个存放这个消息的堆栈,如果发布时没有人订阅,就把这个发布的动作包装在一个函数中放入这个堆栈中,等到有人来订阅的时候,可以遍历堆栈并依次执行这些包装函数,具体的代码大家可以自己去实现以下,我就不放在这里了。