首页
/ Angular Signal Forms 字段状态管理:FieldState 信号体系详解与源码解析

Angular Signal Forms 字段状态管理:FieldState 信号体系详解与源码解析

2026-09-06 17:19:24作者:秋泉律Samson

Signal Forms(Angular 22 起随 @angular/forms/signals 发布的响应式表单 API)把表单的每一个状态都建模为响应式信号:验证状态(validinvaliderrorspending)、交互追踪(toucheddirty)、可用性状态(disabledhiddenreadonly)。本文完整讲解字段树(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 会把 shippingshipping.nameshipping.address 全部标记为 touched,于是子字段上的 touched() && invalid() 错误消息在整表提交前就会显现。

注意:仅当希望“被调用的字段自身变 touched 但不改变其后代 touched 状态”时才传 {skipDescendants: true}

源码印证了上述语义:markAsTouched(options?: MarkAsTouchedOptions) 的定义见 types.tsMarkAsTouchedOptions.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。

这一规则的实现位于 FieldNodeStatetoucheddirty 均为 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.),其内部对 disabledreadonlyrequiredmin/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()(或已孤儿化)时,rawSyncTreeErrorssyncErrorsrawAsyncErrors 等全部短路返回空,syncValid 直接为 truestatus 直接为 '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 信号;强制 controlValuevalue 对齐(处理“重置值等于当前值”时联动不触发的情况);调用 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 接口:每个绑定记录 elementinjectorstatefocus(options?) 方法,注释说明默认聚焦 element,但 custom controls can implement their own focus behaviorFieldNode.getBindingForFocus 则展示了选择策略——优先选择自身绑定中实现了 focus 且位于 DOM 最靠前者,若自身没有可聚焦绑定,则回退到后代节点中第一个可聚焦的绑定。焦点相关行为可由 focus.spec.ts 的测试用例验证。

相关文档与源码入口

本文覆盖了验证与可用性状态处理、交互追踪、字段状态传播、提交与重置、样式绑定及程序化聚焦。Signal Forms 的其他方面请参考仓库中的相关指南:

关键源码索引(便于深入实现细节):

主题 路径
FieldState/ReadonlyFieldState 接口与全部字段信号类型 packages/forms/signals/src/api/types.ts
字段节点实现(markAsTouchedresetfocusBoundControlvalue 控制) 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
登录后查看全文
热门项目推荐
相关项目推荐