Rx真實案例展示

43 mins.
  1. 1. AutoComplete
    1. 1.1. 監聽input
    2. 1.2. 呼叫API
    3. 1.3. 優化
  2. 2. 資料整理轉換
    1. 2.1. 實作
      1. 2.1.1. 第一組資料
      2. 2.1.2. 第二組資料
      3. 2.1.3. 組合
  3. 3. 動態資料監聽
  4. 4. Conculsion
  5. 5. Reference

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

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