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

参考资料

定义

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

使用场景:

  • DOM事件;
  • Vue EventBus;

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

全局事件的命名冲突

添加了命名空间的概念,可以有效的避免因长期维护导致命名冲突的问题:

<script type="text/javascript">
    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 ] === 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' ){
                        }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;
    })();

</script>

源码中的发布-订阅模式(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中的源码也非常容易理解了呢。

优缺点

  • 优点: 时间上的解耦,对象间的解耦,既可以应用在异步编程中,也可以帮助我们完成更松耦合的代码编写。
  • 缺点:创建订阅者本身要消耗一定的时间和内存,而且当你订阅一个消息后,也许此消息最后都未发生,但这个订阅者会始终存在于内存中;第二点,发布-订阅模式如果过度使用的话,对象和对象之间的必要联系也将被深埋在背后,会导致程序难以跟踪维护和理解。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值