• 使用JavaScript浅谈发布-订阅模式


    发布-订阅模式是什么?

    发布-订阅模式又叫做观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变的时候,所有依赖于它的对象都将得到通知。

    作为一名JavaScript开发者,我100%相信你已经使用过了这个模式,不信你看如下代码:

    document.body.addEventListener('click',function(){
        console.log('执行了点击事件');
    })

    在这里我们为body加上了一个点击事件,相当于我们订阅了点击事件,但是我们不关系,它什么时候触发,但是一旦触发点击事件,那么就会执行我们所写功能函数。

    这个就是一个简单的应用。我们在来看一个例子:

    var obj = {name: 'ydb'};
    Object.defineProperty(obj,'name',{
        set: function(){
            console.log('更新了');
        }
    })
    obj.name = 'ydb11';

    在这里我们订阅了name属性的更新,一旦name发生改变,就会执行set函数,同样我们并不关心name什么时候更新,但是只要更新,就会触发我们定义的set函数,从而执行相关的操作。

    仔细想一下,你在日常开发中除了使用DOM事件外,有没有使用过自定义事件,比如vue中子组件向父组件通信,看代码:

    <div @update="func">父组件</div>
    相信使用过vue开发的你,这段代码对你没有任何问题,在这里div注册了一个update事件,只要别人发送了这个事件,那么func函数就会触发,这个就是简单的一个自定义事件。
     
    好了,通过前面的例子,我们对发布-订阅模式有了一定的了解,接下来我们通过案例,来进一步掌握它。(这里的例子,就拿我在学习这个模式的时候,别人举过的例子,可以很好的阐述发布-订阅模式)。

    假设有那么一个场景:小明要去买房,但是没有他喜欢的房源,所以他就留下了自己的联系方式和要求给售房处,一旦有了符合自己要求的房子,就打电话给他。这个时候小红也来买房子,和小明一样没有喜欢的房子,于是也留下了自己的联系方式和要求。

     
    对场景就是这么一个场景,但是我们能从这个场景中获取很多有用的东西:

    1.有了符合自己要求的,售房处就会主动联系自己,不需要自己每天打电话问有没有符合自己的房子。

    2.售房处只要记得有了房子,通知这些买家就行了,其他的因素影响不了这个操作。比如售房处搬家了,之前的员工辞职了,这些都无关紧要,只要在新的地方或者新的员工记得打电话通知就行了。

     
    从中我们可以看处,售房处就是消息的发布者,买家就是消息的订阅者,只要发布者发布消息,订阅者就能收到消息,来做相关的事情,比如这里的来买房子。
     
    现在看看是怎样一步步的实现发布-订阅模式的。
     
    1.首先要指定谁是发布者(售房处)
     
    2.然后给发布者添加一个缓存列表,用于存放回调函数以便通知订阅者(售房处的花名册)

    3.最后发布消息的时候,遍历缓存列表,依次触发里面的回调函数(遍历花名册,挨个打电话通知)

    看代码:

    // 定义售房处
    var salesOffices = {};
    // 定义花名册
    salesOffices.clientList = [];
    // 留下联系方式 订阅消息
    salesOffices.on = function (callback) {
        this.clientList.push(callback);
    }
    salesOffices.emit = function () {
        for (var i = 0, fn; fn = this.clientList[i++];) {
            fn.apply(this, arguments); // arguments 发布消息时所带的参数
        }
    }
    // 下面进行订阅消息
    // 小明 
    salesOffices.on(function (price, squareMetar) {
        console.log('价格:' + price + '万');
        console.log('面积:' + squareMetar);
    });
    // 小红 价格300万,面积110平方米
    salesOffices.on(function (price, squareMetar) {
        console.log('价格:' + price + '万');
        console.log('面积:' + squareMetar);
    });
    // 发布消息 小明(价格200万,面积88平方米)
    salesOffices.emit(200, 88);
    
    // 发布消息 小红(价格200万,面积88平方米)
    salesOffices.emit(300, 110);

    这里我们基本上实现了这个场景,当有满足要求的房子时候,发布者只要发布消息,订阅者就能做出相关的事情,挺好的,看一下测试结果:

     结果正确,但是注意现在的代码中,不管哪个订阅者被满足的时候,其他订阅者也会收到消息,这也就是为什么会出现四次打印结果的原因。设想一下假如有100个买房子的人,只要其中一个满足条件了,其他的买房子的人也会收到电话。我擦这谁顶的住啊,别人买的房子给我打什么电话,我tm一天都被电话轰炸了,所以必须修改上面的代码。

    且看代码:

    // 定义售房处
    var salesOffices = {};
    // 定义花名册
    salesOffices.clientList = {};
    // 留下联系方式 订阅消息
    salesOffices.on = function (key, callback) {
        if (!this.clientList[key]) { // 如果没有订阅此类消息,就给该类消息创建一个缓存列表
            this.clientList[key] = [];
        }
        this.clientList[key].push(callback); // 消息加入缓存列表
    }
    salesOffices.emit = 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); // arguments 发布消息时所带的参数
        }
    }
    // 下面进行订阅消息
    // 小明 
    salesOffices.on('squareMeter88', function (price) {
        console.log('价格:' + price + '万');
    });
    // 小红 价格300万,面积110平方米
    salesOffices.on('squareMetar110', function (price) {
        console.log('价格:' + price + '万');
    });
    // 发布消息 小明(价格200万,面积88平方米)
    salesOffices.emit('squareMeter88', 88);
    
    // 发布消息 小红(价格200万,面积88平方米)
    salesOffices.emit('squareMetar110', 110);

    现在只有符合自己要求的订阅者,才会收到电话,这样子就合理多了。

    在我们日常开发中,增加需求是很常见的事情,这里也是,小明有点不放心这个售房处,期间他又找了许多售房处,并登记了信息。通过上面测例子我们可以看出,售房处的代码还是有点多的,多个售房处,就有多个相同的操作,那是不是每一个售房处,都要这样子写?可以是可以,但是太麻烦了,我们想着如果把订阅发布那部分统一出来,那岂不是很简单了。

    看代码:

    var event = {
        clientList: {},
        on: function (key, fn) {
            if (!this.clientList[key]) {
                this.clientList[key] = [];
            }
            this.clientList[key].push(fn); // 订阅的消息添加缓存列表
        },
        emit: 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); // arguemnts是emit时候带上的参数
            }
        }
    }

    这里我们封装了一个发布-订阅的对象,里面具备完整的功能,现在只要有新的售房处出现,就可以直接复用里面的代码:

    var event = {
        clientList: {},
        on: function (key, fn) {
            if (!this.clientList[key]) {
                this.clientList[key] = [];
            }
            this.clientList[key].push(fn); // 订阅的消息添加缓存列表
        },
        emit: 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); // arguemnts是emit时候带上的参数
            }
        }
    }
    var initalEvent = function (obj) {
        for (key in event) {
            obj[key] = event[key];
        }
    }
    var salesOffices1 = {};
    // 给售房处添加发布-订阅功能
    initalEvent(salesOffices1);
    
    salesOffices1.on('squareMeter88', function (price) {
        console.log('价格:' + price + '万');
    })
    salesOffices1.emit('squareMeter88', 200)

    就这样子操作,所有售房处都能发布消息了,initalEvent相当于售房处的电话,只要买了电话,那么就可以打电话了。

     现在各个售房处都为能及时通知订阅者买房,而高兴的时候,小明突然说不想买房了。what?你tm逗我玩了,我就是还要给你打电话轰炸你,这个时候小明生气了,说你在打电话我就告你骚扰了,没办法只能妥协了,需要把小明订阅的消息给删除掉。下面我们来看看怎样取消订阅:
    var event = {
        clientList: {},
        on: function (key, fn) {
            if (!this.clientList[key]) {
                this.clientList[key] = [];
            }
            this.clientList[key].push(fn); // 订阅的消息添加缓存列表
        },
        emit: 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); // arguemnts是emit时候带上的参数
            }
        },
        remove: function(key,fn){
            var fns = this.clientList[key];
            if (!fns) { // 如果没有订阅的消息,则返回
                return false;
            }
            if (!fn) { // 没有传入具体的回调函数,标示需要取消key对应的所有订阅
                fns && (fns.length = 0);
            } else {
                for (var i=fns.length-1;i>=0;i--) {
                    if (fn === fns[i]) {
                        fns.splice(i,1) // 删除订阅的回调函数
                    }
                }
            }
        }
    }
    var initalEvent = function (obj) {
        for (key in event) {
            obj[key] = event[key];
        }
    }
    var salesOffices1 = {};
    // 给售房处添加发布-订阅功能
    initalEvent(salesOffices1);
    
    var fn1 = function(price) {
        console.log('价格:' + price + '万');
    }
    salesOffices1.on('squareMeter88', fn1);
    salesOffices1.emit('squareMeter88', 200);
    // 删除小明的订阅
    salesOffices1.remove('squareMeter88',fn1);
    salesOffices1.emit('squareMeter88', 200);

    测试如下:

     嗯,没毛病老铁。

    有一天小明中了五千万,想要出国买房,但是想如果能在国内买一套别墅,放在那儿升值也可以。由于之前的矛盾,他对售房处产生了不好的印象,说只给你们一次机会给我找好房子,一次过后我不满意我就要出国了,你们就联系不到我了。所以现在我们就需要实现一次订阅的事件,看看代码:

    var event = {
        clientList: {},
        on: function (key, fn) {
            if (!this.clientList[key]) {
                this.clientList[key] = [];
            }
            this.clientList[key].push(fn); // 订阅的消息添加缓存列表
        },
        onece: function (key, fn) {
            this.on(key, fn);
            // 标志只订阅一次
            fn.onece = true;
        },
        emit: function () {
            var key = Array.prototype.shift.call(arguments);
            var fns = this.clientList[key];
            if (!fns || fns.length === 0) {
                return false; // 如果没有绑定对应的消息
            }
            for (var i = fns.length - 1; i >= 0; i--) {
                var fn = fns[i];
                fn.apply(this, arguments); // arguemnts是emit时候带上的参数
                if (!!fn.onece) {
                    // 删除订阅的消息所对应的回调函数
                    fns.splice(i, 1);
                }
            }
        },
        remove: function (key, fn) {
            var fns = this.clientList[key];
            if (!fns) { // 如果没有订阅的消息,则返回
                return false;
            }
            if (!fn) { // 没有传入具体的回调函数,标示需要取消key对应的所有订阅
                fns && (fns.length = 0);
            } else {
                for (var i = fns.length - 1; i >= 0; i--) {
                    if (fn === fns[i]) {
                        fns.splice(i, 1) // 删除订阅的回调函数
                    }
                }
            }
        }
    }
    var initalEvent = function (obj) {
        for (key in event) {
            obj[key] = event[key];
        }
    }
    var salesOffices1 = {};
    // 给售房处添加发布-订阅功能
    initalEvent(salesOffices1);
    
    var fn1 = function (price) {
        console.log('价格:' + price + '万');
    }
    // 小明只订阅一次
    salesOffices1.onece('squareMeter88', fn1);
    salesOffices1.emit('squareMeter88', 200);
    salesOffices1.emit('squareMeter88', 200);

    测试如下:

     

    现在一看,我们这个发布-订阅功能还是很完美的,对吧!但是还存在一些问题的:

    1. 我们给米一个发布者都添加了on,emit,clientList,这其实是一种浪费资源的现象

    2.小明跟售房处对象还存在一定的耦合性,小明至少要知道售房处对象名字是salesOffice,才能顺利订阅事件。

    想一想我们平时找房子很少直接跟房东联系的,我们大多数是跟各种各样的中介公司联系的,我们留下联系方式给中介,房东通过中介发布房源信息。

    所以我们需要定制一个中介公司,也就是全局的发布-订阅对象,看代码:

    var event = (function () {
        var clientList = {},
            on,
            emit,
            remove,
            onece;
        on = function (key, fn) {
            if (!clientList[key]) {
                clientList[key] = [];
            }
            clientList[key].push(fn);
        };
        onece = function (key, fn) {
            this.on(key, fn);
            fn.onece = true;
        }
        emit = function () {
            var key = Array.prototype.shift.call(arguments);
            var fns = clientList[key];
            if (!fns || fns.length === 0) {
                return false;
            }
            for (var i = fns.length - 1; i >= 0; i--) {
                var fn = fns[i];
                fn.apply(this, arguments);
                if (!!fn.onece) {
                    fns.splice(i, 1);
                }
            }
        }
        remove = function (key, fn) {
            var fns = clientList[key];
            if (!fns) {
                return false;
            }
            if (!fn) {
                fns && (fns.length === 0);
            }
            for (var i = fns.length - 1; i >= 0; i--) {
                if (fns[i] === fn) {
                    fns.splice(i, 1);
                }
            }
        }
        return {
            on,
            emit,
            onece,
            remove
        }
    })();
    
    var fn1 = function (price) {
        console.log('价格:' + price + '万');
    }
    console.log('一直订阅');
    event.on('squareMeter88', fn1);
    event.emit('squareMeter88', 200);
    event.emit('squareMeter88', 200);
    console.log('订阅一次');
    event.onece('squareMeter120', fn1);
    event.emit('squareMeter120', 300);
    event.emit('squareMeter120', 300);
    console.log('取消订阅');
    event.on('squareMeter160', fn1);
    event.remove('squareMeter160', fn1);
    event.emit('squareMeter160', 500);

    看看测试结果:

    果然如此。

    但是在这里我们又遇到了新的问题,模块之间如果用了太多的全局发布-订阅模式来通信,那么模块与模块之间的联系就被隐藏到了背后。我们最终会搞不清楚消息来自哪个模块,或者消息会流向那些模块,这个又会对我们的维护带来一定的麻烦,也许某个模块的作用就是暴露一些接口给其他模块使用。具体使用还是要根据业务场景来的。

    到这里我们基本实现来发布-订阅功能,但是我们想几个问题:

    我们QQ离线的时候,我们登陆QQ是不是会收到之前的离线消息,而且只能收到一次,所以说不是必须先订阅在发布,也可以先发布,之后在订阅与否是自己的事情。

    我们在全局使用发布-订阅对象很方便,但是随着使用的次数增多,难免会出现事件名冲突的情况,所以我们可以给event对象提供创建命名空间的空能。

    这两个需求只是我们为了更加完善我们全局的发布-订阅对象,对之前的event对象不是去颠覆,而是去升级,使其更健壮。

    再加入这两个需求之后,我们最终的全局的发布-订阅对象如下:

    var event = (function () {
        // 全局的命名空间缓存数据
        var namesapceCaches = {};
        var _default = 'default';
        var shift = Array.prototype.shift;
        var hasNameSpace = function (namespace, key) {
    
            // 不存在命名空间
            if (!namesapceCaches[namespace]) {
                namesapceCaches[namespace] = {}
            }
            // 命名空间下不存在该key的订阅对象
            if (!namesapceCaches[namespace][key]) {
                namesapceCaches[namespace][key] = {
                    // 该key下的订阅的事件缓存列表
                    cache: [],
                    // 该key下的离线事件
                    offlineStack: []
    
                }
            }
        }
        // 使用命名空间
        var _use = function (namespace) {
            var namespace = namespace || _default;
            return {
                // 订阅消息
                on: function (key, fn) {
                    hasNameSpace(namespace, key);
                    namesapceCaches[namespace][key].cache.push(fn);
                    // 没有订阅之前,发布者发布的信息保存在offlineStack中,现在开始显示离线消息(只发送一次)
                    var offlineStack = namesapceCaches[namespace][key].offlineStack;
                    if (offlineStack.length === 0) { return; }
    
                    for (var i = offlineStack.length - 1; i >= 0; i--) {
                        // 一次性发送所有的离线数据
                        fn(offlineStack[i]);
                    }
                    offlineStack.length = 0;
    
    
                },
                // 发布消息
                emit: function () {
                    // 获取key 
                    var key = shift.call(arguments);
                    hasNameSpace(namespace, key);
                    // 获取该key对应缓存的订阅回调函数
                    var fns = namesapceCaches[namespace][key].cache;
                    if (fns.length === 0) {
                        var data = shift.call(arguments);
                        // 还没有订阅,保存发布的信息
                        namesapceCaches[namespace][key].offlineStack.push(data);
                        return;
                    }
                    for (var i = fns.length - 1; i >= 0; i--) {
                        fns[i].apply(this, arguments);
                        if (fns.onece) {
                            fns.splice(i, 1);
                        }
                    }
    
                },
                remove: function (key, fn) {
                    // 获取key 
                    var key = shift.call(arguments);
                    // 不存在命名空间和订阅对象
                    if (!namesapceCaches[namespace] || !namesapceCaches[namespace][key]) {
                        return;
                    }
                    // 获取该key对应缓存的订阅回调函数
                    var fns = namesapceCaches[namespace][key].cache;
                    if (fns.length === 0) {
                        return;
                    }
                    for (var i = fns.length - 1; i >= 0; i--) {
                        if (fn === fns[i]) {
                            fns.splice(i, 1);
                        }
                    }
                },
                onece: function (key, fn) {
                    this.on(key, fn);
                    fn.onece = true;
                }
            }
        }
        return {
            // 用户的命名空间
            use: _use,
            /**
             * 默认的命名空间
             * on,emit,remove,onece都为代理方法。
            */
            on: function (key, fn) {
                var event = this.use();
                event.on(key, fn);
            },
            emit: function () {
                var event = this.use();
                event.emit.apply(this, arguments);
            },
            remove: function (key, fn) {
                var event = this.use();
                event.remove(key, fn);
            },
            onece: function (key, fn) {
                var event = this.use();
                event.onece(key, fn);
            },
            show: function () {
                return namesapceCaches;
            }
        }
    })();

    看就是那么简单,但是这里有一个不好的地方,那就是离线消息,只要有一个对应的订阅者订阅,那么离线消息就会全部发送完毕。聪明的你可以自己再去改造一下。

    下面的是我的测试代码:

    console.log('先发布后订阅测试');
    event.emit('111', '离线数据1');
    event.emit('111', '离线数据2');
    setTimeout(function () {
        event.on('111', function (data) {
            console.log(data);
        })
    }, 2000);
    setTimeout(function () {
        event.emit('111', '在线数据');
    }, 3000);
    console.log('默认命名空间测试----');
    var fn1 = function (data) { console.log(data) }
    event.on('default', fn1);
    event.emit('default', '默认命名空间测试');
    event.remove('default', fn1);
    event.emit('default', '默认命名空间测试');
    console.log('自定义命名空间测试');
    var fn1 = function (data) { console.log(data) }
    event.use('ydb').on('111', fn1);
    event.emit('ydb', '默认命名空间发布消息');
    event.use('ydb').emit('111', 'ydb空间发送数据1');
    event.use('ydb').remove('111', fn1);
    event.use('ydb').emit('111', 'ydb空间发送数据1(现在是离线数据)');
    event.use('ydb').emit('111', '离线数据');
    event.use('ydb').on('111', fn1);
    event.use('ydb').emit('111', '在线数据');

    可以自己下去测试一下,看看结果是怎么样子的。用这个模式我们完全可以在自己的spa应用中实现跨组件通信。那就再见了。

  • 相关阅读:
    尚硅谷Docker笔记(6) DockerFile解析
    注意防踩坑系列
    尚硅谷Docker笔记(1) Docker简介
    尚硅谷Docker笔记(4) Docker 镜像
    尚硅谷Docker笔记(8) 本地镜像发布到阿里云
    尚硅谷Docker笔记(2) Docker安装
    SQL中join连接查询时条件放在on后与where后的区别
    尚硅谷Docker笔记(3) Docker常用命令
    尚硅谷Docker笔记(7) Docker常用软件安装
    关于存储过程的几个sql
  • 原文地址:https://www.cnblogs.com/jsydb/p/12545274.html
Copyright © 2020-2023  润新知