乐趣区

关于前端:一文带你彻底搞定发布订阅与观察者模式

公布订阅

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

要彻底的学习并了解公布订阅并不是件容易的事,但也并非是件艰难的事件。人常说书读百遍其义自见,很多货色都只是工夫的问题,用的多了天然瓜熟蒂落。上面联合我的学习过程,讲述一下我对公布订阅模式的了解。

写一个简略的公布订阅

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

请大家关注我的公众号:web 瑞驰,发送 1024 获取更多前端学习资源:

  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 后更新页面。

总结

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

而观察者模式中观察者和被观察者是松耦合的,全程由被观察者调度,被观察者能够自在的决定被哪个观察者观测,观察者也能够决定是否对某个被察看进行解决。被观察者状态发生变化会被动告诉到观察者,由观察者做出相应的解决。另外:

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

退出移动版