1. 程式人生 > >你不容錯過的響應式程式設計介紹

你不容錯過的響應式程式設計介紹

呼,翻譯完後又花了些時間重新校對了一遍,刪掉了原文作者一些比較“矯情”的地方,也修改了一些段落,目的是為了讓全文讀起來更加通俗易懂。以前也做過些有趣的翻譯,比如翻譯Morphia的API文件。一來是為了鍛鍊一下自己閱讀英語文件的能力,二來是覺得響應式程式設計非常難懂,特別是它的思維。它解決問題的思路不是直接的解決,而是通過描述,讓問題在描述過程得到解決。如果你正在學習“響應式程式設計”、“函數語言程式設計”或“非同步程式設計”,那希望本文可以給你些啟示。

你或許會對學習“響應式程式設計”(函式化反應程式設計,FRP)非常感興趣。

但是學習FRP非常困難,主要是因為缺少好的學習資料。當我嘗試學習的時候,也努力的去找了很多教程。結果只找到少量的實用性指導,而且他們大多都過於簡單的描述了一些概念,並沒有描述如何用Rx構建一個完整的應用。說實在的,當你嘗試去理解一些函式的時候,FRP的API文件幾乎起不了什麼作用,譬如:

Rx.Observable.prototype.flatMapLatest(selector, [thisArg])Projects each element of an observable sequence into a new sequence of observable sequences by incorporating the element’s index and then transforms an observable sequence of observable sequences into an observable sequence producing values only from the most recent observable sequence.

我的天哪!

我曾經讀過兩本書,其中一本大篇幅介紹了FRP是什麼,另外一本只是教我們怎麼使用FRP的API。在Futurice工作期間,我需要在專案中使用FRP。幸運的是,當我陷入問題的時候,同事們給予了很大的幫助。最終通過使用FRP,我算是搞懂了它。哎,這真是一個艱難的方法。

學習FRP最艱難的地方是“FRP的思維(Thinking in FRP)”。這需要你在寫程式碼的時候,放棄習慣的程式設計方法,用新的思維去思考。我沒在網上找到這方面的資料,但我覺得應該要有一個實用性的教程來說明“用FRP來思考”並幫助你解決問題。一旦在思想層面有了覺悟,FRP的API就可以為你解決以後的問題。我希望這篇文章可以幫到你。

網上有大量糟糕的解析和定義:

說實在的,相比起你熟悉的“MV*”和程式語言,諸如“響應式”和“傳播改變”這類術語很具體傳達它們之間有什麼不同。當然我用的框架是“響應式”的,我說的“改變”是會傳播的。不然的話,也沒法說下去了。

好吧,讓我們開始正題吧。

這不是什麼新鮮的東西了。在前端程式設計中(用Javascript),監聽某個按鈕的點選事件,並在事件被觸發以後回撥一個函式做一些操作,這個過程就是非同步資料流程式設計,也就是FRP。FRP的靈感來源於細胞的激素刺激,你可以回想一下初中生物學的“生物應激”。我們可以為任何東西建立資料流(Stream),不僅僅侷限於click和hover事件。Stream是隨處可見的,任何東西都可以成為Stream:變數、使用者的輸入、屬性、快取、資料結構等等。舉個例子,微博的推薦(推薦好友,推薦好新聞)就是一種和click事件一樣的Stream,你可以監聽它的裡面(推薦事件)並按需作出響應。

單一的一個Stream可以用來作為另一個Stream的輸入,甚至多個Stream也可以輸入給某一個Stream。假設現在你有一個超牛逼的工具集,包含了很多功能可以讓你合併、建立和過濾Stream。那麼你就可以merge(合併)多個Stream,對Stream做你只感興趣的事件的filter(過濾),也可以把一個Streammap(對映)為另外一個新的Stream。

既然Stream是FRP的核心,那我們就從最熟悉的“click a button”來仔細的瞭解Stream。

zoom-圖1

從上面可以看出,Stream是一個不間斷的按照時間順序排列的event序列。它可以發出三樣訊號:值(value,對應於某些型別)、錯誤(error)和完成(completed)。只有當按鈕所在的視窗或者視窗被關閉的時候,才會發出“完成”訊號。

既然知道Stream會發出(emit)三種訊號,那麼我們就可以為其定義三個對應的執行函式非同步的捕捉並處理這三種訊號量。有時候,對於從Stream裡面發出的error和completed,你可以按照需要選擇捕捉處理或不捕捉處理。對Stream的“監聽”叫做“訂閱(subscribe)”,這些執行函式就是“觀察者(observeers)”,Stream就是被觀察的“主體(subject)”或者“可觀察序列(observable)”。這正是觀察者模式的設計體現。

在本教程的某些地方,我會用ASCII圖來表示Stream,就叫做StreamGraph吧:

--a---b-c---d---X---|->

a, b, c, d 事件對應的型別或者值
X 錯誤
| 完成
---> 時間軸

下面一起來做些有趣的嘗試:把“Stream(origin click event)”轉換為“Stream(counter click event)”。

使用FRP的時候,Stream會被拓展了很多方法,如mapfilterscan等。當呼叫其中一個方法時,如clickStream.map(f),它將返回一個“new Stream”。這意味著“origin Stream”沒有發生改變。在FRP裡,Stream具備恆定(immutability)的特性,說白了Stream是一個發生後即不可變的序列。所以clickStream.map(f).scan(g)這樣鏈式呼叫可以在Stream上操作。

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。map產生的流會被scan(g)方法掃描並用g(accumulated, current)來聚合,在例子中就是簡單地相加。最後的countStream統計了每次點選時,當前一共產生了多少次點選事件。

為了體現FRP的強大,假設現在需要一個double-click(雙擊)Stream(短時間內兩次或三次的單擊認為是雙擊)。深呼吸並回想你是怎麼用傳統的方式來實現的。我敢打賭一定讓人非常抓狂,因為會需要大量的變數去描述每個階段的狀態,還會用到定時處理。

但用FRP會讓事情變得非常簡單。事實上,僅需要4行邏輯程式碼。不過,我們先忽略程式碼來看個圖:

圖2

不用需要理解底層是如何實現的,只管用就是了。灰色框裡面的函式把一個Stram轉換為另外一個Stream。先用buffer(stream.throttle(250ms))判定出那些單擊可以歸為一次雙擊,從而獲得一個新的歸併後的單擊Stream。然後用map()做事件數量統計,獲得一個新的含有每個歸併單元中事件數量的Stream。最後用filter(x >= 2)來忽略數量為1的歸併單元。就這樣,通過3步操作獲得所需要的Stream。現在可以按照需要,通過subscribe對雙擊Stream進行監聽並做出響應。

我希望你會享受這種程式設計方式。這個例子僅僅是冰山一角:你可以用FRP實現的類庫如“Rx*”來做更多。

FRP提高了抽象的層次,因此你可以專注於業務邏輯裡面事件間的相互依賴,而不需要關心一大堆實現的細節。用FRP將會使程式碼變得更加簡潔。

FRP的優勢在富“UI事件和資料事件互動”的現代Web、移動應用得到了證明。10年前,Web頁面的互動基本上就是提交一個大表單到後端,然後在前端執行簡單的渲染。應用逐漸變得更加實時:修改單個表單域能夠自動觸發儲存到後臺,內容會根據個人的“喜好”匹配到相關使用者等等。

現今的應用需要通過豐富多樣的實時事件來提供高水平的使用者體驗,而FRP可以很好解決。

讓我們來點乾貨。通過一個真實的例子一步步的理解FRP。在教程的最後會給出所有的程式碼,同時瞭解每行程式碼在做什麼。

我用Javascript和RxJS作為工具,那是因為:Javascript是目前比較熟悉的語言,同時Rx*庫系列提供了對多種語言和平臺的(.NETJavaScalaClojureJavaScriptRubyPythonC++Objective-C/CocoaGroovy等等)支援。所以無論你用什麼工具,都可以按照本教程享受FRP的好處。

在微博裡,有個專門推薦新使用者給你關注的面板:

圖3

這個“推薦關注”的實現包含以下這些核心特點:

  • 啟動的時候,從API中載入並顯示3條其他賬戶資訊。
  • 點選“Refresh”按鈕時,重新載入3條其他賬戶資訊。
  • 點選每條賬戶資訊的“x”按鈕時,清掉當前這條賬戶,並顯示另外一條。

微博裡面對未通過認證的開發著不公開這個“推薦關注”的API,因此我們用Github的API來實現。

請求和響應

你會怎麼用FRP來解決這個問題呢?嗯,開始的時候(幾乎)把任何東西都流化。這幾乎成了FRP的魔咒。我們從最簡單的功能開始:“啟動的時候,從API中載入並顯示3條其他賬戶資訊。”。這裡沒有任何問題,就是簡單的(1)傳送一個請求,(2)接收一個響應,(3)渲染這個響應。因此,我們用Stream來代表(1)的請求。起初這感覺像殺雞用牛刀,但是我們需要從基本的東西開始,對吧?

啟動的時候只需要傳送一次請求,那麼對應的StreamModel(流模型)是一個只含有一個值的Stream。最後會發現啟動的時候會有很多請求,但目前只有一個:

--a------|->

a 'https://api.github.com/users'這個字串

這是一個請求地址(URL)Stream。這條Stream告訴了我們兩件事情:“什麼時候”和“是什麼”(When & What)。“什麼時候”意味著當產生事件(emit event)時要傳送請求,而“是什麼”說明了產生的事件(emitted event)是一串請求地址字元。

用“Rx*”建立Stream是非常簡單的。Stream的術語是“Observable(可觀察序列)”,這說明它是可以被其他人觀察的。但我發現這真是個愚蠢的名詞,所以我更喜歡稱它為“Stream(流)”

var requestStream = Rx.Observable.returnValue('https://api.github.com/users');

這裡現在有一個只含一個字串事件的Stream,但是沒有任何操作,所以我們需要新增一個處理即將到來的字串事件的函式。下面給requestStream加上subscribing

requestStream.subscribe(function(requestUrl){
	// 傳送請求
	jQuery.getJSON(requestUrl, function(responseData){
		// ...
	});
});

注意上面我們用了jQuery的Ajax回撥處理響應的結果。( ⊙ o ⊙ )!,等等,FRP不是擅長於處理非同步資料流嗎?能不能把jQuery的結果交給RxJS處理?感覺好像沒什麼問題,我們來試試:

requestStream.subscribe(function(requestUrl){
	// 響應也是個流
	var responseStream = Rx.Observable.create(function(observer){
		jQuery.getJSON(requestUrl)
    // 當jQuery成功呼叫以後,就把結果交給RxJS處理
		.done(function(response){ observer.onNext(response); })
    // 當jQuery失敗時,把失敗交給RxJS處理
		.fail(function(jqXHR, status, error){ observer.onError(error); })
    // 當jQuery完成時,告知RxJS呼叫完成的處理
		.always(function(){ observer.onCompleted(); })
	});

	responseStream.subscribe(function(response){
		// 為響應做處理
	});
});

因為我們需要傳送一個Ajax請求,所以我們就用jQuery包裝一下我們的RxJS。上面看起來非常直觀,而Rx.Observable.create()通過傳入一個包含observer引數的函式,會返回一個自定義的Stream。當Stream產生任何事件的時候,都會呼叫這個傳入的方法,並傳入當前observer。打擾一下,這是不是意味著Promise也是一個“Observable(可觀察序列)”?【注:作者這裡不用Stream,是為了更加官方的描述Promise。】

圖4

是的!

Observable是Promise++。在RxJS裡面,你可以很容易的把Promise轉換為Observable通過呼叫var stream = Rx.Observable.fromPromise(promise),所以我們來用用它。值得一提的是,Observable和Promises/A+是不相容的,但概念上並沒有衝突。你可以這樣理解,Promise就是Observable的單值版本。

可以看到RxJS比起jQuery這類框架實現的Promise要強大多了。當別人大肆吹捧Promises的時候,你給給他說說RxJS。

好吧,回到我們的例子來。你注意到下面這些問題了嗎?

  • 把一個subscribe()呼叫嵌入了另外一個subscribe()裡面,這可能會陷入“callback hell”
  • resposneStream緊密依賴於requestStream。【注:這裡涉及“關注點分離”】

哎呀,那麼多問題。幸虧,FRP提供了大量的操作函式來解決上面的問題。

現在相信你已經很清楚基礎函式map(f)了。這是一個把生產流(Stream A)裡面的所有值你拿出來執行f轉換,把轉換的結果放入到消費流(Stream B)中。例如,我們正好需要把請求地址(URL)對應的轉成一個響應的Stream(Promise可以包裝成Stream)。

var responseMetastream = requestStream.map(function(requestUrl){
	return Rx.Observable.fromPromise(jQuery.getJSON(requestUrl));
});

不過,上面的程式碼建立了一個怪獸:“metastream”。“metastream”的每個值是一個Stream的指標:每個值指向另外一個Stream【注:map轉換以後是流,但是流裡面的東西是指向Promise的指標】。在我們的例子中,每個請求URL都被對映為一個指標指向對應包含響應的promise流。

zoom-圖5

響應的“metastream”讓人看起來非常困惑,而且實際上我們需要的是一個包含Promise【注:Promise是流】的Stream,而不是一條包含Stream指標的“metastream”。向Flatmap先生說“你好”吧。flatmap()map()的一個“扁平化”處理版本,就像是從“主幹”流裡分出“支流”,然後對“支流”處理。【注:flatmap和map的對比可以看這裡,可以這樣理解:map就是在源流的每個事件上用一個“返回值的函式”做了計算並返回值,然後組合再返回新的流。而flatmap是在源流的每個事件上用一個“會迴流的函式”做了計算並返回流,然後把返回的流(子流)組合再返回新的流。】值得注意的時候,flatmap() 不是在修復map(),“metastream”也不是一個錯誤,它們都是真實的工具用於在FRP中解決非同步響應的問題。

var responseStream = requestStream.flatmap(function(requestUrl){
	return Rx.Observable.fromPromise(jQuery.getJSON(requestUrl));
});

zoom-圖6

很好。因為響應的Stream是基於請求的Stream而定義的,所以如果以後我們有更多的事件在請求的Stream中產生,就會有對應的事件在響應的Stream中產生。

requestStream:  --a-----b--c------------|->
responseStream: -----A--------B-----C---|->

小寫的是請求大寫的是響應

既然我們好不容易擁有了響應的Stream,那麼我們就可以渲染所接收的資料:

responseStream.subscribe(function(response){
	// 按照你的意願在DOM樹裡面渲染response物件
});

我們把前面所有的程式碼合在一起,那樣就是:

var requestStream = Rx.Observable.returnValue('https://api.github.com/users');

var responseStream = requestStream
  .flatMap(function(requestUrl) {
    return Rx.Observable.fromPromise(jQuery.getJSON(requestUrl));
  });

responseStream.subscribe(function(response) {
  // 按照你的意願在DOM樹裡面渲染response物件
});

重新整理按鈕

我沒有提及一件事情就是上面的響應返回的JSON制式的使用者資訊有100條。這個API僅僅允許我們傳頁偏移值,而不允許傳頁限制數,所以導致我們只能用3條資料物件而浪費97條。我們現在先忽略這些問題,後面將會看到如何快取這些響應。

每次重新整理按鈕被點選的時候,請求的Stream就會產生一個String事件。我們需要兩樣東西:

  1. 重新整理按鈕上產生點選事件Stream;
  2. 上述的重新整理按鈕的點選事件Stream可以改變請求的Stream。

可喜的是,RxJS具備相應的工具給DOM元素構建指定的事件的Stream:

var refreshButton = document.querySelector('.refresh');
var refreshClickStream = Rx.Observable.fromEvent(refreshButton, 'click');

接下來,讓重新整理按鈕的點選事件Stream改變請求的Stream。通過傳一個每次都隨機產生的引數作為偏移值傳送請求給Github: