likes
comments
collection

JavaScript设计模式之发布-订阅模式

作者站长头像
站长
· 阅读数 12

概念

在《JavaScript设计模式与开发实践》 中对发布-订阅模式的定义为 定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖它的对象都将得到通知。。在JavaScript开发中,我们一般用事件模型来替代传统的发布-订阅模式

JS中的发布-订阅模式

DOM事件

只要我们在DOM节点上绑定过事件函数,那我们就算是使用过发布-订阅模式,代码如下:

document.body.addEventListener('click', function() {
    console.log(1);
}, false)

这里,我们就订阅document.body上的click事件,当body被点击时,body节点便会向订阅者发布这个消息,当然我们还可以随意的添加或者删除订阅者,代码如下:

funtion func() {
    console.log(2)
}

// 添加订阅者
document.body.addEventListener('click', func, false)

// 删除订阅者
document.body.removeEventListener('click', func)

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

代码如下,

var event = {
    clientList: [],
    
    // 添加订阅
    listen: function(key, fn) {
        if (!this.clientList[key]) {
            this.clientList[key] = [];
        }
        
        // 订阅的消息添加进缓存列表
        this.clientList[key].push(fn);
    },
    
    // 取消订阅
    remove: function(key, fn) {
        var fns = this.clientList[key];
        if (!fns) {
            return false;
        }
        
        // 没有传入fn,则表示需要取消key对应的所有订阅消息
        if (!fn) {
            fns && (fns.length = 0);
        } else {
            // 取消fn的订阅
            for (var l = fns.length - 1; l >= 0; l--) {
                var _fn = fns[l];
                if (_fn === fn) {
                    fns.splice(l, 1);
                }
            }
        }
    },
    
    // 触发订阅
    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)
        }
    }
}

这里我们就实现了一个通用的发布-订阅模式,我们可以通过调用event.listen('eventName', func)添加一个eventName的消息订阅,其中订阅回调函数为func,在需要的地方,我们调用event.trigger('eventName')方法来发布eventName消息,此时会执行所有订阅的函数。remove的逻辑也非常简单,找到需要删除的方法,从订阅列表中删除即可。整体来说,发布-订阅模式还是非常容易理解的。

先发布再订阅

按照之前的例子,我们必须先订阅一个事件,然后才能收到发布者发布的消息。那么如果我们在订阅前已经有了发布信息,是不是订阅后之前的消息就再也找不到了呢?在某些场景下,我们也需要之前的消息,比如QQ的离线消息,在我们再次订阅时,需要重新收到之前的消息,那么这个应该如何实现呢? 先说思路,其实这里我们就需要一个缓存数据,将之前发布的消息进行缓存,当我们订阅一个消息后,我们会遍历之前的缓存消息,找到之前发布过的历史消息,这样我们在订阅消息时,也可以同时收到之前的历史消息了。那么我们的代码应该如何实现呢?这里我简单的修改listentrigger方法:

// 离线事件
offlineStack: {},

// 添加订阅
listen: function (key, fn) {
    if (!this.clientList[key]) {
      this.clientList[key] = []
    }

    // 订阅的消息添加进缓存列表
    this.clientList[key].push(fn);

    // 有离线事件时,需要将缓存的离线事件也执行
    if (this.offlineStack[key] && this.offlineStack[key].length) {
      this.offlineStack[key].forEach(cacheFn => {
                cacheFn.call(this, key)
        });
        this.offlineStack[key] = null;
    }
},

// 触发订阅
trigger: function () {
    var key = Array.prototype.shift.call(arguments),
      fns = this.clientList[key];

    if (!fns || fns.length === 0) {
      const cacheFn = function () {
        return event.trigger.apply(this, arguments)
      }
      
      // 如果没有订阅事件时,需要将触发事件进行缓存
      if (!this.offlineStack[key]) {
        this.offlineStack[key] = [cacheFn]
      } else {
        this.offlineStack[key].push(cacheFn)
      }
      return false
    }

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

// 测试执行效果先订阅,后监听
event.trigger('hello')
event.trigger('world')

event.listen('hello', function () {
  console.log('listen hello')
})
event.listen('world', function () {
  console.log('listen world')
})

// listen hello
// listen world

看到上面代码,相信大家也能明白类似场景应该如何去实现:需要新增一个offlineStack变量去保存我们的离线信息,我们添加一条消息订阅时,会到离线数据中找是否有历史信息,如果有的话,将其依次执行,并将历史数据清空即可

上面这种方式是我自行实现的,在书中也有更完善的代码,不仅支持先发布再订阅的逻辑,也添加了命名空间的概念,可以有效的避免因长期维护导致命名冲突的问题,下面就是书中的原始代码:

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)
    }

    _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)
      })
    }

    _remove = function () {}

    _create = function (namespace) {
      var namespace = namespace || _default
      var cache = {},
        offlineStack = [],
        ret = {
          listen: function (key, fn) {
            _listen(key, fn, cache)
            if (offlineStack === null) {
              return
            } else {
              each(offlineStack, function () {
                this()
              })
            }
            offlineStack = null
          },
          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()
          },
          remove: function (key, fn) {
            _remove(key, cache, fn)
          },
        }

      return namespace
        ? namespaceCache[namespace]
          ? namespaceCache[namespace]
          : (namespaceCache[namespace] = ret)
        : ret
    }

    return {
      create: _create,
      remove: function (key, fn) {
        var event = this.create()
        event.remove(key, fn)
      },
      listen: function (key, fn) {
        var event = this.create()
        event.listen(key, fn)
      },
      tirgger: function () {
        var event = this.create()
        event.trigger.apply(this, arguments)
      },
    }
  })()

  return Event
})()

// 可以通过这种方式创建一个命名空间
Event.create('name1').trigger('hello')
Event.create('name1').trigger('world')

Event.create('name1').listen('hello', function () {
  console.log('hello')
})

Event.create('name1').listen('world', function () {
  console.log('world')
})

书中的代码,理解起来可能比较吃力,但其核心的逻辑是一致的,我也是根据书中源码进行了一定的简化,只是为了方便大家理解这种思路,如果在项目中实际使用,还是推荐大家参考书中的代码实现。但书中这种方式有一个问题:由于offlineStack是一个数组,在每次添加订阅时(listen)都会判断是否有离线消息,执行完成后会设置为null,这样就会导致: 当我们事先发布了两条不同的消息时,再次添加订阅只会执行第一条订阅的历史消息。如上的代码,最终只会执行hello,也正因为如此,我将offlineStack改成了对象的形式,这样我们可以仅清空对应事件的历史消息即可。

源码中的发布-订阅模式

Vue EventBus

在vue中我们通常使用EventBus来实现兄弟组件中的通信,EventBus又称为事件总线,相当于一个事件中心,我们可以向该中心注册、发送或接收事件。就相当于我前面介绍的event一样,属于发布-订阅模式。那么,我们一起来看下Vue源码是如何实现这个发布-订阅的呢?

Vue中的实现是在src/core/instace/events.js文件下的eventsMixin方法。

  • 先看$on方法,当调用$on方法时,会将回调函数fn存入到vm._events中,代码如下:
Vue.prototype.$on = function (event, fn) {
    const vm = this
    if (Array.isArray(event)) {
      for (let i = 0, l = event.length; i < l; i++) {
        vm.$on(event[i], fn)
      }
    } else {
      (vm._events[event] || (vm._events[event] = [])).push(fn)
      // optimize hook:event cost by using a boolean flag marked at registration
      // instead of a hash lookup
      if (hookRE.test(event)) {
        vm._hasHookEvent = true
      }
    }
    return vm
  }

可以看到,$on方法的思路也是一样,存入到_events中,不过vue中的还支持了传入数组,传入数组时,可以批量添加订阅。

  • 再看$emit方法,当调用$emit方法时,会取出之前$on的事件,然后依次执行,代码如下:
 Vue.prototype.$emit = function (event) {
    const vm = this
    let cbs = vm._events[event]
    if (cbs) {
      cbs = cbs.length > 1 ? toArray(cbs) : cbs
      const args = toArray(arguments, 1)
      const info = `event handler for "${event}"`
      for (let i = 0, l = cbs.length; i < l; i++) {
        invokeWithErrorHandling(cbs[i], vm, args, vm, info)
      }
    }
    return vm
 }
  
// invokeWithErrorHandling 方法
export function invokeWithErrorHandling (handler, context, args, vm, info) {
  let res
  try {
    res = args ? handler.apply(context, args) : handler.call(context)
    if (res && !res._isVue && isPromise(res) && !res._handled) {
      res.catch(e => handleError(e, vm, info + ` (Promise/async)`))
      // issue #9511
      // avoid catch triggering multiple times when nested calls
      res._handled = true
    }
  } catch (e) {
    handleError(e, vm, info)
  }
  return res
}

到这里,我们已经了解了Vue中实现发布-订阅模式的方式,当然vue中还实现了$off取消监听、$once函数只执行一次的方法,也都非常容易理解,这里就没有展开介绍了,感兴趣的同学可以自己去了解一下就好。是不是感觉Vue中的源码也非常容易理解了呢。

总结

这节没有介绍发布-订阅模式的应用,一个原因是因为相信大家已经很熟悉使用场景啦,还有就是其实不管是什么场景,我们的实现逻辑都是相似的,所以也就没有再单独介绍了。 发布-订阅模式优点: 时间上的解耦,对象间的解耦,既可以应用在异步编程中,也可以帮助我们完成更松耦合的代码编写。 发布-订阅模式缺点:创建订阅者本身要消耗一定的时间和内存,而且当你订阅一个消息后,也许此消息最后都未发生,但这个订阅者会始终存在于内存中;第二点,发布-订阅模式如果过度使用的话,对象和对象之间的必要联系也将被深埋在背后,会导致程序难以跟踪维护和理解。 感谢阅读 🙏