5_发布订阅模式

1 简介

作为一种优秀的设计模式,发布订阅者模式被广泛应用在前端领域

eg: 在 vue 的源码中,为了让数据劫持和视图驱动解耦 就是通过架设一层消息管理层实现的,而这一层消息管理层实现的原理就是发布订阅模式

现在举个现实中的例子来感受一下:你看上了一套房,到了售楼处被告知该楼盘的房子早已售罄。然后售楼处就说有一批尾盘开发商正在办理相关手续,办好就可以购买了。但是具体什么时候,目前还不知道

  • 方案A:你记下售楼处的电话,每天打电话问什么时候可以买(张三李四也每天打电话问)
  • 方案B:你留下电话号码,新楼盘推出的时候售楼处打电话通知你

售楼处收集意向购房者电话,有新楼盘推出则一一通知

显然方案B才是合理的,在这个场景中,售楼处扮演了被观察者(Subject) 的角色,你就扮演了其中一个观察者(Observer)

观察者模式

存在的问题:

  • 你需要知道售楼处的名字才能顺利订阅--存在一定耦合性
  • 售楼处要在新楼盘推出的时候挨个通知你们--存在一定资源浪费

解决方案:

  • 将购房需求交给中介公司,而售楼处也通过中介来发布房子信息 -- 你跟售楼处解耦

我们只需要关心能否顺利收到消息

在这个场景中,售楼处扮演了被消息发布者(Publisher)角色 的角色,中介扮演了消息中心角色,你就扮演了其中一个订阅者(Subscriber)

发布订阅者模式

2 概念

  • 发布订阅者模式定义对象间一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。

优点

  1. 在合适的时间点售楼处作为发布者会通知消息订阅者

    说明发布订阅者模式可广泛应用于异步编程,这是一种替代传递回调函数的方案。

  2. 购房者与售楼处解耦,售楼处无需关注购房者的任何情况,只要售楼处记得通知即可

    说明发布订阅者模式可以取代对象之间硬编码的通知机制,一个对象不再显式地调用另一个对象的某个接口。发布者不需要知道有多少订阅者,以及订阅者接收到消息之后会干什么,而订阅者也不需要关心发布者会在什么时候发布消息,两者相互独立运行。

3 DOM 事件

document.body.addEventListener('click', () => alert(2))
document.body.addEventListener('click', () => alert(3))
// 模拟用户点击
document.body.click()

需要监控用户点击documet.body的动作,但是无法预知用户何时点击 --> 所以订阅了document.body上的click事件,当body节点被点击时,body节点便会向订阅者发布这个消息

  • 用户点击 -- 发布者
  • 绑定点击事件 -- 订阅者
  • document.body处理绑定事件 -- 消息中心

    滚动、按键

4 自定义事件

如何一步步实现发布订阅者模式

实现思路

image.png

4.1 简单实现

// 定义售楼处
let salesOffices = {
  // 缓存列表 -- 存放订阅者的回调函数
  clientList: [],
  // 增加订阅者
  listen(fn) {
    // 订阅的消息添加到缓存列表
    this.clientList.push(fn)
  },
  // 发布消息
  trigger() {
    for(let fn in this.clientList) {
    // arguments是发布时带上的参数
      fn.apply(this, arguments) 
    }
  }
}

4.2 简单测试

// A订阅
salesOffices.listen((price, squareMeter) => {
  console.log('A订阅 价格=' + price)
  console.log('A订阅 面积=' + squareMeter)
})
// B订阅
salesOffices.listen((price, squareMeter) => {
  console.log('B订阅 价格=' + price)
  console.log('B订阅 面积=' + squareMeter)
})
salesOffices.trigger(2000000, 88)
salesOffices.trigger(3000000, 110)
// A订阅 价格=2000000
// A订阅 面积=88
// B订阅 价格=2000000
// B订阅 面积=88
// A订阅 价格=3000000
// A订阅 面积=110
// B订阅 价格=3000000
// B订阅 面积=110

A只想订阅88平方米的消息,但是发布者将110平方的消息也推送给了A

4.3 增加标识 key

// 定义售楼处
let salesOffices = {
  // 缓存列表--存放订阅者的回调消息
  clientList: {},
  // 增加订阅者
  listen(key, fn) {
    // 若还没订阅过此类消息,给该类消息创建一个缓存列表
    if(!this.clientList[key]) {
      this.clientList[key] = []
    }
    // 订阅的消息添加到缓存列表
    this.clientList[key].push(fn) 
  },
  // 发布消息
  trigger() {
    // 取出消息类型 -- 第一个参数对应消息类型key
    let key = Array.prototype.shift.call(arguments)
    // 取出该消息对应的回调函数数组
    let fns = this.clientList[key]
    // 若没有订阅过相关消息则返回
    if(!fns || fns.length === 0) return false
    for(let fn in fns) {
      // 执行对应列表的回调
      fn.apply(this, arguments)
    }
  }
}

4.4 测试

// A订阅
salesOffices.listen('squareMeter88', price => {
  console.log('A订阅 价格=' + price)
})
// B订阅
salesOffices.listen('squareMeter110', price => {
  console.log('B订阅 价格=' + price)
})
salesOffices.trigger('squareMeter88', 2000000)
salesOffices.trigger('squareMeter110', 3000000)
// A订阅 价格=2000000
// B订阅 价格=3000000

此时,订阅者可以只订阅自己感兴趣的事件了

5 发布—订阅模式的通用实现

已经实现了售楼处接受订阅和发布事件的功能,此时A想去另一个售楼处买房子,是否必须给另一个售楼处copy一份代码?有没有办法让所有对象都拥有发布订阅功能?

  • 给对象动态添加职责

5.1 提取发布订阅功能放在单独的对象内

let event = {
  // 缓存列表
  clientList: {},
  listen(key, fn) {
    if(!this.clientList[key]) {
      this.clientList[key] = []
    }
    this.clientList[key].push(fn)
  },
  // 发布消息
  trigger() {
    let key = Array.prototype.shift.call(arguments)
    let fns = this.clientList[key]
    if(!fns || fns.length === 0) return false
    for(let fn in fns) {
      // arguments 是发布消息时带上的参数
      fn.apply(this, arguments)
    }
  }
}

5.2 定义 installEvent 函数

  • 该函数给所有对象动态安装发布订阅功能
function installEvent(obj) {
  for(let i in event) {
    obj[i] = event[i]
  }
}

5.3 测试

  • 给售楼处对象salesOffices动态增加发布订阅者功能
let salesOffices = {}
installEvent(salesOffices)
// A订阅
salesOffices.listen('squareMeter88', price => {
  console.log('A订阅 价格=' + price)
})
// B订阅
salesOffices.listen('squareMeter110', price => {
  console.log('B订阅 价格=' + price)
})
salesOffices.trigger('squareMeter88', 2000000)
salesOffices.trigger('squareMeter110', 3000000)
// A订阅 价格=2000000
// B订阅 价格=3000000

5.4 取消订阅的事件

有时也需要取消订阅事件的功能,比如A突然不想买房了,为了避免收到售楼处推送来的消息,需要取消之前的订阅事件

  • 给event对象增加remove事件
event.remove = function(key, fn) {
  let fns = this.clientList[key]
  if(!fns) return false
  // 若没有传入具体回调,则表示取消key对应消息的所有订阅
  if(!fn) {
    fns && (fns.length = 0)
  } else {
    for(let i = fns.length - 1; i >= 0; i--) {
      if(fns[i] === fn) {
        fns.splice(i, 1)
      }
    }
  }
}
  • 测试
let salesOffices = {}
installEvent(salesOffices)
// A订阅
salesOffices.listen('squareMeter88', fn1 = price => {
  console.log('A订阅 价格=' + price)
})
// B订阅
salesOffices.listen('squareMeter88', fn2 = price => {
  console.log('B订阅 价格=' + price)
})
salesOffices.remove('squareMeter88', fn1) // 删除A订阅
salesOffices.trigger('squareMeter88', 3000000)
// B订阅  价格=3000000

6 真实的例子 -- 网站登录

6.1 前置条件

我们正在开发一个商城网站,网站里有header、nav、消息列表、购物车等模块,这几个模块的渲染有一个共同的前提,就是必须先用ajax异步请求获取用户的登录信息

你负责编写登录模块
login.succ(data => {
 header.setAvatar(data.avatar); // 设置 header 模块的头像
 nav.setAvatar(data.avatar); // 设置导航模块的头像
 message.refresh(); // 刷新消息列表
 cart.refresh(); // 刷新购物车列表
});

需要了解到

  • header模块里设置头像的方法叫setAvatar
  • 购物车模块里刷新的方法叫refresh

这是针对具体实现编程 -- 模块之间高耦合

将来可能还有其它模块要使用用户的登录信息
  • +收货地址管理模块 --> 往登录模块添加信息
login.succ(data => {
 header.setAvatar(data.avatar);
 nav.setAvatar(data.avatar);
 message.refresh();
 cart.refresh();
 address.refresh(); // 增加这行代码
});
疲于应付业务,不得不重构代码
  • 业务维护--开放封闭原则
  • 登录模块只需发布登录成功的消息,并不关心业务方的内部细节
  • 异步通知

6.2 实现

  • 用发布订阅模式重写后,对用户信息感兴趣的业务模块将自行订阅登陆成功的消息事件
  • 当登录成功时,登录模块只需要发布登陆成功的消息
  • 而业务放接收到消息之后,自行进行各自的业务处理
改善后的登录模块代码
$.ajax('http://xxx/login', data => {
  // 登录成功-发布登录成功的消息
  login.trigger('loginSucc', data)
})
各模块监听登录成功的消息
let header =(() => {
  login.listen('loginSucc', data => {
    header.setAvatar(data.avatar)
  })
  return {
    setAvatar(data) {
      console.log(data, '设置header模块的头像')
    }
  }
})()
let nav = (() => {
  login.listen('loginSucc', data => {
    nav.setAvatar(data.avatar)
  })
  return {
    setAvatar(data) {
      console.log(data, '设置nav模块的头像')
    }
  }
})()
扩展--新增刷新收货地址列表行为
let address = (() => {
  login.listen('loginSucc', data => {
    address.refresh(data)
  })
  return {
    refresh(avatar) {
      console.log('刷新收货地址列表')
    }
  }
})()

7 必须先订阅再发布吗

  • 在商城网站中,获取用户信息之后才能渲染nav模块,而获取用户信息是一个ajax异步请求。当ajax请求成功返回之后会发布一个事件,在此之前订阅了此事件的nav模块便可以接收到这些用户信息。-- 先订阅后发布
  • 懒加载模块需要用到用户信息 -- 在发布后订阅

这种情况下需要将发布的消息保存起来,等有对象订阅时再重新把消息发布给订阅者

  • 建立一个存放离线事件的堆栈,将发布事件包裹在一个函数中,暂存在堆栈中
  • 当有对象来订阅时,遍历堆栈并且依次执行这些包装函数,即重新发布里面的事件。

8 全局的发布—订阅对象

已经实现了给售楼处对象添加订阅和发布的功能,但还存在两个问题

8.1 存在的问题

  1. 给每个发布者都添加了listen和trigger方法、缓存列表clientList,造成了一定的资源浪费
  2. 订阅者需要知道发布者的名字才能顺利订阅消息 -- 存在一定耦合性
// A订阅salesOffices卖家的100平
salesOffices.listen('squareMeter100', price => {
  console.log('价格=' + price)
})
// A订阅salesOffices1卖家的88平
salesOffices1.listen('squareMeter88', price => {
  console.log('价格=' + price)
})

8.2 解决方案

  • 将订阅请求交给中介公司,而售楼处也通过中介来发布房子信息

改进

  1. 使用全局Event对象实现发布订阅模式
  2. 订阅者无需了解消息来自哪个发布者
  3. 发布者无需了解消息推送给哪些订阅者
  4. 全局Event对象将订阅者和发布者联系起来
class Event {
  constructor() {
    this.clientList = {}
  }
  listen(key, fn) {
    if(!this.clientList[key]) {
      this.clientList[key] = []
    }
    this.clientList[key].push(fn)
  }
  trigger() {
    let key = Array.prototype.shift.call(arguments)
    let fns = this.clientList[key]
    if(!fns || fns.length === 0) return false
    for(let fn in fns) {
      fn.apply(this, arguments)
    }
  }
  remove(key, fn) {
    let fns = this.clientList[key]
    if(!fns) return false
    if(!fn) {
      fns && (fns.length = 0)
    } else {
      for(let i = fns.length - 1; i >= 0; i--) {
        if(fns[i] === fn) {
          fns.splice(i, 1)
        }
      }
    }
  }
}
let e = new Event()
// A订阅消息
e.listen('squareMeter100', price => {
  console.log('价格=' + price)
})
// 各售楼处发布消息
e.trigger('squareMeter100', 2000000)

9 全局事件的命名冲突 -- 命名空间

全局的发布订阅对象里只有一个clientList来存放消息名和回调函数 -> 事件名冲突

  • 使用命名空间如何实现监听
/************** 先发布后订阅 ********************/
Event.trigger('click', 1);
Event.listen('click', a => {
  console.log( a ); // 输出:1
});

/************** 使用命名空间 ********************/
Event.create('namespace1').listen('click', a => {
  console.log(a); // 输出:1
});
Event.create('namespace1').trigger('click', 1);
Event.create('namespace2').listen('click', a => {
  console.log(a); // 输出:2
});
Event.create('namespace2').trigger('click', 2);

9.1 实现 EventBase 类

class EventBase {
  constructor() {
    this._default = 'default'
    this._slice = Array.prototype.slice
    this._shift = Array.prototype.shift
    this._unshift = Array.prototype.unshift
    this.namespaceCache = {}
  }
  // 迭代执行回调
  each(arr, fn) {}
  // 消息订阅
  _listen(key, fn, cache) {}
  // 移除消息订阅
  _remove(key, cache, fn) {}
  // 消息发布
  _trigger() {}
  // 创建命名空间
  _create(namespace) {}
}
1. 迭代执行回调
/**
 * 迭代执行回调
 * @param arr key对应的单个缓存栈
 * @param fn 可执行的回调函数
 * @return ret 
*/
each(arr, fn) {
  let ret
  for(let i = 0, l = arr.length; i < l; i++) {
    let n = arr[i]
    ret = fn.call(n, i, n)
  }
  return ret
}
2. 消息订阅
/**
 * 消息订阅
 * @param key 消息标识
 * @param fn 消息订阅时的回调
 * @param cache 消息列表
*/
_listen(key, fn, cache) {
  if(!cache[key]) {
    cache[key] = []
  }
  cache[key].push(fn)
}
3. 移除消息订阅
/**
 * 移除消息订阅
 * @param key 消息标识
 * @param cache 消息列表
 * @param fn 消息订阅时的回调
*/
_remove(key, cache, fn) {
  let fns = cache[key]
  if(!fns) return false
  if(!fn) {
    fns  = []
  } else {
    for(let i = fns.length - 1; i >= 0; i--) {
      if(fns[i] === fn) {
        fns.splice(i, 1)
      }
    }
  }
}
4 消息发布
/**
 * 消息发布
 * @param cache 方法名
 * @param key 标识
*/
_trigger() {
  let cache = this._shift.call(arguments),
    key = this._shift.call(arguments),
    stack = cache[key],
    args = arguments,
    _self = this
  if(!stack || !stack.length) return
  return _self.each(stack, function() {
    return this.apply(_self, args)
  })
}
5 创建命名空间
/**
 * 创建命名空间
 * @param namespace 命名空间
*/
_create(namespace) {
  namespace = namespace || this._default
  let _self = this
  let cache = {},
    offlineStack = [], // 离线事件
    ret = {
      // 消息订阅
      listen(key, fn, last) {
        _self._listen(key, fn, cache)
        if(offlineStack === null) return
        if(last === 'last') {
          offlineStack.length && offlineStack.pop()()
        } else {
          _self.each(offlineStack, function() {
            this()
          })
        }
        offlineStack = null
      },
      // 订阅最后一个消息
      one(key, fn, last) {
        _self._remove(key, cache)
        this.listen(key, fn, last)
      },
      // 移除消息订阅
      remove(key, fn) {
        _self.remove(key, cache, fn)
      },
      // 消息发布
      trigger() {
        let fn, args = arguments
        _self._unshift.call(arguments, cache)
        fn = () =>  _self._trigger.apply(_self, args)
        if(offlineStack) {
          return offlineStack.push(fn)
        }
        return fn()
      }
    }
    return namespace ?
      (this.namespaceCache[namespace] ?
        this.namespaceCache[namespace] : this.namespaceCache[namespace] = ret)
          : ret
}

9.2 实现 Event 类

class Event extends EventBase {
  constructor() {
    super()
    this.create = this._create
  }
  one(key, fn, last) {
    let event = this.create()
    event.one(key, fn, last)
  }
  remove(key, fn) {
    let event = this.create()
    event.remove(key, fn)
  }
  listen(key, fn, last) {
    let event = this.create()
    event.listen(key, fn, last)
  }
  trigger() {
    let event = this.create()
    event.trigger.apply(this, arguments)
  }
}

9.3 测试

let e = new Event()
e.trigger('click', 1)
e.trigger('click', 2)
e.listen('click', a => {
  console.log(a)
})
e.create('namespace1').listen('click', a => {
  console.log('namespace1', a)
})
e.create('namespace1').trigger('click', 2)
e.create('namespace2').listen('click', a => {
  console.log('namespace2', a)
})
e.create('namespace2').trigger('click', 3)

0801.png

posted on 2023-05-09 10:49  pleaseAnswer  阅读(12)  评论(0编辑  收藏  举报