詳解JavaScript中的客戶端消息框架設(shè)計(jì)原理
哇——是個危險的題目,對嗎?我們對于什么是本質(zhì)的理解當(dāng)然會隨著我們對要解決問題的理解而變化。因此我不會說謊——一年前我所理解的本質(zhì)很不幸并不完整,因?yàn)槲掖_信我將要寫的已經(jīng)快伴隨我有6個月之久。所以,這篇文章是我在發(fā)現(xiàn)JavaScript中成功的運(yùn)用客戶端消息模式的一些關(guān)鍵要點(diǎn)時的一個掠影。
1.) 理解中介者與觀察者的區(qū)別
大多數(shù)人在描述任何事件/消息機(jī)制的時候喜歡套用“發(fā)布者/訂閱者”(pub/sub)——但我認(rèn)為這個術(shù)語不能很好的與抽象建立聯(lián)系。當(dāng)然,從根本上說,一些東西訂閱了另一些東西發(fā)布的事件。但是發(fā)布者與訂閱者在何等層次上封裝在一起有可能使一個好的模式變得暗淡無光。那么,區(qū)別在什么地方呢?
觀察者
觀察者模式包括了被一個或多個觀察者所觀察的某個對象。典型的,該對象記錄下所有觀察者的痕跡,通常是用一個list來存儲觀察者注冊的回調(diào)方法,這些是觀察者為了接收通知而訂閱的。 注意: (哦,雙關(guān)語,我有多愛他們啊)(譯者注:Observe 觀察、注意)
var observer = { listen : function() { console.log("Yay for more cliché examples..."); } }; var elem = document.getElementById("cliche"); elem.addEventListener("click", observer.listen);
一些需要注意的事情是:
- 我們必須獲得對此對象的直接引用
- 此對象必須保持一些內(nèi)部的狀態(tài),保存觀察者的回調(diào)痕跡
- 有時偵聽者不會利用由此對象返回的任何參數(shù),理論上來說,有可能有 0-n*個參數(shù) (更多是取決于以后會變得多有趣)
* n事實(shí)上不是無限的,但為了討論的目的,它指我們永遠(yuǎn)也達(dá)不到的極限
中介者
中介者模式在一個對象與一個觀察者之間引入了一個“第三方”——有效的將二者解耦而且將他們之間如何通信封裝起來。一個中介者的API可能像“發(fā)布”、“訂閱”、“取消訂閱”一樣簡單,或者某個領(lǐng)域范圍內(nèi)的實(shí)現(xiàn)可能被提供用來隱藏這些方法于某些更有意義的語義之中。大多數(shù)我用過的服務(wù)器端的實(shí)現(xiàn)更傾向于領(lǐng)域范圍而不是更簡單,但是并沒有對一個通用的中介者有任何規(guī)則限制!并不罕見,有種想法認(rèn)為一個通用的中介者是一種信息經(jīng)紀(jì)人。無論何種情形,結(jié)果都一樣——特定對象與觀察者之間不再互相直接知曉:
// It's fun to be naive! var mediator = { _subs: {}, // a real subscribe would at least check to make sure the // same callback instance wasn't registered 2x. // Sheesh, where did they find this guy?! subscribe: function(topic, callback) { this._subs[topic] = this._subs[topic] || []; this._subs[topic].push(callback); }, // lolwut? No ability to pass function context? :-) publish : function(topic, data) { var subs = this._subs[topic] || []; subs.forEach(function(cb) { cb(data); }); } } var FatherTime = function(med) { this.mediator = med; }; FatherTime.prototype.wakeyWakey = function() { this.mediator.publish("alarm.clock", { time: "06:00 AM", canSnooze: "heck-no-get-up-lazy-bum" }); } var Developer = function(mediator) { this.mediator = mediator; this.mediator.subscribe("alarm.clock", this.pleaseGodNo); }; Developer.prototype.pleaseGodNo = function(data) { alert("ZOMG, it's " + data.time + ". Please just make it stop."); } var fatherTime = new FatherTime(mediator); var developer = new Developer(mediator); fatherTime.wakeyWakey();
你可能會想,除了特別純粹的中介者實(shí)現(xiàn),特定對象不再負(fù)有保存訂閱者列表的責(zé)任,而且“時光老人”(FatherTime)與“開發(fā)者”(Developer)實(shí)例永遠(yuǎn)沒法真正互相知道。他們只是共享了一個信息——將如我們今后所見,這是一個很重要的合約。 “很好,Jim。這對我而言仍然是發(fā)布者/訂閱者,那么重點(diǎn)呢?我選擇某個方向真的會有區(qū)別嗎?”哦,繼續(xù)吧,親愛的讀者們,繼續(xù)吧。
2.) 了解什么時候使用中介者和觀察者
使用本地的觀察者和中介者,即寫在組件當(dāng)中的,而中介者看起來又像遠(yuǎn)程的組件間通信。不管怎樣。我對待這種情況的原則雖然是——tl;dr(too long; don't read)(太長,不讀了)。但無論如何,反正串聯(lián)在一起最好。
要我簡捷地說真是麻煩,就像把幾個月來的細(xì)致體驗(yàn)壓縮到裝不下140個字的溝里?,F(xiàn)實(shí)中回答這個問題肯定不簡潔。所以有一個長版本的解釋:
觀察者除了關(guān)心數(shù)據(jù)映射之外還有必要引用別的項(xiàng)目嗎?例如Backbone.View視圖有各種理由直接引用它的模型。這是非常自然的關(guān)系,視圖不僅要在模型改變時進(jìn)行渲染,還需要調(diào)用模型的事件處理。如果段首的問題答案是”yes“,那觀察者就是有意義的。
如果觀察者和觀察對象的關(guān)系僅僅是依賴數(shù)據(jù),那我愿意使用中介pub/sub方式。兩個Backbone.View視圖或模型之間的通信,用觀察者是合適的。比如控制導(dǎo)航菜單的視圖發(fā)出的信息,是面包屑(breadcrumb)掛件需要的(響應(yīng)當(dāng)前的層級)。掛件不需要引用導(dǎo)航視圖,它只需要導(dǎo)航視圖提供信息。更關(guān)鍵的,導(dǎo)航視圖也許不是唯一的信息來源,別的視圖可能也可以提供。此時,中介pub/sub模式是最理想的——而且自身擴(kuò)展性良好。
看起來這樣又好又全面,但是其實(shí)還有一個露點(diǎn):如果我給對象定義一個本地事件,既想要觀察者直接調(diào)用,又可以被訂閱者間接訪問到,怎么辦?這就是我為什么說要串聯(lián)在一起:你推送或者橋接本地事件到消息組去吧。需要些更多代碼?很有可能——但是總比你把觀察對象傳遞給所有觀察者,一直緊耦合下去的情況好。然后,我們可以很好地繼續(xù)以下兩點(diǎn)...
3.) 選擇性的“提交”本地事件到總線
最開始我?guī)缀踔挥糜^察者模式來在JavaScript中觸發(fā)事件。這是我們一次又一次遇到的模式,但更流行的客戶端輔助庫行為方式根本上來說是混合中介者的,給我們提供了就像它們是觀察者模式的API。我最初寫postal.js的時候,開始走進(jìn)“為所有事物搭中介”的階段。在我寫的原型與構(gòu)造函數(shù)中,分布各處的發(fā)布與訂閱的調(diào)用并不罕見。當(dāng)我從這個改變中自然的解耦受益時,非基礎(chǔ)的代碼開始似乎充滿了相關(guān)于基礎(chǔ)的部分。構(gòu)造函數(shù)到處都要帶上一個通道,訂閱被當(dāng)作新實(shí)例的一部分被創(chuàng)建,原型方法直接發(fā)布一個數(shù)值到總線(甚至本地的訂閱者都不能直接的而必須監(jiān)聽總線以獲得信息)。將這些明顯關(guān)于總線的東西納入app的這些部分,開始像是代碼的味道。代碼的“敘述”似乎總是被打斷,如“噢,將這個向所有訂閱者發(fā)布出去”,“等等!等等!監(jiān)聽這個通道那個事情。好,現(xiàn)在繼續(xù)吧”。我的測試忽然開始需要依賴總線來做低層次的單元測試。而這感覺有點(diǎn)不對勁。
鐘擺擺動的指向了中間,我認(rèn)識到我應(yīng)該保持一個“本地API”,并且在需要的時候通過一個中介者為應(yīng)用擴(kuò)展其可以觸及的數(shù)據(jù)。 例如,我的backbone視圖與模型,仍然用普通的Backbome.Events行為來給本地觀察者發(fā)送事件(就是說,模型的事件被它相應(yīng)的視圖所觀察)。當(dāng)app的其它部分需要知道模型的變化時,我開始通過這些行將本地事件與總線橋接起來:
var SomeModel = Backbone.Model.extend({ initialize: function() { this.on("change:superImportantField", function(model, value) { postal.publish({ channel : "someChannel", topic : "omg.super.important.field.changed", data : { muyImportante: value, otherFoo: "otherBar" } }); }); } });
重要的是要認(rèn)識到,當(dāng)有可能透明的推送事件到消息總線時,本地事件和消息必須被認(rèn)為是分開的合約——至少概念上如此。換句話說,你要能夠修改“內(nèi)部的/本地的”事件而不破壞消息合約。這是要在腦海中記住的重要事實(shí)——否則你就是為緊耦合提供了一個新的途徑,在一個方法上走反了!
所以理所當(dāng)然,上述的模型是可以在沒有消息總線的情況下被測試。而且如果我移去橋接在本地事件與總線之間的邏輯,我的視圖與模型依然工作得毫無不暢。但是,這可是七行的例子(盡管格式化了)。 僅僅橋接四個事件就需要幾乎三十行的代碼。
噢,你怎樣才能二者兼顧呢—— 在適合直接觀察者時本地通知,同時使涉及事件可以擴(kuò)展,以便你的對象不必給所有對象都發(fā)送一圈——不需要代碼膨脹。通知怎樣才能很少的代碼又有更多的味道呢?
4.)在你的構(gòu)架中隱藏樣板
這并不是說上面的例子中的代碼 —— 將事件接入總線 —— 的語法或概念是錯誤的(假設(shè)你接受本地和遠(yuǎn)程/橋接事件的概念)。然而,這是一個很好的體現(xiàn)在代碼基礎(chǔ)之上培養(yǎng)良好習(xí)慣的作用的例子。有時我們會聽到類似“代碼實(shí)在太多了”的抱怨(特別是當(dāng) LOC 作為代碼質(zhì)量的唯一判定者時)。 當(dāng)這種情況下,我表示贊同。 它是一個可怕的樣板。 下面是我在橋接 Backbone 對象的本地事件到 postal.js 時使用的模式:
// the logic to wire up publications and subscriptions // exists in our custom MsgBackboneView constructor var SomeView = MsgBackboneView.extend({ className : "i-am-classy", // bridging local events triggered by this view publications: { // This is the more common 'shorthand' syntax // The key name is the name of the event. The // value is "channel topic" in postal. So this // means the bridgeTooFar event will get // published to postal on the "comm" channel // using a topic of "thats.far.enough". By default // the 1st argument passed to the event callback // will become the message payload. bridgeTooFar : "comm thats.far.enough", // However, the longhand approach works like this: // The key is still the event name that will be bridged. // The value is an object that provides a channel name, // a topic (which can be a string or a function returning // a string), and an optional data function that returns // the object that should be the message payload. bridgeBurned: { channel : "comm", topic : "match.lit", data : function() { return { id: this.get("id"), foo: 'bar' }; } }, // This is how we subscribe to the bus and invoke // local methods to handle incoming messages subscriptions: { // The key is the name of the method to invoke. // The value is the "channel topic" to subscribe to. // So this will subscribe to the "hotChannel" channel // with a topic binding of "start.burning.*", and any // message arriving gets routed to the "burnItWithFire" // method on the view. burnItWithFire : "hotChannel start.burning.*" }, burnItWithFire: function(data, envelope) { // do stuff with message data and/or envelope } // other wire-up, etc. });
顯然你可以用幾種不同的方式做這些——選擇總線式的框架——這要比樣板方式少很多無關(guān)內(nèi)容,而且為Backbone開發(fā)人員所熟知。當(dāng)你同時控制事件發(fā)送器和消息總線的實(shí)現(xiàn)時,橋接要更容易。這里有個將monologue.js發(fā)送器橋接到postal.js的例子:
// using the 'monopost' add-on for monologue/postal: // assuming we have a worker instance that has monologue // methods on its prototype chain, etc. The keys are event // topic bindings to match local events to, and if a match is // found, it gets published to the channel specified in the // value (using the same topic value) worker.goPostal({ "match.stuff.like.#" : "ThisChannelYo", "secret.sauce.*" : "SeeecretChannel", "another.*.topic" : "YayMoarChannelsChannel" });
以不同的方式使用樣板是令人愉快的好習(xí)慣?,F(xiàn)在我可以分別獨(dú)立的測試我的本地對象,橋接代碼,甚至測試二者合一的生產(chǎn)&消費(fèi)期待的消息過程等等。
同樣重要的是要注意到,如果我需要在上述的場景訪問普通的postal API,沒有什么可以阻止我這么做。沒有丟失靈活性這么就等于成功了
5.) 消息是合約——要明智的選擇實(shí)現(xiàn)方式
有兩種將數(shù)據(jù)傳遞給訂閱者的方法——也許可以給他們貼上更“官方”的標(biāo)簽,我將如此描述他們:
- “0-n 參數(shù)”
- “封套” (或“單對象載荷“)
看看這些例子:
// 0-n args this.trigger("someGuyBlogged", "Jim", "Cowart", "JavaScript"); // envelope style this.emit("someGuyBlogged", { firstName: "Jim", lastName: "Cowart", category: "JavaScript" }); /* In an emitter like monologue.js, the emit call above would actually publish an envelope that looked similar to this: { topic: "someGuyBlogged", timeStamp: "2013-02-05T04:54:59.209Z", data : { firstName: "Jim", lastName: "Cowart", category: "JavaScript" } } */
經(jīng)過一段時間,我發(fā)現(xiàn)封套方式比0-n參數(shù)方式要少很多很多麻煩(與代碼)。"0-n參數(shù)"途徑的挑戰(zhàn)主要在于兩個原因(就我的經(jīng)驗(yàn)而言):第一,很典型的是“當(dāng)事件觸發(fā)時,你還記得要傳遞哪一個參數(shù)嗎?不記得?好,我想我會看看觸發(fā)的源頭”。不是一個真正意義上的好方法,對嗎?但它可以打斷代碼的正常流程。你可以用一個調(diào)試工具,檢測執(zhí)行條件下的參數(shù)值并由此推斷基于這些數(shù)值的”標(biāo)簽“,但哪個更簡單呢——看到一個”1.21“的參數(shù)值,困惑于它的意義,或者檢測一個對象并發(fā)現(xiàn){千兆瓦:1.21}。第二個原因是由于伴隨事件傳送可選的數(shù)據(jù),以及當(dāng)方法簽名變得更長帶來的痛苦。
"說實(shí)話,Jim,你這是在搭車棚。"或許是的,但是一段時間以來我一直看到代碼的基礎(chǔ)在擴(kuò)充與變形,簡單的包含一兩個參數(shù)的原始事件,在其間包含了可選的參數(shù)以后開始變得畸形:
// 最開始是這樣的 this.trigger("someEvent", "a string!", 99); // 有一天, 它變得包含了一切 this.trigger("someEvent", "string", 99, { sky: "blue" }, [1,2,3,4], true, 0); // 可是等等——第4和第5個參數(shù)是可選的,因此也可能傳的是: this.trigger("someEvent", "string", 99, [1,2,3,4], true, 0); // 噢,你還檢查第5個參數(shù)的真/假嗎? // 哎呦!現(xiàn)在是早先的參數(shù)了…… this.trigger("someEvent", "string", 99, true, 0);
如果有任何數(shù)據(jù)是可選的,將沒有圍繞它的測試。但需要更少的代碼,需要能更具擴(kuò)展性,特別典型的是能自解釋(感謝這些成員名字)以便能在逐一傳送給訂閱者回調(diào)方法時,對一個對象進(jìn)行那種測試。我仍然在不得不用"0-n參數(shù)"的地方用它,但如果由我決定,將是一直用封套的方法——我的事件發(fā)送者和消息總線都是這樣。(說明我存在偏見,monologue與postal共享同一個封套的數(shù)據(jù)結(jié)構(gòu),去掉了monologue不用的通道)
因此——得承認(rèn)用來給訂閱者傳輸數(shù)據(jù)的結(jié)構(gòu)是”合約“的一個部分。在封套方式這個方向,你可以用額外的元數(shù)據(jù)描述事件(不需要增加額外的參數(shù))——這保持了方法簽名(這就是合約的一個部分)對每個事件和訂閱者一致。你也能很容易的為一個信息結(jié)構(gòu)編制版本(或在必要的時候增加其他封套層級的信息)。如果你沿著這個方向做的話,請確保用的是一致的封套結(jié)構(gòu)。
6.) 消息”拓?fù)洹氨饶阆氲倪€重要
這里沒有銀彈。但是你要對如何命名主題與通道,以及如何設(shè)計(jì)消息載荷的結(jié)構(gòu)深思熟慮。我傾向于用兩種方法之一映射我的模型:用一個單一的數(shù)據(jù)通道,主題的前綴采用模型的名字,后跟其唯一的id,然后通過它的操作({modelType.id.operation})處理,或者給模型的自身通道,主題就是{id.operation}。一個恒定的習(xí)慣是在模型請求數(shù)據(jù)的時候自動響應(yīng)這個行為。但并不是所有總線上的操作都是請求。可能有簡單的事件發(fā)布到app。你是否命名主題來描述事件(理想條件下)?或者你是否掉進(jìn)了這樣的陷阱,通過命名主題來描述某個訂閱者可能的傾向行為?例如,包含“route.changed” 抑或 “show.customer.ui”主題的消息。一個表明了事件,另一個表明了命令。做這些決定的時候要仔細(xì)思考。命令并不壞,但在你需要請求/響應(yīng)或命令之前,你會為事件所能描述的數(shù)量而吃驚的。
相關(guān)文章
JavaScript arguments 多參傳值函數(shù)
在一個函數(shù)體內(nèi),標(biāo)識符arguments引用了arguments對象的一個特殊屬性??梢园凑諗?shù)目(而不是名字)獲取傳遞給函數(shù)的參數(shù)值。2010-10-10使用JavaScript制作一個簡單的計(jì)數(shù)器的方法
這篇文章主要介紹了使用JavaScript制作一個簡單的計(jì)數(shù)器的方法,用于計(jì)算網(wǎng)頁用戶的來訪次數(shù),需要的朋友可以參考下2015-07-07