返回首页

Angular 21 信号表单:架构与类型

本文详述 Angular 21 中的信号表单:架构原语 FieldTree 和 FieldState、类型优势、元素状态管理 (Hidden/Disabled/Readonly)、内置 Debounce 和通过 ValidateTree 的跨字段验证。

Angular 21:无抽象的信号表单
Advertisement 728x90

Angular 21 信号表单:Signal API 如何在架构层面革新表单管理

Angular 21 引入了信号表单——这不仅是一项新工具,更是表单状态管理范式的根本性变革。这不是对响应式表单的简单演进,而是通过 Signal API 的全新视角进行重构:模型与 UI 直接同步、严格类型化且不失上下文、针对表单元素的声明式状态管理,以及内置的防抖和跨字段验证支持。尽管其功能仍处于实验阶段,但其架构级解决方案已为对可预测性和可维护性要求极高的企业级应用指明了方向。

架构基础:FieldTree 和 FieldState 取代 FormGroup/FormControl

信号表单摒弃了 AbstractControl 抽象,转而采用两种严格类型的原语:

  • FieldTree<T> —— 一个以信号形式表示表单树的代理对象。每个节点都是一个 Signal<FieldState<T>>,通过 () 调用即可访问值。
  • FieldState<T> —— 特定字段的状态:值、状态(validtoucheddirty)、错误,以及管理方法(setresetmarkAsTouched)。

这消除了手动同步输入数据(@Input)与内部表单状态的必要。在响应式表单中,你需要使用 valueChanges 加上 patchValue,这不仅增加了不同步的风险,还使生命周期管理变得复杂。而在信号表单中,模型直接成为唯一的事实来源:

Google AdInline article slot
export interface LoginFormModel {
  email: string;
  password: string;
}

@Component({
  imports: [Field],
  template: `
    <form>
      Email: <input [field]="loginForm.email">
      Password: <input [field]="loginForm.password">
    </form>
  `,
})
export class LoginForm {
  login = model.required<LoginFormModel>();
  loginForm = form(this.login); // 将模型信号与表单树关联
}

在这里,this.login 是一个 WritableSignal<LoginFormModel>,而 loginForm.email() 返回一个 FieldState<string>。修改 login.set({...}) 会立即更新所有连接的 FieldState,反之亦然,通过 loginForm.email().set(...) 修改值也会更新 login()

类型化不丢失:从 AbstractControl 到精确的字段类型

关键优势在于各层级都保留了类型结构。在响应式表单中,get('nested.field') 返回的是 AbstractControl | null,从而丢失了嵌套结构的信息。而在信号表单中,路径 path.additionalInformation.firstName 的严格类型是 FieldState<string>,而 path.additionalInformation() 则是一个 FieldState<{ firstName: string; lastName: string; }>. 这使得可以安全地使用 effectcomputed 等 Signal API 原语,无需进行类型转换或检查 undefined

additionalInformation 组为例:

Google AdInline article slot
effect(() => {
  const info = this.loginForm.additionalInformation();
  console.log(info.firstName().value()); // string — 编译器完全知道
  console.log(info.lastName().touched()); // boolean
});

这种严格的类型化杜绝了经典错误:尝试对 null 调用 .value、处理嵌套 FormGroup 时出现的错误,以及在类似 get().get().value 这样的链式调用中隐含的 any 类型。

管理元素状态:hidden、disabled、readonly

信号表单提供了三种内置机制来控制可见性和交互性,每种都有其独特的语义:

  • hidden(path, condition?) —— 完全将字段从 DOM 中移除(通过 @if),并从验证树中排除。由于元素不存在于表单中,因此不会影响 touched/dirty 状态。
  • disabled(path, condition?) —— 使字段不可编辑,并将其排除在验证、touched/dirty 状态之外。
  • readonly(path, condition?) —— 阻止编辑,但保持字段在验证周期内,并允许跟踪 touched/dirty 状态。

条件通过一个接收 { valueOf } 上下文的函数来定义。例如:

Google AdInline article slot
form<{ email: string; password: string }>(
  { email: '', password: '' },
  (path) => {
    disabled(path.password, ({ valueOf }) => !valueOf(path.email));
  }
);

这等同于 password.disabled = !email.value,但它是以响应式方式执行的,确保了一致性。

防抖与验证:内置解决方案取代 RxJS

为了控制数据更新的频率,实现了 debounce 函数,可接受毫秒级超时或自定义的 Debouncer

debounce(path, (
  { valueOf },
  abortSignal
) => {
  return new Promise<void>((resolve) => {
    const timeout = setTimeout(() => resolve(), valueOf(path.email).length * 100);
    abortSignal.addEventListener('abort', () => clearTimeout(timeout));
  });
});

验证分为同步(requiredminLengthpattern)和异步(validateAsyncvalidateHttp)。特别值得一提的是对 validateTree 的支持,它允许创建跨字段规则。例如,检查密码是否匹配:

validateTree(path, (ctx) => {
  const { password, confirmPassword } = ctx.value();
  if (password !== confirmPassword) {
    return {
      field: ctx.fieldTree.confirmPassword,
      kind: 'passwordMismatch',
      message: '密码不匹配'
    };
  }
});

在这里,ctx.fieldTree 提供了对整个树中所有 FieldState 的访问,而 ctx.value() 则给出了整个表单的当前值。

重要事项

  • 信号表单并非响应式表单的替代品,而是基于 Signal API 的一次架构级重构:模型与表单现在是唯一的事实来源。
  • 各级嵌套都保留了类型信息,彻底摆脱了 AbstractControl 和手动 instanceof 检查的需要。
  • hiddendisabledreadonly 具有清晰的语义,并自动管理验证及 touched/dirty 状态。
  • debouncevalidateTree 已集成到核心中,基本场景下不再依赖 RxJS。
  • 实验性质意味着 API 可能会变化;在生产环境中使用需充分理解风险并制定迁移计划。

Angular 21 信号表单标志着迈向更可预测、类型安全且更简洁的表单管理的一步。它并未简化简单场景,却能在复杂场景中大幅降低复杂度:嵌套表单、条件字段、跨验证以及动态同步。对于中高级开发者而言,这并非“快速入门”工具,而是一种值得深入研究的架构级原语,甚至要细致到 FieldTree 的实现细节以及 effect 在表单上下文中的行为。

— Editorial Team

Advertisement 728x90

继续阅读