使用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應用中實現跨組件通信。那就再見了。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM