JavaScript常用设计模式:发布订阅模式
前端页面开发中经常会用到设计模式,其中最常见的是单例模式、工厂模式和发布订阅模式。如大名鼎鼎的 JQuery 就是单例模式,整个页面中只有 $ 一个全局变量作为 JQuery 的引用。本文暂不讨论单例模式,而是介绍发布订阅模式,这个模式在 Vue 的中也有使用,例如在 Vue 中初始化的全局总线就是一个发布订阅模式的应用。
本篇将主要整理和实现一个发布订阅模型单例。
发布订阅模式
发布订阅模式是一种在开发中经常会使用到的设计模式,一般由订阅者订阅话题,然后由发布者发布话题,以便实现一种异步的事件回调。
后端中大名鼎鼎的 Redis 数据库也参考了该模式,另外,在物联网开发中注明的MQTT协议,则完全实现了该模式。
微信公众号也基于这种模式,比如:
- 只有公众号关注者才能收到公众号的推送,关注者即订阅者;
- 公众号值负责推送,不关心订阅者是谁,只要订阅者存在,就发送推文;
- 订阅者不需要轮询,也不用查阅是否有数据更新,当公众号需要推送消息时,订阅者就会收到消息;
- 关注了公众号的人也可以取消关注,即订阅者可以取消订阅;
用公众号这个模型来看,发布者与订阅者直接其实是不需要互相关心的,订阅者只负责订阅,发布者只负责发布,当发布者发布消息时,订阅者就会收到消息。
我们把发布者抽象为总线,熟悉硬件开发的朋友应该比较熟悉这个名词比如 I2C 总线,设备与主机之间也是通过地址来今天通讯。
总线中的发布者即总线本身,只不过这个是可以编程的。总线需要实现至少三个方法:
- on,订阅事件,关联事件回调;
- emit,触发事件,相当于发布消息,
- off,取消订阅,实际是取消事件关联函数。
我们来实现它:
function Bus () {
let topicHandles = {};
// 添加监听
function addListener(topic, func) {
if (!topicHandles[topic]) {
topicHandles[topic] = [];
}
topicHandles[topic].push(func);
}
// 移除监听
function removeListener(topic, func) {
if (!topicHandles[topic]) return;
let index = topicHandles[topic].indexOf(func);
if (index > -1) {
topicHandles[topic].splice(index, 1);
}
}
// 触发监听回调
function trigTopicHandle(topic, ...params) {
if (!topicHandles[topic]) return;
for (let func in topicHandles[topic]) {
topicHandles[topic][func](...params);
}
}
return {
on: function (topic, func) {
if (typeof func !== "function") {
throw "func must be a function";
}
addListener(topic, func);
},
emit: function (topic, ...parmas) {
trigTopicHandle(topic, ...parmas);
},
off: function (topic, func) {
if (typeof func !== "function") {
throw "func must be a function";
}
removeListener(topic, func);
}
}
}
上面代码实现的 Bus 既是发布者,也是订阅者。
代码中 topicHandles 是一个对象,存放的是 topic 与回调函数的映射,在代码中定义了一些变量和函数,全部使用的是局部定义,没有在 this 上绑定,因为我们不希望开发者访问其中的数据,topicHandles 应该只能由 Bus 对象自己来维护。
实例化一个总线对象:
var bus = Bus();
订阅
当我们需要订阅一个 topic 时,可以这样实现:
bus.on("deviceOnline", function () {
console.log("device online");
})
上面的回调函数是一个匿名函数,有时候,我们需要可以取消某个函数的订阅关系,这么时候需要使用一个具名函数,所以也可以这样写:
function onDeviceOnline () {
console.log("device online");
}
如果需要撤掉某个函数与话题的回调关系,可以使用 off 方法来解除:
bus.off("deviceOnline", onDeviceOnline);
发布
发布也很简单:
bus.emit("deviceOnline");
// 控制台输出:
// device online
功能扩展
很多时候,我们希望只订阅一次话题,所以增加一个 once 方法;同时,我们也希望能清楚某个话题的所有回调,于是再增加 clear 方法。
function Bus () {
let topicHandles = {};
// 添加监听
function addListener(topic, func, once = false) {
if (!topicHandles[topic]) {
topicHandles[topic] = [];
}
topicHandles[topic].push({
handle: func,
once: once
});
}
// 移除监听
function removeListener(topic, func = null) {
if (!topicHandles[topic]) return;
if (func !== null) {
for (let i in topicHandles[topic]) {
if (topicHandles[topic][i].handle === func) {
topicHandles[topic].splice(i, 1);
return;
}
}
}
else {
delete topicHandles[topic];
}
}
// 触发监听回调
function trigTopicHandle(topic, ...params) {
if (!topicHandles[topic]) return;
for (let i in topicHandles[topic]) {
topicHandles[topic][i].handle(...params);
if (topicHandles[topic][i].once) {
topicHandles[topic].splice(i, 1);
i--;
}
}
}
return {
on: function (topic, func, once = false) {
if (typeof func !== "function") {
throw "func must be a function";
}
addListener(topic, func, once);
},
once: function (topic, func) {
this.on(topic, func, true);
},
emit: function (topic, ...parmas) {
trigTopicHandle(topic, ...parmas);
},
off: function (topic, func) {
if (typeof func !== "function") {
throw "func must be a function";
}
removeListener(topic, func);
},
clear: function (topic) {
removeListener(topic);
}
}
}
测试:
var bus = Bus();
bus.once("hello", function () {
console.log("hello");
})
bus.emit("hello");
bus.emit("hello");
// 输出
// hello
可以看到,只有一个 hello 输出,这是符合预期的。到目前为止,这是一个有效的发布订阅管理器,基于这个函数我们可以在任何地方来异步的推送消息。