指路Reactive Programming
來自: http://blog.leapoahead.com/2016/03/02/introduction-to-reactive-programming/
我在工作中采用Reactive Programming(RP)已經有一年了,對于這個“新鮮”的辭藻或許有一些人還不甚熟悉,這里就和大家說說關于RP我的理解。希望在讀完本文后,你能夠用Reactive Extension進行RP。
需要說明的是,我實在不知道如何翻譯Reactive Programming這個詞組,所以在本文中均用RP代替,而不是什么“響應式編程”、“反應式編程”。本文假定你對JavaScript及HTML5有初步的了解,如果有使用過,那么就再好不過了。
讓我們首先來想象一個很常見的交互場景。當用戶點擊一個頁面上的按鈕,程序開始在后臺執行一些工作(例如從網絡獲取數據)。在獲取數據期間,按鈕不能再被點擊,而會顯示成灰色的”disabled”狀態。當加載完成后,頁面展現數據,而后按鈕又可以再次使用。(如下面例子的這個load按鈕)
在這里我使用jQuery編寫了按鈕的邏輯,具體的代碼是這樣的。
var loading = false; $('.load').click(function () { loading = true; var $btn = $(this); $btn.prop('disabled', loading); $btn.text('Loading ...'); $.getJSON('https://www.reddit.com/r/cats.json') .done(function (data) { loading = false; $btn.prop('disabled', loading); $btn.text('Load'); $('#result').text("Got " + data.data.children.length + " results"); }); });
對應的HTML:
<button class="load">Load</button> <div id="result"></div>
不知道你有沒有注意到,在這里 loading 變量其實是完全可以不用存在的。而我寫出 loading 變量,就是為了抓住你的眼球。 loading 代表的是一個狀態,意思是“我的程序現在有沒有在后臺加載程序”。
另外還有幾個不是很明顯的狀態。比如按鈕的 disabled 狀態(由 $btn.prop('disabled') 獲得),以及按鈕的文字。在加載的時候,也就是 loading === true 的時候,按鈕的 disable 狀態會是 true ,而文字會是 Loading ... ;在不加載的時候, loading === false 成立,按鈕的 disabled 狀態就應該為 false ,而文字就是 Load 。
現在讓我們用靜態的圖來描述用戶點擊一次按鈕的過程。

如果用戶點擊很多次的按鈕的話,那么 loading 的值的變化將是這樣的。
loading: false -> true -> false -> true -> false -> true -> ...
類似像 loading 這樣的 狀態(state) 在應用程序中隨處可見,而且其值的變化可以不局限于兩個值。舉個栗子,假如我們現在設計微博的前端,一條微博的JSON數據形式如下:
var aWeibo = { user: 1, text: '我今天好高興啊!' };
另外有一個 weiboList 數組,存儲當前用戶所看到的微博。
var weiboList = [ {user: 1, text: '今天又出去玩了'}, {user: 2, text: '人有多大膽,地有多大產!'}, // ... ]
這當然是個極度精簡的模型了,真實的微博應用一定比這個復雜許多。但是有一個和 loading 狀態很類似的就是 weiboList ,因為我們都知道每過一段時間微博就會自動刷新,也就是說 weiboList 也在一直經歷著變化。
weiboList: [一些微博] -> [舊的微博,和一些新的微博] -> [更多的微博] -> ...
再次強調,無論是 weiboList 還是 loading ,它們都是應用程序的狀態。上面的用箭頭組成的示意圖僅僅是我們對狀態變化的一種展現形式(或者說建模)。然而,我們其實還可以用更加簡單的模型來表現它,而這個模型我們都熟悉 —— 數組。
如果它們都只是數組
如果說 loading 變化的過程就是一個數組,那么不妨把它寫作:
var loadingProcess = [false, true, false, true, false, ...]
為了表現出這是一個過程,我們將其重新命名為 loadingProcess 。不過它沒有什么不同,它是一個數組。而且我們還可以注意到,按鈕的 disabled 狀態的變化過程和 loadingProcess 的變化過程是一模一樣的。我們將 disabled 的變化過程命名為 disabledProcess 。
var disabledProcess = [false, true, false, true, false, ...]
那么如果將 loadingProcess 做下面的處理,我們將得到什么呢?
var textProcess = loadingProcess.map(function(loading) { return loading ? "Loading ..." : "Load" });
我們得到的將是按鈕上文字的狀態變化過程,也就是 $btn.text() 的值。我們將其命名為 textProcess 。在有了 textProcess 和 disabledProcess 之后,就可以直接對UI進行更新。在這里,我們不再需要使用到 loadingProcess 了。
disabledProcess.forEach(function (disabled) { $btn.prop('disabled', disabled); }); textProcess.forEach(function (text) { $btn.text(text); });
這個變換的過程看起來就像下圖。

在YY了那么久之后,你可能會說,不對啊!狀態的變化是 一段時間內 發生的事情,在程序一開始怎么可能就知道之后的全部狀態,并全部放到一個數組里面呢?是的,我們在之前刻意省略掉了一個重要的元素,也就是 時間(time) 。
時間都去哪兒啦?
loadingProcess 是如何得出的?當用戶觸發按鈕的點擊事件的時候, loadingProcess 會被置為 false ;而當HTTP請求完成的時候,我們將其置為 true 。在這里,用戶觸發點擊事件,和HTTP請求完成都是一個需要時間的過程。用戶的兩次點擊之間必定要有時間,就像這樣:
clickEvent … clickEvent …… clickEvent ….. clickEvent
兩個clickEvent之間一個點我們假設代表一秒鐘,用戶點擊的事件之間是由長度不同的時間間隔開的。
如果我們再嘗試用剛才的方法,把click事件表示成一個數組,就會覺得特別的古怪:
var clickEventProcess = [ clickEvent, clickEvent, clickEvent, clickEvent, clickEvent, ... ]
你會想,古怪之處在于,這里沒了時間的概念。其實不一定是這樣的。你覺得這里少了時間,只是因為你被我剛才的例子所迷惑了。你的腦袋里面可能是在想下面的這段代碼:
// 代碼A clickEventProcess.forEach(function (clickEvent) { // ... });
如果是下面這段代碼,我相信你再熟悉不過了,你還會覺得奇怪嗎?
// 代碼B document.querySelector('.load').addEventListener('click', function (clickEvent) { // ... });
代碼A中,我們所看到的是迭代器模式(Iterative Pattern)。所謂迭代器模式是對遍歷一個集合的算法所進行的抽象。對于一個數組、一個二叉樹和一個鏈表的遍歷算法各不相同,但我都可以用統一的一個接口來獲取遍歷的結果。 forEach 就是一個例子。
數組.forEach(function (元素) { /* ... */}); 二叉樹.forEach(function (元素) { /* ... */}); 鏈表.forEach(function (元素) { /* ... */});
雖然每個 forEach 的實現方式一定不同,但是只要接口(即 forEach 這個名字以及 元素 這個參數)一致,我就可以遍歷它們之中任何的一個,不管是數組、二叉樹還是二郎神。只要它們都是實現了 forEach 的集合。
下面這句話希望你仔細品味:
迭代器模式的一個最大的特點就是,數據是由你向集合索要過來的。
在使用迭代器的時候,我們其實就是在向集合要數據,而且每次都企圖一次性要完。
[1,2,3,4,5].forEach(function (num) { console.log(num); });
這就好像在對集合說,你把那五個數字給我吧,快點兒,一個接一個一次性給完。在生活中,就好像蛋糕店的服務員幫你切蛋糕一樣。你總是在和服務員說,麻煩你再給我下一塊,再給我下一塊……

而代碼B是截然相反的。在代碼B中,我們是在等待著數據被 推送 過來。又拿切蛋糕為例,這次就好像是你一言不發,而服務員一直跟你說,“這塊切好了,給你!”。

如果你對設計模式熟悉的話,你應該知道代碼B的模式叫做觀察者模式(Observer Pattern)。所謂觀察者模式,就是你觀察集合,當集合告訴你它有元素要給你的時候,你就可以拿到元素。 addEventListener 本身就是一個很好的觀察者模式的例子。
在切蛋糕的例子中,當你雙目注視的服務員,耳朵豎得高高的,你就是在對服務員進行觀察。每當服務員告訴你,有一塊新的蛋糕切好了,你就過去拿。
迭代器和觀察者的對立和統一
迭代器模式和觀察者模式本質上是對稱的。它們相同的地方在于:
- 都是對集合的遍歷(都是那塊大蛋糕)
- 每次都只獲得一個元素
他們完全相反的地方只有一個:迭代器模式是你主動去要數據,而觀察者模式是數據的提供方(切蛋糕的服務員)把數據推給你。他們其實完全可以用同樣的接口來實現,例如前面的例子中的代碼A,我們來回顧一下:
// 代碼A clickEventProcess.forEach(function (clickEvent) { // ... });
對于代碼B,我們可以進行如下的改寫
// 代碼B clickEventProcess.forEach = function(fn) { this._fn = fn; }; clickEventProcess.onNext = function(clickEvent) { this._fn(clickEvent); }; document.querySelector('.load').addEventListener('click', function (clickEvent) { clickEventProcess.onNext(clickEvent); }); clickEventProcess.forEach(function (clickEvent) { // ... });
我們解讀一下修改過的代碼B。
- clickEventProcess.forEach : 它接受一個回調函數作為參數,并存儲在 this._fn 里面。這是為了將來在 clickEventProcess.onNext 里面調用
- 當clickEvent觸發的時候,調用 clickEventProcess.onNext(clickEvent) ,將 clickEvent 傳給了 clickEventProcess
- clickEventProcess.onNext 將 clickEvent 傳給了 this._fn ,也就是之前我們所存儲的回調函數
- 回調函數正確地接收到新的點擊事件
來看看現在發生了什么……迭代器模式和觀察者模式用了同樣的接口(API)實現了!因為,它們本質上就是對稱的,能用同樣的API將兩件原本對稱的事物給統一起來,這是可以做到的。
迭代器模式,英文叫做Iterative,由你去迭代數據;而觀察者模式,要求你對數據來源的事件做出反應(react),所以其實也可以稱作是Reactive(能做出反應的)。Iterative和Reactive,互相對稱,相愛不相殺。
話外音:在這里我沒有明確提及,實際上在觀察者模式中數據就是以流(stream)的形式出現。而所謂數組,不過就是無需等待,馬上就可以獲得所有元素的流而已。從流的角度來理解Iterative和Reactive的對稱性也可以,這里我們不多加闡述。
Reactive Extension
上面代碼B中我們最后獲得了一個新的 clickEventProcess ,它不是一個真正意義上的集合,卻被我們抽象成了一個集合,一個被時間所間隔開的集合。 Rx.js,也稱作Reactive Extension 提供給了抽象出這樣集合的能力,它把這種集合命名為 Observable (可觀察的)。
添加Rx.js及其插件Rx-DOM.js。我們需要Rx-DOM.js,因為它提供網絡通訊相關的Observable抽象,稍后我們就會看到。
<script src="https://cdn.rawgit.com/Reactive-Extensions/RxJS/master/dist/rx.all.min.js"></script> <script src="https://cdn.rawgit.com/Reactive-Extensions/RxJS-DOM/master/dist/rx.dom.min.js"></script>
只需要很簡單的一句工廠函數(factory method)就可以將鼠標點擊的事件抽象成一個 Observable 。Rx.js提供一個全局對象 Rx , Rx.Observable 就是Observable的類。
var loadButton = document.querySelector('.load'); var resultPanel = document.getElementById('result'); var click$ = Rx.Observable.fromEvent(loadButton, 'click');
click$ 就是前面的 clickEventProcess ,在這里我們將所有的Observable變量名結尾都添加 $ 。點擊事件是像下面這樣子的:
[click ... click ........ click .. click ..... click ..........]
每個點擊事件后應該發起一個網絡請求。
var response$$ = click$.map(function () { // 為了不處理跨域問題,這里換了個地址,返回和前面是一樣的 return Rx.DOM.get('http://output.jsbin.com/tafulo.json'); });
Rx.DOM.ajax.get 會發起HTTP GET請求,并返回響應(Response)的Observable。因為每次請求只會有一個響應,所以響應的Observable實際上只會有一個元素。它將會是這樣的:
[...[.....response].......[........response]......[....response]...........[....response]......[....response]]
由于這是Observable的Observable,就好像二維數組一樣,所以在變量名末尾是 $$ 。 若將click$和response$$的對應關系勾勒出來,會更加清晰。

然而,我們更希望的是直接獲得Response的Observble,而不是Response的Observble的Observble。Rx.js提供了 .flatMap 方法,可以將二維的Observable“攤平”成一維。你可以參考 underscore.js里面的 flatten 方法 ,只不過它是將普通數組攤平,而非將Observable攤平。
var response$ = click$.flatMap(function () { return Rx.DOM.get('http://output.jsbin.com/tafulo.json'); });
圖示:

對于每一個click事件,我們都想將 loading 置為 true ;而對于每次HTTP請求返回,則置為 false 。于是,我們可以將 click$ 映射成一個純粹的只含有 true 的Observable,但其每個 true 到達的事件都和點擊事件到達的時間一樣;對于 response$ ,同樣,將其映射呈只含有 false 的Observable。最后,我們將兩個Observable結合在一起(用 Rx.Observable.merge ),最終就可以形成 loading$ ,也就是剛才我們的 loadingProcess 。
此外, $loading 還應有一個初始值,可以用 startWith 方法來指定。
var loading$ = Rx.Observable.merge( click$.map(function () { return true; }), response$.map(function () { return false; }) ).startWith(false);
整個結合的過程如圖所示

有了 loading$ 之后,我們很快就能得出剛才我們所想要的 textProcess 和 enabledProcess 。 enabledProcess 和 loading$ 是一致的,就無需再生成,只要生成 textProcess 即可(命名為 text$ )。
var text$ = loading$.map(function (loading) { return loading ? 'Loading ...' : 'Load'; });
在Rx.js中沒有 forEach 方法,但有一個更好名字的方法,和 forEach 效用一樣,叫做 subscribe 。這樣我們就可以更新按鈕的樣式了。
text$.subscribe(function (text) { $loadButton.text(text); }); loading$.subscribe(function (loading) { $loadButton.prop('disabled', loading); }); // response$ 還可以拿來更新#result的內容 response$.subscribe(function (data) { $resultPanel.text('Got ' + JSON.parse(data.response).data.children.length + ' items'); });
這樣就用完全Reactive的方式重構了之前我們的例子。
在我們重構后的方案中,消滅了所有的狀態。狀態都被Observable抽象了出去。于是,這樣的代碼如果放在一個函數里面,這個函數將是沒有副作用的純函數。關于純函數、函數式編程,可以閱讀我的文章 《“函數是一等公民”背后的含義》 。
總結
本文從應用的角度入手解釋了Reactive Programming的思路。Observable作為對狀態的抽象,統一了Iterative和Reactive,淡化了兩者之間的邊界。當然,最大的好處就是我們用抽象的形式將煩人的狀態趕出了視野,取而代之的是可組合的、可變換的Observable。
事物之間的對立統一通常很難找到。實際上,即使是在《設計模式》這本書中,作者們也未曾看到迭代器模式和觀察者模式之間存在的對稱關系。在UI設計領域,我們更多地和用戶驅動、通信驅動出來的事件打交道,這才促成了這兩個模式的合并。