Angular2表单-模板驱动的表单(Template-Driven Forms)

在网页开发中,表单估计是最常用的一个,同时也是最麻烦、最容易出问题的。在一个稍微复杂一点的应用中,我们除了用表单元素收集数据,还需要验证,几个数据之间可能还会相互关联,然后根据不同的数据值调用不同的业务逻辑等。
使用Angular提供的数据绑定的功能,我们可以很容易就在组件中获得用户输入的数据,Angular也提供了几种验证方式方便我们进行数据的校验。但是,一些自定义的数据验证、数据交互和业务逻辑还是需要自己处理。
在Angular2中,提供了2种表单实现方式,分别是’template-driven’(模板驱动的表单)和’model-driven’(模型驱动表单)。在这篇文章中,我们先来看看模板驱动的表单。顾名思义,模板驱动的表单就是大部分表单相关代码都在模板里,通过在模板里面添加ngForm, ngModel和ngModelGroup等属性来定义模板和验证信息,以及它跟组件之间的数据交互。

实例

下图是这篇文章使用的实例的界面:
screen.png
它是一个用户信息输入的表单,包括4个字段,用户名、电话、城市和街道,演示了如何使用表单,给各个字段添加验证并显示验证结果,以及如何在组件中判断是否出错并获取出错信息。

项目源码可以从github获取,这个项目包含了几个Angular2表单相关的实例,可以使用下面的命令获取本文所对应的代码:

1
git clone https://github.com/Mavlarn/angular2-forms-tutorial

然后进入项目目录,运行下面的命令安装依赖然后运行测试服务器:

1
2
3
4
cd angular2-forms-tutorial
git checkout template-driven # 检出该文所使用的tag
npm install
npm start

该项目是基于之前的Angular2-basic模板,这个教程相关的代码都在’template-forms’目录里面。

引入FormsModule

首先,我们需要在app.module.ts里引入FormsModule。

1
2
3
4
5
6
import { FormsModule } from '@angular/forms';
//省略其他
@NgModule({
imports: [ BrowserModule, FormsModule ],
//省略其他
})

初始表单

然后,我们从一个基本的html表单开始:

1
2
3
4
5
6
7
8
9
10
11
<form>
<label>姓名:</label>
<input type="text">
<label>电话:</label>
<input type="text">
<label>城市:</label>
<input type="text">
<label>街道:</label>
<input type="text">
<button type="submit">保存</button>
</form>

在实际的实例中,使用了bootstrap的表单样式,一组输入框应该是下面这个样子,但是在本文中,为了节省页面显示的篇幅,我省略了div, form-group等,我们只需要关心如何在Angular2中使用模板驱动的表单。如果想查看完整的带样式的代码,请查看源文件

1
2
3
4
5
6
<div class="form-group">
<label class="col-sm-2 control-label">姓名:</label>
<div class="col-sm-10">
<input class="form-control" type="text">
</div>
</div>

ngForm

在上面的表单里,我们没有使用Angular2的任何功能,如数据绑定,也没有使用其他指令。但是,Angular2在<form>上实现了一个指令’ngForm’,这样,对于所有的html的form表单,都会使用ngForm组件去初始化该表单。

使用ngForm对象

接下来,我们需要在模板里面访问这个ngForm的实例,这样我们就能够从这个实例里面获取数据,或者获取数据验证状态。
在Angular2里,都提供了一个模板引用变量的功能,通过#加变量实现。通过这个功能,我们可以在同一元素、兄弟元素或任何子元素中引用模板引用变量。这样听着还是不好理解,我们看一个例子:

1
2
<input #phone placeholder="phone number">
<button (click)="callPhone(phone.value)">Call</button>

在这个例子中,我们通过#phone定义了一个变量,它所指的就是这个input元素,phone.value也就是这个输入框输入的值。

除了使用#,也可以使用ref-,例如ref-phone形式的定义跟#phone是一样的。

我们可以对任何的DOM元素使用这种方式获取当前引用,也可以对任何的Angular2的指令使用。在这个表单的例子中,我们这样来获取这个ngFrom的引用:

1
<form #userForm="ngForm">

其中’ngForm’就是当前这个指令,这样在这个模板里面,我们可以用userForm获得表单的所有数据。

提交表单

在html中,我们要提交一个form,会在form里写一个action的属性,然后,用一个类型为’submit’的按钮来提交。但是,在Angular2中,我们需要使用ngSubmit事件:

1
2
3
<form #userForm="ngForm" (ngSubmit)="logForm(userForm)">
<button type="submit">保存</button>
</form>

这样,当用户点击保存按钮的时候,Angular2会使用自己的验证机制,验证所有的数据,然后在调用’logForm(userForm)’方法。
在我们的组件中,实现这个方法:

1
2
3
4
5
6
logForm(theForm: NgForm) {
console.log(theForm.value);
if (theForm.invalid) {
// handle error.
}
}

在这个方法里,我们使用theForm.invalid就可以获得这个表单是否验证成功的状态,也可以用’theForm.value’获得所有的表单数据。在这里,我们把表单数据打印到控制台来检查数据。至于如何从这个表单引用中获取控件数据和状态,会在接下来再讲。

使用ngModel绑定数据

接下来,我们需要绑定数据。假设我们的业务是打开这个页面的时候获取用户数据,然后显示到页面表单上。我们在组件的构造方法中创建一个模拟的用户数据:

1
2
3
4
5
6
7
8
9
10
11
export class TemplateFormsComponent {
user: any;
constructor() {
this.user = {
name: '张三',
mobile: 13800138001,
city: '北京',
street: '朝阳望京...'
};
}
}

然后在模板中将这个组件中的数据绑定到模板页面上:

1
2
3
4
5
<input type="text" name="name" [(ngModel)]="user.name">
<input type="text" name="mobile" [ngModel]="user.mobile">
<input type="text" name="city" [ngModel]="user.city">
<input type="text" name="street" [ngModel]="user.street">
<!-- 其他的输入框都类似 -->

在这里,我们使用[(ngModel)]="user.name",这是双向绑定的方式,这样,当我们修改页面上的数据的时候,在组件中也能获得更新后的数据;同时,如果在组件中更新了数据,在页面上也能更新。
为了演示这个双向绑定跟单向绑定的区别,我们只对姓名使用双向绑定,对其他的都是用单向绑定,也就是[ngModel]="user.mobile"。使用[]的单向绑定是从模板到组件的绑定,也就是页面中的输入的数据改变,组件中的数据也会改变。但是组件中的数据更新不会引起页面上该数据的更新。
使用单向绑定可以减少数据的更新检查,从来可以提高性能。

如果不需要数据的初始化,我们其实可以只用ngModel,例如:

1
<input type="text" name="city" ngModel>

这样,我们在组件中创建的用户数据就无法显示到页面上,但是,他还是能够将页面上输入的数据绑定到组件中的数据上。

在Angular2中,使用ngModel结合name属性来创建一个表单控件FormControls。例如上面的<input name="city" ngModel>就对应一个userForm里面的控件city。由于我们在提交方法里面将这个userForm作为参数传到方法里,我们可以在方法里面获得所有的表单控件theForm.controls,它是一个Map类型的对象,key是所有的表单元素的name,值就是一个FormControl对象,里面保存着数据、和验证结果、是否修改等状态。也正是因为这些FormControls,我们才能够使用theForm.value的方式获取表单里的数据。当我们点击保存按钮的时候,就能在日志里面看到表单的数据:

1
2
3
4
5
6
{
name: "张三",
mobile: 13800138001,
city: "北京",
street: "朝阳望京..."
}

使用ngModelGroup分组显示

一般情况下,我们的model数据有可能是嵌套的,比如对于用户信息来说,城市和街道可能在一个地址对象address里,例如:

1
2
3
4
5
6
7
8
{
name: "张三",
mobile: 13800138001,
address: {
city: "北京",
street: "朝阳望京..."
}
}

对于这样的数据,我们就可以使用ngModelGroup来分组。模板就是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<form #userForm="ngForm" (ngSubmit)="logForm(userForm)">
<label>姓名:</label>
<input type="text" name="name" [(ngModel)]="user.name">
<label>电话:</label>
<input type="text" name="mobile" [ngModel]="user.mobile">
<fieldset ngModelGroup="address">
<label>城市:</label>
<input type="text" name="city" [ngModel]="user.address.city">
<label>街道:</label>
<input type="text" name="street" [ngModel]="user.address.street">
<button type="submit">保存</button>
</fieldset>
</form>

这样我们就把地址信息都封装到一个address对象里面。注意我们绑定的数据的结构也发生改变,这样,我们也需要修改我们的组件里面的用户数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
export class TemplateFormsComponent {
user: any;
constructor() {
this.user = {
name: '张三',
mobile: 13800138001,
address: {
city: '北京',
street: '朝阳望京...'
}
};
}
}

至此,我们的表单的基本功能就算完成了。我们在面板中创建了表单,在组件中初始化了用户数据,并显示到页面上,在页面上用ngModel,将页面上的数据更改绑定到组件上。同时,使用name属性,使得表单里面的所有数据都成为FormControl对象。在提交所调用的方法里,获得了表单的验证状态和数据。

表单控件的验证和状态

下一步,我们来添加数据验证,Angular2为我们提供了几种最基本的验证:

  • required:表明该数据是必须的。
  • minlength:设置该字段的长度的最小值,即使输入的是数字,也按照字符串来判断长度。
  • maxlength:设置该字段的长度的最大值。
  • pattern:使用正则表达式验证

在使用Angular的验证之前,我们首先需要关闭浏览器默认的验证,不然,如果某一个输入不合法,提交按钮就无法提交。我们在form里添加novalidate

1
<form #userForm="ngForm" (ngSubmit)="logForm(userForm)" novalidate>

然后,我们对姓名输入框添加验证,并根据验证的结果显示不同的提示,同时,为了演示Angular2表单控件的特性,再添加几个提示,来显示该值的状态,代码如下:

1
2
3
4
5
6
7
8
<input type="text" name="name" [(ngModel)]="user.name" #name="ngModel" required minlength="3">
<span *ngIf="name.pristine" class="label label-primary">未修改</span>
<span *ngIf="name.dirty" class="label label-warning">已修改</span>
<span *ngIf="name.valid" class="label label-success">有效</span>
<div [hidden]="name.valid || name.pristine" class="alert alert-danger">
<p *ngIf="name.errors?.minlength">姓名最小长度为3</p>
<p *ngIf="name.errors?.required">必须输入姓名</p>
</div>

首先,我们在input上添加了2个验证,requiredminlength="3"
其次,我们使用#name="ngModel"创建了一个模板引用变量,这样我们在下面就可以使用name来获取这个表单控件(FormControl)的引用。表单控件有一些属性,如pristine, dirty, valid, touched,这几个都是状态类型,表示某一种状态是否为真。除此以外还有控件的值可以用name.value获取。最后,还有验证的错误信息结果,会放在name.errors里。
在上面的代码里,我们用<span *ngIf="name.pristine" class="label label-primary">未修改</span>,在控件值未被修改的时候,显示一个lebel。同样,在被修改、验证有效的时候显示相应的标签。
最后,所有的验证结果的错误信息会保存在name.errors里,如果没有数据验证错误,这个errors值就是null,所以,在上面的代码里,我们用name.errors?.minlength,这表示,如果errors不为null,而且errors.minlength也不为空的时候,才显示里面的信息。
我们可以看到,表单控件的验证会将验证器的名字作为key放在errors里面,对应的值是true。我们就是用这个特性,来根据控件验证的不同结果,来显示友好的错误信息。

如果运行我们的实例,可以发现,对于姓名,如果清空它的值,发现只有一个错误信息,就是必须输入姓名。你可能会觉得,这时候,值为空,那他的长度也小于3,那么minlength这个错误也应该被检测到才对,但是实际上,遇到第一个错误以后,就没有其他的验证。

在上面姓名输入框上,我们使用#name="ngModel"创建了一个模板引用变量,然后在接下来的模板里面使用它获得表单控件。实际上,我们也可以直接使用之前定义的对ngForm的引用,来获得这个表单里所有控件的状态。例如,对电话,我们使用下面的方式:

1
2
3
4
5
6
7
8
<input type="text" name="mobile" [ngModel]="user.mobile" required minlength="11" maxlength="11">
<span *ngIf="userForm.controls.mobile?.pristine" class="label label-primary">未修改</span>
<span *ngIf="userForm.controls.mobile?.dirty" class="label label-warning">已修改</span>
<span *ngIf="userForm.controls.mobile?.valid" class="label label-success">有效</span>
<div [hidden]="userForm.controls.mobile?.valid || userForm.controls.mobile?.pristine" class="alert alert-danger">
<p *ngIf="userForm.controls.mobile?.errors?.minlength">电话长度必须为11</p>
<p *ngIf="userForm.controls.mobile?.errors?.required">必须输入电话</p>
</div>

在这里,我们没有获取对mobile的模板引用,而是用ngForm的引用获得:

1
userForm.controls.mobile?.pristine

当获取验证错误结果时:

1
userForm.controls.mobile?.errors?.minlength

注意这里在mobile上就使用?是因为,在使用ngIf渲染页面上的元素的时候,这个表单控件还没有初始化完成,如果不加这个?,就会出现错误。

根据验证状态定义样式

Angular的表单验证,除了在控件上的数据以外,它还会根据状态在控件所在的html元素上添加css样式:
validation_css.png

所以,我们只需要定义相关的css,就可以实现根据状态显示不同的效果。

1
2
3
4
5
6
.ng-valid[required], .ng-valid.required {
border-left: 5px solid #42A948; /* green */
}
.ng-invalid:not(form).ng-invalid:not(fieldset) {
border-left: 5px solid #a94442; /* red */
}

结合各种css的选择器,我们就可以根据表单控件的状态实现各种显示的样式。

在组件中获取表单控件数据

最后,我们再看看怎样在组件中获取这些控件的状态和结果,在上面,我们给ngForm添加了一个提交方法:

1
<form #userForm="ngForm" (ngSubmit)="logForm(userForm)" novalidate>

然后在组件中,这个logForm(userForm)方法如下:

1
2
3
4
5
6
7
8
9
10
logForm(theForm: NgForm) {
if (theForm.invalid) {
if (theForm.controls['name'].errors) {
this.nameErrorMsg = 'name error:' + JSON.stringify(theForm.controls['name'].errors);
} else {
this.nameErrorMsg = null;
}
}
console.log(theForm.value);
}

在这个方法里,theForm就是ngForm的模板引用实例,类型是NgForm的。
如果表单验证有失败,theForm.invalid就是false。
theForm.controls就是这个表单里的所有控件,如果想获取姓名的验证结果,就是theForm.controls['name'].errors
用这种方式,我们就可以在组件中获取所有表单控件的数据、验证状态、错误信息等。

重置表单

一般情况下,如果是新建用户信息,我们需要在保存成功以后,清空当前数据,重置表单的状态,等待用户重新输入。如果我们只是清空数据,这时候那些验证错误就会被检测到,我们我们需要将表单控件也都重置成未修改状态。这在Angular2里很简单,它提供了一个reset方法。
我们在

里面添加一个重置按钮:
1
<button (click)="reset(userForm)">重置</button>

然后在组件里:

1
2
3
4
reset(theForm: NgForm) {
theForm.reset();
return false;
}

注意我们需要让这个方法返回false,这样他就不会触发submit的方法。

在官方的文档中,还提供了另一种技巧来实现这种重置,就是在form上使用ngIf

1
<form #userForm="ngForm" (ngSubmit)="logForm(userForm)" novalidate *ngIf="active">

只有在active为true时这个表单才会创建。
然后在重置的时候,设置这个active为false,这样这个表单就会被销毁,然后用setTimeout的方式再设置它为true,这个表单就会重新创建,这样就实现了重置的效果。

1
2
3
4
5
6
7
8
reset() {
this.user = { // 重置用户数据
address: {}
};
this.active = false;
setTimeout(() => this.active = true, 0);
return false;
}

这也是一种小窍门,可以在某些情况下使用。

总结

至此,有关模板驱动的表单的基本用法大致完成,再总结一下模板驱动的表单的基本特性:

  • 所有的表单控件的定义都在模板里
  • 所有的验证器都在模板里面添加
  • 表单数据的状态、验证结果都在模板上通过判断表单里面控件数据的状态来显示
  • 如果需要测试这部分的代码,需要使用e2e(端到端)测试,也就是在浏览器里面
坚持原创技术分享,您的支持将鼓励我继续创作!