在 Rx--隱藏在Angular 2.x中利劍 一文中我們已經初步的了解了 Rx 和 Rx 在 Angular 的應用。 今天我們一起通過一個具體的例子來理解響應式編程設計的思路。最后會看看剛剛發布的 Angular 4 的新特性給響應式編程帶來了什么新鮮的元素。
為什么要做響應式編程?
我給出的答案很簡單:響應式編程可以讓你把程序邏輯想的很清楚。為什么這么說呢?讓我們先來看一個小例子,比如我們有這樣一個需求,在生日的控件之前添加一個年齡的選擇,用以輔助生日的輸入。雖然很變態,其實直接輸入趕腳比這種方式快啊,但真的有客戶提出過這種需求,不管怎樣我們來看一下好了。

首先分析一下需求:
- 年齡可以按歲、月、天為單位。
- 其中如果年齡小于等于3個月,按天為單位,如果小于等于2歲按月為單位,其余情況按歲為單位。其實就是考慮幼兒的情況啦。
- 填年齡時,出生日期隨之變化,因為無法精確,所以只需精確到選擇的單位即可。
如果按傳統方式編程的話,我們可能需要在年齡和年齡單位的兩個處理輸入改變的 event handler
去對數據進行處理,具體我們就不展開了。我們來看一下用響應式編程如何處理這個邏輯。
理解 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 個步驟:
- 在組件的 HTML 模版中給要處理的控件加上
formControlName="blablabla"
-
form
標簽中添加[formGroup]="xxx"
指令,這個xxx
就是你在組件中聲明的FormGroup
類型的成員變量:比如下面代碼中的form: FormGroup;
- 在組件的構造函數中取得
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
上面這行代碼的意思是從表單的控件數組中取得 formControlName
為 age
的這個控件然后監聽其值的變化。這個 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>
有問題的童鞋可以加入我的小密圈討論: http://t.xiaomiquan.com/jayRnaQ (該鏈接7天內(5月14日前)有效)
好久沒寫 Angular 了,希望后面會有時間多寫一些。另外,我的 《Angular 從零到一》出版了,本文出自第 8 章部分內容,下面是書籍的內容簡介:
本書系統介紹Angular的基礎知識與開發技巧,可幫助前端開發者快速入門。共有9章,第1章介紹Angular的基本概念,第2~7章從零開始搭建一個待辦事項應用,然后逐步增加功能,如增加登錄驗證、將應用模塊化、多用戶版本的實現、使用第三方樣式庫、動態效果制作等。第8章介紹響應式編程的概念和Rx在Angular中的應用。第9章介紹在React中非常流行的Redux狀態管理機制,這種機制的引入可以讓代碼和邏輯隔離得更好,在團隊工作中強烈建議采用這種方案。本書不僅講解Angular的基本概念和最佳實踐,而且分享了作者解決問題的過程和邏輯,講解細膩,風趣幽默,適合有面向對象編程基礎的讀者閱讀。
歡迎大家圍觀、訂購、提出寶貴意見。
京東鏈接:https://item.m.jd.com/product/12059091.html?from=singlemessage&isappinstalled=0