使用 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
模板驱动的表单
反应式表单,模板验证器
反应式表单,代码验证器
反应式表单,表单构建器
模板驱动的表单,嵌套在同一个组件中
反应式表单,嵌套在同一个组件中
通过组件树嵌套表单