JavaScript 語言特性之先 ++ 跟後 ++ 到底哪裡不同?

今天我在看 Angular 的原始碼的時候我發現了一段程式碼如下:

1
2
3
4
5
6
let _nextReactiveId: number = 0;

export abstract class ReactiveNode {
private readonly id = _nextReactiveId ++;
// 以下省略
}

然後我就覺得,咦?!第一次看到別人這樣用(被發現我很少看別人的程式碼了),然後就很好奇所以這樣 id 會是從多少開始、然後也很好奇如果改為使用 ++ _nextReactiveId 或是 _nextReactiveId += 1 的話會怎樣,所以我就做了一下實驗,並且分享給大家!

實驗 1 - i += 1

首先我們一樣先宣告一個變數 i 等於 0 :

1
let i = 0;

然後宣告一個變數 a 等於 i += 1 之後的結果:

1
const a = i += 1;

接著把它印出來看看:

1
2
console.log(a) // 1
console.log(i) // 1

從結果回推,看起來 i += 1 這部份會先運算 (為避免有人不知道,提示一下:i += 1 ⇒ i = i + 1),然後變數 a 會再把變數 i 的值給記錄下來,所以最終兩個變數的值都會是運算完之後的結果。

實驗 2 - i ++

再來是第二種方式 - i ++ ,我們一樣宣告一個變數 b 來記錄 i ++ 之後的結果:

1
const b = i ++;

猜猜看,bi 的值分別會是什麼呢?(小提示,剛剛 i 的值已經變為 1 了唷!)

1
2
console.log(b) // 1
console.log(i) // 2

看到結果的時候,大家是不是跟我最初剛知道這件事情的時候一樣驚訝呢?

從結果回推之後我們了解到,原來這樣子使用時, b 會先把 i 的值給記錄下來,然後 i 再執行如同 i += 1 的運算,所以 b 的值會是 i 運算前的值,而 i 則會記錄運算後的值。寫成程式碼的話有點像是這樣:

1
2
const b = i;
i += 1;

所以實驗到這邊我就明白為什麼 Angular 的原始碼會那樣寫了!

實驗 3 - ++ i

最後,我們來看看 ++ i 吧!宣告一個變數 c 用以記錄 ++ i 之後的結果:

1
const c = ++ i;

大家再來猜猜看 c 跟 i 的值分別會是什麼吧!(小提示:i 的值剛剛已經變為 2 囉!)

1
2
console.log(c) // 3
console.log(i) // 3

大家都有猜到嗎? ++ i 的結果跟 i += 1 是一樣的,都是先運算,再記錄值。

結論

從實驗結果來看,我們可以下一個結論:「在這個使用情境中,使用 i += 1 等於使用 ++ i 不等於使用 i ++

而在此之前,雖然我知道這三種方式有所差異,但其實並沒有真的很清楚的知道差異的所在,而最近自己也在改變自己讀書與學習的方式(其實也就只是好好做筆記,建立自己的知識庫罷了),希望這一點一滴的累積,都可以幫助到自己,也幫助到大家。

JavaScript 小技巧:數字字串補零時超好用的原生方法 - padStart

今天我想跟大家分享一個數字字串補零時超好用的原生方法: padStart;

當然所謂的「數字字串補零時超好用的原生方法」這件事情是我自己覺得最適合用它的應用場景,而如果你有想到更適合的應用場景也拜託請跟我分享!!

假設我們今天收到需求是不管是 0~999 之間的任何一個數字,都要以三位數的形式顯示給使用者看。例如:

  1. 如果資料是 9 的話,要顯示 009
  2. 如果資料是 99 ,要顯示 099
  3. 如果是 999 ,就顯示 999

所以,以前的我們大概會這樣寫:

1
2
3
4
5
6
7
8
9
// 隨機產生一個 0~999 的數字
const randomNum = Number((Math.random() * 1000).toFixed());

let numString = `${randomNum}`;
if (randomNum < 10) {
numString = `00${randomNum}`;
} else if (randomNum < 100) {
numString = `0${randomNum}`;
}

如果今天需求是需要一個超長的位數的話,那就會有很多個 if/else ,看起來是不是很笨?!是不是很醜?!

但有了 padStart 之後,我們只要這樣寫:

1
const num = (Math.random() * 1000).toFixed().padStart(3, "0");

一行就解決了,是不是超讚的?!!!

不管需求要幾位數,都只要改那個 3 就好,而如果不想補 "0" ,想補 "*" 也沒問題,把 "0" 改成 "*" 就好,方便的不得了!!

而這個方法在各大瀏覽器的支援程度高達 95.72% ,所以各位可以放心使用!!

瀏覽器支援程度

如果你還要支援 IE 的話就抱歉了XD

同場加映

聰明的你一定會想,既然有 padStart ,那是不是也有 padEnd

沒錯,其使用的方式一模一樣,只是改為在後面補上你想要補的字而已!!

像是:

1
2
3
4
const name = 'Leo';
const result = name.padEnd(5, "a");

console.log(result); // 'Leoaa'

是不是非常簡單呢?!

今天的 JavaScript 小技巧就到這邊囉,我們下次見,掰掰!

Reference

用 RxJS 翻轉你的 coding 人生 - 以 Timer 為例

自製梗圖

昨天同事在某個頁面裡新增了一個「背景自動刷新頁面」的功能,而我在幫他 code review 之後趕緊請他改用 RxJS 實作這個功能,這是為什麼呢?

沒有要鞭同事的意思,純粹是藉機分享!

一般實作方式

一般用 JavaScript 實作的計時器大概會長這樣:

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
var REFRESH_PERIOD_SECOND = 30;

var timer;
var counter = 0;

function startTimer() {
timer = setInterval(function () {
counter += 1;
if (counter >= REFRESH_PERIOD_SECOND) {
// do something..
resetTimer();
}
}, 1000);
}

function resetTimer() {
stopTimer();
startTimer();
}

function stopTimer() {
clearInterval(timer);
counter = 0;
}

startTimer();

完整程式碼範例: JS Bin

用 RxJS 的實作方式

換用 RxJS 來實作的話大概會長這樣(以在 Angular 裡面實作為例):

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
@Component({
// ...
})
export class AppComponent implements OnInit {
count = 0;

private readonly REFRESH_PERIOD_SECOND = 30;

private start$ = new Subject();
private stop$ = new Subject();

private timer$ = this.start$.pipe(
switchMap(() => interval(1000).pipe(map((count) => count + 1))),
tap((count) => (this.count = count)),
filter((count) => count === this.REFRESH_PERIOD_SECOND),
takeUntil(this.stop$),
repeat()
);

ngOnInit(): void {
this.timer$.subscribe(() => this.resetTimer());
this.startTimer();
}

startTimer(): void {
this.start$.next(null);
}

stopTimer(): void {
this.count = 0;
this.stop$.next(null);
}

resetTimer(): void {
this.stopTimer();
this.startTimer();
}
}

完整程式碼範例: Stackblitz

所以說,改用 RxJS 來實作到底好在哪裡呢?

我個人覺得有以下三個好處:

  1. 計時器要做的事情在初始化的時候就做好了,後續不用再重做第二次
  2. 由於有上一點的好處,所以實際在執行動作的時候,就只要用 start$stop$ 送出通知即可。
  3. 更好閱讀與更美觀

你覺得呢?

JavaScript 之那些年我們都腦補錯的事

原來都是我腦補補錯了

我有一個很尊敬的老師兼程式入門導師 ─ 馬老師,他曾經這樣跟我說過:

電腦很乖很聽話,你叫他往東他不會往西,如果它做錯了那一定你的錯!

這句話我一直放在心上並引以為鑑,所以凡遇到錯誤我一定會先仔細檢查個兩三遍,因為用膝蓋想都知道一定是我的錯,哈哈!

後來我在上另一位我同樣也很尊敬的老師兼人生導師 ─ 保哥的 JavaScript 核心教戰時,他曾經這樣跟我說:

我們在寫 JavaScript 的時候常常自己在腦補,但我們自己常常腦補錯。

所以我為什麼會寫這篇文呢?

事情是這樣子的,今天我在 Angular Taiwan 社群時看到有人發文提問:

群友提問的問題

其實我個人覺得這是一個滿好的問題,怎麼說呢?

因為這個問題其實是出在對於 JavaScript 執行機制的認知不夠深刻所造成的 「腦補補錯」 的問題。

而我個人其實有的時候也會犯這種錯誤,曾經我異想天開地想在前端做一個讓所有頁籤都能夠在同一個時間點(例如整點)才發出事件的功能,但最後就因為這個 JavaScript 的執行機制導致它一定會有誤差,這個誤差時間會隨著頁面的忙碌程度而有所不同,再加上實在是效能太差(因為每毫秒都要檢查一次),所以最終宣告失敗。

總之,藉著這個機會發文來分享 JavaScript 的執行機制給大家,已經知道的朋友們可以複習一下,而不知道的朋友們則從中可以學到新知識,希望大家都能夠從中獲得些什麼。

順帶一提,此文發佈前已徵得該群友的同意,讓我可以將他在群中提問的問題分享給大家。

不過他說他很期待我的文,希望他看到此文不會太過於失望,因為我只是單純的經驗分享,因為我覺得參考連結的影片已經講得非常詳細了 ^^”

Reference

Angular 深入淺出三十天:表單與測試 Day30 - 表單原理

Day30

經過前面二十九天的的練習與學習,相信大家應該在表單的實作上都熟悉了不少,只要不是太複雜、太特別的表單應該也都難不倒你們。

今天是本系列文的最後一天,就讓我們來好好地深入了解一下 Angular 表單會這麼強大的原因吧!

首先,我想趁大家記憶猶新時,先帶大家來看為什麼我們昨天可以用 ControlContainer 來自訂一個可以被 Template Driven Forms 或是 Reactive Forms 所使用的 Component 。

ControlContainer

Diagram

上圖是我根據 Angular 的 Source Code 找出 ControlContainer 的關係所畫的(斜體表抽象類別)。

從圖中我們會發現, ControlContainer 其實只是一個抽象類別,並繼承了另一個抽象類別 AbstractControlDirective ,而 AbstractControlDirective 這個抽象類別其實也被另一個抽象類別 NgControl 所繼承。

NgControl 晚點會提到,此處暫不多做說明。

至於 ControlContainer ,它其實也被 AbstractFormGroupDirectNgFormFormGroupDirectiveFormArrayName 這四個 Directive 所繼承;甚至 AbstractFormGroupDirect 還被 FormGroupNameNgModelGroup 這兩個 Directive 所繼承。

換句話說, Angular 根據 ControlContainer 為基底,做出了以下五個 Directive :

  • FormGroupDirective
  • FormGroupName
  • FormArrayName
  • NgForm
  • NgModelGroup

在這五個 Directive 裡,前面三個是為什麼我們在用 Reactive Forms 的方式來開發表單時,可以在 Template 裡用 [formGroup][formGroupName][formArrayName] 的方式將元素與 FormGroupFormArray 綁定的原因。

大家應該都還記得我們是怎麼將 FormGroupFormArray 綁定到元素上的吧?!

而後面兩個則是為什麼我們在用 Template Driven Forms 的方式來開發表單時,可以在 Template 裡在元素上使用 #XXX="ngForm'#XXX="ngModelGroup" 之後,可以拿到 NgFormNgModelGroup 的實體的原因。

雖然本系列文沒有特別提到 ngModelGroup 的用法,想知道的朋友可以參考官方的 NgModelGroup API 文件

那為什麼我們可以使用 ControlContainer 來自訂元件呢?

其實這正是因為上述五個 Directive 都透過 ControlContainer 這個令牌,把自己註冊到 Angular 的 DI 系統裡,讓想使用它們的類別,可以很方便地透過 Angular 的 DI 系統來找到它們的實體。

像是在昨天的文章裡所分享的那樣(Reactive Forms 的方式):

1
2
3
export class AddressInfoComponent {
constructor(private controlContainer: ControlContainer) { }
}

Template Driven Forms 則是透過 viewProvider 的方式,忘記的話請看昨天的文章

NgControl

Diagram

同樣地, Angular 也根據 NgControl 為基底,做出了以下三個 Directive :

  • FormControlName
  • FormControlDirective
  • NgModel

在這三個 Directive 裡,前面兩個是為什麼我們在用 Reactive Forms 的方式來開發表單時,可以在 Template 裡用 [formControl][formControlName] 的方式將元素與 FormControl 綁定的原因。

大家應該都還記得我們是怎麼將 FormControl 綁定到元素上的吧?!

而最後一個則是為什麼我們在用 Template Driven Forms 的方式來開發表單時,會在 Template 裡在元素上使用 #XXX="ngModel' 之後,可以拿到 NgFormNgModelGroup 的實體的原因。

不過,大家還記不記得我們在第二十八天的時候,是怎麼自訂表單元件的嗎?

沒錯!就是 ControlValueAccessor

上述三個 Directive 實作時,也是透過 DI 拿到實作了 ControlValueAccessor 介面的實體,並在初始化的時候透過 ControlValueAccessor 這個介面,搭建 FormControl 與實作了它的實體之間的溝通管道。

想看原始碼的朋友可以點我看原始碼。

如果想知道 @Self@Optional 裝飾器是幹嘛用的,可以參考這篇很前顯易懂的文章: @Self or @Optional @Host? The visual guide to Angular DI decorators.

但除了 ControlValueAccessor 之外,其實 Angular 還有其他內建的 ValueAccessor:

Diagram

從上圖中我們可以發現,內建的 ValueAccessor 基本上都是繼承於 BaseControlValueAccessor 這個類別,然後分別被 DefaultValueAccessorBuiltInControlValueAccessor 繼承,而只有 DefaultValueAccessor 有實作 ControlValueAccessor 這個介面。

然後 Angular 再基於 BuiltInControlValueAccessor 之上去建立了以下六個 ValueAccessor:

  • NumberValueAccessor
  • RangeValueAccessor
  • RadioControlValueAccessor
  • CheckboxControlValueAccessor
  • SelectControlValueAccessor
  • SelectMultipleControlValueAccessor

而這六個 ValueAccessor 對於我們的表單開發來說是至關重要的存在,沒有了它們,我們就沒辦法在這些元素上綁定我們的表單控制項。

但其實 BaseControlValueAccessorBuiltInControlValueAccessor 本身並沒有什麼比較特別的實作或定義,之所以會有 DefaultValueAccessorBuiltInControlValueAccessor 的區別,是為了在機制上,能夠做到一個優先權判斷的機制。

當我們在使用自訂的 ValueAccessor 時候, DefaultValueAccessor 或是上述六個 ValueAccessor 其實都有可能與我們自訂的 ValueAccessor 同時存在。

因此,在將我們的表單控制項與元素綁定的時候, Angular 會根據以下的優先權來抓取對應的 ValueAccessor :

  1. CustomValueAccessor
  2. BuiltInValueAccessor
  3. DefaultValueAccessor

如此一來,只要我們沒有自訂 ValueAccessor ,預設就是會使用內建的 ValueAccessor 來搭建表單控制項與元素之間的溝通橋樑。

我覺得 Angular 的開發團隊真的很聰明!

同步與非同步

除了上述的東西之外,其實還有一個比較特別的點,就是關於同步更新與非同步更新的問題。

Day16 - Template Driven Forms vs Reactive Forms 的小結裡,我分享了一個表格,表格裡提到了 Template Driven Forms 的可預測性是非同步的。

而這件事情其實可以在 NgModel原始碼第 222 行 看出一點端倪。

原始碼第 222 行這行是指 NgModel 在初始化的時候,會去設置表單控制項。

而在原始碼第 268 行中可以看到,它會判斷該 NgModel 是不是 _isStandalone ,也就是它是不是單獨存在,還是有被 <form></form> 包住。

如果是單獨存在, NgModel 的行為其實會跟 FormControlDirectiveFormControlName 一樣,因為他們會用一樣的方法設置表單控制項。

但如果不是單獨存在,它會用 NgFormaddControl 來設置表單控制項,這時,它就會是非同步的,因為他必須等到 resolvedPromise 發出 resolver 事件的時候,才會進行設置。

addControl 的實作在原始碼的第 187 行到 196 行

resolvedPromise 的定義則是在原始碼的第 27 行

我其實不太懂 Angular 為什麼要在 NgModel<form></form> 包起來的時候這樣子做,因為 resolvedPromise 其實也沒什麼特別的地方,但它們就讓 NgFormaddControlremoveControladdFormGroupremoveFormGroup 這四個方法要變成非同步的方式處理。

如果大家有興趣,或許找個時間研究一下,說不定,你就幫官方解決了一個問題呢!

本日小結

今天主要是幫本系列文做個結尾,希望能讓大家透過我的分享,更熟悉、更了解 Angular 一點。

也因為分享的關係,其實在撰寫本系列文的同時,我也得到了許多。

以前尚不熟悉的更熟悉了;以前不知道的知道了。

這或許就是所謂的「施比受更有福」吧?!

未來,還會不會再寫鐵人賽還不曉得(目前是不想再寫了,哈哈!)。

但是,寫鐵人賽真的是一個非常好的學習機會。

透過撰寫文章,來疏理自己所學,仔細咀嚼後再回饋給社群、回饋給社會。

如果你還沒寫過鐵人賽,我衷心推薦你這一輩子一定至少要寫一次鐵人賽。

最後,我想感謝訂閱我的文章、閱讀我的文章、喜歡我的文章的你們,謝謝你們不嫌棄,也謝謝你們願意讓我能夠幫到你們。

我也想感謝我的家人們,寫鐵人賽的這段期間,真的是非常地疏於陪伴,謝謝他們的支持(雖然他們看不到)。

感謝大家的收看,我們有緣再見! :)

其他資源

Angular 深入淺出三十天:表單與測試 Day29 - ControlContainer

Day29

昨天跟大家分享了自訂表單元件的作法,但昨天的作法只適用於一個欄位、一個 FormControl

雖然 FormControl 裡是可以設 {} 的值,但如果我們真的想要的是一個可以直接用 [formGroup][formArray] 所使用的元件呢?

沒問題,只要你想要, Angular 都給你

實作開始

大家還記得之前我們做了個「被保人表單」吧?

一開始只有「姓名」、「性別」跟「年齡」這三個欄位,後來我們加了「聯絡資訊」的欄位,這次我們再幫它加個「聯絡地址」的欄位吧!

一般來說,聯絡地址的欄位通常會分成「縣市」、「鄉鎮市區」、「郵遞區號」與「地址」,而「縣市」、「鄉鎮市區」與「郵遞區號」之間會有一些連動邏輯,縣市」與「鄉鎮市區」這兩個欄位也通常會是下拉選單,其他的則是一般的 input 欄位。

首先一樣先把 HTML 準備好,像這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
<p><label>聯絡地址:</label></p>
<p>
<select>
<option value="">請選擇縣市</option>
</select>
<select>
<option value="">請選擇鄉鎮市區</option>
</select>
</p>
<p>
<input type="text" style="width: 4rem" placeholder="郵遞區號">
<input type="text" place="請輸入地址">
</p>

畫面:

Template View

樣式用 inline 的方式設定是方便教學,小朋友們要盡量少用噢!

接著,在 .ts 裡加入地址的相關欄位的 FormGroupFormControl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const addressInfoFormGroup = this.formBuilder.group({
city: '',
district: '',
zip: '',
address: ''
});

return this.formBuilder.group({
name: [
'',
[Validators.required, Validators.minLength(2), Validators.maxLength(10)]
],
gender: ['', Validators.required],
age: ['', Validators.required],
contactInfoType: contactInfoTypeControl,
contactInfo: contactInfoControl,
addressInfo: addressInfoFormGroup
});

然後再將其綁與畫面上元素綁定,像這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<ng-container formGroupName="addressInfo">
<p><label>聯絡地址:</label></p>
<p>
<select formControlName="city">
<option value="">請選擇縣市</option>
</select>
<select formControlName="district">
<option value="">請選擇鄉鎮市區</option>
</select>
</p>
<p>
<input type="text" style="width: 4rem" placeholder="郵遞區號" formControlName="zip">
<input type="text" placeholder="請輸入地址" formControlName="address">
</p>
</ng-container>

連動邏輯的實作就交給大家練習囉,我們今天沒有要著重於此部分的處理。

至此,我們就完成了第一步的準備工作。

ControlContainer

接下來,我們就要將聯絡地址這塊拆成一個獨立的 Component ─ AddressInfoComponent

首先,先將 HTML 搬過去並稍微調整一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<ng-container [formGroup]="formGroup">
<p>
<select formControlName="city">
<option value="">請選擇縣市</option>
</select>
<select formControlName="district">
<option value="">請選擇鄉鎮市區</option>
</select>
</p>
<p>
<input type="text" style="width: 4rem" placeholder="郵遞區號" formControlName="zip">
<input type="text" placeholder="請輸入地址" formControlName="address">
</p>
</ng-container>

接著在 AddressInfoComponent 裡注入 ControlContainer

1
2
3
4
5
export class AddressInfoComponent {

constructor(private controlContainer: ControlContainer) { }

}

然後加上:

1
2
3
get formGroup(): FormGroup {
return this.controlContainer.control as FormGroup;
}

再回到被保人表單裡,把原本的聯絡地址區塊改成:

1
2
<p><label>聯絡地址:</label></p>
<app-address-info formGroupName="addressInfo"></app-address-info>

至此就大功告成了!是不是超簡單的?!

不過之所以這麼簡單是因為這是 Reactive Forms 的方式,今天的 ControlContainer 不像昨天的 ControlValueAccessor 可以做一次之後,兩種方式都可以使用。

如果今天這個元件是要讓 Template Driven Forms 使用的話,首先要先將 Template 原本用 Reactive Forms 的綁定方式改成使用 Template Driven Forms 的綁定方式,像是這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<ng-container ngModelGroup="addressInfo">
<p>
<select name="zip" ngModel>
<option value="">請選擇縣市</option>
</select>
<select name="district" >
<option value="">請選擇鄉鎮市區</option>
</select>
</p>
<p>
<input type="text" style="width: 4rem" placeholder="郵遞區號" name="zip" ngModel>
<input type="text" placeholder="請輸入地址" name="address" ngModel>
</p>
</ng-container>

然後也不用在 AddressInfoComponent 裡注入 ControlContainer ,而是改在 AddressInfoComponentMetaDataviewProviders 裡新增以下設定:

1
2
3
4
5
6
7
8
9
10
11
12
@Component({
selector: 'app-address-info',
templateUrl: './address-info.component.html',
styleUrls: ['./address-info.component.scss'],
viewProviders:[
{
provide: ControlContainer,
useExisting: NgForm
}
]
})
export class AddressInfoComponent {

這樣就能直接用 <app-address-info></app-address-info> 的方式使用這個元件了。

大家覺得,是 Reactive Forms 的方式好用,還是 Template Driven Forms 的方式好用呢?

本日小結

今天的重點主要是讓大家知道要怎麼使用 ControlContainer 這個類別來包裝我們的元件,以達到提昇重用性維護性的目的。

雖然麻煩的是,它沒辦法像昨天分享的 ControlValueAccessor 一樣,做好了之後可以適用於 Template Driven FormsReactive Forms ,但好在它的用法其實頗為簡單,主要的差異就只有在 Template Driven Forms 需要靠 viewProvider ,而 Reactive Froms 只要注入就行。

關於 viewProvider 與 provider 的差異,我推薦大家可以去看 Kevin (台灣 Angular GDE)的 [Angular] viewProviders V.S. providers ,我覺得寫得非常的清楚。

此外,如果覺得我分享不好,也可以參考 Kevin 的 [Angular] ControlContainer 的應用

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

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

Angular 深入淺出三十天:表單與測試 Day28 - 自訂表單元件

Day28

經過了這段時間的練習與學習,相信大家應該越來越能體會 Angular 表單的強大與便利。

不過既然 Angular 表單這麼好用,如果能讓自己做的 Component 也像 Angular 表單那樣一般使用該有多好?

因此,今天想要跟大家分享的是 ─ 如何自訂表單元件

應用場景

大家跟我一起想像一下,假設我們今天需要做一個管理平台,在這個管理平台裡,會有很多地方都會需要用到我們昨天做的 DateRangeComponent ,但不一定會是在同一個表單裡,只是剛好也需要 startDateendDate 這兩個欄位,而且畫面與欄位驗證的規則也都是一樣。

例如:

A 頁面是一個查詢訂單系統, B 頁面是查詢會員系統,雖然這兩個頁面的查詢條件可能都不太一樣,但恰好都可以根據起迄日來查詢相應的資料。

這時,我們很有可能就會將我們做好的 DateRangeComponent 做成表單元件,讓 A 跟 B 在使用它的時候,就像使用一般的表單元件一樣輕鬆、自然。

那究竟要怎麼做呢?

ControlValueAccessor

首先要介紹給大家認識的是 ControlValueAccessor ,它是個 Interface ,而它定義了以下四個函式:

1
2
3
4
5
6
interface ControlValueAccessor {
writeValue(obj: any): void
registerOnChange(fn: any): void
registerOnTouched(fn: any): void
setDisabledState(isDisabled: boolean)?: void
}
  • writeValue(obj: any): void表單控件想要將值寫入時,會呼叫此函式
  • registerOnChange(fn: any): void表單控件初始化時會呼叫此函式,並傳入一個回呼函式,讓實作此介面的類別在其值有變動時,使用該回呼函式並傳入欲變動的值
  • registerOnTouched(fn: any): void表單控件初始化時會呼叫此函式,並傳入一個回呼函式,讓實作此介面的類別在失去焦點時,使用該回呼函式以通知表單控件
  • setDisabledState(isDisabled: boolean)?: void ─ 當表單控件的狀態變成 DISABLED 抑或是從 DISABLED 改變成其他狀態時,會呼叫此函式以通知實作此介面的類別

雖然我覺得我說的滿清楚的,但大家應該還是覺得很模糊,對吧?

不要緊,我只是先讓大家有個印象,待會實作時大家就會更加理解了。

實作開始

首先,我們需要另一個 Component 來用我們昨天做好的 DateRangeComponent ,像這樣:

1
2
3
<form *ngIf="formGroup" [formGroup]="formGroup">
<app-date-range formControlName="dateRange"></app-date-range>
</form>

然後在 Component 的 .ts 裡準備好 FormGroup ,像這樣:

1
2
3
4
5
6
7
8
9
10
11
export class ReactiveFormsDateRangeComponent implements OnInit {

formGroup: FormGroup | undefined;

constructor(private formBuilder: FormBuilder) { }

ngOnInit(): void {
this.formGroup = this.formBuilder.group({ dateRange: '' });
}

}

接著打開昨天做的 DateRangeComponent ,並在 implements 的後方加上 ControlValueAccessor ,像這樣:

1
2
3
export class DateRangeComponent implements OnInit, ControlValueAccessor {
// ...
}

這時你應該會發現 DateRangeComponent 出現了一條紅色毛毛蟲,當你把滑鼠游標移到上面的時候,它說:

The Tooltip

這是因為我們為 DateRangeComponent 加上實作 ControlValueAccessor 的宣告後,編輯器提醒我們要記得實作 ControlValueAccessor 的四個函式,才符合該介面的定義。

這就像是我們如果想要 Cosplay 鋼鐵人,但我什麼盔甲都沒穿就說自己是鋼鐵人,別人只會覺得滿臉問號。

但只要我們戴上了頭盔,別人就會知道你在扮演鋼鐵人。

所以我們就在 DateRangeComponent 裡加上以下四個函式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export class DateRangeComponent implements OnInit, ControlValueAccessor {

// ...

writeValue(obj: any): void {
console.log('writeValue', obj);
}

registerOnChange(fn: any): void {
console.log('registerOnChange', fn);
}
registerOnTouched(fn: any): void {
console.log('registerOnTouched', fn);
}

setDisabledState(isDisabled: boolean): void {
console.log('setDisabledState', isDisabled);
}

}

接下來,我們需要在 DateRangeComponentMetaData 裡的 providers 裡加入一些設定,像這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component({
selector: 'app-date-range',
templateUrl: './date-range.component.html',
styleUrls: ['./date-range.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DateRangeComponent),
multi: true
}
]
})
export class DateRangeComponent implements OnInit, ControlValueAccessor {
// ...
}

我們之前其實也曾經在第二十五天的文章 ─ 測試進階技巧 - DI 抽換裡用過類似的技巧。

簡單來說,這個設定是為了讓表單可以透過 NG_VALUE_ACCESSOR 這個 InjectionToken 取得我們這個實作了 ControlValueAccessor 介面的 DateRangeComponent 實體。

想知道什麼是 InjectionToken 的朋友,可以參考 Mike 的 [Angular 大師之路] Day 23 - 認識 InjectionToken

想知道 useExistinguseValueuseClassuseFactory 有哪裡不一樣的,也可以參考 Mike 的 [Angular 大師之路] Day 20 - 在 @NgModule 的 providers: [] 自由更換注入內容 (1)[Angular 大師之路] Day 21 - 在 @NgModule 的 providers: [] 自由更換注入內容 (2)

forwardRef() 的部份,我覺得官網的 Dependency injection in action - Break circularities with a forward class reference 講得比較清楚。

最後的 multi: true ,可以參考林穎平 EP 的 [Day 8] 所以我說那個 multi 是? ,如果想要更深入的了解其原理,他也寫了一篇 [Day 10] 深度看一下 Angular 建立 multi provider 的機制(真的很深入)

至此,我們就可以儲存檔案來看一下初始化完後會印出的 Log :

Log

接著我們在使用 DateRangeComponent 的 Component 裡加上以下程式碼以觀察其運作結果:

1
2
3
4
5
6
7
8
9
10
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
dateRange: ''
});
setTimeout(() => {
console.log('---- 3秒後 ----');
this.formGroup?.setValue({ dateRange: 'Leo' });
this.formGroup?.disable();
}, 3000);
}

然後我們會發現:

Log

這樣大家有比較了解一開始關於 ControlValueAccessor 各函式的說明了嗎?

如果用圖示的話,現在的結構大概像這樣:

Image 1

如果我們設值給 FormControl 時,則會觸發 ControlValueAccessor 的函式 writeValue

Image 2

如果我們 disableenable 了該 FormControl ,則會觸發 ControlValueAccessor 的函式 setDisabledState

Image 3

而如果使用者改動了自訂的表單元件的值,則我們自訂的表單元件應該要呼叫透過初始化時所觸發的 registerOnChange 所傳入的 fn 去通知 FormControl

Image 4

讀萬卷書不如行萬里路。接下來,我們把剩下的實作做完就會更了解這其中的運作流程了!

首先,先加工一下使用 DateRangeComponent 的 Component :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export class ReactiveFormsDateRangeComponent implements OnInit {

formGroup: FormGroup | undefined;

constructor(private formBuilder: FormBuilder) { }

ngOnInit(): void {
const date = new Date();
this.formGroup = this.formBuilder.group({
dateRange: `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
});
}

enable(): void {
this.formGroup?.enable();
}

disable(): void {
this.formGroup?.disable();
}

}

Template 的部份也加工一下:

1
2
3
4
5
6
7
8
<form *ngIf="formGroup" [formGroup]="formGroup">
<app-date-range formControlName="dateRange"></app-date-range>
<p>
<button type="button" [disabled]="formGroup.disabled" (click)="disable()">DISABLE</button>
<button type="button" [disabled]="formGroup.enabled" (click)="enable()">ENABLE</button>
</p>
</form>
<pre>{{ formGroup?.getRawValue() | json }}</pre>

然後把 DateRangeComponent 改成這樣:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
export class DateRangeComponent implements OnInit, ControlValueAccessor {

formGroup: FormGroup | undefined;

fnFormRegisterOnChange: ((dateString: string) => void) | undefined;
fnFormRegisterOnTouched: (() => void) | undefined;

constructor(private formBuilder: FormBuilder) { }

ngOnInit(): void {
this.formGroup = this.formBuilder.group({
startDate: ['', [Validators.required, Validators.pattern(/^\d{4}-\d{2}-\d{2}$/)]],
endDate: ['', Validators.pattern(/^\d{4}-\d{2}-\d{2}$/)]
}, { validators: dateRangeValidator });

this.formGroup.valueChanges.subscribe(({ startDate, endDate }) => {
let dateString = startDate;
if (endDate) {
dateString += `, ${endDate}`;
}
if (this.formGroup?.errors) {
dateString = '';
}
if (this.fnFormRegisterOnChange) {
this.fnFormRegisterOnChange(dateString);
}
});
}

writeValue(dateRangeString: string): void {
const [startDate, endDate] = dateRangeString.split(', ');
this.formGroup?.patchValue({ startDate, endDate }, {
emitEvent: false
});
}

registerOnChange(fn: (dateRangeString: string) => void): void {
this.fnFormRegisterOnChange = fn;
}
registerOnTouched(fn: () => void): void {
this.fnFormRegisterOnTouched = fn;
}

setDisabledState(isDisabled: boolean): void {
if (isDisabled) {
this.formGroup?.disable();
} else {
this.formGroup?.enable();
}
}

}

結果:

Result

對了,這樣的作法不僅僅只適用於 Reactive Forms 噢!大家可以在使用 DateRangeComponent 的時候用 Template Driven Forms 的方式試試看,也是行得通的唷!

本日小結

今天的實作練習應該滿好玩的吧?

我能理解大家第一次碰到的時候都會比較難以理解,記得我第一次碰到的時候,也只是複製人家的程式碼然後貼上而已,根本就不是了解其運作原理。

因此,希望我今天的文章能讓大家可以不僅僅只是複製貼上,而是對於其流程與原理有所掌握與理解。

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

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

Angular 深入淺出三十天:表單與測試 Day27 - Reactive Forms 進階技巧 - 跨欄位驗證

Day27

今天想要跟大家分享的是跨欄位驗證的小技巧,這個小技巧其實沒有多厲害或多特別,只是可能滿多人剛好不知道原來可以這樣用。

而我們在 Day 23 - Reactive Forms 進階技巧 - 欄位連動檢核邏輯 所分享過欄位連動檢核邏輯的部份,就某方面來說,其實也可以使用這種方式來做,但究竟要適不適合、要不要使用,我覺得一切都還是要看需求、看想給使用者什麼樣的使用體驗來決定。

畢竟系統是為了服務需求而存在,至於能不能做到、能不能解決問題就看工程師的功力囉。

實作開始

言歸正傳,我們今天要做的功能是起迄日日期欄位檢核

感謝我的朋友 ─ Joseph 所提供的案例讓我多活了一天

規格需求

詳細規格需求如下:

  • 起日
    • 必填,驗證有誤時需顯示錯誤訊息: 此欄位必填
    • 格式需為 yyyy-MM-dd ,驗證有誤時需顯示錯誤訊息: 日期格式不正確
    • 需為確切存在的日期,驗證有誤時需顯示錯誤訊息: 此日期不存在
  • 迄日
    • 非必填
    • 格式需為 yyyy-MM-dd ,驗證有誤時需顯示錯誤訊息: 日期格式不正確
    • 需為確切存在的日期,驗證有誤時需顯示錯誤訊息: 此日期不存在
    • 迄日不可早於起日,驗證有誤時需顯示錯誤訊息: 迄日不可早於起日
    • 迄日不可晚於起日超過七天,驗證有誤時需顯示錯誤訊息: 迄日不可晚於起日超過七天
  • 以上驗證皆需在使用者輸入時動態檢查

準備畫面

接下來我們先把畫面準備好, HTML 如下:

1
2
3
4
5
6
7
8
9
10
<form>
<p>
<label for="start-date">起日:</label>
<input type="text" id="start-date" placeholder="yyyy-mm-dd">
</p>
<p>
<label for="end-date">迄日:</label>
<input type="text" id="end-date" placeholder="yyyy-mm-dd">
</p>
</form>

畫面應該會長這樣:

Template View

我知道一般大家在實作的時候會用漂亮的 UI 套件,不過我們現在主要聚焦在功能面,所以欄位的部份我只用簡單的 <input type="text"> 的方式實作。

其實我本來想至少用 <input type="date"> 來實作的,但它會害我們無法判斷使用者到底有沒有輸入值,所以最後還是放棄了使用它的打算。

準備 FormGroup

接著把 Reactive Forms 的 FormGroup 也準備好:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export class ReactiveFormsDateRangeComponent implements OnInit {

formGroup: FormGroup | undefined;

constructor(private formBuilder: FormBuilder) { }

ngOnInit(): void {
this.formGroup = this.formBuilder.group({
startDate: [
'', [
Validators.required,
Validators.pattern(/^\d{4}-\d{2}-\d{2}$/)
]
],
endDate: ['', Validators.pattern(/^\d{4}-\d{2}-\d{2}$/)]
});
}

}

其實這邊的 Validators.requiredValidators.pattern(/^\d{4}-\d{2}-\d{2}$/) 可以不加,只不過後續就要把判斷寫在另一個地方,看大家想要稍稍彈性一點,還是直接寫死在另外一個地方都可以。

然後綁定到 Template 的表單上:

1
2
3
4
5
6
7
8
9
10
<form *ngIf="formGroup" [formGroup]="formGroup">
<p>
<label for="start-date">起日:</label>
<input type="text" id="start-date" placeholder="yyyy-mm-dd" formControlName="startDate">
</p>
<p>
<label for="end-date">迄日:</label>
<input type="text" id="end-date" placeholder="yyyy-mm-dd" formControlName="endDate">
</p>
</form>

再次提醒大家,在使用 Reactive Forms 的方式來開發表單時,請記得到 .module.ts 裡的引入 FormsModuleReactiveFormsModule

大家不要覺得我像老頭子一樣囉哩囉嗦的,都已經做了幾次的練習了還要一直提醒大家記得引入 FormsModuleReactiveFormsModule

相信我,如果我沒提醒,一定會有很多還不是很熟悉的朋友會卡住。

所以大家互相體諒包容一下,熟悉的朋友快速略過就好。

自訂驗證器 ─ dateRangeValidator

接著我們來用昨天分享過的自訂驗證器的的技巧來自訂一個名為 dateRangeValidator 的驗證器,程式碼如下:

1
2
3
4
export const dateRangeValidator: ValidatorFn = (formGroup) => {
console.log(formGroup.value);
return null
};

先把它掛在 FormGroup 上:

1
2
3
4
5
6
7
8
9
this.formGroup = this.formBuilder.group({
startDate: [
'', [
Validators.required,
Validators.pattern(/^\d{4}-\d{2}-\d{2}$/)
]
],
endDate: ['', Validators.pattern(/^\d{4}-\d{2}-\d{2}$/)]
}, { validators: dateRangeValidator });

然後我們就可以在控制台裡看到 ─ 當 FormGroup 裡的欄位的值有變動時,就會觸發我們自訂的驗證器:

Template View

一開始的四個 { startDate: '', endDate: '' } 是 FormGroup 在初始化的時後所觸發的。

其實今天要做的這個功能最關鍵、最重要的兩件事情就是:

  1. 實作自訂驗證器
  2. 把它掛在 FormGroup 上

所以我們已經做完了,今天的文章就分享到這邊。

謎之音:喂!你給我回來!(抓回)

接下來在實作驗證器之前,我想先制定該驗證器在驗證有誤時,所要回傳的 ValidationErrors 格式。

制定驗證器的 ValidationErrors

之所以想要先制定 ValidationErrors 的格式,一方面是因為待會實作驗證邏輯的時候需要用到;另一方面則是因為這個格式如果訂得好,後續實作時會輕鬆許多。

我的預期是這樣:

1
2
3
4
5
6
{
dateRange: {
startDate: null | ValidationErrors;
endDate: null | ValidationErrors;
}
}

這樣制定的意思是,如果起日欄位沒有錯誤,則 dateRange.startDate 的值會是 null ;而如果迄日欄位沒有錯誤,則 dateRange.endDate 的值會是 null ;又如果兩個欄位都沒錯誤,則該驗證器就會直接回傳 null

反之,如果起日欄位有錯誤,則 dateRange.startDate 的值會是我們接下來要制定的錯誤;迄日欄位亦然。

如此一來,如果驗證有誤時,我們比較能夠從驗證器所回傳的 ValidationErrors 來解析是哪個欄位有誤。

dateRange.startDatedateRange.endDate 究竟會有哪些錯誤呢?

先複習一下規格:

  • 起日
    • 必填,驗證有誤時需顯示錯誤訊息: 此欄位必填
    • 格式需為 yyyy-MM-dd ,驗證有誤時需顯示錯誤訊息: 日期格式不正確
    • 需為確切存在的日期,驗證有誤時需顯示錯誤訊息: 此日期不存在
  • 迄日
    • 非必填
    • 格式需為 yyyy-MM-dd ,驗證有誤時需顯示錯誤訊息: 日期格式不正確
    • 需為確切存在的日期,驗證有誤時需顯示錯誤訊息: 此日期不存在
    • 迄日不可早於起日,驗證有誤時需顯示錯誤訊息: 迄日不可早於起日
    • 迄日不可晚於起日超過七天,驗證有誤時需顯示錯誤訊息: 迄日不可晚於起日超過七天

除了必填與日期格式的部份已經用官方提供的 Validator 外,其他的錯誤應該就剩下:

  1. 不存在的日期:

    1
    2
    3
    {
    inexistentDate: true
    }
  2. 迄日早於起日:

    1
    2
    3
    {
    lessThanStartDate: true;
    }
  3. 迄日晚於起日七天

    1
    2
    3
    4
    5
    6
    {
    greaterThanStartDate: {
    actualGreater: 8
    requiredGreater: 7
    }
    }

以上格式是我自訂的,大家可以不用跟我一樣沒關係。

接著我們可以把錯誤訊息稍微訂個 type ,以便後續使用:

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
39
40
export type DateRangeValidationErrors = {
dateRange: {
startDate: null | DateErrors;
endDate: null | DateErrors;
}
};

export type DateErrors =
| RequiredError
| PatternError
| InexistentDateError
| LessThanStartDateError
| GreaterThanStartDateError
| ValidationErrors;

export type RequiredError = {
required: true;
};

export type PatternError = {
pattern: {
actualValue: string;
requiredPattern: string;
}
};

export type InexistentDateError = {
inexistentDate: true;
};

export type LessThanStartDateError = {
lessThanStartDate: true;
};

export type GreaterThanStartDateError = {
greaterThanStartDate: {
actualGreater: number;
requiredGreater: number;
}
};

如此一來,我們差不多就可以開始來寫驗證器的邏輯囉!

實作驗證器的邏輯

首先,我們先處理判斷使用者所輸入的日期是否真實存在的邏輯。

舉例來說,大家覺得 2021-02-29 這個日期是存在的嗎?大家應該翻一下年曆就會知道,今年不是閏年,所以二月不會有第二十九天。

但是如果單純用 Date 來判斷,它其實可以算得出來:

1
2
console.log(new Date('2021-02-29'));
// Mon Mar 01 2021 08:00:00 GMT+0800 (Taipei Standard Time)

2021-02-31 呢?

1
2
console.log(new Date('2021-02-31'));
// Mon Mar 03 2021 08:00:00 GMT+0800 (Taipei Standard Time)

為什麼會這樣呢?

以上述例子來說, 用字串來建立 Date 的時候,它只會幫我們驗證兩件事情:

  1. 月份不可以超過 12
  2. 日期不可以超過 31

只要合乎上述這兩件事情,它就不會是 Invalid Date

那年份呢?我很無聊的幫大家試了一下,可以到 275759 年唷!

為了處理這件事情,我很偷懶的 Google 了一下大家的解法,最後借用了 Summer。桑莫。夏天JavaScript:檢查日期是否存在文中的程式碼,並且稍稍調整了一下以符合我的需求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export const isDateExist = (dateString: string) => {
const dateObj = dateString.split('-'); // yyyy-mm-dd

//列出12個月,每月最大日期限制
const limitInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];

const theYear = parseInt(dateObj[0]);
const theMonth = parseInt(dateObj[1]);
const theDay = parseInt(dateObj[2]);
const isLeap = new Date(theYear, 2, 0).getDate() === 29; // 是否為閏年?

if (isLeap) {
// 若為閏年,最大日期限制改為 29
limitInMonth[1] = 29;
}

// 月份不可以大於 12, 並比對該日是否超過每個月份最大日期限制
return theMonth < 12 && theDay <= limitInMonth[theMonth - 1];
}

感謝每一個願意分享的朋友。

準備萬全之後,再來就是把驗證器的判斷邏輯補完:

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
39
40
41
42
43
44
45
export const dateRangeValidator: ValidatorFn = (formGroup) => {
const startDateControl = formGroup.get('startDate')!;
const endDateControl = formGroup.get('endDate')!;

let errors: DateRangeValidationErrors = {
dateRange: {
startDate: null,
endDate: null,
}
};

if (startDateControl.errors) {
errors.dateRange.startDate = startDateControl.errors;
} else if (!isDateExist(startDateControl.value)) {
errors.dateRange.startDate = { inexistentDate: true };
}

if (endDateControl.errors) {
errors.dateRange.endDate = endDateControl.errors;
} else if (endDateControl.value) {
if (!isDateExist(endDateControl.value)) {
errors.dateRange.endDate = { inexistentDate: true };
} else if (!errors.dateRange.startDate) {
const startDateTimeStamp = new Date(startDateControl.value).getTime();
const endDateTimeStamp = new Date(endDateControl.value).getTime();
const dayInMilliseconds = 24 * 60 * 60 * 1000;
const duration = 7 * dayInMilliseconds;
if (endDateTimeStamp < startDateTimeStamp) {
errors.dateRange.endDate = { lessThanStartDate: true };
} else if (endDateTimeStamp - duration > startDateTimeStamp) {
errors.dateRange.endDate = {
greaterThanStartDate: {
actualGreater: (endDateTimeStamp - startDateTimeStamp) / dayInMilliseconds,
requiredGreater: 7
}
}
}
}
}

if (!errors.dateRange.startDate && !errors.dateRange.endDate) {
return null;
}
return errors;
};

結果:

Template View

看起來效果不錯,接下來就是把錯誤訊息接上囉!

ErrorMessagePipe

關於錯誤訊息的部份,今天就不把邏輯寫到 Component 的 .ts 裡了,來做個 ErrorMessagePipe 吧!

程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Pipe({
name: 'errorMessage',
})
export class ErrorMessagePipe implements PipeTransform {
transform(errors: null | DateErrors, ...args: unknown[]): string {
if (errors) {
if ((errors as RequiredError).required) {
return '此欄位必填';
} else if ((errors as PatternError).pattern) {
return '日期格式不正確';
} else if ((errors as InexistentDateError).inexistentDate) {
return '此日期不存在';
} else if ((errors as LessThanStartDateError).lessThanStartDate) {
return '迄日不可早於起日';
} else if ((errors as GreaterThanStartDateError).greaterThanStartDate) {
return '迄日不可晚於起日超過七天';
}
}
return '';
}
}

接著再到 Template 將其接上:

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
<h1>Reactive Forms 進階技巧 ─ 跨欄位驗證</h1>
<form *ngIf="formGroup" [formGroup]="formGroup">
<p>
<label for="start-date">起日:</label>
<input
type="text"
id="start-date"
placeholder="yyyy-mm-dd"
formControlName="startDate"
/>
<span
class="error-message"
*ngIf="formGroup.errors && formGroup.dirty"
>
{{ formGroup.errors.dateRange.startDate | errorMessage }}
</span>
</p>
<p>
<label for="end-date">迄日:</label>
<input
type="text"
id="end-date"
placeholder="yyyy-mm-dd"
formControlName="endDate"
/>
<span
class="error-message"
*ngIf="formGroup.errors && formGroup.dirty"
>
{{ formGroup.errors.dateRange.endDate | errorMessage }}
</span>
</p>
</form>

最終成果:

Template View

本日小結

今天主要想告訴大家的是 FormGroupFormArray 以及 FormControl 其實都可以設定 ValidatorAsyncValidator ,不管是在初始化時就設定還是初始化後再動態設定都沒問題。

但可能是因為沒有遇過需要用到的場景,所以滿多對 Reactive Forms 還不太熟的朋友還是會不知道。

雖說今天主要想讓大家的知道的是 FormGroup 上也可以設定 ValidatorAsyncValidator ,但寫著寫著又不知不覺寫了很多東西,希望這些東西都有幫助到大家。

此外, Template Driven Forms 當然也是可以跨欄位驗證,不過由於之前已經說過不會再分享 Template Driven Forms 的關係,所以有興趣的朋友可以參考官方的 Form Validation - Adding cross-validation to template-driven forms 的文件。

早知道就不要說不再分享,害自己少了好多篇可以寫,失策!

對了,測試大家可以練習寫寫看,我就不實作給大家看囉!

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

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

Angular 深入淺出三十天:表單與測試 Day26 - 進階表單開發技巧 - 自訂驗證器

Day26

之前在開發表單的時候,我們都是使用 Angular 所提供的驗證器來驗證表單欄位裡的值是否符合我們的需求。

雖然 Angular 已經這麼貼心地提供了這麼多驗證了,但每個國家、地區的人文風土民情都不同,還有太多太多需要我們自己自訂規則才能符合需求的情況。

因此,今天我們就一起來看看要怎麼自訂驗證器吧!

自訂驗證器的型別

既然要自訂驗證器,就不能不知道驗證器的型別與其定義。

其實之前在第三天的文章:Reactive Forms 實作 - 以登入為例 裡就有提到驗證器的型別。

ValidatorFn

驗證器的型別是 ValidatorFn ,其原始碼定義如下:

1
2
3
interface ValidatorFn {
(control: AbstractControl): ValidationErrors | null
}

從定義中我們可以知道,驗證器其實就只是一個函式,該函式會傳入一個型別為 AbstractControl 的參數來讓我們在函式中判定該欄位的值是否符合我們的需求。

如果驗證結果符合需求,那就回傳 null ,代表沒有任何的錯誤;如果驗證結果不符合需求那就回傳一個型別為 ValidationErrors 的錯誤。

ValidationErrors 又是什麼呢?

ValidationErrors

ValidationErrors 之前最早是在第二天的文章: Template Driven Forms 實作 - 以登入為例 裡登場。

其原始碼定義如下:

1
2
3
type ValidationErrors = {
[key: string]: any;
};

沒錯,你沒看錯,就是這麼簡單!

從定義上看起來,基本上只要是個物件,就符合該型別的要求,而這也是因為滿足客製的條件,讓使用 Angular 的開發者有程度的規範但擁有盡可能大的彈性。

不過雖然大家可以隨意自訂,但我非常建議大家在自訂的時候可以參考官方的驗證器。

舉例來說,官方的 Validators.required 驗證器在驗證有誤時,會回傳的 ValidationErrors 是:

1
{ required: true }

Validators.pattern 驗證器在驗證有誤時,會回傳的 ValidationErrors 是:

1
2
3
4
{
actualValue: 'xxx',
requiredPattern: 'xxx'
}

Validators.minlengthValidators.minlength 驗證器在驗證有誤時,則會回傳:

1
2
3
4
{
actualLength: 1,
requiredLength: 2
}

我們從中不難發現官方會在 ValidationErrors 中,回傳該欄位當前的狀態以及需求的狀態;而物件的屬性名稱也會按照 actual 加上 XXXX 以及 required 加上 XXXX 的方式來命名。

雖然具體上還是要看實際需求,但我個人覺得我們自己在自訂驗證器的 ValidationErrors 時,也可以照著這個規則來處理。

一方面,整個系統會比較一致;另一方面,也不需要多寫太多額外的程式來處理我們自訂的錯誤。

舉個例子,如果我們想自訂一個欄位的值只能是 Leo 的驗證器,其程式碼可能會像是這樣:

1
2
3
4
5
6
7
8
9
10
export leoValidator: ValidatorFn = (control: AbstractControl) => {
const isLeo = control.value === 'Leo';
if (isLeo) {
return null
}
return {
actualValue: control.value,
requiredValue: 'Leo'
};
};

如此我們就在需要用到它時,直接像這樣使用即可:

1
new FormControl('', leoValidator);

又或者是彈性一點,讓使用它的人來決定該欄位的值只能是什麼,其程式碼應該會像是這樣:

1
2
3
4
5
6
7
8
9
10
11
export function nameValidator(name: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (control.value === name) {
return null
}
return {
actualValue: control.value,
requiredValue: name
};
};
}

然後就可以像這樣使用:

1
new FormControl('', nameValidator('Leo'));

不過, ValidatorFn 是用同步的方式來執行驗證,萬一遇到需要非同步驗證的情況要怎麼辦?

非同步驗證器

大家應該都滿喜歡玩遊戲的吧?!

謎之音:不要自己愛玩就認為別人都愛玩

絕大多數的遊戲,尤其是線上遊戲,在取名時不能夠取跟別人相同的名字,而當取到跟別人一樣的名字時,系統會提示「此名稱已被使用」之類的錯誤訊息。

面對這種應用場景,相信大部分的朋友可能會是使用 valueChanges 來訂閱欄位的變化事件,當使用者輸入名稱時,會呼叫 API 讓後端來幫忙驗證該名字是否已被使用,然後再根據回傳結果來決定是否顯示錯誤訊息。

畢竟前端不可能事先取得幾千、幾萬甚至是幾十萬、幾百萬的名字再一一比對吧?

不過如果有這樣的應用場景,我個人覺得還滿適合使用非同步驗證器來處理的。

順帶一提,這只是我個人舉例,不代表真實應用情況。

AsyncValidator

1
2
3
4
5
6
7
interface AsyncValidator extends Validator {
validate(control: AbstractControl): Promise<ValidationErrors | null> | Observable<ValidationErrors | null>

// inherited from forms/Validator
validate(control: AbstractControl): ValidationErrors | null
registerOnValidatorChange(fn: () => void)?: void
}

從上述定義可以看出,跟同步的驗證器所不一樣的是,我們在自訂非同步的驗證器時,不是直接製作一個符合 AsyncValidatorFn 定義的函式

而是要用一個可被注入的 Class 來實作 AsyncValidator 這個介面。

就像下面這個官網的範例一樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Injectable({ providedIn: 'root' })
export class UniqueAlterEgoValidator implements AsyncValidator {
constructor(private heroesService: HeroesService) {}

validate(
ctrl: AbstractControl
): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> {
return this.heroesService.isAlterEgoTaken(ctrl.value).pipe(
map(isTaken => (isTaken ? { uniqueAlterEgo: true } : null)),
catchError(() => of(null))
);
}
}

使用方式

為我們欄位設定非同步驗證器的方式也非常地簡單。

FormControl 來說, 我們可以用這樣子的方式來設定:

1
new FormControl('', [/* 一般驗證器 */], [/* 非同步驗證器 */]);

簡單來說,不論是用 Reactive Forms 的哪種方式建立欄位,非同步驗證器都是放在一般驗證器後面就對了!

本日小結

希望透過今天的分享,能讓大家可以初步掌握自訂驗證器的技巧。雖然在實務上,大家不一定遇的到需要使用非同步驗證器的場景,但如果真的有需要用到又忘記怎麼做時,至少有這篇文章在,隨時都可以回來查詢。

此外,雖然沒有分享自訂 Template Driven Forms 驗證器的方式,但大家可以自行參考官方的 Form Validation - Adding custom validators to template-driven forms 文件。

而在非同步驗證器的部份,官網也有提到一些優化非同步驗證器的技巧,大家可以參考官方的 Form Validation - Optimizing performance of async validators 文件。

以上,就是今天的文章,如果有任何的問題或是回饋,還請麻煩留言給我讓我知道,感激不盡!

Angular 深入淺出三十天:表單與測試 Day25 - 測試進階技巧 - DI 抽換

Day25

好一陣子沒寫單元測試與整合測試了,大家是否覺得有些生疏了呢?

之前的測試都寫得很簡單,正好昨天好好地寫了搜尋輸入框還有呼叫 API ,可以藉由撰寫這個功能的測試來分享一些小技巧給大家。

小提醒:昨天的程式碼大家可以從 Github - Branch: day24 上 Clone 或者是 Fork 下來。

實作開始

這次要撰寫測試的檔案比較多,有三個 Pipe 、 一個 Service 與一個 Component 的測試需要撰寫。

不過雖然檔案比較多,但要撰寫的測試其實不會比較難,相反地,由於我們昨天在開發的時候有把邏輯切到各個 PipeService ,因此凡而在撰寫測試上會顯得更加地好寫。

測試單元 - BooleanInZhTwPipe

首先,我們來看看最簡單的 BooleanInZhTwPipe ,其程式碼如下:

1
2
3
4
5
6
7
export class BooleanInZhTwPipe implements PipeTransform {

transform(value: boolean, ...args: unknown[]): string {
return value ? '是' : '否';
}

}

BooleanInZhTwPipe 只有一個函式 transform ,因此我們只要驗證:

  1. 當傳入的 valuetrue 時,則回傳
  2. 當傳入的 valuefalse 時,則回傳

夠簡單了吧?

測試程式碼如下:

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
describe('BooleanInZhTwPipe', () => {
let pipe: BooleanInZhTwPipe;
beforeEach(() => {
pipe = new BooleanInZhTwPipe();
});

it('create an instance', () => {
expect(pipe).toBeTruthy();
});

describe('transform', () => {
describe('when the first parameter is `true`', () => {
it('should return "是"', () => {
// Arrange
const firstParameter = true;
const expectedResult = '是';
// Acc
const actualResult = pipe.transform(firstParameter);
// Assert
expect(actualResult).toBe(expectedResult);
});
});

describe('when the first parameter is `false`', () => {
it('should return "否"', () => {
// Arrange
const firstParameter = false;
const expectedResult = '否';
// Acc
const actualResult = pipe.transform(firstParameter);
// Assert
expect(actualResult).toBe(expectedResult);
});
});
});
});

測試結果:

Testing Result

測試單元 - GoogleMapLinkPipe

GoogleMapLinkPipe 的部份也很簡單,其程式碼如下:

1
2
3
4
5
6
7
export class GoogleMapLinkPipe implements PipeTransform {

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

}

而其驗證項目只需要驗證將傳入的第一個參數的 PositionLatPositionLong 是否有與 URL 相結合即可。

其測試程式碼如下:

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
describe('GoogleMapLinkPipe', () => {
let pipe: GoogleMapLinkPipe;
beforeEach(() => {
pipe = new GoogleMapLinkPipe();
});

it('create an instance', () => {
expect(pipe).toBeTruthy();
});

describe('transform', () => {
describe('when the first parameter is `true`', () => {
it('should return "https://www.google.com/maps?q=2.34567,12.34567&z=7"', () => {
// Arrange
const firstParameter: StationPosition = {
PositionLon: 12.34567,
PositionLat: 2.34567,
GeoHash: 'abcdefg'
};
const expectedResult = 'https://www.google.com/maps?q=2.34567,12.34567&z=7';
// Acc
const actualResult = pipe.transform(firstParameter);
// Assert
expect(actualResult).toBe(expectedResult);
});
});
});
});

測試結果:

Testing Result

測試單元 - LocationStringPipe

最後一個 Pipe ─ LocationStringPipe 的程式碼如下:

1
2
3
4
5
6
7
export class LocationStringPipe implements PipeTransform {

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

}

其驗證項目只需要驗證將傳入的第一個參數的 PositionLatPositionLong 是否有變成字串並在其中加上逗號即可。

其測試程式碼如下:

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
describe('LocationStringPipe', () => {
let pipe: LocationStringPipe;
beforeEach(() => {
pipe = new LocationStringPipe();
});

it('create an instance', () => {
const pipe = new LocationStringPipe();
expect(pipe).toBeTruthy();
});

describe('transform', () => {
describe('when the first parameter is `true`', () => {
it('should return "2.34567, 12.34567"', () => {
// Arrange
const firstParameter: StationPosition = {
PositionLon: 12.34567,
PositionLat: 2.34567,
GeoHash: 'abcdefg'
};
const expectedResult = '2.34567, 12.34567';
// Acc
const actualResult = pipe.transform(firstParameter);
// Assert
expect(actualResult).toBe(expectedResult);
});
});
});
});

測試結果:

Testing Result

至此, Pipe 的部份就全測完了,相信大家這部份一定沒什麼問題。

而大家應該也有發現,我們在今天在驗 Pipe 的時候跟在驗 Component 的時候有一個滿明顯的不同,那就是我們今天沒有 TestBed

其實這是因為我們的這幾個 Pipe 很乾淨,沒有依賴任何其他的 Class ,所以在撰寫測試時,其實就把它當成一般的 Class ,用 new xxxPipe() 的方式產生出實體就行了。

ReactiveFormsAutoCompleteSearchingService

剛剛前面的 Pipe 只是先讓大家熱熱身,抓抓手感,接下來我們要為 ReactiveFormsAutoCompleteSearchingService 撰寫測試,算是今天的重頭戲之一。

雖然 ReactiveFormsAutoCompleteSearchingService 的程式碼也很簡單,但為什麼會是今天的重頭戲呢?

這是因為 ReactiveFormsAutoCompleteSearchingService 有用到我們之前沒有用過的 httpClient

先來看看它的程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
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);
}
}

ReactiveFormsAutoCompleteSearchingService 跟上面的 Pipe 一樣,都只有一個函式,不過在這個函式裡我們會需要驗兩個情境,四個案例:

  1. 呼叫 searchStation 所帶入的參數是空字串時
    1. 該函式會回傳一個 Observable (單元測試)
    2. 要呼叫 httpClientget 函式,並帶入參數 https://ptx.transportdata.tw/MOTC/v2/Rail/Metro/Station/TRTC?$format=JSON (整合測試)
  2. 呼叫 searchStation 所帶入的參數是有效字串時
    1. 該函式會回傳一個 Observable (單元測試)
    2. 要呼叫 httpClientget 函式,並帶入參數 https://ptx.transportdata.tw/MOTC/v2/Rail/Metro/Station/TRTC?$format=JSON&$filter=contains(StationName/Zh_tw,'xxx') (整合測試)

開始撰寫測試之前,我們一樣先把 ReactiveFormsAutoCompleteSearchingService 所依賴的項目準備好:

1
2
3
4
5
6
7
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [ReactiveFormsAutoCompleteSearchingService]
});
service = TestBed.inject(ReactiveFormsAutoCompleteSearchingService);
});

準備好依賴項目之後,就可以開始撰寫測試程式囉。

看仔細噢!原本 Service 要使用 HttpClient 的話,正常要在模組內引入 HttpClientModule

但在撰寫測試時,我們要引入的是 HttpClientTestingModule 這個 Angular 幫我們準備好專門給撰寫測試所要引入的 Module 。

我的測試程式碼如下:

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
39
40
41
42
43
describe('searchStation', () => {
describe('When the stationName is a empty string', () => {
const stationName = '';
it('should return a Observable', () => {
// Act
const result = service.searchStation(stationName);
// Assert
expect(result).toBeInstanceOf(Observable);
});

it('should call function "get" of the "HttpClient" with the correct API\'s URL', () => {
// Arrange
const apiUrl = 'https://ptx.transportdata.tw/MOTC/v2/Rail/Metro/Station/TRTC?$format=JSON';
const httpClient = TestBed.inject(HttpClient);
spyOn(httpClient, 'get');
// Act
service.searchStation(stationName);
// Assert
expect(httpClient.get).toHaveBeenCalledWith(apiUrl);
});
});

describe('When the stationName is a valid string', () => {
const stationName = 'Leo';
it('should return a Observable', () => {
// Act
const result = service.searchStation(stationName);
// Assert
expect(result).toBeInstanceOf(Observable);
});

it('should call function "get" of the "HttpClient" with the correct API\'s URL', () => {
// Arrange
const apiUrl = 'https://ptx.transportdata.tw/MOTC/v2/Rail/Metro/Station/TRTC?$format=JSON&$filter=contains(StationName/Zh_tw,\'Leo\')';
const httpClient = TestBed.inject(HttpClient);
spyOn(httpClient, 'get');
// Act
service.searchStation(stationName);
// Assert
expect(httpClient.get).toHaveBeenCalledWith(apiUrl);
});
});
});

測試結果:

Testing Result

ReactiveFormsAutoCompleteSearchingComponent

最後要測的是 ReactiveFormsAutoCompleteSearchingComponent ,由於是 Component 的關係,基本上除了 Class 本身之外,我們還要來驗證 Template 的部份。

先來看看 Class 的程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
export class ReactiveFormsAutoCompleteSearchingComponent {

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

constructor(private service: ReactiveFormsAutoCompleteSearchingService) { }

}

這個 Component 要驗的情境有:

  1. 驗證 searchingInputControl 是不是 FormControl
  2. 驗證 stations$ 是不是 Observable
  3. 驗證 stations$ 被訂閱時, ReactiveFormsAutoCompleteSearchingService 的函式 searchStation 會不會被呼叫並傳入空字串
  4. 驗證 searchingInputControl 的值變動時, ReactiveFormsAutoCompleteSearchingService 的函式 searchStation 會不會被呼叫並傳入 searchingInputControl 的值
  5. 驗證 searchingInputControl 的值快速變動兩次時,ReactiveFormsAutoCompleteSearchingService 的函式 searchStation 是否只被呼叫一次
  6. 驗證 searchingInputControl 的值變動兩次的間隔時間超過 500 毫秒時,ReactiveFormsAutoCompleteSearchingService 的函式 searchStation 是否被呼叫兩次

開始測試前,一樣先把依賴的項目準備好:

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
describe('ReactiveFormsAutoCompleteSearchingComponent', () => {
let component: ReactiveFormsAutoCompleteSearchingComponent;
let fixture: ComponentFixture<ReactiveFormsAutoCompleteSearchingComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ReactiveFormsAutoCompleteSearchingComponent],
providers: [
{
provide: ReactiveFormsAutoCompleteSearchingService,
useValue: {
searchStation: () => EMPTY
}
}
]
})
.compileComponents();

fixture = TestBed.createComponent(ReactiveFormsAutoCompleteSearchingComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});

從上述程式碼中,大家可能會發現以前從來沒看過的程式碼:

1
2
3
4
5
6
{
provide: ReactiveFormsAutoCompleteSearchingService,
useValue: {
searchStation: () => EMPTY
}
}

而這也是我們今天文章的主軸, DI 抽換

DI 抽換

DI ,也就是 Dependency Injection ,依賴注入

這點大家應該知道,而 DI 抽換是 Angular 提供的一個很有趣的功能,讓我們可以用以下三種方式替換掉想替換的 Provider :

  1. useClass ─ 提供一個繼承於想替換掉的 Provider 的 Class ,然後用新的 Class 取代原本的 Provider

    像是:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class MyRouter extends Router {
    // ...
    }

    @NgModule({
    // ...
    providers: [
    {
    provide: Router,
    useClass: MyRouter
    }
    ]
    })
    export class AbcModule { }
  2. useValue ─ 像剛剛在測試程式碼裡所寫的那樣,直接用物件抽換掉想換掉的 Provider

  3. useFactory ─ 用函式來抽換,像是:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    const abcServiceFactory = () => {
    return new AbcService();
    }

    @NgModule({
    // ...
    providers: [
    {
    provide: AbcService,
    useClass: abcServiceFactory
    }
    ]
    })
    export class ABCModule { }

關於這部份,真的要講很細的話可以寫一整篇,不過我今天只是想讓大家知道我們可以透過 DI 抽換的方式,把不可控的依賴變成可控的,這樣才能寫出優秀的測試

關於 DI 抽換的部分,如果想了解更多可以參考官方的 Dependency providers 文件。

知道 DI 抽換是什麼概念之後,我們就來開始撰寫測試案例吧!

我的測試程式碼如下:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
describe('Property searchingInputControl', () => {
it('should be a instance of FormControl', () => {
// Assert
expect(component.searchingInputControl).toBeInstanceOf(FormControl);
});
});

describe('Property stations$', () => {
it('should be a instance of FormControl', () => {
// Assert
expect(component.stations$).toBeInstanceOf(Observable);
});

describe('when it be subscribed', () => {
let service: ReactiveFormsAutoCompleteSearchingService;
beforeEach(() => {
service = TestBed.inject(ReactiveFormsAutoCompleteSearchingService);
spyOn(service, 'searchStation').and.returnValue(of([]));
});

it('should call function "searchStation" of the service with empty string', (done) => {
// Act
component.stations$.subscribe(() => {
// Assert
expect(service.searchStation).toHaveBeenCalledOnceWith('');
done();
});
});

describe('when the input value changes', () => {
it('should call function "searchStation" of the service with the value', (done) => {
// Arrange
const value = 'Leo'
// Act
component.stations$.subscribe(() => {
// Assert
expect(service.searchStation).toHaveBeenCalledOnceWith(value);
done();
});
component.searchingInputControl.patchValue(value);
});
});

describe('when the input value changes twice quickly', () => {
it('should call function "searchStation" of the service once with the last value', (done) => {
// Arrange
const firstValue = 'Leo'
const secondValue = 'Chen'
// Act
component.stations$.subscribe(() => {
// Assert
expect(service.searchStation).toHaveBeenCalledOnceWith(secondValue);
done();
});
component.searchingInputControl.patchValue(firstValue);
component.searchingInputControl.patchValue(secondValue);
});
});

describe('when the input value changes twice slowly', () => {
it('should call function "searchStation" of the service twice', fakeAsync(() => {
// Arrange
const firstValue = 'Leo'
const secondValue = 'Chen'
// Act
component.stations$.subscribe();
component.searchingInputControl.patchValue(firstValue);
tick(600);
component.searchingInputControl.patchValue(secondValue);
tick(600);
// Assert
expect(service.searchStation).toHaveBeenCalledTimes(2);
expect(service.searchStation).toHaveBeenCalledWith(firstValue);
expect(service.searchStation).toHaveBeenCalledWith(secondValue);
}));
});
})
});

測試結果:

Testing Result

在上述的測試程式碼中,我們可以看到今天要分享給大家的最後一個技巧:非同步測試。

Angular 的非同步測試技巧

在驗證非同步事件處理邏輯如 PromiseObservable 時,最簡單的方式當然就是直接 then 或是 subscribe 之後再驗證。

而這時我們會在傳入 it 的函式裡,多一個名為 done 的參數 (你要取名為別的名字也可以) ,如此我們就可以讓測試知道我們要等非同步事件完成後再行驗證。

像這樣:

1
2
3
4
5
it('description', (done) => {
observable.subscribe(() => {
done();
});
});

但除了這個方式外,Angular 還有提供另一個方式是是永 fakeAsynctick 的組合。

使用方式是將原本要傳入 it 裡的函式傳入 fakeAsync() 裡並用它來做替代,接著就可以在 it 裡面使用 tick() 這個函式來代表時間的流逝。

例如:

1
2
3
4
5
6
7
it('description', fakeAsync(() => {
// Do A

tick(300) // ms

// Assert A
}));

而且這個時間的流逝是假的,又或者是說,有種「時間加速器的概念」。

假設 Do AAssert A 之間相隔十年,用了 tick(10年) 之後,瞬間就過完了十年,厲害吧!

簡直媲美薩諾斯收集完無限寶石之後,一彈指就讓全宇宙的一半人口都灰飛湮滅的帥度

今天差不多就到這邊,訊息量應該滿大的,至於剩下 Template 的測試沒什麼太特別的地方,就讓大家練習做做看囉!

本日小結

今天的重點:

  1. 如果被測試的 Class 沒有任何依賴,則只需使用 new XXX() 來產生實體即可( Component 除外)
  2. 如果有使用到 HttpClient 的話,撰寫測試時要引入的是 HttpClientTestingModule ,而不是 HttpClientModule
  3. DI 抽換
  4. 非同步的處理

以上技巧會在大家實際撰寫測時非常大量的使用,記得要多加練習才會熟能生巧噢!

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

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

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×