iT邦幫忙

2021 iThome 鐵人賽

DAY 28
0
Modern Web

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

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

Day28

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

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

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

應用場景

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

例如:

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

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

那究竟要怎麼做呢?

ControlValueAccessor

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

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 ,像這樣:

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

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

export class ReactiveFormsDateRangeComponent implements OnInit {

  formGroup: FormGroup | undefined;

  constructor(private formBuilder: FormBuilder) { }

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

}

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

export class DateRangeComponent implements OnInit, ControlValueAccessor {
  // ...
}

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

The Tooltip

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

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

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

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

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 裡加入一些設定,像這樣:

@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 裡加上以下程式碼以觀察其運作結果:

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 :

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 的部份也加工一下:

<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 改成這樣:

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 進階技巧 - 跨欄位驗證
下一篇
Angular 深入淺出三十天:表單與測試 Day29 - ControlContainer
系列文
Angular 深入淺出三十天:表單與測試30

1 則留言

0
juck30808
iT邦新手 3 級 ‧ 2021-10-14 12:06:33

恭喜即將邁入完賽啦~

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

Hi @juck30808,

謝謝!!

我要留言

立即登入留言