JS前端设计模式之发布订阅模式详解
本文深入解析JavaScript中的发布订阅模式(观察者模式),通过包子铺案例逐步演示从基础实现到通用EventBus封装的全过程。详细讲解如何添加、取消订阅及解耦发布与订阅者,并结合Vue、React组件通信及登录鉴权等实际场景,阐述该模式在前端开发中的核心应用与注意事项。
引言
上一篇关于策略模式和代理模式的文章获得了良好的反馈,今天我们将继续深入探讨前端开发中另一种常用且重要的设计模式:发布-订阅模式。
发布-订阅模式在日常生活和工作中极为常见,应用频率极高。它广泛应用于EventBus、框架内的组件间通信以及鉴权业务等场景。接下来,我们将一同进入该模式的学习。
发布-订阅模式,亦称观察者模式,定义了对象之间一对多的依赖关系。当一个对象的状态发生变化时,所有依赖它的订阅者都会收到通知。尽管该模式应用广泛,但在JavaScript中,通常直接使用事件模型(如addEventListener)来替代传统的发布-订阅实现。那么,发布-订阅模式具体有哪些实际用途呢?
示例1:
以小明为例,他酷爱包子,每天去楼下询问是否有货。若无货,他便白跑一趟,且无法预知何时补货,这让他十分困扰。解决此问题的方案是:老板记录小明的联系方式,有货时主动通知,避免小明空跑。这种机制类似于点击事件、Ajax请求中的error或success事件,均采用了发布-订阅的思想。下面我们将通过代码还原这一场景。
版本1:
const baoziShop = {};//定义包子铺
baoziShop.listenList = [];//缓存列表 存放订阅者的回调函数
//添加订阅者
baoziShop.listen = function (fn) {
baoziShop.listenList.push(fn)
}
//发布消息
baoziShop.trigger = function() {
for(let i = 0, fn; fn = baoziShop.listenList[i++]) {
fn.apply(this, arguments);
}
}
//接下来尝试添加监听者
baoziShop.listen( function (price, baoziType) { //小明订阅消息
console.log(`种类:${baoziType}, 价格: ${price}`)
})
baoziShop.listen( function (price, baoziType) { //小王订阅消息
console.log(`种类:${baoziType}, 价格: ${price}`)
})
//接下来我们尝试发布消息
baoziShop.trigger(2, '豆沙包');//输出:种类:豆沙包, 价格 2
baoziShop.trigger(3, '肉包');//输出:种类:肉包,价格 3
上述代码实现了基础功能,但存在缺陷:订阅者会无差别接收发布者发出的所有消息。若小明只喜欢菜包,却收到肉包上架的通知,体验不佳。因此,我们需要引入key机制,使订阅者仅接收感兴趣的消息。代码如下:
版本2:
const baoziShop = {}; //定义包子铺
baoziShop.listenList = {}; //存放订阅者的回调函数 注意 这里从前面的数组改成了对象
//添加订阅者 key用来标识订阅者
baoziShop.listen = function(key, fn) {
if( !this.listenList[key]) {
this.listenList[key] = [];//如果没有订阅过此类消息 就给该消息创建订阅列表
}
this.listenList[key].push(fn);//将回调放入订阅列表
}
//发布消息
baoziShop.trigger = function() {
const key = Array.prototype.shift.call(arguments), //取出消息类型
fns = this.listenList[key];//取出该订阅对应的回调列表
if(!fns || fns.length === 0) return false;//没有订阅则直接返回
for(let i = 0, fn; fn = fns[i]; i++) {
fn.apply(this, arguments) //绑定this
}
}
//接下来我们尝试下订阅不同的消息
baoziShop.listen('菜包子', function(price) { //小明订阅菜包子的消息
console.log('价格:', price)
})
baoziShop.listen('肉包子', function(price) { //小王订阅肉包子
console.log('价格:', price)
})
//接下来我们发布下消息
baoziShop.trigger('菜包子', 2); //只有订阅菜包子的小明能收到消息
baoziShop.trigger('肉包子', 3); //只有订阅肉包子的小王能收到通知
经过优化,我们实现了按类型接收消息的功能。然而,代码仍有改进空间:若小明楼下有两家包子铺,他希望在另一家也购买包子,则需在另一家包子铺的对象上复制粘贴相同代码。若有十家包子铺,难道要写十遍吗?
正确的做法是将发布-订阅功能封装为一个通用的对象,避免重复编码。基于此思路,我们继续重构代码。
const event = {
listenList : [], //订阅列表
listen: function (key, fn) {
if( !this.listenList[key]) {
this.listenList[key] = [];//如果没有订阅过此类消息 就给该消息创建订阅列表
}
this.listenList[key].push(fn);//将回调放入订阅列表
},
trigger: function() {
const key = Array.prototype.shift.call(arguments), //取出消息类型
fns = this.listenList[key];//取出该订阅对应的回调列表
if(!fns || fns.length === 0) return false;//没有订阅则直接返回
for(let i = 0, fn; fn = fns[i]; i++) {
fn.apply(this, arguments) //绑定this
}
}
}
我们将发布-订阅逻辑抽离至event对象中,后续可通过event.trigger()等形式调用。接下来,我们封装一个方法,为任意对象动态安装发布-订阅功能,从而避免重复操作。
const installEvent = function(obj) {
for(let i in event) {
obj[i] = event[i];
}
}
//接下来我们测试下我们的代码
const baoziShop = {};//定义包子铺
installEvent(baoziShop);
//接下来我们就可以订阅和发布消息了
baoziShop.listen('菜包子', function(price) { //小明订阅菜包子的消息
console.log('价格:', price)
})
baoziShop.listen('肉包子', function(price) { //小王订阅肉包子
console.log('价格:', price)
})
baoziShop.trigger('菜包子', 2); //只有订阅菜包子的小明能收到消息
baoziShop.trigger('肉包子', 3); //只有订阅肉包子的小王能收到通知
经过改写,我们可以轻松为任意对象添加订阅和发布消息的能力,无需重复编写代码。进一步思考,若小明不再吃包子但仍收到包子铺消息,该如何处理?这引出了取消订阅的需求:有订阅就应有取消订阅的功能。
接下来,我们继续完善代码。
//我们给我们的event对象增加一个remove的方法用来取消订阅
event.remove = function(key, fn) {
const fns = this.listenList[key];//取出该key对应的列表
if(!fns) { //如果该key没被人订阅,直接返回
return false;
} if(!fn) { //如果传入了key但是没有对应的回调函数,则标识取消该key对应的所有订阅!!
fns && (fns.length == 0)
}else {
for(let len = fns.length - 1; len >= 0; len --) { //反向遍历订阅的回调列表
const _fn = fns[len];
if(_fn === fn) {
fns.splice(len, 1) ;//删除订阅者的回调函数
}
}
}
}
//接下来我们照常给包子铺添加一些订阅
const baoziShop = {};
installEvent(baoziShop);
baoziShop.listen('菜包子', fn1 = function(price) { //小明订阅消息
console.log('价格', price);
})
baoziShop.listen('菜包子', fn2 = function(price) { //小王订阅消息
console.log('价格', price)
})
baoziShop.trigger('菜包子', 2);//小明和小王都收到消息
baoziShop.remove('菜包子', fn1); //删除小明的订阅
baoziShop.trigger('菜包子', 2);//只有小王会收到订阅
至此,系统已支持添加不同订阅、赋予对象发布-订阅功能以及取消订阅等操作。
理论上,代码已实现基本功能,但仍存在以下问题:
- 每个对象都必须添加
listen和trigger功能,并分配一个listenList订阅列表,这造成了资源浪费。 - 代码耦合度过高,如下所示:
//小明必须知道包子铺的名称才能开始订阅
baoziShop.listen('菜包子', function(price) {
//....
})
//如果小明要去另外的包子铺买 就必须订阅另一家包子铺
baoziAnother.listen('菜包子', function(price) {
//....
})
这种直接耦合的方式显得不够优雅。我们可以参考现实生活中的场景:购买包子时,无需逐一与各家店铺沟通,而是通过美团等第三方平台完成交易。在此模型中,美团扮演中介角色,买家只需表达需求,无需知晓具体供应方;卖家也仅需将信息发布至平台,无需关注最终消费者(尽管现实中需参考商家评价,此处仅作简化类比)。基于此逻辑,我们对代码进行重构。
//我们尝试改写event对象 使其充当一个中介的角色 将发布者和订阅者连接起来
const Event = ({
const listenList = {};//订阅列表
//添加订阅者
const listen = function(key, fn) {
if( !this.listenList[key]) {
this.listenList[key] = [];//如果没有订阅过此类消息 就给该消息创建订阅列表
}
this.listenList[key].push(fn);//将回调放入订阅列表
};
//发布消息
const trigger = function() {
const key = Array.prototype.shift.call(arguments), //取出消息类型
fns = this.listenList[key];//取出该订阅对应的回调列表
if(!fns || fns.length === 0) return false;//没有订阅则直接返回
for(let i = 0, fn; fn = fns[i]; i++) {
fn.apply(this, arguments) //绑定this
}
};
//取消订阅
const remove = function(key, fn) {
const fns = this.listenList[key];//取出该key对应的列表
if(!fns) { //如果该key没被人订阅,直接返回
return false;
} if(!fn) { //如果传入了key但是没有对应的回调函数,则标识取消该key对应的所有订阅!!
fns && (fns.length == 0)
}else {
for(let len = fns.length - 1; len >= 0; len --) { //反向遍历订阅的回调列表
const _fn = fns[len];
if(_fn === fn) {
fns.splice(len, 1) ;//删除订阅者的回调函数
}
}
};
return {
listen,
trigger,
remove
}
})();
//接下来我们就能用Event来实现发布-订阅功能而不需要创建那么多的对象了
Event.listen('菜包子', function(price) { //小明订阅消息
console.log('价格:', price)
})
Event.listen('菜包子', 2);//包子铺发布消息
修改后,订阅消息无需知晓包子铺名称,也无需为每家包子铺创建对象,只需统一通过Event对象订阅。发布消息同理,通过Event中介对象巧妙连接发布者和订阅者。
发布-订阅模式不仅适用于上述示例,还常见于模块间通信。熟悉vue或react的开发者对组件间的事件响应应不陌生。下面展示具体用法。
//例如我们在a元素发布一个消息 b元素就可以监听到并实施对应的操作
a.onclick = () => {
Event.listen('onclickEvent', 'this is data')
}
//b元素接收到消息
const b = (function() {
Event.listen('onclikcEvent', function(data) {
console.log('这是接收到的数据', data);//输出这是接收到的数据thisisdata
})
})();
这种用法在日常开发中极为普遍。
此外,该模式也可应用于登录业务。假设用户登录后,需更新首页推荐、个人信息及好友列表,该如何实现?
由于无法预知用户登录时间,我们可在登录成功后发布消息,并在需要权限的模块监听该消息以执行操作,如下所示:
//在登录成功后发布消息
login().then((data:[code]) => {
if(code === 200) {
Event.trigger('success', code);//登录成功后发布消息
}
})
//用户信息模块监听并更新
Event.listen('success', function(code) => {
refleshUserInfo();//更新用户信息
})
这样,即使后续有其他模块需要鉴权,只需添加对应的订阅者,无需修改登录部分的代码和逻辑,从而显著提升代码的健壮性。
总结
关于发布-订阅模式的讲解至此结束。该模式实用性强且实现难度适中,但需注意细节:例如命名冲突问题(每个key需唯一,可使用ES6的Symbol单独封装至专门文件),以及内存和时间消耗(订阅消息后,除非手动取消,否则订阅者将一直占用内存)。总体而言,发布-订阅模式优势显著,希望大家掌握并熟练运用。
前端常见的设计模式和使用场景
一文带你读懂作用域、作用链和this的原理
更多关于JS发布订阅模式的资料请关注本站其它相关文章!
JS大数值转字符串出现科学计数法怎么办
2026-11-05 17:09:21
JS split切割后出现空字符串怎么过滤
2026-11-05 16:00:30
在 React 或 Vue 项目中封装使用 JS 节流防抖
2026-11-05 15:27:29
JavaScript中怎么将带小数的字符串转为整数?
2026-11-05 14:32:38
怎么用JavaScript策略模式重构电商促销计算逻辑?
2026-11-05 14:01:09
