发布-订阅模式

918 阅读8分钟

发布—订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。在 JavaScript 开发中,我们一般用事件模型来替代传统的发布—订阅模式。

1. 发布订阅模式的作用

  1. 发布—订阅模式可以广泛应用于异步编程中,这是一种替代传递回调函数的方案。比如,我们可以订阅 ajax 请求的 error、succ 等事件。或者如果想在动画的每一帧完成之后做一些事情,那我们可以订阅一个事件,然后在动画的每一帧完成之后发布这个事件。在异步编程中使用发布—订阅模式,我们就无需过多关注对象在异步运行期间的内部状态,而只需要订阅感兴趣的事件发生点。

  2. 发布—订阅模式可以取代对象之间硬编码的通知机制,一个对象不用再显式地调用另外一个对象的某个接口。发布—订阅模式让两个对象松耦合地联系在一起,虽然不太清楚彼此的细节,但这不影响它们之间相互通信。当有新的订阅者出现时,发布者的代码不需要任何修改;同样发布者需要改变时,也不会影响到之前的订阅者。只要之前约定的事件名没有变化,就可以自由地改变它们。

2. DOM事件

在 DOM 节点上面绑定过事件函数,那我们就曾经使用过发布—订阅模式,

document.body.addEventListener( 'click', function(){ 
 	alert(2); 
}, false ); 
document.body.click(); // 模拟用户点击
/** 在这里需要监控用户点击 document.body 的动作,但是我们没办法预知用户将在什么时候点击。所以我们订阅 document.body 上的 click 事件,当 body 节点被点击时,body 节点便会向订阅者发布这个消息 **/

//当然我们还可以随意增加或者删除订阅者,增加任何订阅者都不会影响发布者代码的编写
document.body.addEventListener( 'click', function(){ 
 alert(2); 
}, false ); 
document.body.addEventListener( 'click', function(){
   alert(3); 
}, false ); 
document.body.addEventListener( 'click', function(){ 
 alert(4); 
}, false ); 
document.body.click(); // 模拟用户点击

3. 自定义事件

除了 DOM 事件,我们还会经常实现一些自定义的事件,这种依靠自定义事件完成的发布—订阅模式可以用于任何 JavaScript 代码中。现在看看如何一步步实现发布—订阅模式。

  • 首先要指定好谁充当发布者
  • 然后给发布者添加一个缓存列表,用于存放回调函数以便通知订阅者
  • 最后发布消息的时候,发布者会遍历这个缓存列表,依次触发里面存放的订阅者回调函数。

另外,我们还可以往回调函数里填入一些参数,订阅者可以接收这些参数。这是很有必要的

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

salesOffices.listen(function (price, squareMeter) {
  // 小明订阅消息
  console.log('价格= ' + price)
  console.log('squareMeter= ' + squareMeter)
})
salesOffices.listen(function (price, squareMeter) {
  // 小红订阅消息
  console.log('价格= ' + price)
  console.log('squareMeter= ' + squareMeter)
})
salesOffices.trigger(2000000, 88) // 输出:200 万,88 平方米
salesOffices.trigger(3000000, 110) // 输出:300 万,110 平方米

至此,我们已经实现了一个最简单的发布—订阅模式,但这里还存在一些问题。我们看到订阅者接收到了发布者发布的每个消息,虽然小明只想买 88 平方米的房子,但是发布者把 110 平方米的信息也推送给了小明,这对小明来说是不必要的困扰。所以我们有必要增加一个标示 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) // (2) // arguments 是发布消息时附送的参数
  }
}
salesOffices.listen('squareMeter88', function (price) {
  // 小明订阅 88 平方米房子的消息
  console.log('价格= ' + price) // 输出: 2000000
})
salesOffices.listen('squareMeter110', function (price) {
  // 小红订阅 110 平方米房子的消息
  console.log('价格= ' + price) // 输出: 3000000
})
salesOffices.trigger('squareMeter88', 2000000) // 发布 88 平方米房子的价格
salesOffices.trigger('squareMeter110', 3000000) // 发布 110 平方米房子的价格

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

//发布—订阅的功能提取出来,放在一个单独的对象内:
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), // (1);
      fns = this.clientList[key]
    if (!fns || fns.length === 0) {
      // 如果没有绑定对应的消息
      return false
    }
    for (var i = 0, fn; (fn = fns[i++]); ) {
      fn.apply(this, arguments) // (2) // arguments 是 trigger 时带上的参数
    }
  },
}

//再定义一个 installEvent 函数,这个函数可以给所有的对象都动态安装发布—订阅功能:
var installEvent = function (obj) {
  for (var i in event) {
    obj[i] = event[i]
  }
}


var salesOffices = {}
installEvent(salesOffices)
salesOffices.listen('squareMeter88', function (price) {
  // 小明订阅消息
  console.log('价格= ' + price)
})
salesOffices.listen('squareMeter100', function (price) {
  // 小红订阅消息
  console.log('价格= ' + price)
})
salesOffices.trigger('squareMeter88', 2000000) // 输出:2000000
salesOffices.trigger('squareMeter100', 3000000) // 输出:3000000

5. 取消事件订阅

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) // 删除订阅者的回调函数
      }
    }
  }
}

6. 全局的发布-订阅对象

回想下刚刚实现的发布—订阅模式,我们给售楼处对象和登录对象都添加了订阅和发布的功能,这里还存在两个小问题。

  • 我们给每个发布者对象都添加了 listen 和 trigger 方法,以及一个缓存列表 clientList,这其实是一种资源浪费。小明跟发布者对象还是存在一定的耦合性,小明至少要知道发布者对象的名字是salesOffices,才能顺利的订阅到事件。
salesOffices.listen( 'squareMeter100', function( price ){ // 小明订阅消息
 console.log( '价格= ' + price ); 
});

发布—订阅模式可以用一个全局的 Event 对象来实现,订阅者不需要了解消息来自哪个发布者,发布者也不知道消息会推送给哪些订阅者,Event 作为一个类似“中介者”的角色,把订阅者和发布者联系起来。

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: listen,
    trigger: trigger,
    remove: remove,
  }
})()
Event.listen('squareMeter88', function (price) {
  // 小红订阅消息
  console.log('价格= ' + price) // 输出:'价格=2000000'
})
Event.trigger('squareMeter88', 2000000) 

7. 模块间通信

上一节中实现的发布—订阅模式的实现,是基于一个全局的 Event 对象,我们利用它可以在两个封装良好的模块中进行通信,这两个模块可以完全不知道对方的存在。比如现在有两个模块,a 模块里面有一个按钮,每次点击按钮之后,b 模块里的 div 中会显示按钮的总点击次数,我们用全局发布—订阅模式完成下面的代码,使得 a 模块和 b 模块可以在保持封装性的前提下进行通信。

<!DOCTYPE html>
<html>
  <body>
    <button id="count">点我</button>
    <div id="show"></div>
  </body>
  <script type="text/JavaScript">
    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>

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

8. 全局事件的命名冲突

全局的发布—订阅对象里只有一个 clinetList 来存放消息名和回调函数,大家都通过它来订阅和发布各种消息,久而久之,难免会出现事件名冲突的情况,所以我们还可以给 Event 对象提供创建命名空间的功能。

var Event = (function () {
  var global = this,
      Event,
      _default = 'default'
  Event = (function () {
    var _listen,
      _trigger,
      _remove,
      _slice = Array.prototype.slice,
      _shift = Array.prototype.shift,
      _unshift = Array.prototype.unshift,
      namespaceCache = {},
      _create,
      find,
      each = function (ary, fn) {
        var ret
        for (var i = 0, l = ary.length; i < l; i++) {
          var n = ary[i]
          ret = fn.call(n, i, n)
        }
        return ret
      }
    _listen = function (key, fn, cache) {
      if (!cache[key]) {
        cache[key] = []
      }
      cache[key].push(fn)
    }
    _remove = function (key, cache, fn) {
      if (cache[key]) {
        if (fn) {
          for (var i = cache[key].length; i >= 0; i--) {
            if (cache[key][i] === fn) {
              cache[key].splice(i, 1)
            }
          }
        } else {
          cache[key] = []
        }
      }
    }
    _trigger = function () {
      var cache = _shift.call(arguments),
        key = _shift.call(arguments),
        args = arguments,
        _self = this,
        ret,
        stack = cache[key]
      if (!stack || !stack.length) {
        return
      }
      return each(stack, function () {
        return this.apply(_self, args)
      })
    }
    _create = function (namespace) {
      var namespace = namespace || _default
      var cache = {},
        offlineStack = [], // 离线事件
        ret = {
          listen: function (key, fn, last) {
            _listen(key, fn, cache)
            if (offlineStack === null) {
              return
            }
            if (last === 'last') {
              offlineStack.length && offlineStack.pop()()
            } else {
              each(offlineStack, function () {
                this()
              })
            }
            offlineStack = null
          },
          one: function (key, fn, last) {
            _remove(key, cache)
            this.listen(key, fn, last)
          },
          remove: function (key, fn) {
            _remove(key, cache, fn)
          },
          trigger: function () {
            var fn,
              args,
              _self = this
            _unshift.call(arguments, cache)
            args = arguments
            fn = function () {
              return _trigger.apply(_self, args)
            }
            if (offlineStack) {
              return offlineStack.push(fn)
            }
            return fn()
          },
        }
      return namespace
        ? namespaceCache[namespace]
          ? namespaceCache[namespace]
          : (namespaceCache[namespace] = ret)
        : ret
    }
    return {
      create: _create,
      one: function (key, fn, last) {
        var event = this.create()
        event.one(key, fn, last)
      },
      remove: function (key, fn) {
        var event = this.create()
        event.remove(key, fn)
      },
      listen: function (key, fn, last) {
        var event = this.create()
        event.listen(key, fn, last)
      },
      trigger: function () {
        var event = this.create()
        event.trigger.apply(this, arguments)
      },
    }
  })()
  return Event
})()