• 设计模式 —— 发布订阅模式


    设计模式 —— 发布订阅模式

    《工欲善其事,必先利其器》

    banner

    我在之前有写过一篇关于 《观察者模式》 的文章,大家有兴趣的可以去看看,个人认为那个例子还是挺生动的。(狗头)

    不过今天我们要学习的是,发布订阅模式。那么话不多说,我们开始!

    一、什么是发布订阅模式?

    发布订阅模式,听起来好像很陌生?但其实我们在工作之中经常有它的射影,例如:

    • Vue 中的 EventBus, $on 以及 $emit 和 $off;
    • Nodejs 中的 EventEmitter,其中 on 和 emit;
    • MQTT 中的 Topic,也是应用了此设计模式。

    可见,虽然设计模式在日常的业务开发中可能用到的地方并不多,但是一门优秀的框架,其根本上是离不开设计模式数据结构算法的,这两者对于程序员的编程思想有着举足轻重的意义,我们依旧还是有学习它的必要。

    那么发布订阅模式观察者模式又有什么区别呢?如下图,我们可以直观的观察到,这两种模式之间的区别:

    观察者模式和发布订阅模式的区别

    发布订阅模式,发布者和订阅者是间接的关系。他们之间其实是一种对象间一对多的依赖关系,当一个对象的状态发送改变时,所有依赖于它的对象都将得到状态改变的通知,前提必须是,订阅者和发布者任何一方触发了同一个主题。本质上,发布者是否发布,订阅者是无感的(完全解耦)。只有发布者发布的内容是订阅者订阅的主题,订阅者才会收到通知。

    观察者模式 ,观察者和被观察者之间是直接的关系,被观察者的变化都会影响或通知到另外一方。从上图看,我们可以认为:

    • 观察者模式包含着发布订阅模式
    • 两者都拥有通知客户的能力,发布订阅模式由调度中心分配,没有直接关联,相当于观察者模式的升级版
    • 观察者模式主体在发生事件时与客体是松解耦的,不需要感知到客体具体的行为,进行统一的update,但主体还是需要感知到客体的存在,初始化时要预先attach到观察者。发布订阅模式主体不需要感知到客体的任何行为或存在,主体和客体通过事件关联,解耦性更强

    二、为什么会有这两种设计模式?

    额。。好问题。。。

    无语

    个人认为,有时候设计模式就是为了解耦

    例如,Vue 中数据的双向绑定。假如我某个Object发生了变化,其中,我有很多个子组件都双向绑定了这个对象的不同字段。那么其实,我的 Object 中只需要 update 其中一个字段,则对应的子组件中的字段就会发生改变,这个过程中观察其他字段的子组件,是无感的。

    反之,如果全部都集中管理这些字段,假如这时候我新增了一个字段或删减了一个字段,那么我就需要重写这部分的代码。而且每触发一次字段,所有的子组件都要被通知一遍,这对性能的损耗无疑是巨大的。这就是高度耦合的不好的地方。

    解耦 的思想,对于程序员来讲也是非常重要的。因此,就有了这两种设计模式

    三、如何实现发布订阅模式?

    备注,以下代码都在 Node 环境下编写的,有需要的小伙伴自行查阅文末的 git。

    举例: 实现一个 EventBus。就是所谓的 事件总线模式,其实就和发布订阅模式非常类似,比如我们关注了一个作者,作者发布文章之后我们就能收到信息,这就是一种订阅发布的关系。

    export default class EventBus {
    	constructor() {
    		this.eventId = 0;
    		this.eventLine = {};
    	}
    	$on(eventName, handler) {
    		this.eventId++;
    		if (!this.eventLine[eventName]) this.eventLine[eventName] = {};
    		this.eventLine[eventName][this.eventId] = handler;
    		console.log(eventName + "新增了一位粉丝!!");
    		return this.eventId;
    	}
    	$emit(eventName, ...args) {
    		const eventHandlers = this.eventLine[eventName];
    		for(const id in eventHandlers) {
    			eventHandlers[id](...args);
    		}
    	}
    	$off(eventName, key) {
    		delete this.eventLine[eventName][key];
    		if (!Object.keys(this.eventLine[eventName]).length) delete this.eventLine[eventName];
    		console.log("一位粉丝取消了关注");
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    然后,实例化事件总线,并模拟一下场景:

    import EventBus from "EventBus.js";
    
    const eventBus = new EventBus;
    // 关注
    const key1 = eventBus.$on("vk哥", (articleName) => {
    	// 张三关注了你,有发布新文章请通知它
    	console.log("vk哥发布新文章了!!—— 《" + articleName + "》,通知了张三。");
    })
    const key2 = eventBus.$on("vk哥", (articleName) => {
    	// 李四关注了你,有发布新文章请通知它
    	console.log("vk哥发布新文章了!!—— 《" + articleName + "》,通知了李四。");
    })
    // 发布
    eventBus.$emit("vk哥", "设计模式——发布订阅模式");
    // 取消关注
    eventBus.$off("vk哥", key2);
    // 取消关注后再次发布
    eventBus.$emit("vk哥", "Vue2.0源码剖析");
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    然后,让我们看看效果:

    效果

    可见,发布者和订阅者通过同一个主题,也就是 “vk哥”,来绑定关系的。换句话说,发布者是否发布与订阅者是否订阅,没有直接的关联,达到了完全解耦的目的。

    四、Vue 源码中的发布订阅模式

    温馨提示:含有英文的注释都是源码的原注释,中文的注释才是我自己理解的注释。

    Vue 里面,发布订阅模式就体现在它自带的事件总线的方法:

    • Vue.$on
    • Vue.$once
    • Vue.$off
    • Vue.$emit

    所以接下来,我们就这四个核心方法进行分析。

    eventsMixin 里面:

    • Vue.prototype.$on
    Vue.prototype.$on = function (
     event: string | Array<string>,
      fn: Function
    ): Component {
      const vm: Component = this
      // 判断传入的主题是否为数组,如果是,则遍历数组为每一个主题都添加 fn
      if (isArray(event)) {
        for (let i = 0, l = event.length; i < l; i++) {
          vm.$on(event[i], fn)
        }
      } else {
      	// 如果不是,就直接为当前主题添加 fn
        ;(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
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • Vue.prototype.$once
    Vue.prototype.$once = function (event: string, fn: Function): Component {
      const vm: Component = this
      // 把订阅和取消订阅封装成一个新函数对象
      function on() {
        // 取消订阅当前函数对象 on
        vm.$off(event, on)
        // 回调执行 fn
        fn.apply(vm, arguments)
      }
      // 为新函数对象的 fn 赋值
      on.fn = fn
      // 用新函数对象 on 订阅主题
      vm.$on(event, on)
      return vm
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • Vue.prototype.$off
    Vue.prototype.$off = function (
      event?: string | Array<string>,
      fn?: Function
    ): Component {
      const vm: Component = this
      // all
      // 如果没有传入参数,则将所有的主题全部清空为一个空对象
      // Object.create(null) 是创建一个空对象
      if (!arguments.length) {
        vm._events = Object.create(null)
        return vm
      }
      // array of events
      // 判断是否主题为数组,如果是则遍历取消订阅
      if (isArray(event)) {
        for (let i = 0, l = event.length; i < l; i++) {
          vm.$off(event[i], fn)
        }
        return vm
      }
      // specific event
      // vm._events[event!] 是强解析,判断必有 event 参数的情况,这是 typescript 语法
      const cbs = vm._events[event!]
      if (!cbs) {
        // 有 event 参数,但是已经没有 callback 了
        return vm
      }
      // 如果没有 fn 参数,则设置主题为 null,并不删除主题
      if (!fn) {
        vm._events[event!] = null
        return vm
      }
      // specific handler
      // 如果有 callback 的情况,就遍历,一个个从数组删除取消订阅
      // 用 while 则是防止意外的错误
      let cb
      let i = cbs.length
      while (i--) {
        cb = cbs[i]
        if (cb === fn || cb.fn === fn) {
          cbs.splice(i, 1)
          break
        }
      }
      return vm
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • Vue.prototype.$emit
    Vue.prototype.$emit = function (event: string): Component {
      const vm: Component = this
      if (__DEV__) {
        const lowerCaseEvent = event.toLowerCase()
        if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) {
          tip(
            `Event "${lowerCaseEvent}" is emitted in component ` +
              `${formatComponentName(
                vm
              )} but the handler is registered for "${event}". ` +
              `Note that HTML attributes are case-insensitive and you cannot use ` +
              `v-on to listen to camelCase events when using in-DOM templates. ` +
              `You should probably use "${hyphenate(
                event
              )}" instead of "${event}".`
          )
        }
      }
      // 获取 callbacks
      let cbs = vm._events[event]
      if (cbs) {
      	// 如果 callback 的长度大于1,就把 vm._events[event] 整理成数组,方便循环
        cbs = cbs.length > 1 ? toArray(cbs) : cbs
        // 这里是将参数的第一项 eventName 去除
        const args = toArray(arguments, 1)
        // 定义错误提示
        const info = `event handler for "${event}"`
        // 遍历把每个 callback 都执行一次
        for (let i = 0, l = cbs.length; i < l; i++) {
          // 这个是错误捕获函数,一旦报错会把 info 抛出,但并不会让整个js进程奔溃
          invokeWithErrorHandling(cbs[i], vm, args, vm, info)
        }
      }
      return vm
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35

    以上仅仅是部分代码,我只是取出了 Event 的核心,也就是本文所讲的 发布订阅模式 而已。相信结合我的注释看应该能理解,如果有兴趣的小伙伴也可以自己看一下源码的伟大!!!(狗头)

    五、Vue 事件总线的弊端

    针对全局的组件通信,个人建议最好就不用 EventBus 。为什么?

    因为一旦跨组件通信,最大的问题就是事件来源不明确,如果不是自己写的代码,其他人并不知道这东西在哪触发的,啥时候触发,会触发多少次?归根结底就是一个管理困难的问题,没有一个直观的调用顺序,维护起来非常之困难。如果在这个模块上出现问题,那么排查起来将会是灾难级别的。

    但是,仁者见仁智者见智吧,我的观点就是 存在即合理。尽可能的做到:不弃用、不滥用。

    最后,感谢你的阅读,希望我的文章能够帮到你,愿你的未来一片光明。

  • 相关阅读:
    牛客网刷题——JAVA
    spring5:IOC底层原理(2)
    Java中各种数据格式-json/latex/obo/rdf/ turtle/owl/xml介绍对比示例加使用介绍
    PLC面向对象编程系列之数据结构(博途Constant类型和Codesys枚举类型)
    2021.06青少年软件编程(Python)等级考试试卷(四级)
    LeetCode算法心得——生成特殊数字的最少操作(贪心找规律)
    Java异常处理笔记
    用vba实现将记录集输出到Excel模板
    网络安全深入学习第一课——热门框架漏洞(RCE-代码执行)
    微信小程序----父子组件之间通信
  • 原文地址:https://blog.csdn.net/LizequaNNN/article/details/126537409