深入理解JavaScript设计模式之发布订阅模式
订报案例,先订阅报纸,当报社发布报纸的时候触发订阅者的读报的收藏事件。

登录案例:当前有header模块,nav模块都需要登陆后获取登陆后获取的信息进行渲染,就需要使用订阅登录,等登录发布后进行回调。

文章目录
- 深入理解JavaScript设计模式之发布订阅模式
- 定义
- 现实生活中的发布订阅模式
- 报纸订阅
- 移动应用推送通知:
- 发布-订阅模式的作用
- 解耦组件
- 促进模块化设计
- 提高系统的灵活性和可扩展性
- 支持异步通信
- 实现事件驱动架构
- 简化复杂网络中的通信
- 增强系统的可维护性
- DOM 事件下的发布和订阅模式
- 自定义订阅发布事件(订报纸案例)
- 第一步、实现发布订阅模式思路
- 第二步、订阅发布HTML
- 第三步、定义发布者(报社对象)
- 第四步、定义订阅者(读者行为阅读或收集)
- 第五步、绑定 UI 操作(订阅与发布)
- 第六步、发布成功后打印结果
- 第七步、查看最终效果
- 发布-订阅模式的通用实现
- 取消订阅事件
- 案例
- 登录案例
- 全局的发布-订阅对象
- 全局发布的订阅对象
- 先发布后订阅
- 总结
定义
发布订阅模式又叫做观察模式,就是让一些对象(订阅者)监听另一个对象(发布者)的状态变化,一旦发布者的状态发生了改变,所有依赖于它的订阅者都会自动收到通知,并作出相应的反应。
现实生活中的发布订阅模式
现实生活中有很多发布订阅模式,比如报纸订阅,电视节目和观众,社交平台,移动推送等等。。。
报纸订阅
你一天订阅了一份日报或者月刊杂志,这种情况下报社扮演了"发布者"的角色,而你则是"订阅者"的角色,一旦有了新的期刊出版,发布者就会主动将期刊送到你的手中,无需你每次取询问是否有新的期刊。
移动应用推送通知:
很多应用程序比如百度网盘应用程序允许用户设置偏好或选择接收通知的类型,当应用有更新,促销活动或者其他相关信息时,可以根据用户的订阅偏好向他们发送推送通知。
发布-订阅模式的作用
我有时候压力大的时候就会去打王者,可以用《王者荣耀》游戏中的场景描述发布和订阅模式,假设你需要设计一个系统,用于处理游戏中英雄升级,获得新技能或者装备更新的通知。
- 发布者: 游戏服务器扮演了发布者的角色,扶着检测游戏内的各种事件,比如某个英雄的升级,技能更改,皮肤上新,装备属性修改等等。
- 订阅者:玩家的设备手机平板都是订阅者,玩家对特定的游戏事件或活动感兴趣,例如炸弹猫的技能准备升级成从以前的前突改成了前突过程中碰到了可伤害物体进行一段升高跳跃,当这个英雄升级的时候,想要立即知道并查看解锁的技能,或者当炸弹猫的新赛季皮肤上线的时候,想要及时收到通知。
发布-订阅模式(Publish/Subscribe Pattern),又称Pub/Sub模式,是一种消息传递模式,它通过一个事件通道解耦了消息发送者(发布者)和消息接收者(订阅者)。这种设计模式在许多现代应用程序中扮演着重要角色,特别是在需要处理异步通信或松耦合组件的场景中。以下是发布-订阅模式的主要作用:
解耦组件
发布者和订阅者之间没有直接的依赖关系。发布者不知道也不关心谁会订阅它的消息,而订阅者也不知道也不关心是谁发布的消息。这使得系统中的各个部分可以独立地开发、测试和部署。
促进模块化设计
由于发布者和订阅者之间的解耦,可以更容易地将系统分解为更小的、更易于管理的模块。每个模块都可以专注于自己的功能,并通过消息与其他模块进行交互。
提高系统的灵活性和可扩展性
新的订阅者可以在不修改现有发布者代码的情况下添加到系统中,反之亦然。这意味着系统可以更容易地适应新的需求变化。
支持异步通信
发布-订阅模式非常适合用于异步通信,因为消息的产生和消费可以发生在不同的时间点。发布者发出消息后不需要等待响应即可继续执行其他任务。
实现事件驱动架构
在事件驱动的软件架构中,发布-订阅模式是一个核心概念。它允许系统基于事件来触发行为,而不是通过直接调用函数或方法。
简化复杂网络中的通信
在分布式系统中,发布-订阅模式可以帮助简化跨多个服务或应用的消息传递。它提供了一种机制,使得不同服务可以通过主题或频道交换信息,而无需彼此直接连接。
增强系统的可维护性
由于减少了组件间的直接依赖,系统变得更加容易理解和维护。此外,当某个组件发生故障时,影响范围被限制在其订阅的主题内,不会波及其他部分。
综上所述,发布-订阅模式是构建灵活、可扩展且易于维护的软件系统的重要工具。它特别适用于那些要求高度解耦和异步通信的应用场景。
DOM 事件下的发布和订阅模式
DOM事件本质上就是一个发布订阅模型的实现,按钮输入框等可以发布事件,比如点击和获取焦点事件,而其它部分的代码可以选择性的订阅这些事件来执行特定的操作。
下面通过一个简单的DOM事件驱动的例子展示如何利用DOM事件来模拟发布订阅模式,按钮为发布者和作为订阅者的函数,这些函数会在按钮被点击的时候触发。
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8" /><title>Pub/Sub 登录模块化示例 - 单例事件中心</title></head><body><button id="publishEvent">点击发布事件</button><div id="subscriber1"></div><div id="subscriber2"></div><script>// 定义订阅者1的回调函数function subscriber1() {const subscriberDiv = document.getElementById("subscriber1");subscriberDiv.innerHTML += "<p>订阅者1: 收到事件通知!</p>";}// 定义订阅者2的回调函数function subscriber2() {const subscriberDiv = document.getElementById("subscriber2");subscriberDiv.innerHTML += "<p>订阅者2: 收到事件通知!</p>";}// 发布者逻辑 - 监听按钮点击事件document.getElementById("publishEvent").addEventListener("click", function () {// 模拟发布事件console.log("事件已发布");// 通知所有订阅者subscriber1();subscriber2();});</script></body>
</html>

上面例子中,有两个订阅者[subscriber1,subscriber2] ,两个订阅者都是简单的函数,当被调用时候才会在页面上添加一条消息,发布者是一个按钮,通过监听click事件来发布事件,当点击按钮的时候触发click事件处理程序,该处理程序扮演的是处理事件中心的角色,复制每个订阅者的回调函数,以此来模拟事件的通知过程。发布者不知道也不关心谁订阅了它的事件,同样,订阅者也不知道也不关心是谁发布了事件。两者之间的连接由事件系统(在这里是DOM事件机制)来维护。这种模式非常适合用于需要解耦组件的场景,例如在一个大型应用中,不同模块之间需要通信但又不想直接依赖对方的时候。通过事件驱动的方式,可以轻松实现松耦合的设计。
自定义订阅发布事件(订报纸案例)
第一步、实现发布订阅模式思路
- 指定发布者(报社)
NewspaperPublisher报社对象【财经日报,体育快报,科技前沿】等类型。- 添加订阅者缓存列表
subscribers[type] = [](订报用户),每个用户订阅不同类型的报纸。- 用户订阅事件
subscribe(type, fn)。- 报社发布事件
publish(type, newspaperData)。- 用户订阅,报社发布后,每个用户执行相应的动作
callback[data]【收藏,阅读】。
第二步、订阅发布HTML
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8" /><title>发布-订阅模式 - 订报纸示例</title></head><body><h2>发布-订阅模式 - 订报纸</h2><!-- 选择订阅的报纸类型 --><label for="subscriberType">用户一订阅的报纸类型:</label><select id="subscriberType"><option value="财经日报">财经日报</option><option value="体育快报">体育快报</option><option value="科技前沿">科技前沿</option></select><br /><label for="subscriberType">用户二订阅的报纸类型:</label><select id="subscriberType2"><option value="财经日报">财经日报</option><option value="体育快报">体育快报</option><option value="科技前沿">科技前沿</option></select><br /><button onclick="subscribe()">报纸订阅</button><br /><br /><!-- 输入今日报纸信息 --><label for="newspaperType">选择发布的报纸类型:</label><select id="newspaperType"><option value="财经日报">财经日报</option><option value="体育快报">体育快报</option><option value="科技前沿">科技前沿</option></select><input type="text" id="title" placeholder="输入今日标题" /><input type="number" id="pages" placeholder="页数" /><button onclick="publish()">发布报纸</button><hr /><!-- 显示输出结果 --><div id="output"></div></body>
</html>
两个用户同时订阅报纸,可订阅不同类型的报纸,也可以订阅相同类型的报纸,点击报纸订阅按钮,即可调用subscribe()方法分别完成用户一用户二的订阅。

第三步、定义发布者(报社对象)
// 报社对象(发布者)
const NewspaperPublisher = {subscribers: {}, // 缓存列表,按报纸类型分类存储订阅者回调函数// 订阅方法subscribe(type, callback) {if (!this.subscribers[type]) {this.subscribers[type] = [];}this.subscribers[type].push(callback);logOutput(`已订阅:${type}`);},// 取消订阅unsubscribe(type, callback) {if (this.subscribers[type]) {this.subscribers[type] = this.subscribers[type].filter((cb) => cb !== callback);logOutput(`取消订阅:${type}`);}},// 发布方法publish(type, data) {logOutput(`📢 正在发布【${type}】`);if (this.subscribers[type]) {this.subscribers[type].forEach((callback) => {callback(data); // 执行回调函数,并传入参数});} else {logOutput(`⚠️ 【${type}】目前无人订阅`);}},
};
第四步、定义订阅者(读者行为阅读或收集)
两个读者可以阅读或者收藏,用户接收到发布后的回调行为。
// 模拟两个读者
function readerA(data) {logOutput(`📰 读者A正在阅读:《${data.title}》,共 ${data.pages} 页`);
}
function readerB(data) {logOutput(`📚 读者B开始收藏:《${data.title}》(${data.pages} 页)`);
}
第五步、绑定 UI 操作(订阅与发布)
在订阅的时候,分别获取用户一与用户二的订阅类型,传递给NewspaperPublisher.subscribe的订阅方法,如果没有就订阅此类型报纸,给该类型报纸创建一个缓存列表,将订阅消息传入到缓存中。
// 用户一点击订阅按钮
function subscribe() {const type = document.getElementById("subscriberType").value;const type2 = document.getElementById("subscriberType2").value;// 这里可以灵活添加更多订阅者NewspaperPublisher.subscribe(type, readerA);NewspaperPublisher.subscribe(type2, readerB);
}
发布消息,报社发布消息的时候,需要告知用户今日报纸的标题,页数等数据,在调用NewspaperPublisher.publish(type, newspaperData);发布方法,传入发布的类型及其报纸信息,如果缓存列表this.subscribers[type]为空,说明当前发布没人订阅,如果缓存列表不为空则遍历订阅者存入缓存中的回调方法进行回调,执行发布后对应订阅者的消息回调['readerA,'readerB']。
// 用户点击发布按钮
function publish() {const type = document.getElementById("newspaperType").value;const title = document.getElementById("title").value;const pages = document.getElementById("pages").value;if (!title || !pages) {alert("请输入标题和页数");return;}const newspaperData = { title, pages };NewspaperPublisher.publish(type, newspaperData);
}
第六步、发布成功后打印结果
function logOutput(message) {const outputDiv = document.getElementById("output");outputDiv.innerHTML += `<div>${message}</div>`;
}
第七步、查看最终效果



发布-订阅模式的通用实现
上面看到了如何让报社拥有接受订阅和发布事件的功能,如果现在你觉得光明日报满足不了你的需求了,你又去另一个凌晨报社订报,那么这个订阅代码必须要在凌晨报社重写一次,你不想那么麻烦,可以把订阅功能提取出来放在单独的对象内。
var installEvent = function (obj) {for (var i in NewspaperPublisher) {obj[i] = NewspaperPublisher[i];}
};
const gmrb = {};
const lcrb = {};installEvent(gmrb);
installEvent(lcrb);
console.log("光明日报", gmrb);
console.log("凌晨日报", lcrb);
你会发现,小明去的光明日报和凌晨日报都有了发布和订阅模式,不需要重新写他们的发布和订阅模式了。

取消订阅事件
用户也许需要取消订阅事件的功能。比如用户突然不想订报了,觉得手机上面阅读比报纸好很多,为了避免继续接收到报社推送过来的报纸,用户需要取消之前订阅的事件。现在我们给 NewspaperPublisher对象增加unsubscribe方法用来取消订阅。
// 取消订阅
unsubscribe(type, callback) {const fns = this.subscribers[type];if (!fns) return false;if (!callback) {this.subscribers[type] = [];} else {for (let i = fns.length - 1; i >= 0; i--) {if (fns[i] === callback) {fns.splice(i, 1);logOutput(`✅ 成功取消订阅:${type}`);}}}
},
效果:

案例
登录案例
在前端开发中经常碰到开发一个网站,网站里面有header头部,nav导航,消息列表,购物车等模块,这几个模块渲染需要调用接口获取用户的登录信息,比如用户的名字,头像,账号都要先是在header模块里面,这几个字段都是来自用户登陆后的返回信息渲染,你不知道除了header,nav,消息列表,购物车之外还有那些模块需要使用登陆后获取的用户信息,在编码中这些模块与用户信息产生了强耦合比如:
login.succ(function (data) {header.setAvatar(data.avatar); // 设置 header 模块的头像nav.setAvatar(data.avatar); // 设置导航模块的头像message.refresh(); // 刷新消息列表cart.refresh(); // 刷新购物车列表});
现在这个模块你来负责编写,你必须了解header模块里设置投降的方法叫做setAvatar,购物车模块刷新方法叫做refresh,这种耦合性会使程序变得很僵硬,header模块不能随意改变setAvater的方法名,自身的名字也不能被改为header1,header2所以这种实现不赞同,如果按照上面的进行程序编写,有一天你过年回家正在休假,突然公司来电话加一个根据登录刷新收获地址列表,于是你又会在最后一行加上:
login.succ(function (data) {header.setAvatar(data.avatar); // 设置 header 模块的头像nav.setAvatar(data.avatar); // 设置导航模块的头像message.refresh(); // 刷新消息列表cart.refresh(); // 刷新购物车列表address.refresh(); // 新增刷新收货地址列表
});
如果后面又有很多这种根据登录的用户信息进行【地址刷新,图片刷新,列表刷新】等需求,越来越疲惫应付这种业务要求,接下来重构一下这段代码,使用发布订阅模式重写之后,对用户感兴趣的模块将自行订阅登陆成功的消息时间,当登录成功的时候,登录模块只要发布登陆成功的消息,业务方接收到消息之后就会进行各自的业务处理,不用关心业务方做什么,也不用去想他的内部细节,经过模块化 + 发布订阅(IIFE 封装模块)进行改造后:
var header = (function () {// header 模块login.listen("loginSucc", function (data) {header.setAvatar(data.avatar);});return {setAvatar: function (data) {console.log("设置 header 模块的头像");},};
})();
var nav = (function () {// nav 模块login.listen("loginSucc", function (data) {nav.setAvatar(data.avatar);});return {setAvatar: function (avatar) {console.log("设置 nav 模块的头像");},};
})();
上面改造后,随时可以把 setAvatar 的方法名改成 setTouxiang。如果有一天在登录完成之后,又增加一个刷新收货地址列表的行为,那么只要在收货地址模块里加上监听消息的方法即可,而这可以让开发该模块的同事自己完成,你作为登录模块的开发者,永远不用再关心这些行为了。代码如下:
var address = (function () {
// nav 模块login.listen("loginSucc", function (obj) {address.refresh(obj);});return {refresh: function (avatar) {console.log("刷新收货地址列表");},};
})();
总体代码:
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8" /><title>Pub/Sub 登录模块化示例</title></head><body><h1>模拟登录后更新 header 和 nav 头像</h1><button onclick="simulateLogin()">点击登录</button><script>// ============ 1. 定义发布/订阅系统 ============var Event = {clientList: {},// 订阅方法,也就是将对应方法推入对应key的类型数组中,用于后期登录成功后的trigger方法循环调用。listen: function (key, fn) {if (!this.clientList[key]) {this.clientList[key] = [];}this.clientList[key].push(fn);},trigger: function () {// 获取第一个参数作为 key,也就是触发事件loginSuccvar key = Array.prototype.shift.call(arguments);// 触发loginSucc列表中经过IIFE之后函数的函数列表,并执行var fns = this.clientList[key];// 如果没有绑定任何事件,则返回 falseif (!fns || fns.length === 0) return false;// 如果有绑定事件,遍历列表并执行for (var i = 0, fn; (fn = fns[i++]); ) {fn.apply(this, arguments); // arguments 是 trigger 时传入的参数}},remove: function (key, fn) {var fns = this.clientList[key];if (!fns) return false;if (!fn) {this.clientList[key] = []; // 移除所有该事件的回调} else {for (var l = fns.length - 1; l >= 0; l--) {if (fns[l] === fn) {fns.splice(l, 1); // 删除指定回调}}}},};// ============ 2. 模拟登录对象 ============var login = {// 模拟异步登录doLogin: function () {setTimeout(() => {console.log("用户登录成功");// 假设从服务器返回的数据var userData = {username: "Tom",avatar: "tom_avatar.jpg",};// 触发登录成功的事件Event.trigger("loginSucc", userData);}, 500);},};// ============ 3. header 模块 ============var header = (function () {Event.listen("loginSucc", function (data) {header.setAvatar(data.avatar);});return {setAvatar: function (avatar) {console.log("设置 header 模块的头像:", avatar);// 实际项目中可能是 DOM 操作...},};})();// ============ 4. nav 模块 ============var nav = (function () {Event.listen("loginSucc", function (data) {nav.setAvatar(data.avatar);});return {setAvatar: function (avatar) {console.log("设置 nav 模块:", avatar);},};})();// ============ 5. 收货地址模块 ============var address = (function () {// nav 模块Event.listen("loginSucc", function (obj) {address.refresh(obj);});return {refresh: function (avatar) {console.log("刷新收货地址列表");},};})();console.log("clientList", Event.clientList);// ============ 5. 模拟点击登录 ============function simulateLogin() {console.log("正在登录...");login.doLogin();}</script></body>
</html>
效果:

全局的发布-订阅对象
上面真实案例使用发布订阅模式还存在两个问题,
- 每个发布者都添加了
listen,trigger和clientList方法,造成了资源浪费,因为目前event是一个全局对象,但是它的结构固定写死了,不能灵活的为不同发布者创建独立的事件系统,如果希望多个发布者,比如不同的模块有自己的独立消息通道,那么每个发布者都有自己的clientList和方法,会照冗余。- 监听者和
Event耦合性强,必须知道Event对象名称才能订阅事件:监听者如header,nav等模块,需要硬编码调用Event.listen(),这样意味着如果将来需要换一个实践中心,或支持多个事件中心,代码会改动很大,不利于维护与扩展。
发布—订阅模式可以用一个全局的 Event 对象来实现,订阅者不需要了解消息来自哪个发布者,发布者也不知道消息会推送给哪些订阅者,Event 作为一个类似“中介者”的角色,把订阅者和发布者联系起来,修改如下:
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8" /><title>Pub/Sub 登录模块化示例 - 单例事件中心</title></head><body><h1>模拟登录后更新 header 和 nav 头像</h1><button onclick="simulateLogin()">点击登录</button><script>// ============ 1. 单例事件中心(使用闭包)============var Event = (function () {var clientList = {};function listen(key, fn) {if (!clientList[key]) {clientList[key] = [];}clientList[key].push(fn);}function trigger() {var key = Array.prototype.shift.call(arguments);var fns = clientList[key];if (!fns || fns.length === 0) return false;for (var i = 0, fn; (fn = fns[i++]); ) {fn.apply(this, arguments); // 注意:这里的 this 在浏览器中是 window}}function remove(key, fn) {var fns = clientList[key];if (!fns) return false;if (!fn) {clientList[key] = []; // 移除所有该事件回调} else {for (var l = fns.length - 1; l >= 0; l--) {if (fns[l] === fn) {fns.splice(l, 1);}}}}return {listen: listen,trigger: trigger,remove: remove,};})();// ============ 2. 模拟登录对象 ============var login = {doLogin: function () {setTimeout(() => {console.log("用户登录成功");var userData = { username: "Tom", avatar: "tom_avatar.jpg" };Event.trigger("loginSucc", userData);}, 500);},};// ============ 3. header 模块 ============var header = (function () {Event.listen("loginSucc", function (data) {header.setAvatar(data.avatar);});return {setAvatar: function (avatar) {console.log("设置 header 模块的头像:", avatar);},};})();// ============ 4. nav 模块 ============var nav = (function () {Event.listen("loginSucc", function (data) {nav.setAvatar(data.avatar);});return {setAvatar: function (avatar) {console.log("设置 nav 模块:", avatar);},};})();// ============ 5. 地址模块 ============var address = (function () {Event.listen("loginSucc", function (obj) {address.refresh(obj);});return {refresh: function (avatar) {console.log("刷新收货地址列表");},};})();// ============ 6. 模拟点击登录 ============function simulateLogin() {console.log("正在登录...");login.doLogin();}</script></body>
</html>
又或者使用全局类的方式实现:
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8" /><title>解耦版 Pub/Sub 登录模块化示例</title></head><body><h1>模拟登录后更新 header 和 nav 头像</h1><button onclick="simulateLogin()">点击登录</button><script>// ============ 1. 定义可复用的事件中心类 ============class EventEmitter {constructor() {this.clientList = {};}listen(key, fn) {if (!this.clientList[key]) {this.clientList[key] = [];}this.clientList[key].push(fn);}trigger(key, ...args) {const fns = this.clientList[key];if (!fns || fns.length === 0) return;fns.forEach((fn) => fn.apply(this, args));}remove(key, fn) {const fns = this.clientList[key];if (!fns) return;if (!fn) {this.clientList[key] = [];} else {this.clientList[key] = fns.filter((_fn) => _fn !== fn);}}}// ============ 2. 创建一个事件中心实例 ============const eventCenter = new EventEmitter();// ============ 3. 模拟登录对象 ============var login = {doLogin: function () {setTimeout(() => {console.log("用户登录成功");var userData = { username: "Tom", avatar: "tom_avatar.jpg" };eventCenter.trigger("loginSucc", userData);}, 500);},};// ============ 4. header 模块 ============function createHeaderModule(eventEmitter) {return (function () {eventEmitter.listen("loginSucc", function (data) {header.setAvatar(data.avatar);});return {setAvatar: function (avatar) {console.log("设置 header 模块的头像:", avatar);},};})();}const header = createHeaderModule(eventCenter);// ============ 5. nav 模块 ============function createNavModule(eventEmitter) {return (function () {eventEmitter.listen("loginSucc", function (data) {nav.setAvatar(data.avatar);});return {setAvatar: function (avatar) {console.log("设置 nav 模块:", avatar);},};})();}const nav = createNavModule(eventCenter);// ============ 6. 地址模块 ============function createAddressModule(eventEmitter) {return (function () {eventEmitter.listen("loginSucc", function (obj) {address.refresh(obj);});return {refresh: function (avatar) {console.log("刷新收货地址列表");},};})();}const address = createAddressModule(eventCenter);// ============ 7. 模拟点击登录 ============function simulateLogin() {console.log("正在登录...");login.doLogin();}</script></body>
</html>
效果:

全局发布的订阅对象
上面订报纸案例,登录案例都是给予一个全局的Evnet对象,可以利用Event在两个封装良好的模块中进行通信,这两个模块可以完全不知道对方的存在,就如同有了送报员不再需要知道报纸来自哪个报社,下面有两个模块,A模块里面有一个按钮,每次点击按钮后,B模块里的div会显示按钮的总店技术,使用全局发布实现使得A和B模块可以保持封装的前提下进行通讯,如下效果。

<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8" /><title>发布-订阅模式 - 订报纸示例</title></head><body><button id="count">点我</button><div id="show"></div><script>var Event = (function () {var clientList = {},listen,trigger,remove;listen = function (key, fn) {if (!clientList[key]) {clientList[key] = [];}clientList[key].push(fn);};trigger = function () {var key = Array.prototype.shift.call(arguments);var fns = clientList[key];if (!fns || fns.length === 0) {return false;}for (var i = 0, fn; (fn = fns[i++]); ) {fn.apply(this, arguments);}};remove = function (key, fn) {var fns = clientList[key];if (!fns) {return false;}if (!fn) {fns && (fns.length = 0);} else {for (var l = fns.length - 1; l >= 0; l--) {var _fn = fns[l];if (_fn === fn) {fns.splice(l, 1);}}}};return {listen: listen,trigger: trigger,remove: remove,};})();var a = (function () {var count = 0;var button = document.getElementById("count");button.onclick = function () {count++;Event.trigger("add", count++);};})();var b = (function () {Event.listen("add", function (count) {var span = document.getElementById("show");span.innerHTML = count;});})();</script></body>
</html>
先发布后订阅
上面的登录或者订报业务实现的发布订阅模式,都是先订阅后才能接收到发布者发布的消息,如果顺序反过来,先发布,后订阅,发布者先发布一条消息,在此之前没有对象来订阅它,这条消息无疑是消失的效果没有任何承接的行为。
如何解决先发布后订阅呢,那就需要建立一个存放离线事件的堆栈,当时间发布的时候,如果此时还没有订阅者来订阅这个时间,那我就暂时把发布时间的动作包裹在一个函数offlineEventStack中,这些包装函数将被存入堆栈中,等到终于有对象来订阅此事件的时候,我们将遍历堆栈且依次执行包装的函数,也就是重新发布里面的事件。当然离线事件的生命周期只有一次,上面的操作也就只能进行一次。
修改Evnet全局订阅发布代码如下:
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8" /><title>发布-订阅模式 - 订报纸示例</title></head><body><button id="count">点我</button><div id="show"></div><script>var Event = (function () {var clientList = {},listen,offlineEventStack = {}, // 用于存放离线事件的堆栈trigger,remove;listen = function (key, fn) {if (!clientList[key]) {clientList[key] = [];}clientList[key].push(fn);// 如果有离线事件,则重新触发if (offlineEventStack[key]) {for (var i = 0; i < offlineEventStack[key].length; i++) {tirgger.apply(this, [key].concat(offlineEventStack[key][i]));}// 删除触发过的离线,确保离线事件只触发一次delete offlineEventStack[key];}};trigger = function () {var key = Array.prototype.shift.call(arguments);var fns = clientList[key];// 如果没有订阅者if (!fns || fns.length === 0) {if (!offlineEventStack[key]) {offlineEventStack[key] = [];}// 将当前事件保存到离线事件堆栈offlineEventStack[key].push(Array.prototype.slice.call(arguments));return false;}for (var i = 0, fn; (fn = fns[i++]); ) {fn.apply(this, arguments);}};remove = function (key, fn) {var fns = clientList[key];if (!fns) {return false;}if (!fn) {fns && (fns.length = 0);} else {for (var l = fns.length - 1; l >= 0; l--) {var _fn = fns[l];if (_fn === fn) {fns.splice(l, 1);}}}};return {listen: listen,trigger: trigger,remove: remove,};})();var a = (function () {var count = 0;var button = document.getElementById("count");button.onclick = function () {count++;Event.trigger("add", count++);};})();var b = (function () {Event.listen("add", function (count) {var span = document.getElementById("show");span.innerHTML = count;});})();</script></body>
</html>
主要修改的地方:
offlineEventStack:新增加一个对象,用于存储还没有订阅者的事件,即离线事件。
listen方法:添加新的订阅者的时候检查是否有对应的离线事件存在,如果有重新触发这些事件,并确保他们只被触发一次。
trigger方法:当尝试触发一个没有订阅者的事件的时候,将时间参数保存到offlineEventStack中,以便将来有订阅者的时候可以重新触发。
上面的代码为了实现发布订阅模式中的先发布后订阅的功能,需要增加一个离线事件存储机制,当有事件被处罚但是还灭有订阅者的时候,将这些时间存储起来,一旦有了订阅者订阅了相应的事件,就将之前的离线事件重新触发给新的订阅者。
总结
设计模式不是“炫技”,而是"沉淀",希望通过阅读和学习《JavaScript设计模式》和实践中,在显示业务需求开发中写出更具有可维护性,可扩展性的代码。
致敬—— 《JavaScript设计模式》· 曾探
