Angular表单

背景

Angular相关内容一个文件写不下,分开在多个文档里可能更好一些.

简介

表单作为web里的一个基础元素,要点并不多

  • 表单在html页面上的写法
  • 表单值在后台的获取
  • 表单的验证
  • 验证结果的表达

而Angular作为一个MVVM框架,将表单中的每个字段都与一个 FormControl 对象绑定在一起.
这些对象里包含了字段的值,字段的验证状态等等元素.
通常表单里有多个字段,为了方便统一管理,又有了 FormGroupFormArray 两个对象.
这两个对象里可以额外存储一些跨字段验证的状态和信息.
其中 FormArray 还可以动态地加入字段.

上面这种方法称为 响应式表单,
有时Angular想方便一些简单场景下的使用,于是以响应式表单为底层,
使用指令实现了又一种表单, 模板驱动表单.

Angular中提到表单,可能会关注到

  • FormControl对象的定义,方法,绑定到模板
  • 使用formBuilder快捷创建
  • 自定义验证函数
  • 验证结果的表达

响应式表单

使用响应式表单,需要在模块文件中导入 ReactiveFormsModule,
不然之后的许多地方会提示未定义.

原生创建

简单的场景:就一个字段
模板中

1
2
3
4
<label>
Name:
<input type="text" [formControl]="name">
</label>

组件中

1
2
3
4
5
6
7
export class XXXComponent {
name = new FormControl('');
}

updateName() {
this.name.setValue('Tom');
}

稍微复杂一些的场景: 使用了FormGroup统一管理
组件中

1
2
3
4
profileForm = new FormGroup({
firstName: new FormControl(''),
lastName: new FormControl(''),
});

模板中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- 这里绑定组件中的FormGroup -->
<form [formGroup]="profileForm">

<label>
First Name:
<!-- 由于使用group进行了统一管理,不再使用FormControl来绑定 -->
<input type="text" formControlName="firstName">
</label>

<label>
Last Name:
<input type="text" formControlName="lastName">
</label>

</form>

使用FormBuilder创建

表单会有更复杂的情况,比如嵌套,此时如果再用原生创建方法,就变得非常麻烦.
官方提供了FormBuilder来创建表单

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
export class ProfileEditorComponent {
profileForm = this.fb.group({
// 参数: 1.初始值 2.验证规则
firstName: ['', Validators.required],
lastName: [''],
address: this.fb.group({
street: [''],
city: [''],
state: [''],
zip: ['']
}),
aliases: this.fb.array([
this.fb.control('')
])
});

// 为了方便前端循环访问表单组中的某一个部分
get aliases() {
return this.profileForm.get('aliases') as FormArray;
}

// 属性,getter,setter方法之后才开始写constructor
// 注入了formbuilder
constructor(private fb: FormBuilder) { }

updateProfile() {
// 一种更新表单组部分值的方法
this.profileForm.patchValue({
firstName: 'Nancy',
address: {
street: '123 Drew Street'
}
});
}

addAlias() {
// formArray动态添加formControl成员
this.aliases.push(this.fb.control(''));
}

onSubmit() {
// 结果类似一个json
console.log(this.profileForm.value);
}
}

模板中

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
<!-- 绑定了表单提交时调用的函数 -->
<form [formGroup]="profileForm" (ngSubmit)="onSubmit()">
<label>
First Name:
<input type="text" formControlName="firstName" required>
</label>

<label>
Last Name:
<input type="text" formControlName="lastName">
</label>

<div formGroupName="address"> <!-- 引用formGroup时用formGroupName -->
<h3>Address</h3>

<label>
Street:
<input type="text" formControlName="street">
</label>

<label>
City:
<input type="text" formControlName="city">
</label>

<label>
State:
<input type="text" formControlName="state">
</label>

<label>
Zip Code:
<input type="text" formControlName="zip">
</label>
</div>

<div formArrayName="aliases"> <!-- 引用FormArray时使用formArrayName -->
<h3>Aliases</h3> <button (click)="addAlias()">Add Alias</button>

<!-- FormArray中元素并没有key名,只能先循环取出 -->
<div *ngFor="let alias of aliases.controls; let i=index">
<label>
Alias:
<!-- 然后以绑定的方式起一个key名 -->
<input type="text" [formControlName]="i">
</label>
</div>
</div>

<!-- 通常按钮都会贴心地加上不可用的判断条件 -->
<button type="submit" [disabled]="!profileForm.valid">Submit</button>
</form>

表单的验证

  1. 简单的验证

    可以在formBuilder中定义要使用的验证条件

    1
    2
    3
    4
    5
    6
    7
    this.form = this.fb.group({
    username: ['', Validators.required], // 一个规则可以直接写
    password: ['', [ // 多个规则可以放进数组传递
    Validators.required,
    Validators.minLength(6),
    ]]
    })

    另外html5的原生验证,其结果也能被formControl收到.因此也可以使用.
    个人也更偏好使用原生验证,此时的模板能够更完整地表达意图.

    1
    <input type="text" formControlName="firstName" required minLength="4">
  2. 自定义验证

    响应式表单的自定义验证靠函数.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    this.form = this.fb.group({
    password: ['', this.forbiddenPassValidator(/123456/i)]
    })

    // 不知道为什么自定义验证需要返回的是一个函数
    // 可能是不想让第三方的参数比如 nameRe 污染了后面 (AbstractControl => ValidationErrors|null) 这个函数的参数列表
    // 故意要求柯里化
    forbiddenPassValidator(nameRe: RegExp): ValidatorFn {
    // 输入抽象的表单对象,可以是FormGroup, FormArray, FormControl
    // 输出错误或null
    // 错误的格式是 {errorName: {value: xxx}} 引用时使用 xxx.errorName.value
    return (control: AbstractControl): ValidationErrors | null => {
    const forbidden = nameRe.test(control.value);
    return forbidden ? { forbiddenPass: { value: control.value } } : null;
    };
    }
  3. 跨字段验证

    在formBuilder定义时可以使用跨字段验证

    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
    this.form = this.fb.group({
    username: [''],
    password: ['']
    }, { validators: this.cross1Validator });

    // 如果不用到第三方参数,就可以直接定义一个类型是ValidatorFn的对象
    cross1Validator: ValidatorFn = (fg: AbstractControl): ValidationErrors | null => {
    const name = fg.get('username');
    const pass = fg.get('password');

    // 这里考虑了需要name和pass都不为空null情况下才算验证成功
    return name && pass && name.value === pass.value ?
    { cross1: true } : null;
    };

    cross2Validator(): ValidatorFn {
    return (fg: AbstractControl): ValidationErrors | null => {
    // abstractControl太过抽象了,尽管不as也不报错,个人觉得还是as一下好
    const fg1 = fg as FormGroup;
    const name = fg1.get('username');
    const pass = fg1.get('password');

    return name && pass && name.value === pass.value ? { cross2: true } : null;
    }
    };
  4. 验证结果的使用

    通常可以在html文件中使用ngIf等方式预制一些提示语句

    1
    2
    3
    4
    <div *ngIf="fc.username.invalid && (fc.username.dirty || fc.username.touched)">
    <div *ngIf="fc.username.errors?.required">Name is required</div>
    <div *ngIf="fc.username.errors?.minlength">minlength is 2</div>
    </div>

    其中fc定义是

    1
    get fc() { return this.form.controls; }

    然后就可以使用 fc.username 来获取一个字段了.
    否则就需要使用 form.get("username")

    一个字段有许多状态

    • invalid:boolean 是否合法
    • dirty:boolean 被修改
    • touched:boolean 被选中
    • errors:ValidationErrors 一个对象,比如 {minLength: {value: 6}}

    formGroup对象等等也有许多状态

    • invalid
    • dirty
    • touched
    • errors 这里会存放一些跨字段验证的结果

一个比较完整的例子

用于登陆的组件

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
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { AbstractControl, FormBuilder, FormControl, FormGroup, ValidationErrors, ValidatorFn, Validators } from '@angular/forms';
import { AuthService } from '../auth.service';
import { first } from 'rxjs/operators';

@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.css']
})
export class LoginComponent implements OnInit {
form: FormGroup;
loading = false;

constructor(
private fb: FormBuilder,
private router: Router,
private authService: AuthService,
) {
this.form = this.fb.group({
username: [''],
password: ['', [
this.forbiddenPassValidator(/123456/i)
]]
}, { validators: this.cross1Validator });
}

ngOnInit(): void { }

get fc() { return this.form.controls; }

onSubmit() {
if (this.form.invalid) {
return;
}

this.loading = true;
this.authService.login(this.fc.username.value, this.fc.password.value)
.pipe(first())
.subscribe({
next: () => {
this.router.navigate(['/']);
},
error: (error: any) => {
this.loading = false;
}
})
}

forbiddenPassValidator(nameRe: RegExp): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const forbidden = nameRe.test(control.value);
return forbidden ? { forbiddenPass: { value: control.value } } : null;
};
}

cross1Validator: ValidatorFn = (fg: AbstractControl): ValidationErrors | null => {
const name = fg.get('username');
const pass = fg.get('password');

return name && pass && name.value === pass.value ?
{ cross1: true } : null;
};

cross2Validator(): ValidatorFn {
return (fg: AbstractControl): ValidationErrors | null => {
const fg1 = fg as FormGroup;
const name = fg1.get('username');
const pass = fg1.get('password');

return name && pass && name.value === pass.value ? { cross2: true } : null;
}
};

}

对应的模板

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
<h1>login</h1>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div>
<label for="username">Username</label>
<input type="text" formControlName="username" required minlength="2" />
<div *ngIf="fc.username.invalid && (fc.username.dirty || fc.username.touched)">
<div *ngIf="fc.username.errors?.required">Name is required</div>
<div *ngIf="fc.username.errors?.minlength">minlength:2</div>
</div>
</div>
<div>
<label for="password">Password</label>
<input type="password" formControlName="password" required minlength="6" />
<div *ngIf="fc.password.invalid && (fc.password.dirty || fc.password.touched)">
<div *ngIf="fc.password.errors?.required">password is required</div>
<div *ngIf="fc.password.errors?.minlength">minlength:6</div>
<div *ngIf="fc.password.errors?.forbiddenPass">forbidden: {{fc.password.errors?.forbiddenPass.value}}</div>
</div>
</div>
<div *ngIf="form.invalid && (form.dirty || form.touched)">
<div *ngIf="form.errors?.cross1"> cross check 1 error</div>
</div>
<div>
<button [disabled]="loading">Login</button>
</div>
</form>

模板驱动表单

使用模板驱动表单,需要在模块中引入 FormsModule 模块

简单定义

模板驱动表单的建立需要靠 ngModel 这一指令

1
Favorite Color: <input type="text" [(ngModel)]="favoriteColor">

好处是ts文件中仅仅会看到一个简单的值

1
2
3
export class FavoriteColorComponent {
favoriteColor = '';
}

使用类的字段绑定

稍微复杂一些,表单的字段不仅仅可以绑定一个独立的值,
还可以绑定一个对象的某个属性,从而实现一个表单能够对应一个Model

1
2
3
4
5
export class HeroFormTemplateComponent {
powers = ['Really Smart', 'Super Flexible', 'Weather Changer'];
// 该表单有三个字段, 1.名字 2.别名? 3.一个下拉框
hero = {name: 'Dr.', alterEgo: 'Dr. What', power: this.powers[0]};
}

模板中这样绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<h1>模板驱动表单</h1>
<form>
<label for="name">Name</label>
<!-- 这里直接点就可以 -->
<input name="name" class="form-control" [(ngModel)]="hero.name">

<label for="alterEgo">Alter Ego</label>
<input class="form-control" name="alterEgo" [(ngModel)]="hero.alterEgo">

<label for="power">Hero Power</label>
<select name="power" class="form-control" [(ngModel)]="hero.power">
<option *ngFor="let p of powers" [value]="p">{{p}}</option>
</select>

<button type="submit" class="btn btn-default" [disabled]="heroForm.invalid">Submit</button>
</form>

表单的验证

  1. 简单的验证

    直接使用html5的原生验证方式

  2. 自定义验证

    模板驱动表单的验证也是通过指令.
    首先定义指令

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    @Directive({
    selector: '[appForbiddenName]', // 声明了在模板中的使用方法
    providers: [{provide: NG_VALIDATORS, useExisting: ForbiddenValidatorDirective, multi: true}] // 需要注册一下
    })
    export class ForbiddenValidatorDirective implements Validator {
    // 这里作为该指令接收参数的位置
    // 参数之于函数,类似于input的属性之于这个类
    @Input('appForbiddenName') forbiddenName: string;

    // validator接口需要实现validate方法
    validate(control: AbstractControl): ValidationErrors | null {
    return this.forbiddenName ?
    forbiddenNameValidator(new RegExp(this.forbiddenName, 'i'))(control)
    : null;
    }
    }
    // 这里的验证函数就能和响应式表单复用了
    export function forbiddenNameValidator(nameRe: RegExp): ValidatorFn {
    return (control: AbstractControl): {[key: string]: any} | null => {
    const forbidden = nameRe.test(control.value);
    return forbidden ? {forbiddenName: {value: control.value}} : null;
    };
    }

    然后就可以在模板中使用了

    1
    2
    3
    <input name="name" class="form-control"
    required minlength="4" appForbiddenName="bob"
    [(ngModel)]="hero.name" #name="ngModel" >
  3. 跨字段验证

    同样的,跨字段验证也需要使用指令来实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Directive({
    selector: '[appIdentityRevealed]',
    providers: [{ provide: NG_VALIDATORS, useExisting: IdentityRevealedValidatorDirective, multi: true }]
    })
    export class IdentityRevealedValidatorDirective implements Validator {
    validate(control: AbstractControl): ValidationErrors|null {
    // 这里的函数也能复用
    return identityRevealedValidator(control);
    }
    }

    然后在整个表单对象的定义域使用

    1
    <form #heroForm="ngForm" appIdentityRevealed>
  4. 验证结果

    模板驱动表单不再能使用controls等方式来获取一个表单组里的formControl对象,
    于是只能通过另一种使用 # 的方式来引用页面上的一个变量,
    从而获得invalid等状态来展示验证结果.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <form #heroForm="ngForm" appIdentityRevealed>

    <label for="name">Name</label>
    <!-- 下面的 #name="ngModel"将表单的控件绑定到了名为name的变量上 -->
    <input name="name" required minlength="4" appForbiddenName="bob" [(ngModel)]="hero.name" #name="ngModel">
    <!-- 所以这里才可以使用nave.invalid等变量 -->
    <div *ngIf="name.invalid && (name.dirty || name.touched)" class="alert alert-danger">
    <div *ngIf="name.errors.required">Name is required.</div>
    <div *ngIf="name.errors.minlength">Name must be at least 4 characters long.</div>
    <div *ngIf="name.errors.forbiddenName">Name cannot be Bob.</div>
    </div>

    <!-- 其他部分略 -->

    <!-- 这里的heroForm也需要在上面用#声明好 -->
    <div *ngIf="heroForm.errors?.identityRevealed && (heroForm.touched || heroForm.dirty)">
    Name cannot match alter ego.
    </div>
    </form>

表示状态的CSS

Angular预先定义了许多css类名,会自动应用于对应状态的表单上,
不过css的内容还需要人自己来定义,比如

1
2
3
4
5
6
7
.ng-valid[required], .ng-valid.required  {
border-left: 5px solid #42A948; /* green */
}

.ng-invalid:not(form) {
border-left: 5px solid #a94442; /* red */
}

响应式表单和模板驱动表单的区别

原理上的区别

访问方式不同,
响应式表单: 页面元素 直接与 FormControl 对象访问
模板驱动表单: 页面元素 经由 ngModel 指令,间接访问 FormControl 的值

因此数据的流动是不同的
响应式表单:

  • 页面变化带动模型
    1. input的值变化
    2. 组件中由于有FormControl,直接可以看到值的变化
  • 模型变化带动页面
    1. 使用setValue等方法改变了FormControl的值
    2. input的值变化

模板驱动表单:

  • 页面变化带动模型
    1. input值变化
    2. FormControl实例变化
    3. 在下一个周期,ngModel发现了这一变化
    4. 组件里的值更新了
  • 模型变化带动页面
    1. 组件里的值更新了
    2. 在下一个周期,ngModel发现了这一变化
    3. FormControl实例变化
    4. input值变化

最直观的区别

模板驱动表单的自定义验证,直接写在模板中,可以看见.
而响应式表单的自定义验证是写在ts文件中,模板里看不见.

参考

  1. 主要参考