Angular 中的響應式程式設計 -- 淺淡 Rx 的流式思維

接灰的電子產品發表於2017-03-31

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

Rx -- 隱藏在 Angular 中的利劍 一文中我們已經初步的瞭解了 Rx 和 Rx 在 Angular 的應用。 今天我們一起通過一個具體的例子來理解響應式程式設計設計的思路。最後會看看剛剛釋出的 Angular 4 的新特性給響應式程式設計帶來了什麼新鮮的元素。

為什麼要做響應式程式設計?

我給出的答案很簡單:響應式程式設計可以讓你把程式邏輯想的很清楚。為什麼這麼說呢?讓我們先來看一個小例子,比如我們有這樣一個需求,在生日的控制元件之前新增一個年齡的選擇,用以輔助生日的輸入。雖然很變態,其實直接輸入趕腳比這種方式快啊,但真的有客戶提出過這種需求,不管怎樣我們來看一下好了。

Angular 中的響應式程式設計 -- 淺淡 Rx 的流式思維
有年齡和單位選擇的日期輸入

首先分析一下需求:

  • 年齡可以按歲、月、天為單位。
  • 其中如果年齡小於等於3個月,按天為單位,如果小於等於2歲按月為單位,其餘情況按歲為單位。其實就是考慮幼兒的情況啦。
  • 填年齡時,出生日期隨之變化,因為無法精確,所以只需精確到選擇的單位即可。

如果按傳統方式程式設計的話,我們可能需要在年齡和年齡單位的兩個處理輸入改變的 event handler 去對資料進行處理,具體我們就不展開了。我們來看一下用響應式程式設計如何處理這個邏輯。

理解 Rx 的關鍵是要把任何變化想象成資料流,資料流分為幾種:

  1. 永遠不會結束的
  2. 有限次的,比如執行若干次結束的(包括只發生一次的)
  3. 當然還有一些特殊的,比如永遠不會發生的(這個是為了解決某些特定場景問題存在的)

這麼說好像比較抽象,那麼還是回到例子來看這個問題。就這個需求來看的話,年齡和年齡單位這兩個資料要一起來考慮,

Angular 中的響應式程式設計 -- 淺淡 Rx 的流式思維
資料流的合併

上圖中(由於太懶,後面的合併虛線就沒有畫了),上面兩個流為原始資料流,一個是年齡的資料流,每次更改年齡數時,這個資料流就產生一個資料:比如一開始初始值為 33,我們刪掉個位數的 3,這時由於其變化,產生第二個值 3 (原十位的3),然後我們新增了5,新值變成35,因此流中的第三個資料是35,以此類推。另一個資料流反映了年齡單位的變化,按照“歲-月-歲-天”的次序產生新的資料。一個人的最終的年齡是通過年齡值和年齡單位聯合確定的,這也就是說我們需要對這兩個流做合併計算。

那麼選擇什麼樣的合併方式呢?其實我們需要的是任何一個流的值變化的時候,新的合併流都應該有一個對應資料,這個資料包括剛剛變化的那個值和另一個流中最新的值。比如:如果年齡資料從 33 刪掉個位變成 3,此時我們沒有改變年齡單位,合併流中的新資料應該是 3歲 。接下來我們改變單位為 ,那這時候年齡資料的最新值仍然是 3 ,所以新流的資料應為 3月等等以此類推。

這樣的一種合併方式在 Rx 中專門有一個操作符來處理,那就是 combineLatest。如果我們使用 age$ 代表年齡資料流(那個 $ 代表 Stream -- 流的意思,約定俗成的寫法,不強制要求),用 ageUnit$ 代表年齡單位資料流的話,我們可以寫出如下的合併邏輯,為了簡化問題,我們這裡合併後都使用 作為單位:

// 這裡前面兩個引數都是參與合併的資料流,第三個是個處理函式
// 這個處理函式接受兩個流中的最新資料,然後經過運算輸出新值
this.computed$ = Observable.combineLatest(age$, ageUnit$, (a, u)=>{
      // 非法數字就都按初始值處理,這裡就簡單粗暴了
      if(a === undefined || a <= 0 ) return initialAge;
      // 全部轉化為天數
      switch (parseInt(u)) {
        case AgeUnit.Day.valueOf():
          return a;
        case AgeUnit.Month.valueOf():
          return a * 30;
        case AgeUnit.Year.valueOf():
        default:
          // 別問我閏年大小月啥的,只是個例子而已
          return a * 365; 
      }
    })複製程式碼

合併之後呢,由於我們最終需要向生日那個輸入框中寫入一個日期,而我們合併之後的流給出的是按天數計算的年齡,所以這裡顯然需要一個轉換。

在 Rx 中這種資料的轉換再容易不過了,最常用的一個就是 map 轉換操作符,接著上面的程式碼繼續來一個 map 函式,這裡使用了 momentjs 的按當前日期減去剛剛的以天數為單位的年齡值,就得到一個大概估算的出生日期。

.map(a => {
      const date = moment().subtract(a, 'days').format('YYYY-MM-DD');
      return date;
    });複製程式碼

但是到這裡,你會發現我們還沒有定義兩個原始資料流呢,別急,留到後面是為了引出 Angular 對於 Rx 的良好支援。

響應式表單中的 Rx

Angular 的表單處理非常強大,有模版驅動的表單和響應式表單兩類,兩種表單各有千秋,在不同場合可以分別使用,甚至混合使用,但這裡就不展開了。我們這裡使用了響應式表單,也非常簡單,就是一個 form 裡面 3 個控制元件,這裡我採用了官方的 Material 控制元件,如果你覺得不爽,可以直接用基礎的 HTML 控制元件搭配樣式即可。

<form 
[formGroup]="form" 
(ngSubmit)="onSubmit()">
  <md-input-container align="end">
      <input mdInput 
        formControlName="age" 
        type="number" 
        placeholder="年齡" 
        max="200" 
        min="1" />
  </md-input-container>
  <md-button-toggle-group formControlName="ageUnit">
    <md-button-toggle value="0" ></md-button-toggle>
    <md-button-toggle value="1" ></md-button-toggle>
    <md-button-toggle value="2" ></md-button-toggle>
  </md-button-toggle-group>
  <md-input-container>
      <input mdInput 
        formControlName="dateOfBirth" 
        type="date" 
        placeholder="出生日期" 
        max="2100-12-31" 
        min="1900-01-01"
        [value]="computed$ | async"
        />
      <md-hint align="start">YYYY/MM/DD格式輸入</md-hint>
  </md-input-container>
</form>複製程式碼

Angular 中處理響應式表單只有 3 個步驟:

  1. 在元件的 HTML 模版中給要處理的控制元件加上 formControlName="blablabla"
  2. form 標籤中新增 [formGroup]="xxx" 指令,這個 xxx 就是你在元件中宣告的 FormGroup 型別的成員變數:比如下面程式碼中的 form: FormGroup;
  3. 在元件的建構函式中取得 FormBuilder 後(比如下面程式碼中的 constructor(private fb: FormBuilder) { }),用 FormBuilder 構造表單控制元件陣列並賦值給剛才的型別為 FormGroup 的成員變數。
import { Component, OnInit } from '@angular/core';
import { FormGroup, FormBuilder, FormControl, Validators } from '@angular/forms';
import { AgeUnit } from '../../domain/entities.interface';
import * as moment from 'moment/moment';

@Component({
  selector: 'app-reactive',
  templateUrl: './reactive.component.html',
  styleUrls: ['./reactive.component.scss']
})
export class ReactiveComponent implements OnInit {
  form: FormGroup;
  computed$: Observable<string>;
  ageSub: Subscription;
  dateOfBirth$: Observable<string>;
  dateOfBirthSub: Subscription;
  constructor(private fb: FormBuilder) { }

  ngOnInit() {
    this.form = this.fb.group({
      age: ['', Validators.required],
      ageUnit: ['', Validators.required],
      dateOfBirth: ['', Validators.compose([Validators.required, this.validateDate])]
    });

    const initialAge = 33;
    const initialAgeUnit = AgeUnit.Year;
    this.form.controls['age'].setValue(initialAge);
    this.form.controls['ageUnit'].setValue(initialAgeUnit);
  }

  validateDate(c: FormControl): {[key: string]: any}{
    const result = moment(c.value).isValid 
        && moment(c.value).isBefore()
        && moment(c.value).year()> 1900;
    return {
      "valid": result
    }
  }

  onSubmit() {
    if(!this.form.valid) return;
  }
}複製程式碼

現在這個表單就建立好了,但你可能會問,這也沒看出來響應式啊,別急,接下來我們就要看看它的響應式支援了。我們再回到一開始的小題目,我們的兩個原始資料流:age$ageUnit$ 怎麼構建?這兩個資料流其實是來自於兩個控制元件的值的變化,而響應式表單獲取值的變化是非常簡單的就一行:

this.form.controls['age'].valueChanges複製程式碼

上面這行程式碼的意思是從表單的控制元件陣列中取得 formControlNameage 的這個控制元件然後監聽其值的變化。這個 valueChanges 返回的其實就是一個 Observable ,見下面的 TypeScript 定義:

/**
 * Emits an event every time the value of the control changes, in
 * the UI or programmatically.
 */
readonly valueChanges: Observable<any>;複製程式碼

既然我們得到了這個原始資料流,剩下的工作就比較簡單了。但我們可能需要對這個原始資料流再做點處理。首先,我們並不希望每次改這個值都去監聽,因為輸入是一個連續事件,每一次按鍵都監聽是不太划算的。這就需要一個濾波器的處理 .debounceTime(500),我們不去處理 500 毫秒內的變化,而是等待其輸入停頓時再傳送資料。第二,如果使用者採用了拷貝貼上的方式,我們希望同樣的資料不重複傳送,所以濾掉相同的資料。最後,我們採用 startWith 給這個流一個初始值,這是由於如果一開始我們什麼都不做,兩個流就都沒有資料;或者只改變其中一個,另一個由於一直沒有變就不會產生資料,這樣的話,合併流也不會有資料。

// 省略其它引入
import 'rxjs/add/operator/debounceTime';
import 'rxjs/add/operator/distinctUntilChanged';
// 省略其它部分
const age$ = this.form.controls['age'].valueChanges
      .debounceTime(500)
      .distinctUntilChanged()
      .startWith(initialAge);
const ageUnit$ = this.form.controls['ageUnit'].valueChanges
      .distinctUntilChanged()
      .startWith(initialAgeUnit);複製程式碼

Async 管道

到目前為止,我們還沒有進行對 Observable 的訂閱,如果不訂閱的話,寫的再漂亮的語句也不會執行的。按常規套路來講,我們得宣告 Subscription 物件,因為 Observable 是一直監聽的,即使頁面銷燬,它也還在,這會造成記憶體洩漏。所以,我們需要再頁面銷燬(ngOnDestroy 中)的適合取消訂閱。 需要訂閱的 Observable 少的時候還好,一旦多起來,處理時也挺麻煩,像下面的程式碼那樣。

// 省略其它引入
import { Subscription } from 'rxjs/Subscription';
// 省略其它部分
ageSub: Subscription;
// 省略其它部分
this.ageSub = this.computed$.subscribe(date => this.form.controls['dateOfBirth'].setValue(date));
// 省略其它部分
onNgDestroy(){
  if(this.ageSub !== undefined || !this.ageSub.closed)
    this.ageSub.unsubscribe();
}複製程式碼

所幸的是,Angular 提供了對於響應式程式設計非常友好的設計,我們完全可以不在程式碼中做訂閱或取消訂閱的動作。那麼問題來了,不訂閱的話,值怎麼獲得呢?答案是 Async 管道。Async 會在元件初始化時自動的訂閱以及在元件銷燬時自動取消訂閱,太爽了。因此,我們可以刪掉上面的程式碼了,然後在元件模版中給生日的那個 input 新增一個指令 [value]="computed$ | async",這就是說該 input 的 value 就是 computed$ 訂閱後的值,那麼 | async 是說 computed$ 是一個 Observable,請對他採用非同步處理,即初始化時自動的訂閱以及在元件銷燬時自動取消訂閱。

<input mdInput 
        formControlName="dateOfBirth" 
        // 省略其它屬性
        [value]="computed$ | async"
        />複製程式碼

對於響應式程式設計方式的思考

上面的例子,我不知道大家發現沒有,當然 Rx 提供了好多方便的操作符。但更重要的是,寫 Rx 的時候,我們需要對流程理解的足夠清晰,或者說 Rx 逼著我們對流程反覆梳理。其實有的時候,寫 Rx 不一定很快,但一旦業務梳理清楚了,接下來就是幾行程式碼的事情。如果你有時候覺得用現有的 Rx 操作符寫不出,那多半是你的對需求中涉及的資料流的關係沒有弄清楚。

Angular 4 中的 NgIf 的改進

Angular 4 中的 ngIf 現在可以攜帶 else 了,如果你曾經使用過 Angular 就知道,原來我們是得寫兩個 ngIf 來完成類似的功能的。這個 else 可以攜帶一個模版的引用。比如下面例子中:如果使用者登入成功顯示使用者名稱,否則顯示登入連結。

<span *ngIf="auth$ else login">
  <a routerLink="/profile">{{(auth$|async).user.name}}</a>
  <a routerLink="/blablabla">{{(auth$|async).visits}}</a>
</span>
<ng-template #login>
  <a routerLink="/login">登入</a>
</ng-template>複製程式碼

另一個改進是 ngIf 中現在可以將評估表示式的結果賦值給一個變數,好處是什麼呢?可以讓你少寫很多 (auth$|async)

<span *ngIf="auth$ | async as auth else login">
  <a routerLink="/profile">{{auth.user.name}}</a>
  <a routerLink="/blablabla">{{auth.visits}}</a>
</span>
<ng-template #login>
  <a routerLink="/login">登入</a>
</ng-template>複製程式碼

好久沒寫 Angular 了,希望後面會有時間多謝一些。另外,我的 《Angular 從零到一》出版了,歡迎大家圍觀、訂購、提出寶貴意見。

京東連結:item.m.jd.com/product/120…

Angular 中的響應式程式設計 -- 淺淡 Rx 的流式思維
Angular從零到一

相關文章