Angular 18–20 中的表单有哪些最佳实践?
Angular 18 至 20 的复杂业务表单,建议继续使用强类型 Reactive Forms。按业务区域组织 FormGroup,用 FormArray 管理可增删的重复项,再逐步拆出可复用的校验规则、数据转换和请求逻辑。
Signals 可以用于页面状态和派生展示。要降低以后的迁移成本,可以先理清表单与业务逻辑的边界,不必提前封装一套通用表单框架。
先确认 Signal Forms 的版本范围
Angular 的 signal() 与 Signal Forms 是两件事。在组件中使用 Signals,不代表必须更换表单方案。
官方 Signal Forms 的 form() API 从 Angular 21 开始以实验性 API 引入。Angular 18 至 20 的项目继续使用 @angular/forms 中的 Reactive Forms 即可。阅读示例时要确认版本,不能直接把新版 @angular/forms/signals 的代码搬进这些项目。官方版本标记
将来迁移时,表单绑定、校验适配和自定义控件仍可能需要调整,不能承诺“一键迁移”。业务规则与数据模型拆分得越清楚,通常越容易控制重写范围。
按业务含义组织 FormGroup 和 FormArray
根据数据形态选择表单结构:
| 数据形态 | 建议结构 | 示例 |
|---|---|---|
| 单个字段 | FormControl | 姓名、邮箱 |
| 固定字段组成的对象 | FormGroup | 地址、开票信息 |
| 可增删的同类条目 | FormArray | 联系人、订单明细 |
| 键名动态变化、控件类型一致的集合 | FormRecord | 按动态标识组织的开关项 |
嵌套层级应反映数据关系。地址中的城市和街道适合放在同一个 group;如果只是因为页面分成左右两栏就增加两层 group,往往会让取值和校验更绕。官方结构示例、强类型表单说明
例如,用户资料表单可以这样创建:
// profile.form.ts
import { NonNullableFormBuilder, Validators } from '@angular/forms';
export function createContactForm(fb: NonNullableFormBuilder) {
return fb.group({
label: fb.control(''),
email: fb.control('', {
validators: [Validators.required, Validators.email],
}),
});
}
export function createProfileForm(fb: NonNullableFormBuilder) {
return fb.group({
name: fb.control('', {
validators: [Validators.required],
}),
address: fb.group({
city: fb.control('', {
validators: [Validators.required],
}),
street: fb.control(''),
}),
contacts: fb.array([createContactForm(fb)]),
});
}
export type ProfileForm = ReturnType<typeof createProfileForm>;
每次调用工厂函数都会创建新的控件。不要把可变的 FormGroup 实例导出为全局单例,否则不同页面可能共用同一份输入值和校验状态。
NonNullableFormBuilder 适合这些以空字符串表示空值的字段,也会影响 reset() 的行为:控件通常重置为初始值,而不是 null。日期、可清空选择项等确实允许 null 的字段,应保留相应类型。空值与重置行为
组件负责交互,复杂规则按职责拆分
简单页面可以直接在组件里定义表单。等到出现重复规则、多个业务区域或大量订阅时,再按职责拆分:
- 表单工厂定义控件结构、初始值,并装配校验器。
- 校验函数判断输入是否合法。
- 业务服务处理接口请求、业务计算和提交数据转换。
- 组件展示错误、增删条目和触发保存。
页面组件可以保留这些交互:
// profile-editor.component.ts
import {
Component,
EventEmitter,
Output,
inject,
} from '@angular/core';
import {
NonNullableFormBuilder,
ReactiveFormsModule,
} from '@angular/forms';
import { createContactForm, createProfileForm } from './profile.form';
export interface ProfileDraft {
name: string;
address: {
city: string;
street: string;
};
contacts: Array<{
label: string;
email: string;
}>;
}
@Component({
selector: 'app-profile-editor',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './profile-editor.component.html',
})
export class ProfileEditorComponent {
private readonly fb = inject(NonNullableFormBuilder);
readonly form = createProfileForm(this.fb);
readonly contacts = this.form.controls.contacts;
@Output() readonly save = new EventEmitter<ProfileDraft>();
addContact(): void {
this.contacts.push(createContactForm(this.fb));
}
removeContact(index: number): void {
this.contacts.removeAt(index);
}
submit(): void {
this.form.markAllAsTouched();
if (this.form.status !== 'VALID') {
return;
}
this.save.emit(this.form.getRawValue());
}
}
使用 status !== 'VALID' 拦截提交,也能拦住异步校验尚未完成的 PENDING 状态。实际发送请求的一层还应处理重复提交、保存失败和服务端返回的字段错误。
校验器放在哪里,取决于规则涉及的范围:
- 必填、长度等规则放在字段上。
- 日期先后、密码确认等跨字段规则放在相关字段的共同父级
FormGroup上。 - 明细重复等集合规则放在
FormArray上。
跨字段校验器应返回错误对象或 null。通常不需要在校验器内部修改其他字段的值,或反复调用子控件的 setErrors()。这些操作容易覆盖已有错误,也会让校验依赖执行顺序。官方跨字段校验示例
需要服务端检查时,可以使用 AsyncValidatorFn,返回会完成的 Observable 或 Promise。要区分“输入不合法”和“检查请求失败”,同时保留提交接口上的服务端校验。
大型表单按业务区域拆组件
表单变大后,可以把地址、联系人列表等区域拆成子组件。父组件持有根表单,子组件通过类型明确的输入接收对应的 group:
<app-address-fields [group]="form.controls.address" />
子组件需要导入 ReactiveFormsModule,并在自己的容器上绑定 [formGroup]="group"。如果外层已经有 <form>,子组件使用 <section>、<fieldset> 等容器即可,避免嵌套 <form>。
联系人列表的模板可以这样绑定:
<!-- profile-editor.component.html -->
<form [formGroup]="form" (ngSubmit)="submit()">
<label for="name">姓名</label>
<input id="name" formControlName="name" />
@if (
form.controls.name.hasError('required') &&
form.controls.name.touched
) {
<p>请填写姓名。</p>
}
<fieldset formGroupName="address">
<legend>地址</legend>
<label>城市 <input formControlName="city" /></label>
<label>街道 <input formControlName="street" /></label>
</fieldset>
<div formArrayName="contacts">
@for (contact of contacts.controls; track contact; let i = $index) {
<fieldset [formGroupName]="i">
<legend>联系人 {{ i + 1 }}</legend>
<label>称呼 <input formControlName="label" /></label>
<label>邮箱 <input type="email" formControlName="email" /></label>
@if (contact.controls.email.invalid && contact.controls.email.touched) {
<p>请填写有效的邮箱。</p>
}
<button type="button" (click)="removeContact(i)">删除</button>
</fieldset>
}
</div>
<button type="button" (click)="addContact()">添加联系人</button>
<button type="submit">保存</button>
</form>
这里用 track contact 按控件实例追踪动态数组中的条目。删除或移动条目时,这比按索引追踪更适合保留对应的 DOM 和输入状态。新增、删除按钮要明确写 type="button",避免意外触发表单提交。
金额输入器这类可独立复用的输入控件,可以考虑实现 ControlValueAccessor,接入值、触碰状态和禁用状态。普通业务分区直接接收 FormGroup 通常更直观。ControlValueAccessor API
大表单中几个容易遗漏的细节
明确提交哪些值
对于启用状态的 FormGroup,.value 通常不包含被禁用的子控件,getRawValue() 则包含它们。示例使用后者来取得完整草稿,实际项目应根据提交协议选择。官方取值说明
表单结构与接口结构不一致时,可以增加显式的转换函数,例如 toUpdateProfileRequest()。这样,接口字段调整就能集中处理,不会散落在模板和各个事件处理方法中。
回填 FormArray 时先处理控件结构
patchValue() 只更新已有控件,不会根据接口返回的数组自动创建条目。编辑页加载数据时,应先创建或调整各行控件,再回填字段。
reset() 也不会删除动态行。如果“重置”还需要恢复条目数量,就要单独处理 FormArray。
控制校验和订阅的执行频率
如果可以接受字段失焦后再更新值和校验,可以为耗时检查设置 updateOn: 'blur'。联想搜索则可以在 RxJS 流中使用 debounceTime()、distinctUntilChanged() 和 switchMap()。
手动订阅 valueChanges 时要处理生命周期,可以使用 takeUntilDestroyed();在注入上下文之外调用时,需要显式传入 DestroyRef。生命周期清理 API
某些初始化操作或需要避免循环触发的场景,可以使用 emitEvent: false。但依赖这些事件的汇总展示、自动保存等逻辑也不会收到通知,需要另行安排同步。
测试业务规则和关键交互
测试应覆盖跨字段规则、动态行增删、接口数据回填,以及异步校验期间能否提交。切换页签或步骤后,未展示的字段是否仍参与校验,也要有明确约定。
现在如何为 Signals 做准备
可以先在现有 Reactive Forms 外围使用 Signals。例如,在前面的组件中加入以下成员,将表单状态用于派生展示:
import { computed } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
// 放在 form 初始化之后
readonly status = toSignal(this.form.statusChanges, {
initialValue: this.form.status,
});
readonly canSubmit = computed(() => this.status() === 'VALID');
toSignal() 将 Observable 的最新值提供为 Signal,适合这类展示用途。官方 toSignal API
直接写 computed(() => this.form.valid) 不会随表单更新,因为 form.valid 是普通属性,读取它不会建立 Signal 依赖。也要避免通过双向订阅,将整份表单值同步到另一份可写 Signal 中,这容易造成循环更新和状态不一致。
为迁移做准备,可以先调整现有代码:让业务判断尽量接收普通数据,集中维护接口转换,并明确每个子表单的输入边界。保留关键交互测试,以后评估 Signal Forms 时就可以按区域验证,无须同时改动整页业务流程。
备注:内容仅供参考。
