CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / ANGULAR / P-191 · Angular 高级教程

Angular 22+ 教程 25:Signal Forms——v22 起稳定的表单方案

表单是 Angular 变化最慢、也最讲究向后兼容的领域:基于 RxJS 的 Reactive Forms 服务了 Angular 2 以来的所有版本。Signal Forms 在 v21 以开发者预览亮相,v22 起正式稳定。它把表单的值与状态全部建立在 Signals 之上,验证器变成可组合的函数,异步验证与 httpResource 打通,自定义控件从 ControlValueAccessor 协议进化为 FormValueControl。这是本系列的重点篇,我们逐一展开。

为什么表单需要重做

Reactive Forms 的问题在 Signals 时代变得刺眼:

  • FormControl.valueFormGroup.touched 都是普通属性,模板只能依赖 Zone 驱动的全树检查感知变化,Zoneless 下要做大量内部桥接
  • 异步验证器的取消、防抖要靠 AsyncValidatorFn 的订阅管理,请求过期竞态需要自己小心
  • 派生状态(“表单整体是否有效且未被提交”)要订阅 valueChanges/statusChanges 手工拼装
  • 自定义控件的 ControlValueAccessor 协议有四个回调 + 注册函数,样板代码多且容易写漏

Signal Forms 的答案:表单就是信号。字段值是可写信号,字段状态(touched、invalid、errors)是只读信号,验证器是纯函数,加载类验证直接复用 Resources 的取消机制。

两个原语:form() 与 model()

form() 定义表单结构,字段默认从初始值推断类型:

import { Component } from '@angular/core';
import { form, validate, required, minLength } from '@angular/forms';

@Component({
  selector: 'app-signup',
  templateUrl: './signup.component.html',
})
export class SignupComponent {
  readonly signupForm = form({
    username: validate(required(), minLength(3)),
    password: validate(required(), minLength(8)),
  });
}

这个对象是一个字段树signupForm.username 是一个字段,signupForm 本身是根字段(表单)。树可以任意嵌套,嵌套表单直接用 form() 作为字段值:

readonly orderForm = form({
  remark: '',
  address: form({
    city: validate(required()),
    street: validate(required()),
  }),
});

model() 则是更底层的原语:一个可双向绑定的值字段。form() 内部的字段就是基于 model 语义构建的,而你在自定义控件里会直接用到它(见下文 FormValueControl 一节)。它同样出现在组件间需要”值 + 值变化”双向契约的所有场合:

export class TagInputComponent {
  readonly value = model<string[]>([]);
}

[formField] 绑定与字段状态

模板用 [formField] 把原生或自定义控件接到字段上:

<form (ngSubmit)="submit()">
  <label>
    用户名
    <input [formField]="signupForm.username" name="username" />
    @if (signupForm.username.touched() && signupForm.username.getError('required')) {
      <span class="error">用户名必填</span>
    }
  </label>

  <label>
    密码
    <input type="password" [formField]="signupForm.password" name="password" />
    @if (signupForm.password.touched() && signupForm.password.getError('minLength'); as err) {
      <span class="error">密码至少 {{ err.required }} 位,当前 {{ err.actual }} 位</span>
    }
  </label>

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

字段上的核心状态 API 都是信号调用:

API含义
field.value()当前值(可写:支持 .set() / .update()
field.touched()是否被”触碰”过(失焦/提交时置位)
field.invalid()是否验证不通过
field.errors()全部错误的键值对象
field.getError('required')取特定错误,返回 undefined 表示无此错误

表单级 API 同构:signupForm.value() 返回整个表单值对象,signupForm.invalid()signupForm.touched() 聚合所有后代字段。因为全是信号,派生状态直接用 computed

readonly canSubmit = computed(
  () => this.signupForm.valid() && this.signupForm.touched(),
);

验证器:validate() 与内置验证器

validate() 把验证器函数组合到字段上。内置验证器包括 requiredrequiredTrueminLengthmaxLengthpatternminmax,以及日期专用的 minDate() / maxDate()

import {
  form, validate, required, minLength, minDate, maxDate, pattern,
} from '@angular/forms';

readonly bookingForm = form({
  title: validate(required(), maxLength(50)),
  seats: validate(min(1), max(20)),
  email: validate(pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)),
  // 日期范围:入住不早于今天,退房不晚于今年年底
  checkin: validate(required(), minDate(today()), maxDate(endOfYear())),
  checkout: validate(required(), minDate(today()), maxDate(endOfYear())),
});

验证器是普通函数:输入值,返回错误对象或 undefined。自定义一个也只是一行函数:

const isbn = (value: string) =>
  value.length === 13 ? undefined : { isbn: { length: value.length } };

getError() 与类型收窄

getError('required') 不只做存在性判断——返回值会按错误键收窄类型,模板和 TS 里都能拿到结构化的错误详情:

showPasswordHint(): string {
  const err = this.signupForm.password.getError('minLength');
  if (err !== undefined) {
    // err 已收窄为 minLength 错误的负载:{ required: number; actual: number }
    return `至少 ${err.required} 位,还差 ${err.required - err.actual} 位`;
  }
  return '';
}

对比旧的 hasError('minlength') + getError('minlength') 双调用,新的 API 一次调用完成判断与取值,且类型是完整的。

异步验证:validateAsync 与 validateHttp

validateAsync + debounce

validateAsync() 声明异步验证器,debounce 选项直接内建,不再需要给字段套 RxJS:

export class SignupComponent {
  readonly signupForm = form({
    username: validate(
      required(),
      minLength(3),
      validateAsync(
        { debounce: 300 },
        async (value: string) => {
          const taken = await this.usersApi.exists(value);
          return taken ? { usernameTaken: true } : undefined;
        },
      ),
    ),
  });

  private readonly usersApi = inject(UsersApi);
}

异步验证进行中,field.pending() 为 true(可用来显示”检查中”的转角动画);值变化时旧的验证请求自动作废,过期结果不会污染最新状态——这正是老方案最容易出竞态 bug 的地方。

validateHttp:与 httpResource 联动

validateHttp() 是为”请求后端校验”定制的验证器,底层走 httpResource(见第 23 篇),继承其取消与缓存能力:

readonly signupForm = form({
  email: validate(
    required(),
    validateHttp(
      (email: string) => `/api/users/exists?email=${encodeURIComponent(email)}`,
    ),
  ),
});

特征:声明式、无手动订阅、过期请求自动取消,并与 SSR 请求转移机制兼容。适合”用户名占用""邀请码有效性""邮箱黑名单”这类必须由服务端裁决的校验。

reloadValidation():条件变化后重跑

有些异步校验依赖外部条件——比如”当前选择的团队里昵称是否重复”。切换团队后,即便昵称没变也应重新校验。reloadValidation() 用来手动触发重跑:

onTeamChange(): void {
  this.signupForm.username.reloadValidation();
}

行为选项统一 when

validateAsync / validateHttp 等的行为开关统一收敛到 when 选项:条件不满足时跳过验证,避免无意义的请求:

validateHttp(
  (email: string) => `/api/users/exists?email=${encodeURIComponent(email)}`,
  { when: (email) => /.+@.+\..+/.test(email) }, // 格式合法才发起校验
),

动态行为:disabled 与 debounce

disabled(field, { when })

字段的动态禁用不再需要监听状态手工 enable()/disable()disabled() 声明式表达”什么时候不可用”:

export class PaymentComponent {
  readonly paymentForm = form({
    useCoupon: false,
    couponCode: disabled(validate(required()), {
      // when 为 true 时字段禁用;行为选项统一用 when
      when: () => !this.paymentForm.useCoupon.value(),
    }),
  });
}

when 是响应式求值的:勾选”使用优惠券”复选框的瞬间,couponCode 自动解禁,反之自动禁用(禁用字段的值不参与表单值聚合)。

debounce(field, ‘blur’)

debounce() 控制字段值向外的传播时机。'blur' 表示失焦才提交值,中间的击键不会触发下游派生逻辑与异步验证:

export class ProfileComponent {
  readonly profileForm = form({
    nickname: validate(required(), validateAsync({ debounce: 200 }, checkNickname)),
  });

  constructor() {
    // 昵称失焦时才提交值,输入过程完全安静
    debounce(this.profileForm.nickname, 'blur');
  }
}

这是”实时校验”与”不打扰用户”之间的精细调节阀:比在每次 input 上做全文验证克制得多。

touched 的语义变化:input + touch()

旧表单里 touched 是控件内部状态,自定义控件要通过 onTouched 回调”上报”。Signal Forms 把这个契约显式化为一个输入加一个输出

  • touched:框架下发到控件的只读状态(控件据此渲染自己的样式)
  • touch:控件在合适的时机(通常失焦)向上发出的输出事件

对自定义控件作者来说,这比记住”四个回调何时调用”直观得多。见下节完整示例。

markAsTouched() 的递归语义

提交时批量展示错误是表单的固定戏码:

submit(): void {
  if (this.signupForm.invalid()) {
    // 默认递归:根字段与所有后代字段全部标记 touched,错误一次性铺开
    this.signupForm.markAsTouched();
    return;
  }
  this.http.post('/api/signup', this.signupForm.value()).subscribe();
}

注意 v22 的默认语义是递归标记全部后代(旧版 markAllAsTouchedmarkAsTouched 两个方法合并成了一个带选项的方法)。只想标记根字段本身时:

this.signupForm.markAsTouched({ skipDescendants: true });

字段状态的生命周期如下:

stateDiagram-v2
    direction LR
    state "未触碰 untouched" as UT {
        [*] --> Pristine
    }
    UT --> Touched: touch 事件 / markAsTouched
    Touched --> Touched: 再次失焦仍是 touched
    state "校验状态独立演进" as VS {
        [*] --> Valid
        Valid --> Pending: 值变化且有异步验证器
        Pending --> Valid: 通过
        Pending --> Invalid: 失败
        Invalid --> Valid: 修正后通过
    }

自定义表单控件:FormValueControl

旧协议 ControlValueAccessorwriteValueregisterOnChangeregisterOnTouchedsetDisabledState 四件套。新协议 FormValueControl 只要求一个 value model,touched 契约通过 input/output 表达。完整示例——一个星级评分控件:

import { Component, input, model, output } from '@angular/core';
import { FormValueControl } from '@angular/forms';

@Component({
  selector: 'app-star-rating',
  template: `
    <div class="stars" role="radiogroup" aria-label="评分" [class.disabled]="disabled()">
      @for (star of stars; track star) {
        <button
          type="button"
          role="radio"
          [attr.aria-checked]="value() >= star"
          [class.active]="value() >= star"
          [disabled]="disabled()"
          (click)="select(star)"
          (blur)="touch.emit()"
        >
          {{ star }} 星
        </button>
      }
    </div>
  `,
})
export class StarRatingComponent implements FormValueControl<number> {
  readonly stars = [1, 2, 3, 4, 5];

  // 值:双向绑定的核心,[formField] 会读写它
  readonly value = model(0);

  // touched 契约:框架下发状态,控件在失焦时上报
  readonly touched = input(false);
  readonly touch = output<void>();

  // 禁用状态同样由框架下发
  readonly disabled = input(false);

  select(star: number): void {
    if (!this.disabled()) {
      this.value.set(star);
    }
  }
}

在 Signal Forms 里使用它:

<label>
  服务评分
  <app-star-rating [formField]="reviewForm.rating" />
</label>

不接 [formField] 时它依然是普通组件,可以用 [(value)]="score" 直接双向绑定。双向兼容是这套设计的关键承诺:

  • 新写的 FormValueControl 控件也能接 [(ngModel)] 和旧 formControlName(框架做协议桥接)
  • 旧的 ControlValueAccessor 控件在 Signal Forms 中继续可用

所以组件库不必为新旧表单各写一版控件。

表单级验证与提交

跨字段校验用 form() 根字段上的验证器表达。提交逻辑读 value() 与状态信号即可:

export class BookingComponent {
  readonly bookingForm = form({
    checkin: validate(required(), minDate(today())),
    checkout: validate(required(), maxDate(endOfYear())),
  });

  readonly submitting = signal(false);

  async submit(): Promise<void> {
    if (this.bookingForm.invalid()) {
      this.bookingForm.markAsTouched();
      return;
    }
    this.submitting.set(true);
    try {
      await firstValueFrom(this.http.post('/api/bookings', this.bookingForm.value()));
    } finally {
      this.submitting.set(false);
    }
  }
}

与 Reactive Forms 全面对比

维度Reactive Forms(旧)Signal Forms(v22 稳定)
引入时间Angular 2v21 预览,v22 稳定
核心构建FormControl / FormGroup / FormBuilderform() / model() 信号原语
模板绑定formGroup + formControlName[formField]="form.field"
值访问control.value 属性、valueChanges 订阅field.value() 信号调用
状态访问touched / invalid 属性touched() / invalid() 信号
派生状态订阅 valueChanges 手工维护computed 直接派生
同步验证Validators.required 静态数组validate(...) 函数组合
异步验证AsyncValidatorFn + 手动防抖validateAsync({ debounce }) 内建
HTTP 校验自己封装请求与竞态处理validateHttp() 复用 httpResource 取消机制
条件重跑手动 updateValueAndValidityreloadValidation()
动态禁用enable()/disable() 命令式disabled(field, { when }) 声明式
值传播时机无独立控制debounce(field, 'blur')
全部标记 touchedmarkAllAsTouched() 单独方法markAsTouched() 默认递归,skipDescendants 可关
自定义控件ControlValueAccessor 四回调FormValueControl:value model + touched input + touch output
变更检测依赖 Zone/桥接信号驱动,Zoneless 原生

迁移建议

  1. 新项目、新模块直接用 Signal Forms,没有理由再开 FormBuilder
  2. 旧应用不必重写。两套表单可以共存于同一应用甚至同一页面(ReactiveForms 与 Signal Forms 的模块互相独立),[formField]formGroup 各管各的
  3. 按”改动成本从低到高”排序替换:新页面 → 独立小表单(登录、筛选器)→ 复杂编辑页。自定义控件最后动,因为 ControlValueAccessor 控件在新表单里仍然可用
  4. 利用双向兼容做平滑过渡:共享组件库逐步实现 FormValueControl,旧页面继续走兼容层
  5. 关注官方后续版本提供的自动化迁移工具(v22 时点仍以手工迁移为主,API 已稳定,早迁早受益)

附:experimentalWebMcpTool

form() 的选项中有一个实验性开关 experimentalWebMcpTool,可将表单暴露为 WebMCP 工具,供浏览器中的 AI 代理(自动填充助手、智能客服)按结构化方式读取与填充表单:

const chatForm = form(
  { message: validate(required()) },
  { experimentalWebMcpTool: 'chat-composer' }, // 实验性,形态可能调整
);

它不影响正常表单行为,仅在需要对接 WebMCP 生态时开启,属于”值得关注但暂不依赖”的能力。

系列导航

← 算法 025:图:最小生成树(Prim & Kruskal) 目录 设计模式 025:模板方法(Template Method) →
← 返回文章列表