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;
    }
  }
}
有问题反馈加微信:mue233 私聊送一本电子书,绝对受益良多! 微信公众号:慕意,分享创业、使用的软件和教程~
知识星球精选推荐 » JavaScript 设计模式:发布订阅