发布订阅模式又称为观察者模式,它用来定义一对多的依赖关系。当对象的状态改变时,所有依赖它的对象都会得到通知。在JavaScript的实现中,最常见的订阅发布模式应用就是事件模型。
我们常说nodeJs是事件驱动,如何理解?以去麦当劳点餐为例:
在基于线程的工作方式中(thread-based way)你到了柜台前,把你的点餐单给收银员或者给收银员直接点餐,然后等在那直到你要的食物准备好给你。收银员不能接待下一个人,除非你拿到食物离开。想接待更多的客户,那就加更多的收银员! 当然,我们知道快餐店其实不是这样工作的。他们其实就是基于事件驱动方式,这样收银员更高效。只要你把点餐单给收银员,某个人已经开始准备你的食物,而同时收银员在进行收款,当你付完钱,你就站在一边而收银员已经开始接待下一个客户。在一些餐馆,甚至会给你一个号码,如果你的食物准备好了,就呼叫你的号码让你去柜台取。关键的一点是,你没有阻塞下一个客户的订餐请求。你订餐的食物做好的事件会导致某个人做某个动作(某个服务员喊你的订单号码,你听到你的号码被喊到去取食物),在编程领域,我们称这个为回调(callback function)。
传统的web server多为基于线程模型。你启动Apache或者什么server,它开始等待接受连接。当收到一个连接,server保持连接连通直到页面或者什么事务请求完成。如果他需要花几微妙时间去读取磁盘或者访问数据库,web server就阻塞了IO操作(这也被称之为阻塞式IO).想提高这样的web server的性能就只有启动更多的server实例。
相反的,Node.Js使用事件驱动模型,当web server接收到请求,就把它关闭然后进行处理,然后去服务下一个web请求。当这个请求完成,它被放回处理队列,当到达队列开头,这个结果被返回给用户。这个模型非常高效可扩展性非常强,因为webserver一直接受请求而不等待任何读写操作。(这也被称之为非阻塞式IO或者事件驱动IO)。
var mcD = require('express')();
mcD.get('/chiken',(req,res)=>{ // ...})
可以发现,在这个例子中使用发布—订阅模式有着显而易见的优点。
(1) 用餐者不必排队守着前台一个个等待上一个服务结束,在合适的时间点,麦当劳作为发布者会通知这些消息订阅者取餐。
(2) 订阅者和麦当劳前台之间不再强耦合在一起,当有新的客人出现时,他只需下单取号,找个座位等待,麦当劳前台不关心客人的任何情况,不管ta是男是女还是一只猴子。而麦当劳的任何变动也不会影响购买者,比如某个服务员离职,售楼处从一楼搬到二楼,这些改变都跟客人无关。
第一点说明发布订阅模式可以广泛用于异步编程。以ajax请求为例,你可以订阅成功和失败的返回,前端就不必关心后端运行过程,报了什么错误。我就可以把自己的关注点放在错误怎么展示,成功怎么展示。
第二点说明,对象之间硬编码的通知机制是可以优化的。
document.body.addEventListener('click',()=>{ alert(1)})
document.body.addEventListener('click',()=>{ alert(2)})
document.body.addEventListener('click',()=>{ alert(3)})
顾名思义,body标签监听(订阅)了点击事件。无论你添加几个订阅着,彼此都没有影响。可见发布订阅模式也是老生常谈。
那么问题来了,我如何给标签自定义一个事件,能够用类似addEventListener的形式写出来呢?
发布订阅模式实现有以下要点:
通常,还会给回调函数输入一些参数。
let announce={};// 定义订阅者
announce.listeners=[];// 增加订阅者
announce.addListener=function(fn){ this.listeners.push(fn);}// 发布触发
announce.trigger=function(...args){
let fn=null;
this.listeners.forEach((item,index)=>{
let fn= item;
fn.apply(this,args);
})
}
接下来测试一下:
const lilei=({sex})=>{
console.log('lilei likes',sex)
}
const hanmeimei=({sex})=>{
console.log('hanmeimei likes',sex)
}
announce.addListener(lilei);
announce.addListener(hanmeimei);
announce.trigger({sex:'male'});
/*
* lilei likes male
* hanmeimei likes male
*/
当发布者发布事件后,无论是李雷还是韩梅梅都接收到了统一的推送。然而这个匹配显示是不对的。李雷应该只接受male,韩梅梅应该是female,问题是他们两个都接收了推送,缺乏过滤机制。
解决方案之一是增加一个唯一的标示key。让订阅者只接受自己关心的信息。
let announce={};
// 定义订阅者
announce.listeners={};
// 增加订阅者
announce.addListener=function(key,fn){
this.listeners[key]=this.listeners[key]?this.listeners[key]:[];
this.listeners[key].push(fn);
}
// 发布触发
announce.trigger=function(...args){
//取出消息类型
var key=Array.prototype.shift.call(args);
if(!this.listeners[key]){
return false;
}
this.listeners[key].forEach((item,index)=>{
let fn= item;
fn.apply(this,args);
})
}
const lilei=(sex)=>{
console.log('lilei likes',sex)
}
const hanmeimei=(sex)=>{
console.log('hanmeimei likes',sex)
}
announce.addListener('lilei',lilei);
announce.addListener('hanmeimei',hanmeimei);
announce.trigger('lilei','female');
announce.trigger('hanmeimei','male');
// lilei like female
// hanmeimei like male
这样,李雷有了自己的专属事件"lilei",韩梅梅亦是如此,就可以让每个人订阅自己感兴趣的事件了。
然而代码还是不可用, addListener
已经很像传统的addEventListener,但是必须在announce对象上进行。通用的实现是希望一类对象都拥有属于自己的发布订阅功能。那可以给你需要的对象"安装"上这个功能。实际上就是一个mixin(混入)实现。
// annouce不用改
const installAnnounce=(obj)=>{
Object.keys(announce).forEach((key,i)=>{
obj[key]=announce[key];
});
return obj;
}
let _aaa={}let aaa=installAnnounce(_aaa);
aaa.addListener('event1',function(...args){
console.log('event1 '+args)
});
aaa.trigger('event1','occured');
事件有绑定就有解绑,增加一个removeListener方法,
announce.removeListener=function(key,fn){
let fns=this.listeners[key];
if(!fns||fns.length==0){
return false;
}
fns.forEach((_fn,i)=>{
if(_fn==fn){
fns.splice(i,1);
}
})
}
测试用例:
const e1=(...args)=>{
console.log('e1 '+args)
}
const e2=(...args)=>{
console.log('e2 '+args)
}
aaa.addListener('event',e1);
aaa.removeListener('event',e1);
aaa.addListener('event',e2);
aaa.trigger('event','occured');
// e2 occured
同理还能实现一个unbind方法,直接delete掉就行了:
announce.unbind=function(key){
delete this.listeners[key];
}
假设我们在开发一个商城,需要根据登录后做以下刷新以下组件:
….
这是我们最为熟悉的操作方式。通常也觉得挺合理。无可诟病。
用程序的表达就是:
const userInfo=await Login();
if(userInfo.success){
header.setInfo(userInfo);
nav.setAvatar(useInfo);
msgList.refresh(userInfo);
cart.refresh(userInfo);
}
可以看到几个组件对userInfo产生了强耦合。而面向实现的编程常常是为人所诟病的。
小剧场:烂尾项目是怎样炼成的 项目做完了。客户要求增加一个切换账号的功能:你把这段代码几乎原封不动地copy到新的地方。 完事后需求又说,我们再加一个收货地址列表吧。你又不得不在切换账号和登录两个地方加上你的登录逻辑。 等不及搞完这个功能,测试说,每个组件用的方法名不符合规范(Ps:这种所谓规范不排除是当初信息沟通不畅或是无中生有的),统一改成refresh吧,于是你在项目不同文件中反复的查找替换检查。 随着时间流逝,你越来越疲于应付这种突入其来的业务需求,项目愈发难以维护。人也离跑路不远了。
但如果用发布订阅模式来重构这段代码,结局就不同了。
const userInfo=await Login();
if(userInfo.success){
login.trigger('loginSucc',userInfo);
}
重构增加了一个login对象作为发布者,代码已经清爽了很多。接下来再到各个组件中订阅login:
const header = (function () {
//header模块
login.listen('loginSucc', function (userInfo) {
header.refresh(userInfo);
});
return {
refresh: function (data) {
console.log('刷新用户信息');
}
}
})();
// ...
同理,如果你的产品让让你根据登录做多一个刷新收货地址的功能,只要在对应的组件订阅login对象即可:
const address = (function () {
//header模块
login.listen('loginSucc', function (userInfo) {
header.refresh(userInfo);
});
return {
refresh: function (data) {
console.log('刷新地址');
}
}
})();
这样组件方法叫什么名字,在哪个业务场景使用完全不关登录的事。在多人协作中,你可以直接把功能扔给开发改功能的同事。到点就可以下班走人了。
让我们用洁癖者的眼光来看之前的代码,登录订阅实现还有一些问题:
为了节省资源,考虑用一个全局的对象来实现它。让订阅者和订阅发布对象接耦。
那就借鉴jq的写法:
const announce=(function(){
const listenrs={};
const addListener=...
const removeListener=...
const trigger=...
return {
listeners,addListener,removeListener,trigger
}
})();
当然,笔者觉得还是用Es6语法更加舒适:
// Announce.js
let listeners={};
class announce {
static addListener(key, fn) {
listeners[key]= listeners[key] ? listeners[key] : [];
listeners[key].push(fn);
}
static removeListener(key, fn){
let fns = listeners[key];
if (!fns || fns.length == 0) {
console.warn('删除的事件未找到');
return false;
}
fns.forEach((_fn, i) => {
if (_fn == fn) {
fns.splice(i, 1);
}
});
}
static trigger(...args){
//取出消息类型
const key = Array.prototype.shift.call(args);
let fns = listeners[key];
if (!fns) {
console.warn('未配置的事件!')
return false;
}
fns.forEach((fn, index) => {
fn.apply(this, args);
})
}
};
module.exports=announce;
自此,我们已经完成了一个伟大的工作了。
上一节的代码中,不同的模块可以借助announce对象进行通信了。在流行的mvvm框架中都会使用这个模式。
在这里,我们借助node来跑这段程序。一共分为4个模块:index为全局入口,通用类Announce.js,负责派派发和展示的业务模块Show.js,执行事件的Add.js。
// Show.js
const announce = require('./Announce');
const show = () => {
let count = 0;
const _show = (args) => {
count += args
console.log(count)
}
announce.addListener('show', _show);
}
// 订阅
module.exports = show();
// Add.js
const announce=require('./Announce');
module.exports=()=>{
announce.trigger('show',1)
}
在index.js中,只要add,就自动输出show。
// index.js
const show=require('./Show');
const add=require('./Add');
add();
add();
add();
// 1 2 3
自此就用es6完成了模块间的全局通信功能。
当然你可以对show方法解耦合:
// dispatchShow.js
const announce = require('./Announce');
const show=require('./methods/show')
const dispatchShow = () => {
announce.addListener('show',show);
}
module.exports = dispatchShow();
// show.js
let count=0;
module.expors=show(args) {
count += args
console.log(count)
}
发布—订阅模式的优点非常明显,一为时间上的解耦,二为对象之间的解耦。它的应用非常广泛,既可以用在异步编程中,也可以帮助我们完成更松耦合的代码编写。发布—订阅模式还可以用来帮助实现一些别的设计模式,比如中介者模式。从架构上来看,无论是MVC还是MVVM,都少不了发布—订阅模式的参与,而且JavaScript本身也是一门基于事件驱动的语言。但在这里要留意另一个问题,模块之间如果用了太多的全局发布—订阅模式来通信,那么模块与模块之间的联系就被隐藏到了背后。我们最终会搞不清楚消息来自哪个模块,或者消息会流向哪些模块,这又会给我们的维护带来一些麻烦,也许某个模块的作用就是暴露一些接口给其他模块调用。