Angular Signal Forms 字段状态管理:FieldState 信号体系详解与源码解析
Signal Forms(Angular 22 起随 @angular/forms/signals 发布的响应式表单 API)把表单的每一个状态都建模为响应式信号:验证状态(valid、invalid、errors、pending)、交互追踪(touched、dirty)、可用性状态(disabled、hidden、readonly)。本文完整讲解字段树(field tree)中 FieldState 对象的使用方式——从单个字段的错误提示到整表提交、从状态自下而上的聚合传播到程序化焦点控制,并结合 Angular 仓库中 Signal Forms 实现源码 验证每个信号背后的计算逻辑,帮助你在模板和组件逻辑中精确、可复现地驾驭表单状态。
理解字段状态(Field State)
调用 form() 函数创建表单时,返回的是一个字段树(field tree)——一个镜像表单模型结构的对象。树中的每个字段都可以用点号访问(如 form.email)。
访问字段状态
把字段树中的任意字段作为函数调用(如 form.email()),即可拿到该字段的 FieldState 对象,其中包含追踪验证、交互与可用性状态的响应式信号。例如 invalid() 告诉你该字段是否存在验证错误:
import {Component, signal} from '@angular/core';
import {form, FormField, required, email} from '@angular/forms/signals';
@Component({
selector: 'app-registration',
imports: [FormField],
template: `
<input type="email" [formField]="registrationForm.email" />
@if (registrationForm.email().invalid()) {
<p class="error">Email has validation errors:</p>
<ul>
@for (error of registrationForm.email().errors(); track error) {
<li>{{ error.message }}</li>
}
</ul>
}
`,
})
export class Registration {
registrationModel = signal({
email: '',
password: '',
});
registrationForm = form(this.registrationModel, (schemaPath) => {
required(schemaPath.email, {message: 'Email is required'});
email(schemaPath.email, {message: 'Enter a valid email address'});
});
}
模板中通过 registrationForm.email().invalid() 决定何时渲染错误消息。这个“把字段当函数调用”的访问方式在类型定义中有明确依据:Field 类型被注释为 A field accessor function that returns the state of the field;而 FieldTree 的文档注释(types.ts)说明字段树“结构与底层数据结构同构,要访问字段状态就把它当函数调用”。
字段状态信号一览
最常用的信号是 value(),它是一个 WritableSignal,直接读写字段的当前值:
const emailValue = registrationForm.email().value();
console.log(emailValue); // Current email string
除 value() 外,FieldState 还提供三大类信号:
| 类别 | 信号 | 说明 |
|---|---|---|
| 验证 | valid() |
字段通过所有验证规则,且没有处于 pending 的验证器 |
invalid() |
字段存在验证错误 | |
errors() |
验证错误对象数组 | |
pending() |
异步验证正在进行 | |
| 交互 | touched() |
用户已聚焦并离开该字段(仅限可交互字段) |
dirty() |
用户已修改该字段(即使当前值与初始值相同) | |
| 可用性 | disabled() |
字段被禁用,不影响父表单状态 |
hidden() |
字段应被隐藏;模板中的显隐需用 @if 配合控制 |
|
readonly() |
字段只读,不影响父表单状态 |
这些信号使你能构建跟随用户行为自动反应的表单体验。源码中,FieldState 接口完整列出了这些成员(含 reset()、markAsTouched()、markAsDirty()、reloadValidation() 等写操作方法),见 ReadonlyFieldState 与 FieldState 定义。
验证状态(Validation state)
验证状态信号告诉你字段是否有效、包含哪些错误。
注意:本节聚焦于使用验证状态(在模板和逻辑中读取
valid()、invalid()、errors()来展示反馈)。定义验证规则和编写自定义验证器,请参考仓库中的 Validation guide。
检查有效性
使用 valid() 和 invalid() 检查验证状态:
@Component({
template: `
<input type="email" [formField]="loginForm.email" />
@if (loginForm.email().invalid()) {
<p class="error">Email is invalid</p>
}
@if (loginForm.email().valid()) {
<p class="success">Email looks good</p>
}
`,
})
export class Login {
loginModel = signal({email: '', password: ''});
loginForm = form(this.loginModel);
}
| 信号 | 何时返回 true |
|---|---|
valid() |
字段通过所有验证规则,且没有处于 pending 的验证器 |
invalid() |
字段存在验证错误 |
在代码中做有效性判断时,如果想区分“有错误”和“验证尚未完成”,应使用 invalid() 而不是 !valid()。原因是:当异步验证进行中时,valid() 和 invalid() 可能同时为 false——验证未完成所以还不算 valid,但又尚未发现任何错误所以也不算 invalid。
这一非直觉行为在源码中有精确落地:calculateValidationSelfStatus 把字段状态划分为三态 'invalid' | 'unknown' | 'valid'(有错误 → invalid;无错误但有 pending → unknown;其余 → valid),随后 valid 仅在 status() === 'valid' 时为真,invalid 仅在 status() === 'invalid' 时为真。三态模型同时存在于聚合后的子树上,因此“两个信号都为 false”恰好对应 unknown 状态,行为可被 validation_status.spec.ts 中的测试用例验证。
读取验证错误
用 errors() 获取验证错误数组。每个错误对象包含以下属性:
| 属性 | 说明 |
|---|---|
kind |
失败的验证规则(如 "required"、"email") |
message |
可选的人类可读错误消息 |
fieldTree |
指向错误发生位置的 FieldTree 引用 |
注意:
message是可选的。验证器可以提供自定义错误消息,但如果没提供,你可能需要自行把kind映射成文案。
模板中遍历展示错误的典型写法:
@Component({
template: `
<input type="email" [formField]="loginForm.email" />
@if (loginForm.email().errors().length > 0) {
<div class="errors">
@for (error of loginForm.email().errors(); track error) {
<p>{{ error.message }}</p>
}
</div>
}
`
})
这种方式会遍历该字段的全部错误并逐条展示给用户。从源码看,errors() 是 errors 计算信号,它把三部分错误合并:parseErrors(控件解析错误)、syncErrors(同步验证器与提交时命令式加入的错误)、asyncErrors 中已出结果的非 'pending' 项。错误类型(含 kind/message 以及 fieldTree 注入机制)定义在 validation_errors.ts,其中 WithFieldTree<T> = T & {fieldTree: ReadonlyFieldTree<unknown>} 保证了错误总是携带其发生位置的字段树引用。
待处理验证(Pending)
pending() 信号表示异步验证正在进行:
@Component({
template: `
<input type="email" [formField]="signupForm.email" />
@if (signupForm.email().pending()) {
<p>Checking if email is available...</p>
}
@if (signupForm.email().invalid() && !signupForm.email().pending()) {
<p>Email is already taken</p>
}
`
})
该信号让你在异步验证执行期间展示 loading 状态。实现上,pending 信号 通过 reduceChildren 聚合自身 asyncErrors 中是否包含 'pending' 哨兵值以及任一子字段的 pending 状态——异步验证器未完成时会向错误列表注入 'pending' 标记(见 rawAsyncErrors 的文档注释)。
交互状态(Interaction state)
交互状态追踪用户是否与字段发生过交互,支撑“用户碰过字段后才显示错误”这类常见模式。
Touched 状态
touched() 追踪用户是否聚焦后又离开了字段,或者字段是否被程序化地标记为 touched。只有可交互字段才能被 touched:hidden、disabled、readonly 字段既不会因用户交互而 touched,也不会被 markAsTouched() 标记。
当需要以“区块级”动作展开整块区域的验证错误时,调用区块字段的 markAsTouched()。skipDescendants 默认值为 false,因此调用会同时标记区块字段及其所有后代字段为 touched。
例如结账流程可以在进入下一步前验证收货信息区块:
import {Component, signal} from '@angular/core';
import {form, FormField, required} from '@angular/forms/signals';
@Component({
selector: 'app-checkout-shipping',
imports: [FormField],
template: `
<label>
Name
<input [formField]="checkoutForm.shipping.name" />
</label>
@if (checkoutForm.shipping.name().touched() && checkoutForm.shipping.name().invalid()) {
<p>{{ checkoutForm.shipping.name().errors()[0].message }}</p>
}
<label>
Address
<input [formField]="checkoutForm.shipping.address" />
</label>
@if (checkoutForm.shipping.address().touched() && checkoutForm.shipping.address().invalid()) {
<p>{{ checkoutForm.shipping.address().errors()[0].message }}</p>
}
<button type="button" (click)="continueToPayment()">Continue</button>
@if (showPayment() && checkoutForm.shipping().valid()) {
<p>Ready for payment.</p>
}
`,
})
export class CheckoutShipping {
checkoutModel = signal({
shipping: {
name: '',
address: '',
},
});
showPayment = signal(false);
checkoutForm = form(this.checkoutModel, (schemaPath) => {
required(schemaPath.shipping.name, {message: 'Enter a name'});
required(schemaPath.shipping.address, {message: 'Enter an address'});
});
continueToPayment() {
this.checkoutForm.shipping().markAsTouched();
if (this.checkoutForm.shipping().invalid()) {
return;
}
this.showPayment.set(true);
}
}
continueToPayment() 对 checkoutForm.shipping() 调用 markAsTouched() 时采用默认的 skipDescendants: false 行为,Angular 会把 shipping、shipping.name、shipping.address 全部标记为 touched,于是子字段上的 touched() && invalid() 错误消息在整表提交前就会显现。
注意:仅当希望“被调用的字段自身变 touched 但不改变其后代 touched 状态”时才传
{skipDescendants: true}。
源码印证了上述语义:markAsTouched(options?: MarkAsTouchedOptions) 的定义见 types.ts,MarkAsTouchedOptions.skipDescendants 的注释明确写着“若为 true 仅标记当前字段,否则标记字段及其全部后代”(types.ts#L59-L71)。markAsTouchedInternal 的递归实现也证实了:遇到 skipDescendants 提前返回,否则遍历 structure.children() 逐个递归;并且在 shouldSkipValidation()(即字段 hidden/disabled/readonly)时直接跳过,这正是“非交互字段不会被标记”的实现依据。
Dirty 状态
表单常需感知数据是否真正发生过变化——例如提示用户“有未保存的修改”,或仅在有改动时启用保存按钮。dirty() 追踪用户是否修改过字段:
当用户对可交互字段的值做出修改时,dirty() 变为 true;且即使之后把值改回与初始值一致,它仍保持 true:
@Component({
template: `
<form novalidate>
<input [formField]="profileForm.name" />
<input [formField]="profileForm.bio" />
@if (profileForm().dirty()) {
<p class="warning">You have unsaved changes</p>
}
</form>
`,
})
export class Profile {
profileModel = signal({name: 'Alice', bio: 'Developer'});
profileForm = form(this.profileModel);
}
dirty() 适合用于“未保存修改”提示或“有改动才启用保存按钮”。
从源码看,“修改即脏、且同值也记脏”来自 controlValueSignal 的实现:controlValue.set 被刻意允许“同值更新”,注释写道 We intentionally allow same-value updates here to ensure that setting the control value (even to the same value) still marks the control as dirty——每次控件写入值都会调用 markAsDirty(),之后即使值被改回也不会自动还原(还原要等 reset(),见后文)。
Touched 与 dirty 的区别
两个信号追踪的是不同维度的交互状态:
| 信号 | 何时变为 true |
|---|---|
touched() |
用户聚焦并离开了一个可交互字段,或字段被程序化标记为 touched |
dirty() |
用户修改过可交互字段的值(即使从未 blur,即使当前值与初始值相同) |
字段可能处于不同的组合状态:
| 状态 | 场景 |
|---|---|
| Touched 但未 dirty | 用户聚焦后又离开了字段,但没有做任何修改 |
| 既 touched 又 dirty | 用户聚焦字段、修改了值并离开 |
注意:hidden、disabled、readonly 字段属于非交互字段——它们不会因用户交互而 touched 或 dirty。
这一规则的实现位于 FieldNodeState:touched 与 dirty 均为 computed 信号,先取 selfTouched()/selfDirty() && !isNonInteractive() 作为自身初始值,再通过 reduceChildren 与子字段状态做“或”聚合(带短路优化)。而 isNonInteractive 的定义就是 hidden() || disabled() || readonly()——非交互字段的自我 touched/dirty 会被直接置为无效,但子字段的贡献仍会向上聚合。
可用性状态(Availability state)
可用性状态信号控制字段是否可交互、可编辑、可见。Disabled、hidden、readonly 字段都是非交互的:它们不会影响父表单的 valid、touched、dirty 状态。
Disabled 字段
disabled() 指示字段是否接受用户输入。Disabled 字段在界面上仍然可见,但用户无法与之交互。
import {Component, signal} from '@angular/core';
import {form, FormField, disabled} from '@angular/forms/signals';
@Component({
selector: 'app-order',
imports: [FormField],
// TIP: 使用 `[formField]` 指令时,`disabled` 属性会基于字段的
// `disabled()` 状态自动绑定,无需手动写 `[disabled]="field().disabled()"`
template: `
<input [formField]="orderForm.couponCode" />
@if (orderForm.couponCode().disabled()) {
<p class="info">Coupon code is only available for orders over $50</p>
}
`,
})
export class Order {
orderModel = signal({
total: 25,
couponCode: '',
});
orderForm = form(this.orderModel, (schemaPath) => {
disabled(schemaPath.couponCode, {when: ({valueOf}) => valueOf(schemaPath.total) < 50});
});
}
本例用 valueOf(schemaPath.total) 读取 total 字段的值来决定 couponCode 是否禁用。
注意:schema 回调的参数(示例中的
schemaPath)是一个SchemaPathTree对象,提供通往表单所有字段的路径,参数名可以任意取。
定义 disabled()、hidden()、readonly() 这类规则时,when 函数接收一个 FieldContext 对象,通常会被解构(如 ({valueOf}))。其中两个常用方法:
valueOf(schemaPath.otherField)—— 读取表单中其他字段的值value()—— 包含规则所绑定字段自身值的信号
Disabled 字段不参与父表单的验证状态。即使某个 disabled 字段本身“应该是无效的”,父表单依然可以是 valid 的。disabled() 状态影响的是交互性与验证,但不改变字段本身的值。
注意:从源码结构看,
disabled()的实现还额外提供了disabledReasons(禁用原因列表)——disabled 规则允许when返回字符串作为原因,when返回字符串时会自动包装为{fieldTree, message}形式的DisabledReason(定义见 types.ts);disabled 信号本身 是disabledReasons是否非空的派生值,且会合并父字段的禁用原因,因此父字段禁用会使后代整体禁用。
Hidden 字段
hidden() 指示字段是否被条件隐藏。用 hidden() 配合 @if 按条件显隐字段:
import {Component, signal} from '@angular/core';
import {form, FormField, hidden} from '@angular/forms/signals';
@Component({
selector: 'app-profile',
imports: [FormField],
template: `
<label>
<input type="checkbox" [formField]="profileForm.isPublic" />
Make profile public
</label>
@if (!profileForm.publicUrl().hidden()) {
<label>
Public URL
<input [formField]="profileForm.publicUrl" />
</label>
}
`,
})
export class Profile {
profileModel = signal({
isPublic: false,
publicUrl: '',
});
profileForm = form(this.profileModel, (schemaPath) => {
hidden(schemaPath.publicUrl, {when: ({valueOf}) => !valueOf(schemaPath.isPublic)});
});
}
Hidden 字段不参与验证:如果某个必填字段被隐藏,它不会阻止表单提交。hidden() 状态影响可用性与验证,但不改变字段本身的值。
一个关键细节:hidden() 信号只标记状态,不会替你隐藏 DOM 元素。ReadonlyFieldState.hidden 的文档注释明确写道 Note: This doesn't hide the field in the template, that must be done manually,这正是上面模板中 @if (!field.hidden()) 包装的由来。
Readonly 字段
readonly() 指示字段是否只读。Readonly 字段展示其值但用户不能编辑:
import {Component, signal} from '@angular/core';
import {form, FormField, readonly} from '@angular/forms/signals';
@Component({
selector: 'app-account',
imports: [FormField],
template: `
<label>
Username (cannot be changed)
<input [formField]="accountForm.username" />
</label>
<label>
Email
<input [formField]="accountForm.email" />
</label>
`,
})
export class Account {
accountModel = signal({
username: 'johndoe',
email: 'john@example.com',
});
accountForm = form(this.accountModel, (schemaPath) => {
readonly(schemaPath.username);
});
}
注意:
[formField]指令会基于字段的readonly()状态自动绑定readonly属性,无需手动写[readonly]="field().readonly()"。
与 disabled 和 hidden 字段一样,readonly 字段是非交互的,不影响父表单状态。readonly() 状态影响可编辑性与验证,但不改变字段本身的值。
关于“自动绑定”一说的源码依据:FormField 指令 的职责注释(第 92 行)写明它 Binds additional forms related state on the field state to the UI control (disabled, required, etc.),其内部对 disabled、readonly、required、min/max 等属性做了逐一分发的绑定(form_field.ts#L417-L431)。
何时用哪一种
| 状态 | 使用场景 | 用户可见 | 用户可交互 | 参与验证 |
|---|---|---|---|---|
disabled() |
字段暂时不可用(如取决于其他字段的值) | 是 | 否 | 否 |
hidden() |
字段在当前语境下不相关 | 否(配合 @if) | 否 | 否 |
readonly() |
值应可见但不可编辑 | 是 | 否 | 否 |
表单级状态(Form-level state)
根表单本身也是字段树中的一个字段。把它当函数调用时,同样返回一个 FieldState 对象,并聚合全部子字段的状态。
访问表单状态
@Component({
template: `
<form novalidate>
<input [formField]="loginForm.email" />
<input [formField]="loginForm.password" />
<button [disabled]="!loginForm().valid()">Sign In</button>
</form>
`,
})
export class Login {
loginModel = signal({email: '', password: ''});
loginForm = form(this.loginModel);
}
在这个例子中,表单只有在所有子字段都 valid 时才是 valid。这使得你可以基于整表有效性来控制提交按钮的启用/禁用。
表单级信号
根表单既然也是字段,就拥有与字段相同的信号(valid()、invalid()、touched()、dirty() 等),但其语义是聚合行为:
| 信号 | 表单级行为 |
|---|---|
valid() |
所有可交互字段有效且没有 pending 的验证器 |
invalid() |
至少一个可交互字段存在验证错误 |
pending() |
至少一个可交互字段的异步验证未完成 |
touched() |
表单自身,或至少一个可交互后代,是 touched 的 |
dirty() |
用户修改过至少一个可交互字段 |
何时用表单级 vs 字段级
表单级状态适用于:
- 提交按钮的启用/禁用
- “保存”按钮的状态
- 整表有效性检查
- 未保存修改提示
字段级状态适用于:
- 单字段错误消息
- 字段级样式
- 逐字段验证反馈
- 字段可用性条件控制
状态传播(State propagation)
字段状态从子字段自下而上逐级汇聚到父字段组,最终到达根表单。
子状态如何影响父表单
当某个子字段变为 invalid 时,其父字段组变为 invalid,根表单也随之 invalid;子字段变 touched/dirty 时,父字段组和根表单也会反映该变化。这种聚合允许你在任意层级检查有效性——单个字段或整个表单:
const userModel = signal({
profile: {
firstName: '',
lastName: '',
},
address: {
street: '',
city: '',
},
});
const userForm = form(userModel);
// 若 firstName invalid,则 profile invalid
userForm.profile.firstName().invalid() === true;
// → userForm.profile().invalid() === true
// → userForm().invalid() === true
这一自下而上聚合的实现即上文 FieldNodeState.touched/dirty 的 reduceChildren 聚合 与 ValidationState.status 的三态归约——其中 status 的归约(validation.ts#L317-L336)采用“遇到 invalid 立即短路”策略:只要子树中任一节点为 invalid,父级即为 invalid。
Hidden、disabled 与 readonly 字段
Hidden、disabled、readonly 字段属于非交互字段,不影响父表单状态:
const orderModel = signal({
customerName: '',
requiresShipping: false,
shippingAddress: '',
});
const orderForm = form(orderModel, (schemaPath) => {
hidden(schemaPath.shippingAddress, {when: ({valueOf}) => !valueOf(schemaPath.requiresShipping)});
});
本例中,当 shippingAddress 处于 hidden 状态时,它不影响表单有效性——即使该字段为空且必填,表单依然可以是 valid 的。
这一机制防止 hidden、disabled 或 readonly 字段阻塞表单提交或影响验证、touched、dirty 状态。源码依据是 shouldSkipValidation:当字段 hidden() || disabled() || readonly()(或已孤儿化)时,rawSyncTreeErrors、syncErrors、rawAsyncErrors 等全部短路返回空,syncValid 直接为 true,status 直接为 'valid'——非交互字段被整体排除在验证聚合之外。
在模板中使用状态
字段状态信号与 Angular 模板天然集成,无需手动处理事件即可构建响应式表单体验。
条件错误显示
仅当用户与字段交互过之后才展示错误:
import {Component, signal} from '@angular/core';
import {form, FormField, email} from '@angular/forms/signals';
@Component({
selector: 'app-signup',
imports: [FormField],
template: `
<label>
Email
<input type="email" [formField]="signupForm.email" />
</label>
@if (signupForm.email().touched() && signupForm.email().invalid()) {
<p class="error">{{ signupForm.email().errors()[0].message }}</p>
}
`,
})
export class Signup {
signupModel = signal({email: '', password: ''});
signupForm = form(this.signupModel, (schemaPath) => {
email(schemaPath.email);
});
}
这一模式避免了在用户还没机会交互之前就展示错误:错误只在用户聚焦后又离开字段之后出现。
条件字段可用性
用 hidden() 配合 @if 条件显隐字段:
import {Component, signal} from '@angular/core';
import {form, FormField, hidden} from '@angular/forms/signals';
@Component({
selector: 'app-order',
imports: [FormField],
template: `
<label>
<input type="checkbox" [formField]="orderForm.requiresShipping" />
Requires shipping
</label>
@if (!orderForm.shippingAddress().hidden()) {
<label>
Shipping Address
<input [formField]="orderForm.shippingAddress" />
</label>
}
`,
})
export class Order {
orderModel = signal({
requiresShipping: false,
shippingAddress: '',
});
orderForm = form(this.orderModel, (schemaPath) => {
hidden(schemaPath.shippingAddress, {
when: ({valueOf}) => !valueOf(schemaPath.requiresShipping),
});
});
}
Hidden 字段不参与验证,因此即使隐藏字段本身“应该是无效的”,表单仍可正常提交。
数组字段的 @for 追踪
在 Signal Forms 中,针对一组字段的 @for 块应当按字段身份(identity)追踪:
@Component({
imports: [FormField],
template: `
@for (field of form.emails; track field) {
<input [formField]="field" />
}
`,
})
export class App {
formModel = signal({emails: ['john.doe@mail.com', 'max.musterman@mail.com']});
form = form(this.formModel);
}
表单系统内部已经在追踪数组中的模型值,并自动为它创建的字段维持稳定身份(stable identity)。当数组项发生变化时,即使某些属性看起来相同,它也可能代表一个新的逻辑实体;按身份追踪可确保框架把它当作独立项处理,而不是复用已有 UI 元素。这防止了有状态元素(如表单输入框)被错误地共享,也让绑定与模型中正确的部分保持一致。
在组件逻辑中使用字段状态
字段状态信号可以与 Angular 的响应式原语(computed()、effect() 等)组合,用于更复杂的表单逻辑。
提交前校验
在组件方法中检查表单有效性:
export class Registration {
registrationModel = signal({
username: '',
email: '',
password: '',
});
registrationForm = form(this.registrationModel);
async onSubmit() {
// 等待任何 pending 的异步验证
if (this.registrationForm().pending()) {
console.log('Waiting for validation...');
return;
}
// 拦截无效提交
if (this.registrationForm().invalid()) {
console.error('Form is invalid');
return;
}
const data = this.registrationModel();
await this.api.register(data);
}
}
这样可以确保只有有效且完成验证的数据才会到达你的 API。
用 computed 派生状态
基于字段状态创建计算信号,底层字段状态变化时它会自动更新:
export class Password {
passwordModel = signal({password: '', confirmPassword: ''});
passwordForm = form(this.passwordModel);
// 计算密码强度指示
passwordStrength = computed(() => {
const password = this.passwordForm.password().value();
if (password.length < 8) return 'weak';
if (password.length < 12) return 'medium';
return 'strong';
});
// 检查所有必填字段是否已填写
allFieldsFilled = computed(() => {
return (
this.passwordForm.password().value().length > 0 &&
this.passwordForm.confirmPassword().value().length > 0
);
});
}
程序化状态变更
字段状态通常由用户交互(输入、聚焦、失焦)驱动更新,但有时也需要程序化控制,常见场景是表单提交与重置。
表单提交
Signal Forms 提供了 FormRoot 指令来简化表单提交:它会自动阻止浏览器默认提交行为,并为 <form> 元素设置 novalidate 属性。
import {FormField, FormRoot} from '@angular/forms/signals';
@Component({
imports: [FormRoot, FormField],
template: `
<form [formRoot]="registrationForm">
<input [formField]="registrationForm.username" />
<input type="email" [formField]="registrationForm.email" />
<input type="password" [formField]="registrationForm.password" />
<button type="submit">Register</button>
</form>
`,
})
export class Registration {
registrationModel = signal({username: '', email: '', password: ''});
registrationForm = form(
this.registrationModel,
(schemaPath) => {
required(schemaPath.username);
email(schemaPath.email);
required(schemaPath.password);
},
{
submission: {
action: async () => this.submitToServer(),
},
},
);
private submitToServer() {
// 把数据发送到服务器
}
}
使用 FormRoot 后,提交表单会自动调用 submit() 函数:它先把所有字段标记为 touched(让验证错误显现),如果表单有效则执行你的 action 回调。
不使用指令时也可以手动提交,直接调用 submit(this.registrationForm)。这样显式调用 submit 时,还可以传入 FormSubmitOptions 覆盖表单默认的 submission 逻辑:submit(this.registrationForm, {action: () => /* ... */ })。
FormSubmitOptions 的完整定义见 types.ts#L22-L56:除 action(有效时执行,返回 Promise<TreeValidationResult>)外,还支持 onInvalid(验证失败时执行)以及 ignoreValidators(取值 'pending' | 'none' | 'all',控制 pending/invalid 验证器是否阻塞提交,默认 'pending' 表示 pending 不阻塞提交)。
提交后重置表单
成功提交后,你可能希望把表单还原到初始状态——同时清空用户交互历史和字段值。reset() 方法会清除 touched 与 dirty 标志;也可以传入可选值来更新模型数据:
export class Contact {
private readonly INITIAL_MODEL = {name: '', email: '', message: ''};
contactModel = signal({...this.INITIAL_MODEL});
contactForm = form(this.contactModel, {
submission: {
action: async (f) => {
await this.api.sendMessage(this.contactModel());
// 清空交互状态(touched、dirty)并重置为初始值
f().reset({...this.INITIAL_MODEL});
},
},
});
}
这确保表单以干净状态迎接新的输入,不会残留过期错误消息或 dirty 指示。
实现上,FieldNode._reset 会:中止挂起的防抖同步;若传入 value 则写入 value 信号;强制 controlValue 与 value 对齐(处理“重置值等于当前值”时联动不触发的情况);调用 markAsUntouched() 与 markAsPristine();并递归遍历 materializedChildren() 逐层重置所有子字段——这正是“重置后不显示陈旧错误与 dirty 指示”的完整链路。
基于验证状态的样式
可以通过按验证状态绑定 CSS 类来定制表单样式:
import {Component, signal} from '@angular/core';
import {form, FormField, email} from '@angular/forms/signals';
@Component({
imports: [FormField],
template: `
<input
type="email"
[formField]="form.email"
[class.is-invalid]="form.email().touched() && form.email().invalid()"
[class.is-valid]="form.email().touched() && form.email().valid()"
/>
`,
styles: `
input.is-invalid {
border: 2px solid red;
background-color: white;
}
input.is-valid {
border: 2px solid green;
}
`,
})
export class StyleExample {
model = signal({email: ''});
form = form(this.model, (schemaPath) => {
email(schemaPath.email);
});
}
同时检查 touched() 与验证状态,可确保样式只在用户与字段交互过之后才出现。
聚焦与表单字段绑定的控件
Signal Forms 在字段状态上提供了 focusBoundControl() 方法,可把焦点程序化地移到与给定表单字段关联的表单控件上。
一个常见用例是提升提交时的无障碍体验:表单无效时展示错误消息并自动聚焦第一个无效字段,引导用户修正。
基本用法
给定一个注册表单:
@Component({
/* ... */
})
export class Registration {
registrationModel = signal({username: '', email: '', password: ''});
registrationForm = form(this.registrationModel, (schemaPath) => {
required(schemaPath.username);
email(schemaPath.email);
required(schemaPath.password);
});
}
把焦点移到绑定 email 字段的控件:
registrationForm.email().focusBoundControl();
防止滚动
如果目标控件不在视口内、且你希望聚焦时不触发滚动,可在调用 focusBoundControl() 时把 preventScroll 选项设为 true:
registrationForm.email().focusBoundControl({preventScroll: true});
提交时聚焦第一个无效字段
用 errorSummary() 定位第一个无效字段并在带错提交时聚焦它:
onSubmit() {
const firstError = this.registrationForm().errorSummary()[0];
if (firstError?.fieldTree) {
firstError.fieldTree().focusBoundControl();
} else {
// 继续提交流程
}
}
errorSummary() 是“本字段及其全部后代”的错误合集信号(定义见 types.ts),且在浏览器端会按错误对应控件的 DOM 顺序排序(errorSummary 计算信号 中的 compareErrorPosition),因此取 [0] 得到的正是“页面上第一个”出错的字段——这正是无障碍聚焦场景需要的位置语义。
自定义控件
默认情况下,对自定义控件调用 focusBoundControl() 不会有任何效果,因为一个自定义控件内部可能包含多个原生输入框(例如日期选择器可能含日、月、年三个输入框),Angular 无法确定该聚焦哪个元素、执行什么动作。
要支持程序化聚焦,自定义控件需要实现一个 focus() 方法。当对关联该自定义控件的字段状态调用 focusBoundControl() 时,若控件存在 focus() 方法,Angular 会调用它:
<div class="password-block">
<input type="password" #passwordCtrl [value]="value()" (input)="value.set($event.target.value)" />
</div>
@Component({
/* ... */
})
export class PasswordInput implements FormValueControl<string> {
readonly value = model<string>('');
readonly passwordCtrl = viewChild.required<ElementRef<HTMLInputElement>>('passwordCtrl');
// 当对关联该自定义控件的字段状态调用
// focusBoundControl() 时会被自动调用
focus(): void {
this.passwordCtrl().nativeElement.focus();
}
}
源码中这一机制的落点是 FormFieldBinding 接口:每个绑定记录 element、injector、state 与 focus(options?) 方法,注释说明默认聚焦 element,但 custom controls can implement their own focus behavior;FieldNode.getBindingForFocus 则展示了选择策略——优先选择自身绑定中实现了 focus 且位于 DOM 最靠前者,若自身没有可聚焦绑定,则回退到后代节点中第一个可聚焦的绑定。焦点相关行为可由 focus.spec.ts 的测试用例验证。
相关文档与源码入口
本文覆盖了验证与可用性状态处理、交互追踪、字段状态传播、提交与重置、样式绑定及程序化聚焦。Signal Forms 的其他方面请参考仓库中的相关指南:
关键源码索引(便于深入实现细节):
| 主题 | 路径 |
|---|---|
FieldState/ReadonlyFieldState 接口与全部字段信号类型 |
packages/forms/signals/src/api/types.ts |
字段节点实现(markAsTouched、reset、focusBoundControl、value 控制) |
packages/forms/signals/src/field/node.ts |
touched/dirty/disabled/hidden/readonly 的聚合计算 |
packages/forms/signals/src/field/state.ts |
三态验证状态机(valid/invalid/pending/errorSummary) |
packages/forms/signals/src/field/validation.ts |
disabled 规则(含禁用原因机制) |
packages/forms/signals/src/api/rules/disabled.ts |
FormField 指令(自动绑定 disabled/readonly/required 等属性) |
packages/forms/signals/src/directive/form_field.ts |
| 验证状态测试 | packages/forms/signals/test/node/validation_status.spec.ts |
| 焦点行为测试 | packages/forms/signals/test/web/focus.spec.ts |
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00