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 上供大家参考,建议大家在看我的实作之前,先按照需求规格自己做一遍,之後再跟我的对照,看看自己的实作跟我的实作不同的地方在哪里、有什麽好处与坏处,如此反覆咀嚼消化後,我相信你一定可以进步地非常快!

如果有任何的问题或是回馈,还请麻烦留言给我让我知道!


<<:  LeetCode 双刀流:62. Unique Paths

>>:  Day25_CSS语法8

【Android-Notification(通知)】 介绍+实作练习

前言: 通常会和Broadcast(广播)一起使用,在app status条显示的资料, 可以在Ap...

React和DOM的那些事-节点更新

点击进入React源码调试仓库。 React的更新最终要落实到页面上,所以本文主要讲解DOM节点(H...

EP29 - 秽土转生~到了 AWS 也要能够备份~

在 EP13 - 灾难演练,重建你的 VPC, 我们在重建 VPC 之前, 有带着大家怎麽进行单次备...

16 - Logs - 挖掘系统内部发生的状况 (4/4) - 透过 Filebeat 收集 Infrastructure 中各种服务的细节资讯

Logs - 挖掘系统内部发生的状况 系列文章 (1/4) - Logs 与 Filebeat 的基...

Day20【Dev】编程方法:Imperative 与 Declarative

Imperative Programming(命令式/指令式编程) 对程序说明「How to do」...