使用Angular 4表单:嵌套和输入验证

本文概述

在网络上, 一些最早的用户输入元素是按钮, 复选框, 文本输入和单选按钮。直到今天, 这些元素仍在现代Web应用程序中使用, 尽管HTML标准距其早期定义已经走了很长一段路, 现在允许各种形式的交互。

验证用户输入是任何强大的Web应用程序的重要组成部分。

Angular应用程序中的表单可以汇总该表单下所有输入的状态, 并提供整体状态, 例如完整表单的验证状态。无需单独检查每个输入, 便可以很方便地决定是接受还是拒绝用户输入。

Angular 4表单输入验证

在本文中, 你将学习如何在Angular应用程序中使用表单并轻松执行表单验证。

在Angular 4中, 可以使用两种不同类型的形式:模板驱动形式和反应形式。我们将使用相同的示例遍历每种表单类型, 以了解如何以不同的方式实现相同的内容。稍后, 在本文中, 我们将探讨有关如何设置和使用嵌套表单的新颖方法。

Angular4形式

在Angular 4中, 表单通常使用以下四种状态:

  • 有效–所有表单控件的有效性状态, 如果所有控件均有效, 则为true

  • 无效–有效的倒数;如果某些控件无效, 则为true

  • 原始状态–提供有关表单”清洁度”的状态;如果未修改控件, 则为true

  • 肮脏的–原始的反面;如果某些控件被修改, 则为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>

此示例的规范如下:

  • 名称-是必需的, 并且在所有注册用户中都是唯一的

  • 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会自动检测一个表单HTML元素, 并将NgForm组件附加到该元素(通过NgForm组件的选择器)。在我们的示例中就是这种情况。尽管已声明此Angular 4表单, 但目前尚不知道任何Angular 4支持的输入。 Angular 4将每个输入HTML元素注册到最近的表单祖先并不那么麻烦。

NgModel伪指令是允许输入元素被视为Angular 4元素并注册到NgForm组件的键。因此, 我们可以如下扩展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}}, 我们正在用值漂亮地打印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()用于使表单被触摸, 因此我们可以在此时显示错误。单击按钮不会激活此属性, 只能激活输入。为了使下面的示例更加清楚, 我不会在add()和remove()的点击处理程序上添加此行。试想一下它在那里。 (在Plunker中。)

我们还需要更新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事件由一个名为ngSubmit的Angular 4, 特定于NgForm的事件包装, 如果要对提交执行某些操作, 这是正确的方法。因此, 现在的示例如下所示:

<form #myForm="ngForm" (ngSubmit)="register(myForm)">
  …
</form>

我们必须具有在AppComponent中实现的相应方法寄存器。就像是:

register (myForm: NgForm) {
  console.log('Successful registration');
  console.log(myForm);
}

这样, 通过利用onSubmit事件, 仅当执行Submit时, 我们才可以访问NgForm组件。

第二种方法是通过将@ViewChild装饰器添加到组件的属性来使用视图查询。

@ViewChild('myForm')
private myForm: NgForm;

通过这种方法, 无论onSubmit事件是否被触发, 我们都可以访问该表单。

大!现在我们有了一个功能齐全的Angular 4表单, 可以访问组件中的表单。但是, 你是否注意到缺少的东西?如果用户在”年份”输入中输入类似”这不是一年”的内容怎么办?是的, 你已经明白了, 我们缺乏对输入的验证, 我们将在下一节中进行介绍。

验证方式

验证对于每个应用程序确实很重要。我们始终希望验证用户输入(我们不能信任用户), 以防止发送/保存无效数据, 并且我们必须显示一些有关该错误的有意义的消息, 以正确指导用户输入有效数据。

为了对某些输入实施某些验证规则, 必须将适当的验证器与该输入关联。 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 style="color: red" *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或未定义(无输出), 如果用户输入无效, 则返回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接口。验证方法将根据输入的出生年份检查用户是否在18至85岁之间。如果输入有效, 则返回null, 否则返回包含验证消息的对象。最后也是最重要的部分是将该指令声明为Validator。这是在@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一起使用, 这意味着我们可以重用它以显示与该表单直接相关的所有错误。请记住, 此时, 与表单直接相关的唯一验证规则是”国家/地区”和”电话号码”(其他验证器与特定的表单控件相关联)。要打印出所有表单错误, 只需执行以下操作:

<form #myForm="ngForm" countryCity telephoneNumbers >
  <show-errors [control]="myForm"></show-errors>
  ..
</form>

剩下的最后一件事是对唯一名称的验证。这有点不同。要检查名称是否唯一, 很可能需要调用后端来检查所有现有名称。这归类为异步操作。为此, 我们可以将以前的技术用于自定义验证器, 只需使validate返回一个对象, 该对象将在将来的某个时间(承诺或可观察)进行解析。在我们的情况下, 我们将使用一个承诺:

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秒钟, 然后返回结果。与同步验证器类似, 如果用null解析承诺, 则表示验证已通过;如果承诺通过其他任何方式解决, 则验证失败。还要注意, 现在此验证器已注册到另一个多提供者NG_ASYNC_VALIDATORS。与异步验证器有关的表单的一种有用属性是待处理属性。可以这样使用:

<button [disabled]="myForm.pending">Register</button>

它将禁用按钮, 直到解析了异步验证器。

这是一个Plunker, 其中包含完整的AppComponent, ShowErrors组件和所有验证程序。

在这些示例中, 我们介绍了大多数使用模板驱动的表单的情况。我们已经证明, 模板驱动的表单与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的规则。每种类型的构造函数都接收三个参数-值, 验证器或验证器列表以及异步验证器或异步验证器列表(在代码中定义):

constructor(value: any, validator?: ValidatorFn | ValidatorFn[], asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[]);

对于FormGroup, 值是一个对象, 其中每个键代表控件的名称, 值是控件本身。

对于FormArray, 该值为控件数组。

对于FormControl, 该值为控件的初始值或初始状态(包含值和禁用属性的对象)。

我们已经创建了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是”在模板中创建”(通过扫描模板结构)并传递给组件的, 而在反应形式中, 则是相反的, 在组件, 然后”传递到模板”并与相应的控件链接。但是, 再次有一个与验证相同的问题, 这个问题将在下一部分中解决。

验证方式

在进行验证时, 反应形式比模板驱动形式要灵活得多。在没有其他更改的情况下, 我们可以重复使用先前针对模板驱动实现的相同验证器。因此, 通过添加验证器指令, 我们可以激活相同的验证:

<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规则”规则吗?是的, 我们说构造函数可以接收验证函数。因此, 让我们尝试这种方法。

首先, 我们需要将所有验证器的验证函数提取到一个类中, 将其作为静态方法公开:

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;
 }
}

现在, 我们可以将” myForm”的创建更改为:

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])
);

看到?规则” 3s”, 定义一个FormControl时, 可以在一个数组中声明多个验证器, 如果我们想向FormGroup中添加多个验证器, 则必须使用Validators.compose(也可以使用Validators.composeAsync)将它们”合并”。 。就是这样, 验证应该可以正常进行。此示例也有一个Plunker。

这对所有讨厌”新”字眼的人都适用。为了使用反应式表单, 提供了一个快捷方式-构建器, 更准确地说。 FormBuilder允许使用”构建器模式”创建完整的FormGroup。可以通过如下更改FormGroup的构造来完成:

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])
    }
  );
}

与” new”实例化相比, 这不是一个很大的改进, 但是确实存在。而且, 不用担心, 这也有一个Plunker。

在第二部分中, 我们介绍了Angular 4中的反应式表单。你可能会注意到, 这是增加对表单支持的全新方法。尽管看起来很冗长, 但是这种方法使开发人员可以完全控制在Angular 4中启用表单的底层结构。而且, 由于反应式表单是在组件中手动创建的, 因此它们是公开的, 并提供了一种易于测试和控制的方式, 而模板驱动的表单则并非如此。

嵌套表单

嵌套表单在某些情况下是有用的并且是必需的功能, 主要是在需要确定控件子组的状态(例如, 有效性)时。想一想组件树;我们可能会对层次结构中间的某个组件的有效性感兴趣。如果我们在根组件上只有一个表单, 那将很难实现。但是, 天哪, 在几个层面上这都是一种敏感的方式。首先, 根据HTML规范, 不允许嵌套实际的HTML表单。我们可能会尝试嵌套<form>元素。在某些浏览器中, 它实际上可能有效, 但是由于HTML规范中没有它, 因此我们不能确定它是否在所有浏览器中都能工作。在AngularJS中, 解决此限制的方法是使用ngForm指令, 该指令提供AngularJS表单功能(只是对控件进行分组, 而不是将所有表单功能都发布到服务器), 但是可以放置在任何元素上。另外, 在AngularJS中, 表单的嵌套(当我说表单时, 我的意思是NgForm)是开箱即用的。只需使用ngForm指令声明一对元素树, 每种形式的状态就会向上传播到根元素。

在下一节中, 我们将介绍如何嵌套表单的几个选项。我想指出, 我们可以区分两种类型的嵌套:在同一组件内和在不同组件之间。

嵌套在同一组件中

如果看一下我们使用模板驱动和响应式方法实现的示例, 你会注意到我们有两个内部控件容器, 即”位置”和”电话号码”。为了创建该容器, 以将值存储在单独的属性对象中, 我们使用了NgModelGroup, FormGroupName和FormArrayName指令。如果你对每个指令的定义有了很好的了解, 你可能会注意到其中每个指令都扩展了ControlContainer类(直接或间接)。好了, 你知道的事实证明, 这足以提供我们所需的功能, 包装所有内部控件的状态并将该状态传播给父级。

对于模板驱动的表单, 我们需要进行以下更改:

<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>

我们向每个组添加了ShowErrors组件, 以仅显示与该组直接相关的错误。由于我们将countryCity和电话号码验证器移动到了另一个级别, 因此我们还需要适当地更新它们:

// country-city-validator.directive.ts
let countryControl = form.get('country');
let cityControl = form.get('city');

和电话号码-validator.directive.ts到:

let phoneNumbers = form.controls;
let hasPhoneNumbers = phoneNumbers && Object.keys(phoneNumbers).length > 0;

你可以在此Plunker中尝试使用模板驱动的表单的完整示例。

对于反应形式, 我们将需要一些类似的更改:

<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>

CustomValidators中的countryCity和TelephoneNumbers验证程序需要对country-city-validator.directive.ts和phone-numbers-validator.directive.ts进行相同的更改, 才能正确定位控件。

最后, 我们需要将FormGroup的构造修改为:

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)
});

一切就在这里-我们已经改进了反应式的验证性, 并且正如我们预期的那样, 改进了本例中的Plunker。

跨不同组件嵌套

这可能会给所有AngularJS开发人员带来震撼, 但在Angular 4中, 跨不同组件的表单嵌套无法立即使用。我要对你说实话我的观点是, 由于某种原因不支持嵌套(可能不是因为Angular 4团队只是忘记了嵌套)。 Angular4的主要强制原则是单向数据流, 贯穿组件树从上到下。这样设计整个框架, 其中关键操作, 变更检测以相同的方式从上到下执行。如果我们完全遵循此原则, 则应该没有问题, 所有更改都应在一个完整的检测周期内解决。至少是这个主意。为了检查单向数据流是否正确实现, Angular 4团队的好家伙实现了一项功能, 即在每个变更检测周期之后, 在开发模式下, 将触发另一轮变更检测以检查是否没有绑定由于反向数据传播而被更改。这意味着什么, 让我们考虑一下如图1所示的组件树(C1, C2, C3, C4), 更改检测从C1组件开始, 在C2组件处继续, 并在C3组件处结束。

持有表单的嵌套组件树。

如果我们在C3中有某种方法具有副作用, 它会改变C1中的某些绑定, 则意味着我们正在向上推送数据, 但是C1的更改检测已通过。在开发人员模式下工作时, 第二回合开始, 并注意到C1的更改是由于某个子组件中方法的执行而导致的。然后, 你将遇到麻烦, 并且可能会看到”检查后表达式已更改”例外。你只需关闭开发模式就不会有例外, 但是问题不会得到解决。另外, 你晚上会如何睡觉, 只是将所有问题排在地毯下?

一旦你知道了这一点, 就考虑一下如果我们汇总表单状态, 我们应该做什么。没错, 数据被向上推到组件树。即使使用单级表单, 表单控件(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的主要组件, 而在该表单中, 还有另一个嵌套组件component-2。 component-2包含具有嵌套form-2.1, form-2.2的form-2, 以及其中具有反应性形式树的组件(component-3)和包含以下形式的组件(component-4):与所有其他形式隔离。我知道这很混乱, 但是我想做一个相当复杂的场景来展示该指令的功能。

具有多个组件的Angular表单验证的复杂情况

该示例在此Plunker中实现。

它提供的功能有:

  • 通过将nestableForm指令添加到以下元素来启用嵌套:form, ngForm, [ngForm], [formGroup]

  • 适用于模板驱动和反应形式

  • 能够构建跨越多个组件的表单树

  • 用rootNestableForm =” true”隔离表单的子树(它不会注册到父nestableForm)

此指令允许子组件中的表单注册到第一个父nestableForm, 而不管父表单是否在同一组件中声明。我们将详细介绍实施。

首先, 让我们看一下构造函数。第一个参数是:

@SkipSelf()
@Optional()
private parentForm: NestableFormDirective

这将查找第一个NestableFormDirective父级。 @SkipSelf(不匹配自身)和@Optional, 因为在使用根格式时可能找不到父项。现在, 我们有对父级可嵌套表单的引用。

第二个参数是:

private injector: Injector

注入程序用于检索当前的FormGroup提供程序(模板或反应式)。

最后一个参数是:

@Attribute('rootNestableForm') private isRoot

获取确定此表单是否与表单树隔离的值。

接下来, 在ngInit上作为延迟的动作(还记得反向数据流吗?), 解析当前的FormGroup, 将名为CHILD_FORMS的新FormArray控件注册到该FormGroup(将在其中注册子表单), 最后一个动作是当前FormGroup被注册为父级可嵌套表单的子级。

销毁表单后, 将执行ngOnDestroy操作。销毁时, 作为再次推迟的动作, 当前表单将从父表单中删除(注销)。

可针对特定需求进一步定制可嵌套表单的指令-可能会取消对响应表单的支持, 以特定名称(不在CHILD_FORMS数组中)注册每个子表单, 等等。 nestableForm指令的此实现满足了项目的要求, 并在此处进行了介绍。它涵盖了一些基本情况, 例如动态添加新表单或删除现有表单(* ngIf)并将表单状态传播给父对象。这基本上归结为可以在一个变更检测周期内解决的操作(有无延迟)。

如果你需要一些更高级的方案, 例如向某些输入中添加条件验证(例如[required] =” someCondition”), 需要进行两轮变更检测回合, 则该方法将不起作用, 因为”一个检测周期的分辨率”规则由Angular 4.强加

无论如何, 如果你计划使用此伪指令或实现其他解决方案, 请注意所提到的与变更检测有关的内容。至此, 这就是Angular 4的实现方式。将来可能会改变, 我们无法知道。本文中提到的Angular 4中的当前设置和强制限制可能是缺点或好处。它还有待观察。

Angular 4简化表单

如你所见, Angular团队在提供许多与表单相关的功能方面做得非常好。我希望这篇文章可以作为在Angular 4中使用各种类型的表单的完整指南, 并深入了解一些更高级的概念, 例如表单的嵌套和变更检测的过程。

尽管我认为与Angular 4表单(或与此有关的任何其他Angular 4主题)有关的职位有所不同, 但我认为, 最好的起点是Angular 4官方文档。此外, Angular的人员在其代码中也有不错的文档。很多时候, 我只是通过查看他们的源代码和那里的文档找到了解决方案, 而没有使用Googling或其他工具。关于上一节中讨论的表单嵌套, 我相信任何开始学习Angular 4的AngularJS开发人员都会在某个时候遇到这个问题, 这是我撰写本文的灵感。

正如我们已经看到的, 表单有两种类型, 没有严格的规定, 你不能一起使用它们。保持代码库的整洁和一致是很好的, 但是有时, 使用模板驱动的表单可以更轻松地完成某些工作, 而有时则相反。因此, 如果你不介意捆绑的尺寸稍大一些, 我建议根据具体情况使用你认为更合适的方式。只是不要将它们混入同一组件中, 因为这可能会引起一些混乱。

本文中使用的柱塞

  • 模板驱动的表单

  • 反应形式, 模板验证器

  • 反应形式, 代码验证器

  • 反应性表单, 表单生成器

  • 模板驱动的表单, 嵌套在同一组件中

  • 反应形式, 嵌套在同一组件中

  • 通过组件树嵌套表单

相关:Smart Node.js表单验证

微信公众号
手机浏览(小程序)
0
分享到:
没有账号? 忘记密码?