详谈Angular 2+ 的表单(一)之模板驱动型表单

摘要

在企业应用开发时,表单是一个躲不过去的事情,和面向消费者的应用不同,企业领域的开发中,表单的使用量是惊人的。这些表单的处理其实是一个挺复杂的事情,比如有的是涉及到多个 Tab 的表单,有的是向导形式多个步骤的,各种复杂的验证逻辑和时不时需要弹出的对话框等等。笔者试图在这一系列文章中对 Angular 中的表单处理做一个相对完整的梳理。

Angular 中提供两种类型的表单处理机制,一种叫模版驱动型(Template Driven)的表单,另一种叫模型驱动型表单( Model Driven ),这后一种也叫响应式表单 ( Reactive Forms ),由于模版驱动中有一个 ngModel 的指令,容易和这里说的模型驱动混淆,所以在我们的文章中叫后一种说法:响应式表单。

第一篇主要介绍模版驱动型的表单。

号外

本文评论区会抽出5位童鞋,赠送笔者的 《Angular 从零到一》纸书,机不可失,大家踊跃发言哦。

模版驱动的表单

模版驱动的表单和 AngularJS 对于表单的处理类似,把一些指令(比如 ngModel )、数据值和行为约束(比如 require 、 minlength 等等)绑定到模版中(模版就是组件元数据 @Component 中定义的那个 template ),这也是模版驱动这个叫法的来源。总体来说,这种类型的表单通过绑定把很多工作交给了模版。

模版驱动的例子

还是用例子来说话,比如我们有一个用户注册的表单,用户名就是 email ,还需要填的信息有:住址、密码和重复密码。这个应该是比较常见的一个注册时需要的信息了。那么我们第一步来建立领域模型:

// src/app/domain/index.ts
export interface User {
 // 新的用户id一般由服务器自动生成,所以可以为空,用 ? 标示
 id?: string; 
 email: string;
 password: string;
 repeat: string;
 address: Address;
}
export interface Address {
 province: string; // 省份
 city: string; // 城市
 area: string; // 区县
 addr: string; // 详细地址
}

接下来我们建立模版文件,一个最简单的 HTML 模版,先不增加任何的绑定或事件处理:

<!-- template-driven.component.html -->
<form novalidate>
 <label>
 <span>电子邮件地址</span>
 <input
  type="text"
  name="email"
  placeholder="请输入您的 email 地址">
 </label>
 <div>
 <label>
  <span>密码</span>
  <input
  type="password"
  name="password"
  placeholder="请输入您的密码">
 </label>
 <label>
  <span>确认密码</span>
  <input
  type="password"
  name="repeat"
  placeholder="请再次输入密码">
 </label>
 </div>
 <div >
 <label>
  <span>省份</span>
  <select name="province">
  <option value="">请选择省份</option>
  </select>
 </label>
 <label>
  <span>城市</span>
  <select name="city">
  <option value="">请选择城市</option>
  </select>
 </label>
 <label>
  <span>区县</span>
  <select name="area">
  <option value="">请选择区县</option>
  </select>
 </label>
 <label>
  <span>地址</span>
  <input type="text" name="addr">
 </label>
 </div>
 <button type="submit">注册</button>
</form>

渲染之后的效果就像下面这样:

 

简单的Form

数据绑定

对于模版驱动型的表单处理,我们首先需要在对应的模块中引入 FormsModule ,这一点千万不要忘记了。

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from "@angular/forms";
import { TemplateDrivenComponent } from './template-driven/template-driven.component';
@NgModule({
 imports: [
 CommonModule,
 FormsModule
 ],
 exports: [TemplateDrivenComponent],
 declarations: [TemplateDrivenComponent]
})
export class FormDemoModule { }
进行模版驱动类型的表单处理的一个必要步骤就是建立数据的双向绑定,那么我们需要在组件中建立一个类型为 User 的成员变量并赋初始值。
// template-driven.component.ts
// 省略元数据和导入的类库信息
export class TemplateDrivenComponent implements OnInit {
 user: User = {
 email: '',
 password: '',
 repeat: '',
 address: {
  province: '',
  city: '',
  area: '',
  addr: ''
 }
 };
 // 省略其他部分
}

有了这样一个成员变量之后,我们在组件模版中就可以使用 ngModel 进行绑定了。

令人困惑的 ngModel

我们在 Angular 中可以使用三种形式的 ngModel 表达式: ngModel , [ngModel] 和 [(ngModel)] 。但无论那种形式,如果你要使用 ngModel 就必须为该控件(比如下面的 input )指定一个 name 属性,如果你忘记添加 name 的话,多半你会看到下面这样的错误:

ERROR Error: Uncaught (in promise): Error: If ngModel is used within a form tag, either the name attribute must be set or the form control must be defined as 'standalone' in ngModelOptions.

ngModel 和 FormControl

假如我们使用的是 ngModel ,没有任何中括号小括号的话,这代表着我们创建了一个 FormControl 的实例,这个实例将会跟踪值的变化、用户的交互、验证状态以及保持视图和领域对象的同步等工作。

<input
 type="text"
 name="email"
 placeholder="请输入您的 email 地址"
 ngModel>

如果我们将这个控件放在一个 Form 表单中, ngModel 会自动将这个 FormControl 注册为 Form 的子控件。下面的例子中我们在 <form> 中加上了 ngForm 指令,声明这是一个 Angular 可识别的表单,而 ngModel 会将 <input> 注册成表单的子控件,这个子控件的名字就是 email ,而且 ngModel 会基于这个子控件的值去绑定表单的的值,这也是为什么需要显式声明 name 的原因。

其实在我们导入 FormsModule 的时候,所有的 <form> 标签都会默认的被认为是一个 NgForm ,因此我们并不需要显式的在标签中写 ngForm 这个指令。

<!-- ngForm 并不需要显示声明,任何 <form> 标签默认都是 ngForm -->
<form novalidate ngForm>
 <input
 type="text"
 name="email"
 placeholder="请输入您的 email 地址"
 ngModel>
</form>

这一切现在都是不可见的,所以大家可能还是有些困惑,那么下面我们将其“可视化”,这需要我们引用一下表单对象,所以我们使用 #f="ngForm" 以便我们可以在模版中输出表单的一些特性。

<!-- 使用 # 把表单对象导出到 f 这个可引用变量中 -->
<form novalidate #f="ngForm">
 ...
</form>
<!-- 将表单的值以 JSON 形式输出 -->
{{f.value | json}}

这时如果我们在 email 中输入 sss ,可以看到下图的以 JSON 形式出现的表单值:

 

控件的输入值同步到了表单的值中

单向数据绑定

那么接下来,我们看看 [ngModel] 有什么用?如果我们想给控件设置一个初始值怎么办呢,这时就需要进行一个单向绑定,方向是从组件到视图。我们可以做的是在初始化 User 的时候,将 email 属性设置成 wang@163.com

user: User = {
 email: 'wang@163.com',
 ...
 };

而且在模版中使用 [ngModel]="user.email" 进行单向绑定,这个语法其实和普通的属性绑定是一样的,用中括号标示这是一个要进行数据绑定的属性,等号右边是需要绑定的值(这里是 user.email )。那么我们就可以得到下面这样的输出了, email 的初始值被绑定成功!

单向数据绑定

 

双向数据绑定

但上面的例子存在一个问题,数据的绑定是单向的,也就是说,在输入框进行输入的时候,我们的 user 的值不会随之改变的。为了更好的说明,我们将 user 和 表单的值同时输出

<div>
 <span>user: </span> {{user | json}}
</div>
<div>
 <span>表单:</span> {{f.value | json}}
</div>

此时我们将默认的电子邮件改成 wang@gmail.com 的话,表单的值是改变了,但 user 并未改变。

 

输入的值影响了表单,但不会影响领域对象

如果我们希望的是在输入时,这个输入的值也反向的影响我们的 user 对象的值的话,那就需要用到双向绑定了,也就是 [(ngModel)] 需要上场了。

 

表单和领域对象的值保持了同步

无论如何,这个 [()] 表达真是很奇怪的样子,其实这个表达是一个语法糖。只要我们知道下面的两种写法是等价的,我们就会很清楚的理解了:用这个语法糖你就不用既写数据绑定又写事件绑定了。

<input [(ngModel)]="user.email">
<input [ngModel]="user.email"` (ngModelChange)="user.email = $event">

ngModelGroup 是什么鬼?

如果我们仔细观察上面的输出的话,会发现一个问题: user 中是有一个嵌套对象 address 的,而表单中没有嵌套对象的。如果要实现表单中的结构和领域对象的结构一致的话,我们就得请出 ngModelGroup 了。 ngModelGroup 会创建并绑定一个 FormGroup 到该 DOM 元素。 FormGroup 又是什么呢?简单来说,是一组 FormControl。

<!-- 使用 ngModelGroup 来创建并绑定 FormGroup -->
 <div ngModelGroup="address">
 <label>
  <span>省份</span>
  <select name="province" (change)="onProvinceChange()" [(ngModel)]="user.address.province">
  <option value="">请选择省份</option>
  <option [value]="province" *ngFor="let province of provinces">{{province}}</option>
  </select>
 </label>
 <!-- 省略其他部分 -->
 </div>

这样的话,我们再来看一下输出,现在就完全一致了:

 

表单和领域对象的结构也完全一致了

数据验证

模版驱动型的表单的验证也是主要由模版来处理的,在看怎么使用之前,需要界定一下验证规则:

  • 三个必填项: email , password 和 repeat
  • email 的形式需要符合电子邮件的标准
  • password 和 repeat 必须一致

当然除了这几个规则,我们还希望在表单未验证通过时提交按钮是不可用的。

<form novalidate #f="ngForm">
 <label>
 <span>电子邮件地址</span>
 <input
  type="text"
  name="email"
  placeholder="请输入您的 email 地址"
  [ngModel]="user.email"
  required
  pattern="([a-zA-Z0-9]+[_|_|.]?)*[a-zA-Z0-9]+@([a-zA-Z0-9]+[_|_|.]?)*[a-zA-Z0-9]+.[a-zA-Z]{2,4}">
 </label>
 <div>
 <label>
  <span>密码</span>
  <input
  type="password"
  name="password"
  placeholder="请输入您的密码"
  [(ngModel)]="user.password"
  required
  minlength="8">
 </label>
 <label>
  <span>确认密码</span>
  <input
  type="password"
  name="repeat"
  placeholder="请再次输入密码"
  [(ngModel)]="user.repeat"
  required
  minlength="8">
 </label>
 </div>
 <!-- 省略其他部分 -->
 <button type="submit" [disabled]="f.invalid">注册</button>
</form>
<div>

Angular 中有几种内建支持的验证器( Validators )

  • required - 需要 FormControl 有非空值
  • minlength - 需要 FormControl 有最小长度的值
  • maxlength - 需要 FormControl 有最大长度的值
  • pattern - 需要 FormControl 的值可以匹配正则表达式

如果我们想看到结果的话,我们可以在模版中加上下面的代码,将错误以 JSON 形式输出即可。

<div>
 <span>email 验证:</span> {{f.controls.email?.errors | json}}
</div>

我们看到,如果不填电子邮件的话,错误的 JSON 是 {"required": true} ,这告诉我们目前有一个 required 的规则没有被满足。

 

验证结果

当我们输入一个字母 w 之后,就会发现错误变成了下面的样子。这是因为我们对于 email 应用了多个规则,当必填项满足后,系统会继续检查其他验证结果。

{ 
"pattern": 
 { 
  "requiredPattern": "^([a-zA-Z0-9]+[_|_|.]?)*[a-zA-Z0-9]+@([a-zA-Z0-9]+[_|_|.]?)*[a-zA-Z0-9]+.[a-zA-Z]{2,4}$", 
  "actualValue": "w" 
 } 
}

通过几次实验,我们应该可以得出结论,当验证未通过时,验证器返回的是一个对象, key 为验证的规则(比如 required, minlength 等),value 为验证结果。如果验证通过,返回的是一个 null 。

知道这一点后,我们其实就可以做出验证出错的提示了,为了方便引用,我们还是导出 ngModel 到一个 email 引用,然后就可以访问这个 FormControl 的各个属性了:验证的状态( valid/invalid )、控件的状态(是否获得过焦点 -- touched/untouched,是否更改过内容 -- pristine/dirty 等)

<label>
 <span>电子邮件地址</span>
 <input
 ...
 [ngModel]="user.email"
 #email="ngModel">
</label>
<div *ngIf="email.errors?.required && email.touched" class="error">
 email 是必填项
</div>
<div *ngIf="email.errors?.pattern && email.touched" class="error">
 email 格式不正确
</div>

自定义验证

内建的验证器对于两个密码比较的这种验证是不够的,那么这就需要我们自己定义一个验证器。对于响应式表单来说,会比较简单一些,但对于模版驱动的表单,这需要我们实现一个指令来使这个验证器更通用和更一致。因为我们希望实现的样子应该是和 required 、 minlength 等差不多的形式,比如下面这个样子 validateEqual="repeat"

<div>
 <label>
  <span>密码</span>
  <input
  type="password"
  name="password"
  placeholder="请输入您的密码"
  [(ngModel)]="user.password"
  required
  minlength="8"
  validateEqual="repeat">
 </label>
 <label>
  <span>确认密码</span>
  <input
  type="password"
  name="repeat"
  placeholder="请再次输入密码"
  [(ngModel)]="user.repeat"
  required
  minlength="8">
 </label>
 </div>

那么要实现这种形式的验证的话,我们需要建立一个指令,而且这个指令应该实现 Validator 接口。一个基础的框架如下:

import { Directive, forwardRef } from '@angular/core';
import { NG_VALIDATORS, Validator, AbstractControl } from '@angular/forms';
@Directive({
 selector: '[validateEqual][ngModel]',
 providers: [
 { 
  provide: NG_VALIDATORS, 
  useExisting: forwardRef(()=>RepeatValidatorDirective), 
  multi: true 
 }
 ]
})
export class RepeatValidatorDirective implements Validator{
 constructor() { }
 validate(c: AbstractControl): { [key: string]: any } {
 return null;
 }
}

我们还没有开始正式的写验证逻辑,但上面的框架已经出现了几个有意思的点:

1.Validator 接口要求必须实现的一个方法是 validate(c: AbstractControl): ValidationErrors | null; 。这个也就是我们前面提到的验证正确返回 null 否则返回一个对象,虽然没有严格的约束,但其 key 一般用于表示这个验证器的名字或者验证的规则名字,value 一般是失败的原因或验证结果。

2.和组件类似,指令也有 selector 这个元数据,用于选择那个元素应用该指令,那么我们这里除了要求 DOM 元素应用 validateEqual 之外,还需要它是一个 ngModel 元素,这样它才是一个 FormControl,我们在 validate 的时候才是合法的。

3.那么那个 providers 里面那些面目可憎的家伙又是干什么的呢? Angular 对于在一个 FormControl 上执行验证器有一个内部机制: Angular 维护一个令牌为 NG_VALIDATORS 的 multi provider (简单来说,Angular 为一个单一令牌注入多个值的这种形式叫 multi provider )。所有的内建验证器都是加到这个 NG_VALIDATORS 的令牌上的,因此在做验证时,Angular 是注入了 NG_VALIDATORS 的依赖,也就是所有的验证器,然后一个个的按顺序执行。因此我们这里也把自己加到这个 NG_VALIDATORS 中去。

4.但如果我们直接写成 useExisting: RepeatValidatorDirective 会出现一个问题, RepeatValidatorDirective 还没有生成,你怎么能在元数据中使用呢?这就需要使用 forwardRef 来解决这个问题,它接受一个返回一个类的函数作为参数,但这个函数不会立即被调用,而是在该类声明后被调用,也就避免了 undefined 的状况。

下面我们就来实现这个验证逻辑,由于密码和确认密码有主从关系,并非完全的平行关系。也就是说,密码是一个基准对比对象,当密码改变时,我们不应该提示密码和确认密码不符,而是应该将错误放在确认密码中。所以我们给出另一个属性 reverse 。

export class RepeatValidatorDirective implements Validator{
 constructor(
 @Attribute('validateEqual') public validateEqual: string,
 @Attribute('reverse') public reverse: string) { }
 private get isReverse() {
 if (!this.reverse) return false;
 return this.reverse === 'true' ? true: false;
 }
 validate(c: AbstractControl): { [key: string]: any } {
 // 控件自身值
 let self = c.value;
 // 要对比的值,也就是在 validateEqual=“ctrlname” 的那个控件的值
 let target = c.root.get(this.validateEqual);
 // 不反向查询且值不相等
 if (target && self !== target.value && !this.isReverse) {
  return {
  validateEqual: true
  }
 }
 // 反向查询且值相等
 if (target && self === target.value && this.isReverse) {
  delete target.errors['validateEqual'];
  if (!Object.keys(target.errors).length) target.setErrors(null);
 }
 // 反向查询且值不相等
 if (target && self !== target.value && this.isReverse) {
  target.setErrors({
   validateEqual: true
  })
 }
 return null;
 }
}

这样改造后,我们的模版文件中对于密码和确认密码的验证器如下:

<input
 type="password"
 name="password"
 placeholder="请输入您的密码"
 [(ngModel)]="user.password"
 #password="ngModel"
 required
 minlength="8"
 validateEqual="repeat"
 reverse="true">
<!-- 省略其他部分 -->
<input
 type="password"
 name="repeat"
 placeholder="请再次输入密码"
 [(ngModel)]="user.repeat"
 #repeat="ngModel"
 required
 minlength="8"
 validateEqual="password"
 reverse="false">

完成后的验证错误提示

 

表单的提交

表单的提交比较简单,绑定表单的 ngSubmit 事件即可

<form novalidate #f="ngForm" (ngSubmit)="onSubmit(f, $event)">

但需要注意的一点是,button如果不指定类型的话,会被当做 type="submit" ,所以当按钮不是进行提交表单的话,需要显式指定 type="button" 。而且如果遇到点击提交按钮页面刷新的情况的话,意味着默认的表单提交事件引起了浏览器的刷新,这种时候需要阻止事件冒泡。

onSubmit({value, valid}, event: Event){ 
 if(valid){
 console.log(value);
 }
 event.preventDefault();
}

对于模板驱动的表单,我们就先总结到这里,下一篇文章我们会一起讨论响应式表单。

本文代码: https://github.com/wpcfan/ng-features.git

以上所述是小编给大家介绍的Angular 2+ 的表单(一)之模板驱动型表单,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对来客网网站的支持!