Rx真實案例展示

今天要來帶大家看三個我實際有使用過的案例,並且一個步驟一個步驟的走過,當然我們會從簡單到複雜…

AutoComplete

這是一個常見的需求,而且這個功能絕對是非常好使用Rx的一個情境,搭配Angular Reactive Form效果更好

先來描述一下這個案例

  1. 監聽inputkeyup
  2. 呼叫API
  3. 取得資料顯示
  4. 循環步驟1

監聽input

首先我們先寫一個套用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) {}
}

透過FormGroupvalueChanges就能夠監聽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

  1. API取得資料是一個陣列,但我們要用OrgName作為過濾條件,所以需要要先將陣列拆開(switchMap)
  2. 過濾OrgName(filter)
  3. 將拆開的陣列組合回去(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',
}
]

看一下預期目標

  • 沒有criticalLevel或是oddsLevel的忽略

  • 輸出資料1,將每一筆資料組合成以下的格式,日期排序

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
}
]

實作

這邊分兩個部分來做,會比較輕鬆一點,不然整段會漏漏等

第一組資料

一樣來拆執行步驟

  1. 將陣列拆成資料流(switchMap)
  2. 過濾沒有criticalLeveloddsLevel(filter)
  3. 重新組合物件{id, date}(map)
  4. 合成陣列(toArray)
  5. 排序(mapsort)
  6. 加上order(mapmap)
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

再來拆步驟

  1. 直接把陣列拆開(from)
  2. 統計每個id的數量(reducer)
  3. 重新產生新的物件(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
// 1
{
top: 10,
left: 10,
order: 0
}
// 2
{
top: 20,
left: 20,
order: 1
}
// 3
{
top: 30,
left: 30,
order: 2
}
// 4
{
top: 40,
left: 40,
order: 3
}

最後我們期望得到的是這樣一組一組的資料集

1
[[1, 2], [2, 3], [3, 4]]

所以這邊我們要先思考一下怎麼解析這個問題…

  1. 監聽component送出的資料
  2. 確認要取得的資料筆數
  3. 根據資料做排序
  4. 組成兩兩一組的資料(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