发布-订阅模式

发布—订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。它有两个应用场景:

  • 可以广泛应用于异步编程中,替代回调函数。
  • 一个对象不用再显式的调用另一个对象的接口。

让两个对象松耦合地联系在一起,虽然不太清楚彼此的细节,但这不影响它们之间相互通信。当有新的订阅者出现时,发布者的代码不需要任何修改;同样发布者需要改变时,也不会影响到之前的订阅者。只要之前约定的事件名没有变化,就可以自由地改变它们。DOM事件绑定就是典型的发布-订阅模式。

下面用订房的例子来实现发布-订阅模式。

第一版

订阅者根据key来订阅自己感兴趣的事件。

const salesOffices = {}

salesOffices.clientList = []

salesOffices.listen = function (key, fn) {
  if(!this.clientList[key]){
    this.clientList[key]=[]
  }
  this.clientList[key].push(fn)
}

salesOffices.trigger = function () {
  let key=Array.prototype.shift.call(arguments)
  let fns=this.clientList[key]
  if(!fns||fns.length===0){
    return
  }
  for (let i = 0, fn; fn = fns[i++];) {
    fn.apply(this, arguments)
  }
}

// 小明订阅消息
salesOffices.listen('squareMeter88', function (price) {
  console.log('小明得到88平米的价格发布:' + price)
})

// 小红订阅消息
salesOffices.listen('squareMeter100', function (price) {
  console.log('小红得到100平米的价格发布: ' + price)
})

// 小刚订阅消息
salesOffices.listen('squareMeter88', function (price) {
  console.log('小刚得到88平米的价格发布:' + price)
})

salesOffices.trigger('squareMeter88',200000) // 小明得到88平米的价格发布:200000 小刚得到88平米的价格发布:200000
salesOffices.trigger('squareMeter100',300000) //小红得到100平米的价格发布: 300000

但是,上面代码存在几个问题:

  • 没有可扩展性,如果又有另外一个对象也需要这个模式,岂不是要复制一遍一模一样的代码?
  • 没有事件移除机制

第二版

相对第一版,增加下面功能:

  • 给对象动态增加发布订阅功能
  • 增加事件移除函数
  • 增加初始化绑定函数
const event = {
  clientList: [],
  listen(key, fn) {
    if (!this.clientList[key]) {
      this.clientList[key] = []
    }
    this.clientList[key].push(fn)

  },
  trigger() {
    let key = Array.prototype.shift.call(arguments)
    let fns = this.clientList[key]

    if (!fns || fns.length === 0) { // 没有绑定对应的消息
      return false
    }

    for (let i = 0, fn; fn = fns[i++];) {
      fn.apply(this, arguments)
    }
  },
  // 清除已有事件队列,重新绑定
  one(key, fn) {
    this.remove(key)
    this.listen(key, fn)
  },
  remove(key, fn) {
    let fns = this.clientList[key]

    if (!fns) { // 如果key没有被人订阅,则直接返回
      return
    }

    if (!fn) { // 如果没有传回调函数,则表示取消key对应的所有的订阅
      fns.length = 0
    } else {
      for (let len = fns.length - 1; len >= 0; len--) {
        let _fn = fns[len]
        if (_fn === fn) {
          fns.splice(len, 1)
        }
      }
    }
  }
}

const installEvent = function (obj) {
  for (let i in event) {
    obj[i] = event[i]
  }
}

const salesOffices = {}
installEvent(salesOffices)

// 小明订阅消息
salesOffices.listen('squareMeter88', ming = function (price) {
  console.log('小明得到88平米的价格发布:' + price)
})

// 小红订阅消息
salesOffices.listen('squareMeter100', hong = function (price) {
  console.log('小红得到100平米的价格发布: ' + price)
})

// 小刚订阅消息
salesOffices.listen('squareMeter88', gang = function (price) {
  console.log('小刚得到88平米的价格发布:' + price)
})

salesOffices.trigger('squareMeter88', 200000) // 小明得到88平米的价格发布:200000 小刚得到88平米的价格发布:200000
salesOffices.trigger('squareMeter100', 300000) //小红得到100平米的价格发布: 300000

salesOffices.remove('squareMeter88', gang)
salesOffices.trigger('squareMeter88', 200000) // 小明得到88平米的价格发布:200000

第二版已经比较的全面了,但是还是存在一些不足:

  • 给每一个发布者都要添加listen方法和trigger方法,以及clientList列表,浪费资源。
  • 订阅者需要知道发布者的名字,如果多个发布者发布同一个时间,那么订阅者需要订阅多次,这显然不是订阅者希望看到的,因为他只关心事件本身,而非事件的发布者。

第三版

可以把事件发布完全委托给第三方管理,而非事件的本身发布者。所以可以这样写:

const Event=(function(){
  let clientList=[],
  listen,
  trigger,
  remove

  listen=function(key,fn){
    if(!clientList[key]){
      clientList[key]=[]
    }
    clientList[key].push(fn)
  }

  trigger=function(){
    let key=Array.prototype.shift.call(arguments)
    let fns=clientList[key]
    if(!fns||fns.length===0){
      return 
    }
    for(let i=0,fn;fn=fns[i++];){
      fn.apply(this,arguments)
    }
  }

  remove=function(key,fn){
    let fns=clientList[key]
    if(!fns){
      return
    }
    if(!fn){
      fns.length=0
    }else{
      for(let len=fns.length-1;len>=0;len--){
        let _fn=fns[len]
        if(_fn===fn){
          fns.splice(len,1)
        }
      }
    }
  }

  return{
    listen,
    trigger,
    remove
  }

})()

// 小明订阅消息
Event.listen('squareMeter88',ming=function (price){
  console.log('小明得到88平米的价格发布:'+price)
})

// 小刚订阅消息
salesOffices.listen('squareMeter88', gang = function (price) {
  console.log('小刚得到88平米的价格发布:' + price)
})


Event.trigger('squareMeter88',2000000) // 小明得到88平米的价格发布:200000 小刚得到88平米的价
Event.remove('squareMeter88',ming)
Event.trigger('squareMeter88',2000000) // 小明得到88平米的价格发布:200000 

这样就有了一个全局的事件管理对象,发布者和订阅者不用直接通信,而是通过这个第三方对象来进行事件的发布订阅。同时也真正实现了聚焦事件本身。

模式四-高级功能

  • 全局的发布—订阅对象里只有一个clinetList来存放消息名和回调函数,大家都通过它来订阅和发布各种消息,久而久之,难免会出现事件名冲突的情况,所以我们还可以给Event对象提供创建命名空间的功能。
  • 另外,还有一点就是,我们想实现离线消息的功能。就是发布者可以现发布消息,然后等到订阅者订阅后,先完成发布者的离线消息确认,然后再进行常规的发布-订阅操作。
const Event = (function () {
  let _default = 'default'

  let Event = function () {
    // 这里的私有方法表示单纯的事件方法,不包含命名空间和离线消息功能
    let _listen,
      _trigger,
      _remove,
      _shift = Array.prototype.shift,
      _unshift = Array.prototype.unshift,
      namespaceCache = {},
      _create,
      each = function (arr, fn) {
        let ret
        for (let i = 0, len = arr.length; i < len; i++) {
          let n = arr[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 (let len = cache[key].length - 1; len >= 0; len--) {
            if (cache[key][len] === fn) {
              cache[key].splice(len, 1)
            }
          }
        } else {
          cache[key].length = 0
        }
      }
    }

    _trigger = function () {
      let cache = _shift.call(arguments)
      let key = _shift.call(arguments)
      let args = arguments
      let stack = cache[key]
      let _self = this

      if (!stack || !stack.length) {
        return
      }

      return each(stack, function () {
        return this.apply(_self, args)
      })
    }

    _create = function (namespace = _default) {
      let cache = {}
      let offlineStack = []
      let ret = {
        // 这里的方法是对上面的原始方法进行封装,混入离线消息和命名空间逻辑
        listen(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
        },
        // 清除事件队列的所有函数,然后调用listen函数
        // remove all + listen
        one(key, fn, last) {
          _remove(key, cache)
          this.listen(key, fn, last)
        },
        remove(key, fn) {
          _remove(key, cache, fn)
        },
        trigger() {
          let fn
          let args
          let _self = this
          _unshift.call(arguments, cache)
          args = arguments
          fn = function () {
            return _trigger.apply(_self, args)
          }

          if (offlineStack) {
            return offlineStack.push(fn)
          }
          return fn()
        }
      }
      // 外面一层,检验namespace参数是否能作为对象属性,如果不能,则不创建命名空间,直接返回一个新的对象,但这种场景没有任何作用,所以可以简单理解为校验namespace参数
      // 里面一层,检验这个命名空间是否存在,如果存在则返回已存在的命名空间,否则创建一个新的命名空间,并返回这个命名空间
      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()
})()

这里可以有几种用法:

  1. 使用者可以自己创建命名空间,然后在自己的命名空间里管理消息。
Event.create( 'namespace1' ).listen( 'click', function( a ){
    console.log( a );    // 输出:1
});

Event.create( 'namespace1' ).trigger( 'click', 1 );
  1. 使用者也可以不用自己创建空间,直接使用默认的命名空间。
Event.listen( 'click', function( a ){
    console.log( a );       // 输出:1
});
Event.trigger( 'click', 1 );
  1. 使用者可以先发布离线消息,然后在订阅者订阅的时候自动处理。
Event.trigger( 'click', 1 );

Event.listen( 'click', function( a ){
    console.log( a );       
}); // 1

Event.trigger( 'click', 2 ); // 2

发布—订阅模式的优点非常明显,一为时间上的解耦,二为对象之间的解耦。它的应用非常广泛,既可以用在异步编程中,也可以帮助我们完成更松耦合的代码编写。发布—订阅模式还可以用来帮助实现一些别的设计模式,比如中介者模式。 从架构上来看,无论是MVC还是MVVM,都少不了发布—订阅模式的参与,而且JavaScript本身也是一门基于事件驱动的语言。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,451评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,172评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,782评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,709评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,733评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,578评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,320评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,241评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,686评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,878评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,992评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,715评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,336评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,912评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,040评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,173评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,947评论 2 355

推荐阅读更多精彩内容