简述 js 观察者模式和发布订阅模式

1,416 阅读4分钟

前言

发布订阅模式观察者模式的改造,所以本文会先编写一个基础的 观察者模式 案例,在此基础上再编写一个 发布订阅模式 案例,然后结合两者的特点,分析一些前端开发过程中常见的实践案例,帮助大家学习理解这两者的基本概念和区别。

需要留意的是,两者都有共同性: 一对多的思想


订阅器 观察者

  • 订阅器 也称为 主题(subject)、主题事件(topic event),本质上是一个对象,是用于保存观察者(订阅者)的载体,同时拥有 添加观察者、通知所有观察者的方法,例如 公司团建 就是一个 主题事件,这个主题事件包含 公司成员信息、地点、需要干的事等信息
  • 观察者 也称为 订阅者, 一般为对象(也可以是一个函数)

为了方便表述,本文以 订阅器、观察者 称呼这两者


观察者模式

我们设计一个观察者模式:以 click 为标识创建 订阅器 clickDep,然后创建 2个 观察者 watch 订阅 click, 订阅器 clickDep 发布通知,观察者 watch 集体触发

//构造 订阅器 的类
class Dep{
    constructor(id){
        this.id = id; //订阅器 标识
        this.subs = []
        
    }

    push(watch){
        watch.bind_id = this.id; //给 观察者 标识 归属的订阅器
        this.subs.push(watch)
    }

    notify(){
        this.subs.forEach((watch)=>{
            watch.update()
        })
    }
}

//构造 观察者 的类
class Watch{
    constructor(update){
        this.update = update
    }
}

//创建一个订阅器实例 click dep 
var clickDep = new Dep('click');

//创建观察者实例
var w1 = new Watch(()=>console.log('click watch 1'))
var w2 = new Watch(()=>console.log('click watch 2'))

//添加观察者
clickDep.push(w1)
clickDep.push(w2)

//发布消息
clickDep.notify()

发布订阅模式

创建一个 调度中心(事件中心): common, 用来综合管理 订阅器观察者,同样把 添加观察者方法发布订阅器通知 也放在 common 进行管理

class Common{
    constructor(id){
        this.id = id
        this.deps = {} //用于保存 订阅器
    }

    createDep(key){
        this.deps[key] = []
    }

    createWatch(update){
        return {
            bind_id:null,
            update
        }
    }

    push(key,watch){
        watch.bind_id = key
        this.deps[key].push(watch)
    }

    notify(key){
        this.deps[key].forEach((watch)=>{
            watch.update()
        })
    }
}

//创建 调度中心
const common = new Common('my common')

//创建 订阅器 
common.createDep('click')

//创建 观察者 
var w1 = common.createWatch(()=>console.log('click watch 1'))
var w2 = common.createWatch(()=>console.log('click watch 2'))

//添加 观察者
common.push('click',w1)
common.push('click',w2)

//发布 通知
common.notify('click')

两种模式的区别

通过上述案例,我们可以发现,观察者模式的订阅器之间是各自独立的,通俗的讲:就是我们创建 clickDep , touchDep 两个订阅器实例,他们的存放位置是分散的,没有通过统一的对象进行管理

而 发布订阅模式,优化了这方面的问题,通过创建一个 common 对象,统一管理 订阅器、以及它们映射的 观察者,还有 添加观察者、发布订阅器通知这些方法


应用案例分析


  • DOM2 事件绑定: 给 documentaddEventListener 绑定3个事件处理函数,点击页面,会集体触发这三个函数

    document.addEventListener('click',()=>{console.log('click handler 1')})
    
    document.addEventListener('click',()=>{console.log('click handler 2')})
    
    document.addEventListener('click',()=>{console.log('click handler 3')})
    

    案例分析:

    • 订阅器: js 内部关于 click事件处理函数收集器
    • 观察者: 三个 事件处理函数
    • 发布消息: click 触发

  • Promise:实现一个简单的 Promise

    class Promise2{
    
      constructor(fn) {
    
        this.thenDep = []; // thenDep 订阅器
        this.catchDep = [];
    
        fn(
          this.resolve.bind(this),
          this.reject.bind(this)
        )
    
      }
    
      then(cb) { //thenDep 添加订阅者 cb
        this.thenDep.push(cb);
        return this; //返回自身进行链式调用
      }
    
      catch(cb) {
        this.catchDep.push(cb);
        return this;
      }
    
      resolve(res) { // thenDep 发布消息
        this.thenDep.forEach((cb) => {
          cb(res)
        })
      }
    
      reject(res) {
        this.catchDep.forEach((cb) => {
          cb(res)
        })
      }
    }
    
    
    new Promise2(function (resolve, reject) {
    
      console.log('handler')
    
      setTimeout(function () {
        resolve({ data: 'resolve message' })
      }, 2000)
    
    })
      .then((res) => {
        console.log('then 1', res)
      })
      .then((res) => {
        console.log('then 2', res)
      })
      .catch((error) => {
        console.log('catch 1', error)
      })
      .catch((error) => {
        console.log('catch 2', error)
      })
    

    案例分析:

    • 订阅器Promise2 内部维护的 [then/catch]Dep

    • 观察者: 使用 then、catch 传入的 回调函数

    • 发布消息resolve、reject 触发


  • Vue模板更新订阅:在 vue 组件 data 中声明 username , 在页面三处地方(例如 text、html、attr)使用 username 进行渲染,当点击 <button> 触发 username 被改变时,在<dl> 内的三次渲染位置都会得到更新

    
    <template>
        <dl>
            <dt>{{username}}</dt> 
            <dd v-html="username" :data-username="username"></dd>
            <dd>
                <button @click="username = 'Jeck'">change username</button>
            </dd>
        </dl>
    </template>
    
    <script>
    export default {
        data() {
            return {
            username:'Tom' //vue内部根据该属性,创建一个订阅器:[username]Dep
            };
        },
        ...
    }
    </script>
    

    案例分析:

    • 订阅器:vue内部创建 [username]Dep 对象

    • 观察者:三次dom渲染 对应的更新函数 [text/html/attr]Update

    • 发布消息username 被修改


通过设计模式,理解 Vue

  • 订阅器创建阶段: vue源码,通过对 data 每个属性进行绑定(Object.defineProperty),同时创建每个属性的订阅器 [key]Dep ,通过属性绑定时的 set 函数,触发订阅器([key]Dep)通知

  • 观察者绑定阶段: 发生在template模板初始渲染时,vue会把 模板更新函数,以观察者的方式,绑定在属性的订阅器([key]dep)中,

  • 订阅过程: username<dt>{{username}}</dt>使用一次,模板初始更新 dom(textUpdate),同时把 textUpdate 订阅在[username]Dep

  • 发布过程: username 被改变,[username]Dep 发布消息, 观察者 textUpdate 被触发:把 username 最新值 更新同步在 dom <dt>


模板更新函数: 基于原生js dom api进行封装的函数,例如参数传入 node 节点value 目标值,执行以下操作

  • 属性更新 el.setAttribute
  • 文本更新 el.textContent
  • html 更新 el.innerHTML
  • input 更新 input.value
  • class 更新 el.classList[add\remove]
  • style 更新 el.style[name]

内容按个人学习经历、笔记总结,可能有一些知识点理解有出入,希望大家多多指教,提出建议。谢谢~
第二次发文,有收获能给一个赞嘛,谢谢~