《JavaScript設計模式與開發實踐》-- 發佈-訂閱模式

詳情我的博客:https://shengchangwei.github.io/js-shejimoshi-fabudingyue/javascript

發佈-訂閱模式

一、定義

發佈-訂閱模式:發佈—訂閱模式又叫觀察者模式,它定義對象間的一種一對多的依賴關係,當一個對象的狀
態發生改變時,全部依賴於它的對象都將獲得通知。java

二、做用

  • 發佈—訂閱模式能夠普遍應用於異步編程中, 這是一種替代傳遞迴調函數的方案。無需過多關注對象在異步運行期間的內部狀態,而只須要訂閱感興趣的事件發生點。git

  • 發佈—訂閱模式能夠取代對象之間硬編碼的通知機制,一個對象不用再顯式地調用另一個對象的某個接口。發佈—訂閱模式讓兩個對象鬆耦合地聯繫在一塊兒,雖然不太清楚彼此的細節,但這不影響它們之間相互通訊。github

三、DOM事件(最經常使用的訂閱發佈模式)

只要咱們曾經在 DOM 節點上面綁定過事件函數,那咱們就曾經使用過發佈—訂閱模式,來看看下面這兩句簡單的代碼發生了什麼事情:ajax

// 監聽全局點擊事件,(訂閱全局點擊事件)
document.body.addEventListener('click', function() {
    console.log('觸發點擊事件')
}, false)

document.body.click(); // 模擬用戶點擊 (發佈點擊事件)

四、自定義事件

需求介紹:
小明最近看上了一套房子,到了售樓處以後才被告知,該樓盤的房子早已售罄。好在售樓MM 告訴小明,不久後還有一些尾盤推出,開發商正在辦理相關手續,手續辦好後即可以購買。但究竟是何時,目前尚未人可以知道。編程

因而小明記下了售樓處的電話,之後天天都會打電話過去詢問是否是已經到了購買時間。除了小明,還有小紅、小強、小龍也會天天向售樓處諮詢這個問題。一個星期事後,售樓 MM 決定辭職,由於厭倦了天天回答 1000 個相同內容的電話。設計模式

固然現實中沒有這麼笨的銷售公司,實際上故事是這樣的:小明離開以前,把電話號碼留在了售樓處。售樓 MM 答應他,新樓盤一推出就立刻發信息通知小明。小紅、小強和小龍也是同樣,他們的電話號碼都被記在售樓處的花名冊上,新樓盤推出的時候,售樓 MM 會翻開花名冊,遍歷上面的電話號碼,依次發送一條短信來通知他們。緩存

實現發佈—訂閱模式思路:服務器

  • 首先要指定好誰充當發佈者 (好比售樓處)
  • 而後給發佈者添加一個緩存列表,用於存放回調函數以便通知訂閱者(售樓處的花名冊);
  • 接着往回調函數裏填入一些參數,訂閱者能夠接收這些參數 (短信里加上房子的單價、面積、容積率)
  • 最後發佈消息的時候,發佈者會遍歷這個緩存列表,依次觸發裏面存放的訂閱者回調函數(遍歷花名冊,挨個發短信)。

代碼以下:架構

// 定義售樓處
let event = {
    clientList: [], // 緩存列表,存放訂閱者的回調函數
    subscribe: function(fn) { //訂閱事件
        this.clientList.push(fn);
    },
    publish: function() {
        for(var i = 0,fn; fn = this.clientList[i++];) {
            fn.apply(this, arguments); // arguments 是發佈消息時帶上的參數
        }
    }
}

// 下面簡單測試

event.subscribe(function(price, squareMeter) {
    console.log('價格=' + price);
     console.log('平方米=' + squareMeter);
})

event.publish(200000, 88); // 輸出: 200萬,88平方米
event.trigger(3000000, 110); // 輸出:300 萬,110 平方米

至此,咱們已經實現了一個最簡單的發佈—訂閱模式,但這裏還存在一些問題。咱們看到訂閱者接收到了發佈者發佈的每一個消息,雖然小明只想買 88 平方米的房子,可是發佈者把 110 平
方米的信息也推送給了小明,這對小明來講是沒必要要的困擾。因此咱們有必要增長一個標示 key,讓訂閱者只訂閱本身感興趣的消息。改寫後的代碼以下:

let event = {
    clientList: [], // 緩存列表,存放訂閱者的回調函數
    subscribe: function(key, fn) { //訂閱事件
      if ( !this.clientList[ key ] ){ // 若是尚未訂閱過此類消息,給該類消息建立一個緩存列表
            this.clientList[ key ] = []; 
        } 
        this.clientList[ key ].push( fn ); // 訂閱的消息添加進消息緩存列表
    },
    publish: function() {
       var key = Array.prototype.shift.call( arguments ), // 取出消息類型
        fns = this.clientList[ key ]; // 取出該消息對應的回調函數集合
        if ( !fns || fns.length === 0 ){ // 若是沒有訂閱該消息,則返回
            return false; 
        } 
        for( var i = 0, fn; fn = fns[ i++ ]; ){ 
            fn.apply( this, arguments ); // (2) // arguments 是發佈消息時附送的參數
        }
    }
}

// 測試

// 訂閱事件
event.subscribe('test0', function(data) {
	console.log('數據0:' + data);
});
event.subscribe('test0', function(data) {
	console.log('數據1:' + data);
});
event.subscribe('test1', function(data) {
	console.log(data);
});

setTimeout(function() {
    count++
    // 發佈事件
    event.publish('test0', count)
    event.publish('test1', count)
}, 1000)

五、取消訂閱事件

有時候,咱們也許須要取消訂閱事件的功能。好比小明忽然不想買房子了,爲了不繼續接收到售樓處推送過來的短信,小明須要取消以前訂閱的事件。如今咱們給 event 對象增長unsubscribe方法:

event.unsubscribe = function(key, fn) {
	const t = this.clientList[key];
    if (!t) { // 若是 key 對應的消息沒有被人訂閱,則直接返回
        return false;
    }
    if (!fn) {
        // 若是不指定處理方法,則取消該事件下全部的處理方法
        delete this.clientList[key];
        return true;
    }
    // 找到指定取消的處理方法的位置
    const i = t.indexOf(fn);
    if (i < 0) {
        return false;
    }
    t.splice(i, 1);
    // 若是事件下的處理方法爲空則刪除該事件
    if (!t.length) {
        delete this.clientList[key];
    }
    return true;
}

六、全局發佈訂閱事件(增強全局思想)

在程序中,發佈—訂閱模式能夠用一個全局的 Event 對象來實現,訂閱者不須要了解消息來自哪一個發佈者,發佈者也不知道消息會推送給哪些訂閱者,Event 做爲一個相似「中介者」的角色,把訂閱者和發佈者聯繫起來。見以下代碼:

var Event = (function(){
    var clientList = [], 
        subscribe, 
        publish, 
        unsubscribe;
    subscribe = function() { //訂閱事件
        if ( !this.clientList[ key ] ){ // 若是尚未訂閱過此類消息,給該類消息建立一個緩存列表
                this.clientList[ key ] = []; 
        } 
        this.clientList[ key ].push( fn ); // 訂閱的消息添加進消息緩存列表
    };
    publish = function() {
        var key = Array.prototype.shift.call( arguments ), // 取出消息類型
        fns = this.clientList[ key ]; // 取出該消息對應的回調函數集合
        if ( !fns || fns.length === 0 ){ // 若是沒有訂閱該消息,則返回
            return false; 
        } 
        for( var i = 0, fn; fn = fns[ i++ ]; ){ 
            fn.apply( this, arguments ); // (2) // arguments 是發佈消息時附送的參數
        }
    };
    unsubscribe = function(key, fn) {
        const t = this.clientList[key];
        if (!t) { // 若是 key 對應的消息沒有被人訂閱,則直接返回
            return false;
        }
        if (!fn) {
            // 若是不指定處理方法,則取消該事件下全部的處理方法
            delete this.clientList[key];
            return true;
        }
        // 找到指定取消的處理方法的位置
        const i = t.indexOf(fn);
        if (i < 0) {
            return false;
        }
        t.splice(i, 1);
        // 若是事件下的處理方法爲空則刪除該事件
        if (!t.length) {
            delete this.clientList[key];
        }
        return true;
    };
    return { 
        subscribe: listen, 
        publish: trigger, 
        unsubscribe: remove 
    }

})()

七、利用class類實現訂閱發佈

第一次接觸發布-訂閱模式是前組長自定義全局Angular事件服務,用來在全應用中經過發佈/訂閱事件來進行通訊,也曾閱讀學習,受益良多,特此致謝!

代碼以下:
源碼連接

import { Injectable } from '@angular/core';

/**
 * @name EventsService
 * @description 自定義全局事件服務,用來在全應用中經過發佈/訂閱事件來進行通訊
 * @usage
 * ```ts
 * import { EventsService } from '../services/events.service';
 *
 * constructor(public events: EventsService) {}
 *
 * // 訂閱事件並打印信息
 * this.events.subscribe('test', (data: any) => {
 * 		console.log(data); // '我是test事件發送來的信息!'
 * });
 *
 * // 發佈事件
 * this.events.publish('test', '我是test事件發送來的信息!');
 *
 * // 取消訂閱
 * this.events.unsubscribe('test');
 * ```
 */
@Injectable()
export class EventsService {
	private channels: any = [];

	/**
	 * 經過事件主題訂閱相應事件
	 * @param {string} topic 訂閱事件的主題
	 * @param {function[]} handlers 事件處理方法
	 */
	subscribe(topic: string, ...handlers: Function[]): void {
		if (!this.channels[topic]) {
			this.channels[topic] = [];
		}
		handlers.forEach((handler) => {
			this.channels[topic].push(handler);
		});
	}

	/**
	 * 經過事件主題取消訂閱相應事件
	 * @param {string} topic 取消訂閱事件的主題
	 * @param {function} handler 指定取消該事件主下的處理方法
	 * @returns {boolean} 取消成功返回true
	 */
	unsubscribe(topic: string, handler: Function = null): boolean {
		const t = this.channels[topic];
		if (!t) {
			return false;
		}
		if (!handler) {
			// 若是不指定處理方法,則取消該事件下全部的處理方法
			delete this.channels[topic];
			return true;
		}
		// 找到指定取消的處理方法的位置
		const i = t.indexOf(handler);
		if (i < 0) {
			return false;
		}
		t.splice(i, 1);
		// 若是事件下的處理方法爲空則刪除該事件
		if (!t.length) {
			delete this.channels[topic];
		}
		return true;
	}

	/**
	 * 經過事件主題發佈相應事件
	 * @param {string} topic 發佈事件的主題
	 * @param {any[]} args 經過事件發送的數據
	 * @returns {any[]}
	 */
	publish(topic: string, ...args: any[]): any[] {
		const t = this.channels[topic];
		if (!t) {
			return null;
		}
		const responses: any[] = [];
		t.forEach((handler: any) => {
			responses.push(handler(...args));
		});
		return responses;
	}
}

八、必須先訂閱再發布嗎 (記錄於此,增強一下辨證思惟)

咱們所瞭解到的發佈—訂閱模式,都是訂閱者必須先訂閱一個消息,隨後才能接收到發佈者發佈的消息。若是把順序反過來,發佈者先發布一條消息,而在此以前並無對象來訂閱它,這條消息無疑將消失在宇宙中。

在某些狀況下,咱們須要先將這條消息保存下來,等到有對象來訂閱它的時候,再從新把消息發佈給訂閱者。就如同 QQ 中的離線消息同樣,離線消息被保存在服務器中,接收人下次登陸上線以後,能夠從新收到這條消息。

這種需求在實際項目中是存在的,好比在以前的商城網站中,獲取到用戶信息以後才能渲染用戶導航模塊,而獲取用戶信息的操做是一個 ajax 異步請求。當 ajax 請求成功返回以後會發佈一個事件,在此以前訂閱了此事件的用戶導航模塊能夠接收到這些用戶信息。

可是這只是理想的情況,由於異步的緣由,咱們不能保證 ajax 請求返回的時間,有時候它返回得比較快,而此時用戶導航模塊的代碼尚未加載好(尚未訂閱相應事件),特別是在用了一些模塊化惰性加載的技術後,這是極可能發生的事情。也許咱們還須要一個方案,使得咱們的發佈—訂閱對象擁有先發布後訂閱的能力。

爲了知足這個需求,咱們要創建一個存放離線事件的堆棧,當事件發佈的時候,若是此時尚未訂閱者來訂閱這個事件,咱們暫時把發佈事件的動做包裹在一個函數裏,這些包裝函數將被存入堆棧中,等到終於有對象來訂閱此事件的時候,咱們將遍歷堆棧而且依次執行這些包裝函數,也就是從新發布里面的事件。固然離線事件的生命週期只有一次,就像 QQ 的未讀消息只會被從新閱讀一次,因此剛纔的操做咱們只能進行一次。

九、小結

發佈—訂閱模式的優勢很是明顯,一爲時間上的解耦,二爲對象之間的解耦。它的應用很是普遍,既能夠用在異步編程中,也能夠幫助咱們完成更鬆耦合的代碼編寫。發佈—訂閱模式還能夠用來幫助實現一些別的設計模式,好比中介者模式。從架構上來看,不管是 MVC 仍是 MVVM,都少不了發佈—訂閱模式的參與,並且 JavaScript 自己也是一門基於事件驅動的語言。

固然,發佈—訂閱模式也不是徹底沒有缺點。建立訂閱者自己要消耗必定的時間和內存,並且當你訂閱一個消息後,也許此消息最後都未發生,但這個訂閱者會始終存在於內存中。另外,發佈—訂閱模式雖然能夠弱化對象之間的聯繫,但若是度使用的話,對象和對象之間的必要聯繫也將被深埋在背後,會致使程序難以跟蹤維護和理解。特別是有多個發佈者和訂閱者嵌套到一塊兒的時候,要跟蹤一個 bug 不是件輕鬆的事情。

相關文章
相關標籤/搜索