首页
/ Angular 表单三方案对比:Signal Forms、Reactive Forms 与 Template-Driven Forms 的选型与实践

Angular 表单三方案对比:Signal Forms、Reactive Forms 与 Template-Driven Forms 的选型与实践

2026-09-06 16:57:58作者:翟萌耘Ralph

本篇基于 Angular 仓库中的官方对比文档 Comparison with other form approaches,系统梳理 Angular 三种表单方案(Signal Forms、Reactive Forms、Template-Driven Forms)在数据来源、验证机制与类型安全上的本质差异。文中以一个登录表单为例给出三种方案的完整可运行实现,并深入 packages/forms/signals 源码印证 Signal Forms 的 form() API、schema 编译与内置校验器实现,帮助你在新项目选型和存量代码维护中做出有据可依的决策。

三种表单方案的快速对比

Angular 提供三种构建表单的方案:Signal Forms、Reactive Forms 与 Template-Driven Forms。每种方案在状态管理、验证与数据流上采用了不同的设计模式。官方给出的特性对比如下:

特性 Signal Forms Reactive Forms Template-Driven Forms
数据源(Source of truth) 用户定义的可写 signal 模型 FormControl/FormGroup 组件内的用户模型
类型安全 从模型推断 显式的类型化表单(typed forms) 最弱
验证 Schema,基于字段路径绑定校验器 传给 Control 的校验器列表 基于指令属性
状态管理 基于 Signal 基于 Observable Angular 托管
搭建方式 Signal + schema 函数 FormControl 树 模板中的 NgModel
最佳适用场景 Signal 化应用 复杂表单 简单表单
学习曲线 中等 中偏高
状态 Stable(v22+) Stable Stable

从源码可以印证 Signal Forms 的版本定位:form() API 在 structure.ts 的 JSDoc 中标注为 @publicApi 22.0,即 Angular v22 起作为稳定公共 API 提供,与对比表中 “Stable (v22+)” 的结论一致。

以登录表单为例:同一表单的三种实现

理解差异的最好方式,是把同一个表单用三种方案各写一遍。以下代码完整取自仓库中的官方示例 signal-forms.tsreactive-forms.tstemplate-driven-forms.ts

Signal Forms 实现

import {Component, signal} from '@angular/core';
import {email, form, FormField, minLength, required} from '@angular/forms/signals';

@Component({
  selector: 'app-login',
  imports: [FormField],
  template: `
    <form (submit)="onSubmit()">
      <div>
        <label>
          Email
          <input type="email" [formField]="loginForm.email" />
        </label>
        @if (loginForm.email().touched() && loginForm.email().invalid()) {
          <span class="error">
            {{ loginForm.email().errors()[0].message }}
          </span>
        }
      </div>

      <div>
        <label>
          Password
          <input type="password" [formField]="loginForm.password" />
        </label>
        @if (loginForm.password().touched() && loginForm.password().invalid()) {
          <span class="error">
            {{ loginForm.password().errors()[0].message }}
          </span>
        }
      </div>

      <button type="submit" [disabled]="loginForm().invalid()">Sign In</button>
    </form>
  `,
})
export class LoginComponent {
  loginModel = signal({
    email: '',
    password: '',
  });

  loginForm = form(this.loginModel, (fieldPath) => {
    required(fieldPath.email, {message: 'Email is required'});
    email(fieldPath.email, {message: 'Enter a valid email address'});

    required(fieldPath.password, {message: 'Password is required'});
    minLength(fieldPath.password, 8, {message: 'Password must be at least 8 characters'});
  });

  onSubmit() {
    if (this.loginForm().valid()) {
      const credentials = this.loginModel();
      console.log('Submitting:', credentials);
    }
  }
}

要点解读:

  • 数据模型 loginModel 是一个普通可写 signal,form() 以它作为唯一数据源;
  • 模板绑定使用 [formField] 输入(FormField 指令定义于 form_field.ts),字段访问 loginForm.email() 返回 FieldState,再调用 .touched().invalid().errors() 等 signal 读取状态;
  • 错误信息直接从 errors()[0].message 读取——消息是验证定义的一部分,无需在模板中再做映射;
  • 校验器 requiredemailminLength 均以“字段路径 + 选项”的方式在 schema 函数中集中声明。

Reactive Forms 实现

import {Component} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';

@Component({
  selector: 'app-login',
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="loginForm" (submit)="onSubmit()">
      <div>
        <label>
          Email
          <input type="email" formControlName="email" />
        </label>
        @if (loginForm.controls.email.touched && loginForm.controls.email.invalid) {
          <span class="error">
            @if (loginForm.controls.email.errors?.['required']) {
              Email is required
            }
            @if (loginForm.controls.email.errors?.['email']) {
              Enter a valid email address
            }
          </span>
        }
      </div>

      <div>
        <label>
          Password
          <input type="password" formControlName="password" />
        </label>
        @if (loginForm.controls.password.touched && loginForm.controls.password.invalid) {
          <span class="error">
            @if (loginForm.controls.password.errors?.['required']) {
              Password is required
            }
            @if (loginForm.controls.password.errors?.['minlength']) {
              Password must be at least 8 characters
            }
          </span>
        }
      </div>

      <button type="submit" [disabled]="loginForm.invalid">Sign In</button>
    </form>
  `,
})
export class LoginComponent {
  loginForm = new FormGroup({
    email: new FormControl('', [Validators.required, Validators.email]),
    password: new FormControl('', [Validators.required, Validators.minLength(8)]),
  });

  onSubmit() {
    if (this.loginForm.valid) {
      const credentials = this.loginForm.value;
      console.log('Submitting:', credentials);
    }
  }
}

要点解读:

  • 数据保存在 FormControl/FormGroup 内部,通过 loginForm.value 读取整表值;
  • 校验器以数组形式传给每个 FormControl 的第二个参数;
  • 错误对象使用约定的键名(requiredemailminlength),因此模板中需要按 errors?.['required'] 这样的键来分发不同的错误文案——错误消息与验证定义是分离的。

Template-Driven Forms 实现

import {Component} from '@angular/core';
import {FormsModule} from '@angular/forms';

@Component({
  selector: 'app-login',
  imports: [FormsModule],
  template: `
    <form #loginForm="ngForm" (submit)="onSubmit()">
      <div>
        <label>
          Email
          <input
            type="email"
            name="email"
            [(ngModel)]="email"
            #emailInput="ngModel"
            required
            email
          />
        </label>
        @if (emailInput.touched && emailInput.invalid) {
          <span class="error">
            @if (emailInput.errors?.['required']) {
              Email is required
            }
            @if (emailInput.errors?.['email']) {
              Enter a valid email address
            }
          </span>
        }
      </div>

      <div>
        <label>
          Password
          <input
            type="password"
            name="password"
            [(ngModel)]="password"
            #passwordInput="ngModel"
            required
            minlength="8"
          />
        </label>
        @if (passwordInput.touched && passwordInput.invalid) {
          <span class="error">
            @if (passwordInput.errors?.['required']) {
              Password is required
            }
            @if (passwordInput.errors?.['minlength']) {
              Password must be at least 8 characters
            }
          </span>
        }
      </div>

      <button type="submit" [disabled]="loginForm.invalid">Sign In</button>
    </form>
  `,
})
export class LoginComponent {
  email = '';
  password = '';

  onSubmit() {
    const credentials = {
      email: this.email,
      password: this.password,
    };
    console.log('Submitting:', credentials);
  }
}

要点解读:

  • 数据直接保存在组件属性 emailpassword 中,ngModel 双向绑定组件属性;
  • 验证规则通过模板属性声明:requiredemail(布尔属性)与 minlength="8"(值属性);
  • 每个带 name 的输入必须声明 name 属性,ngForm 才能将其纳入表单;
  • 组件中的 onSubmit() 需要手工拼装 {email, password} 对象。

理解差异:三个根本性的设计选择

三种方案在“表单状态存在哪里”和“验证如何管理”上做出了不同取舍,这直接决定了你编写和维护表单的方式。

表单数据存放在哪里

最根本的差异在于每种方案对表单值“数据源”(source of truth)的认定。

Signal Forms 把数据存在可写 signal 中。 需要当前表单值时,直接调用 signal:

const credentials = this.loginModel(); // { email: '...', password: '...' }

表单数据被放在单一的响应式容器里,值变化时自动通知 Angular。这与源码中的设计声明一致——form() 的 JSDoc 明确写道:form 使用传入的 model 作为 source of truth,并不维护自己的数据副本,因此在 FieldState 上更新值会同步写回原始 model。表单结构(FieldTree)与数据模型结构一一对应。

Reactive Forms 把数据存在 FormControl/FormGroup 实例内部。 通过表单层级访问值:

const credentials = this.loginForm.value; // { email: '...', password: '...' }

这种设计把表单状态管理与组件的数据模型分离开:表单结构是显式的,但需要更多的搭建代码(创建 control 树、绑定 formControlName 等)。

Template-Driven Forms 把数据存在组件属性里。 直接访问:

const credentials = {email: this.email, password: this.password};

这是最直接的方式,但每次需要完整值时都要手工拼装。Angular 通过模板中的指令(ngModelngForm)管理表单状态。

验证是如何工作的

每种方案定义验证规则的方式不同,决定了验证逻辑放在哪里、如何维护。

Signal Forms 使用 schema 函数,把校验器绑定到字段路径上:

loginForm = form(this.loginModel, (fieldPath) => {
  required(fieldPath.email, {message: 'Email is required'});
  email(fieldPath.email, {message: 'Enter a valid email address'});
});

所有验证规则集中在一处。schema 函数在表单创建时执行一次(源码中即 SchemaImpl.rootCompile(schema) 对 schema 的编译过程,见 structure.ts),校验器随后在字段值变化时自动执行。错误消息本身就是验证定义的一部分(如 message: '...'),在模板中通过 errors()[0].message 直接读取。

Reactive Forms 在创建 control 时挂载校验器:

loginForm = new FormGroup({
  email: new FormControl('', [Validators.required, Validators.email]),
});

校验器绑定在表单结构中各个独立 control 上,验证逻辑因此分散在整个表单定义里;错误消息通常放在模板中(如示例里按 errors?.['required'] 分支展示)。

Template-Driven Forms 使用模板中的指令属性:

<input [(ngModel)]="email" required email />

验证规则与 HTML 一起写在模板里,离 UI 最近,但逻辑被拆分在模板与组件之间,规模化维护成本更高。

补充一点来自仓库的边界信息:Signal Forms 的 PACKAGE.md 列出了当前尚不支持的能力——验证防抖(debouncing validation)、动态对象(Dynamic objects)与元组(Tuples)。在评估用 Signal Forms 重构存量复杂表单时,这三项是明确的适用前提限制。

类型安全与自动补全

三种方案的 TypeScript 集成程度差异显著,直接影响编译器帮你规避错误的多寡。

Signal Forms 从模型结构推断类型:

const loginModel = signal({email: '', password: ''});
const loginForm = form(loginModel);
// TypeScript knows: loginForm.email exists and returns FieldState<string>

你在 signal 中定义一次数据形状,TypeScript 即自动知道存在哪些字段及其类型。访问不存在的 loginForm.username 会直接产生类型错误。这源于 form() 的重载签名(structure.tsform<TModel>(model: WritableSignal<TModel>): FieldTree<TModel>),字段树类型与模型类型严格对应。

Reactive Forms 使用 typed forms 时需要显式类型标注:

const loginForm = new FormGroup({
  email: new FormControl<string>(''),
  password: new FormControl<string>(''),
});
// TypeScript knows: loginForm.controls.email is FormControl<string>

每个 control 的类型需要单独指定。TypeScript 会校验表单结构,但类型信息与数据模型分离维护,两处定义存在漂移风险。

Template-Driven Forms 类型安全最弱:

email = '';
password = '';
// TypeScript only knows these are strings, no form-level typing

TypeScript 只知道组件属性是字符串,对表单结构与验证一无所知,表单操作层面的编译期检查基本缺失。

深入源码:Signal Forms 的关键实现

如果你选择 Signal Forms,以下仓库路径是继续深入的最佳入口,它们共同构成 @angular/forms/signals 包(入口 public_api.ts):

  • 表单结构构建structure.ts 提供 form() 的三组重载——仅传 model、model + schema/options、model + schema + options。实现上先经 normalizeFormArgs 归一参数,再用 SchemaImpl.rootCompile(schema) 在注入上下文中编译 schema 为路径节点树(pathNode),随后由 FormFieldManager + FieldNode.newRoot(...) 生成字段树,并注册字段管理 effect 将输入变化写回 model。
  • 内置校验器rules/validation/ 目录与 Reactive Forms 的 Validators 基本对位,提供 requiredemailminLengthmaxLengthminmaxminDatemaxDatepattern,以及更通用的 validate(同步)、validate_async(异步)、validate_httpvalidate_tree 等规则函数,还有 standard_schema 供组合使用。相比 Reactive Forms 的 Validators.xxx 静态工厂,这里每个校验器都是“路径 + 参数 + 选项”的函数调用,错误消息随定义一起声明。
  • 模板绑定form_field.ts 中的 FormField 指令即示例中 [formField]="loginForm.email" 背后实现的通用字段绑定指令,同目录还有针对 select、native 输入等的专用指令。
  • 与旧 API 的互操作compat/ 目录提供 Signal Forms 与既有 @angular/forms API(如 FormControl 互操作层)之间的兼容适配,这也是 PACKAGE.md 中 “interoperates with the existing @angular/forms APIs” 声明的落点。
  • 行为验证test/ 下包含 form.spec.tsfield_proxy.spec.tsvalidation_status.spec.tsreactive_fvc.spec.ts(与 Reactive FormControl 互操作)等测试,覆盖了“字段树代理访问”“验证状态”“双向互操作”等本文涉及的核心行为,可作为断言依据。

如何选择

以下是官方文档给出的选型建议:

选 Signal Forms,如果:

  • 你在构建新的 Signal 化应用(Angular v22+);
  • 希望类型安全从模型结构自动推断;
  • 认同基于 schema 的集中式验证;
  • 团队熟悉 signals。

选 Reactive Forms,如果:

  • 你需要久经生产验证的稳定性;
  • 在构建复杂、动态的表单;
  • 偏好基于 Observable 的模式;
  • 需要对表单状态进行细粒度控制;
  • 正在维护既有的 Reactive Forms 代码库。

选 Template-Driven Forms,如果:

  • 你在做简单表单(登录、联系、搜索);
  • 处于快速原型阶段;
  • 表单逻辑简单直接;
  • 偏好把表单逻辑留在模板里;
  • 正在维护既有的 Template-Driven 代码库。

下一步

登录后查看全文
热门项目推荐
相关项目推荐