今天要來帶大家看三個我實際有使用過的案例,並且一個步驟一個步驟的走過,當然我們會從簡單到複雜…
AutoComplete
這是一個常見的需求,而且這個功能絕對是非常好使用Rx的一個情境,搭配Angular Reactive Form效果更好
先來描述一下這個案例
- 監聽input的keyup
- 呼叫API
- 取得資料顯示
- 循環步驟1
首先我們先寫一個套用Reactive Form的input
使用async可以加上$用來區隔變數
| 12
 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>
 
 | 
| 12
 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
| 12
 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來做為搜尋條件
| 12
 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;
 }
 
 | 
| 12
 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)
| 12
 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作法呢?
| 12
 3
 4
 5
 6
 
 | var result = [];array.forEach(p => {
 if(p.OrgName.indexOf(keyword) > -1){
 result.push(p);
 }
 });
 
 | 
優化
- 原本使用mergeMap還要考慮先後順序,可以改成combineLatest
- 加上輸入的間隔時間debounceTime,避免一直呼叫API
| 12
 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)))
 );
 
 | 
資料整理轉換
先來看一下資料,一樣有個陣列裡面有著日期和兩種等級
| 12
 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',
 }
 ]
 
 | 
看一下預期目標
| 12
 3
 4
 5
 6
 7
 
 | [{
 id: `${oddsLevel}-${criticalLevel}`,
 date: '',
 order: 0
 }
 ]
 
 | 
- 輸出資料2,計算相同id的total數並組成以下的格式
| 12
 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)
| 12
 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)
| 12
 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 }))
 ))
 );
 }
 
 | 
組合
將第一組和第二組資料做結合,然後輸出成為最終需要的資料
| 12
 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處理後的資料。格式如下:
| 12
 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
| 12
 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