iT邦幫忙

2021 iThome 鐵人賽

DAY 24
1
Modern Web

Angular 深入淺出三十天:表單與測試系列 第 24

Angular 深入淺出三十天:表單與測試 Day24 - Reactive Forms 進階技巧 - Auto-Complete Searching

Day24

在日常生活中,大家應該滿常看到有些系統的搜尋輸入框是可以在一邊打字的同時,一邊將搜尋結果呈現在一個下拉選單裡,非常地貼心且方便。

當然,這其中其實有很多細節,不過我們今天就專注在前端的表單開發上,來用 Reactive Forms 實作這個搜尋輸入框吧!

沒錯,就算只是個搜尋框,它也是個表單噢!

正好最近六角學院即將舉辦第三屆的前端 & UI 修煉精神時光屋的活動,這次它們與交通部合作,並提供了全國最大的
運輸資料流通服務平台 (TDX) 之交通 API 給大家使用,讓大家可以透過此活動精進自己的實力,非常推薦給大家。

想當初我第一次寫鐵人賽時,也是使用了參加六角舉辦的第一屆前端修煉精神時光屋的素材來寫,雖然這次沒有要參賽,但又跟六角有關係了呢!

總之,藉由這次的機會與交通部提供的 運輸資料流通服務平台 (TDX) 之交通 API ,我們來簡單地做一個可以查詢台北捷運的車站的搜尋輸入框吧!

這次因為有 API 可以使用的關係,會精實很多,如果跟不上的朋友,可能要再多熟悉一下 Angular 噢!

需求規格說明

簡單來說,這個功能會需要一個輸入框與一個表格,當使用者在輸入框裡打字時,表格的內容也會連動呈現出搜尋結果。

由於 Auto-Complete 的搜尋輸入框如果要自己做會需要處理不少細節,又不想安裝 UI 框架佔篇幅,所以我用這個方式來呈現查詢結果。

表格的欄位有以下這些:

  • 車站代號
  • 車站名稱
  • 車站所屬縣市
  • 車站所屬鄉鎮區
  • 假日是否允許自行車進出站
  • 位置

最後呈現結果:

Auto-Complete Searching View

實作開始

首先,如果在需求明確的情況下,我個人習慣會先把畫面準備好。

HTML 的部份大概會長這樣:

<p><input type="text" placeholder="請輸入捷運站名稱" /></p>
<table>
  <caption>
    台北捷運之捷運站查詢結果
  </caption>
  <thead>
    <tr>
      <td>車站代號</td>
      <td>車站名稱</td>
      <td>車站所屬縣市</td>
      <td>車站所屬鄉鎮區</td>
      <td>假日是否允許自行車進出站</td>
      <td>位置</td>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td></td>
      <td></td>
      <td></td>
      <td></td>
      <td></td>
      <td>
        <a target="_blank" href=""></a>
      </td>
    </tr>
  </tbody>
</table>

CSS 的部份大家就自行發揮囉!

畫面看起來會像這樣:

Auto-Complete Searching View

接著我們會需要一個 FormControl 來跟輸入框綁定,所以我們在 .ts 裡新增一個屬性 ─ searchingInputControl

export class ReactiveFormsAutoCompleteSearchingComponent implements OnInit {

  searchingInputControl = new FormControl();

}

別忘了先到 .module.ts 裡引入 FormsModuleReactiveFormsModule 噢!

然後將 searchingInputControl 與畫面輸入框綁定:

<p><input type="text" placeholder="請輸入捷運站名稱" [formControl]="searchingInputControl" /></p>

接著我們使用昨天分享過的 valueChanges 來確認是否已正確綁定:

export class ReactiveFormsAutoCompleteSearchingComponent implements OnInit {

  searchingInputControl = new FormControl();

  ngOnInit(): void {
    this.searchingInputControl.valueChanges.subscribe((value) => {
      console.log(value);
    });
  }

}

結果:

Auto-Complete Searching View

看起來已經有正確的跟搜尋輸入框綁定了,那接下來要怎麼做才好呢?

Service

我們的目的是希望使用者在輸入捷運站名稱的同時,只留下跟使用者的輸入有關聯的捷運站。

因此,我們會需要一支 Service 來幫我們呼叫交通部所提供的 運輸資料流通服務平台 (TDX) 之交通 API ,並把查詢結果顯示到畫面上。

Service 的程式碼大概會長這個樣子:

@Injectable()
export class ReactiveFormsAutoCompleteSearchingService {

  constructor(private httpClient: HttpClient) { }

  searchStation(stationName: string): Observable<MetroStationDTO[]> {
    let url = 'https://ptx.transportdata.tw/MOTC/v2/Rail/Metro/Station/TRTC?$format=JSON';
    if (stationName) {
      url += `&$filter=contains(StationName/Zh_tw,'${stationName}')`;
    }
    return this.httpClient.get<MetroStationDTO[]>(url);
  }
}

上述程式碼中有以下幾個重點:

  1. 要呼叫 API 的話,需要先到 .module.ts 裡引入 HttpClientModule ,才能在 Service 裡使用 HttpClient 來呼叫 API。

  2. MetroStationDTO 是我根據交通部所提供的 運輸資料流通服務平台 (TDX) 之交通 API 裡定義的資料介面,詳細位置需先選擇「軌道」再點選「捷運」,如下圖所示:

TDX API Document

  1. 由於 HTTP MethodGET 的緣故,所以參數是使用 Query Parameters 的方式帶進 URL 之中。

  2. 如果使用者沒有輸入站名時,還帶 $filter 參數會收到伺服器回傳的 Bed Request 錯誤,因此增加一個判斷式 ─ 當傳入的 stationNameTruthy 值時,才帶 $filter 參數。

  3. 參數 $filter 的值該怎麼帶這件事情其實在文件中沒有寫,算是這個文件比較美中不足的地方。好在六角學院的院長 ─ 廖洧杰院長前陣子有開直播課教學,而我猜測院長一定有在那堂課講這件事情,所以去翻了一下該堂直播課的共筆才找到該怎麼帶它的值。

Service 準備好之後,接下來就要將 FormControlvalueChanges 事件與 API 相結合了。

準備好見證神蹟了嗎?

Operators

RxJS 真的是一個很棒的函式庫,它讓我們可以很好地操作非同步資料串流,而且還能讓我們的程式碼非常地簡潔、非常地好閱讀。

就像我們現在需要把使用者的輸入事件與 API 做結合時,用 RxJS 的 Operators 就可以非常完美、漂亮地結合在一起。

就像這樣:

export class ReactiveFormsAutoCompleteSearchingComponent implements OnInit {

  searchingInputControl = new FormControl();

  constructor(private service: ReactiveFormsAutoCompleteSearchingService) { }

  ngOnInit(): void {
    this.searchingInputControl.valueChanges.pipe(
      startWith(''),
      debounceTime(500),
      switchMap(value => this.service.searchStation(value))
    ).subscribe((result) => {
      console.log(result);
    });
  }

}

結果:

Auto-Complete Searching View

我相信在這邊一定會有非常多朋友看傻眼,這是什麼神操作?!這樣就接好了?!

沒錯!這樣就接好了,是不是比你想像中簡單非常多呢?

那這串到底做了什麼事呢?

首先,我希望這個畫面一開始的時候就會先查詢一次,所以我使用 startWith('') 來呼叫查詢 API 。

再者,我希望查詢的間隔不要太過快速,當使用者「可能」已經打完字的時候才查詢,所以我使用 debounceTime(500) 來讓查詢的時間點會在使用者停止打字 500 毫秒後才呼叫查詢 API。

最後,則要將原本是 valueChanges 的 Observable 轉換成 呼叫 API 的 Observable 這件事情 ,所以我使用 switchMap(value => this.service.searchStation(value))

關於 startWith ,大家可以參考官方文件或是 Mike 的文章

關於 debounceTime ,大家可以參考官方文件或是 Mike 的文章

關於 switchMap ,大家可以參考官方文件或是 Mike 的文章

AsyncPipe

接著,我們要將得到的資料綁定到畫面上,而綁定到畫面上的方式大致上有兩種:

  1. 自己訂閱後將資料指定給 Component 的屬性:
export class ReactiveFormsAutoCompleteSearchingComponent implements OnInit {

  searchingInputControl = new FormControl();
  stations: MetroStationDTO[] = []; 

  constructor(private service: ReactiveFormsAutoCompleteSearchingService) { }

  ngOnInit(): void {
    this.searchingInputControl.valueChanges.pipe(
      startWith(''),
      debounceTime(500),
      switchMap(value => this.service.searchStation(value))
    ).subscribe((stations) => {
      this.stations = stations;
    });
  }

}

然後再綁到畫面上:

<table>
  <caption>
    台北捷運之捷運站查詢結果
  </caption>
  <thead>
    <tr>
      <td>車站代號</td>
      <td>車站名稱</td>
      <td>車站所屬縣市</td>
      <td>車站所屬鄉鎮區</td>
      <td>假日是否允許自行車進出站</td>
      <td>位置</td>
    </tr>
  </thead>
  <tbody>
    <tr *ngFor="let station of stations">
      <td>{{ station.StationID }}</td>
      <td>{{ station.StationName.Zh_tw }}</td>
      <td>{{ station.LocationCity }}</td>
      <td>{{ station.LocationTown }}</td>
      <td>{{ station.BikeAllowOnHoliday }}</td>
      <td>
        <a target="_blank" [href]="station.StationPosition">
          {{ station.StationPosition }}
        </a>
      </td>
    </tr>
  </tbody>
</table>
  1. 不要自己訂閱,先將 Observable 準備好並用 Component 的屬性儲存起來:
export class ReactiveFormsAutoCompleteSearchingComponent {

  searchingInputControl = new FormControl();
  stations$ = this.searchingInputControl.valueChanges.pipe(
    startWith(''),
    debounceTime(500),
    switchMap(value => this.service.searchStation(value))
  );

  constructor(private service: ReactiveFormsAutoCompleteSearchingService) { }

}

然後透過 AsyncPipe 讓 Template 自己訂閱:

<table>
  <caption>
    台北捷運之捷運站查詢結果
  </caption>
  <thead>
    <tr>
      <td>車站代號</td>
      <td>車站名稱</td>
      <td>車站所屬縣市</td>
      <td>車站所屬鄉鎮區</td>
      <td>假日是否允許自行車進出站</td>
      <td>位置</td>
    </tr>
  </thead>
  <tbody>
    <tr *ngFor="let station of (stations$ | async) || []">
      <td>{{ station.StationID }}</td>
      <td>{{ station.StationName.Zh_tw }}</td>
      <td>{{ station.LocationCity }}</td>
      <td>{{ station.LocationTown }}</td>
      <td>{{ station.BikeAllowOnHoliday }}</td>
      <td>
        <a target="_blank" [href]="station.StationPosition">
          {{ station.StationPosition }}
        </a>
      </td>
    </tr>
  </tbody>
</table>

就結果來說,這兩個方法基本上都可以,但我個人非常推薦使用第二種方式。

原因是使用第二種的方式一方面可以避免我們在 Component 被 Destroy 時忘記解除訂閱而導致 Memory Leak 的情形,另一方面是 Observable 會比單純資料好用很多。

甚至有時候我們自己訂閱會發生「明明資料就有收到但畫面沒有更新」的詭異狀況。

結果:

Auto-Complete Searching View

Other Pipes

雖然目前運作良好,但還有一些小東西還沒處理完:

  1. 假日是否允許自行車進出站的欄位我想讓它呈現 或是
  2. 位置的欄位我想讓它以 latitude, longitude 的格式呈現。
  3. 連結我想要可以點擊後用新的頁籤打開 Google Map ,並會看到那個捷運站的位置。

以上這三個小東西非常地簡單,我想大家應該也都知道該怎麼做,但是既然都已經到了第二十四天了,這邊我覺得我們要使用 Pipe ,而不是像之前一樣直接寫在 Component 裡。

這是因為,如果像之前的 getErrorMessage 是寫在 Component 裡的話,其實當畫面渲染時,該函式就會被呼叫,不管該值有沒有被改變。

但是使用 Pipe 的話,在該值被改變前,是不會被呼叫第二次的。

再者,使用 Pipe 的話,重用性與可維護性也比較好。

所以我建議大家可以使用 Pipe 來完成最後的小調整。

我個人會建立三個 PipeBooleanInZhTwPipeGoogleMapLinkPipeLocationStringPipe

它們的程式碼如下:

@Pipe({
  name: 'booleanInZhTw'
})
export class BooleanInZhTwPipe implements PipeTransform {

  transform(value: boolean, ...args: unknown[]): string {
    return value ? '是' : '否';
  }
}
@Pipe({
  name: 'googleMapLink'
})
export class GoogleMapLinkPipe implements PipeTransform {

  transform({ PositionLat, PositionLon }: StationPosition, ...args: unknown[]): string {
    return `https://www.google.com/maps?q=${PositionLat},${PositionLon}&z=7`;
  }

}
@Pipe({
  name: 'locationString'
})
export class LocationStringPipe implements PipeTransform {

  transform({ PositionLat, PositionLon }: StationPosition, ...args: unknown[]): string {
    return `${PositionLat}, ${PositionLon}`;
  }

}

最終結果:

Auto-Complete Searching View

本日小結

今天的重點主要是:

  1. 學習如何使用 TDX API
  2. 學習如何使用 RxJS 的 Operator ─ startWithdebounceTimeswitchMapvalueChanges呼叫 API 串聯。
  3. 學習如何使用 AsyncPipe
  4. 學習如何自定 Pipe

今天的練習對於一些剛學 Angular 的朋友來說會滿精實且資訊量有點大的,大家可以多看幾遍,多自己練習、做實驗,相信對大家來說會很有幫助。

關於 RxJS ,如果大家想知道更多資訊,我推薦大家去看 Mike 的打通 RxJS 任督二脈系列文,或者是直接買實體書也行。

雖然今天的實作已經完成了,但還有測試的部份,我們明天來撰寫它吧!

今天的程式碼會放在 Github - Branch: day24 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!

如果有任何的問題或是回饋,還請麻煩留言給我讓我知道!


上一篇
Angular 深入淺出三十天:表單與測試 Day23 - Reactive Forms 進階技巧 - 欄位連動檢核邏輯
下一篇
Angular 深入淺出三十天:表單與測試 Day25 - 測試進階技巧 - DI 抽換
系列文
Angular 深入淺出三十天:表單與測試30

1 則留言

0
cheerupche
iT邦新手 5 級 ‧ 2021-10-13 14:41:40

RxJS真的太神奇啦(歡呼)
之前也是看了Mike大的文章+書了解許多,大大這篇結合許多應用,又學到更多了。

Leo iT邦新手 3 級 ‧ 2021-10-13 14:43:01 檢舉

Hi heerupche

很高興此文章能夠幫到你^^

我要留言

立即登入留言