從自訂 FormControl Component 中抽離出一個 Child Custom FormControl Component
自訂的 FormControl 可能會如下範例所示
<form [formGroup]="customForm">
<ng-container *ngFor="let item of dataSource;">
<mat-form-field>
<input
matInput
formGroupName="{{ item.id }}"
[placeholder]="item.name"
[value]="item.value"
/>
</mat-form-field>
</ng-container>
</form>
import { Component, OnInit, Input } from '@angular/core';
import { FormGroup, FormBuilder } from '@angular/forms';
import { ControlItem, ControlType } from '我們自定義的參數物件 ts 路徑';
@Component({
selector: 'app-form-controls',
templateUrl: './form-controls.component.html',
styleUrls: ['./form-controls.component.css']
})
export class FormControlsComponent implements OnInit {
@Input()
get dataSource(): Array<ControlItem> {
// 傳入的範例資料
/*
[
{ id: 'id', name: '我是 ID', value: '' },
{ id: 'id2', name: '我是 ID2', value: '' }
]
*/
return this._dataSource;
}
set dataSource(v: Array<ControlItem>) {
// 透過 array.reduce 來攤平陣列整理出希望取得的資料物件
const controlsConfig = v.reduce((obj, { id, value }) => {
// id 對應的就是 formControlName,value 對應的就是 formControl 內的 value
return { ...obj, [id]: value };
}, {});
// 將 formGroup 設定塞給想要定義的 form 物件
this.customForm = this.fb.group(controlsConfig);
// 儲存外部傳入的 formGroup 設定檔
this._dataSource = v;
}
private _dataSource: Array<ControlItem>;
// 提供給外部控制表單的物件定義
customForm: FormGroup;
// 提供給表單強行別判斷要產出的項目
CType = ControlType;
constructor(private fb: FormBuilder) { }
ngOnInit(): void {
}
}
/**
* 目前支援的 FormControl 類型定義
*/
export enum ControlType {
/** 字串 查詢條件 */
KeywordInput,
/** 選擇 查詢條件 */
RadioButtonList,
}
/**
* Control 需要使用的物件定義
*/
export class ControlItem {
/** Control Key Word */
id: string;
/** Control 顯示名稱 */
name: string;
/** Control 值 */
value?: string | boolean;
constructor(id: string, name: string, value?: string) {
this.id = id;
this.name = name;
this.value = value;
}
}
首先我們需要知道 Reactive Forms 的基本觀念
Form Data 都是在 component 裡面透過 tree 的方式來呈現Templete 畫面上,使用者任意輸入值都會即時更新 tree 對應節點中的 valueFormControl 當中我們定義了 from 表單,並使用 formGroup 綁定此表單
formGroupName 傳入到控制項當中,定義此 input 為 formGroup tree 中的一員tree 物件結構
formGroup 表單formGroup 表單下有兩個 formGroupName 控制項 input現在我們想要將 input 抽離出來,變成自定義的一個 custom form control,步驟大致上如下
InputComponent 元件input 相關的 codeInputComponent 內繼承 ControlValueAccessor、OnDestroy 這兩個介面並實作方法Input Templete 改為傳入 InputComponent 內定義的 formGroup 物件custom form control 的方式為 propertie binding formControlName 的用法接下來我們開始一步一步實作
首先,建立 InputComponent 元件
ng g c InputComponent --module app--module app:表示我要將 InputComponent 建立於 app.module.ts 這個模組底下搬移 input 相關的 code,依序流程如下
搬移 templete,並作調整 不正統的 Custom FormControl 示範
<!-- 原本寫法 -->
<!-- <mat-form-field>
<input
matInput
formGroupName="{{ item.id }}"
[placeholder]="item.name"
[value]="item.value"
/>
</mat-form-field> -->
<!-- 不正統的 Custom FormControl 作法 -->
<!-- 透過外部將 formGroup 傳入並 Propertie Binding -->
<mat-form-field [formGroup]="customForm">
<input
matInput
formControlName="{{ controlItem.id }}"
placeholder="{{ controlItem.displayName }}"
[value]="controlItem.value"
/>
</mat-form-field>
搬移 component,並調整,不正統的 Custom FormControl 示範
import { Component, Input } from '@angular/core';
import { ControlItem } from '../../form-controls/form-controls.model';
import { FormGroup } from '@angular/forms';
import { ControlItem } from '我們自定義的參數物件 ts 路徑';
@Component({
selector: 'app-keydown-input',
templateUrl: './keydown-input.component.html',
styleUrls: ['./keydown-input.component.css']
})
export class KeydownInputComponent {
// 假設我們調整 FormGroup 物件是由外部傳入的作法,下面會再做說明
@Input() customForm: FormGroup;
@Input() controlItem: ControlItem;
constructor() { }
}
到這裡,對於 Input 這個 Form Control Component 就已經可以正常運作了,這部分目前還不太理解為什麼,猜測可能原因是因為有包裹一層 Material 的 FormFiled 並 Bindind 了 FormGroup 這個 propertie
經過測試,如果是使用像是 mat-radio-group、mat-radio-button 是無法成功即時取得更新的資料的,或是 mat-checkbox 如果沒有包一層 ng-container 來 propertie binding formgroup 勾選切換即時取得 value 也會失效
所以我們還是回到完整正規實作 ReactiveForm 功能並且我們也希望此元件能夠支援被使用在 ngModel TempleteForm 的方式進行吧
要實作出 custom form control 可以有兩種做法,一種是使用 ngModel 的 two way binding 來達成,另一種做法就是接下來要示範的實作兩個介面 ControlValueAccessor、OnDestroy 來完成目標
實作前先簡單說明一下 ControlValueAccessor 介面擁有的方法
writeValue(obj: any):當資料從元件外部被變更時會觸發此方法registerOnChange(fn: any):將一個方法傳入,在元件內呼叫此方法時即代表表單控制項的值有變更registerOnTouched(fn: any):類似 registerOnChange(),但是是 touched 狀態發生時呼叫setDisabledState(isDisabled: boolean):當 disabled 狀態變更時會呼叫此方法
另外在裡面還有實作 OnDestroy 這介面是為了要釋放我們在裡面所做的監聽訂閱,如果沒有釋放掉,則會在每次進入時候都發生一次訂閱占用資源
首先我們先在 InputComponent 內實作方法
import { Component, forwardRef, OnDestroy } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormControl } from '@angular/forms';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ControlItem } from '我們自定義的參數物件 ts 路徑';
// Angular 在執行元件程式時,會檢查 KeydownInputComponent
// 是否包含 NG_VALUE_ACCESSOR 的設定,若有,則將此 Component 視為一個 表單控制項 FormControl,並可被注入
// KEYDOWN_INPUT_VALUE_ACCESSOR 為我們自定義的名稱
export const KEYDOWN_INPUT_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR,
// forwardRef():將所在的程式快轉到 KeydownInputComponent 產生之後,以避免找不到實體的問題
useExisting: forwardRef(() => KeydownInputComponent),
multi: true
};
@Component({
selector: 'app-keydown-input',
templateUrl: './keydown-input.component.html',
styleUrls: ['./keydown-input.component.css'],
// 這段如果忘記加,可能會遇到類似此段的錯誤訊息:ERROR Error: No value accessor for form control with name: 'xxxxxx'
// xxxxxx 是你傳入的 formControlName
providers: [KEYDOWN_INPUT_VALUE_ACCESSOR] // 將剛剛定義可被注入的 token 注入到此 Component
})
// 當注入 KEYDOWN_INPUT_VALUE_ACCESSOR 這個 token 後,此 KeydownInputComponent 在 Angular 當中就會將其視為一個 FormControl
// 但是我們還需要進行一些 FormControl 擁有的功能進行實作,才能夠讓資料正確的進行操作
export class KeydownInputComponent implements OnDestroy, ControlValueAccessor {
@Input() controlItem: ControlItem;
control: FormControl;
// 用來接收 registerOnChange 和 registerOnTouched 傳入的方法
private _onChange: (val: string) => void;
private _onTouch: (val: string) => void;
/** 釋放 subscribe 用的物件 */
private destroy$ = new Subject<any>();
constructor() { }
/** 內部實作資料改變事件 */
noticeValueChange(val: string) {
this._onChange(val);
this._onTouch(val);
}
/** 因為有實作 subscribe,所以要記得自己實作 destroy,否則 subscribe 會一直存在,並且重複持續增長 */
ngOnDestroy() {
this.destroy$.next(); // 實作 Subject 接收到新值時,next 被調用
this.destroy$.complete(); // 實作 Subject 訂閱的 Observable 結束後,complete 被調用
}
// [以下是 ControlValueAccessor 介面需要實作的方法]
// ---------------------------------------------------------------
/** 只要 外部 傳入值發生了變化就會觸發 writeValue */
writeValue(obj: any): void {
// 判斷 control 是否為 undefined,
// 如果是則 new FormControl 物件出來,並訂閱 判斷值是否改變的事件
if (!this.control) {
this.control = new FormControl(obj);
// 當外部傳入值 obj 引發 control.valueChanges 時,內部同步引發 noticeValueChange()
// takeUntil(this.destroy$) 就是為了在 valueChanges 前送出一個訊息到 Subject,以便 Complete Observable
this.control.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(val => this.noticeValueChange(val));
} else {
// 如果不是 undefined 則僅需要設定新的值即可
this.control.setValue(obj);
}
}
/** 登記註冊 OnChange */
registerOnChange(fn: any): void {
/** 內部自訂實作 OnChange */
this._onChange = fn;
}
/** 登記註冊 OnTouched */
registerOnTouched(fn: any): void {
/** 內部自訂實作 onTouch */
this._onTouch = fn;
}
/** 外部觸發 Disable/Enable 事件 */
setDisabledState?(isDisabled: boolean): void {
// 內部依據外部傳入設定實作
if (isDisabled) {
this.control.disable();
} else {
this.control.enable();
}
}
}
補充:
如果在 FormControlsComponent 裡面的 set dataSource(v: Array<ControlItem>) 的流程當中額外想要做些需花費時間的操作,例如多傳入驗證輸入值的參數 requiredList 陣列
可能衍生的問題是 dataSource 的值給予設定完,但是在建立 custom form control 元件的時期,會有一些時間差造成產出失敗
範例 code 如下
set dataSource(v: Array<ControlItem>) {
// 傳入的範例資料
/*
[
{ id: 'id', name: '我是 ID', value: '', disabled: true, requiredList: ['required','minlength'] },
{ id: 'id2', name: '我是 ID2', value: '', disabled: true }
]
*/
// 在處理 getValidLiat() 的過程中,可能會有幾毫秒的延遲,此時在產元件時就會引發例外錯誤
const controlsConfig = v.reduce((obj, { id, value, disabled, requiredList }) => {
return {
...obj, [id]: [{ value, disabled: !!disabled }, Object.keys(requiredList || {}).map((val) => {
// 範例:需求是要依據傳進來的 requiredList,依序取出 val,
// 轉換成 Angular Validator 使用的 ValidatorFn 陣列
return getValidLiat(val);
})]
};
}, {});
this.customForm = this.fb.group(controlsConfig);
this._dataSource = v;
}
/* 依據傳入的 validKey 回傳對應的 ValidatorFn 驗證物件 */
getValidLiat(validKey: string): ValidatorFn {
// 判斷後 return 對應的 ValidatorFn 物件
}
上面這段引發的錯誤訊息如下

針對上面的問題,我們可以來重新審視 custom formcontrol 裡面的寫法,並進行調整
import { Component, OnDestroy, forwardRef, Input, AfterViewInit } from '@angular/core';
import { ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ControlItem } from '我們自定義的參數物件 ts 路徑';
export const KEYDOWN_INPUT_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => KeydownInputComponent),
multi: true
};
@Component({
selector: 'app-keydown-input',
templateUrl: './keydown-input.component.html',
styleUrls: ['./keydown-input.component.scss'],
providers: [KEYDOWN_INPUT_VALUE_ACCESSOR]
})
export class KeydownInputComponent implements ControlValueAccessor, AfterViewInit, OnDestroy {
@Input() controlItem: ControlItem;
// [!!!很重要!!!] 這裡預先就先給予初始化 FormControl 物件的動作
control: FormControl = new FormControl();
private _onChange: (val: string) => void;
private _onTouch: (val: string) => void;
private destroy$ = new Subject<any>();
constructor() {}
/* 將 訂閱 的動作改為在 畫面生成確定完成後,再來訂閱需要監聽的值改變事件 */
ngAfterViewInit(): void {
this.control.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(val => this.noticeValueChange(val));
}
noticeValueChange(val: string) {
this._onChange(val);
this._onTouch(val);
}
/** 調整 writeValue 裡面的流程 */
writeValue(obj: any): void {
this.control.setValue(obj);
}
registerOnChange(fn: any): void {
this._onChange = fn;
}
registerOnTouched(fn: any): void {
this._onTouch = fn;
}
setDisabledState?(isDisabled: boolean): void {
isDisabled ? this.control?.disable() : this.control?.enable();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
完成 component 實作後,接著調整 templete 改為與 自訂的 FormControl 時一樣的做法,做 formControl 的 propertie binding
<mat-form-field>
<input
matInput
[formControl]="control"
[placeholder]="controlItem?.name"
autocomplete="no"
/>
</mat-form-field>
<!-- controlItem? 的 ? 符號,是自動判斷 controlItem 是否存在,如果存在才查找 name propertie-->
<!-- autocomplete="no" 主要是設定 關閉跳出提示建議內容 的清單-->
前面四個步驟都完成後,我們就可以嘗試來使用這個 custom form control component,基本上只需要將 formControlName 傳入後,接下來使用者的任何輸入改變都能夠即時的在父層級的 FormGroup 物件當中取得其變化後的結果
<form [formGroup]="customForm">
<ng-container *ngFor="let item of dataSource;">
<app-keydown-input
formControlName="{{ item.id }}"
[controlItem]="item"
></app-keydown-input>
</ng-container>
</form>
參考自 鐵人賽 [Angular 大師之路] Day 08 的文章範例
在父元件當中使用方式
Templete
<app-my-control
[(ngModel)]="userInfo" (
ngModelChange)="log($event)">
</app-my-control>
<pre>{{ userInfo | json }}</pre>
Component
import { Component } from '@angular/core';
@Component({
selector: 'my-app',
templateUrl: './app.component.html',
styleUrls: [ './app.component.css' ]
})
export class AppComponent {
userInfo = {
name: 'test',
age: 888
};
log(event) {
console.log(event);
}
}
子元件
Templete
<div>
<span>Name</span>
<input type="text"
[(ngModel)]="info.name"
(input)="userInfoChange()" />
</div>
<div>
<span>Age</span>
<input
type="number"
[(ngModel)]="info.age"
(input)="userInfoChange()" />
</div>
Component
import { Component, OnInit, forwardRef } from '@angular/core';
import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms';
export const MY_CONTROL_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => MyControlComponent),
multi: true
};
@Component({
selector: 'app-my-control',
templateUrl: './my-control.component.html',
styleUrls: ['./my-control.component.css'],
providers: [MY_CONTROL_VALUE_ACCESSOR]
})
export class MyControlComponent implements ControlValueAccessor {
info = {};
// 用來接收 setDisabledState 的狀態
disabled = false;
// 用來接收 registerOnChange 和 registerOnTouched 傳入的方法
onChange: (value) => {};
onTouched: () => {};
constructor() { }
writeValue(obj: any): void {
this.info = obj;
}
registerOnChange(fn: any): void {
this.onChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
// 元件內必須找一個時機觸發 onChange 方法,我們將此方法綁定在 input 上
userInfoChange() {
this.onChange(this.info);
}
}