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 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!

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

評論

Your browser is out-of-date!

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

×