js進階-設計模式: 釋出訂閱模式

正義的蜻蜓隊長發表於2018-12-16

前言: 從八月份入職以來,在可以保證專案進度後,我便開始思考,怎麼把事情做得更好,怎麼提升自己。

  • 一方面,提升自己對javascript這門語言的理解,我在udemy上買了 JavaScript: Understanding the Weird Parts.中文翻譯過來,就是javascript: 理解怪異的部分。很經典,我推薦每個越過了基礎這道坎的人去看一下這部分內容。我也買了書,之後計劃對每一章進行解讀。
  • 另一方面,我明白了js是一門程式語言,是工具。那麼工具的用法是有很多種的。在不同的場景,使用不同的方法去處理,會讓你開發速度事半功倍。也可以提升自己對問題不同的解決方案。所以我閱讀了《javascript設計模式與開發實踐》,想知道更好的組織程式碼的形式是怎樣,在同一場景下,別人是怎麼處理問題的。
  • 對於個人提升方面,可以單獨拿一篇來探討了。鑑於篇幅,只說兩點。

正文開始

什麼是釋出訂閱模式

釋出訂閱模式稱觀察者模式,它定義物件間的一種一對多的依賴關係,當一個物件狀態改變的時候,所有依賴於它物件都將得到通知.

有點繞哈。其實說得簡單一點。你關注了我,我更新了文章,你會得到推送,就這個意思。 其實在日常的開發中,你一直在使用著釋出訂閱模式進行開發。最常見的例子是,原生事件API。(也就是滑鼠點選/移動/進入等事件,就是使用了釋出訂閱模式)

來,舉個栗子


// 訂閱
document.body.addEventListener('click', function() {
	alert(2);
});
// 觸發事件釋出
document.body.click();
複製程式碼

在釋出訂閱模式中,有兩個物件,一個是事件的釋出者,一個是訂閱者。

好啦,回答我一個問題,然後繼續看下去:

  1. 在例子中,誰是釋出者?
  2. 在例子中,誰是訂閱者?

假設你答出來了,OK,那麼接下來很容易理解。如果沒有,那沒關係,先看答案: * 釋出者是document.body * 訂閱者是我們 我們訂閱了在document.body上的click事件,當使用者點選了body,那麼會觸發click事件,body節點向使用者也就是我傳送資訊(alert). 使用這個模式還有個優點是:

我們可以隨意的增加或者刪除事件,這對訂閱者不會產生任何影響。

實現釋出訂閱模式

在我們理解了釋出者和訂閱者的關係後,來完成一個官方例項: 假設,現在有一個售樓處, 售樓處作為釋出者,而買家作為訂閱者。當價格變動的時候,售樓處把價格資訊推送給訂閱者。

// 實現一個釋出訂閱的步驟

  1. 指定好釋出的物件是誰?
  2. 給釋出者一個快取佇列,存放回撥函式以便通知訂閱者。
  3. 釋出訊息遍歷這個快取佇列,以此觸發裡面存放的訂閱者回撥函式。(符合條件的就進行觸發)

第一版

var selfOffices = {} // 定義釋出者
selfOffices.clientList = [] // 快取佇列,用來存放回撥函式
// 增加訂閱
selfOffices.listen = function (fn) {
    this.clientList.push(fn)
}
// 觸發事件釋出
selfOffices.trigger = function () {
    for (let i = 0, fn; fn = this.clientList[i++];) {
        fn.apply(this, arguments);
    }
}
//訂閱例項
selfOffices.listen(function (price, squareMeter) {
    console.log('價格 = ', price)
    console.log('squareMeter = ', squareMeter)
})
//訂閱例項
selfOffices.listen(function (price, squareMeter) {
    console.log('價格 = ', price)
    console.log('squareMeter = ', squareMeter)
})
//觸發
selfOffices.trigger(2000000, 90)
selfOffices.trigger(21321312321, 100)
複製程式碼

至此實現了最基本的釋出訂閱模式,但是你發現問題了嗎?

  • 當我觸發其中一個訂閱的時候,在上面的模式下,釋出者把其他使用者的訂閱也釋出給了我。

解決方案是增加一個標識。(就像onclick, onmousemove, 你訂閱click事件,在mousemove事件觸發時,你不會接收到通知)

第二版

var selfOffices = {} 
selfOffices.clientList = []
// 重要: 在這裡,增加了key關鍵字,作為標識位
selfOffices.listen = function (key, fn) {
    if (!this.clientList[key]) {
        this.clientList[key] = []
    }
    this.clientList[key].push(fn)
}
selfOffices.trigger = function () {
	// 重要:在觸發之前進行一個判斷,如果在觸發的事件該訂閱者沒有訂閱,則不會執行相應的訂閱事件
    var key = Array.prototype.shift.call(arguments)
    fns = this.clientList[key]
    if (!fns || fns.length == 0) {
        return false
    }
    for (let i = 0, fn; fn = fns[i++];) {
        fn.apply(this, arguments);
    }
}
selfOffices.listen("square88", function (price) {
    console.log('價格 = ', price)
})
selfOffices.listen("square100", function (price) {
    console.log('價格 = ', price)
})
selfOffices.trigger('square88', 90)
selfOffices.trigger('square100', 100)
複製程式碼

至此,完成了釋出特定訊息,訂閱者訂閱的事件釋出的時候通知訂閱了特定訊息的人。

第三版: 讓我們把以上的流程抽象出來,變成一個通用的釋出訂閱模式

// 釋出訂閱模式的通用模式
// 釋出者
var event = {
    clientList: [],// 監聽佇列
    listen: function (key, fn) {// 訂閱
        if (!this.clientList[key]) {
            this.clientList[key] = []
        }
        this.clientList[key].push(fn)
    },
    trigger: 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)
        }
    }
}
// 訂閱者
var installEvent = function (obj) {
    for (var i in event) {
        obj[i] = event[i]
    }
}
// 測試
var sales = {}// 訂閱者
installEvent(sales)// 初始化訂閱者
sales.listen('88', function (price) {
    console.log(88)
})
sales.listen('99', function (price) {
    console.log(99)
})
sales.trigger('88')
sales.trigger("99")
複製程式碼

至此, 完成了一個非破壞性的通用釋出訂閱模式。

第四版: 你知道的,可以訂閱,就一定要有取消訂閱的功能,不然。。。你看addEventListener.很尷尬。(無法取消)

(這裡偷懶,把第三版的程式碼假裝放在這裡)
// but, 訂閱完成之後,我突然的又不想再繼續訂閱這個事件了,因為我找到更加好的了
// 為我們的釋出訂閱函式增加取消訂閱的功能
event.remove = function (key, fn) {
	// 根據key在快取找到對應的快取佇列
    var fns = this.clientList[key]
    if (!fns) {
        return false
    }
	// 如果沒有傳入fn那麼,清空該條快取佇列
    if (!fn) {
        fns && (fns.length == 0)
    } else {
		// 相反,如果存在fn,那麼遍歷快取佇列,刪除該條快取佇列中的事件
        for (var l = fns.length - 1; l >= 0; l--) {
            var _fn = fns[l]
            if (_fn === fn) {
                fns.splice(l, 1)
            }
        }
    }
}
複製程式碼

雖然,這已經很棒了XD,但是,還是存在一定的問題,問題體現在以下幾個方面:

  • 我們在給每一個物件新增listen和trigger方法,以及一個clientList列表,其實沒有這個必要
  • 訂閱者與釋出者之間還是存在一定的耦合關係,如果訂閱者不知道釋出者的名稱,那就無法進行訂閱,
  • 又或者,訂閱者想訂閱另一個釋出者的事件,那麼還是要去獲取到另一個釋出者的名稱才能訂閱到

解決方案: 使用全域性Event物件實現,訂閱者不需要知道訊息來自哪裡,釋出者了也不知道資訊要釋出給誰 Event物件作為中介,連結兩者(訂閱者,釋出者)。

第五版: 用立即執行函式,形成閉包。對外暴露出Event介面。供外界使用。

var Event = (function () {
    var clientList = [],
        listen,
        trigger,
        remove;
    listen = function (key, fn) {
        if (!clientList[key]) {
            clientList[key] = []
        }
        clientList[key].push(fn)
    }
    trigger = function (key) {
        var key = Array.prototype.shift.call(arguments)
        fns = clientList[key]
        if(!fns  || fns.length == 0) {
            return false
        }
        for(var i = 0, fn; fn = fns[i++];) {
            fn.apply(this, arguments)
        }
    }
    remove = function (key, fn) {
        var fns =  clientList[key]
        if(!fns) {
            return false
        }
        if(!fn) {
            fns && (fns.length = 0)
        }else {
            for(var l = fns.length -1 ; l >= 0; l--) {
                var _fn = fns[l]
                if(_fn === fn) {
                    fns.splice(l, 1)
                }
            }
        }
    }
    return {
        listen: listen,
        trigger: trigger,
        remove: remove
    }
})();
Event.listen( '99', function (price) {
    console.log(price);
})
Event.trigger('99', 2999)
// Event.remove('99')
// Event.trigger('99', 299)
複製程式碼

額,第五版,哎呀,寫得太棒了,感覺沒啥問題了。但是想想,在大型應用中,使用釋出訂閱模式很可能,很可能很多個。那麼在以上的模式下,到最後,clientList會有些膨脹。可能造成很多很多的事件集中在這裡。不好管理,以及debugger. 所以,我們迎來了第六版!為釋出訂閱模式提供名稱空間的能力!更好的管理每個事件,可以對每類事件分門別類的放好。安排!

第六版: 對第五版的程式碼進行增強,提供名稱空間的能力 第六版,其實看起來有點多,其實就是增加了一個create還好還好,如果覺得比較困難你可以收藏,未來再回來看會好很多。

// todo 為了使釋出訂閱模式更加適用。我們要對上個版本的釋出訂閱模式進行增強。提供名稱空間的能力。更好的管理每個釋出訂閱。
var Event = (function () {
    // 相容各個平臺,因為broswer的global是window, 而node.js的是global
    var global = this,
    Event,// 初始化掛載點
    _default = 'default';// 初始化名稱空間
    Event = function () {
            // 初始化Event各個方法:監聽,觸發,移除
        var _listen,
            _trigger,
            _remove,
            // 初始化工具方法
            _slice = Array.prototype.slice,
            _shift = Array.prototype.shift,
            _unshift = Array.prototype.unshift,
            // 初始化名稱空間快取
            namespaceCache = {},
            // 初始化以名稱空間作為event的方法
            _create,
            // ! 這個find就很迷了,不知道什麼作用, 求各位大佬解答
            find,
            // 自建迭代器
            each = function ( ary, fn ) {
                var ret;
                for(var i = 0, l = ary.length; i < l ; i++) {
                    var n = ary[i];
                    ret = fn.call(n, i, n);
                }
                return ret;
            };
            // 監聽: 如果這個監聽的名稱在監聽快取中不存在, 那麼,初始化, 並且把該監聽事件存入cache[key]陣列中。
            _listen = function(key, fn, cache) {
                if( !cache [key]){
                    cache[key] = []
                }
                cache[key].push(fn);
            };
            // 移除: 首先判斷監聽快取佇列中是否存在對應的記錄, 如果存在,在對應的cache[key]陣列中刪除對應的監聽事件。
            _remove = function (key, cache, fn) {
                if(cache[key]){
                    if(fn){
                        for(var i = cache[key].length; i >= 0; i--) {
                            if(cache[key] == fn) {
                                cache[key].splice(i, 1);
                            }
                        }
                    }else{
                        cache [key] = [];
                    }
                }
            };
            // 觸發: 取出cache佇列, 迭代佇列,觸發事件
            _trigger = function () {
                var cache = _shift.call(arguments),// 取出cache佇列
                    key = _shift.call(arguments),// 取出對應的key, 像“click”
                    args = arguments,// 經過以上兩步, 剩下的只有入參了
                    _self = this,// 在這一步,獲取this,也就是Event物件本身
                    ret,
                    // 獲得觸發棧, 也就是之前使用listen設定的監聽事件
                    stack = cache[key];
                if(!stack || !stack.length ) {
                    return;
                }

                return each(stack, function (){
                    // 此時this指向stack中每個匿名函式
                    return this.apply(_self, args);
                });
            };
            // 建立名稱空間的方法
            _create = function (namespace) {
                // 給名稱空間設定預設值
                var namespace = namespace || _default;
                // 初始化cache和離線棧
                var cache = {},
                    offlineStack = [],
                    // 這個ret最後會掛載到名稱空間(namespaceCache)的快取中
                    ret = {
                        listen: function (key, fn, last) {
                            _listen(key, fn, cache);
                            if(offlineStack == null) {
                                return;
                            }
                            if(last == 'last') {
                                offlineStack.length && offlineStack.pop()();
                            }else{
                                each(offlineStack, function () {
                                    this()
                                })
                            }
                            offlineStack = null;
                        },
                        one: function (key, fn, last) {
                            _remove (key, cache);
                            this.listen(key, fn, last);
                        },
                        remove: function (key, fn) {
                            _remove(key, cache, fn);
                        },
                        trigger: function () {
                            var fn,
                                args,
                                _self = this;

                            _unshift.call(arguments, cache);
                            args = arguments;
                            fn = function() {
                                return _trigger.apply(_self, args);
                            };
                            if(offlineStack) {
                                return offlineStack.push(fn);
                            }
                            return fn();
                        }
                    };
                    // 使用名稱空間時的返回
                    return namespaceCache ? (namespaceCache[namespace] ? namespaceCache [namespace] : namespaceCache[namespace] = ret) :ret;
            };
            return {
                // 使用全域性Event時的返回
                create: _create,
                one: function (key, fn, last) {
                    var event = this.create();
                        event.one = (key, fn, last);
                },
                remove: function (key, fn) {
                    var event = this.create();
                        event.remove(key, fn);
                },
                listen: function (key, fn, last) {
                    var event = this.create();
                        event.listen(key, fn, last);
                },
                trigger: function () {
                    var event = this.create();
                    event.trigger.apply(this, arguments);
                }
            };
    }();
    return Event;
}())
// 使用範例
// 先發布後訂閱
Event.trigger('click', 1);

Event.listen('click', function (a) {
    console.log(a)
});

// 使用名稱空間,讓各個訂閱事件整潔有序
Event.create('namespace1').listen('click', function (a) {
    console.log(a)
});

Event.create('namespace1').trigger('click', 1);

Event.create('namespace2').listen('click', function (a) {
    console.log(a)
});

Event.create('namespace2').trigger('click', 1);
複製程式碼

(完)

OK,不知道大家感覺怎麼樣。如果你看到了這裡。謝謝你。我認為自己做的事情有價值,能給大家帶來幫助就會讓我很有成就感。

稍微橫向擴充套件一下,釋出訂閱模式在js這門語言中用在很多地方: node.js的事件驅動模型以及vue中的自定義事件,在我看來,都使用了釋出訂閱這種思想

篇幅有限,週末還有半天,以上兩點就不繼續寫下去了。

另外,掘金社群的各位大佬,感謝批評指正。希望得到一些正向,中肯的評價。感謝各位大佬。

參考: 《javascript設計模式與開發實踐》

相關文章