JavaScript 设计模式:发布订阅
发布-订阅模式 又叫 观察者模式。 它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。
在JavaScript开发中,我们一般用事件模型来替代传统的发布订阅模式。
①发布—订阅模式可以广泛应用于异步编程中,这是一种替代传递回调函数的方案。比如,我们可以订阅ajax请求的error、succ等事件。
②可以取代对象之间硬编码的通知机制,一个对象不用再显式地调用另外一个对象的某个接口。
买房者收到售楼处发布的房源售卖通知。售楼处就是发布者,买房者就是订阅者,订阅的是售卖房源的信息。买房者有很多人,他们会订阅不同的房源(不同的地理位置,价格、周边等),发布者可以根据不同的订阅者发布不同的信息。
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 );
var fns = this.clientList[ key ];
if ( ! fns || fns.length === 0 ) {
return false;
}
for ( var i=0, fn; fn = fns[i++]; ) {
fn.apply( this, arguments );
}
};
***********************************************************************
var clientA = { // A订阅的信息是88平米的价格
key: 'squareMeter88',
fn: function( price ){
console.log('The squareMeter of 88 is :' + price +' RMB.')
}
};
var clientB = { // B订阅的信息是110平米的价格
key: 'squareMeter110',
fn: function( price ){
console.log('The squareMeter of 110 is :' + price +' RMB.')
}
};
//收到用户的订阅
salesOffices.listen( clientA.key, clientA.fn );
salesOffices.listen( clientB.key, clientB.fn );
//售楼开始发布房源
salesOffices.trigger('squareMeter88', '1800000' );
salesOffices.trigger('squareMeter110', '2500000' );
我们可以把发布订阅的功能提取为一个对象,再定义一个 安装函数,这样其他售楼处也可以安装这个功能。给 发布订阅对象 PandS 添加一个 remove 方法,取消订阅的事件。
// 发布订阅对象
var PandS = {
clientList: [], // 一个客户列表
listen: function( key, fn ) {……}, // 一个收集用户订阅信息的方法
trigger: function() {……}, // 一个发布订阅消息给用户的方法
remove: function( key, fn ) { // 增加一个删除订阅事件的方法
var fns = this.clientList[ key ];
if ( ! fns ) { return false; } // 如果没有被订阅,则直接返回
if ( ! fn ) { // 如果没有传 fn, 则表示 要取消 key 所有的订阅事件
fns && ( fns.length = 0 );
} else {
for (var len = fns.length -1; len >= 0; len-- ) {
if ( fns[ len ] === fn ) {
fns.splice( len, 1 );
}
}
}
}
};
// 安装函数
var installPandS = function( obj ) {
for( var i in PandS ) {
obj[ i ] = PandS[ i ]; // 复制对象的属性和方法,即安装
}
};
//测试:售楼处REUB 安装 发布订阅功能
var REUB = {};
installPandS( REUB ); // 安装好了
REUB.listen( clientA.key, clientA.fn );
REUB.trigger('squareMeter88', '1800000' );
REUB.remove( clientA.key, clientA.fn ); // 删除A的订阅
网站登录 实例:
var login = { // 一个叫 login 的发布者
clientList: [],
listen: function( client ) {……},
trigger: function() {……},
remove: function( client ) {……}
};
$.ajax('http://xxx.com?login', function(data) {
login.trigger('loginSuccess', data); // 发布登陆成功消息
});
// header模块订阅
var header = (function(){
// 用户订阅登录成功信息
login.listen( {
key: 'loginSuccess',
fn: function( data) {
header.setAvatar( data.avatar );
}
} );
return {
setAvatar: function( data ) {
console.log('设置header 头像: ', data );
}
}
})();
// nav 模块订阅 略
总结:我们随时可以把setAvatar的方法名改成setTouxiang。如果有一天在登录完成之后,又增加一个刷新收货地址列表的行为,那么只要在收货地址模块里加上监听消息的方法即可,而这可以让开发该模块的同事自己完成,你作为登录模块的开发者,永远不用再关心这些行为。
先发布后订阅 与 命名冲突
前面的代码都是只能 先订阅某个事件,发布必须在其后,否则无法获取到真正的订阅。为了解决这个问题,可以使用一个存放离线事件的堆栈,当发布事件的时候,将动作包裹在函数里,将函数存入堆栈中。等到终于有对象来订阅此事件的时候,我们将遍历堆栈并且依次执行这些包装函数,也就是重新发布里面的事件。
将 发布订阅对象独立出来,取名为 Event。里面只有 clientList 来存放消息名和回调函数,为了避免冲突,给 Event对象提供创建命名空间的功能。
// 先发布后订阅 效果
Event.trigger( 'click', '123' );
Event.listen( 'click', function(a) { console.log(a); } ); // 输出 '123'
// 命名空间效果
Event.create('name1').listen( 'click', function(a) { console.log(a); } ); // 输出 '123'
Event.create('name1').trigger( 'click', '123' );
Event.create('name2').listen( 'click', function(a) { console.log(a); } ); // 输出 '456'
Event.create('name2').trigger( 'click', '456' );
实现 略。(详细参见:JavaScript设计模式与开发实践 8.11节)
发布—订阅模式的优点非常明显,一为时间上的解耦,二为对象之间的解耦。它的应用非常广泛,既可以用在异步编程中,也可以帮助我们完成更松耦合的代码编写。发布—订阅模式还可以用来帮助实现一些别的设计模式,比如中介者模式。从架构上来看,无论是MVC还是MVVM,都少不了发布—订阅模式的参与,而且JavaScript本身也是一门基于事件驱动的语言。
创建订阅者本身要消耗一定的时间和内存,而且当你订阅一个消息后,也许此消息最后都未发生,但这个订阅者会始终存在于内存中。过度使用的话,对象和对象之间的必要联系也将被深埋在背后,会导致程序难以跟踪维护和理解。
手写一个发布订阅:
// 发布订阅中心, on-订阅, off取消订阅, emit发布, 内部需要一个单独事件中心caches进行存储;
interface CacheProps {
[key: string]: Array<((data?: unknown) => void)>;
}
class Observer {
private caches: CacheProps = {}; // 事件中心
on (eventName: string, fn: (data?: unknown) => void){ // eventName事件名-独一无二, fn订阅后执行的自定义行为
this.caches[eventName] = this.caches[eventName] || [];
this.caches[eventName].push(fn);
}
emit (eventName: string, data?: unknown) { // 发布 => 将订阅的事件进行统一执行
if (this.caches[eventName]) {
this.caches[eventName].forEach((fn: (data?: unknown) => void) => fn(data));
}
}
off (eventName: string, fn?: (data?: unknown) => void) { // 取消订阅 => 若fn不传, 直接取消该事件所有订阅信息
if (this.caches[eventName]) {
const newCaches = fn ? this.caches[eventName].filter(e => e !== fn) : [];
this.caches[eventName] = newCaches;
}
}
}