為什麼前端初學者必須要明白髮布訂閱模式
By Hubert Zub | Oct 3, 2018
當你將關注點從樣式,美學和網格系統轉移到邏輯,框架和編寫JavaScript程式碼時。一切都開始了,你會發現你處於你的web開發歷程中最激動人心的那一刻。
在這個非常時刻你會發現,當涉及到JS時,它不僅僅是幾個簡單的jQuery技巧和視覺效果。你的視野是一整個web應用,而不再僅僅是侷限於頁面。
當你把更多的精力投入到寫js程式碼時,你會開始考慮互動、你的子模組和邏輯。事情開始奏效,你感覺到你的app有了生命。一個全新的、令人興奮的世界出現在你眼前,同樣,也出現了很多全新的、棘手的問題。
你並不氣餒,並想出來各種各樣的辦法,程式碼也寫的越來越多。嘗試某些部落格文章中那各種各樣的技術,不斷地完善自己解決問題的方法。
然後,你開始覺得有些不對路。
你的指令碼檔案慢慢變大,一小時前才200行的,現在已經500行了。“嘿”——你想——“這沒什麼大不了的”。隨後,你開始閱讀關於程式碼維護的相關文章,並著手實現它。開始分離你的邏輯程式碼,並把它們分塊、元件。事情開始又變好了點。程式碼像圖書館藏書那樣分類存放。你感覺良好,因為各種各樣的檔案被以正確的命名放置在合適的目錄裡。程式碼變得模組化,更易於維護了。
然而,你又感覺不對路了,但是不知道哪裡有問題。
web應用的行為很少是線性的。事實上,web應用的許多行為應該是瞬時發生(有時候應該是出乎意料或是自發地)。
應用需要正確併合適響應各種網路請求、使用者操作、計時事件和各種延時動作。名為“非同步”和“race condition”的怪物無時不刻在敲你的腦門。
你需要將你帥氣的模組化結構與醜陋的新娘結合 - 非同步程式碼。一個棘手的問題來了:我應該把這段程式碼放在哪裡?
你會把你的app精心地劃分成一個個構建塊。導航和內容元件被整齊地放置在合適的目錄中,較小的輔助指令碼檔案包含了執行普通任務的重複程式碼。一切都通過app.js這個檔案來排程,一切都從這裡開始。完美。
但是,你的目標是在app中的某個地方呼叫非同步程式碼,執行後把它放在一旁。
非同步程式碼應該放在ui元件麼?或者放在主檔案裡?app的哪個構建塊負責響應呢?哪一個構建塊負責開始執行?錯誤處理呢?你在腦海裡考慮著各種方法——但是你還是愁眉不解——你意識到如果想要擴充或維護這些程式碼,那難度是相當大的,問題還沒解決。你需要理想的一勞永逸的方案。
放鬆一下,這對你來說沒有問題。事實上,你的思維越有條理,這種煩惱就會越強烈。
你開始閱讀有關處理此問題的資訊並尋求即用型解決方案。一開始,你瞭解到promises優於回撥的地方。隨後,你開始試圖瞭解什麼是RxJS(並且為什麼網上的一些人說這是解決網路非同步請求的唯一解決方案)。經過一些閱讀之後,你試著去理解,為什麼一個部落格寫道沒有redux-thunk的redux沒有意義,但是另一個人認為redux-saga也是如此。
一天結束後,你疲憊的大腦充斥著各種詞。閱讀完大量可行的方法後,你的想法噴湧出來。為什麼會有這麼多呢?那麼複雜?人們怎麼喜歡在網際網路上爭論,不去開發一個好的模式?
因為這些都不重要
無論使用哪種框架,非同步程式碼都不可能被正確地存放好。並沒有一個單一、通用、既定的解決方案,要根據具體的開發環境、需求來採取不同的方案。
並且,這篇文章也不會提供解決所有問題的方案。但是它可以給你提供一個好的思路,讓你處理好你的非同步程式碼——因為它都基於一個非常基本的原則。
通用部分
從某些角度來看,程式語言的結構並不複雜。畢竟,它們只是類似於計算機的愚蠢東西,能夠在各種盒子裡儲存值而已,並且通過if或函式呼叫改變程式執行流程。作為一種命令式和略微物件導向的語言,js在這裡也是類似的。
這意味著究其本質,來自各路大神寫的各種宇宙級非同步庫(無論是redux-saga、RxJS、觀察者或者其他奇奇怪怪的庫)都依賴相同的基本原理。它們並沒有那麼神奇——它必須讓大家學習它的概念,這裡並沒有新發明。
為什麼這個事實如此重要?讓我們來考慮這樣的一個例子。
Let’s do (and break) something
先來個簡單的app,這個app可以讓我們在地圖上標記我們喜歡的地方。沒有什麼花哨的東西:只是右側的地圖檢視和左側的簡單側邊欄。單擊地圖應在地圖上儲存新標記。
當然,我們需要一個與眾不同的特性:我們需要它用local storage記住我們標記好的地方列表。
綜上所述,我們可以畫一個流程圖出來
看,並不是很複雜
為簡潔起見,下面的示例將在不使用任何框架或UI庫的情況下編寫 - 僅涉及vanilla js。此外,我們將使用谷歌地圖API的一小部分 - 如果你想自己建立類似的應用程式,你應該註冊你的API金鑰cloud.google.com/maps-platfo….
快速分析一下
-
init方法用google地圖api初始化地圖元件,註冊地圖點選事件並且嘗試從local storage載入資料。
-
addPlace方法處理地圖點選事件——把新地點加在列表裡並且更新ui
-
renderMarkers方法迭代地點列表,清除地圖後,將標記放在其上。
忽略一些不完善的地方(沒有錯誤處理之類的)—— 它將作為原型提供足夠好的服務。完美。讓我們寫一些html:
假設我們寫了一些樣式(我們不會在這裡介紹它,因為它不相關),不管你信不信 - 它實際上是這樣做的:
儘管它很醜,但是管用。不過可擴充性不好。
首先,我們的程式碼責任分割不明確。如果你聽說過SOLID原則,你應該清楚我們已經打破了第一條規則:單一責任原理。在我們的例子中——儘管很簡單——一個js檔案包含了所有,包括處理使用者響應的程式碼和資料轉換和非同步程式碼。“為什麼這樣不好,執行起來不是棒棒的麼?”——你可能會這麼說。確實執行起來棒棒的,但是如果要加新特性那就不棒棒了——可維護性低。
我用一個例子讓你徹底心服口服:
首先,我們想要側邊欄加標記列表。第二,我們想要用googleAPI實現在地圖上看到城市名的功能——這就引入了非同步程式碼。
好了,我們的新流程圖畫出來了:
既然你呼叫別人的介面,那肯定不是同步程式碼而是非同步程式碼啦。它首先要呼叫google的js庫,並且回覆過來需要一定時間。雖然有點複雜,但是用於教學剛剛好。
讓我們回到ui程式碼這裡並且這裡有個明顯的事實。我們的頁面分兩大塊,側邊欄和主要內容區。我們絕對不能把它們兩的程式碼放在一起。原因很明顯——我們將來有四個元件怎麼辦?六個呢?一百個呢?我們需要把我們的程式碼分開——我們需要有兩個獨立的js檔案。一個是側邊欄,一個是主要內容區塊。問題來了,哪一個應該存放地方標記列表的陣列呢?
哪一個正確呢?哪個都不對。還記得單一責任原則麼?為了降低程式碼冗餘度,我們應該以某種方式分離關注點並將我們的資料邏輯儲存在其他地方。看吧:
程式碼分離萬金油:我們可以把進行資料操作的程式碼放到另一個檔案裡,這個檔案集中處理資料。這個servce檔案將負責那些與本地儲存同步的問題和機制。相反,元件將僅僅提供介面。這符合SOLID原則。讓我們介紹下這個模式:
Service code
Map component code
Sidebar component code:
好了,一個大問題已經解決。程式碼整齊擺放在它們該待的位置。但在我們感覺良好之前,執行下這個。
。。。oops。
在做任何動作之後,app沒有互動了。
為什麼? 好吧,我們沒有實現任何同步手段。使用匯入的方法新增地點後,我們不會在任何地方發出任何訊號。在呼叫addPlace()之後,我們甚至無法在下一步呼叫getPlaces()方法,因為城市查詢是非同步的,需要時間來完成。
程式在後臺進行,但是並沒有反應到介面上——在地圖上新增標記後,我們沒有看到側邊欄的更新。怎麼解決?
一個簡單的方法就是,使用定時器輪詢我們的服務,例如:
它有用麼?emm。。有,但不是最佳方案。大多數情況下我們並不需要這個服務。
畢竟,你也不會定時去看你的包裹有沒到達。同樣地,如果你把汽車丟去維修,你也不會每半小時給修車師傅打電話詢問工作是否完成(至少希望你不是這種人)。正常的情況應該是這樣的,修車師傅修好了,自然會打電話給你。當然,我們事先留電話了。
現在,我們在js中嘗試下這種“留電話”的方式。
js是一門非常神奇的語言——它的一個古怪的特徵就是可以把函式視為其他值。形象點表示就是,“函式是一等公民”。這意味著任何函式都可以分配給變數或作為引數傳遞給另一個函式。事實上你已經接觸過了:還記得setTimeout,setInterval和各種事件監聽器回撥嗎? 它們通過將函式作為引數來使用。
這種特性在非同步場景中是基礎
我們可以定義一個更新我們的UI的函式 - 然後將它傳遞給另一部分的程式碼,在那裡它將被呼叫。
使用這種機制,我們可以將renderCities方法以某種方式傳遞給dataService。在那裡,它將在必要時被呼叫:畢竟,服務能準確地知道何時應該將資料傳輸到元件。
試一試,我們首先在服務端新增這個功能,然後在某個時刻呼叫它。
現在,在sidebar那裡使用
你知道會發生什麼麼?當在載入我們的sidebar程式碼時,它在dataService註冊了renderCities方法。
在這種情況下,當我們的資料發生更改時,dataService就會呼叫此函式(由於addPlace()的呼叫)。
確切地說,我們的程式碼的一部分是事件的SUBSCRIBER,另一部分是PUBLISHER(服務方法)。我們已經實現了釋出 - 訂閱模式的最基本形式,這是幾乎所有高階非同步概念的基本概念。
還有呢?
請注意,我們的程式碼,僅限於一個監聽元件(即,一位訂閱者)。如果其他方法也用了這個subscribe方法來傳遞的話,它會覆蓋掉dataService的changeListener變數,為了解決這個問題,我們需要用陣列來儲存監聽者。
現在,我們可以稍微整理一下程式碼並編寫一個函式來為我們呼叫所有的監聽者:
這樣我們也可以連線map.js元件,以便它對服務中的所有操作做出正確的反應:
如果需要傳遞引數怎麼辦?我們可以使用監聽者的引數直接獲得。像這樣:
然後,可以輕鬆地在元件中檢索資料:
這裡還有更多的可能性 - 我們可以為不同型別的行為建立不同的主題(或渠道)。此外,我們可以提取釋出和訂閱方法到一個檔案並從那裡使用它。但就目前而言,還OK啦 - 以下是使用我們剛剛建立的相同程式碼的應用的簡短視訊
(譯者注,大家去原文那裡看吧)
(譯者注:接下來的內容是作者關於這個模式的想法,他說,那些元件的概念比如RxjS,雖然它們功能更強大、概念更加地複雜,但是基本概念都是上文講過的。它們搞得太複雜了而已。並且這個模式也可以套在其他的地方。如DOM操作。另外,本文只是講了最基本的,還有很多地方可以擴充。比如取消訂閱、事件訂閱等等。最後作者還建議我們多點搞優秀的原始碼,down下來用debugger研究原始碼。挖掘出它們最基本的思想。多動手、多思考,不要害怕專有名詞,覺得很高大上、很難理解。其實就是那麼一回事。有些人搞得太複雜了。) (譯者為什麼不翻譯完呢?因為想讀者們自己嘗試去翻譯,最重要的原因,是因為譯者懶。。。)
Does this whole publish-subscribe thing resemble something you might already know? After giving it some thought, it’s the pretty same mechanism that you use in element.addEventListener(action, callback). You subscribe your function to a particular event, which ich being called when some action is published by element. Same story.
Going back to the title: why is this thing so bloody important? After all, in the long run, there is little sense in holding up to vanilla JavaScript and modifying the DOM manually — same goes with manual mechanisms for passing and receiving events. Various frameworks have their established solutions: Angular uses RxJS, React have state and props management with possibility of boosting it with redux, literally every usable framework or library have its own method of data synchronization. Well, the truth is that all of them use some variation of publish-subscribe pattern.
As we already said — DOM event listeners are nothing more than subscribing to publishing UI actions. Going further: what is a Promise? From certain point of view, it’s just a mechanism that allows us to subscribe for completion of a certain deferred action, then publishes some data when ready.
React state and props change? Components’ updating mechanisms are subscribed to the changes. Websocket’s on()? Fetch API? They allow to subscribe to certain network action. Redux? It allows to subscribe to changes in the store. And RxJS? It’s a shameless one big subscribe pattern.
It’s the same principle. There are no magic unicorns under the hood. It’s just like the ending of the Scooby-Doo episode.
It’s not a great discovery. But it’s important to know:
No matter what method of solving
asynchronous problem will you use,
it will be always some variation of
the same principle: something
subscribes, something publishes.
複製程式碼
That’s why it is so essential. You can always think of publish and subscribe. Take note and keep going. Keep building larger and more complex application with many asynchronous mechanisms — and no matter how difficult it may look like, try to synchronize everything with publishers and subscribers.
Still, there is a number of topics untouched in this story:
- Mechanisms of unsubscribing listeners when not needed anymore,
- Multi-topic subscribing (just like addEventListener allows you to subscribe to different events),
- Expanded ideas: event buses, etc.
To expand your knowledge, you can review a number of JavaScript libraries that implement publish-subscribe in its bare form:
Go ahead and try to use them, break them and run the debugger in order to see what happens under the hood. Also, there is a number of great articles that describe this idea very well.
You can find the code from this story in the following GitHub repository:
Keep experimenting and tinkering—and don’t be afraid of the buzz words, they’re usually just regular code in disguise. And keep thinking.
See you!