8发布-订阅模式

来源:JavaScript设计模式与开发实践

发布-订阅模式:又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象发生改变时,所有依赖于它的对象都将得到通知。在JS中,一般用事件模型来代替传统的发布-订阅模式。

2. 发布-订阅模式的作用

  • 发布订阅模式可以广泛应用于异步编程中,比如ajax中的succ,error等。
  • 发布订阅模式可以取代对象之间硬编码的通知机制。一个对象不用再显式的调用另外一个对象的接口,让两个对象松耦合的联系在一起。

3. DOM事件即发布-订阅模式

document.body.addEventListener( 'click', function(){ 
    alert(2);
 }, false );
document.body.click();

4. 自定义事件

//简单的发布-订阅模式
var subscribe = {}; //定义发布者
subscribe.clientList = []; //缓存列表,存放订阅者的回调函数
subscribe.listen = function(fn) { //增加订阅者
    subscribe.clientList.push(fn); //订阅的消息添加进缓存列表
};
subscribe.trigger = function() {
    for (var i = 0, fn; fn = this.clientList[i++];) {
        fn.apply(this, arguments);
    }
};
subscribe.listen(function(price, squareMeter) {
    console.log(squareMeter + ':' + price);
});
subscribe.trigger(200, 1);
subscribe.trigger(300, 2);
//缺点:订阅者接收到了发布者发布的每一个消息

增加标识Key,使订阅者只获取自己想要的消息

var subscribe = {}; //定义发布者
subscribe.clientList = []; //缓存列表,存放订阅者的回调函数
subscribe.listen = function(key,fn) { //增加订阅者
    if (!this.clientList[key]) {
        this.clientList[key] = [];
    } 
    this.clientList[key].push(fn); //订阅的消息添加进缓存列表
};
subscribe.trigger = function() {
    var key = [].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); //发送消息时附送的参数,第一个参数消息类型已通过shift方法去除
    }
};
subscribe.listen('click', function(status) {
    console.log('click ' + status);
});
subscribe.listen('move', function(status) {
    console.log('move ' + status);
});
subscribe.trigger('click', 'success');
subscribe.trigger('move', 'success too');

5. 发布-订阅模式的通用实现

//动态让对象都拥有发布-订阅模式
var event = {
    clientList: [],
    listen: function(key, fn) {
        if (!this.clientList[key]) {
            this.clientList[key] = [];
        } 
        this.clientList[key].push(fn);
    },
    trigger: function() {
        var key = [].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 installEvent = function(obj) {
    for (const key in event) {
       obj[key] = event[key];
    }
} //installEvent 函数可以为所有对象动态安装发布-订阅模式
var subscribe = {};
installEvent(subscribe);
subscribe.listen('eventClick', function(res) {
    console.log('eventClick ' + res);
});
subscribe.trigger('eventClick','success!');

6. 取消订阅

subscribe.remove = function(key, fn) {
    var fns = this.clientList[key];
    if (!fns) { //对应的消息没有订阅,则直接返回
        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);
            }
        }
    }
}

subscribe.listen('example', fn1 = function(res) {
    console.log(1, res);
});
subscribe.listen('example', fn2 = function(res) {
    console.log(2, res);
});
subscribe.remove('example',fn1);
subscribe.trigger('example', 'success');

7. example —— 网站登录

  login.success(function(data) {
        header.setAvatar(); // 设置header模块头像
        nav.setAvatar(); //设置nav模块头像
        message.refresh(); //刷新消息列表
        cart.refresh(); //刷新购物车列表
    });

上面这种情况,通过回调函数解决异步时,不同模块与登录模块产生了强耦合,这种耦合会使程序变得僵硬,这是针对具体实现编程的典型例子。

 $.ajax('//xxx.login.com',function(data){
        login.trigger('loginsucc',data);
    });

    var header = (function() {
        login.listen('loginsucc', function(data) {
            header.setAvatar(data.avatar);
        });
        return {
            loginsucc: function(avatar) {
                console.log('set header avatar success!');
            }
        }
    })();
    var nav = (function() {
        login.listen('loginsucc', function(data) {
            nav.setAvatar(data.avatar);
        });
        return {
            nav: function(avatar) {
                console.log('set nav avatar success!');
            }
        }
    })();

用发布-订阅模式重写之后,对用户登录感兴趣的业务将自行订阅登录成功的事件,登录成功时,登录模块只需要发布登录成功的消息,业务方接收到消息之后开始进行各自的业务处理,登录模块不必再处理业务方的业务细节。

8.全局的 发布-订阅对象

var Event = (function() {
    var clientList = [], listen, remove, trigger;
    listen = function (key, fn) {
        if (!clientList[key]) {
            clientList[key] = [];
        }
        clientList[key].push(fn);
    };
    remove = function (key, fn) {
        var fns = clientList[key];
        if (!fns) return false;
        if (!fn) {
            fns.length = 0;
        };
        for (var i = fns.length; i >= 0; i--) {
            var _fn = fns[i];
            if (fn === _fn) {
                fns.splice(i, 1);
            }
        }
    };
    trigger = function() {
        var key = Array.prototype.shift.apply(arguments);
        var fns = clientList[key];
        if (!fns) return;
        for( var i = 0; i < fns.length; i++) {
            fn = fns[i];
            fn.apply(this, arguments); //改变this指向,使listen(key,fn)中fn的this指向Event
        }
    }
    return {
        listen: listen,
        remove: remove,
        trigger: trigger
    };
    /*研究后认为,作者之所以不写成 var Event = {clientList:[],listen:fn,remove:fn,trigger:fn},
     * 而写成闭包方式,可能是不想暴露clientList,使Event只暴露出listen,remove,trigger三个方法
    */
})();
Event.listen('globalPublish', function(success) { //first subscribe
    console.log(this);
    console.log('first subscribe' + success);
});
Event.listen('globalPublish', function(success) { //second subscribe
    console.log('second subscribe' + success);
});
Event.trigger('globalPublish', '1'); //the second publish
setTimeout(function() {
    Event.trigger('globalPublish', '2s'); //the second publish
},2000);
/* 全局发布使得发布与订阅直接相互脱离。
 * 从5的例子中可以看出具有以下两个问题:
 * 每个发布者对象都需要listen,remove,trigger方法和一个缓存队列,十分浪费资源
 * 发布者与订阅者还是具有一定的耦合性,订阅者必须知道发布者的名称,才可以进行订阅
*/

9.模块间通信


var a = (function() {
    var count = 0;
    botton = document.getElementById('a');
    botton.onclick = function() {
        Event.trigger('getCount',count++);
    }
})();
var b = (function() {
    var text = document.getElementById('b');
    Event.listen('getCount', function(data) {
        text.innerHTML = data;
    });
})();

10. 先订阅后发布,与先发布与后订阅

正常情况:订阅者先订阅一个消息,发布者再发布消息,否则没有订阅,发布者发布的消息就没有对象来接收。

例外:某些情况,需要先将发布者发布的消息保存下来,等到有对象订阅时,再重新推给订阅者。
这种需求在实际项目中是存在的,比如在之前的商城网站中,获取到用户信息之后才能渲染用户导航模块,而获取用户信息的操作是一个 ajax 异步请求。当 ajax 请求成功返回之后会发布 一个事件,在此之前订阅了此事件的用户导航模块可以接收到这些用户信息。
而ajax请求时异步的,我们不能确定异步的请求时间,有可能在ajax请求成功时,nav模块还没有加载完成,此时还没有订阅事件,特别是在运用了一些模块化惰性加载的技术后,这种情况更加可能发生。
此时,我们需要建立一个存放离线事件的堆栈,如果此时还没有订阅事件,我们把发布事件的动作包裹在一个函数里,这些包装函数将被存放进堆栈中,等有订阅对象来订阅的时候,遍历堆栈并依次执行这些包装函数,即重新发布事件。并且注意离线事件的生命周期只有一次。

11. 全局事件的命名冲突

为Event对象提供创建命名空间的功能,防止事件命名冲突

var Event = (function() {
    var glabal = this,
    Event,
    _default = 'default';
    Event = (function() {
        var _shift = Array.prototype.shift,//移除数组头部第一个元素并返回该元素
        _unshift = Array.prototype.unshift,//在头部插入元素生成新的数组
        nameSpaceCache = {}, //命名空间缓存存储
        find;
        var each = function(arys, fn) { //arys:一组函数
            var ret;
            for (var i = 0; i < arys.length; i++) {
                var  ary = arys[i];
                ret = fn.call(ary, i, ary); //this指向ary所代表的函数本身 
            }
            return ret;
        };
        var _listen = function(key, fn, cache) {
            if (!cache[key]) { //是否存在该类消息订阅,若无,创建缓存列表
                cache[key] = [];
            }
            cache[key].push(fn);
        };
        var _trigger = function() {
            var cache = _shift.call(arguments), //订阅者的回调函数缓存队列
                key = _shift.call(arguments), //订阅的事件类型
                args = arguments, // 订阅事件发布的结果
                _self = this, 
                ret,
                stack = cache[key]; // 缓存的关于key的订阅者的回调函数

                if (!stack || !stack.length) { //若没有相关订阅
                    return;
                } 
                return each(stack, function() {
                    // _self 此时指的是 _create函数所返回的对象,因为 _trigger被调用时,通过_trigger.apply(_self, args);改变了this的指针
                    return this.apply(_self, args);
                });
        };
        var _remove = function(key, cache, fn) {
            if (cache[key]) {
                if (fn) {
                    for (var i = cache[key].length; i >=0; i--) {
                        if (cache[key][i] === fn) {
                            cache[key].splice(i, 1);
                        } 
                    }
                } else {
                cache[key] = [];
            }
            }
        };
        var _create = function(namespace) {
            var namespace = namespace || _default, //命名空间
                cache = {}, //缓存列表,存放订阅者的回调事件
                offlineStack = []; //离线发布者缓存
            var ret = { 
                listen: function(key, fn, last) {
                    /*_create函数返回一个可以调动函数的对象,生成一个闭包,
                     *每一个命名空间内的订阅相同内容即(key)的订阅事件都被存储近cache,
                     *cache 在闭包中被保存起来,每生成不同的
                    */
                    _listen(key, fn, cache); // 向缓存列表插入订阅者的回调事件
                    if (offlineStack === null) { // 判断是否有还存在离线事件,如果没有,则不再执行离线事件
                        return;
                    }
                    if (last === 'last') { //执行离线事件队列中的最后一个
                        offlineStack.length && offlineStack.pop()(); 
                        //pop()方法 remove the last element from the array and return the element
                        //offlineStack.pop()是函数,offlineStack.pop()()代表执行该函数
                    } else { //依次执行离线事件
                        each(offlineStack, function() {
                            this(); // this是包裹发布事件的函数
                        });
                    }
                    offlineStack = null; //离线事件的生命周期只有一次
                },
                one: function(key, fn, last) {
                    _remove(key, cache);
                    this.listen(key, fn, last);
                },
                remove: function(key, fn) {
                    _remove(key, cache, fn);
                },
                trigger: function() {
                    var fn,
                        args,
                        _self = this; //this指_create函数返回的对象;作为对象属性调用,指向当前对象
                    _unshift.call(arguments, cache); // 往arguments头部加入cache,生成一个新数组
                    
                    args = arguments;
                    fn = function() { // 把发布事件的动作包裹在函数里,在有事件订阅后再调用
                        return _trigger.apply(_self, args);
                    } 
                    if (offlineStack) { // 如果listen方法还未被调用,即离线事件还未被执行,将离线事件存储中插入包装好的函数
                        return offlineStack.push(fn); 
                    }
                    return fn(); // 若已有订阅,则调用fn函数,处理订阅事件发布的函数
                }
            };
            return namespace ? (nameSpaceCache[namespace] ? nameSpaceCache[namespace] : nameSpaceCache[namespace] = ret) : ret;
        };
        return {
            create: _create,
            one: function(key, fn, last) { // one函数指订阅key的事件保证只有一个订阅者
               var event = this.create();
               event.one(key, fn, last);
            },
            listen: function(key, fn, last) {
               var event = this.create();
               event.listen(key, fn, last);
            },
            remove: function(key, fn) {
                var event = this.create();
                event.remove(key, fn);
            },
            trigger: function() {
                var event = this.create();
                event.trigger.apply(this.arguments);
            }
        }
    })();
    return Event;
})();
Event.create('namespace1').trigger('click','namespace1 success');
Event.create('namespace1').listen('click', function(res) {
    console.log(res);
});
Event.create('namespace2').listen('click', function(res) {
    console.log(res,222);
});
Event.create('namespace2').one('click', function(res) {
    console.log(res,111);
});

Event.create('namespace2').one('click', function(res) {
    console.log(res,123);
});
Event.create('namespace2').trigger('click', 'success2');
Event.create('namespace2').trigger('click', 'success2');

12 JS实现发布-订阅模式的便利性

js中实现发布-订阅模式与别的语言实现方式不同。在Java中,实现发布订阅模式,通常会把订阅者对象自身当成引用传入发布者对象中,同时订阅者对象还需要提供一个名为例如update的方法,供发布者对象在合适的时候调用。在js中,用注册回调函数的形式来替代传统的发布-订阅模式,显得更加简洁和优雅。

另外,在JS中无需选择使用推模式还是拉模式。推模式是指事件发生时,发布者一次性把所有更改状态和数据都退送给订阅者;而拉模式是发布者仅仅通知订阅者事件发生了,此外发布者要提供一些公开的接口供订阅者主动拉取数据,拉模型好处是可用让订阅者按需获取,但同时有可能让发布者变成一个门户大开的对象,同时增加了代码量和复杂度。

刚好在js中,arguments可用很方便的表示参数列表,因为我们一般会选择推模型,使用function.prototype.apply来把所有参数推给订阅者。

13小结

优点:一是时间上的解耦,二是对象上的解耦。
缺点:创建订阅要消耗一定时间和内存,而当订阅一件时间后,也可能此消息最后都没有发生,但这个订阅会始终存在内存中。并且发布-订阅模式虽然可以弱化对象之间的联系,但过度使用的话,对象与对象间的必要联系也将深埋在背后,会导致程序难以跟踪维护和理解。

你可能感兴趣的:(8发布-订阅模式)