javascript設計模式 之 5 釋出-訂閱模式

zhaoyezi發表於2018-05-31

1 釋出-訂閱模式的定義

釋出-訂閱模式又稱為觀察者模式。它定義了物件之間的一種一對多的依賴關係。當一個物件發生改變時,所有依賴於它的物件都將得到通知。在javascript開發中,我們一般用事件模型來替代傳統的釋出-訂閱模式。

1.1 觀察者模式與釋出訂閱模式關係

  • 釋出訂閱模式屬於廣義上的觀察者模式。
    釋出訂閱模式是最常用的一種觀察者模式的實現,並且從解耦和重用角度來看,更優於典型的觀察者模式
  • 釋出訂閱模式多了個事件通道
    在觀察者模式中,觀察者需要直接訂閱目標事件;在目標發出內容改變的事件後,直接接收事件並作出響應。
    javascript設計模式 之 5 釋出-訂閱模式

    在釋出訂閱模式中,釋出者和訂閱者之間多了一個釋出通道;一方面從釋出者接收事件,另一方面向訂閱者釋出事件;訂閱者需要從事件通道訂閱事件,以此避免釋出者和訂閱者之間產生依賴關係。
    javascript設計模式 之 5 釋出-訂閱模式

2 現實中的釋出-訂閱模式

現實中的售樓部就是一個釋出-訂閱模式的例子。想要買房子的顧客會去售樓部檢視房源情況,但是不一定都遇到此時有房源,因此留下了自己的電話號碼,向售樓部訂閱了最新房源的訊息。而售樓部就是釋出者,當有房源就會立馬通知所有訂閱了訊息的使用者,房源已經出來了。這樣的好處在於:

  • 顧客不用每天都去售房部看是否有房源,顧客就是訂閱者,售樓部是釋出者。
  • 顧客與售房部不會強耦合在一起,當有新的購房者出現,只需要向售樓部訂閱即可。售樓部不會管使用者是人還是鬼,而顧客也不會管售樓部內部職員變動情況,只要售樓部即使傳送訊息。

第一點在非同步程式設計中有體現,例如我們訂閱了ajax請求的error,succ事件。當ajax請求完畢後,一定會通知error或succ執行。我們無需關心ajax非同步執行期間的內部狀態,只需要訂閱感興趣的事件發生點。
第二點可以取代物件之間的硬編碼通知機制,一個物件不再需要顯示呼叫另一個物件的某個介面。釋出訂閱模式將釋出者和訂閱者鬆耦合地聯絡在一起,不需要知道彼此之間的實現細節,也能夠相互通訊。當新的訂閱者出現,釋出者不需要有任何修改。同樣釋出者有修改時,訂閱者也不會有任何影響。只要之前約定的事件名沒有發生任何變化,就可以自由改變它們。

3 DOM事件

編寫程式碼的過程中,我們隊DOM結構上的div註冊事件也是一個釋出訂閱模式。釋出者DOM的div,向該釋出者訂閱了click事件,當釋出者發生了click事件後,之前訂閱的click函式都會觸發。

var div = document.getElementById('myDiv');
div.addEventListener('click', function() {
    console.log('訊息通知過來了1');
});
div.addEventListener('click', function() {
    console.log('訊息通知過來了2');
});
div.click();
複製程式碼

4 自定義事件

下面我們就以現實中的售樓部與客戶的關係,來實現一個釋出-訂閱模式的例子。

  • 有一個售樓部物件,擁有存放客戶資訊的倉庫,擁有釋出訊息的方法
  • 不同的使用者向售樓部訂閱不同的訊息
  • 售樓部在適當的時候釋出訊息
var saleOffices = {
    // 使用者資料中心
    customerDatas: {}, 
    // type:訂閱型別, messageFunc:使用者感興趣的內容
    subscribe: function(type, messageFunc) {
        // 如果沒有訂閱過,則在資料中心是不存在的,需要為其建立一個存放訂閱內容
        var customerData = this.customerDatas[type];
        if (!customerData) {
            this.customerDatas[type] = [];
        }
        // 訂閱的訊息新增到訊息快取列表
        this.customerDatas[type].push(messageFunc);
    },
    notify: function() {
         // 獲取訊息型別
        var type = [].shift.call(arguments);
        // 取出訊息型別的訊息集合
        var funcs = this.customerDatas[type];
        // 不存在的訊息返回
        if (!funcs || funcs.length === 0) {
            return false;
        }
        // 存在則呼叫
        funcs.map(function(fn) {
            fn.apply(this, arguments);
        })
    }
};

// 使用者1:訂閱
saleOffices.subscribe('squareMeter88', function(price) {
    console.log('88平米的價格:' + price);
});
// 使用者2:訂閱
saleOffices.subscribe('squareMeter120', function(price) {
    console.log('120平米的價格:' + price);
});

// 釋出者釋出
// 使用者1才能收到
saleOffices.notify('squareMeter88', 8000);
// 使用者2才能收到
saleOffices.notify('squareMeter120', 9000);
複製程式碼

5 釋出訂閱的通用實現

上例中,加入客戶1不僅去售樓部A出訂閱了,也去售樓部B處訂閱了訊息。那麼相當於我們需要給售樓部B處也寫一個釋出訂閱功能。那麼如何使售樓部都能夠使用呢?

  • 提取釋出-訂閱方法
  • 擁有售樓部類,建立售樓部A,售樓部B
  • 在售樓部A處訂閱訊息,在售樓部B處訂閱訊息
// 提取觀察訂閱方法
var observer = {
    // 使用者資料中心
    customerDatas: {}, 
    // type:訂閱型別, messageFunc:使用者感興趣的內容
    subscribe: function(type, messageFunc) {
        // 如果沒有訂閱過,則在資料中心是不存在的,需要為其建立一個存放訂閱內容
        var customerData = this.customerDatas[type];
        if (!customerData) {
            this.customerDatas[type] = [];
        }
        // 訂閱的訊息新增到訊息快取列表
        this.customerDatas[type].push(messageFunc);
    },
    notify: function() {
         // 獲取訊息型別
        var type = [].shift.call(arguments);
        var params = arguments;
        // 取出訊息型別的訊息集合
        var funcs = this.customerDatas[type];
        // 不存在的訊息返回
        if (!funcs || funcs.length === 0) {
            return false;
        }
        // 存在則呼叫
        funcs.map(function(fn) {
            fn.apply(this, params);
        })
    },
    // 訂閱了的內容,可以取消
    remove: function(type, fn) {
        var fns = this.customerDatas[type];
        // 如果不存在訂閱內容返回
        if (!fns || fns.length === 0) {
            return false;
        }
        // 如果沒有傳入具體的需要退訂的內容,則取消所有該型別的訂閱
        if (!fn && fns) {
            fns.length = 0;
            return;
        }
        fns.map(function(_fn, index) {
            if (fn === _fn) {
                fns.splice(index, 1)
            }
        });
    }   
};

// 建立售樓部類
var SaleOffice = function() {
    return Object.create(observer);
}

// 售樓部1 訂閱訊息 
var saleOfficeA = SaleOffice();
saleOfficeA.subscribe('squareMeter88', f1 = function(price) {
    console.log('88平米的價格:' + price);
});

// 售樓部2 訂閱訊息 
var saleOfficeB = SaleOffice();
saleOfficeB.subscribe('squareMeter120', f2 = function(price) {
    console.log('120平米的價格:' + price);
});
 
// 售樓部A通知,售樓部B通知 都能夠收到
saleOfficeA.notify('squareMeter88', 8000);
saleOfficeB.notify('squareMeter120', 9000);

// 移除訂閱
saleOfficeA.remove('squareMeter88', f1);

// 售樓部A的通知不能收到
saleOfficeA.notify('squareMeter88', 8000);
saleOfficeB.notify('squareMeter120', 9000);
複製程式碼

6 全域性的釋出-訂閱模式

剛剛的釋出訂閱模式中,我們給售樓處物件和登入物件都新增了訂閱和釋出的功能。但是還有2個小問題:

  • 我們給每一個售樓部都新增了subscrible和notify方法,以及一個快取customerDatas物件。這其實是一種資源浪費
  • 購買者與售樓部有一定的耦合,購買者必須要知道售樓部,售樓部也必須要知道購買者,才能順利訂閱事件
    現實中,我們一般不回去售樓部,我們會把訂閱事件的請求交給中介公司,而不同的售樓部會將資訊釋出在中介公司。這樣就消除了購買者與售樓部的耦合關係。只需要購買者與售樓部知道中介公司就可以了。
    因此釋出-訂閱模式可以使用一個Observer全域性的物件來實現。訂閱者不需要知道是哪個釋出者,釋出者也不知道訊息會推送給哪些訂閱者。Observer物件作為中介的角色,把釋出者和訂閱者關聯起來。
var Observer = (function() {
    // 使用者資料中心
    var customerDatas = {};

    // 訂閱
    function subscribe(type, fn) {
        if (!(type in customerDatas)) {
            customerDatas[type] = [];
        }
        customerDatas[type].push(fn);
    }

    // 釋出
    function notify() {
        var type = [].shift.call(arguments);
        var param = arguments;
        var fns = customerDatas[type];
        if(fns.length === 0) {
            return false;
        }
        fns.map(function(fn) {
            fn.apply(this, param);
        });
    }

    function remove(type, fn) {
        var fns = this.customerDatas[type];
        // 如果不存在訂閱內容返回
        if (!fns || fns.length === 0) {
            return false;
        }
        // 如果沒有傳入具體的需要退訂的內容,則取消所有該型別的訂閱
        if (!fn && fns) {
            fns.length = 0;
            return;
        }
        fns.map(function(_fn, index) {
            if (fn === _fn) {
                fns.splice(index, 1)
            }
        });
    }
    return {
        subscribe: subscribe,
        notify: notify,
        remove: remove
    };
})();

// 使用者1 註冊
Observer.subscribe('squareMeter88', f1 = function(price, tel) {
    console.log(tel + '使用者您好:' + '88平米的價格:' + price);
});
Observer.subscribe('squareMeter120', f2 = function(price, tel) {
    console.log(tel + '使用者您好:' + '120平米的價格:' + price);
});

// 中介通知
Observer.notify('squareMeter88', 8000, 18784578444);
Observer.notify('squareMeter120', 9000, 18784578444);

複製程式碼

7 網站登入-釋出訂閱模式

假如我們正在開發一個商城網站,網站有header頭部,nav導航,訊息列表,購物車等等。而這幾個模組的渲染有一個共同的條件:登入使用者的資訊(使用者名稱,暱稱等)。而使用者資訊需要通過ajax請求獲取結果。然後將資訊填寫到各個模組中。

var getUserInfo = function() {
    $.ajax('url', function(data) {
        header.setAvata(data.avata); // 設定header的頭像
        nav.setAvata(data.avata); // 設定導航模組的頭像
        message.refresh(); // 重新整理訊息
        cart.refresh(); // 重新整理購物車
    });
};
複製程式碼

各個模組的設定都依賴於使用者資訊的獲取,導致模組與使用者資訊產生了強烈的耦合性。
假如專案中還要新增一個收貨地址模組。也需要在使用者資訊獲取後重新整理。僱員A負責獲取使用者資訊,僱員B負責收貨地址模組,此時僱員B就會找僱員A又去修改使用者獲取模組。而不願B也需要新增收貨地址模組程式碼

var getUserInfo = function() {
    $.ajax('url', function(data) {
        header.setAvata(data.avata); 
        nav.setAvata(data.avata); 
        message.refresh();
        cart.refresh();
        // 僱員A:為了收貨地址模組需要修改程式碼
        address.refresh();
    });
};

// 僱員B需要新增收貨地址模組程式碼
var address = (function() {

    function refresh() {
        console.log('地址模組完成');
    }
    return {
        refresh: refresh
    };
})();
複製程式碼

假如後面還需要新增其他模組,也需要依賴於使用者資訊,那麼僱員A就會發脾氣了:“為什麼你們的模組老是讓我來修改,增加我的工作量”。那麼此時僱員A就思考,是不是我的程式碼有問題?因此就想到了重構程式碼。這時他引用了釋出訂閱模式。我只管告知你們,我登入成功了,你們愛做什麼做什麼。我不再管你們的業務了,自己玩去。而其他模組收到訊息後就處理自己的業務。

var Login = (function() {
    // 存放所有的訂閱事件
    var registerFunc = {};
    // 釋出
    function notify(userInfo) {
        // 遍歷存放在Login中的訂閱事件,依次觸發
        for ( var type in registerFunc) {
            var fns = registerFunc[type];
            if(!fns || fns.length === 0) {
                return false;
            }
            fns.map(function(fn) {
                fn.call(this, userInfo);
            });
        }
    }
    // 訂閱
    function subscribe(type, fn) {
        if (!(type in registerFunc)) {
            registerFunc[type] = [];
        }
        registerFunc[type].push(fn);
    }

    // 獲取使用者資訊後釋出訊息
    function getUserInfo() {
        $.ajax('url', function(data) {
            notify({avata: 'yezi'});
        });
    }
    
    return {
        getUserInfo: getUserInfo,
        subscribe: subscribe
    };
})();

// 地址模組
var address = (function() {

    function refresh() {
        console.log('地址模組完成');
    }
    // 訂閱
    Login.subscribe('address', refresh);
})();

var header = (function() {

    function setAvata() {
        console.log('頭部頭像完成');
    }
    // 訂閱
    Login.subscribe('header', setAvata);
})();

// 登入
Login.getUserInfo();
複製程式碼

8 模組之間的通訊

根據第7小節中編寫的中介Observer物件,其實可以提取出來作為一個完整的釋出訂閱物件。例如頁面上有兩個模組,點選A模組的按鈕後B模組的內容進行修改。

  • B模組向觀察者(Observer)訂閱事件:如果A模組的count修改則通知自己count的內容,B會顯示count內容
  • A模組通過觀察者(Observer)釋出訊息:我的count修改了,你幫我通知一下訂閱的模組
<html>
    <body>
   <body>
	<button id="count">點我</button>
	<div id="show"></div>
    <script>
    var Observer = (function() {
        // ...和上面的一樣
        return {
            subscribe: subscribe,
            notify: notify,
            remove: remove
        };
    })();

    var Button = (function() {
        var count = 0;
        var el = document.getElementById('count');
        el.onclick = function() {
            Observer.notify('addCount', count++);
        }
    })();	

    var Div = (function() {
        var el = document.getElementById('show');
        Observer.subscribe('addCount', function(count) {
            el.innerHTML = count;
        });
    })();
    </script>
    </body>
</html>
複製程式碼

這裡我們需要注意一點:如果模組與模組之間使用太多的釋出訂閱模式,那麼模組與模組之間的聯絡被隱藏,最終我們自己都會搞不清楚訊息來自哪個模組,會在維護的時候帶來麻煩。

9 先發布再訂閱

一直以來我們都是通過先訂閱後釋出,然後訊息都會傳送出來,但是如果我們先發布後訂閱那麼由於沒有人訂閱它,這條訊息就會石沉大海。
某些情況下,我們需要先將訊息保留下來,等有物件訂閱它,再重新將訊息傳送給訂閱者。

  • 就像QQ訊息在離線時,訊息被保留在伺服器,接收人下次登入上線則可以重新收到這條訊息。
  • 就像商城登入後需要渲染導航模組的使用者圖片。假如出現導航還沒渲染完畢,而使用者ajax資料已經返回,那麼導航模組就無法渲染圖片。因此需要建立一個離線事件的堆疊,將事件釋出時,將沒有訂閱者的事件的動作包裹在一個函式中,然後將包裹的函式放入堆疊,等到有物件來訂閱事件,依次遍歷堆疊並且依次執行這些包裝的函式,也就是重新發布里面的事件。當然離線事件的生命週期只有一次,就像QQ未讀訊息紙杯重新閱讀一次。
var Observer = (function() {
    // 使用者資料中心
    var customerDatas = {};
	
	// 存放已釋出但是未訂閱的事件
	var enquene = {};
    // 訂閱
    function subscribe(type, fn) {
        if (!(type in customerDatas)) {
            customerDatas[type] = [];
        }
        customerDatas[type].push(fn);

		// 獲取該型別未真正釋出的事件佇列
		var noNotifyParanms = enquene[type];
		// 如果已經存在訂閱未釋出,需要重新發布,並從未釋出佇列中移除
		if (noNotifyParanms && noNotifyParanms.length > 0) {
			noNotifyParanms.map(function(noNotifyParam) {
				fn.apply(this, noNotifyParam);
			});
			// 移除在為訂閱事件佇列中的事件
			delete enquene[type];
		}
    }

    // 釋出
    function notify() {
        var type = [].shift.call(arguments);
        var param = arguments;
        var fns = customerDatas[type];
		// 如果釋出時候訂閱數為0,則按照訂閱型別放入未訂閱事件佇列,並直接返回
        if(!fns || fns.length === 0) {
			enquene[type] = enquene[type] || [];
			enquene[type].push(param);
            return false;
        }
        fns.map(function(fn) {
			fn.apply(this, param);
			
        });
		
    }

    function remove(type, fn) {
        var fns = this.customerDatas[type];
        // 如果不存在訂閱內容返回
        if (!fns || fns.length === 0) {
            return false;
        }
        // 如果沒有傳入具體的需要退訂的內容,則取消所有該型別的訂閱
        if (!fn && fns) {
            fns.length = 0;
            return;
        }
        fns.map(function(_fn, index) {
            if (fn === _fn) {
                fns.splice(index, 1)
            }
        });
    }
    return {
        subscribe: subscribe,
        notify: notify,
        remove: remove
    };
})();
// 先發布
Observer.notify('add', 10);
Observer.notify('add', 20);

// 訂閱,會收到前兩次的通知
Observer.subscribe('add', function(num) {
	console.log('訂閱的value:' + num);
});
複製程式碼

10 全域性事件的命名衝突

全域性的釋出-訂閱物件裡只有一個customerDatas來存放訊息名和回撥函式,大家都通過它來訂閱和釋出各種訊息,久而久之,難免會出現事件名衝突的情況。因此可以給Observer物件提供建立名稱空間的功能。

var Observer = (function() {

    var _default = 'default';

    var Event = (function() {
        // 快取名稱空間物件
        var namespaceCache = [];

        var _listener = function(key, fn, cache) {
            if (!(key in cache)) {
                cache[key] = [];
            }
            cache[key].push(fn);
        }

        var _remove = function(key, cache, fn) {
            if (!(key in cache)) {
                // 如果移除fn沒有傳入,則移除所有該key型別的訂閱
                if (fn) {
                    cache[key] = cache[key].filter(function(_fn) {
                        return _fn != fn;
                    })
                } else {
                    cache[key] = [];
                }
            }
        }

        var _notify = function() {
            var cache = [].shift.call(arguments);
            var key = [].shift.call(arguments);
            var args = arguments;
            var stack = cache[key];
            if (!stack || stack.length === 0) {
                return;
            }
            return stack.map(function(fn) {
                fn.apply(this, args);
            });
        }

        var _create = function(nameSpace) {
            var nameSpace = nameSpace || _default;
            // 快取註冊的事件
            var cache = [];
            // 離線事件
            var offlineStack = [], ret;
            
            if (!(nameSpace in namespaceCache)) {
                ret = {
                    // 訂閱事件
                    subscribe: function(key, fn, last) {
                        // 基本的訂閱事件
                        _listener(key, fn, cache);
                        // 離線事件不存在返回
                        if (offlineStack === null) {
                            return;
                        }
                        // 執行最後一個離線事件
                        if (last === 'last') {
                            offlineStack.length && offlineStack.pop()();
                        } else {
                            // 執行所有的離線事件
                            offlineStack.map(function(_fn) {
                                _fn();
                            });
                        }
                        // 清空離線事件
                        offlineStack = null;
                    },
                    // 移除訂閱
                    remove: function(key, fn) {
                        _remove(key, cache, fn);
                    },
                    // 釋出
                    notify: function() {
                        var _self = this;
                        // 將註冊的所有事件cache插入到引數前面
                        [].unshift.call(arguments, cache);
						var args = arguments;
                        var fn = function() {
                            return _notify.apply(_self, args);
                        }
                        // 如果是被訂閱了的,則離線offlineStack物件等於null。如果沒有訂閱則offlineStack為[]
                        if (offlineStack) {
                            return offlineStack.push(fn);
                        }
                        return fn();
                    }
                };
                namespaceCache[nameSpace] = ret;
            }
            
            return namespaceCache[nameSpace];
        }

        return {
            create: _create,
            remove: function() {
                var event = this.create();
                event.remove(key, fn);
            },
            subscribe: function(key, fn, last) {
                var event = this.create();
                event.subscribe(key, fn, last);
            },
            notify: function() {
                var event = this.create();
                event.remove.apply(this, arguments);
            }
        };
    })();
    return Event;
})();

// 定義namespace1, 先訂閱再發布
var a = Observer.create('nameSpace1');
a.subscribe('onclick', function(value) {
	console.log('nameSpace1: ' +  value);
});
a.notify('onclick', 20);

// 定義namespace2, 先發布再訂閱
var b = Observer.create('nameSpace2');
b.notify('onclick', 10);
b.notify('onclick', 30);
b.subscribe('onclick', function(value) {
	console.log('nameSpace2: ' +  value);
});
複製程式碼

11 小結

在java語言中實現釋出訂閱模式,需要將訂閱者物件自身當成引用傳入到釋出物件中,同時訂閱者需要提供一個諸如update的方法,供釋出者物件在適當的時候呼叫。javascript中,使用註冊回撥函式的形式代替傳統的釋出定語模式,更簡潔優雅。
在javascript中不需要選擇推模型還是拉模型。一般都是推模型

  • 推模型指當事件發生,釋出者一次性將所有的改變的狀態和資料都推送給訂閱者
  • 拉模型:釋出者僅僅只通知訂閱事件已經發生了,此外發布者需要提供一些公開的介面供訂閱者主動拉去資料。好處在於按需獲取,但是會讓釋出者變成一個門戶大開的物件,同時增加程式碼量和複雜度
    釋出訂閱模式的缺點:
  • 建立訂閱者需要消耗一定的時間和記憶體(當訂閱一個訊息後,也許訊息從未發生,但是訂閱者始終都存在於記憶體中)
  • 弱化了定於這與建立者之間的關係,但是如果過度使用的話,物件與物件之間的關係也將被掩蓋,難以維護跟蹤。

相關文章