WinddSnow

Angular-Forms-Template-Driven-and-Reactive-Forms-Validation-Custom-Validators

字数统计: 6.5k阅读时长: 29 min
2026/08/02

第120课:Angular 表单——模板驱动表单、响应式表单、验证、自定义验证器

表单是 Web 应用中最常见的用户交互界面——从登录注册到数据筛选,从设置页面到复杂的数据录入。Angular 提供了两种互补的表单构建方式:模板驱动表单(Template-driven Forms)和响应式表单(Reactive Forms)。两者共享相同的验证器体系和数据模型,但在哲学理念上有根本区别:模板驱动表单将大部分逻辑放在模板中,通过指令隐式创建和管理表单控件,适合简单、静态的表单;响应式表单将表单模型显式地定义在 TypeScript 代码中,通过 FormControlFormGroupFormArray 构建可编程、可测试的数据结构,适合复杂、动态的表单。理解两种方式的差异、各自的验证机制、以及如何编写自定义验证器和异步验证器,是构建健壮 Angular 应用的必要技能。本节课将逐一拆解这两种表单模式的核心 API、验证流程、动态表单构建以及跨字段验证的高级技巧。


1. 两种表单模式的核心区别

特性 模板驱动表单(Template-driven) 响应式表单(Reactive)
模型定义位置 模板中(通过 ngModelngForm 指令隐式创建)。 组件类中(显式创建 FormControlFormGroup)。
数据流方向 双向绑定[(ngModel)] 自动同步模板和组件)。 单向数据流(通过 setValue/patchValue 更新视图)。
验证配置位置 模板中(通过 requiredpattern 等 HTML 属性)。 组件类中(将验证器函数传给 FormControl 构造函数)。
同步性 异步ngModel 在变更检测周期中更新)。 同步(创建和修改表单控件时立即生效)。
可测试性 较弱(逻辑散布在模板中,难以单元测试)。 (表单逻辑完全在 TypeScript 中,可独立测试)。
动态表单支持 困难(依赖 *ngFor 和模板引用变量的复杂组合)。 原生支持(通过 FormArray 动态增删表单组)。
依赖模块 FormsModule ReactiveFormsModule
适用场景 简单、静态的表单(如登录、搜索)。 复杂、动态的表单(如数据表格内编辑、动态问卷)。

选择原则

  • 表单逻辑简单、字段固定、不需要动态增删 → 模板驱动表单。
  • 表单逻辑复杂、需要动态添加/移除字段、需要单元测试、或需要跨字段自定义验证 → 响应式表单。
  • 两者不可在同一表单中混用——一个表单必须使用其中一种模式。

2. 模板驱动表单:指令驱动的声明式表单

模板驱动表单完全在 HTML 模板中定义。你不需要在组件类中手动创建 FormControl——Angular 会根据 ngModel 和验证属性(requiredminlength 等)隐式地为每个表单控件创建对应的 FormControl 实例,并将其注册到父级 ngForm 指令中。

2.1 基础模板驱动表单

步骤一:在根模块或特性模块中导入 FormsModule

1
2
3
4
5
6
7
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';

@NgModule({
imports: [FormsModule, /* ... */],
})
export class AppModule {}

步骤二:在模板中使用 ngModelngForm 指令。

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
<form #loginForm="ngForm" (ngSubmit)="onSubmit(loginForm)">
<div>
<label for="username">用户名</label>
<input
type="text"
id="username"
name="username"
[(ngModel)]="model.username"
#usernameCtrl="ngModel"
required
minlength="3"
/>
<!-- 验证错误提示 -->
<div *ngIf="usernameCtrl.invalid && (usernameCtrl.dirty || usernameCtrl.touched)">
<small *ngIf="usernameCtrl.errors?.['required']">用户名为必填。</small>
<small *ngIf="usernameCtrl.errors?.['minlength']">用户名至少 3 个字符。</small>
</div>
</div>

<div>
<label for="password">密码</label>
<input
type="password"
id="password"
name="password"
[(ngModel)]="model.password"
required
minlength="6"
/>
</div>

<button type="submit" [disabled]="loginForm.invalid">登录</button>
</form>

关键机制

  • #loginForm="ngForm" 创建了一个模板引用变量,指向 Angular 为该 <form> 自动创建的 NgForm 指令实例。你可以在模板中通过 loginForm.validloginForm.invalid 等属性访问整个表单的状态。
  • [(ngModel)]="model.username" 实现了双向数据绑定,同时告诉 Angular:“这是一个表单控件,请为我创建一个 FormControl”。name 属性是必需的——它作为该控件在 NgForm.value 对象中的键名。
  • #usernameCtrl="ngModel" 创建了指向该输入框的 NgModel 指令实例,你可以通过它访问该控件的验证状态(.invalid.dirty.touched.errors)。
  • requiredminlength 是原生 HTML 验证属性,Angular 会自动将它们映射为对应的验证器函数Validators.requiredValidators.minLength)。

组件类

1
2
3
4
5
6
7
8
9
10
11
12
13
import { Component } from '@angular/core';
import { NgForm } from '@angular/forms';

@Component({ /* ... */ })
export class LoginComponent {
model = { username: '', password: '' };

onSubmit(form: NgForm): void {
if (form.valid) {
console.log('提交数据:', this.model);
}
}
}

2.2 模板驱动表单的验证状态属性

每个控件(NgModel)和整个表单(NgForm)都拥有以下状态属性:

属性 含义
valid 所有验证都通过时为 true
invalid 任一验证失败时为 true
pending 异步验证正在进行时为 true
pristine 用户尚未与控件交互(未修改初始值)。
dirty 用户已经修改过控件的值。
touched 控件获得过焦点并失去了焦点(blur 事件触发)。
untouched 控件尚未获得过焦点(或获得后尚未失去)。

验证错误提示的最佳实践:仅在控件 invalid && (dirty || touched) 时显示错误。这样可以避免用户在刚进入页面时就看到一堆红色警告(此时他们尚未开始填写),同时确保在用户离开控件(blur)后给出及时反馈。


3. 响应式表单:显式构建表单模型

响应式表单将表单的结构和验证逻辑完全定义在组件类中,模板中仅通过 formGroupformControlName 等指令将 DOM 元素与 TypeScript 中的表单模型关联。

3.1 导入 ReactiveFormsModule

1
2
3
4
5
6
7
import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';

@NgModule({
imports: [ReactiveFormsModule, /* ... */],
})
export class AppModule {}

3.2 使用 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
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

@Component({ /* ... */ })
export class SignupComponent implements OnInit {
signupForm!: FormGroup;

constructor(private fb: FormBuilder) {}

ngOnInit(): void {
this.signupForm = this.fb.group({
name: ['', [Validators.required, Validators.minLength(3)]],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required],
role: ['user', Validators.required],
terms: [false, Validators.requiredTrue],
});
}

onSubmit(): void {
if (this.signupForm.valid) {
console.log('提交数据:', this.signupForm.value);
}
}
}

FormBuilder.group 的配置格式:对象的每个键对应一个表单控件,值是一个数组[初始值, [同步验证器], [异步验证器]]。如果不需要验证器,可以简写为 'initialValue'(仅初始值)。

FormBuilder 与手动 new FormGroup 等价,但代码更简洁。上面的 fb.group(...) 等价于:

1
2
3
4
new FormGroup({
name: new FormControl('', [Validators.required, Validators.minLength(3)]),
// ...
});

3.3 在模板中绑定响应式表单

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
<form [formGroup]="signupForm" (ngSubmit)="onSubmit()">
<div>
<label for="name">姓名</label>
<input id="name" type="text" formControlName="name" />
<div *ngIf="signupForm.get('name')?.invalid && (signupForm.get('name')?.dirty || signupForm.get('name')?.touched)">
<small *ngIf="signupForm.get('name')?.errors?.['required']">姓名为必填。</small>
<small *ngIf="signupForm.get('name')?.errors?.['minlength']">姓名至少 3 个字符。</small>
</div>
</div>

<div>
<label for="email">邮箱</label>
<input id="email" type="email" formControlName="email" />
<div *ngIf="signupForm.get('email')?.invalid && (signupForm.get('email')?.dirty || signupForm.get('email')?.touched)">
<small *ngIf="signupForm.get('email')?.errors?.['required']">邮箱为必填。</small>
<small *ngIf="signupForm.get('email')?.errors?.['email']">请输入有效的邮箱地址。</small>
</div>
</div>

<div>
<label for="password">密码</label>
<input id="password" type="password" formControlName="password" />
<!-- 验证错误提示 -->
</div>

<div>
<label for="confirmPassword">确认密码</label>
<input id="confirmPassword" type="password" formControlName="confirmPassword" />
<!-- 跨字段验证错误(见第 4 节) -->
</div>

<button type="submit" [disabled]="signupForm.invalid">注册</button>
</form>

关键指令

  • [formGroup]="signupForm":将 <form> 元素与 TypeScript 中创建的 FormGroup 实例关联。
  • formControlName="name":将 <input> 元素映射到 FormGroup 中名为 'name'FormControl不需要 [(ngModel)] 双向绑定——响应式表单的数据流是单向的。
  • signupForm.get('name'):在模板中通过 get(controlName) 方法获取对应的 FormControl 实例以访问其状态。

4. 跨字段验证:确认密码匹配

单个字段的验证器(如 requiredemail)只能访问该字段自身的值。当需要比较两个字段时(如“确认密码必须与密码相同”),需要编写跨字段验证器,将其挂载到父级 FormGroup 上,而非单个 FormControl

1
2
3
4
5
6
7
8
9
10
11
12
// validators/password-match.validator.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

export const passwordMatchValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
const password = control.get('password');
const confirmPassword = control.get('confirmPassword');

// 如果控件尚未初始化,不执行验证
if (!password || !confirmPassword) return null;

return password.value === confirmPassword.value ? null : { passwordMismatch: true };
};

在组件中,将该验证器挂载到 FormGroup 上:

1
2
3
4
5
6
7
this.signupForm = this.fb.group(
{
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required],
},
{ validators: passwordMatchValidator } // 第二个参数:表单级别的验证器
);

在模板中显示跨字段验证错误:

1
2
3
<div *ngIf="signupForm.errors?.['passwordMismatch'] && (signupForm.get('confirmPassword')?.dirty || signupForm.get('confirmPassword')?.touched)">
<small>两次输入的密码不一致。</small>
</div>

关键点:跨字段验证器挂载在 FormGroup 上,它的错误会出现在 signupForm.errors 而非 signupForm.get('confirmPassword').errors 中。这是因为验证器接收的 control 参数是整个 FormGroup,它需要对组内的多个字段进行比较。


5. 自定义验证器:同步与异步

5.1 同步自定义验证器:密码强度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// validators/strong-password.validator.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

export const strongPasswordValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
const value: string = control.value || '';

if (!value) return null; // 空值交给 required 验证器处理

const hasUpperCase = /[A-Z]/.test(value);
const hasLowerCase = /[a-z]/.test(value);
const hasNumber = /\d/.test(value);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(value);

const valid = hasUpperCase && hasLowerCase && hasNumber && hasSpecialChar;

return valid ? null : { weakPassword: {
hasUpperCase,
hasLowerCase,
hasNumber,
hasSpecialChar,
} };
};

使用:

1
password: ['', [Validators.required, Validators.minLength(8), strongPasswordValidator]],

模板中显示详细的密码要求:

1
2
3
4
5
6
7
8
9
<div *ngIf="signupForm.get('password')?.errors?.['weakPassword'] && (signupForm.get('password')?.dirty || signupForm.get('password')?.touched)">
<small>密码必须包含:</small>
<ul>
<li [style.color]="signupForm.get('password')?.errors?.['weakPassword']?.hasUpperCase ? 'green' : 'red'">大写字母</li>
<li [style.color]="signupForm.get('password')?.errors?.['weakPassword']?.hasLowerCase ? 'green' : 'red'">小写字母</li>
<li [style.color]="signupForm.get('password')?.errors?.['weakPassword']?.hasNumber ? 'green' : 'red'">数字</li>
<li [style.color]="signupForm.get('password')?.errors?.['weakPassword']?.hasSpecialChar ? 'green' : 'red'">特殊字符</li>
</ul>
</div>

5.2 异步验证器:检查用户名是否已存在

异步验证器返回一个 PromiseObservable,用于验证需要向服务器查询的逻辑(如用户名唯一性)。它通常模拟网络请求,并需要处理延迟和取消。

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
// validators/username-exists.validator.ts
import { Injectable } from '@angular/core';
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { Observable, of } from 'rxjs';
import { map, catchError, debounceTime, switchMap, first } from 'rxjs/operators';
import { UserService } from '../services/user.service';

@Injectable({ providedIn: 'root' })
export class UsernameValidator {
static createValidator(userService: UserService): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!control.value) return of(null); // 空值不检查

return of(control.value).pipe(
debounceTime(300), // 防抖,避免每次按键都请求
switchMap(username =>
userService.checkUsernameExists(username).pipe(
map(exists => exists ? { usernameTaken: true } : null),
catchError(() => of(null)) // 网络错误时返回 null(视为通过)
)
),
first() // 确保 Observable 完成
);
};
}
}

在组件中使用(需要注入 UserService):

1
2
3
4
5
6
7
this.signupForm = this.fb.group({
username: [
'',
[Validators.required, Validators.minLength(3)],
[UsernameValidator.createValidator(this.userService)], // 第三个参数:异步验证器
],
});

模板中显示异步验证状态:

1
2
3
4
5
6
<div *ngIf="signupForm.get('username')?.pending">
<small>正在验证用户名...</small>
</div>
<div *ngIf="signupForm.get('username')?.errors?.['usernameTaken']">
<small>该用户名已被使用。</small>
</div>

关键行为

  • 异步验证器不会在每次按键时立即执行——Angular 会在控件的同步验证通过、且值稳定后(通常是 blur 事件或经过 debounceTime 延迟)才触发异步验证。
  • 在异步验证进行中,控件的 status 变为 'PENDING',可通过 signupForm.get('username')?.pending 检测。
  • debounceTimeswitchMap 确保用户快速输入时取消上一个未完成的验证请求,仅保留最新的。

6. FormArray:动态表单控件

当需要允许用户动态添加或删除一组表单控件时(如多个电话号码、多个收货地址),使用 FormArray。它是一个 FormControlFormGroup 或嵌套 FormArray 的有序集合。

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
ngOnInit(): void {
this.orderForm = this.fb.group({
customerName: ['', Validators.required],
items: this.fb.array([this.createItem()]), // 初始包含一个空行
});
}

// 创建一个商品行
createItem(): FormGroup {
return this.fb.group({
product: ['', Validators.required],
quantity: [1, [Validators.required, Validators.min(1)]],
price: [0, [Validators.required, Validators.min(0.01)]],
});
}

// 获取 items 的 FormArray 引用
get items(): FormArray {
return this.orderForm.get('items') as FormArray;
}

// 添加一个商品行
addItem(): void {
this.items.push(this.createItem());
}

// 移除指定索引的商品行
removeItem(index: number): void {
this.items.removeAt(index);
}

模板中渲染动态表单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<form [formGroup]="orderForm" (ngSubmit)="onSubmit()">
<input formControlName="customerName" placeholder="客户姓名" />

<div formArrayName="items">
<div
*ngFor="let item of items.controls; let i = index"
[formGroupName]="i"
>
<input formControlName="product" placeholder="商品名称" />
<input formControlName="quantity" type="number" min="1" />
<input formControlName="price" type="number" min="0" step="0.01" />
<button type="button" (click)="removeItem(i)">删除</button>
</div>
</div>

<button type="button" (click)="addItem()">+ 添加商品</button>
<button type="submit" [disabled]="orderForm.invalid">提交订单</button>
</form>

关键指令与流程

  • formArrayName="items":将容器 <div> 绑定到 FormArray
  • *ngFor="let item of items.controls; let i = index":遍历 FormArray 中的每个 FormGroup
  • [formGroupName]="i":将每个迭代中的子 <div> 绑定到 FormArray 中对应索引的 FormGroup
  • 调用 this.items.push(...)this.items.removeAt(...) 来动态增删表单行。

7. 综合实战:完整的注册表单(响应式 + 跨字段验证 + 自定义验证器 + 异步验证)

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
// signup.component.ts
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators, FormArray, AbstractControl, ValidationErrors } from '@angular/forms';
import { UserService } from '../../services/user.service';
import { UsernameValidator } from '../../validators/username-exists.validator';

@Component({
selector: 'app-signup',
templateUrl: './signup.component.html',
})
export class SignupComponent implements OnInit {
form!: FormGroup;

constructor(private fb: FormBuilder, private userService: UserService) {}

ngOnInit(): void {
this.form = this.fb.group({
username: [
'',
[Validators.required, Validators.minLength(3)],
[UsernameValidator.createValidator(this.userService)],
],
email: ['', [Validators.required, Validators.email]],
passwordGroup: this.fb.group(
{
password: ['', [Validators.required, Validators.minLength(8), this.strongPassword]],
confirmPassword: ['', Validators.required],
},
{ validators: this.passwordMatch }
),
role: ['user', Validators.required],
terms: [false, Validators.requiredTrue],
});
}

// 自定义同步验证器(作为方法,方便访问组件上下文)
private strongPassword(control: AbstractControl): ValidationErrors | null {
const value: string = control.value || '';
if (!value) return null;
const hasUpper = /[A-Z]/.test(value);
const hasLower = /[a-z]/.test(value);
const hasDigit = /\d/.test(value);
const valid = hasUpper && hasLower && hasDigit;
return valid ? null : { weakPassword: { hasUpper, hasLower, hasDigit } };
}

// 跨字段验证器
private passwordMatch(group: AbstractControl): ValidationErrors | null {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
return password === confirm ? null : { passwordMismatch: true };
}

// 便捷 getter
get username() { return this.form.get('username')!; }
get email() { return this.form.get('email')!; }
get passwordGroup() { return this.form.get('passwordGroup') as FormGroup; }
get password() { return this.passwordGroup.get('password')!; }
get confirmPassword() { return this.passwordGroup.get('confirmPassword')!; }

onSubmit(): void {
if (this.form.invalid) return;
const { passwordGroup, ...data } = this.form.value;
const payload = { ...data, password: passwordGroup.password };
console.log('注册数据:', payload);
}
}
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
<!-- signup.component.html -->
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="signup-form">
<h2>创建账户</h2>

<!-- 用户名 -->
<div class="form-field">
<label>用户名</label>
<input type="text" formControlName="username" placeholder="请输入用户名" />
<small *ngIf="username.pending">验证中...</small>
<small *ngIf="username.errors?.['usernameTaken']">用户名已被占用</small>
<small *ngIf="username.errors?.['required'] && username.touched">必填</small>
</div>

<!-- 邮箱 -->
<div class="form-field">
<label>邮箱</label>
<input type="email" formControlName="email" placeholder="请输入邮箱" />
<small *ngIf="email.errors?.['required'] && email.touched">必填</small>
<small *ngIf="email.errors?.['email'] && email.touched">邮箱格式不正确</small>
</div>

<!-- 密码组 -->
<div formGroupName="passwordGroup">
<div class="form-field">
<label>密码</label>
<input type="password" formControlName="password" placeholder="请输入密码" />
<small *ngIf="password.errors?.['required'] && password.touched">必填</small>
<small *ngIf="password.errors?.['weakPassword'] && password.touched">
密码需包含大小写字母和数字
</small>
</div>

<div class="form-field">
<label>确认密码</label>
<input type="password" formControlName="confirmPassword" placeholder="请再次输入密码" />
<small *ngIf="passwordGroup.errors?.['passwordMismatch'] && confirmPassword.touched">
两次密码不一致
</small>
</div>
</div>

<!-- 角色 -->
<div class="form-field">
<label>角色</label>
<select formControlName="role">
<option value="user">普通用户</option>
<option value="editor">编辑者</option>
</select>
</div>

<!-- 协议 -->
<div class="form-field checkbox">
<label>
<input type="checkbox" formControlName="terms" />
我同意服务条款
</label>
<small *ngIf="form.get('terms')?.errors?.['requiredTrue'] && form.get('terms')?.touched">
必须同意条款
</small>
</div>

<button type="submit" [disabled]="form.invalid || username.pending">注册</button>
</form>

8. 最佳实践总结

  1. 模板驱动 vs 响应式的选择:简单表单用模板驱动;复杂动态表单用响应式。不要混用。
  2. 验证器复用:将自定义验证器提取为独立的导出函数(非类方法),放在 validators/ 目录下,方便多组件共享和单元测试。
  3. 异步验证器必须处理网络错误:在 catchError 中返回 of(null) 避免因验证请求失败而阻塞表单提交。
  4. dirty/touched 控制错误显示:不要在 pristine 状态显示错误,使用 *ngIf="ctrl.invalid && (ctrl.dirty || ctrl.touched)"
  5. FormArray 中的索引绑定:使用 trackBy: index 配合 *ngFor 优化性能;避免在循环内调用 removeAt 直接使用索引而不检查数组长度。
  6. 表单提交按钮的禁用逻辑[disabled]="form.invalid || form.pending" 确保异步验证进行中也禁用提交按钮。

课后练习

一、概念自测(选择题 / 填空题)

  1. (单选) 以下关于 Angular 模板驱动表单的描述,哪项是错误的?
    A. 表单模型由 ngFormngModel 指令在模板中隐式创建。
    B. 验证规则通过 HTML 属性(如 requiredminlength)添加。
    C. 可以动态添加或移除表单字段(通过 FormArray)。
    D. 它依赖 FormsModule 而非 ReactiveFormsModule

  2. (单选) 要为一个 FormControl 添加异步验证器,应将其放在 fb.control()fb.group() 的哪个参数位置?
    A. 第一个参数(初始值)
    B. 第二个参数(同步验证器数组)
    C. 第三个参数(异步验证器数组)
    D. FormControl 不支持异步验证器

  3. (填空) 跨字段验证器(如确认密码)应挂载在 ______ 级别,而非单个 FormControl 级别,因为它需要访问多个字段的值。

  4. (多选) 关于响应式表单的 FormBuilder,哪些描述是正确的?
    A. fb.group({ name: ['Alice', Validators.required] }) 创建一个 FormGroup
    B. fb.array([fb.control('')]) 创建一个 FormArray
    C. fb.control('', Validators.required) 创建一个 FormControl
    D. FormBuilder 是唯一创建表单控件的方式,不能手动 new FormGroup

二、AI 编程任务:编写面向 AI 的提示词

场景:你需要使用 Angular 响应式表单创建一个地址管理组件。要求如下:

  • 使用 FormBuilder 创建 addressForm,包含以下字段:street(必填)、city(必填)、zipCode(必填,5 位数字正则验证)、country(必填)。
  • 编写一个自定义验证器 zipCodeValidator(同步),使用正则 /^\d{5}$/ 验证邮编。如果格式不正确,返回 { invalidZip: true }
  • 表单底部有一个“添加新地址”按钮,点击后通过 FormArray 动态添加另一组地址字段。
  • 提供 removeAddress(index) 方法删除指定索引的地址。
  • 提交时打印所有地址数据到控制台。

任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成符合上述要求的 Angular 组件代码(含 TypeScript 类、HTML 模板和自定义验证器)。提示词中需明确指定 FormArray 的用法、自定义验证器的实现位置和正则表达式。

三、Agent 模式下的提示词示例

你是一个资深前端开发 Agent。请为 Angular 应用创建一个响应式地址管理组件。需要创建以下文件:

  1. src/app/validators/zip-code.validator.ts:导出同步验证器函数 zipCodeValidator,返回 ValidatorFn。验证 control.value 是否为 5 位数字(/^\d{5}$/),否则返回 { invalidZip: true }
  2. src/app/components/address-manager/address-manager.component.ts
    • 选择器 app-address-manager
    • 使用 FormBuilder 创建 FormGroupaddressesFormArray,初始包含一个地址组(通过 createAddressGroup() 方法创建)。每个地址组包含 street(必填)、city(必填)、zipCode(必填 + zipCodeValidator)、country(必填)。
    • get addresses(): FormArray 返回 this.form.get('addresses') as FormArray
    • addAddress()this.addresses.push(this.createAddressGroup())
    • removeAddress(index: number)this.addresses.removeAt(index)
    • onSubmit():若 form.validconsole.log(this.form.value.addresses)
    • 模板:<form [formGroup]="form" (ngSubmit)="onSubmit()">。使用 formArrayName="addresses"*ngFor 遍历 addresses.controls,每个地址组使用 [formGroupName]="i"。每组包含 4 个 <input> 和验证错误提示,以及“删除”按钮。底部“添加新地址”按钮和提交按钮。
  3. 添加 JSDoc 注释。完成后列出所有文件内容。

四、面试真题与参考答案

题目(字节跳动前端面试题):

请对比 Angular 的模板驱动表单和响应式表单,详细说明它们的设计哲学、数据流方向、验证配置方式和适用场景。如果你需要在用户填写表单时根据某个字段的选择动态显示或隐藏另一个字段,并动态添加其验证规则,应该使用哪种表单?请给出具体的实现思路。

参考答案

设计哲学:模板驱动表单将表单逻辑放在模板中,依赖指令(ngModelngForm)隐式创建表单控件,属于“声明式”风格。响应式表单将表单模型显式定义在 TypeScript 类中,模板仅负责绑定,属于“编程式”风格。

数据流:模板驱动表单使用 [(ngModel)] 双向绑定,数据在模板和组件之间自动同步。响应式表单使用单向数据流——通过 setValue/patchValue 从组件流向视图,通过 valueChanges Observable 从视图流向组件。

验证配置:模板驱动表单通过 HTML 验证属性(requiredpattern 等)添加验证。响应式表单向 FormControl 构造函数传入验证器函数数组。

适用场景:简单、静态的表单用模板驱动;复杂、动态的表单用响应式。

动态显示/隐藏字段并添加/移除验证:应使用响应式表单。实现思路:

  1. 监听触发字段(如 category.valueChanges)的变化。
  2. 根据当前值动态调用 form.addControl('conditionalField', new FormControl('', Validators.required))form.removeControl('conditionalField')
  3. 调用 form.updateValueAndValidity() 确保表单整体验证状态正确。
    模板驱动表单无法在运行时动态添加/移除验证器(验证规则通过静态属性配置),而响应式表单可以在 TypeScript 代码中自由操控控件树,是动态表单的唯一选择。

课后练习答案

一、概念自测答案

  1. C

    • 解析:FormArray 是响应式表单的特性,模板驱动表单不支持动态添加/移除表单控件。A、B、D 均为模板驱动表单的正确描述。
  2. C

    • 解析:fb.control(initialValue, syncValidators, asyncValidators) 中,第三个参数是异步验证器数组。第二个参数是同步验证器。
  3. FormGroup

    • 解析:跨字段验证器需要比较同一组内的多个字段,因此必须挂载在包含这些字段的 FormGroup 上。
  4. A、B、C

    • 解析:D 错误,FormBuilder 是语法糖,等价于手动 new FormGroup/new FormControl/new FormArray。A、B、C 均为正确用法。

二、AI 编程任务参考答案(提示词示例)

示例提示词
“请用 Angular 响应式表单实现地址管理组件。要求:

  • FormArray 管理多组地址,初始包含一组。
  • 每组包含 street/city/zipCode/country(均必填),zipCode 使用自定义正则验证器 /^\d{5}$/
  • 动态添加/删除地址组。
  • 提交时打印所有地址。输出 component.ts、component.html 和 zip-code.validator.ts 的完整代码。”
CATALOG
  1. 1. 第120课:Angular 表单——模板驱动表单、响应式表单、验证、自定义验证器
    1. 1.1. 1. 两种表单模式的核心区别
    2. 1.2. 2. 模板驱动表单:指令驱动的声明式表单
      1. 1.2.1. 2.1 基础模板驱动表单
      2. 1.2.2. 2.2 模板驱动表单的验证状态属性
    3. 1.3. 3. 响应式表单:显式构建表单模型
      1. 1.3.1. 3.1 导入 ReactiveFormsModule
      2. 1.3.2. 3.2 使用 FormBuilder 创建表单组
      3. 1.3.3. 3.3 在模板中绑定响应式表单
    4. 1.4. 4. 跨字段验证:确认密码匹配
    5. 1.5. 5. 自定义验证器:同步与异步
      1. 1.5.1. 5.1 同步自定义验证器:密码强度
      2. 1.5.2. 5.2 异步验证器:检查用户名是否已存在
    6. 1.6. 6. FormArray:动态表单控件
    7. 1.7. 7. 综合实战:完整的注册表单(响应式 + 跨字段验证 + 自定义验证器 + 异步验证)
    8. 1.8. 8. 最佳实践总结
    9. 1.9. 课后练习
      1. 1.9.1. 一、概念自测(选择题 / 填空题)
      2. 1.9.2. 二、AI 编程任务:编写面向 AI 的提示词
      3. 1.9.3. 三、Agent 模式下的提示词示例
      4. 1.9.4. 四、面试真题与参考答案
    10. 1.10. 课后练习答案
      1. 1.10.1. 一、概念自测答案
      2. 1.10.2. 二、AI 编程任务参考答案(提示词示例)