原文連接:Custom Form Controls in Angularhtml
在建立表單時,Angular能夠幫助咱們完成不少事情。咱們已經介紹了有關Angular中的Forms的幾個主題,例如模型驅動的表單和模板驅動的表單。若是您尚未閱讀這些文章,咱們強烈建議您先去閱讀這些文章,由於這篇文章是基於它們的。Almero Steyn是咱們的培訓學生之一,後來做爲Angular的文檔編寫團隊的一員爲正式文檔作出了貢獻,他還爲建立自定義控件撰寫了很是不錯的介紹。typescript
他的文章啓發了咱們,咱們想更進一步,探討如何建立與Angular的 form API很好地集成的自定義表單控件。json
在開始並構建本身的自定義表單控件以前,咱們要確保咱們對建立自定義表單控件時所起的做用有所瞭解。bootstrap
首先,重要的是要認識到,若是有一個原生元素(如<input type="number">
)能夠完美地完成工做,那麼咱們不該該當即建立自定義表單控件。彷佛原生表單元素的功能經常被低估了。儘管咱們常常看到能夠輸入的文本框,但它爲咱們帶來了更多工做。每一個原生表單元素都是可訪問的,有些輸入具備內置的驗證,有些甚至在不一樣平臺(例如移動瀏覽器)上提供了改進的用戶體驗。api
所以,每當考慮建立自定義表單控件時,咱們都應該問本身:數組
可能還有更多要考慮的事情,但這是最重要的。若是確實要建立一個自定義表單控件(在Angular中),則應確保:瀏覽器
在本文中,咱們將討論不一樣的場景,以演示如何實現這些功能。不過,本文將不涉及可訪問性,由於將有後續文章對此進行深刻討論。angular2
讓咱們從一個很是簡單的計數器組件開始。這個想法是要有一個組件,讓咱們能夠對 model 值遞增和遞減。是的,若是咱們考慮要考慮的事情,咱們可能會意識到一個 <input type="number">
能夠解決問題。閉包
可是,在本文中,咱們要演示如何實現自定義表單控件,而自定義計數器組件彷佛微不足道,以致於使事情看起來不太複雜。此外,咱們的計數器組件將具備不一樣的外觀,該外觀在全部瀏覽器中均應相同,不管如何咱們均可能會受到原生input元素的限制。app
咱們從原始組件開始。咱們須要的是一個能夠更改的 model 值和兩個觸發更改的按鈕。
import { Component, Input } from '@angular/core';
@Component({
selector: 'counter-input',
template: ` <button (click)="increment()">+</button> {{counterValue}} <button (click)="decrement()">-</button> `
})
class CounterInputComponent {
@Input()
counterValue = 0;
increment() {
this.counterValue++;
}
decrement() {
this.counterValue--;
}
}
複製代碼
這裏沒什麼特別的。CounterInputComponent
有一個counterValue
,它被插入到模板中,能夠分別經過increment()
和decrement()
方法對其進行遞增或遞減。這個組件工做得很好,一旦在應用程序模塊上聲明瞭這個組件,咱們就可使用它,好比像這樣將它放入另外一個組件中:
app.module.ts
@NgModule({
imports: [BrowserModule],
declarations: [AppComponent, CounterInputComponent],
bootstrap: [AppComponent]
})
export class AppModule {}
複製代碼
app.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-component',
template: ` <counter-input></counter-input> `,
})
class AppComponent {}
複製代碼
很好,可是如今咱們想使其與Angular的 Form API一塊兒使用。理想狀況下,咱們最終獲得的是一個自定義控件,該控件可與模板驅動的表單和響應式驅動的表單一塊兒使用。例如,在最簡單的狀況下,咱們應該可以建立一個模板驅動的表單,以下所示:
<!-- this doesn't work YET -->
<form #form="ngForm" (ngSubmit)="submit(form.value)">
<counter-input name="counter" ngModel></counter-input>
<button type="submit">Submit</button>
</form>
複製代碼
若是您不熟悉該語法,請查看Angular中有關模板驅動表單的文章。好的,可是咱們怎麼實現?咱們須要學習ControlValueAccessor
是什麼,由於Angular就是使用它來創建表單模型和DOM元素之間的聯繫。
雖然咱們的計數器組件有效,但目前尚沒法將其鏈接到外部表單。實際上,若是咱們嘗試將任何形式的表單模型綁定到咱們的自定義控件,則會收到錯誤消息,提示缺乏ControlValueAccessor
。而這正是咱們實現與Angular中的表單進行正確集成所須要的。
那麼,什麼是ControlValueAccessor
?好吧,還記得咱們以前談到的實現自定義表單控件所需的內容嗎?咱們須要確保的一件事是,更改從模型傳播到視圖/ DOM,也從視圖傳播回模型。這是ControlValueAccessor
目的。
ControlValueAccessor
是用於處理如下內容的接口:
Angular之因此具備這樣的界面,是由於DOM元素須要更新的方式可能因input類型而異。例如,普通文本輸入框具備value
屬性,這個是一個須要被寫入的屬性,而複選框帶有checked
屬性,這是一個須要更新的屬性。若是咱們深刻了解,咱們意識到,每一個input類型都有一個ControlValueAccessor
,它知道如何更新其視圖/ DOM。
DefaultValueAccessor
用於處理文本輸入和文本區域,SelectControlValueAccessor
用於處理選擇輸入,CheckboxControlValueAccessor
用於處理複選框等等。
咱們的計數組件須要一個ControlValueAccessor
,它知道如何更新counterValue
並告知外部變化的信息。一旦實現該接口,即可以與Angular表單進行對話。
該ControlValueAccessor
接口以下所示:
export interface ControlValueAccessor {
writeValue(obj: any) : void
registerOnChange(fn: any) : void
registerOnTouched(fn: any) : void
}
複製代碼
**writeValue(obj:any)**是將表單模型中的新值寫入視圖或DOM屬性(若是須要)的方法。這是咱們要更新counterValue
的地方,由於這就是視圖中使用的東西。
**registerOnChange(fn:any)**是一種註冊處理程序的方法,當視圖中的某些內容發生更改時會調用該處理程序。它具備一個告訴其餘表單指令和表單控件以更新其值的函數。換句話說,這就是咱們但願counterValue
在視圖中進行更改時調用的處理程序函數。
與registerOnChange()
類似的**registerOnTouched(fn:any)**會註冊一個專門用於當控件收到觸摸事件時的處理程序。在咱們的自定義控件中不須要用到它。
ControlValueAccessor
須要訪問其控件的視圖和模型,這意味着自定義表單控件自己必須實現該接口。讓咱們從writeValue()
開始。首先,咱們實現接口並更新類簽名。
import { ControlValueAccessor } from '@angular/forms';
@Component(...)
class CounterInputComponent implements ControlValueAccessor {
...
}
複製代碼
接下來,咱們實現writeValue()
。如前所述,它從表單模型中獲取一個新值並將其寫入視圖中。在咱們的例子中,咱們所須要作的只是更新的counterValue
屬性,由於它是自動插入的。
@Component(...)
class CounterInputComponent implements ControlValueAccessor {
...
writeValue(value: any) {
this.counterValue = value;
}
}
複製代碼
初始化表單時,將使用表單模型的初始值調用此方法。這意味着它將覆蓋默認值0
,這很好,可是若是咱們考慮前面提到的簡單表單設置,咱們會意識到表單模型中沒有初始值:
<counter-input name="counter" ngModel></counter-input>
複製代碼
這將致使咱們的組件呈現一個空字符串。爲了快速解決,咱們僅在不是undefined
時設置該值:
writeValue(value: any) {
if (value !== undefined) {
this.counterValue = value;
}
}
複製代碼
如今,僅當有實際值寫入控件時,它纔會覆蓋默認值。接下來,咱們實現registerOnChange()
和registerOnTouched()
。registerOnChange()
能夠通知外界組件內的變化。只要咱們願意,每當在此處傳播變動,就能夠在這裏作一些特殊的工做。registerOnTouched()
註冊了一個回調函數,只要表單控件是「touched」,該回調便會執行。例如,當 input 元素失去焦點時,它將觸發 touch 事件。咱們不想在此事件上作任何事情,所以咱們可使用一個空函數來實現該接口。
@Component(...)
class CounterInputComponent implements ControlValueAccessor {
...
propagateChange = (_: any) => {};
registerOnChange(fn) {
this.propagateChange = fn;
}
registerOnTouched() {}
}
複製代碼
很好,咱們的計數器如今實現了該ControlValueAccessor
接口。咱們須要作的下一件事是,只要counterValue
在視圖中進行更改,就調用propagateChange()
。換句話說,若是單擊increment()
或decrement()
按鈕,咱們但願將新值傳播到外界。
讓咱們相應地更新這些方法。
@Component(...)
class CounterInputComponent implements ControlValueAccessor {
...
increment() {
this.counterValue++;
this.propagateChange(this.counterValue);
}
decrement() {
this.counterValue--;
this.propagateChange(this.counterValue);
}
}
複製代碼
咱們可使用屬性訪問器使此代碼更好一些。increment()
和decrement()
這兩種方法,每當counterValue
變化時都會調用propagateChange()
。讓咱們使用 getter 和 setter 擺脫多餘的代碼:
@Component(...)
class CounterInputComponent implements ControlValueAccessor {
...
@Input()
_counterValue = 0; // 注意'_'
get counterValue() {
return this._counterValue;
}
set counterValue(val) {
this._counterValue = val;
this.propagateChange(this._counterValue);
}
increment() {
this.counterValue++;
}
decrement() {
this.counterValue--;
}
}
複製代碼
CounterInputComponent
已經接近完成。即便它實現了ControlValueAccessor
接口,也沒有任何東西告訴Angular應該怎樣作。咱們須要註冊。
實現接口僅僅才完成了一半。衆所周知,ES5中不存在接口,這意味着一旦代碼被編譯,該信息就消失了。所以,雖然咱們的組件實現了該接口,可是咱們仍然須要使 Angular 接受它。
在關於Angular中的多註冊提供商的文章中,咱們瞭解到 Angular 使用了一些 DI 令牌來注入多個值,以便對它們進行某些處理。例如,有一個NG_VALIDATORS
令牌爲 Angular 提供了表單控件上全部已註冊的驗證器,咱們能夠在其中添加本身的驗證器。
爲了讓ControlValueAccessor
控制表單控件,Angular內部注入了在NG_VALUE_ACCESSOR
令牌上註冊的全部值。所以,咱們須要作的就是擴展NG_VALUE_ACCESSOR
的多註冊提供商,讓NG_VALUE_ACCESSOR
使用咱們本身的值訪問器實例(也就是咱們的組件)。
讓咱們立刻試一下:
import { Component, Input, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
...
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CounterInputComponent),
multi: true
}
]
})
class CounterInputComponent {
...
}
複製代碼
若是這段代碼對您沒有任何意義,您絕對應該查看那篇Angular中的多註冊提供商的文章,但最重要的是,咱們正在將自定義的值訪問器添加到 DI 系統,以便 Angular 能夠拿到該值訪問器的實例。咱們還必須使用useExisting
,由於CounterInputComponent
將在使用它的組件中,做爲指令依賴建立。若是不這樣作,則會獲得一個新實例,由於這是 Angular 中 DI 的工做方式。forwardRef()
回調函數將在這篇文章中進行解釋。
太棒了,咱們的自定義表單控件如今可使用了!
咱們已經看到計數器組件能夠按預期工做,可是如今咱們但願將其放入實際表單中,並確保它在全部常見狀況下均可以工做。
正如咱們在Angular中模板驅動的表單文章中所討論的那樣,咱們須要像這樣激活 Form API:
import { FormsModule} from '@angular/forms';
@NgModule({
imports: [BrowserModule, FormsModule], // 在這裏添加 FormsModule
...
})
export class AppModule {}
複製代碼
差很少了!還記得咱們以前的AppComponent
嗎?讓咱們在其中建立一個模板驅動的表單,看看它是否有效。這是一個使用計數器控件而不用值初始化的示例(它將使用本身的內部默認值:0
):
@Component({
selector: 'app-component',
template: ` <form #form="ngForm"> <counter-input name="counter" ngModel></counter-input> </form> <pre>{{ form.value | json }}</pre> `
})
class AppComponent {}
複製代碼
特別提示:使用json管道是調試表單值的好技巧。
form.value
返回以JSON結構映射到其名稱的全部表單控件的值。這就是爲何JsonPipe
會輸出一個帶有counter
計數器值的對象字面量。
這是另外一個使用屬性綁定將值綁定到自定義控件的示例:
@Component({
selector: 'app-component',
template: ` <form #form="ngForm"> <counter-input name="counter" [ngModel]="outerCounterValue"></counter-input> </form> <pre>{{ form.value | json }}</pre> `
})
class AppComponent {
outerCounterValue = 5;
}
複製代碼
固然,咱們能夠利用ngModel
的雙向數據綁定便可實現,只需將語法更改成此:
<p>ngModel value: {{outerCounterValue}}</p>
<counter-input name="counter" [(ngModel)]="outerCounterValue"></counter-input>
複製代碼
多麼酷啊?咱們的自定義表單控件可與模板驅動的表單API無縫配合!讓咱們看看使用響應式表單時的表現。
下面的示例使用 Angular 的響應式表單指令,因此不要忘記添加ReactiveFormsModule
到AppModule
,就像這篇文章中討論的。
一旦設置了表明表單模型的FormGroup
,就能夠將其綁定到表單元素,並使用formControlName
關聯每一個控件。此示例將值綁定到表單模型中的自定義表單控件:
@Component({
selector: 'app-component',
template: ` <form [formGroup]="form"> <counter-input formControlName="counter"></counter-input> </form> <pre>{{ form.value | json }}</pre> `
})
class AppComponent implements OnInit {
form: FormGroup;
constructor(private fb: FormBuilder) {}
ngOnInit() {
this.form = this.fb.group({
counter: 5
});
}
}
複製代碼
咱們要看的最後一件事是如何向咱們的自定義控件添加驗證。實際上,咱們已經寫了一篇關於Angular 中的自定義驗證器的文章,全部須要瞭解的內容都寫在這裏。可是,爲了使事情更清楚,咱們將經過示例向自定義表單控件中添加一個自定義驗證器。
假設咱們要讓控件在counterValue
大於10
或小於0
時變爲無效。以下所示:
import { NG_VALIDATORS, FormControl } from '@angular/forms';
@Component({
...
providers: [
{
provide: NG_VALIDATORS,
useValue: (c: FormControl) => {
let err = {
rangeError: {
given: c.value,
max: 10,
min: 0
}
};
return (c.value > 10 || c.value < 0) ? err : null;
},
multi: true
}
]
})
class CounterInputComponent implements ControlValueAccessor {
...
}
複製代碼
咱們註冊了一個驗證器函數,若是控制值有效返回null
,則返回該函數;不然,返回一個錯誤對象。這已經很好用了,咱們能夠像這樣顯示錯誤消息:
<form [formGroup]="form">
<counter-input formControlName="counter" ></counter-input>
</form>
<p *ngIf="!form.valid">Counter is invalid!</p>
<pre>{{ form.value | json }}</pre>
複製代碼
不過,咱們能夠作得更好。使用響應式表單時,咱們可能要在具備該表單功能但沒有DOM的狀況下測試組件。在這種狀況下,驗證器將不存在,由於它是由計數器組件提供的。經過將驗證器函數提取到其本身的聲明中並將其導出,能夠輕鬆解決此問題,以便其餘模塊能夠在須要時導入它。
讓咱們將代碼更改成:
export function validateCounterRange(c: FormControl) {
let err = {
rangeError: {
given: c.value,
max: 10,
min: 0
}
};
return (c.value > 10 || c.value < 0) ? err : null;
}
@Component({
...
providers: [
{
provide: NG_VALIDATORS,
useValue: validateCounterRange,
multi: true
}
]
})
class CounterInputComponent implements ControlValueAccessor {
...
}
複製代碼
特別提示:在構建響應式表單時,爲了使驗證器功能可用於其餘模塊,優良做法是先聲明它們並在註冊提供商的配置中引用它們。
如今,能夠將驗證器導入並添加到咱們的表單模型中,以下所示:
import { validateCounterRange } from './counter-input';
@Component(...)
class AppComponent implements OnInit {
...
ngOnInit() {
this.form = this.fb.group({
counter: [5, validateCounterRange]
});
}
}
複製代碼
這個自定義控件愈來愈好了,可是若是驗證器是可配置的,那不是真的很酷嗎!這樣自定義表單控件的使用者能夠決定最大和最小值是什麼。
理想狀況下,咱們的自定義控件的使用者應該可以執行如下操做:
<counter-input formControlName="counter" counterRangeMax="10" counterRangeMin="0" ></counter-input>
複製代碼
因爲Angular的依賴項注入和屬性綁定系統,這很是容易實現。基本上,咱們想要作的是讓咱們的驗證器具備依賴項。
讓咱們從添加輸入屬性開始。
import { Input } from '@angular/core';
...
@Component(...)
class CounterInputComponent implements ControlValueAccessor {
...
@Input()
counterRangeMax;
@Input()
counterRangeMin;
...
}
複製代碼
接下來,咱們必須以某種方式將這些值傳遞給咱們的validateCounterRange(c: FormControl)
,可是對於每一個API,它們須要共用一個FormControl
。這意味着咱們須要使用工廠模式來建立該驗證器函數,該工廠建立一個以下所示的閉包:
export function createCounterRangeValidator(maxValue, minValue) {
return function validateCounterRange(c: FormControl) {
let err = {
rangeError: {
given: c.value,
max: maxValue,
min: minValue
}
};
return (c.value > +maxValue || c.value < +minValue) ? err: null;
}
}
複製代碼
太好了,咱們如今可使用從組件內部的輸入屬性得到的動態值來建立驗證器函數,並實現 Angular 中用於執行驗證的validate()
方法:
import { Input, OnInit } from '@angular/core';
...
@Component(...)
class CounterInputComponent implements ControlValueAccessor, OnInit {
...
validateFn:Function;
ngOnInit() {
this.validateFn = createCounterRangeValidator(this.counterRangeMax, this.counterRangeMin);
}
validate(c: FormControl) {
return this.validateFn(c);
}
}
複製代碼
這可行,但引入了一個新問題:validateFn
僅在ngOnInit()
中設置。若是counterRangeMax
或counterRangeMin
經過綁定更改,該怎麼辦?咱們須要根據這些更改建立一個新的驗證器函數。幸運的是,有一個ngOnChanges()
生命週期掛鉤可使咱們作到這一點。咱們要作的就是檢查輸入屬性之一是否發生更改,而後從新建立咱們的驗證函數。咱們甚至能夠擺脫ngOnInit()
,由於不管如何ngOnChanges()
都會在ngOnInit()
以前被調用:
import { Input, OnChanges } from '@angular/core';
...
@Component(...)
class CounterInputComponent implements ControlValueAccessor, OnChanges {
...
validateFn:Function;
ngOnChanges(changes) {
if (changes.counterRangeMin || changes.counterRangeMax) {
this.validateFn = createCounterRangeValidator(this.counterRangeMax, this.counterRangeMin);
}
}
...
}
複製代碼
最後一點是,咱們須要更新驗證器的提供商,由於它再也不只是一個函數,而是執行驗證的組件自己:
@Component({
...
providers: [
...
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => CounterInputComponent),
multi: true
}
]
})
class CounterInputComponent implements ControlValueAccessor, OnInit {
...
}
複製代碼
信不信由你,咱們如今能夠爲自定義表單控件配置最大值和最小值!若是咱們要構建模板驅動的表單,則看起來就像這樣:
<counter-input ngModel name="counter" counterRangeMax="10" counterRangeMin="0" ></counter-input>
複製代碼
這也適用於表達式:
<counter-input ngModel name="counter" [counterRangeMax]="maxValue" [counterRangeMin]="minValue" ></counter-input>
複製代碼
若是要構建響應式表單,則能夠簡單地使用驗證器工廠將驗證器添加到表單控件中,以下所示:
import { createCounterRangeValidator } from './counter-input';
@Component(...)
class AppComponent implements OnInit {
...
ngOnInit() {
this.form = this.fb.group({
counter: [5, createCounterRangeValidator(10, 0)]
});
}
}
複製代碼