函式響應式程式設計
相信你們在學習響應式程式設計這個新技術的時候都會充滿了好奇,特別是它的一些變體,例如:Rx系列、Bacon.js、RAC等等……
在缺乏優秀資料的前提下,響應式程式設計的學習過程將滿是荊棘。起初,我試圖尋找一些教程,卻只找到少量的實踐指南,而且它們講的都非常淺顯,從來沒人接受圍繞響應式程式設計建立一個完整知識體系的挑戰。此外,官方文件通常也不能很好地幫助你理解某些函式,因為它們通常看起來很繞,不信請看這裡:
Rx.Observable.prototype.flatMapLatest(selector, [thisArg])
根據元素下標,將可觀察序列中每個元素一一對映到一個新的可觀察序列當中,然後...%…………%&¥#@@……&**(暈了)
天吶,這簡直太繞了!
我讀過兩本相關的書,一本只是在給你描繪響應式程式設計的偉大景象,而另一本卻只是深入到如何使用響應式庫而已。我在不斷的構建專案過程中把響應式程式設計瞭解的透徹了一些,最後以這種艱難的方式學完了響應式程式設計。在我工作公司的一個實際專案中我會用到它,當我遇到問題時,還可以得到同事的支援。
學習過程中最難的部分是如何以響應式的方式來思考,更多的意味著要摒棄那些老舊的命令式和狀態式的典型程式設計習慣,並且強迫自己的大腦以不同的正規化來運作。我還沒有在網路上找到任何一個教程是從這個層面來剖析的,我覺得這個世界非常值得擁有一個優秀的實踐教程來教你如何以響應式程式設計的方式來思考
"什是響應式程式設計?"
網路上有一大堆糟糕的解釋和定義,如Wikipedia上通常都是些非常籠統和理論性的解釋,而Stackoverflow上的一些規範的回答顯然也不適合新手來參考,Reactive Manifesto看起來也只像是拿給你的PM或者老闆看的東西,微軟的Rx術語"Rx = Observables + LINQ + Schedulers" 也顯得太過沉重,而且充滿了太多微軟式的東西,反而給我們帶來更多疑惑。相對於你使用的MV*框架以及你鍾愛的程式語言,"Reactive"和"Propagation of change"這樣的術語並沒有傳達任何有意義的概念。當然,我的view框架能夠從model做出反應,我的改變當然也會傳播,如果沒有這些,我的介面根本就沒有東西可渲染。
所以,不要再扯這些廢話了。
響應式程式設計就是與非同步資料流互動的程式設計正規化
一方面,這已經不是什麼新事物了。事件匯流排(Event Buses)或一些典型的點選事件本質上就是一個非同步事件流(asynchronous event stream),這樣你就可以觀察它的變化並使其做出一些反應(do some side effects)。響應式是這樣的一個思路:除了點選和懸停(hover)的事件,你還可以給其他任何事物建立資料流。資料流無處不在,任何東西都可以成為一個數據流,例如變數、使用者輸入、屬性、快取、資料結構等等。舉個栗子,你可以把你的微博訂閱功能想象成跟點選事件一樣的資料流,你可以監聽這樣的資料流,並做出相應的反應。
最重要的是,你會擁有一些令人驚豔的函式去結合、建立和過濾任何一組資料流。 這就是"函數語言程式設計"的魔力所在。一個資料流可以作為另一個資料流的輸入,甚至多個資料流也可以作為另一個資料流的輸入。你可以合併兩個資料流,也可以過濾一個數據流得到另一個只包含你感興趣的事件的資料流,還可以對映一個資料流的值到一個新的資料流裡。
資料流是整個響應式程式設計體系中的核心,要想學習響應式程式設計,當然要先走進資料流一探究竟了。那現在就讓我們先從熟悉的"點選一個按鈕"的事件流開始
一個資料流是一個按時間排序的即將發生的事件(Ongoing events ordered in time)的序列。如上圖,它可以發出3種不同的事件(上一句已經把它們叫做事件):一個某種型別的值事件,一個錯誤事件和一個完成事件。當一個完成事件發生時,在某些情況下,我們可能會做這樣的操作:關閉包含那個按鈕的視窗或者檢視元件。
我們只能非同步捕捉被髮出的事件,使得我們可以在發出一個值事件時執行一個函式,發出錯誤事件時執行一個函式,發出完成事件時執行另一個函式。有時候你可以忽略後兩個事件,只需聚焦於如何定義和設計在發出值事件時要執行的函式,監聽這個事件流的過程叫做訂閱,我們定義的函式叫做觀察者,而事件流就可以叫做被觀察的主題(或者叫被觀察者)。你應該察覺到了,對的,它就是觀察者模式。
上面的示意圖我們也可以用ASCII碼的形式重新畫一遍,請注意,下面的部分教程中我們會繼續使用這幅圖:
--a---b-c---d---X---|->
a, b, c, d 是值事件
X 是錯誤事件
| 是完成事件
---> 是時間線(軸)
現在你對響應式程式設計事件流應該非常熟悉了,為了不讓你感到無聊,讓我們來做一些新的嘗試吧:我們將建立一個由原始點選事件流演變而來的一種新的點選事件流。
首先,讓我們來建立一個記錄按鈕點選次數的事件流。在常用的響應式庫中,每個事件流都會附有一些函式,例如 map
,filter
, scan
等,當你呼叫這其中的一個方法時,比如clickStream.map(f)
,它會返回基於點選事件流的一個新事件流。它不會對原來的點選事件流做任何的修改。這種特性叫做不可變性(immutability),而且它可以和響應式事件流搭配在一起使用,就像豆漿和油條一樣完美的搭配。這樣我們可以用鏈式函式的方式來呼叫,例如:clickStream.map(f).scan(g)
:
clickStream: ---c----c--c----c------c-->
vvvvv map(c becomes 1) vvvv
---1----1--1----1------1-->
vvvvvvvvv scan(+) vvvvvvvvv
counterStream: ---1----2--3----4------5-->
map(f)
函式會根據你提供的f
函式把原事件流中每一個返回值分別對映到新的事件流中。在上圖的例子中,我們把每一次點選事件都對映成數字1,scan(g)
函式則把之前對映的值聚集起來,然後根據x
= g(accumulated, current)
演算法來作相應的處理,而本例的g
函式其實就是簡單的加法函式。然後,當一個點選事件發生時,counterStream
函式則上報當前點選事件總數。
為了展示響應式程式設計真正的魅力,我們假設你有一個"雙擊"事件流,為了讓它更有趣,我們假設這個事件流同時處理"三次點選"或者"多次點選"事件,然後深吸一口氣想想如何用傳統的命令式和狀態式的方式來處理,我敢打賭,這麼做會相當的討厭,其中還要涉及到一些變數來儲存狀態,並且還得做一些時間間隔的調整。
而用響應式程式設計的方式處理會非常的簡潔,實際上,邏輯處理部分只需要四行程式碼。但是,當前階段讓我們現忽略程式碼的部分,無論你是新手還是專家,看著圖表思考來理解和建立事件流將是一個非常棒的方法。
圖中,灰色盒子表示將上面的事件流轉換下面的事件流的函式過程,首先根據250毫秒的間隔時間(event silence, 譯者注:無事件發生的時間段,上一個事件發生到下一個事件發生的間隔時間)把點選事件流一段一隔開,再將每一段的一個或多個點選事件新增到列表中(這就是這個函式:buffer(stream.throttle(250ms))
所做的事情,當前我們先不要急著去理解細節,我們只需專注響應式的部分先)。現在我們得到的是多個含有事件流的列表,然後我們使用了map()
中的函式來算出每一個列表長度的整數數值對映到下一個事件流當中。最後我們使用了過濾filter(x
>= 2)
函式忽略掉了小於1
的整數。就這樣,我們用了3步操作生成了我們想要的事件流,接下來,我們就可以訂閱("監聽")這個事件並作出我們想要的操作了。
我希望你能感受到這個示例的優雅之處。當然了,這個示例也只是響應式程式設計魔力的冰山一角而已,你同樣可以將這3步操作應用到不同種類的事件流中去,例如,一串API響應的事件流。另一方面,你還有非常多的函式可以使用。
"我為什麼要採用響應式程式設計?"
響應式程式設計可以加深你程式碼抽象的程度,讓你可以更專注於定義與事件相互依賴的業務邏輯,而不是把大量精力放在實現細節上,同時,使用響應式程式設計還能讓你的程式碼變得更加簡潔。
特別對於現在流行的webapps和mobile apps,它們的 UI 事件與資料頻繁地產生互動,在開發這些應用時使用響應式程式設計的優點將更加明顯。十年前,web頁面的互動是通過提交一個很長的表單資料到後端,然後再做一些簡單的前端渲染操作。而現在的Apps則演變的更具有實時性:僅僅修改一個單獨的表單域就能自動的觸發儲存到後端的程式碼,就像某個使用者對一些內容點了贊,就能夠實時反映到其他已連線的使用者一樣,等等。
當今的Apps都含有豐富的實時事件來保證一個高效的使用者體驗,我們就需要採用一個合適的工具來處理,那麼響應式程式設計就正好是我們想要的答案。
以響應式程式設計方式思考的例子
讓我們深入到一些真實的例子,一個能夠一步一步教你如何以響應式程式設計的方式思考的例子,沒有虛構的示例,沒有一知半解的概念。在這個教程的末尾我們將產生一些真實的函式程式碼,並能夠知曉每一步為什麼那樣做的原因(知其然,知其所以然)。
我選了JavaScript和RxJS來作為本教程的程式語言,原因是:JavaScript是目前最多人熟悉的語言,而Rx系列的庫對於很多語言和平臺的運用是非常廣泛的,例如(.NET, Java, Scala, Clojure, JavaScript, Ruby, Python, C++, Objective-C/Cocoa, Groovy等等。所以,無論你用的是什麼語言、庫、工具,你都能從下面這個教程中學到東西(從中受益)。
實現一個推薦關注(Who to follow)的功能
在Twitter裡有一個UI元素向你推薦你可以關注的使用者,如下圖:
我們將聚焦於模仿它的主要功能,它們是:
- 開始階段,從API載入推薦關注的使用者賬戶資料,然後顯示三個推薦使用者
- 點選重新整理,載入另外三個推薦使用者到當前的三行中顯示
- 點選每一行的推薦使用者上的'x'按鈕,清楚當前被點選的使用者,並顯示新的一個使用者到當前行
- 每一行顯示一個使用者的頭像並且在點選之後可以連結到他們的主頁。
我們可以先不管其他的功能和按鈕,因為它們是次要的。因為Twitter最近關閉了未經授權的公共API呼叫,我們將用Github獲取使用者的API代替,並且以此來構建我們的UI。
如果你想先看一下最終效果,這裡有完成後的程式碼。
Request和Response
在Rx中是怎麼處理這個問題呢?,在開始之前,我們要明白,(幾乎)一切都可以成為一個事件流,這就是Rx的準則(mantra)。讓我們從最簡單的功能開始:"開始階段,從API載入推薦關注的使用者賬戶資料,然後顯示三個推薦使用者"。其實這個功能沒什麼特殊的,簡單的步驟分為: (1)發出一個請求,(2)獲取響應資料,(3)渲染響應資料。ok,讓我們把請求作為一個事件流,一開始你可能會覺得這樣做有些誇張,但別急,我們也得從最基本的開始,不是嗎?
開始時我們只需做一次請求,如果我們把它作為一個數據流的話,它只能成為一個僅僅返回一個值的事件流而已。一會兒我們還會有很多請求要做,但當前,只有一個。
--a------|->
a就是字串:'https://api.github.com/users'
這是一個我們要請求的URL事件流。每當發生一個請求時,它將告訴我們兩件事:什麼時候和做了什麼事(when and what)。什麼時候請求被執行,什麼時候事件就被髮出。而做了什麼就是請求了什麼,也就是請求的URL字串。
在Rx中,建立返回一個值的事件流是非常簡單的。其實事件流在Rx裡的術語是叫"被觀察者",也就是說它是可以被觀察的,但是我發現這名字比較傻,所以我更喜歡把它叫做事件流。
var requestStream = Rx.Observable.just('https://api.github.com/users');
但現在,這只是一個字串的事件流而已,並沒有做其他操作,所以我們需要在發出這個值的時候做一些我們要做的操作,可以通過訂閱(subscribing)這個事件來實現。
requestStream.subscribe(function(requestUrl) { // execute the request jQuery.getJSON(requestUrl, function(responseData) { // ... }); }
注意到我們這裡使用的是JQuery的AJAX回撥方法(我們假設你已經很瞭解JQuery和AJAX了)來的處理這個非同步的請求操作。但是,請稍等一下,Rx就是用來處理非同步資料流的,難道它就不能處理來自請求(request)在未來某個時間響應(response)的資料流嗎?好吧,理論上是可以的,讓我們嘗試一下。
requestStream.subscribe(function(requestUrl) { // execute the request var responseStream = Rx.Observable.create(function (observer) { jQuery.getJSON(requestUrl) .done(function(response) { observer.onNext(response); }) .fail(function(jqXHR, status, error) { observer.onError(error); }) .always(function() { observer.onCompleted(); }); }); responseStream.subscribe(function(response) { // do something with the response }); }
Rx.Observable.create()
操作就是在建立自己定製的事件流,且對於資料事件(onNext()
)和錯誤事件(onError()
)都會顯示的通知該事件每一個觀察者(或訂閱者)。我們做的只是小小的封裝一下jQuery
Ajax Promise而已。等等,這是否意味者jQuery Ajax Promise本質上就是一個被觀察者呢(Observable)?
是的。
Promise++就是被觀察者(Observable),在Rx裡你可以使用這樣的操作:var stream = Rx.Observable.fromPromise(promise)
,就可以很輕鬆的將Promise轉換成一個被觀察者(Observable),非常簡單的操作就能讓我們現在就開始使用它。不同的是,這些被觀察者都不能相容Promises/A+,但理論上並不衝突。一個Promise就是一個只有一個返回值的簡單的被觀察者,而Rx就遠超於Promise,它允許多個值返回。
這樣更好,這樣更突出被觀察者至少比Promise強大,所以如果你相信Promise宣傳的東西,那麼也請留意一下響應式程式設計能勝任些什麼。
現在回到示例當中,你應該能快速發現,我們在subscribe()
方法的內部再次呼叫了subscribe()
方法,這有點類似於回撥地獄(callback
hell),而且responseStream
的建立也是依賴於requestStream
的。在之前我們說過,在Rx裡,有很多很簡單的機制來從其他事件流的轉化並創建出一些新的事件流,那麼,我們也應該這樣做試試。
現在你需要了解的一個最基本的函式是map(f)
,它可以從事件流A中取出每一個值,並對每一個值執行f()
函式,然後將產生的新值填充到事件流B。如果將它應用到我們的請求和響應事件流當中,那我們就可以將請求的URL對映到一個響應Promises上了(偽裝成資料流)。
var responseMetastream = requestStream .map(function(requestUrl) { return Rx.Observable.fromPromise(jQuery.getJSON(requestUrl)); });
一個響應的metastream,看起來確實讓人容易困惑,看樣子對我們一點幫助也沒有。我們只想要一個簡單的響應資料流,每一個發出的值是一個簡單的JSON物件就行,而不是一個'Promise' 的JSON物件。ok,讓我們來見識一下另一個函式:Flatmap,它是map()
函式的另一個版本,它比metastream更扁平。一切在"主軀幹"事件流發出的事件都將在"分支"事件流中發出。Flatmap並不是metastreams的修復版,metastreams也不是一個bug。它倆在Rx中都是處理非同步響應事件的好工具、好幫手。
var responseStream = requestStream .flatMap(function(requestUrl) { return Rx.Observable.fromPromise(jQuery.getJSON(requestUrl)); });
很贊,因為我們的響應事件流是根據請求事件流定義的,如果我們以後有更多事件發生在請求事件流的話,我們也將會在相應的響應事件流收到響應事件,就如所期待的那樣:
requestStream: --a-----b--c------------|->
responseStream: -----A--------B-----C---|->
(小寫的是請求事件流, 大寫的是響應事件流)
現在,我們終於有響應的事件流了,並且可以用我們收到的資料來渲染了:
responseStream.subscribe(function(response) { // render `response` to the DOM however you wish });
讓我們把所有程式碼合起來,看一下:
var requestStream = Rx.Observable.just('https://api.github.com/users'); var responseStream = requestStream .flatMap(function(requestUrl) { return Rx.Observable.fromPromise(jQuery.getJSON(requestUrl)); }); responseStream.subscribe(function(response) { // render `response` to the DOM however you wish });
重新整理按鈕
我還沒提到本次響應的JSON資料是含有100個使用者資料的list,這個API只允許指定頁面偏移量(page offset),而不能指定每頁大小(page size),我們只用到了3個使用者資料而浪費了其他97個,現在可以先忽略這個問題,稍後我們將學習如何快取響應的資料。
每當重新整理按鈕被點選,請求事件流就會發出一個新的URL值,這樣我們就可以獲取新的響應資料。這裡我們需要兩個東西:點選重新整理按鈕的事件流(準則:一切都能作為事件流),我們需要將點選重新整理按鈕的事件流作為請求事件流的依賴(即點選重新整理事件流會引起請求事件流)。幸運的是,RxJS已經有了可以從事件監聽者轉換成被觀察者的方法了。
var refreshButton = document.querySelector('.refresh'); var refreshClickStream = Rx.Observable.fromEvent(refreshButton, 'click');
因為重新整理按鈕點選事件不會攜帶將要請求的API的URL,我們需要將每次的點選對映到一個實際的URL上,現在我們將請求事件流轉換成了一個點選事件流,並將每次的點選對映成一個隨機的頁面偏移量(offset)引數來組成API的URL。
var requestStream = refreshClickStream .map(function() { var randomOffset = Math.floor(Math.random()*500); return 'https://api.github.com/users?since=' + randomOffset; });
因為我比較笨而且也沒有使用自動化測試,所以我剛把之前做好的一個功能搞爛了。這樣,請求在一開始的時候就不會執行,而只有在點選事件發生時才會執行。我們需要的是兩種情況都要執行:剛開始開啟網頁和點選重新整理按鈕都會執行的請求。
我們知道如何為每一種情況做一個單獨的事件流:
var requestOnRefreshStream = refreshClickStream .map(function() { var randomOffset = Math.floor(Math.random()*500); return 'https://api.github.com/users?since=' + randomOffset; }); var startupRequestStream = Rx.Observable.just('https://api.github.com/users');
但是我們是否可以將這兩個合併成一個呢?沒錯,是可以的,我們可以使用merge()
方法來實現。下圖可以解釋merge()
函式的用處:
stream A: ---a--------e-----o----->
stream B: -----B---C-----D-------->
vvvvvvvvv merge vvvvvvvvv
---a-B---C--e--D--o----->
現在做起來應該很簡單:
var requestOnRefreshStream = refreshClickStream .map(function() { var randomOffset = Math.floor(Math.random()*500); return 'https://api.github.com/users?since=' + randomOffset; }); var startupRequestStream = Rx.Observable.just('https://api.github.com/users'); var requestStream = Rx.Observable.merge( requestOnRefreshStream, startupRequestStream );
還有一個更乾淨的寫法,省去了中間事件流變數:
var requestStream = refreshClickStream .map(function() { var randomOffset = Math.floor(Math.random()*500); return 'https://api.github.com/users?since=' + randomOffset; }) .merge(Rx.Observable.just('https://api.github.com/users'));
甚至可以更簡短,更具有可讀性:
var requestStream = refreshClickStream .map(function() { var randomOffset = Math.floor(Math.random()*500); return 'https://api.github.com/users?since=' + randomOffset; }) .startWith('https://api.github.com/users');
startWith()
函式做的事和你預期的完全一樣。無論你的輸入事件流是怎樣的,使用startWith(x)
函式處理過後輸出的事件流一定是一個x
開頭的結果。但是我沒有總是重複程式碼(
DRY),我只是在重複API的URL字串,改進的方法是將 startWith()
函式挪到refreshClickStream
那裡,這樣就可以在啟動時,模擬一個重新整理按鈕的點選事件了。
var requestStream = refreshClickStream.startWith('startup click') .map(function() { var randomOffset = Math.floor(Math.random()*500); return 'https://api.github.com/users?since=' + randomOffset; });
不錯,如果你倒回到"搞爛了的自動測試"的地方,然後再對比這兩個地方,你會發現我僅僅是加了一個startWith()
函式而已。
用事件流將3個推薦的使用者資料模型化
直到現在,在響應事件流(responseStream)的訂閱(subscribe()
)函式發生的渲染步驟裡,我們只是稍微提及了一下推薦關注的UI。現在有了重新整理按鈕,我們就會出現一個問題:當你點選了重新整理按鈕,當前的三個推薦關注使用者沒有被清楚,而只要響應的資料達到後我們就拿到了新的推薦關注的使用者資料,為了讓UI看起來更漂亮,我們需要在點選重新整理按鈕的事件發生的時候清楚當前的三個推薦關注的使用者。
refreshClickStream.subscribe(function() { // clear the 3 suggestion DOM elements });
不,老兄,還沒那麼快。我們又出現了新的問題,因為我們現在有兩個訂閱者在影響著推薦關注的UI DOM元素(另一個是responseStream.subscribe()
),這看起來並不符合關注分離(Separation
of concerns)原則,還記得響應式程式設計的原則麼?
現在,讓我們把推薦關注的使用者資料模型化成事件流形式,每個被髮出的值是一個包含了推薦關注使用者資料的JSON物件。我們將把這三個使用者資料分開處理,下面是推薦關注的1號使用者資料的事件流:
var suggestion1Stream = responseStream .map(function(listUsers) { // get one random user from the list return listUsers[Math.floor(Math.random()*listUsers.length)]; });
其他的,如推薦關注的2號使用者資料的事件流suggestion2Stream
和推薦關注的3號使用者資料的事件流suggestion3Stream
都可以方便的從suggestion1Stream
複製貼上就好。這裡並不是重複程式碼,只是為讓我們的示例更加簡單,而且我認為這是一個思考如何避免重複程式碼的好案例。
Instead of having the rendering happen in responseStream's subscribe(), we do that here:
suggestion1Stream.subscribe(