文章目录
  1. 1. 序章
    1. 1.1. 知识点概览
    2. 1.2. 引入forms模块
  2. 2. 模板式表单(模板驱动表单)
    1. 2.1. 快速上手
    2. 2.2. ngModel与双向绑定
  3. 3. 表单校验
    1. 3.1. 快速上手
    2. 3.2. 状态标志位
    3. 3.3. 内置校验规则
    4. 3.4. 自定义校验规则
  4. 4. 响应式表单(模型驱动表单)
  5. 5. 动态表单
    1. 5.1. 抽象
    2. 5.2. 完善组件
    3. 5.3. 调用组件

这篇文章本来早就该写完的,但由于Angular的表单模块实在太过复杂,导致断断续续摸索了近半个月,这其中又牵扯了诸多其它知识点,因此现在才勉强整理完。Angular的表单非常强大,这里会挑重点的来。

序章

知识点概览

WEB应用中,表单或许是最重要的部分,大多数的“富数据”都是通过表单从用户那里获得的。

从表面上看,创建一个input标签,填入数据,点击提交,这有什么难的?但事实上,表单处理可能是非常复杂的,常常会占据日常开发的大块编码时间。原因如下:

  • 某项表单输入后是否需要在页面和服务端同时修改这项数据?
  • 通过表单修改的内容往往需要在页面其它地方反映出来。
  • 用户输入的内容可能会存在诸多问题,所以需要验证这些内容。
  • 用户界面需要清晰地显示出表单的预期结果和错误信息。
  • 表单中每个字段的依赖是否存在复杂的业务逻辑?
  • 一般我们不希望通过DOM选择器来测试表单。

为此,Angular为forms模块设计了诸多API和概念,以下是主要知识点:

  • FormControl:封装了表单中的输入框,并提供了一些可供操作的对象。
  • FormGroup:一组FormControl,提供了一些总包接口。
  • Validator:验证器,用来验证表单的输入内容。可自定义。
  • FormBuilder:一个表单构建助手,用于快速创建一个响应式表单。

引入forms模块

想要用到Angular中表单特性,首先需要在app.module.ts引入此模块:

FormsModule为我们提供了一些模板驱动的指令,例如:

  • ngModel
  • ngForm

ReactiveFormsModule提供的指令则主要用于响应式表单,例如:

  • formControl
  • ngFormGroup

模板式表单(模板驱动表单)

模板式表单的写法是,把表单相关的逻辑,包括校验逻辑全部写在模板里面,组件内部几乎没写什么代码。

快速上手

创建一个组件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { Component, OnInit } from '@angular/core';

@Component({
selector: 'form-quick-start',
template: `
<input type="email" (keyup)="userNameChange($event)">
<input #pwd type="password" (keyup)="0">

<!-- 输出结果 -->
<div>
<p>用户名:{{userName}}</p>
<p>密码:{{pwd.value}}</p>
</div>
`
})
export class FormQuickStartComponent implements OnInit {
public userName:string;

constructor() { }

ngOnInit() {
}

public userNameChange(event):void{
this.userName=event.target.value;
}
}

输出结果里的两个值都会根据输入的内容而变化。过程是这样的:

  • 第一个 input:用事件绑定的方式,把input的值传递给组件内部定义的 userName 属性,然后页面上再用 获取数据。
  • 第二个 input:我们定义了一个模板局部变量 #pwd,然后底部直接用这个名字来获取 input 的值 。这里有一个小小的注意点,标签里面必须写 (keyup)=”0”,要不然 Angular 不会启动变更检测机制, 取不到值。

ngModel与双向绑定

快速上手中的例子实际上没有用到任何Angular的forms模块。下面将会改造上述代码,使用表单模块的ngModel来实现双向数据绑定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { Component, OnInit } from '@angular/core';

@Component({
selector: 'form-quick-start',
template: `
<input type="email" [(ngModel)]="regModel.email" name="email">
<input type="password" [(ngModel)]="regModel.password" name="password">
<input type="checkbox" name="rememberMe" [(ngModel)]="regModel.rememberMe">记住我

<!-- 输出结果 -->
<div>
<p>用户名:{{regModel.email}}</p>
<p>密码:{{regModel.password}}</p>
<p>记住我:{{regModel.rememberMe}}</p>
</div>
`
})
export class FormQuickStartComponent implements OnInit {
public regModel:RegisterModel=new RegisterModel();

constructor() { }

ngOnInit() {
}
}
// 表单模型
export class RegisterModel {
email: string;
password: string;
rememberMe:boolean=false;
}

需要注意的是,用双向绑定的时候,必须给 <input> 标签设置 name 或者 id,否则会报错。原因:

ngModel会给该输入框元素创建一个表单控制器(FormControl),并把这个FormControl对象绑定到DOM上。也就是说,它会在视图中的Input标签和FormControl之间建立关联,这种关联通过name属性或id属性建立。

表单上面展现的字段和处理业务用的数据模型不一定完全一致,推荐设计两个 Model,一个用来给表单进行绑定操作,一个用来处理业务。

表单校验

快速上手

继续更改上述模板式表单中的代码,把模板抽出去,组件内部保持原样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { Component, OnInit } from '@angular/core';

@Component({
selector: 'form-quick-start',
templateUrl: './form-quick-start.component.html',
})
export class FormQuickStartComponent implements OnInit {
public regModel:RegisterModel=new RegisterModel();

constructor() { }

ngOnInit() {
}
}
// 表单模型
export class RegisterModel {
email: string;
password: string;
rememberMe:boolean=false;
}

修改模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<form #registerForm="ngForm">
<div [ngClass]="{'has-error': userName.invalid && (userName.dirty || userName.touched) }">
<label>用户名:</label>
<div>
<input #userName="ngModel" [(ngModel)]="regModel.userName" name="userName" type="email" required minlength="12" maxlength="32">
<!-- 错误提示 -->
<div *ngIf="userName.invalid && (userName.dirty || userName.touched)">
<div *ngIf="userName.errors.required">
用户名不能为空
</div>
<div *ngIf="userName.errors.minlength">
最小长度不能小于12个字符
</div>
<div *ngIf="userName.errors.maxlength">
最大长度不能大于32个字符
</div>
</div>
</div>
</div>
</form>

效果如图:

userName输入框就是一个FormControl

Angular为它提供了一些状态标志位(如userName.dirty)和校验规则(如minlength="12"

状态标志位

状态标志位用来判断一个或一组Angular表单控件的状态:

状态名称 描述
valid 校验成功
invalid 校验失败
pending 表单正在提交过程中
pristine 数据依然处于原始状态,用户没有修改过
dirty 数据已经变脏了,被用户改过了
touched 被触摸或者点击过
untouched 未被触摸或者点击
enabled 启用状态
disabled 禁用状态
submitted 判断表单已经被提交

内置校验规则

Angular 内置了几种常用校验规则:

校验规则 描述
required 输入结果非空
requiredTrue 输入结果为true
minLength 设置最小长度
maxLength 设置最大长度
pattern 设置需匹配的正则表达式
nullValidator 禁用验证器
email 输入结果为电子邮件格式
详细的 API 描述在这里。

如果没有通过校验,那么该表单控件下的errors列表中就会获得相应的错误名称:

1
2
3
<div *ngIf="userName.errors.required">
用户名不能为空
</div>

自定义校验规则

内置的校验规则经常不够用,尤其在需要多条件联合校验的时候,所以我们需要自己定义校验规则。

在上一章节中,我们已经实现了一个简单的手机号验证规则:利用指令实现表单校验器,这一次,将会实现一个稍难的校验规则:确认密码

命令行或手工创建文件equal-validator.directive.ts,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import { Directive,Input } from '@angular/core';
import { Validator, AbstractControl, NG_VALIDATORS } from '@angular/forms';


@Directive({
selector: '[validateEqual]',
providers: [
{ provide: NG_VALIDATORS, useExisting: EqualValidator, multi: true }
]
})
export class EqualValidator implements Validator {
@Input()validateEqual: string;
@Input()reverse: boolean;
constructor() { }

validate(control: AbstractControl): { [key: string]: any } {
//当前控件的值
let selfValue = control.value;

// 需要比较的控件,根据属性名称获取
let targetControl = control.root.get(this.validateEqual);
// 值不相等
if (targetControl && selfValue !== targetControl.value ) {
// 如果输入框是"确认密码",直接返回错误信息
if(!this.reverse){
return {
validateEqual: false
}
// 如果输入框不是"确认密码",给目标值输入框添加错误信息
}else{
targetControl.setErrors({
validateEqual: false
})
}
}else{//值相等,清空错误信息
targetControl.setErrors(null);
}
return null;
}
}

用法(详细代码见文首):

1
2
3
<!-- 密码及验证密码 -->
<input formControlName="password" validateEqual="confirmPassword" reverse=true type="password">
<input formControlName="confirmPassword" validateEqual="password" type="password">

如果两个输入框的密码不一致,confirmPassword的errors列表就会获得一个名为validateEqual的错误。

效果演示:

响应式表单(模型驱动表单)

响应式表单的特点是:把表单的创建、校验等逻辑全部用代码写到组件里面,让 HTML 模板变得很简单。

观察以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
import { Component, OnInit, ViewEncapsulation } from '@angular/core';
import { FormGroup, FormBuilder, Validators } from "@angular/forms";


@Component({
selector: 'form-register',
templateUrl: './register.component.html',
styleUrls: ['./register.component.css'],
encapsulation: ViewEncapsulation.Emulated
})
export class RegisterComponent implements OnInit {

public userForm:FormGroup;
private userInfo:User = new User();

constructor(
private fb:FormBuilder
) { }

ngOnInit() {
this.buidForm();
}

// 构建表单及验证方法
private buidForm() {
this.userForm = this.fb.group({
userName: [
this.userInfo.userName,
[
Validators.required,
Validators.minLength(2),
Validators.maxLength(32)
]
],
email: [
this.userInfo.email,
[
Validators.required,
Validators.pattern('^([a-zA-Z0-9_-])+@([a-zA-Z0-9_-])+((\.[a-zA-Z0-9_-]{2,3}){1,2})$')
]
],
telephone: [
this.userInfo.telephone,
[
Validators.required
]
],
nickName: [
this.userInfo.nickName,
[
Validators.required,
Validators.minLength(4),
Validators.maxLength(32)
]
],
password: [
this.userInfo.password,
[
Validators.required,
Validators.minLength(8)
]
],
confirmPassword: [
this.userInfo.confirmPassword,
[
Validators.required,
Validators.minLength(8)
]
],
vcode: [
this.userInfo.vcode,
[
Validators.required,
Validators.minLength(6),
Validators.maxLength(6)
]
]
})
}
}
export class User {
userId:string;
userName: string;
nickName: string;
password: string;
remeberMe:boolean;
email: string;
telephone:string;
confirmPassword: string;
vcode:string;
}

上述代码在组件中利用FormBuilder构建了一个完整的表单,并在每个FormControl里设置了一些校验规则。

在模板中,把这些表单控件手动添加到各个表单元素上:

1
2
3
4
5
6
7
8
9
10
11
12
13
<form [formGroup]="userForm" novalidate>
<!-- 用户名 -->
<div class="form-group">
<label class="col-sm-3 control-label">用户名</label>
<div class="col-sm-8">
<input formControlName="userName" type="text">
</div>
</div>
<!-- 手机号 -->
<!-- ... -->
<!-- 昵称 -->
<!-- ... -->
<form>

不难看出,响应式表单的本质和模板式标签是一样的,只不过脱离了模板的束缚,可以在组件内部中自由DIY。

实际应用中的业务逻辑会比上述代码复杂得多,详细代码见文首链接。代码量太大这里就不帖了。

动态表单

有这样一种业务场景:你不知道表单里面的输入项都应该有哪些内容,需要根据服务端返回的数据动态进行创建。

这样的话html模板是没办法写死的,我们需要把常用的表单项抽象出来,在需要的时候调用。

抽象

首先命令行生成一个空的动态组件:

1
ng g c dynamic-form

我们现在先不用忙着给这个组件内部添加内容,而是先缕清思路:

  1. 思考常用组件都应具备哪些属性
  2. 创建一个基类
  3. 根据这个基类进行细分
  4. 输出各个类型的表单控件

dynamic-form文件夹中,新建forms文件夹,我们将会把各类表单控件抽象并存放到里面:

存放抽象出来的类

其中,base.ts用来存放基类。其它的类都要继承它的属性和基本规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export class Base<T>{
value: T;
key: string;
label: string;
controlType: string;
placeholder: string;

constructor(options: {
value?: T;
key?: string;
label?: string;
required?: boolean;
order?: number;
controlType?: string;
placeholder?: string;
} = {}) {
this.value = options.value;
this.key = options.key || '';
this.label = options.label || '';
this.controlType = options.controlType || '';
this.placeholder = options.placeholder || '';
}
}

其它的类根据需求可设置不同的属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
// image.ts
import { Base } from './base';

export class Image extends Base<string>{

controlType: string = 'image'
src: string

constructor(options: {} = {}) {
super(options);
this.src = options['src'] || '';
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// textbox.ts
import { Base } from './base';

export class TextBox extends Base<string>{

controlType: string = 'textbox';
type: string;

constructor(options: {} = {}) {
super(options);
this.type = options['type'] || '';
}
}

最后,以index.ts作为出口:

1
2
3
4
5
// index.ts
export { Base } from './base';
export { Image } from './image';
export { Textarea } from './textarea';
export { TextBox } from './textbox';

完善组件

完善dynamic-form-component.html模板如下:

1
2
3
4
5
6
7
8
9
10
11
12
<div [formGroup]="form">
<label class="col-sm-2 control-label">{{field.label}}</label>
<div class="col-sm-10">
<div [ngSwitch]="field.controlType">
<input *ngSwitchCase="'textbox'" class="form-control" value="{{field.value}}" [formControlName]="field.key" [type]="field.type"
placeholder="{{field.placeholder}}" />
<textarea *ngSwitchCase="'textarea'" value="{{field.value}}" rows="{{field.rows}}" class="form-control" [formControlName]="field.key"
placeholder="{{field.placeholder}}"></textarea>
<img *ngSwitchCase="'image'" src="{{field.src}}">
</div>
</div>
</div>

我们利用了ngSwitch指令,来区分需要提供哪种类型的表单控件。

组件内部代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { Component, OnInit, ViewEncapsulation, Input } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { Base } from '../dynamic-form/forms';

@Component({
selector: 'form-dynamic-form',
templateUrl: './dynamic-form.component.html',
styleUrls: ['./dynamic-form.component.css'],
encapsulation: ViewEncapsulation.None
})
export class DynamicFormComponent implements OnInit {

@Input()
form: FormGroup;
@Input()
field: Base<any>

constructor() { }

ngOnInit() {
}

}

formfield通过调用此组件的父组件实例化后传进来。

调用组件

想要调用上述组件,必须传入两个对象,form对象和field对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<div class="user-profile-container">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">修改个人资料</h3>
</div>
<div class="panel-body">
<form class="form-horizontal" role="form" [formGroup]="form">
<div *ngFor="let field of fields" class="form-group">
<!-- 动态创建每个输入框组件 -->
<form-dynamic-form [form]="form" [field]="field"></form-dynamic-form>
</div>
<div class="form-group">
<div class="col-md-offset-2 col-md-10">
<button class="btn btn-success">保存</button>
</div>
</div>
</form>
</div>
</div>
</div>

模板中的form绑定到了<form>标签,它是一个formGroup对象;field对象来自fields数组。它们都会在组件内部完成实例化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
import { Component, OnInit, ViewEncapsulation } from '@angular/core';
import { FormGroup, FormControl } from "@angular/forms";
import { Base, Image, Textarea, TextBox } from './dynamic-form/forms';

@Component({
selector: 'form-user-data',
templateUrl: './user-data.component.html',
styleUrls: ['./user-data.component.css'],
encapsulation: ViewEncapsulation.None
})
export class UserDataComponent implements OnInit {

public fields: Base<any>[] = [
new Image({
key: "logo",
src: 'https://angular.io/assets/images/logos/angular/logo-nav@2x.png'
}),
new TextBox({
key: "avatar",
label: "头像:",
placeholder: "上传头像",
type: "file"
}),
new TextBox({
key: "userName",
label: "用户名:",
placeholder: "用户名"
}),
new TextBox({
key: "email",
label: "常用邮箱:",
placeholder: "常用邮箱"
}),
new TextBox({
key: "password",
label: "密码:",
type: "password",
placeholder: "密码,至少8位"
}),
new TextBox({
key: "confirmPassword",
label: "重复密码:",
type: "password",
placeholder: "重复密码"
}),
new Textarea({
key: "description",
label: "个人简介:",
placeholder: "个人简介,最多140字,不能放链接。",
rows: 3,
})
]

public form:FormGroup;

constructor() { }

ngOnInit() {
this.form = this.toFormGroup(this.fields);
console.log(this.form);
}

toFormGroup(fields: Base<any>[]) {
let group: any = {};

fields.forEach(field => {
// 用每个field的key值作为group对象每一项的名字,
// 构建FormControl并存入group对象
group[field.key] = new FormControl(field.value || '');
});
console.log(group)
// 构建整个FormGroup
return new FormGroup(group);
}

}

代码中,我们首先在fields数组中初始化了各个所需的表单控件,然后遍历这个数组存入一个对象,利用这个对象构建了一个完整了formGroup对象。

一个表单就这样被动态构建出来了,它和其它类型的表单没有任何区别:
最终效果

文章目录
  1. 1. 序章
    1. 1.1. 知识点概览
    2. 1.2. 引入forms模块
  2. 2. 模板式表单(模板驱动表单)
    1. 2.1. 快速上手
    2. 2.2. ngModel与双向绑定
  3. 3. 表单校验
    1. 3.1. 快速上手
    2. 3.2. 状态标志位
    3. 3.3. 内置校验规则
    4. 3.4. 自定义校验规则
  4. 4. 响应式表单(模型驱动表单)
  5. 5. 动态表单
    1. 5.1. 抽象
    2. 5.2. 完善组件
    3. 5.3. 调用组件