
經過了這段時間的練習與學習,相信大家應該越來越能體會 Angular 表單的強大與便利。
不過既然 Angular 表單這麼好用,如果能讓自己做的 Component 也像 Angular 表單那樣一般使用該有多好?
因此,今天想要跟大家分享的是 ─ 如何自訂表單元件。
大家跟我一起想像一下,假設我們今天需要做一個管理平台,在這個管理平台裡,會有很多地方都會需要用到我們昨天做的 DateRangeComponent ,但不一定會是在同一個表單裡,只是剛好也需要 startDate 與 endDate 這兩個欄位,而且畫面與欄位驗證的規則也都是一樣。
例如:
A 頁面是一個查詢訂單系統, B 頁面是查詢會員系統,雖然這兩個頁面的查詢條件可能都不太一樣,但恰好都可以根據起迄日來查詢相應的資料。
這時,我們很有可能就會將我們做好的 DateRangeComponent 做成表單元件,讓 A 跟 B 在使用它的時候,就像使用一般的表單元件一樣輕鬆、自然。
那究竟要怎麼做呢?
首先要介紹給大家認識的是 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 出現了一條紅色毛毛蟲,當你把滑鼠游標移到上面的時候,它說:

這是因為我們為 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);
  }
}
接下來,我們需要在 DateRangeComponent 的 MetaData 裡的 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 。
想知道
useExisting跟useValue、useClass與useFactory有哪裡不一樣的,也可以參考 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 :

接著我們在使用 DateRangeComponent 的 Component 裡加上以下程式碼以觀察其運作結果:
ngOnInit(): void {
  this.formGroup = this.formBuilder.group({
    dateRange: ''
  });
  setTimeout(() => {
    console.log('---- 3秒後 ----');
    this.formGroup?.setValue({ dateRange: 'Leo' });
    this.formGroup?.disable();
  }, 3000);
}
然後我們會發現:

這樣大家有比較了解一開始關於 ControlValueAccessor 各函式的說明了嗎?
如果用圖示的話,現在的結構大概像這樣:

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

如果我們 disable 或 enable 了該 FormControl ,則會觸發 ControlValueAccessor 的函式 setDisabledState :

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

讀萬卷書不如行萬里路。接下來,我們把剩下的實作做完就會更了解這其中的運作流程了!
首先,先加工一下使用 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();
    }
  }
}
結果:

對了,這樣的作法不僅僅只適用於 Reactive Forms 噢!大家可以在使用
DateRangeComponent的時候用 Template Driven Forms 的方式試試看,也是行得通的唷!
今天的實作練習應該滿好玩的吧?
我能理解大家第一次碰到的時候都會比較難以理解,記得我第一次碰到的時候,也只是複製人家的程式碼然後貼上而已,根本就不是了解其運作原理。
因此,希望我今天的文章能讓大家可以不僅僅只是複製貼上,而是對於其流程與原理有所掌握與理解。
今天的程式碼會放在 Github - Branch: day28 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!
如果有任何的問題或是回饋,還請麻煩留言給我讓我知道!
Hi Leo,
後面幾篇真的有點難,蠻多設定的,看了好久才有點看懂~~>_<~~這篇的範例我在測試時發現了一個奇怪的地方,那就是一開始DateRangeComponent在writeValue完,其欄位不會檢查輸入值的正確性(如圖)
示意圖:
作者撰寫文章時剛好當天日期不會報錯,所以可能沒發現這個奇怪的點XD
不知道是否有辦法讓他一開始就能辨識出錯誤的日期?已經在嘗試將writeValue中的emitEvent改成true去觸發valueChanges,但還是沒有用QQ
Ps.把writeValue完的GroupControl errors印出來有看到startDate的錯誤
示意圖:
還有一個很前面就想問的菜鳥問題~那就是在DateRangeComponent裡的ngOnInit有subscribe this.formGroup.valueChanges,那麼我們在寫的時候要在ngOnDestroy寫上unsubscribe this.formGroup.valueChanges嗎?因為好像記得subscribe東西要記得unsubscribe,但都沒看到筆者寫?
2022/3/11 18:00更:
第一個問題已經知道解決辦法了XD原來只要在writeValue中再呼叫markAsDirty的函式就可以順利讓錯誤出現了