PAYATHON 2026

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 时就可以按区域验证,无须同时改动整页业务流程。

备注:内容仅供参考。