一文带你彻底搞定发布订阅与观察者模式

314 阅读9分钟

发布订阅

发布订阅是极其基础且重要的设计模式之一,如果在面试中要考察一个设计模式,我想我会毫不犹豫选择发布订阅。那发布订阅到底是个啥,他又应用在哪些场景?我在开始学习这个模式的时候也是一脸懵逼,大佬们告诉我,前端中的事件绑定就是一个发布订阅(黑人问号脸)。不错,这确实是,难道这样一句话就概括了发布订阅?

要彻底的学习并理解发布订阅并不是件容易的事,但也并非是件困难的事情。人常说书读百遍其义自见,很多东西都只是时间的问题,用的多了自然水到渠成。下面结合我的学习过程,讲述一下我对发布订阅模式的理解。

写一个简单的发布订阅

发布订阅当然分两个部分了:订阅和发布,就如同关注的公众号一样,只有订阅了才会收到他的发布。那订阅和发布总得有一个载体,或者说一个目标,就拿这个公众号的订阅发布为例,那这个载体就应该是公众号,比如我的公众号 web瑞驰 就是一个载体,所有的订阅者都应该被存储在公众号中的某个地方。

  1. 声明载体,将所有的订阅者放在这个载体中。
// 声明一个公众号作为载体
class Account {
  constructor(name) {
    // 这里的 name 没什么特殊含义,我的本意是指代当前 new 出来的公众号名
    this.name = name
    // 所有的订阅者都放在这个公众号的 subscribers 属性上
    this.subscribers = {}
  }
}
  1. 载体已经有了,接下来是调度中心添加订阅的过程
class Account {
  // 订阅过程,name 是订阅者的账号,fn 就代表订阅的事件
  // 订阅者的订阅事件可能不止一个,因此将事件作为一个数组
  // 考虑到可能重复订阅,因此也可以使用 set 作为订阅事件的容器
  subscribe(name, fn) {
    if(typeof fn !== 'function') return

    const subscribers = this.subscribers
    if(subscribers[name]) {
      // 去重
      !subscribers[name].includes(fn) && subscribers[name].push(fn)
    }else {
      subscribers[name] = [fn]
    }
  }
}
  1. 接下来是发布的过程
class Account {
  // 发布的过程可能只针对某些订阅者,比如是 a 用户发送了一条消息
  // 那公众号只对 a 进行回复,因此这里只对某一个订阅者做发布
  publish(name) {
    const subscribers = this.subscribers

    if(subscribers[name]) {
      subscribers[name].forEach(fn => fn())
    }
  }
}

到此整个订阅发布就写完了。目前我们实现了最基本的功能订阅发布功能,比如张三订阅了我的公众号,那实现过程如下:

const webRuichi = new Account('web瑞驰')

// 张三订阅
webRuichi.subscribe('张三', function() {
  console.log(`张三订阅了公众号`)
})

// 公众号给张三发布内容
webRuichi.publish('张三') // 输出-->  张三订阅了公众号

至此我们的订阅发布已经可以运行了,但还有些不足之处,比张三在订阅内容的时候需要一些特殊的信息,在公众号为张三发布信息的时候将其需要的东西发送给他。接下来对发布流程做一个修改:

class Account {
  publish(name, ...rest) {
    const subscribers = this.subscribers

    if(subscribers[name]) {
      subscribers[name].forEach(fn => fn(...rest))
    }
  }
}

接下来张三在订阅的时候就可以告诉公众后需要什么样的信息:

// 张三在订阅的时候想知道他订阅的是哪个公众号
webRuichi.subscribe('张三', function(name) {
  console.log(`张三订阅了 "${name}" 公众号`)
})

// 公众号在发布时告诉张三自己的公众号名
webRuichi.publish('张三', webRuichi.name) // 输出-->  张三订阅了 "web瑞驰" 公众号

订阅发布的实现很简单,就是将订阅者们的事件放在自己的数组中,在发布的时候取到对应的订阅者将其中的事件依次执行。应用到 web 前端领域,最常见的就是事件的绑定。在初始化时为某个按钮订阅相关的事件(这里以点击事件为例),在点击触发的时候做一个发布。这个按钮就是一个载体,上面有订阅者们订阅的事件,当点击事件被触发时发布。

大家应该都清楚,订阅发布是用来解耦的。作为一个初学者,订阅发布他怎么就解耦了?这不就是个函数数组的依次执行吗,还就解耦了?还有,他真正的用武之地是在哪里?带着这些问题继续往下探索。

发布订阅运用在哪里

  1. vue组件传参

相信大家在面试中都遇到过vue中组件之间的传参问题,当组件嵌套过深,普通的props传参将会边得十分繁琐,比如组件A要给堂兄弟B组件传参,那路径将会是这样:A组件 —> 父组件 —> 爷爷组件 —> 叔叔组件 —> B组件,前后要穿过三个组件,这对着三层组件会造成污染,另外在后期的维护上也造成很大的麻烦。这时候应用发布订阅的模式传参将会十分方便,下面看一下代码:

// main.js文件
import Vue from 'vue'
import Event from './event'   // 引入订阅发布

Vue.prototype.$event = new Event()

// A组件
<template>
  <div>
    <button @click="handleClick">传值</button>
  </div>
</template>
  
<script>
  export default {
    methods: {
      handleClick() {
        const passVal = '这是A组件想B组件传入的值'
        // 在点击的时候发布事件
        this.$event.publish('AToB', passVal)
      }
    }
  }
</script>

// B组件
<template>
  <div></div>
</template>
  
<script>
  export default {
    mounted() {
      // 订阅事件
      this.$event.subscribe('AToB')
    }
  }
</script>

从这个例子就可以很明显的看出来,在A组件中发布事件和B组件中订阅事件完全是分开的,在A中发布的逻辑发生了变化并不会影响到B组件中的逻辑。而之前的props传参耦合了中间的三层组件,一旦更改了要传递的参数个数,那中间三层组件都要做出相应更改。

  1. 异步回调中的订阅发布

由于javascript运行机制的原因,代码中充斥着各种各样的异步回调。比如在 node 中的这样一个场景:浏览器请求 node 服务器,node 要返回相应的页面,但页面需要读取数据库以及读取模板文件两个I/O操作,如果使用传统的回调,将会是这样:

const fs = require('fs')
const queryMysql = require('./queryMysql')

fs.readFile('./template.ejs', 'utf8', (err, template) => {
  if(err) throw err
  
  queryMysql('SELECT * FROM user', (err, data) => {
    if(err) throw err
    
    render(template, data)
  })
})

我们来分析一下上面的代码:

  1. 可读性十分糟糕,实现也不够优雅
  2. 出现两层的回调嵌套,每一层回调都要进行错误处理
  3. 本可以并行的I/O变成了串行,性能上比较耗时

这只是两层嵌套,如果在更多的异步回调中将显得异常繁琐。在对异步回调的发展过程中,就曾出现过使用发布订阅来简化的方式。那下面我们就用订阅发布来改良一下上面的代码:

const fs = require('fs')
const Event = require('event')
const queryMysql = require('./queryMysql')

const eventEmitter = new Event()
// 这里使用闭包的形式返回订阅的事件,目的是使 html 成为局部变量
const genReadyEvent = () => {
  const html = {}
  const TOTAL_KEY_COUNT = 2  // 渲染模板使用的数据有两个
  
  return (key, data) => {
    html[key] = data
    if(Object.keys(html).length === TOTAL_KEY_COUNT) {
      render(html[template], html[data])
    }
  }
}
eventEmitter.subscribe('ready', genReadyEvent())

fs.readFile('./template.ejs', 'utf8', (err, template) => {
  if(err) throw err
  eventEmitter.publish('ready', 'template', template)
})

queryMysql('SELECT * FROM user', (err, data) => {
  if(err) throw err
  eventEmitter.publish('ready', 'data', data)
})

经过上面代码的改良,首先代码可读性有了很大的提高,而且在性能方面也有了提升,并行的I/O操作充分利用了node的特性。另外,如果渲染逻辑发生了变化,也一般更改的是readyEvent内部的逻辑,这与订阅处的逻辑完全解耦。

订阅发布的再次完善

通过上面的例子介绍了订阅发布的实现以及运用,其实目前的实现仍然有些缺陷,比如

  • 用户不能取消订阅
  • 某些情况下可能只需要订阅一次
  • 如果订阅的事件在执行过程中取消了某次订阅
  • ...

针对以上问题我们来做出解决方案:

  1. 取消订阅,需要提供一个方法用来取消订阅
class Account {
  unsubscribe(name, fn) {
    const subscribers = this.subscribers

    if(subscribers[name]) {
      // 如果没有提供对应的事件则将整个订阅全部移除
      if(!fn) {
        delete subscribers[name]
      }else if(typeof fn === 'function') {
        const index = subscribers[name].findIndex(event => event === fn)
        // 如果要移除的事件没有在订阅中则 index 为-1 (~按位非运算符)
        ~index && subscribers[name].splice(index, 1)
      }
    }
  }
}
  1. 只订阅一次后就取消订阅的
class Account {
  subscribeOnce(name, fn) {
    if(typeof fn !== 'function') return
      
    const wrap = () => {
      fn()
      this.unsubscribe(name, fn)
    }
      
    this.subscribe(name, fn)
  }
}
  1. 如果在发布时某次订阅中的事件取消了后续要发布的事件,这样在遍历的时候可能会出现数组塌陷的问题,因此在这里我们对订阅的事件做一个改造,如此一来在取消订阅的时候也要做改造。
class Account {
  constructor(name) {
    this.name = name
    this.subscribers = {}
  }
  
  subscribe(name, fn) {
    if(typeof fn !== 'function') return
    
    const subscribers = this.subscribers
    if(subscribers[name]) {
      // 对订阅的事件进行包装
      const event = {
        hasRemoved: false,
        event: fn
      }
        
      subscribers[name].push(event)
    }else {
      const event = {
        hasRemoved: false,
        event: fn
      }
      
      subscribers[name] = [event]
    }
  }
  
  // 只订阅一次的代码不变
  
  // 取消订阅的代码也需要改造
  unsubscribe(name, fn) {
    const subscribers = this.subscribers

    if(subscribers[name]) {
      // 如果没有提供对应的事件则将整个订阅全部移除
      if(!fn) {
        delete subscribers[name]
      }else if(typeof fn === 'function') {
        const target = subscribers[name].find(eventInfo => eventInfo.event === fn)
        target && (target.hasRemoved = true)
      }
    }
  }
  
  // 发布的代码需要改造
  publish(name, ...rest) {
    const subscribers = this.subscribers
    const events = subscribers[name]
    if(events) {
      for(let i = 0, len = events.length; i < len; i++) {
        const eventInfo = events[i]
        if(eventInfo.hasRemoved) continue
        eventInfo.event(...rest)
        
        // 如果是将整个订阅事件移除后面就不用了继续发布了
        // 注意顺序,移除整个事件只可能是在某次发布之后,如果在之前已经全部移除了将不会执行到for内
        if(!subscribers[name]) break
      }
      
      // 发布完成后将取消订阅的事件移除
      if(subscribers[name]) {
        subscribers[name] = events.filter(eventInfo => !eventInfo.hasRemoved)
      }
    }
  }
}

以上就是一段完整的订阅发布的实现了,在实际调用时都需要 new 一个新的对象,订阅和发布都由这个对象进行调度(或者说管理)。这个对象将订阅和发布彼此独立开来使他们互不影响,这也正是解耦的精髓所在。

观察者模式

观察者模式是由订阅发布衍生出来的,他也是基于订阅发布实现的。订阅发布是按需进行发布,而观察者在被观察的对象状态发生变化时观察对象的状态也将同步更新。

另外,观察者模式是一对多的依赖关系,被观察者可以被多个观察者所观测,当被观察者的状态更新时通知到观察者,此时观察者再同步更新状态。根据这个我们来实现一下观察者模式:

// 首先应该有一个被观察者
class Subject {
  constructor(name, state) {
    this.name = name
    this.state = state
 
    this.observers = []
  }
 
  // 添加观察者
  addObserver(ob) {
    this.observers.push(ob)
  }
 
  // 删除观察者
  removeObserver(ob) {
    const observers = this.observers
    const index = observers.indexOf(ob)
    ~index && observers.splice(index, 1)
  }
 
  // 被观察者的状态发生变化后通知观察者
  setState(newState) {
    if(this.state !== newState) {
      const oldState = this.state
      this.state = newState
      this.notify(oldState)
    }
  }
 
  // 通知所有观察者被观察者状态变化
  notify(oldState) {
    this.observers.forEach(ob => ob.update(this, oldState))
  }
}
 
// 之后有一个观察者
class Observer {
  constructor(name) {
    this.name = name
  }
 
  update(sub, oldState) {
    console.log(`${this.name}观察的${sub.name}的状态由 ${oldState} 变化为 ${sub.state}`)
  }
}
 
const sub = new Subject('小baby', '开心的笑')
const father = new Observer('爸爸')
const mother = new Observer('妈妈')
sub.addObserver(father)
sub.addObserver(mother)
sub.setState('悲伤的哭')

由上面的代码可以看出,观察者和被观察者之间是松耦合的,他们可以分别独立的改变。一个被观察者可以被多个观察者观测,被观察者可以自己决定要被哪个观察者观测。

当然,我们也可以在观察者中存储当前的观察者观测了哪些被观察者,如此一来,当被观察者发送广播通知的时候,观察者也可以自由的决定是否要对某个被观察者进行处理:

class Subject {
  addObserver(ob) {
    this.observers.push(ob)
    // 观察者添加当前的观察对象(也就是被观察者)
    ob.addSub(this)
  }
}
 
class Observer {
  constructor(name) {
    this.name = name
    // 当前的观察对象(被观察者)
    this.subs = []
    this.excludeSubs = []
  }
 
  // 添加当前观察的对象
  addSub(sub) {
    this.subs.push(sub)
  }
 
  // 添加不进行处理的观察者
  addExcludeSubs(sub) {
    this.excludeSubs.push(sub)
  }
 
  update(sub, oldState) {
    if(this.excludeSubs.includes(sub)) return
 
    // do something ...    
  }
}

vue 的响应式原理就使用到了观察者模式,在Object.definePerporty的 get 方法中为每一个属性添加观察者,在 set 方法中通知观察者该属性的状态发生变化,因此数据变化后会触发 watcher 再次执行,从而调用 vue 原型上的 _update 方法生成新的虚拟 DOM,继续调用render方法进行 DOM-diff 后更新页面。

总结

本文对订阅发布与观察者模式进行了对比,订阅发布是通过一个载体(调度中心)将订阅者与发布者联系起来,订阅者与发布者相互独立,以此达到解耦的目的。

而观察者模式中观察者和被观察者是松耦合的,全程由被观察者调度,被观察者可以自由的决定被哪个观察者观测,观察者也可以决定是否对某个被观察进行处理。被观察者状态发生变化会主动通知到观察者,由观察者做出相应的处理。另外:

文中若有表述不妥或是知识点有误之处,欢迎留言批评指正,共同进步!