发布-订阅模式 可以说是学前端第一个接触的设计模式了,因为只要学到 DOM ,就一定会用到事件监听,事件监听就是一种 发布-订阅模式的应用。
一个简单的发布-订阅例子:
var salesOffices = {}; // 发布者
salesOffices.clientList = []; // 订阅的用户
salesOffices.listen = function(fn) {
this.clientList.push(fn);
}
salesOffices.trigger = function() { // 触发订阅者们收到订阅后要触发的事件
for (var i = 0, fn; fn = this.clientList[i++];) {
fn.apply(this, arguments);
}
}
salesOffices.listen(function(price, squareMeter) {
console.log(`价格${price}`);
})
salesOffices.listen(function(price, squareMeter) {
console.log(`价格${price}`)
console.log(`squareMeter = ${squareMeter}`)
})
salesOffices.trigger(20000, 99);
salesOffices.trigger(23333, 110);
这是一个及其简单的发布-订阅模式,存在的问题是:发布者只要触发事件,订阅者的事件一定会触发,无论订阅者是不是想要监听这个事件。
所以我们可以升级一下,给事件命个名,只有订阅者指定的事件发生了才通知订阅者:
var salesOffices = {};
salesOffices.clientList = [];
salesOffices.listen = function(key, fn) {
if (!this.clientList[key]) {
this.clientList[key] = [];
}
this.clientList[key].push(fn);
}
salesOffices.trigger = function() {
var key = Array.prototype.shift.call(arguments),
fns = this.clientList[key];
if(!fns || fns.length === 0) {
return false;
}
for (var i = 0, fn; fn = fns[i++];) {
fn.apply(this, arguments);
}
}
salesOffices.listen('squareMeter88', function(price, squareMeter) {
console.log(`价格${price}`);
})
salesOffices.listen('squareMeter110', function(price, squareMeter) {
console.log(`价格${price}`)
console.log(`squareMeter = ${squareMeter}`)
})
salesOffices.trigger('squareMeter88', 2000000);
salesOffices.trigger('squareMeter110', 3000000);
就像 DOM 事件的 click
事件,mousemove
事件等,给事件命名,就能触发特定类型的事件。
发布订阅的通用实现
一个通用的 发布-订阅 对象:
var event = {
clientList: {},
listen: function(key, fn) {
if(!this.clientList[key]) {
this.clientList[key] = [];
}
this.clientList[key].push(fn); // 订阅的消息添加进缓存列表
},
trigger: function() {
var key = Array.prototype.shift.call(arguments),
fns = this.clientList[key];
if (!fns || fns.length === 0) {
return false;
}
for (var i = 0, fn; fn = fns[i++];) {
fn.apply(this, arguments); // arguments 是 trigger 时带上的参数
}
}
}
再定义一个 ``installEvent``` 函数,这个函数可以给所有的对象都动态安装发布-订阅功能:
var installEvent = function(obj) {
for (var i in event) {
obj[i] = event[i];
}
}
现在再测试一番,给salesOffices
动态增加订阅-发布功能:
var salesOffices = {};
installEvent(salesOffices);
salesOffices.listen('squareMeter88', function(price, squareMeter) {
console.log(`价格${price}`);
})
salesOffices.listen('squareMeter110', function(price, squareMeter) {
console.log(`价格${price}`)
console.log(`squareMeter = ${squareMeter}`)
})
salesOffices.trigger('squareMeter88', 2000000);
salesOffices.trigger('squareMeter110', 3000000);
取消订阅的事件
订阅的事件应该是允许取消的,因为订阅者有可能不需要接收订阅事件了。
我们给 event 对象增加 remove
方法。
event.remove = function(key, fn) {
var fns = this.clientList[key];
if (!fns) { // 如果key对应的消息没有被人订阅,则直接返回
return false;
}
if (!fn) { // 如果没有传入具体的回调函数,表示需要取消 key 对应消息的所有订阅
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); // 删除订阅者的回调函数
}
}
}
}
var salesOffices = {};
installEvent(salesOffices);
salesOffices.listen('squareMeter88', fn1 = function(price) { // 函数需要有函数名
console.log(``价格${price});
})
salesOffices.remove('squareMeter88', fn1);
真实的例子——网站登录
考虑以下场景:假如我们正在开发一个商城的网站,网站里的 header头部,nav导航,消息列表,购物车渲染,都有一个共同的前提条件,就是必须先用 ajax 异步请求获取用户的登录信息。
至于 ajax 什么时候可以请求成功返回用户信息,是没法确定的,虽然发布-订阅模式之外,我们可以在 ajax 的回调里添加处理,但是有一点很重要:我们不知道除了header头部,nav导航,消息列表,购物车之外,会不会以后还有其他模块需要用到用户的信息。这样就会出现 ajax 回调和用户信息的强耦合。比如下面这样:
login.succ(function(data) {
header.setAvatar(data.avatar); // header模块的头像设置
nav.setAvatar(data.avatar); // 导航模块的头像设置
message.refresh(); // 刷新消息列表
cart.refresh(); // 购物车列表
})
这样会出现一种情况:有新的模块要接收用户信息,又要翻出三个月前写的登录 ajax 函数,往里加一个函数 如果这个函数不是你维护的,而是其他同事写的,你还要联系他让他添加上,这同事还不一定有空。
这就违反了 封闭-开放原则:动到了已经写好的函数。
用发布-订阅模式的话,让用户登录的 ajax 在获取到用户信息后发布登录成功的信息,需要用户信息的模块只要订阅了这个事件,就能接收到通知。登录模块不需要关心业务方究竟要做什么。
改善后的代码:
$.ajax('api.login', function(data) {
login.trigger('loginSucc', data);
})
各模块监听登录成功的信息:
var header = (function() {
login.listen('loginSucc', function(data) {
header.setAvatar(data.avatar);
});
return {
setAvatar: function(data) {
console.log('设置header模块的头像');
}
}
})()
var nav = (function() {
login.listen('loginSucc', function(data) {
nav.setAvatar(data.avatar);
})
return {
setAvatar: function(avatar) {
console.log('设置nav模块的头像');
}
}
})()
这就像 登录的 ajax 暴露出来了的 API,用的人想要就拿来用,ajax 不管你怎么用,业务方也不用关心 ajax 什么时候请求成功。
虽然使用 React、Vue 不需要用这种模式,因为登录成功后,传入组件的 props
会有一个不为空的对象(更新状态),组件拿到对象后只管渲染就行。 jQuery 时期,用命令式编程的时期用这种模式就很好。
全局的发布-订阅对象
在“发布订阅的通用实现”中,我们给一个具体的对象salesOffices
注入了clientList
、listen
、trigger
、remove
,等属性和方法,才让salesOffices
具有了发布和清除订阅事件的能力,而实际上,发布-订阅 对象可以进一步抽象为一个全局的 Event Bus。因为 trigger
也好,订阅也好,都用不到某一个对象的自身属性,所以发布-订阅对象可以是一个公共的对象:
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),
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,
trigger,
remove,
}
})();
Event.listen('squareMeter88', function(price) {
console.log(`价格${price}`);
})
Event.trigger('squareMeter88', 20000);
一个事件订阅模型就是这么简单。一个监听函数,一个触发函数,一个删除函数,一个监听队列。
模块间的通信
考虑以下需求:有一个div里面是数字0,和一个按钮,按钮每点击一次,div里的数值就➕1。
最直接的思路就是,给 btn 一个点击事件,在回调里直接拿到div的引用,直接改写 div 里的值。
这样做一个值得商榷的地方就是,你让两个元素知道了他们彼此的存在,这其实就是种耦合。有没有办法让两个元素之间彼此不知道对方就能实现这种效果?
就是使用 Event 对象。
必须先订阅后发布吗
前面都是先订阅好事件,等事件可以触发后再执行触发。类似预售,先登记要买,等货备好了再卖给你。 能不能反过来?先发布,再订阅?
听起来,如果先发布了却找不到订阅者,就像一个发出的信号,如果没人回应,最终会消失掉。
所以开发的思路里,就要想方法存储这个发出的信号,等有人订阅的时候,那个人就把这个信号拿出来。
全局事件的命名冲突
在全局事件 Event 中,只有一个 clientList
来存放消息和回调函数,如果大家都通过它来订阅和发布各种消息,久而久之就会出现事件名冲突的情况,所以要给 Event 对象提供命名空间的方法。(一开始觉得也可以把 Event 写成构造函数,实例化出来也可以解决,但是显然不行,因为 发布 和 订阅不是在一个作用域里)