引言
昨天我发布了一篇关于策略模式和代理模式的文章,收到的反响还不错,于是今天我们继续来学习前端中常用的设计模式之一:发布-订阅模式。
说到发布订阅模式大家应该都不陌生,它在我们的日常学习和工作中出现的频率简直不要太高,常见的有EventBus
、框架里的组件间通信、鉴权业务等等......话不多说,让我们一起进入今天的学习把!!!
发布-订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系 当一个对象的状态发生改变时,所有依赖它的订阅者都会接收到通知。发布-订阅模式在日常应用十分广泛(js中一般用事件模型来替代传统的发布订阅模式,如addEventListener
)。那发布-订阅者模式有啥用呢?
例子1:
我们举个例子,小明是一个喜欢吃包子的人,于是他每天都去楼下询问有没有包子,如果运气不好今天没有包子,小明就得白跑一趟,但是啥时候有包子小明又不知道,这让他很是困扰。那如何解决这个问题呢,这个时候发布-订阅模式就派上用场了。假如老板把小明的电话记了下来,有包子就通知小明,这样小明就不会白白跑一趟了。看到这个例子你有没有觉得这种模式很眼熟,像我们的点击事件,ajax请求的error
或者success
事件其实都是用了这种模式,接下来我们就用代码来还原上面小明的场景
version1:
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
来让订阅者只订阅自己感兴趣的东西,接下来我们对代码进行一些改动:
version2:
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); //只有订阅肉包子的小王能收到通知
好了,经过上面的改写,我们已经实现了只收到自己订阅的类型的消息的功能。那我们不妨想一下我们的代码还有啥可以完善的功能,比如如果小明楼下有两个包子铺,如果小明想要在另一个包子铺买v包子,那这段代码就必须在另一个包子铺的对象上复制粘贴一遍,如果只有两个包子铺还好,那万一有十个包子铺呢?是不是得写十遍?
所以我们正确的做法应该是将发布-订阅的功能单独抽离出来封装在一个通用的对象内,这样避免重复写同样的代码,那我们按着这种思路开始改写我们的代码
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发布订阅模式的资料请关注脚本之家其它相关文章!