Angular 中的響應式編程 -- 淺淡 Rx 的流式思維
今天我們一起通過一個具體的例子來理解響應式編程設計的思路。最后會看看剛剛發布的 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://www.jianshu.com/p/925adede7c60