使用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应用中实现跨组件通信。那就再见了。

posted @ 2020-03-22 19:11  只会一点前端  阅读(1273)  评论(0编辑  收藏  举报