表单是 Angular 变化最慢、也最讲究向后兼容的领域:基于 RxJS 的 Reactive Forms 服务了 Angular 2 以来的所有版本。Signal Forms 在 v21 以开发者预览亮相,v22 起正式稳定。它把表单的值与状态全部建立在 Signals 之上,验证器变成可组合的函数,异步验证与 httpResource 打通,自定义控件从 ControlValueAccessor 协议进化为 FormValueControl。这是本系列的重点篇,我们逐一展开。
为什么表单需要重做
Reactive Forms 的问题在 Signals 时代变得刺眼:
FormControl.value、FormGroup.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() 把验证器函数组合到字段上。内置验证器包括 required、requiredTrue、minLength、maxLength、pattern、min、max,以及日期专用的 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 的默认语义是递归标记全部后代(旧版 markAllAsTouched 与 markAsTouched 两个方法合并成了一个带选项的方法)。只想标记根字段本身时:
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
旧协议 ControlValueAccessor 有 writeValue、registerOnChange、registerOnTouched、setDisabledState 四件套。新协议 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 2 | v21 预览,v22 稳定 |
| 核心构建 | FormControl / FormGroup / FormBuilder 类 | form() / 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 取消机制 |
| 条件重跑 | 手动 updateValueAndValidity | reloadValidation() |
| 动态禁用 | enable()/disable() 命令式 | disabled(field, { when }) 声明式 |
| 值传播时机 | 无独立控制 | debounce(field, 'blur') |
| 全部标记 touched | markAllAsTouched() 单独方法 | markAsTouched() 默认递归,skipDescendants 可关 |
| 自定义控件 | ControlValueAccessor 四回调 | FormValueControl:value model + touched input + touch output |
| 变更检测 | 依赖 Zone/桥接 | 信号驱动,Zoneless 原生 |
迁移建议
- 新项目、新模块直接用 Signal Forms,没有理由再开
FormBuilder - 旧应用不必重写。两套表单可以共存于同一应用甚至同一页面(
ReactiveForms与 Signal Forms 的模块互相独立),[formField]和formGroup各管各的 - 按”改动成本从低到高”排序替换:新页面 → 独立小表单(登录、筛选器)→ 复杂编辑页。自定义控件最后动,因为
ControlValueAccessor控件在新表单里仍然可用 - 利用双向兼容做平滑过渡:共享组件库逐步实现
FormValueControl,旧页面继续走兼容层 - 关注官方后续版本提供的自动化迁移工具(v22 时点仍以手工迁移为主,API 已稳定,早迁早受益)
附:experimentalWebMcpTool
form() 的选项中有一个实验性开关 experimentalWebMcpTool,可将表单暴露为 WebMCP 工具,供浏览器中的 AI 代理(自动填充助手、智能客服)按结构化方式读取与填充表单:
const chatForm = form(
{ message: validate(required()) },
{ experimentalWebMcpTool: 'chat-composer' }, // 实验性,形态可能调整
);
它不影响正常表单行为,仅在需要对接 WebMCP 生态时开启,属于”值得关注但暂不依赖”的能力。