Angular 從0到1:Rx--隱藏在 Angular 中的利劍

接灰的電子產品發表於2016-12-26

第一節:初識Angular-CLI
第二節:登入元件的構建
第三節:建立一個待辦事項應用
第四節:進化!模組化你的應用
第五節:多使用者版本的待辦事項應用
第六節:使用第三方樣式庫及模組優化用
第七節:給元件帶來活力
Rx--隱藏在 Angular 中的利劍
Redux你的 Angular 應用
第八節:查缺補漏大合集(上)
第九節:查缺補漏大合集(下)

Rx(Reactive Extension -- 響應式擴充套件 reactivex.io )最近在各個領域都非常火。其實Rx這個貨是微軟在好多年前針對C#寫的一個開源類庫,但好多年都不溫不火,一直到Netflix針對Java平臺做出了RxJava版本後才在開源社群熱度飛速躥升。

這裡還有個小故事,Netflix之所以做RxJava完全是一個偶然。箇中緣由是由於Netflix的系統越做越複雜,大家都絞盡腦汁琢磨怎麼才能從這些複雜邏輯的地獄中把系統拯救出來。一天,一個從微軟跳槽過來的員工和主管說,我們原來在微軟做的一個叫Rx的東東挺好的,可以非常簡單的處理這些邏輯。主管理都沒理,心想微軟那套東西肯定又臃腫又不好用,從來沒聽說過微軟有什麼好的開源產品。但那位前微軟的哥們鍥而不捨,非常執著,不斷和組內員工和主管遊說,宣傳這個Rx思想有多牛X。終於有一天,大家受不了了,說,這麼著吧,給你個機會,你給大家仔細講講這個Rx,我們討論看看到底適不適合。於是這哥們一頓噴,把大家都驚住了,微軟竟然有這麼好的東西。但是這東西是.Net的,怎麼辦呢,那就寫一個吧(此處略去高山仰止的3000字)。

八卦講完,進入正題,那麼什麼叫響應式程式設計呢?這裡引用一下Wikipedia的解釋:

英文原文:In computing, reactive programming is a programming paradigm oriented around data flows and the propagation of change. This means that it should be possible to express static or dynamic data flows with ease in the programming languages used, and that the underlying execution model will automatically propagate changes through the data flow.

我的翻譯:在計算領域,響應式程式設計一種面向資料流和變化傳播的程式設計正規化。這意味著可以在程式語言中很方便地表達靜態或動態的資料流,而相關的計算模型會自動將變化的值通過資料流進行傳播。

這都說的什麼啊?沒關係,概念永遠是抽象的,我們來舉幾個例子。比如說在傳統的程式設計中 a=b+c,表示將表示式的結果賦給a,而之後改變b或c 的值不會影響a。但在響應式程式設計中,a的值會隨著b或c的更新而更新。

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
傳統程式設計中b,c的變化不會影響a

那麼用響應式程式設計方法寫出來就是這個樣子,可以看到隨著b和c的變化a也會隨之變化。

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
響應式程式設計版本的a=b+c

看出來一些不一樣的思維方式了嗎?響應式程式設計需要描述資料流,而不是單個點的資料變數,我們需要把資料的每個變化匯聚成一個資料流。如果說傳統程式設計方式是基於離散的點,那麼響應式程式設計就是線。

上面的程式碼雖然很短,但體現出Rx的一些特點

  1. Lamda表示式,對,就是那個看上去像箭頭的東西 => 。你可以把它想象成一個資料流的指向,我們從箭頭左方取得資料流,在右方做一系列處理後或者輸出成另一個資料流或者做一些其他對於資料的操作。
  2. 操作符:這個例子中的 from, zip 都是操作符。Rx中有太多的操作符,從大類上講分為:建立類操作符、變換類操作符、過濾類操作符、合併類操作符、錯誤處理類操作符、工具類操作符、條件型操作符、數學和聚集類操作符、連線型操作符等等。

Rx再體驗

還是從例子開始,我們逐漸的來熟悉Rx。
為了更直觀的看到Rx的效果,推薦大家去JSBin這個線上Javascript IDE jsbin.com 去實驗我們下面的練習。這個IDE非常方便,一共有5個功能視窗:HTML、CSS、Javascript、Console和Output

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
JSBin線上IDE

首先在HTML中引入Rx類庫,然後定義一個id為todo的文字輸入框:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>JS Bin</title>
  <script src="https://unpkg.com/@reactivex/rxjs@5.0.0-beta.7/dist/global/Rx.umd.js"></script>
</head>
<body>
<input id="todo" type="text"/>
</body>
</html>複製程式碼

在Javascript標籤中選擇 ES6/Babel,因為這樣可以直接使用ES6的語法,在文字框中輸入以下javascript。在RxJS領域一般在Observable型別的變數後面加上$標識這是一個“流變數”(由英文Stream得來,Observable就是一個Stream,所以用$標識),不是必須的,但是屬於約定俗成。

let todo = document.getElementById('todo');
let input$ = Rx.Observable.fromEvent(todo, 'keyup');
input$.subscribe(input => console.log(input.target.value));複製程式碼

如果Console視窗預設沒有開啟的話,請點選 Console 標籤,然後選中右側的 Run with JS 旁邊的Auto-run js核取方塊。在Output視窗中應該可以看到一個文字輸入框,在這個輸入框中輸入任意你要試驗的字元,觀察Console

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
Console和Output視窗

這幾行程式碼很簡單:首先我們得到HTML中id為todo的輸入框物件,然後定義一個觀察者物件將todo這個輸入框的keyup事件轉換成一個資料流,最後訂閱這個資料流並在console中輸出我們接收到的input事件的值。我們從這個例子中可以觀察到幾個現象:

  1. 資料流:你每次在輸入框中輸入時都會有新的資料被推送過來。本例中,你會發現連續輸入“1,2,3,4”,在console的輸出是“1,12,123,1234”,也就是說每次keyup事件我們都得到了完整的輸入框中的值。而且這個資料流是無限的,只要我們不停止訂閱,它就會一直在那裡待命。
  2. 我們觀察的是todo上發生的keyup這個事件,那如果我一直按著某個鍵不放會怎麼樣呢?你的猜測是對的,一直按著的時候,資料流沒有更新,直到你抬起按鍵為止(你看到截圖裡面有2條一模一樣的含有多個5的資料是因為我用的Surface Pro截圖時的快捷鍵也被截獲了,但由於是控制鍵所以文字內容沒有改變)

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
一直按著5不放幾秒之後的輸出

如果觀察的足夠仔細的話,你會發現console中輸出的值其實是 input.target.value,我們觀察的物件其實是id為todo的這個物件上發生的keyup事件(Rx.Observable.fromEvent(todo, 'keyup'))。那麼其實在訂閱的程式碼段中的input其實是keyup事件才對。好,我們看看到底是什麼,將 console.log(input.target.value) 改寫成 console.log(input),看看會怎樣呢?是的,我們得到的確實是KeyboardEvent

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
事件被輸出到Console

不太過癮?那麼我們再來做幾個小練習,首先將程式碼改成下面的樣子,其實不用我講,你應該也可以猜得到,這是要過濾出 keyCode=32 的事件,keyCode是Ascii碼,那麼這就是要把空格濾出來

let todo = document.getElementById('todo');
let input$ = Rx.Observable.fromEvent(todo, 'keyup');
input$
  .filter(ev=>ev.keyCode===32)
  .subscribe(ev=>console.log(ev.target.value));複製程式碼

結果我們看到了,按123456789都沒有反應,直到按了空格

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
只在空格鍵抬起時觸發的資料流

你可能一直在奇怪,我們最終只對輸入框的值有興趣,能不能資料流只傳值過來呢?當然可以,使用map這個變換類操作符就可以完成這個轉換了

let todo = document.getElementById('todo');
let input$ = Rx.Observable.fromEvent(todo, 'keyup');
input$
  .map(ev=>ev.target.value)
  .subscribe(value=>console.log(value));複製程式碼

map這個操作符做的事情就是允許你對原資料流中的每一個元素應用一個函式,然後返回並形成一個新的資料流,這個資料流中的每一個元素都是原來的資料流中的元素應用函式後的值。比如下面的例子,對於原資料流中的每個數應用一個函式10*x,也就是擴大了10倍,形成一個新的資料流。

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
map變換操作符

常見操作

最常見的兩個操作符我們上面已經瞭解了,我們繼續再來認識新的操作符。類似 .map(ev=>ev.target.value) 的場景太多了,以至於rxjs團隊搞出來一個專門的操作符來應對,這個操作符就是 pluck。這個操作符專業從事從一系列巢狀的屬性種把值提取出來形成新的流。比如上面的例子可以改寫成下面的程式碼,效果是一樣的。那麼如果其中某個屬性為空怎麼辦?這個操作符負責返回一個 undefined 作為值加入流中。

let todo = document.getElementById('todo');
let input$ = Rx.Observable.fromEvent(todo, 'keyup');
input$
  .pluck('target', 'value')
  .subscribe(value=>console.log(value));複製程式碼

下面我們稍微給我們的頁面加點料,除了輸入框再加一個按鈕

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>JS Bin</title>
  <script src="https://unpkg.com/@reactivex/rxjs@5.0.0-beta.7/dist/global/Rx.umd.js"></script>
</head>
<body>
  <input id="todo" type="text"/>
  <button id="addBtn">Add</button>
</body>
</html>複製程式碼

在Javascript中我們同樣方法得到按鈕的DOM物件以及宣告對此按鈕點選事件的觀察者:

let addBtn = document.getElementById('addBtn');
let buttonClick$ = Rx.Observable
                      .fromEvent(addBtn, 'click')
                      .mapTo('clicked');複製程式碼

由於點選事件沒有什麼可見的值,所以我們利用一個操作符叫 mapTo 把對應的每次點選轉換成字元 clicked。其實它也是一個 map 的簡化操作。

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
mapTo操作符將每次點選轉換成一個

合併類操作符

combineLatest操作符

既然現在我們已經有了兩個流,應該試驗一下合併類操作符了,先來試試 combineLatest,我們合併了按鈕點選事件的資料流和文字框輸入事件的資料流,並且返回一個物件,這個物件有兩個屬性,第一個是按鈕事件資料流的值,第二個是文字輸入事件資料流的值。也就是說應該是類似 { ev: 'clicked', input: '1'} 這樣的結構。

Rx.Observable.combineLatest(buttonClick$, input$, (ev, input)=>{
  return {
    ev: ev,
    input: input
  }
})
  .subscribe(value => console.log(value))複製程式碼

那看看結果如何,在文字輸入框輸入1,沒反應,再輸入2,還是沒反應

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
CombineLatest實驗一:先輸入文字

那我們點選一下按鈕試試,這回有結果了,但有點沒明白為什麼是12,輸入的資料流應該是: 1,12,... 但那個1怎麼丟了呢?

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
CombineLatest實驗二:點選按鈕

再來文字框輸入3,4看看,這回倒是都出來了

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
CombineLatest實驗二:再次輸入

我們來解釋一下combineLatest的機制就會明白了,如下圖所示,上面的2條線是2個源資料流(我們分別叫它們源1和源2吧),經過combineLatest操作符後產生了最下面的資料流(我們稱它為結果流)。

當源1的資料流發射時,源2沒有資料,這時候結果流也不會有資料產生,當源2發射第一個資料(圖中A)後,combineLatest操作符做的處理是,把A和源1的最近產生的資料(圖中2)組合在一起,形成結果流的第一個資料(圖中2A)。當源2產生第二個資料(圖中B)時,源1這時沒有新的資料產生,那麼還是用源1中最新的資料(圖中2)和源2中最新的資料(圖中B)組合。

也就是說 combineLatest 操作符其實是在組合2個源資料流中選擇最新的2個資料進行配對,如果其中一個源之前沒有任何資料產生,那麼結果流也不會產生資料。

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
CombineLatest操作符

講到這裡,有童鞋會問,原理是明白了,但什麼樣的實際需求會需要這個操作符呢?其實有很多,我這裡只舉一個小例子,現在健身這麼熱,比如說我們做一個簡單的BMI計算器,BMI的計算公式是:體重(公斤)/(身高身高)(米米)。那麼我們在頁面給出兩個輸入框和一個用於顯示結果的div:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>JS Bin</title>
  <script src="https://unpkg.com/@reactivex/rxjs@5.0.0-beta.7/dist/global/Rx.umd.js"></script>
</head>
<body>
  Weight: <input type="number" id="weight"> kg
  <br/>
  Height: <input type="number" id="height"> cm
  <br/>
  Your BMI is <div id="bmi"></div>
</body>
</html>複製程式碼

那麼在JS中,我們想要達成的結果是隻有兩個輸入框都有值的時候才能開始計算BMI,這時你發現combineLatest的邏輯不要太順溜啊。

let weight = document.getElementById('weight');
let height = document.getElementById('height');
let bmi = document.getElementById('bmi');

let weight$ = Rx.Observable
                .fromEvent(weight, 'input')
                .pluck('target', 'value');

let height$ = Rx.Observable
                .fromEvent(height, 'input')
                .pluck('target', 'value');

let bmi$ = Rx.Observable
              .combineLatest(weight$, height$, (w, h) => w/(h*h/100/100));

bmi$.subscribe(b => bmi.innerHTML=b);複製程式碼

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
簡單的BMI計算器

zip操作符

除了 combineLatest ,Rxjs還提供了多個合併類的操作符,我們再試驗一個 zip 操作符。 zipcombineLatest 非常像,但重要的區別點在於 zip 嚴格的需要多個源資料流中的每一個的相同順序的元素配對。

比如說還是上面的例子,zip 要求源1的第一個資料和源2的第一個資料組成一對,產生結果流的第一個資料;源1的第二個資料和源2的第二個資料組成一對,產生結果流的第二個資料。而 combineLatest 不需要等待另一個源資料流產生資料,只要有一個產生,結果流就會產生。

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
zip操作符有對齊的特性

zip 這個詞在英文中有拉鍊的意思,記住這個有助於我們理解這個操作符,就像拉鍊一樣,它需要拉鍊兩邊的齒一一對應。從效果角度上講,這個操作符有減緩發射速度的作用,因為它會等待合併序列中最慢的那個。

下面我們還是看個例子,在我寫第七章的使用Bing Image API變換背景時,我最開始的想法是取得圖片陣列後,把這個陣列中的元素每隔一段時間傳送出去一個,這樣元件端就不用關心圖片變化的邏輯,只要服務發射一個地址,我就載入就行了。我就是用zip來實現的,我們在這個邏輯中有2個源資料流:基於一個陣列生成的資料流以及一個時間間隔資料流。前者的發射速度非常快,後者則速度均勻,我們希望按後者的速度對齊前者,以達到每隔一段時間發射前者的資料的目的。

   yieldByInterval(items, time) {
     return Observable.from(items).zip(
       Observable.interval(time),
       (item, index) => item
     );
   }複製程式碼

為了更好的讓大家體會,我改寫一個純javascript版本,可以在JSBin上面直接跑的,它的本質邏輯和上面講的相同:

let greetings = ['Hello', 'How are you', 'How are you doing'];
let time = 3000;
let item$ = Rx.Observable.from(greetings);
let interval$ = Rx.Observable.interval(time);

Rx.Observable.zip(
    item$,
    interval$,
    (item, index) => {
      return {
        item: item,
        index: index
      }
    }
  )
  .subscribe(result => 
             console.log(
              'item: ' + result.item + 
              ' index: ' + result.index + 
              ' at ' + new Date()));複製程式碼

我們看到結果如下圖所示,每隔3000毫秒,陣列中的歡迎文字被輸出一次。

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
zip操作符示例

這兩個操作符應該是Rx中最常用的2個合併類操作符了。其他的操作符大家可以去 reactivex.io/documentati… 檢視,注意不是所有的操作符RxJS都有。而且RxJS 5.0 目前整體的趨勢是減少不必要的以及冗餘的操作符,所以我們只介紹最常用的一些操作符。

建立類操作符

通常來講,Rx團隊不鼓勵新手自己從0開始建立Observable,因為狀態太複雜,會遺漏一些問題。Rx鼓勵的是通過已有的大量建立類轉換操作符來去建立Observable。我們其實之前已經見過一些了,包括 fromfromEvent

from操作符

from 可以支援從陣列、類似陣列的物件、Promise、iterable 物件或類似Observable的物件(其實這個主要指ES2015中的Observable)來建立一個Observable。

這個操作符應該是可以建立Observable的操作符中最常使用的一個,因為它幾乎可以把任何物件轉換成Observable。

var array = [10, 20, 30];
var result$ = Rx.Observable.from(array);
result$.subscribe(x => console.log(x));複製程式碼

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
from轉換一個陣列為Observable

fromEvent操作符

這個操作符是專門為事件轉換成Observable而製作的,非常強大且方便。對於前端來說,這個方法用於處理各種DOM中的事件再方便不過了。

var click$ = Rx.Observable.fromEvent(document, 'click');
click$.subscribe(x => console.log(x));複製程式碼

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
fromEvent轉換事件為Observable

fromEventPattern

我們經常會遇到一些已有的程式碼,這些程式碼和類庫往往不受我們的控制,無法重構或代價太大。我們需要在這種情況下可以利用Rx的話,就需要大量的可以從原有的程式碼中可以轉換的方法。addXXXHandler和removeXXXHandler就是大家以前經常使用的一種模式,那麼在Rx中也提供了對應的方法可以轉換,那就是

function addClickHandler(handler) {
  document.addEventListener('click', handler);
}

function removeClickHandler(handler) {
  document.removeEventListener('click', handler);
}

var click$ = Rx.Observable.fromEventPattern(
  addClickHandler,
  removeClickHandler
);
click$.subscribe(x => console.log(x));複製程式碼

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
fromEventPattern專門處理addHandler/removeHandler

defer操作符

defer 是直到有訂閱者之後才建立Observable,而且它為每個訂閱者都會這樣做,也就是說其實每個訂閱者都是接收到自己的單獨資料流序列。

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
defer操作符為每個訂閱者單純建立序列

Rx.Observable.defer(()=>{
  let result = doHeavyJob();
  return result?'success':'failed';
})
  .subscribe(x=>console.log(x))

function doHeavyJob(){
  setTimeout(function() {console.log('doing something');}, 2000);
  return true;
}複製程式碼

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
defer惰性建立Observable

Interval

Rx提供內建的可以建立和計時器相關的Observable方法,第一個是Interval,它可以在指定時間間隔傳送整數的自增長序列。

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
Interval在指定時間間隔傳送整數序列

例如下面程式碼,我們每隔500毫秒傳送一個整數,這個數列是無窮的,我們取前三個好了:

let source = Rx.Observable
    .interval(500 /* ms */)
    .take(3);

let subscription = source.subscribe(
    function (x) {
        console.log('Next: ' + x);
    },
    function (err) {
        console.log('Error: ' + err);
    },
    function () {
        console.log('Completed');
    });複製程式碼

那麼輸出是

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
Interval每隔500毫秒傳送一個整數,取前三個的結果

這裡大家可能注意到我們沒有采用箭頭的方式,而是用傳統的寫法,寫了 function(x){...} ,哪種方式其實都可以,箭頭方式會更簡單。

另一個需要注意的地方是,在subscribe方法中我們多了2個引數:一個處理異常,一個處理完成。Rx認為所有的資料流會有三個狀態:next,error和completed。這三個函式就是分別處理這三種狀態的,當然如果我們不寫某個狀態的處理,也就意味著我們認為此狀態不需要特別處理。而且有些序列是沒有completed狀態的,因為是無限序列。本例中,如果我們去掉 .take(3) 那麼completed是永遠無法觸發的。

Timer

下面我們來看看Timer,一共有2種形式的Timer,一種是指定時間後返回一個序列中只有一個元素(值為0)的Observable。

//這裡指定一開始的delay時間
//也可以輸入一個Date,比如“2016-12-31 20:00:00”
//這樣變成了在指定的時間觸發
let source = Rx.Observable.timer(2000);

let subscription = source.subscribe(
    x => console.log('Next: ' + x),
    err => console.log('Error: ' + err),
    () => console.log('Completed'));複製程式碼

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
不指定間隔時間時,Timer只發射1個元素

第二種Timer很類似於Interval。除了第一個引數是一開始的延遲時間,第二個引數是間隔時間,也就是說,在一開始的延遲時間後,每隔一段時間就會返回一個整數序列。這個和Interval基本一樣除了Timer可以指定什麼時間開始(延遲時間)。

var source = Rx.Observable.timer(2000, 100)
    .take(3);

var subscription = source.subscribe(
    x => console.log('Next: ' + x),
    err => console.log('Error: ' + err),
    () => console.log('Completed'));複製程式碼

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
第二種Timer和Interval很類似

當然還有其他建立類的操作符,大家可以去 reactivex.io/documentati… 查閱自行試驗一下。

過濾類操作符

之前我們見過好幾個過濾類操作符:filter,distinct,take和debounce。

filter

Filter操作只允許資料流中滿足其predicate測試的元素髮射出去,這個predicate函式接受3個引數:

  1. 原始資料流元素
  2. 索引,這個是指該元素在源資料流中的位置(從0開始)
  3. 源Observable物件

如下的程式碼將0-5中偶數過濾出來:

let source = Rx.Observable.range(0, 5)
  .filter(function (x, idx, obs) {
    return x % 2 === 0;
  });

let subscription = source.subscribe(
    x => console.log('Next: ' + x),
    err => console.log('Error: ' + err),
    () => console.log('Completed'));複製程式碼

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
Filter是可以依據一個函式來過濾資料流

debounceTime

對於一些發射頻率比較高的資料流,我們有時會想給它安個“整流器”。比如在一個搜尋框中,輸入一些字元後希望出現一些搜尋建議,這是個非常好的功能,很多時候可以減少使用者的輸入。

但是由於這些搜尋建議需要聯網完成資料的傳遞,如果太頻繁操作的話,對於使用者的資料流量和伺服器的效能承載都是有副作用的。所以我們一般希望在使用者連續快速輸入時不去搜尋,而是等待有相對較長的間隔時再去搜尋。

下面的程式碼從輸入上做了這樣的一個“整流器”,濾掉了間隔時間小於400毫米的輸入事件(輸入本身不受影響),只有使用者出現較明顯的停頓時才把輸入值發射出來。

let todo = document.getElementById('todo');
let input$ = Rx.Observable.fromEvent(todo, 'keyup');
input$
  .debounceTime(400)
  .subscribe(input => console.log(input.target.value));複製程式碼

快速輸入“12345”,在這種情況下得到的是一條資料

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
快速輸入12345得到一條資料

但如果不應用debounceTime,我們得到5條記錄

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
不應用debounceTime的結果

其他的過濾類操作符也很有趣,比如Distinct就是可以把重複的元素過濾掉,skip就可以跳過幾個元素等等,可以自行研究,這裡就不一一舉例了。

Rx的操作符實在太多了,我只能列舉一些較常見的給大家介紹一下,其他的建議大家去官方文件學習。

Angular2中的內建支援

Angular2中對於Rx的支援是怎麼樣的呢?先試驗一下吧,簡單粗暴的一個元件模版頁面

<p>
  {{clock}}
</p>複製程式碼

和在元件中定義一個簡單粗暴的成員變數

import { Component } from '@angular/core';

import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/interval';

@Component({
  selector: 'app-playground',
  templateUrl: './playground.component.html',
  styleUrls: ['./playground.component.css']
})
export class PlaygroundComponent{
  clock = Observable.interval(1000);

  constructor() { }

}複製程式碼

搞定!開啟瀏覽器,顯示了一個 [object Object],暈倒。

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
直接把Observable物件顯示在頁面中的效果:啥也沒有

當然經過前面的學習,我們知道Observable是個非同步資料流,我們可以把程式碼改寫一下,在訂閱方法中去賦值就一切ok了。

import { Component } from '@angular/core';

import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/interval';

@Component({
  selector: 'app-playground',
  templateUrl: './playground.component.html',
  styleUrls: ['./playground.component.css']
})
export class PlaygroundComponent{
  clock: number;

  constructor() { 
    Observable.interval(1000)
      .subscribe(value => this.clock= value)
  }

}複製程式碼

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
利用subscribe賦值成功顯示的效果

但是這樣做還是有一個問題,我們加入一個do操作符,在每次訂閱前去記錄就會發現一些問題。當我們離開頁面再回來,每次進入都會建立一個新的訂閱,,但原有的沒有釋放。

Observable.interval(1000)
      .do(_ => console.log('observable created'))
      .subscribe(value => this.clock= value);複製程式碼

觀察console中在‘observable created’之前的數字和頁面顯示的數字,大概是頁面每增加1,console的數字增加2,這說明我們後面執行著2個訂閱。

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
原有的訂閱沒有釋放掉

原因是我們沒有在頁面銷燬時取消訂閱,那麼我們利用生命週期的onDestroy來完成這一步:

import { Component, OnDestroy } from '@angular/core';

import { Observable } from 'rxjs/Observable';
import { Subscription } from 'rxjs/Subscription';
import 'rxjs/add/observable/interval';

@Component({
  selector: 'app-playground',
  templateUrl: './playground.component.html',
  styleUrls: ['./playground.component.css']
})
export class PlaygroundComponent implements OnDestroy{
  clock: number;
  subscription: Subscription;

  constructor() { 
    this.subscription = Observable.interval(1000)
      .do(_ => console.log('observable created'))
      .subscribe(value => this.clock= value);
  }

  ngOnDestroy(){
    if(this.subscription !== undefined)
      this.subscription.unsubscribe();
  }
}複製程式碼

現在再來觀察,同樣進入並離開再進入頁面後,頁面每增加1,console也會增加1。

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
通過onDestory中unsubscribe來防止記憶體洩露

Async管道

現在看起來還是挺麻煩的,有沒有更簡單的方法呢?答案當然是肯定的:Angular2提供一個管道叫:async,有了這個管道,我們無需管理瑣碎的取消訂閱,以及訂閱了。

讓我們回到最開始的簡單粗暴版本,模版檔案稍微改寫一下

<p>
  {{ clock | async }}
</p>複製程式碼

這個 | async 是什麼東東?async是Angular2提供的一種轉換器,叫管道(Pipe)。

每個應用開始的時候差不多都是一些簡單任務:獲取資料、轉換它們,然後把它們顯示給使用者。一旦取到資料,我們可以把它們原始值的結果直接顯示。 但這種做法很少能有好的使用者體驗。比如,幾乎每個人都更喜歡簡單的日期格式,幾月幾號星期幾,而不是原始字串格式 —— Fri Apr 15 1988 00:00:00 GMT-0700 (Pacific Daylight Time)。通過管道我們可以把不友好的值轉換成友好的值顯示在頁面中。

Angular內建了一些管道,比如DatePipe、UpperCasePipe、LowerCasePipe、CurrencyPipe和PercentPipe。它們全都可以直接用在任何模板中。Async管道也是內建管道之一。

當然這樣在頁面寫完管道後,我們的元件版本也迴歸了簡單粗暴版本:

import { Component, OnDestroy } from '@angular/core';

import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/interval';

@Component({
  selector: 'app-playground',
  templateUrl: './playground.component.html',
  styleUrls: ['./playground.component.css']
})
export class PlaygroundComponent {
  clock = Observable.interval(1000).do(_=>console.log('observable created'));

  constructor() { }

}複製程式碼

現在開啟瀏覽器,看一下頁面的效果

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
使用async pipe的版本

你做這個試驗時很可能會遭遇一個錯誤,說async pipe無法找到

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
aync pipe無法找到的錯誤

這種情況一般是由於CommonModule沒有匯入造成的,遇到這種錯誤,請匯入CommonModule。

Rx版本的Todo

這一節我們通過改造我們的待辦事項應用來進一步體會Rx的威力。首先我們把TodoService中原來採用的Promise方式都替換成Observable的方式。

在進行改動之前,我們來重新分析一下邏輯:我們原有的實現方式中,元件中保留了一個todos陣列的本地拷貝,伺服器API邏輯在Service中完成。其實元件最好不關心邏輯,即使是本地拷貝的邏輯,也不應該放到元件中。元件本身的資料都是監聽Service中的資料變化而得到的。

那麼我們應該在Service中建立本地的記憶體“資料庫”,我們叫它 dataStore 吧。這個“資料庫”中只有一個“表”:todos。

//TodoService.ts
  private dataStore: {  // todos的記憶體“資料庫”
    todos: Todo[]
  };複製程式碼

為了讓元件可以監聽到這個資料的變化,我們需要一個Observable,但是在Service中我們還需要寫入變化,這樣的話,我們選擇一個既是Observable又是Observer的物件,在Rx中,Subject就是這樣的物件:

//TodoService.ts
...
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
@Injectable()
export class TodoService {
    ...
    private _todos: BehaviorSubject<Todo[]>; 
    constructor(private http: Http, @Inject('auth') private authService) {
        this.dataStore = { todos: [] };
        this._todos = new BehaviorSubject<Todo[]>([]);
    }
    ...
  get todos(){
    return this._todos.asObservable();
  }
  ...複製程式碼

我們使用了一個BehaviorSubject,它的一個特點是儲存了發射的最新的值,這樣無論什麼訂閱者訂閱時都會得到“當前值”。我們之前通過ReplaySubject也實現過類似功能,但Replay是可以快取多個值的。

我們在構造中分別初始化了 dataStore_todos,然後提供了一個get的屬性方法讓其他訂閱者可以訂閱todos的變化。在這個屬性方法中,我們把Subject轉成了Observable(通過.asObservable())。

那麼我們如何寫入變化呢?拿增加一個代辦事項( addTodo(desc:string) )的邏輯來看一下吧。

  addTodo(desc:string){
    let todoToAdd = {
      id: UUID.UUID(),
      desc: desc,
      completed: false,
      userId: this.userId
    };
    this.http
      .post(this.api_url, JSON.stringify(todoToAdd), {headers: this.headers})
      .map(res => res.json() as Todo)
      .subscribe(todo => {
        this.dataStore.todos = [...this.dataStore.todos, todo];
        this._todos.next(Object.assign({}, this.dataStore).todos);
      });
  }複製程式碼

由於 this.http.post 返回的本身就是Observable,所以我們不再需要 .toPromise() 這個方法了。直接用 map 將response的資料流轉換成Todo的資料流,然後更新本地資料,然後使用Subject的 next 方法(this._todos.next)把本地資料寫入資料流。這個next的含義就是讓推送一個新元素到資料流。

按照這種邏輯,我們把整個 TodoService 改造成下面的樣子。

import { Injectable, Inject } from '@angular/core';
import { Http, Headers } from '@angular/http';
import { UUID } from 'angular2-uuid';

import { Observable } from 'rxjs/Observable';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';

import { Todo } from '../domain/entities';

@Injectable()
export class TodoService {

  private api_url = 'http://localhost:3000/todos';
  private headers = new Headers({'Content-Type': 'application/json'});
  private userId: string;
  private _todos: BehaviorSubject<Todo[]>; 
  private dataStore: {  // todos的記憶體“資料庫”
    todos: Todo[]
  };

  constructor(private http: Http, @Inject('auth') private authService) {
    this.authService.getAuth()
      .filter(auth => auth.user != null)
      .subscribe(auth => this.userId = auth.user.id);
    this.dataStore = { todos: [] };
    this._todos = new BehaviorSubject<Todo[]>([]);
  }

  get todos(){
    return this._todos.asObservable();
  }

  // POST /todos
  addTodo(desc:string){
    let todoToAdd = {
      id: UUID.UUID(),
      desc: desc,
      completed: false,
      userId: this.userId
    };
    this.http
      .post(this.api_url, JSON.stringify(todoToAdd), {headers: this.headers})
      .map(res => res.json() as Todo)
      .subscribe(todo => {
        this.dataStore.todos = [...this.dataStore.todos, todo];
        this._todos.next(Object.assign({}, this.dataStore).todos);
      });
  }
  // PATCH /todos/:id 
  toggleTodo(todo: Todo) {
    const url = `${this.api_url}/${todo.id}`;
    const i = this.dataStore.todos.indexOf(todo);
    let updatedTodo = Object.assign({}, todo, {completed: !todo.completed});
    return this.http
      .patch(url, JSON.stringify({completed: !todo.completed}), {headers: this.headers})
      .subscribe(_ => {
        this.dataStore.todos = [
          ...this.dataStore.todos.slice(0,i),
          updatedTodo,
          ...this.dataStore.todos.slice(i+1)
        ];
        this._todos.next(Object.assign({}, this.dataStore).todos);
      });
  }
  // DELETE /todos/:id
  deleteTodo(todo: Todo){
    const url = `${this.api_url}/${todo.id}`;
    const i = this.dataStore.todos.indexOf(todo);
    this.http
      .delete(url, {headers: this.headers})
      .subscribe(_ => {
        this.dataStore.todos = [
          ...this.dataStore.todos.slice(0,i),
          ...this.dataStore.todos.slice(i+1)
        ];
        this._todos.next(Object.assign({}, this.dataStore).todos);
      });
  }
  // GET /todos
  getTodos(){
    this.http.get(`${this.api_url}?userId=${this.userId}`)
      .map(res => res.json() as Todo[])
      .do(t => console.log(t))
      .subscribe(todos => this.updateStoreAndSubject(todos));
  }
  // GET /todos?completed=true/false
  filterTodos(filter: string) {
    switch(filter){
      case 'ACTIVE': 
        this.http
          .get(`${this.api_url}?completed=false&userId=${this.userId}`)
          .map(res => res.json() as Todo[])
          .subscribe(todos => this.updateStoreAndSubject(todos));
          break;
      case 'COMPLETED': 
        this.http
          .get(`${this.api_url}?completed=true&userId=${this.userId}`)
          .map(res => res.json() as Todo[])
          .subscribe(todos => this.updateStoreAndSubject(todos));
          break;
      default:
        this.getTodos();
    }
  }
  toggleAll(){
    this.dataStore.todos.forEach(todo => this.toggleTodo(todo));
  }
  clearCompleted(){
    this.dataStore.todos
      .filter(todo => todo.completed)
      .forEach(todo => this.deleteTodo(todo));
  }
  private updateStoreAndSubject(todos) {
    this.dataStore.todos = [...todos];
    this._todos.next(Object.assign({}, this.dataStore).todos);
  }
}複製程式碼

接下來我們看一下 src/app/todo/todo.component.ts,由於大部分邏輯已經在 TodoService 中實現了,我們可以刪除客戶端的邏輯程式碼:

import { Component, OnInit, Inject } from '@angular/core';
import { Router, ActivatedRoute, Params } from '@angular/router';
import { TodoService } from './todo.service';
import { Todo } from '../domain/entities';

import { Observable } from 'rxjs/Observable';

@Component({
  templateUrl: './todo.component.html',
  styleUrls: ['./todo.component.css']
})
export class TodoComponent implements OnInit {

  todos : Observable<Todo[]>;

  constructor(
    @Inject('todoService') private service,
    private route: ActivatedRoute,
    private router: Router) {}

  ngOnInit() {
    this.route.params
      .pluck('filter')
      .subscribe(filter => {
        this.service.filterTodos(filter);
        this.todos = this.service.todos;
      })
  }
  addTodo(desc: string) {
    this.service.addTodo(desc);
  }
  toggleTodo(todo: Todo) {
    this.service.toggleTodo(todo);
  }
  removeTodo(todo: Todo) {
    this.service.deleteTodo(todo);
  } 
  toggleAll(){
    this.service.toggleAll();
  }
  clearCompleted(){
    this.service.clearCompleted();
  }
}複製程式碼

可以看到 addTodotoggleTodoremoveTodotoggleAllclearCompleted 基本上已經沒有業務邏輯程式碼了,只是簡單呼叫service的方法而已。

還有一個比較明顯的變化是,我們接收路由引數的方式也變成了Rx的方式,之前我們提過,像Angular2這種深度嵌合Rx的平臺框架,幾乎處處都有Rx的影子。

當然,我們的元件中的todos變成了一個Observable,在構造時直接把Service的屬性方法todos賦值上去了。這樣改造後,我們只需改動模版的兩行程式碼就大功告成了,那就是替換原有的="todos..."= " todos | async"

<div>
  <app-todo-header
    placeholder="What do you want"
    (onEnterUp)="addTodo($event)" >
  </app-todo-header>
  <app-todo-list
    [todos]="todos | async"
    (onToggleAll)="toggleAll()"
    (onRemoveTodo)="removeTodo($event)"
    (onToggleTodo)="toggleTodo($event)"
    >
  </app-todo-list>
  <app-todo-footer
    [itemCount]="(todos | async).length"
    (onClear)="clearCompleted()">
  </app-todo-footer>
</div>複製程式碼

啟動瀏覽器看看吧,一切功能正常,程式碼更加簡潔,邏輯更加清楚。

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
改造成的響應式Todo,所有功能一切正常

小結

我們的Angular學習之旅從零開始到現在,完整的搭建了一個小應用。相信大家現在應該對Angular2有一個大概的認識了,而且也可以參與到正式的開發專案中去了。但Angular2作為一個完整框架,有很多細節我們是沒有提到的,大家可以到官方文件 angular.cn/ 去查詢和學習。

本屆程式碼: github.com/wpcfan/awes…

紙書出版了,比網上內容豐富充實了,歡迎大家訂購!
京東連結:item.m.jd.com/product/120…

Angular 從0到1:Rx--隱藏在 Angular 中的利劍
Angular從零到一

第一節:Angular 2.0 從0到1 (一)
第二節:Angular 2.0 從0到1 (二)
第三節:Angular 2.0 從0到1 (三)
第四節:Angular 2.0 從0到1 (四)
第五節:Angular 2.0 從0到1 (五)
第六節:Angular 2.0 從0到1 (六)
第七節:Angular 2.0 從0到1 (七)
第八節:Angular 2.0 從0到1 (八)

相關文章