今天要來帶大家看三個我實際有使用過的案例,並且一個步驟一個步驟的走過,當然我們會從簡單到複雜…
AutoComplete
這是一個常見的需求,而且這個功能絕對是非常好使用Rx的一個情境,搭配Angular Reactive Form
效果更好
先來描述一下這個案例
- 監聽
input
的keyup
- 呼叫API
- 取得資料顯示
- 循環步驟1
首先我們先寫一個套用Reactive Form
的input
使用async可以加上$用來區隔變數
1 2 3 4 5 6 7 8
| <form [formGroup]="form"> <input formControlName="keyword"> <ul> <li *ngFor="let item of wastes$ | async as wastes"> {{item.OrgName}} </li> </ul> </form>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| import { Component, OnInit } from '@angular/core'; import { FormBuilder, FormGroup } from '@angular/forms'; import { Observable } from 'rxjs';
@Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'] }) export class AppComponent implements OnInit { keyword$: Observable<string>; form: FormGroup; wastes$: any;
ngOnInit() { this.form = this.fb.group({ keyword: [''] }); this.form.get('keyword') .valueChanges .subscribe(p=>console.log('input', p)); }
constructor(private fb: FormBuilder) {} }
|
透過FormGroup
的valueChanges
就能夠監聽input的變動了(484很簡單!!)
呼叫API
這邊我們使用環保署的API,並且先不做server的filter,而是採用client的
最好的作法應該是用server做filter
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| [ { "County": "宜蘭縣", "OrgType": "清除", "Grade": "甲", "OrgName": "境庭有限公司", "RegistrationNo": "G3004187", "OrgAddress": "宜蘭縣宜蘭市文昌路一九八之六號一樓", "TreatMethod": "", "GrantDeadline": "2022/7/18 上午 12:00:00", "OrgTel": "03-9356440", "OperatingAddress": "宜蘭縣宜蘭市文昌路一九八之六號一樓" } ]
|
可以看到輸出的資料是一個物件的陣列,所以我們要宣告一個interface
來做為資料model
等等將採用OrgName來做為搜尋條件
1 2 3 4 5 6 7 8 9 10 11 12
| export interface Waste { County: string; OrgType: string; Grade: string; OrgName: string; RegistrationNo: string; OrgAddress: string; TreatMethod: string; GrantDeadline: string; OrgTel: string; OperatingAddress: string; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import { Injectable } from '@angular/core'; import { environment } from 'src/environments/environment'; import { HttpClient } from '@angular/common/http'; import { map } from 'rxjs/operators'; import { Waste } from '@app/models/waste';
@Injectable({ providedIn: 'root' }) export class EnvAPIService { public wasteAPI$ = this.http.jsonp(this.generatorUrl('355000000I-001154'), 'callback') .pipe( map((p: any) => p.result.records as Waste[]) ); generatorUrl(resouceId: string, params?: any[]): string { const queryParam = !params ? '' : `&${params.join('&')}`; return `${environment.envAPIEndpoint + resouceId}?format=json&toekn=${environment.envToken}${queryParam}`; }
constructor(private http: HttpClient) { } }
|
接著我們要跟service做一個串接,這邊先來解析一下會怎麼思考使用的operator
- API取得資料是一個陣列,但我們要用OrgName作為過濾條件,所以需要要先將陣列拆開(switchMap)
- 過濾OrgName(filter)
- 將拆開的陣列組合回去(toArray)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| import { Component, OnInit } from '@angular/core'; import { filter, mergeMap, switchMap, toArray, debounceTime, map } from 'rxjs/operators'; import { FormBuilder, FormGroup } from '@angular/forms'; import { Observable, combineLatest } from 'rxjs'; import { Waste } from '@app/models/waste'; import { EnvAPIService } from '@app/service/env-api.service';
@Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'] }) export class AppComponent implements OnInit { keyword$: Observable<string>; form: FormGroup; wastes$: Observable<Waste[]>;
hasKeyword = (keyword: string) => { return (waste: Waste) => waste.OrgName.indexOf(keyword) > -1; }
ngOnInit() { this.form = this.fb.group({ keyword: [''] }); this.keyword$ = this.form.get('keyword').valueChanges;
this.wastes$ = this.keyword$.pipe( mergeMap(keyword => this.envAPI.wasteAPI$.pipe( switchMap(p => p), filter(this.hasKeyword(keyword)), toArray() )) ); } constructor(private envAPI: EnvAPIService, private fb: FormBuilder) {} }
|
這邊必須注意service
或是input
事件的先後順序,因為input
會是持續發生,所以必須要以keyword$
為主
如果是es5作法呢?
1 2 3 4 5 6
| var result = []; array.forEach(p => { if(p.OrgName.indexOf(keyword) > -1){ result.push(p); } });
|
優化
- 原本使用
mergeMap
還要考慮先後順序,可以改成combineLatest
- 加上輸入的間隔時間
debounceTime
,避免一直呼叫API
1 2 3 4 5 6 7
| this.wastes$ = combineLatest( this.keyword$.pipe( debounceTime(200) ), this.envAPI.wasteAPI$ ).pipe( map(([keyword, wastes]) => wastes.filter(this.hasKeyword(keyword))) );
|
資料整理轉換
先來看一下資料,一樣有個陣列裡面有著日期和兩種等級
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| [ { modifyTime: '2018/03/27', criticalLevel: 4, oddsLevel: 4 }, { modifyTime: '2018/02/27', criticalLevel: 3, oddsLevel: 3 }, { modifyTime: '2018/01/27', criticalLevel: 2, oddsLevel: 2 }, { modifyTime: '2018/04/27', criticalLevel: 3, oddsLevel: 3 }, { modifyTime: '2018/03/22', } ]
|
看一下預期目標
1 2 3 4 5 6 7
| [ { id: `${oddsLevel}-${criticalLevel}`, date: '', order: 0 } ]
|
- 輸出資料2,計算相同id的total數並組成以下的格式
1 2 3 4 5 6 7
| [ { id: `${oddsLevel}-${criticalLevel}`, total: 0, current: 0 } ]
|
實作
這邊分兩個部分來做,會比較輕鬆一點,不然整段會漏漏等
第一組資料
一樣來拆執行步驟
- 將陣列拆成資料流(switchMap)
- 過濾沒有
criticalLevel
和oddsLevel
(filter)
- 重新組合物件
{id, date}
(map)
- 合成陣列(toArray)
- 排序(map、
sort
)
- 加上order(map、
map
)
1 2 3 4 5 6 7 8 9 10 11 12 13
| getPointList() { return obs => obs.pipe( switchMap((p: HistoryRecord[]) => p), filter((p: HistoryRecord) => !!p.criticalLevel && !!p.oddsLevel), map((p: HistoryRecord) => ({ id: `${p.oddsLevel}-${p.criticalLevel}`, date: p.modifyTime } as PointData)), toArray(), map((p: PointData[]) => p.sort((a, b) => Date.parse(b.date) - Date.parse(a.date))), map((p: PointData[]) => p.map((data, idx) => ({ ...data, order: idx }))) ); }
|
第二組資料
透過第一組資料來組合成第二組,其實步驟很簡單,但是資料從前面來輸出成新的物件,所以我們要把第二步驟的行為視為一個observer
再來拆步驟
- 直接把陣列拆開(from)
- 統計每個id的數量(reducer)
- 重新產生新的物件(map)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| getPointCount() { return obs => obs.pipe( mergeMap((pointList: PointData[]) => from(pointList) .pipe( reduce((acc, value: PointData) => { const id = value.id; if (!acc[id]) { acc[id] = { total: 0, current: 0 } as PointCount; } acc[id].total++; return acc; }, [] as PointCount[]), map(counts => ({ pointList: pointList, pointCount: counts })) )) ); }
|
組合
將第一組和第二組資料做結合,然後輸出成為最終需要的資料
1 2 3 4 5
| this.api.dataApi$.pipe( filter(p => !!p && p.length > 0), this.getPointList(), this.getPointCount() ).subscribe(p => console.log(p));
|
動態資料監聽
這個需求比較特別一點,資料流是從API下來(也就是前一個範例的處理),經過component處理後輸出,然後我們要監聽這些經過component處理後的資料。格式如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| { top: 10, left: 10, order: 0 }
{ top: 20, left: 20, order: 1 }
{ top: 30, left: 30, order: 2 }
{ top: 40, left: 40, order: 3 }
|
最後我們期望得到的是這樣一組一組的資料集
1
| [[1, 2], [2, 3], [3, 4]]
|
所以這邊我們要先思考一下怎麼解析這個問題…
- 監聽component送出的資料
- 確認要取得的資料筆數
- 根據資料做排序
- 組成兩兩一組的資料(pairewise)
思維是這樣,但其實我在寫這個功能的時候為了解決第2、4點,嘗試了很多的方法
- 問題一,使用pairewise資料必須要有終點(complete),不然不會有任何結果出來
- 根據問題一延伸問題二,怎麼知道資料已經取完
最後最後,我使用了bufferCount這個operator來解決這個問題,但是又會延伸新問題
- 當前面重新取得資料,subject中還有前面舊的資料,這時候buffer會拿不到最新的資料,所以又必須讓buffer重新定位
這問題我找了很多的方法,而且也找人一起討論,重要的是pairwise很難debug…TAT
1 2 3 4 5 6 7 8 9 10 11
| this.api.pointList$ .pipe( mergeMap(pointList => this.api.elementPoint$ .pipe( bufferCount(pointList.length), switchMap(p => p.sort((a, b) => a.order - b.order)), pairwise(), bufferCount(pointList.length - 1, 1), ))) .subscribe(p => console.log(p));
|
Conculsion
- 在不熟的情況下,先使用最熟悉的作法,再來開始想裡面的步驟如何被拆出來
- 不用試著對每個operator都很熟悉,百分之七十以上的情況都在寫常用的那幾個
- 最後就是用問的看有沒有更好的寫法
Reference