发布者-订阅者模式简单实现

之前在看DMQ根据vue双向数据绑定原理模拟实现了mvvm,里面有提高发布者-订阅者模式,看了一些资料,今天自己简单实现了一个发布-订阅模式。

何为发布-订阅模式?

其定义对象间一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。

作了一幅画,关于两者的关系说明:


发布-订阅模式图解.png

首次接触这个概念的时候,会有几个疑问,对象?指DOM对象还是自定义对象,还是两者均可?依赖如何建立的?一个对象状态的改变如何影响所有依赖它的对象?
这里面以微信公众号为例,展开说明:

  • 假如用户A订阅了 某一个公众号G,那么当公众号G推送消息的时候,用户A就会收到相关的推送,点开可以查看推送的消息内容。
  • 但是公众号G并不关心订阅的它的是男人、女人还是二哈,它只负责发布自己的主体,只要是订阅公众号的用户均会收到该消息。
  • 作为用户A,不需要时刻打开手机查看公众号G是否有推动消息,因为在公众号推送消息的那一刻,用户A就会收到相关推送。
  • 当然了,用户A如果不想继续关注公众号G,那么可以取消关注,取关以后,公众号G再推送消息,A就无法收到了。
发布-订阅模式抽象化

上面即是对发布-订阅实例化的描述,但是跟上面问题的答案还是有些差距,我们付诸于代码,以代码的形式来模拟订阅消息、发布消息、取消订阅的功能,来解决上面提到的问题:

        // 01-定义一个订阅-发布模式函数;
        function Pub2Sub() {
            // 02-订阅器;
            this._observer = {}
        }
        // 03-原型对象上面添加方法;
        Pub2Sub.prototype = {
            constructor: Pub2Sub,
            // 04-订阅者;
            subscribe: function (type, callback) {
                if (Object.prototype.toString.call(callback) !== '[object Function]') return
                // 订阅器中是否存在订阅行为;
                if (!this._observer[type]) this._observer[type] = []
                this._observer[type].push(callback)
                return this
            },
            // 05-发布者;
            publish: function () {
                let _self = this
                // 获取发布行为
                let type = Array.prototype.shift.call(arguments)
                // 获取发布主题
                let theme = Array.prototype.slice.call(arguments)
                // 获取相关主题所有订阅者
                let subscribes = _self._observer[type]
                // 发布主题
                if (!subscribes || !subscribes.length) {
                    console.warn('unsubscribe action or no actions in observer, please check out')
                    return
                }
                subscribes.forEach(callback => {
                    callback.apply(_self, theme)
                })
                return _self
            },
            // 06-取消订阅
            unsubscrible: function (type, callback) {
                if (!this._observer[type] || !this._observer[type].length) return
                let subscribes = this._observer[type]
                subscribes.some((item, index, arr) => {
                    if (item === callback) {
                      // 删除对应的订阅行为
                        arr.splice(index, 1)
                        return true
                    }
                })
                return this
            }
        }
        // 实例化发布-订阅模式
        let ps = new Pub2Sub()

        // 添加订阅
        let sub1 = function (data) {
            console.log('sub1' + data)
        }
        let sub2 = function (data) {
            console.log('sub2' + data)
        }
        ps.subscribe('click', sub1)
        ps.subscribe('click', sub2)

        // 实现发布、取订及再发布
        ps.publish('click', '第一次点击消息').unsubscrible('click', sub2).publish('click', '第二次点击消息')
        // 打印结果依次是:
        // sub1第一次点击消息
        // sub2第一次点击消息
        // sub1第二次点击消息

上面代码块中,订阅者1 sub1 和 订阅者 sub2 分别订阅了 'click',这个行为,当发布者 ps.publish 发布主题的时候,sub1sub2 均收到了消息,在控制台输出 sub1第一次点击消息sub2第一次点击消息,然后 订阅者 sub2 又取订了 click 行为,所以当 发布者 ps.publish 再次发布主题的时候,只有 sub1 才收到相关消息。
那么我们就通过代码阐述了依赖是如何建立的,就是通过订阅器来实现;

但是,上述实现的代码存在两个问题:

  • 订阅行为需要在发布行为之前,如果直接发布主题,订阅器中没有相关的订阅行为,我这里手动抛出了警告。但是这是不应该的,正如用户A订阅了公众号G,也可以查看G的历史消息,所以这里需要实现查看发布主题历史记录的功能;
  • 其次,上述功能的实现是通过定义在一个自定义对象,这样就与发布-订阅模式的松散耦合理念有些出入,所以还需要做到如何更优雅的管理接口。
发布-订阅模式优化版

针对上述的问题,我在这个版本里面做了优化,看代码:

// 声明一个全局发布-订阅对象,为不同模块之间的可能存在的通信做铺垫
const Observer = (function () {
            // 订阅器
            const _observer = {}
            // 历史记录
            const _cache = {},
                _shift = Array.prototype.shift,
                _slice = Array.prototype.slice,
                _toString = Object.prototype.toString
            // 订阅
            const subscribe = function (type, callback) {
                if (_toString.call(callback) !== '[object Function]') return
                // 订阅器中是否存在订阅行为;
                if (!_observer[type]) _observer[type] = []
                _observer[type].push(callback)
                return this
            }
            // 发布
            const publish = function () {
                // 获取发布行为
                let type = _shift.call(arguments)
                // 获取发布主题
                let theme = _slice.call(arguments)
                // 记录发布主题
                if (!_cache[type]) {
                    _cache[type] = [theme]
                } else {
                    _cache[type].push(theme)
                }
                // 获取相关主题所有订阅者行为
                let subscribes = _observer[type]
                // 发布主题
                if (!subscribes || !subscribes.length) return
                subscribes.forEach(callback => {
                    callback.apply(this, theme)
                })
                return this
            }
            // 取订
            const unsubscrible = function (type, callback) {
                if (!_observer[type] || !_observer[type].length) return
                let subscribes = _observer[type]
                subscribes.some((item, index, arr) => {
                    if (item === callback) {
                        arr.splice(index, 1)
                        return true
                    }
                })
                return this
            }
            // 查看发布记录
            const viewLog = function (type, callback) {
                if (!_cache[type] || _toString.call(callback) !== '[object Function]') return
                _cache[type].forEach(item => {
                    callback.apply(this, item)
                })
                return this
            }
            return {
                _observer,
                _cache,
                subscribe,
                publish,
                unsubscrible,
                viewLog
            }
        }())
        // 先发布主题;
        Observer.publish('click', '第一次发布点击消息')
        Observer.publish('focus', '第一次发布聚焦消息')
        Observer.publish('blur', '第一次发布失焦消息')

        // 订阅
        let sub1 = function (data) {
            console.log('sub1' + data)
        }
        let sub2 = function (data) {
            console.log('sub2' + data)
        }
        let sub3 = function (data) {
            console.log('sub3' + data)
        }
        Observer.subscribe('click', sub1)
        Observer.subscribe('click', sub2)
        Observer.subscribe('focus', sub3)

        // 再发布、取订、查看发布记录
        Observer.publish('click', '第二次发布点击消息').unsubscrible('click', sub2).publish('click', '第三次发布点击消息').publish('focus', '第二次发布聚焦消息').viewLog('click', function (message) {
                console.log(message)
            })

我们现在无论是先发布主题再订阅,还是订阅之后再发布主题,都不会有问题,因为在 Observer.publish 里面,发布者只关注自己发布主题功能,并且发布的时候将自己发布的对应主题保存。
在发布功能里面添加一个存放发布记录的功能,在这里面我存放的是一个数组,是为了在 Observer.viewLog() 中方便调用。
通过一系列的发布、取订、再发布、以及查看发布记录,打印结果如下:

sub1第二次发布点击消息
sub2第二次发布点击消息
sub1第三次发布点击消息
sub3第二次发布聚焦消息
// 这是查看历史发布主题的结果,因为针对 click 行为,一共发布了三次主题
第一次发布点击消息
第二次发布点击消息
第三次发布点击消息
理解对象间一对多的依赖关系

回到最初我们的问题,这个对象指的是既可以是自定义对象也可以是DOM对象

  • 定义两个模块
  let moduleA = {
          // 伪代码
          todo() {
            Observer.subscribe(type1, function (data) {
                // 拿到 data 然后做一些事情
            })
        }
    }
  let moduleB = {
          // 伪代码
          todo() {
            Observer.subscribe(type1, function (data) {
                // 拿到 data 然后做一些事情
            })
        }
    }
  // 下面是异步获取到数据
 // 伪代码
  ajax(function (data) {
        // 发布数据,所有的订阅均会拿到 data,然后按照自己的逻辑处理
        Observer.publish(type, data)
    })

可能会有人疑问,为什么需要这样来传递数据,直接在 moduleAmoduleB 里面直接获取数据不可以吗?
答案肯定是可以的,但是发布-订阅这种模式可以更优雅地在不同模块之间传递数据。

2019/02/09
const isFun = function (fun) {
  return typeof fun === 'function'
}
class Observer {
  constructor () {
    this.messageCollector = {}
    this.history = {}
  }
  on (...arg) {
    const [type, callback] = arg
    if (!isFun(callback)) {
      throw new TypeError(`callback of arguments for function ${this.subscribe.name} must be a function `)
    }
    if (!this.messageCollector[type]) this.messageCollector[type] = []
    this.messageCollector[type].push(callback)
    return this
  }
  emit (...arg) {
    const [type, ...theme] = arg
    const subscribes = this.messageCollector[type]
    if (!this.history[type]) {
      this.history[type] = [theme]
    } else {
      this.history[type].push(theme)
    }
    for (const callback of subscribes) {
      callback.apply(this, theme)
    }
    return this
  }
  off (...arg) {
    const [type, callback] = arg
    if (!this.messageCollector[type] || !this.messageCollector[type].length) return
    if (!isFun(callback)) {
      throw new TypeError(`callback of arguments for function ${this.subscribe.name} must be a function `)
    }
    const subscribes = this.messageCollector[type]
    subscribes.some((item, index, arr) => {
      if (item === callback) {
        arr.splice(index, 1)
        return true
      }
    })
    return this
  }
  viewLog (...arg) {
    const [type, callback] = arg
    if (!this.history[type] || !isFun(callback)) return
    const themes = this.history[type]
    for (const theme of themes) {
      callback.apply(this, theme)
    }
    return this
  }
  reset () {
    this.messageCollector = {}
    this.history = {}
    return this
  }
}
写在最后
  • 有人将观察者模式和发布-订阅模式认为是同一种模式,也有认为不是一种,仁者见仁,这里贴出一篇博客对两者的介绍: 观察者模式与发布/订阅模式区别
  • 关于本人实现的发布-订阅模式,仍存在问题,如果订阅行为过多,在团队协作中,会面临着命名冲突的局面,我就抛砖引玉,贴出大牛对这块逻辑的处理:JavaScript设计模式--观察者模式
  • 最后再贴出DMQ对vue响应式原理的实现过程:mvvm,如果想深入了解vue原理,是一个不错的过渡选择。
  • 关于发布-订阅模式,在 ES6 里面有了更好的实现,下次有时间的时候再继续分享。
  • 本文为原创文章,如果需要转载,请注明出处,方便溯源,如有错误地方,可以在下方留言,欢迎校勘,源码已上传到我的GitHub
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,047评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,807评论 3 386
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,501评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,839评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,951评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,117评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,188评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,929评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,372评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,679评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,837评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,536评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,168评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,886评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,129评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,665评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,739评论 2 351

推荐阅读更多精彩内容