指路Reactive Programming

oblicalow 8年前發布 | 15K 次閱讀 Reactive JavaScript開發

來自: 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按鈕)

JS Bin on jsbin.com

在這里我使用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 本身就是一個很好的觀察者模式的例子。

在切蛋糕的例子中,當你雙目注視的服務員,耳朵豎得高高的,你就是在對服務員進行觀察。每當服務員告訴你,有一塊新的蛋糕切好了,你就過去拿。

迭代器和觀察者的對立和統一

迭代器模式和觀察者模式本質上是對稱的。它們相同的地方在于:

  1. 都是對集合的遍歷(都是那塊大蛋糕)
  2. 每次都只獲得一個元素

他們完全相反的地方只有一個:迭代器模式是你主動去要數據,而觀察者模式是數據的提供方(切蛋糕的服務員)把數據推給你。他們其實完全可以用同樣的接口來實現,例如前面的例子中的代碼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。

  1. clickEventProcess.forEach : 它接受一個回調函數作為參數,并存儲在 this._fn 里面。這是為了將來在 clickEventProcess.onNext 里面調用
  2. 當clickEvent觸發的時候,調用 clickEventProcess.onNext(clickEvent) ,將 clickEvent 傳給了 clickEventProcess
  3. clickEventProcess.onNext 將 clickEvent 傳給了 this._fn ,也就是之前我們所存儲的回調函數
  4. 回調函數正確地接收到新的點擊事件

來看看現在發生了什么……迭代器模式和觀察者模式用了同樣的接口(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的方式重構了之前我們的例子。

JS Bin on jsbin.com

在我們重構后的方案中,消滅了所有的狀態。狀態都被Observable抽象了出去。于是,這樣的代碼如果放在一個函數里面,這個函數將是沒有副作用的純函數。關于純函數、函數式編程,可以閱讀我的文章 《“函數是一等公民”背后的含義》

總結

本文從應用的角度入手解釋了Reactive Programming的思路。Observable作為對狀態的抽象,統一了Iterative和Reactive,淡化了兩者之間的邊界。當然,最大的好處就是我們用抽象的形式將煩人的狀態趕出了視野,取而代之的是可組合的、可變換的Observable。

事物之間的對立統一通常很難找到。實際上,即使是在《設計模式》這本書中,作者們也未曾看到迭代器模式和觀察者模式之間存在的對稱關系。在UI設計領域,我們更多地和用戶驅動、通信驅動出來的事件打交道,這才促成了這兩個模式的合并。

 本文由用戶 oblicalow 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!