使用 Angular 4 表單:嵌套和輸入驗證
已發表: 2022-03-11在 Web 上,一些最早的用戶輸入元素是按鈕、複選框、文本輸入和單選按鈕。 直到今天,這些元素仍在現代 Web 應用程序中使用,儘管 HTML 標準距離其早期定義還有很長的路要走,現在允許各種花哨的交互。
驗證用戶輸入是任何強大的 Web 應用程序的重要組成部分。
Angular 應用程序中的表單可以聚合該表單下所有輸入的狀態,並提供一個整體狀態,如完整表單的驗證狀態。 這可以非常方便地決定是否接受或拒絕用戶輸入,而無需單獨檢查每個輸入。
在本文中,您將了解如何在 Angular 應用程序中輕鬆使用表單並執行表單驗證。
在 Angular 4 中,有兩種不同類型的表單可供使用:模板驅動表單和響應式表單。 我們將通過使用相同的示例來了解每種表單類型,以了解如何以不同的方式實現相同的事物。 稍後,在本文中,我們將介紹一種關於如何設置和使用嵌套表單的新穎方法。
Angular 4 形式
在 Angular 4 中,表單常用的有以下四種狀態:
valid – 所有表單控件的有效性狀態,如果所有控件都有效,則為 true
無效——
valid
的倒數; 如果某些控件無效,則為 true原始的——給出關於表格“乾淨”的狀態; 如果沒有修改控件,則為 true
骯髒的——
pristine
的反面; 如果修改了某些控件,則為 true
讓我們看一個表單的基本示例:
<form> <div> <label>Name</label> <input type="text" name="name"/> </div> <div> <label>Birth Year</label> <input type="text" name="birthYear"/> </div> <div> <h3>Location</h3> <div> <label>Country</label> <input type="text" name="country"/> </div> <div> <label>City</label> <input type="text" name="city"/> </div> </div> <div> <h3>Phone numbers</h3> <div> <label>Phone number 1</label> <input type="text" name="phoneNumber[1]"/> <button type="button">remove</button> </div> <button type="button">Add phone number</button> </div> <button type="submit">Register</button> <button type="button">Print to console</button> </form>
此示例的規範如下:
name - 在所有註冊用戶中是必需的並且是唯一的
birthYear - 應該是一個有效數字,用戶必須至少 18 歲且小於 85 歲
國家 - 是強制性的,為了讓事情變得有點複雜,我們需要驗證如果國家是法國,那麼城市必須是巴黎(假設我們的服務僅在巴黎提供)
phoneNumber – 每個電話號碼必須遵循指定的模式,必須至少有一個電話號碼,並且允許用戶添加新的或刪除現有的電話號碼。
“註冊”按鈕僅在所有輸入都有效且單擊後提交表單時啟用。
“打印到控制台”只是在單擊時將所有輸入的值打印到控制台。
最終目標是完全實現所定義的規範。
模板驅動的表單
模板驅動的表單與 AngularJS(或 Angular 1,有些人提到它)中的表單非常相似。 因此,在 AngularJS 中使用過表單的人會非常熟悉這種使用表單的方法。
隨著 Angular 4 中模塊的引入,強制每種特定類型的表單都在一個單獨的模塊中,我們必須通過導入正確的模塊來明確定義要使用的類型。 模板驅動表單的那個模塊是 FormsModule。 話雖如此,您可以激活模板驅動的表單,如下所示:
import {FormsModule} from '@angular/forms' import {NgModule} from '@angular/core' import {BrowserModule} from '@angular/platform-browser' import {AppComponent} from 'src/app.component'; @NgModule({ imports: [ BrowserModule, FormsModule ], declarations: [ AppComponent], bootstrap: [ AppComponent ] }) export class AppModule {}
如本代碼片段所示,我們首先必須導入瀏覽器模塊,因為它“提供了啟動和運行瀏覽器應用程序所必需的服務”。 (來自 Angular 4 文檔)。 然後我們導入所需的 FormsModule 來激活模板驅動的表單。 最後是根組件 AppComponent 的聲明,在接下來的步驟中我們將實現表單。
請記住,在此示例和以下示例中,您必須確保使用platformBrowserDynamic
方法正確引導應用程序。
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; import {AppModule} from './app.module'; platformBrowserDynamic().bootstrapModule(AppModule);
我們可以假設我們的 AppComponent (app.component.ts) 看起來像這樣:
import {Component} from '@angular/core' @Component({ selector: 'my-app', templateUrl: 'src/app.component.tpl.html' }) export class AppComponent { }
該組件的模板在 app.component.tpl.html 中,我們可以將初始模板複製到該文件中。
請注意,每個輸入元素必須具有name
屬性才能在表單中正確標識。 雖然這看起來像是一個簡單的 HTML 表單,但我們已經定義了一個 Angular 4 支持的表單(也許你還沒有看到它)。 當 FormsModule 被導入時,Angular 4 會自動檢測form
HTML 元素並將 NgForm 組件附加到該元素(通過 NgForm 組件的selector
)。 在我們的示例中就是這種情況。 儘管聲明了這個 Angular 4 表單,但此時它不知道任何 Angular 4 支持的輸入。 Angular 4 將每個input
HTML 元素註冊到最近的form
祖先並不是那麼具有侵略性。
允許輸入元素被視為 Angular 4 元素並註冊到 NgForm 組件的關鍵是 NgModel 指令。 因此,我們可以擴展 app.component.tpl.html 模板如下:
<form> .. <input type="text" name="name" ngModel> .. <input type="text" name="birthYear" ngModel > .. <input type="text" name="country" ngModel/> .. <input type="text" name="city" ngModel/> .. <input type="text" name="phoneNumber[1]" ngModel/> </form>
通過添加 NgModel 指令,所有輸入都註冊到 NgForm 組件。 有了這個,我們已經定義了一個完全工作的 Angular 4 表單,到目前為止,一切都很好,但我們仍然沒有辦法訪問 NgForm 組件及其提供的功能。 NgForm 提供的兩個主要功能是:
檢索所有已註冊輸入控件的值
檢索所有控件的整體狀態
要公開 NgForm,我們可以在 <form> 元素中添加以下內容:
<form #myForm="ngForm"> .. </form>
這要歸功於Component
裝飾器的exportAs
屬性。
完成後,我們可以訪問所有輸入控件的值並將模板擴展為:
<form #myForm="ngForm"> .. <pre>{{myForm.value | json}}</pre> </form>
使用myForm.value
我們正在訪問包含所有已註冊輸入值的 JSON 數據,並且使用{{myForm.value | json}}
{{myForm.value | json}}
,我們正在漂亮地打印帶有值的 JSON。
如果我們希望將來自特定上下文的輸入子組包裝在容器中,並在值 JSON 中單獨對象,例如包含國家和城市的位置或電話號碼,該怎麼辦? 不要強調——Angular 4 中的模板驅動表單也涵蓋了這一點。 實現這一點的方法是使用ngModelGroup
指令。
<form #myForm="ngForm"> .. <div ngModelGroup="location"> .. </div> </div ngModelGroup="phoneNumbers"> .. <div> .. </form>
我們現在缺少的是一種添加多個電話號碼的方法。 最好的方法是使用數組,作為多個對象的可迭代容器的最佳表示,但在撰寫本文時,該功能還沒有為模板驅動的表單實現。 因此,我們必須應用一種解決方法來完成這項工作。 電話號碼部分需要更新如下:
<div ngModelGroup="phoneNumbers"> <h3>Phone numbers</h3> <div *ngFor="let phoneId of phoneNumberIds; let i=index;"> <label>Phone number {{i + 1}}</label> <input type="text" name="phoneNumber[{{phoneId}}]" #phoneNumber="ngModel" ngModel/> <button type="button" (click)="remove(i); myForm.control.markAsTouched()">remove</button> </div> <button type="button" (click)="add(); myForm.control.markAsTouched()">Add phone number</button> </div>
myForm.control.markAsTouched()
用於使表單被touched
,以便我們可以在那一刻顯示錯誤。 單擊時,按鈕不會激活此屬性,只有輸入。 為了使下一個示例更清楚,我不會在add()
和remove()
的單擊處理程序中添加這一行。 想像一下它就在那裡。 (它存在於 Plunkers 中。)
我們還需要更新AppComponent
以包含以下代碼:
private count:number = 1; phoneNumberIds:number[] = [1]; remove(i:number) { this.phoneNumberIds.splice(i, 1); } add() { this.phoneNumberIds.push(++this.count); }
我們必須為每個添加的新電話號碼存儲一個唯一的 ID,並在*ngFor
中,通過他們的 ID 跟踪電話號碼控件(我承認這不是很好,但在 Angular 4 團隊實現這個功能之前,恐怕,這是我們能做的最好的)
好的,到目前為止,我們已經添加了 Angular 4 支持的表單和輸入,添加了特定的輸入分組(位置和電話號碼)並在模板中公開了表單。 但是如果我們想在組件的某個方法中訪問 NgForm 對象呢? 我們將看看兩種方法。
對於第一種方式,在當前示例中標記為myForm
的 NgForm 可以作為參數傳遞給函數,該函數將用作表單的 onSubmit 事件的處理程序。 為了更好的集成,onSubmit 事件被一個名為wrapped
的 Angular 4 特定的 NgForm 事件ngSubmit
,如果我們想在提交時執行一些操作,這是正確的方法。 因此,現在的示例將如下所示:
<form #myForm="ngForm" (ngSubmit)="register(myForm)"> … </form>
我們必須有一個相應的方法register
,在 AppComponent 中實現。 就像是:
register (myForm: NgForm) { console.log('Successful registration'); console.log(myForm); }
這樣,通過利用 onSubmit 事件,我們只有在執行提交時才能訪問 NgForm 組件。
第二種方法是通過將 @ViewChild 裝飾器添加到組件的屬性來使用視圖查詢。
@ViewChild('myForm') private myForm: NgForm;
使用這種方法,無論是否觸發了 onSubmit 事件,我們都可以訪問表單。
偉大的! 現在我們有了一個功能齊全的 Angular 4 表單,可以訪問組件中的表單。 但是,您是否注意到缺少一些東西? 如果用戶在“years”輸入中輸入“this-is-not-a-year”之類的內容會怎樣? 是的,你明白了,我們缺乏對輸入的驗證,我們將在下一節中介紹。
驗證
驗證對於每個應用程序都非常重要。 我們總是希望驗證用戶輸入(我們不能信任用戶)以防止發送/保存無效數據,並且我們必須顯示一些關於錯誤的有意義的消息以正確引導用戶輸入有效數據。
對於某些輸入強制執行某些驗證規則,正確的驗證器必須與該輸入相關聯。 Angular 4 已經提供了一組常見的驗證器,例如: required
、 maxLength
、 minLength
...
那麼,我們如何將驗證器與輸入關聯起來呢? 嗯,很容易; 只需將驗證器指令添加到控件:
<input name="name" ngModel required/>
此示例強制輸入“名稱”。 讓我們為示例中的所有輸入添加一些驗證。
<form #myForm="ngForm" (ngSubmit)="actionOnSubmit(myForm)" novalidate> <p>Is "myForm" valid? {{myForm.valid}}</p> .. <input type="text" name="name" ngModel required/> .. <input type="text" name="birthYear" ngModel required pattern="\\d{4,4}"/> .. <div ngModelGroup="location"> .. <input type="text" name="country" ngModel required/> .. <input type="text" name="city" ngModel/> </div> <div ngModelGroup="phoneNumbers"> .. <input type="text" name="phoneNumber[{{phoneId}}]" ngModel required/> .. </div> .. </form>
注意:
novalidate
用於禁用瀏覽器的原生表單驗證。
我們已將“姓名”設為必填,“年份”字段為必填且必須僅包含數字,需要輸入國家/地區以及電話號碼。 此外,我們使用{{myForm.valid}}
打印表單的有效性狀態。
對這個示例的改進是還顯示用戶輸入有什麼問題(不僅僅是顯示整體狀態)。 在我們繼續添加額外的驗證之前,我想實現一個幫助組件,它允許我們打印提供的控件的所有錯誤。
// show-errors.component.ts import { Component, Input } from '@angular/core'; import { AbstractControlDirective, AbstractControl } from '@angular/forms'; @Component({ selector: 'show-errors', template: ` <ul *ngIf="shouldShowErrors()"> <li *ngFor="let error of listOfErrors()">{{error}}</li> </ul> `, }) export class ShowErrorsComponent { private static readonly errorMessages = { 'required': () => 'This field is required', 'minlength': (params) => 'The min number of characters is ' + params.requiredLength, 'maxlength': (params) => 'The max allowed number of characters is ' + params.requiredLength, 'pattern': (params) => 'The required pattern is: ' + params.requiredPattern, 'years': (params) => params.message, 'countryCity': (params) => params.message, 'uniqueName': (params) => params.message, 'telephoneNumbers': (params) => params.message, 'telephoneNumber': (params) => params.message }; @Input() private control: AbstractControlDirective | AbstractControl; shouldShowErrors(): boolean { return this.control && this.control.errors && (this.control.dirty || this.control.touched); } listOfErrors(): string[] { return Object.keys(this.control.errors) .map(field => this.getMessage(field, this.control.errors[field])); } private getMessage(type: string, params: any) { return ShowErrorsComponent.errorMessages[type](params); } }
僅當存在一些現有錯誤並且輸入被觸摸或臟時,才會顯示帶有錯誤的列表。
在預定義消息errorMessages
的映射中查找每個錯誤的消息(我已經預先添加了所有消息)。
該組件可以按如下方式使用:
<div> <label>Birth Year</label> <input type="text" name="birthYear" #birthYear="ngModel" ngModel required pattern="\\d{4,4}"/> <show-errors [control]="birthYear"></show-errors> </div>
我們需要為每個輸入公開 NgModel 並將其傳遞給呈現所有錯誤的組件。 你可以注意到在這個例子中我們使用了一個模式來檢查數據是否是一個數字; 如果用戶輸入“0000”怎麼辦? 這將是一個無效的輸入。 此外,我們缺少唯一名稱的驗證器、國家的奇怪限制(如果 country='France',則城市必須是 'Paris')、正確電話號碼的模式以及至少一個電話號碼的驗證存在。 現在是查看自定義驗證器的最佳時機。
Angular 4 提供了一個每個自定義驗證器都必須實現的接口,即 Validator 接口(真是令人驚訝!)。 Validator 接口基本上是這樣的:
export interface Validator { validate(c: AbstractControl): ValidationErrors | null; registerOnValidatorChange?(fn: () => void): void; }
每個具體實現必須實現“驗證”方法。 這個validate
方法非常有趣,什麼可以作為輸入接收,什麼應該作為輸出返回。 輸入是一個 AbstractControl,這意味著參數可以是擴展 AbstractControl 的任何類型(FormGroup、FormControl 和 FormArray)。 如果用戶輸入有效,則validate
方法的輸出應為null
或undefined
(無輸出),如果用戶輸入無效,則返回ValidationErrors
對象。 有了這些知識,現在我們將實現一個自定義的birthYear
驗證器。
import { Directive } from '@angular/core'; import { NG_VALIDATORS, FormControl, Validator, ValidationErrors } from '@angular/forms'; @Directive({ selector: '[birthYear]', providers: [{provide: NG_VALIDATORS, useExisting: BirthYearValidatorDirective, multi: true}] }) export class BirthYearValidatorDirective implements Validator { validate(c: FormControl): ValidationErrors { const numValue = Number(c.value); const currentYear = new Date().getFullYear(); const minYear = currentYear - 85; const maxYear = currentYear - 18; const isValid = !isNaN(numValue) && numValue >= minYear && numValue <= maxYear; const message = { 'years': { 'message': 'The year must be a valid number between ' + minYear + ' and ' + maxYear } }; return isValid ? null : message; } }
這裡有幾件事需要解釋。 首先,您可能會注意到我們實現了 Validator 接口。 validate
方法根據輸入的出生年份檢查用戶是否在 18 到 85 歲之間。 如果輸入有效,則返回null
,否則返回包含驗證消息的對象。 最後也是最重要的部分是將這個指令聲明為驗證器。 這是在@Directive 裝飾器的“providers”參數中完成的。 該驗證器作為多提供者 NG_VALIDATORS 的一個值提供。 另外,不要忘記在 NgModule 中聲明這個指令。 現在我們可以使用這個驗證器如下:
<input type="text" name="birthYear" #year="ngModel" ngModel required birthYear/>
對,就這麼簡單!
對於電話號碼,我們可以像這樣驗證電話號碼的格式:
import { Directive } from '@angular/core'; import { NG_VALIDATORS, Validator, FormControl, ValidationErrors } from '@angular/forms'; @Directive({ selector: '[telephoneNumber]', providers: [{provide: NG_VALIDATORS, useExisting: TelephoneNumberFormatValidatorDirective, multi: true}] }) export class TelephoneNumberFormatValidatorDirective implements Validator { validate(c: FormControl): ValidationErrors { const isValidPhoneNumber = /^\d{3,3}-\d{3,3}-\d{3,3}$/.test(c.value); const message = { 'telephoneNumber': { 'message': 'The phone number must be valid (XXX-XXX-XXX, where X is a digit)' } }; return isValidPhoneNumber ? null : message; } }
現在來兩個驗證,國家和電話號碼的數量。 注意到他們倆的共同點了嗎? 兩者都需要多個控件來執行正確的驗證。 好吧,您還記得 Validator 接口,以及我們所說的嗎? validate
方法的參數是 AbstractControl,可以是用戶輸入,也可以是表單本身。 這創造了實現驗證器的機會,該驗證器使用多個控件來確定具體的驗證狀態。
import { Directive } from '@angular/core'; import { NG_VALIDATORS, Validator, FormGroup, ValidationErrors } from '@angular/forms'; @Directive({ selector: '[countryCity]', providers: [{provide: NG_VALIDATORS, useExisting: CountryCityValidatorDirective, multi: true}] }) export class CountryCityValidatorDirective implements Validator { validate(form: FormGroup): ValidationErrors { const countryControl = form.get('location.country'); const cityControl = form.get('location.city'); if (countryControl != null && cityControl != null) { const country = countryControl.value; const city = cityControl.value; let error = null; if (country === 'France' && city !== 'Paris') { error = 'If the country is France, the city must be Paris'; } const message = { 'countryCity': { 'message': error } }; return error ? message : null; } } }
我們實施了一個新的驗證器,國家-城市驗證器。 您可以注意到,現在 validate 方法接收一個 FormGroup 作為參數,並且我們可以從該 FormGroup 檢索驗證所需的輸入。 其餘的事情與單輸入驗證器非常相似。
電話號碼數量的驗證器將如下所示:
import { Directive } from '@angular/core'; import { NG_VALIDATORS, Validator, FormGroup, ValidationErrors, FormControl } from '@angular/forms'; @Directive({ selector: '[telephoneNumbers]', providers: [{provide: NG_VALIDATORS, useExisting: TelephoneNumbersValidatorDirective, multi: true}] }) export class TelephoneNumbersValidatorDirective implements Validator { validate(form: FormGroup): ValidationErrors { const message = { 'telephoneNumbers': { 'message': 'At least one telephone number must be entered' } }; const phoneNumbers = <FormGroup> form.get('phoneNumbers'); const hasPhoneNumbers = phoneNumbers && Object.keys(phoneNumbers.controls).length > 0; return hasPhoneNumbers ? null : message; } }
我們可以像這樣使用它們:
<form #myForm="ngForm" countryCity telephoneNumbers> .. </form>
與輸入驗證器相同,對吧? 剛剛申請表格。
你還記得 ShowErrors 組件嗎? 我們實現它以使用 AbstractControlDirective,這意味著我們可以重用它來顯示與此表單直接相關的所有錯誤。 請記住,此時唯一與表單直接關聯的驗證規則是Country-city
和Telephone numbers
(其他驗證器與特定的表單控件相關聯)。 要打印出所有表單錯誤,只需執行以下操作:
<form #myForm="ngForm" countryCity telephoneNumbers > <show-errors [control]="myForm"></show-errors> .. </form>
剩下的最後一件事是驗證唯一名稱。 這有點不同; 要檢查名稱是否唯一,很可能需要調用後端來檢查所有現有名稱。 這歸類為異步操作。 為此,我們可以為自定義驗證器重用以前的技術,只需讓validate
返回一個將在未來某個時間解析的對象(promise 或 observable)。 在我們的例子中,我們將使用一個 Promise:
import { Directive } from '@angular/core'; import { NG_ASYNC_VALIDATORS, Validator, FormControl, ValidationErrors } from '@angular/forms'; @Directive({ selector: '[uniqueName]', providers: [{provide: NG_ASYNC_VALIDATORS, useExisting: UniqueNameValidatorDirective, multi: true}] }) export class UniqueNameValidatorDirective implements Validator { validate(c: FormControl): ValidationErrors { const message = { 'uniqueName': { 'message': 'The name is not unique' } }; return new Promise(resolve => { setTimeout(() => { resolve(c.value === 'Existing' ? message : null); }, 1000); }); } }
我們等待 1 秒,然後返回結果。 與同步驗證器類似,如果 promise 被解析為null
,則表示驗證通過; 如果承諾通過其他方式解決,則驗證失敗。 另請注意,現在此驗證器已註冊到另一個多提供者NG_ASYNC_VALIDATORS
。 關於異步驗證器的表單的一個有用屬性是pending
屬性。 它可以這樣使用:
<button [disabled]="myForm.pending">Register</button>
它將禁用該按鈕,直到異步驗證器得到解決。
這是一個包含完整 AppComponent、ShowErrors 組件和所有驗證器的 Plunker。
通過這些示例,我們已經涵蓋了使用模板驅動表單的大多數情況。 我們已經展示了模板驅動的表單與 AngularJS 中的表單非常相似(AngularJS 開發人員非常容易遷移)。 使用這種類型的表單,很容易以最少的編程集成 Angular 4 表單,主要是在 HTML 模板中進行操作。
反應形式
反應式表單也被稱為“模型驅動”表單,但我喜歡稱它們為“程序化”表單,很快你就會明白為什麼。 響應式表單是支持 Angular 4 表單的一種新方法,因此與模板驅動不同,AngularJS 開發人員不會熟悉這種類型。
我們現在可以開始了,還記得模板驅動的表單是如何有一個特殊的模塊的嗎? 好吧,反應式表單也有自己的模塊,稱為 ReactiveFormsModule ,必須導入才能激活這種類型的表單。
import {ReactiveFormsModule} from '@angular/forms' import {NgModule} from '@angular/core' import {BrowserModule} from '@angular/platform-browser' import {AppComponent} from 'src/app.component'; @NgModule({ imports: [ BrowserModule, ReactiveFormsModule ], declarations: [ AppComponent], bootstrap: [ AppComponent ] }) export class AppModule {}
另外,不要忘記引導應用程序。
我們可以從與上一節相同的 AppComponent 和模板開始。
此時,如果沒有導入 FormsModule(請確保沒有導入),我們只有一個帶有幾個表單控件的常規 HTML 表單元素,這裡沒有 Angular 魔法。
我們到了這一點,您會注意到為什麼我喜歡將這種方法稱為“程序化”。 為了啟用 Angular 4 表單,我們必須手動聲明 FormGroup 對象並使用如下控件填充它:
import { FormGroup, FormControl, FormArray, NgForm } from '@angular/forms'; import { Component, OnInit } from '@angular/core'; @Component({ selector: 'my-app', templateUrl: 'src/app.component.html' }) export class AppComponent implements OnInit { private myForm: FormGroup; constructor() { } ngOnInit() { this.myForm = new FormGroup({ 'name': new FormControl(), 'birthYear': new FormControl(), 'location': new FormGroup({ 'country': new FormControl(), 'city': new FormControl() }), 'phoneNumbers': new FormArray([new FormControl('')]) }); } printMyForm() { console.log(this.myForm); } register(myForm: NgForm) { console.log('Registration successful.'); console.log(myForm.value); } }
printForm
和register
方法與前面的示例相同,將在接下來的步驟中使用。 這裡使用的關鍵類型是 FormGroup、FormControl 和 FormArray。 這三種類型是我們創建有效 FormGroup 所需要的全部。 FormGroup 很簡單; 它是一個簡單的控件容器。 FormControl 也很簡單; 它是任何控件(例如,輸入)。 最後,FormArray 是我們在模板驅動方法中缺少的一塊拼圖。 FormArray 允許維護一組控件,而無需為每個控件指定一個具體的鍵,基本上是一個控件數組(對於電話號碼來說似乎是完美的東西,對吧?)。
在構建這三種類型中的任何一種時,請記住這條 3 規則。 每種類型的構造函數接收三個參數—— value
、驗證器或驗證器列表,以及異步驗證器或異步驗證器列表,在代碼中定義:
constructor(value: any, validator?: ValidatorFn | ValidatorFn[], asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[]);
對於 FormGroup, value
是一個對象,其中每個鍵代表控件的名稱,值是控件本身。
對於 FormArray,該value
是一個控件數組。
對於 FormControl,該value
是控件的初始值或初始狀態(包含value
和disabled
屬性的對象)。
我們已經創建了 FormGroup 對象,但是模板仍然不知道這個對象。 組件中的 FormGroup 和模板之間的鏈接是通過四個指令完成的: formGroup
、 formControlName
、 formGroupName
和formArrayName
,使用如下:
<form [formGroup]="myForm" (ngSubmit)="register(myForm)"> <div> <label>Name</label> <input type="text" name="name" formControlName="name"> </div> <div> <label>Birth Year</label> <input type="text" name="birthYear" formControlName="birthYear"> </div> <div formGroupName="location"> <h3>Location</h3> <div> <label>Country</label> <input type="text" name="country" formControlName="country"> </div> <div> <label>City</label> <input type="text" name="city" formControlName="city"> </div> </div> <div formArrayName="phoneNumbers"> <h3>Phone numbers</h3> <div *ngFor="let phoneNumberControl of myForm.controls.phoneNumbers.controls; let i=index;"> <label>Phone number {{i + 1}}</label> <input type="text" name="phoneNumber[{{phoneId}}]" [formControlName]="i"> <button type="button" (click)="remove(i)">remove</button> </div> <button type="button" (click)="add()">Add phone number</button> </div> <pre>{{myForm.value | json}}</pre> <button type="submit">Register</button> <button type="button" (click)="printMyForm()">Print to console</button> </form>
現在我們有了 FormArray,您可以看到我們可以使用該結構來呈現所有電話號碼。

現在添加對添加和刪除電話號碼的支持(在組件中):
remove(i: number) { (<FormArray>this.myForm.get('phoneNumbers')).removeAt(i); } add() { (<FormArray>this.myForm.get('phoneNumbers')).push(new FormControl('')); }
現在,我們有了一個功能齊全的 Angular 4 響應式表單。 注意與模板驅動表單的區別,FormGroup 是“在模板中創建”(通過掃描模板結構)並傳遞給組件的,而在反應式表單中則相反,完整的 FormGroup 是在組件,然後“傳遞給模板”並與相應的控件鏈接。 但是,我們在驗證方面也遇到了同樣的問題,這個問題將在下一節中解決。
驗證
在驗證方面,反應式表單比模板驅動的表單靈活得多。 無需額外更改,我們可以重用之前實現的相同驗證器(用於模板驅動)。 因此,通過添加驗證器指令,我們可以激活相同的驗證:
<form [formGroup]="myForm" (ngSubmit)="register(myForm)" countryCity telephoneNumbers novalidate> <input type="text" name="name" formControlName="name" required uniqueName> <show-errors [control]="myForm.controls.name"></show-errors> .. <input type="text" name="birthYear" formControlName="birthYear" required birthYear> <show-errors [control]="myForm.controls.birthYear"></show-errors> .. <div formGroupName="location"> .. <input type="text" name="country" formControlName="country" required> <show-errors [control]="myForm.controls.location.controls.country"></show-errors> .. <input type="text" name="city" formControlName="city"> .. </div> <div formArrayName="phoneNumbers"> <h3>Phone numbers</h3> .. <input type="text" name="phoneNumber[{{phoneId}}]" [formControlName]="i" required telephoneNumber> <show-errors [control]="phoneNumberControl"></show-errors> .. </div> .. </form>
請記住,現在我們沒有將 NgModel 指令傳遞給 ShowErrors 組件,但是已經構建了完整的 FormGroup,我們可以傳遞正確的 AbstractControl 來檢索錯誤。
這是一個完整的工作 Plunker,對反應形式進行了這種類型的驗證。
但是如果我們只是重用驗證器,那不會很有趣,對吧? 我們將看看如何在創建表單組時指定驗證器。
還記得我們提到的關於 FormGroup、FormControl 和 FormArray 的構造函數的“3s 規則”嗎? 是的,我們說過構造函數可以接收驗證器函數。 所以,讓我們試試這種方法。
首先,我們需要將所有驗證器的validate
函數提取到一個類中,將它們公開為靜態方法:
import { FormArray, FormControl, FormGroup, ValidationErrors } from '@angular/forms'; export class CustomValidators { static birthYear(c: FormControl): ValidationErrors { const numValue = Number(c.value); const currentYear = new Date().getFullYear(); const minYear = currentYear - 85; const maxYear = currentYear - 18; const isValid = !isNaN(numValue) && numValue >= minYear && numValue <= maxYear; const message = { 'years': { 'message': 'The year must be a valid number between ' + minYear + ' and ' + maxYear } }; return isValid ? null : message; } static countryCity(form: FormGroup): ValidationErrors { const countryControl = form.get('location.country'); const cityControl = form.get('location.city'); if (countryControl != null && cityControl != null) { const country = countryControl.value; const city = cityControl.value; let error = null; if (country === 'France' && city !== 'Paris') { error = 'If the country is France, the city must be Paris'; } const message = { 'countryCity': { 'message': error } }; return error ? message : null; } } static uniqueName(c: FormControl): Promise<ValidationErrors> { const message = { 'uniqueName': { 'message': 'The name is not unique' } }; return new Promise(resolve => { setTimeout(() => { resolve(c.value === 'Existing' ? message : null); }, 1000); }); } static telephoneNumber(c: FormControl): ValidationErrors { const isValidPhoneNumber = /^\d{3,3}-\d{3,3}-\d{3,3}$/.test(c.value); const message = { 'telephoneNumber': { 'message': 'The phone number must be valid (XXX-XXX-XXX, where X is a digit)' } }; return isValidPhoneNumber ? null : message; } static telephoneNumbers(form: FormGroup): ValidationErrors { const message = { 'telephoneNumbers': { 'message': 'At least one telephone number must be entered' } }; const phoneNumbers = <FormArray>form.get('phoneNumbers'); const hasPhoneNumbers = phoneNumbers && Object.keys(phoneNumbers.controls).length > 0; return hasPhoneNumbers ? null : message; } }
Now we can change the creation of 'myForm' to:
this.myForm = new FormGroup({ 'name': new FormControl('', Validators.required, CustomValidators.uniqueName), 'birthYear': new FormControl('', [Validators.required, CustomValidators.birthYear]), 'location': new FormGroup({ 'country': new FormControl('', Validators.required), 'city': new FormControl() }), 'phoneNumbers': new FormArray([this.buildPhoneNumberComponent()]) }, Validators.compose([CustomValidators.countryCity, CustomValidators.telephoneNumbers]) );
看? The rule of “3s,” when defining a FormControl, multiple validators can be declared in an array, and if we want to add multiple validators to a FormGroup they must be “merged” using Validators.compose (also Validators.composeAsync is available). And, that's it, validation should be working completely. There's a Plunker for this example as well.
This goes out to everybody that hates the “new” word. For working with the reactive forms, there's a shortcut provided—a builder, to be more precise. The FormBuilder allows creating the complete FormGroup by using the “builder pattern.” And that can be done by changing the FormGroup construction like this:
constructor(private fb: FormBuilder) { } ngOnInit() { this.myForm = this.fb.group({ 'name': ['', Validators.required, CustomValidators.uniqueName], 'birthYear': ['', [Validators.required, CustomValidators.birthYear]], 'location': this.fb.group({ 'country': ['', Validators.required], 'city': '' }), 'phoneNumbers': this.fb.array([this.buildPhoneNumberComponent()]) }, { validator: Validators.compose([CustomValidators.countryCity, CustomValidators.telephoneNumbers]) } ); }
Not a very big improvement from the instantiation with “new,” but there it is. And, don't worry, there's a Plunker for this also.
In this second section, we had a look at reactive forms in Angular 4. As you may notice, it is a completely new approach towards adding support for forms. Even though it seems verbose, this approach gives the developer total control over the underlying structure that enables forms in Angular 4. Also, since the reactive forms are created manually in the component, they are exposed and provide an easy way to be tested and controlled, while this was not the case with the template-driven forms.
Nesting Forms
Nesting forms is in some cases useful and a required feature, mainly when the state (eg, validity) of a sub-group of controls needs to determined. Think about a tree of components; we might be interested in the validity of a certain component in the middle of that hierarchy. That would be really hard to achieve if we had a single form at the root component. But, oh boy, it is a sensitive manner on a couple of levels. First, nesting real HTML forms, according to the HTML specification, is not allowed. We might try to nest <form> elements. In some browsers it might actually work, but we cannot be sure that it will work on all browsers, since it is not in the HTML spec. In AngularJS, the way to work around this limitation was to use the ngForm
directive, which offered the AngularJS form functionalities (just grouping of the controls, not all form
capabilities like posting to the server) but could be placed on any element. Also, in AngularJS, nesting of forms (when I say forms, I mean NgForm) was available out of the box. Just by declaring a tree of couple of elements with the ngForm
directive, the state of each form was propagated upwards to the root element.
In the next section, we will have a look at a couple options on how to nest forms. I like to point out that we can differentiate two types of nesting: within the same component and across different components.
Nesting within the Same Component
If you take a look at the example that we implemented with the template-driven and the reactive approach, you will notice that we have two inner containers of controls, the “location” and the “phone numbers.” To create that container, to store the values in a separate property object, we used the NgModelGroup, FormGroupName, and the FormArrayName directives. If you have a good look at the definition of each directive, you may notice that each one of them extends the ControlContainer class (directly or indirectly). Well, what do you know, it turns out this is enough to provide the functionality that we require, wrapping up the state of all inner controls and propagating that state to the parent.
For the template-driven form, we need to do the following changes:
<form #myForm="ngForm" (ngSubmit)="register(myForm)" novalidate> .. <div ngModelGroup="location" #location="ngModelGroup" countryCity> .. <show-errors [control]="location"></show-errors> </div> <div ngModelGroup="phoneNumbers" #phoneNumbers="ngModelGroup" telephoneNumbers> .. <show-errors [control]="phoneNumbers"></show-errors> </div> </form>
We added the ShowErrors component to each group, to show the errors directly associated with that group only. Since we moved the countryCity
and telephoneNumbers
validators to a different level, we also need to update them appropriately:
// country-city-validator.directive.ts let countryControl = form.get('country'); let cityControl = form.get('city');
And telephone-numbers-validator.directive.ts to:
let phoneNumbers = form.controls; let hasPhoneNumbers = phoneNumbers && Object.keys(phoneNumbers).length > 0;
You can try the full example with template-driven forms in this Plunker.
And for the reactive forms, we will need some similar changes:
<form [formGroup]="myForm" (ngSubmit)="register(myForm)" novalidate> .. <div formGroupName="location"> .. <show-errors [control]="myForm.controls.location"></show-errors> </div> <div formArrayName="phoneNumbers"> .. <show-errors [control]="myForm.controls.phoneNumbers"></show-errors> </div> .. </form>
The same changes from country-city-validator.directive.ts
and telephone-numbers-validator.directive.ts
are required for the countryCity
and telephoneNumbers
validators in CustomValidators to properly locate the controls.
And lastly, we need to modify the construction of the FormGroup to:
this.myForm = new FormGroup({ 'name': new FormControl('', Validators.required, CustomValidators.uniqueName), 'birthYear': new FormControl('', [Validators.required, CustomValidators.birthYear]), 'location': new FormGroup({ 'country': new FormControl('', Validators.required), 'city': new FormControl() }, CustomValidators.countryCity), 'phoneNumbers': new FormArray([this.buildPhoneNumberComponent()], CustomValidators.telephoneNumbers) });
And there you have it—we've improved the validation for the reactive forms as well and as expected, the Plunker for this example.
Nesting across Different Components
It may come as a shock to all AngularJS developers, but in Angular 4, nesting of forms across different component doesn't work out of the box. I'm going to be straight honest with you; my opinion is that nesting is not supported for a reason (probably not because the Angular 4 team just forgot about it). Angular4's main enforced principle is a one-way data flow, top to bottom through the tree of components. The whole framework was designed like that, where the vital operation, the change detection, is executed in the same manner, top to bottom. If we follow this principle completely, we should have no issues, and all changes should be resolved within one full detection cycle. That's the idea, at least. In order to check that one-way data flow is implemented correctly, the nice guys in the Angular 4 team implemented a feature that after each change detection cycle, while in development mode, an additional round of change detection is triggered to check that no binding was changed as a result of reverse data propagation. What this means, let's think about a tree of components (C1, C2, C3, C4) as in Fig. 1, the change detection starts at the C1 component, continues at the C2 component and ends in the C3 component.
If we have some method in C3 with a side effect that changes some binding in C1, that means that we are pushing data upwards, but the change detection for C1 already passed. When working in dev mode, the second round kicks in and notices a change in C1 that came as a result of a method execution in some child component. Then you are in trouble and you'll probably see the “Expression has changed after it was checked” exception. You could just turn off the development mode and there will be no exception, but the problem will not be solved; plus, how would you sleep at night, just sweeping all your problems under the rug like that?
一旦你知道了,想想如果我們聚合表單狀態我們在做什麼。 沒錯,數據是向上推組件樹的。 即使在使用單級表單時,表單控件 ( ngModel
) 和表單本身的集成也不是很好。 它們在註冊或更新控件的值時觸發額外的更改檢測週期(使用已解析的 Promise 完成,但要保密)。 為什麼需要額外一輪? 出於同樣的原因,數據向上流動,從控件流向表單。 但是,也許有時,跨多個組件嵌套表單是一項必需的功能,我們需要考慮一種解決方案來支持這一要求。
就我們目前所知,想到的第一個想法是使用響應式表單,在某個根組件中創建完整的表單樹,然後將子表單作為輸入傳遞給子組件。 通過這種方式,您將父組件與子組件緊密耦合,並在處理所有子表單的創建時使根組件的業務邏輯變得混亂。 來吧,我們是專業人士,我相信我們可以找到一種方法來創建帶有表單的完全隔離的組件,並提供一種將狀態傳播給父級的方法。
說了這麼多,這是一個允許嵌套 Angular 4 表單的指令(實施是因為它是項目所需要的):
import { OnInit, OnDestroy, Directive, SkipSelf, Optional, Attribute, Injector, Input } from '@angular/core'; import { NgForm, FormArray, FormGroup, AbstractControl } from '@angular/forms'; const resolvedPromise = Promise.resolve(null); @Directive({ selector: '[nestableForm]' }) export class NestableFormDirective implements OnInit, OnDestroy { private static readonly FORM_ARRAY_NAME = 'CHILD_FORMS'; private currentForm: FormGroup; @Input() private formGroup: FormGroup; constructor(@SkipSelf() @Optional() private parentForm: NestableFormDirective, private injector: Injector, @Attribute('rootNestableForm') private isRoot) { } ngOnInit() { if (!this.currentForm) { // NOTE: at this point both NgForm and ReactiveFrom should be available this.executePostponed(() => this.resolveAndRegister()); } } ngOnDestroy() { this.executePostponed(() => this.parentForm.removeControl(this.currentForm)); } public registerNestedForm(control: AbstractControl): void { // NOTE: prevent circular reference (adding to itself) if (control === this.currentForm) { throw new Error('Trying to add itself! Nestable form can be added only on parent "NgForm" or "FormGroup".'); } (<FormArray>this.currentForm.get(NestableFormDirective.FORM_ARRAY_NAME)).push(control); } public removeControl(control: AbstractControl): void { const array = (<FormArray>this.currentForm.get(NestableFormDirective.FORM_ARRAY_NAME)); const idx = array.controls.indexOf(control); array.removeAt(idx); } private resolveAndRegister(): void { this.currentForm = this.resolveCurrentForm(); this.currentForm.addControl(NestableFormDirective.FORM_ARRAY_NAME, new FormArray([])); this.registerToParent(); } private resolveCurrentForm(): FormGroup { // NOTE: template-driven or model-driven => determined by the formGroup input return this.formGroup ? this.formGroup : this.injector.get(NgForm).control; } private registerToParent(): void { if (this.parentForm != null && !this.isRoot) { this.parentForm.registerNestedForm(this.currentForm); } } private executePostponed(callback: () => void): void { resolvedPromise.then(() => callback()); } }
以下 GIF 中的示例顯示了一個包含form-1
main
組件,並且在該表單內,還有另一個嵌套組件component-2
。 component-2
包含form-2
,它具有嵌套form-2.1
、 form-2.2
和一個組件 ( component-3
),其中包含一個反應式表單樹,以及一個包含一個表單的組件 ( component-4
)與所有其他形式隔離。 我知道這很混亂,但我想製作一個相當複雜的場景來展示該指令的功能。
該示例在此 Plunker 中實現。
它提供的功能是:
通過向元素添加 nestableForm 指令來啟用嵌套:form、ngForm、[ngForm]、[formGroup]
適用於模板驅動和反應式表單
允許構建跨越多個組件的表單樹
使用 rootNestableForm=”true” 隔離表單的子樹(它不會註冊到父嵌套表單)
該指令允許子組件中的表單註冊到第一個父嵌套表單,無論父表單是否在同一組件中聲明。 我們將詳細介紹實現的細節。
首先,讓我們看一下構造函數。 第一個論點是:
@SkipSelf() @Optional() private parentForm: NestableFormDirective
這會查找第一個 NestableFormDirective 父級。 @SkipSelf,不匹配自身,@Optional 因為它可能找不到父級,如果是根表單。 現在我們有了對父可嵌套表單的引用。
第二個論點是:
private injector: Injector
注入器用於檢索當前的 FormGroup provider
(模板或反應式)。
最後一個論點是:
@Attribute('rootNestableForm') private isRoot
獲取確定此表單是否與表單樹隔離的值。
接下來,在ngInit
上作為一個延遲操作(還記得反向數據流嗎?),當前 FormGroup 被解析,一個名為CHILD_FORMS
的新 FormArray 控件被註冊到這個 FormGroup(將註冊子表單),作為最後一個操作,當前 FormGroup 註冊為父可嵌套表單的子項。
ngOnDestroy
操作在表單被銷毀時執行。 在銷毀時,再次作為延遲操作,當前表單將從父級中刪除(取消註冊)。
可嵌套表單的指令可以根據特定需求進一步定制——可能移除對反應式表單的支持,以特定名稱註冊每個子表單(而不是在數組 CHILD_FORMS 中)等等。 nestableForm 指令的這種實現滿足了項目的要求,因此在此呈現。 它涵蓋了一些基本情況,例如添加新表單或動態刪除現有表單 (*ngIf) 並將表單的狀態傳播到父級。 這基本上歸結為可以在一個變更檢測週期內解決的操作(無論是否推遲)。
如果您想要一些更高級的場景,例如向需要 2 輪更改檢測的輸入(例如 [required]=”someCondition”)添加條件驗證,則由於“one-detection-cycle-resolution”規則,它將無法工作由 Angular 4 強加。
無論如何,如果您打算使用此指令或實施其他一些解決方案,請非常小心提到的與更改檢測相關的事情。 至此,Angular 4 就是這樣實現的。 它可能會在未來發生變化——我們無法知道。 本文中提到的 Angular 4 中的當前設置和強制限制可能是缺點或優點。 它還有待觀察。
Angular 4 讓表單變得簡單
如您所見,Angular 團隊在提供許多與表單相關的功能方面做得非常好。 我希望這篇文章將作為在 Angular 4 中使用不同類型表單的完整指南,同時也能深入了解一些更高級的概念,如表單嵌套和變更檢測過程。
儘管有所有與 Angular 4 表單(或任何其他 Angular 4 主題)相關的不同帖子,但在我看來,最好的起點是官方 Angular 4 文檔。 此外,Angular 的人在他們的代碼中有很好的文檔。 很多時候,我只通過查看他們的源代碼和那裡的文檔就找到了解決方案,沒有谷歌搜索或任何東西。 關於表單的嵌套,在上一節中討論過,我相信任何開始學習 Angular 4 的 AngularJS 開發人員都會在某個時候偶然發現這個問題,這也是我寫這篇文章的靈感。
正如我們也看到的,有兩種類型的表單,並且沒有嚴格的規定不能一起使用它們。 保持代碼庫的干淨和一致很好,但有時,使用模板驅動的表單可以更輕鬆地完成某些事情,有時,情況正好相反。 因此,如果您不介意更大的捆綁包大小,我建議您根據具體情況使用您認為更合適的任何內容。 只是不要將它們混合在同一個組件中,因為它可能會導致一些混亂。
這篇文章中使用的 Plunkers
模板驅動的表單
反應式表單,模板驗證器
反應式表單,代碼驗證器
反應式表單,表單構建器
模板驅動的表單,嵌套在同一個組件中
反應式表單,嵌套在同一個組件中
通過組件樹嵌套表單