首页
/ Angular Signal Forms 表单模型详解:用 signal() 构建双向同步的 Form Model

Angular Signal Forms 表单模型详解:用 signal() 构建双向同步的 Form Model

2026-09-06 17:36:06作者:虞亚竹Luna

本文基于 Angular 仓库中的官方指南 Form models@angular/forms/signals 的源码实现,系统讲解 Signal Forms 的核心概念——表单模型(Form Model):如何用 signal() 创建模型、如何通过 form() 获得 FieldTree、字段级与模型级的读写方式、双向数据绑定的数据流,以及扁平/嵌套/数组三种模型结构模式的选择策略。读完本文,你将能够设计可维护的表单模型,并理解模型与字段状态在底层是如何保持同步的。

表单模型解决什么问题

表单需要管理随时间变化的数据。如果没有清晰的结构,数据会散落在各个组件属性中,难以跟踪变更、校验输入或向服务器提交数据。

表单模型通过把所有表单数据集中到一个**可写信号(writable signal)**中来解决这个问题。模型更新时,表单自动反映这些变更;用户与表单交互时,模型也随之更新。换句话说,模型是表单数据的唯一事实来源(single source of truth)。

需要注意一个常见混淆:表单模型与 Angular 用于组件双向绑定的 model() 信号是不同的东西。表单模型是一个存储表单数据、可以被表单写入的可写信号;而 model() 是为父/子组件通信创建的输入/输出。

创建表单模型

表单模型是一个用 Angular 的 signal() 函数创建的可写信号,信号中保存一个代表表单数据结构对象。

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

@Component({
  selector: 'app-login',
  imports: [FormField],
  template: `
    <input type="email" [formField]="loginForm.email" />
    <input type="password" [formField]="loginForm.password" />
  `,
})
export class LoginComponent {
  loginModel = signal({
    email: '',
    password: '',
  });

  loginForm = form(this.loginModel);
}

其中 form() 接受模型信号并创建一个 field tree(字段树)——一种镜像模型形状的特殊对象结构。字段树既是可导航的(用点号访问子字段,如 loginForm.email),又是可调用的(把字段当函数调用,访问其状态)。

\[formField\] 指令把每个输入元素绑定到字段树中对应的字段,实现 UI 与模型之间自动的双向同步。

form() 的源码级实现

从源码结构看,form() 并不复制数据。其 JSDoc 明确声明:“form uses the given model as the source of truth and does not maintain its own copy of the data. This means that updating the value on a FieldState updates the originally passed in model as well.”

实现主体 的关键步骤是:

  1. normalizeFormArgs 归一化参数(模型、可选的 schema、可选的 options);
  2. 通过 runInInjectionContext 编译 schema,得到根路径节点;
  3. 创建 FormFieldManager 与默认的 BasicFieldAdapter
  4. 调用 FieldNode.newRoot(fieldManager, model, pathNode, adapter) 把模型信号直接交给根字段节点,最终返回 fieldRoot.fieldTree

也就是说,模型信号本身就是表单状态的数据底座,字段树只是叠加在其上的响应式访问层。form() 还支持第二、第三个可选参数:schema(以函数或 schema() 定义的规则声明校验、禁用等逻辑)以及 FormOptions(注入器、表单名、提交配置等),但模型创建本身只依赖第一个参数。

支持的模型结构

Signal Forms 通过遍历(walk)模型来构建字段树。它遍历的对象和数组(即结构层)必须是纯 JavaScript 对象和数组;而位于叶子位置(没有嵌套字段的位置)的值通常是基本类型(字符串、数字、布尔值)或 null。原生的 datemonthtimeweek 输入还接受 Date 类型,自定义控件则可以接受它们能理解的任意值类型。

interface UserFormModel {
  name: string;
  birthday: Date | null;
  preferences: {
    theme: string;
    notifications: boolean;
  };
  tags: string[];
}

const userModel = signal<UserFormModel>({
  name: '',
  birthday: null,
  preferences: {
    theme: 'dark',
    notifications: true,
  },
  tags: [],
});

类实例、Map、Set 在结构层不受支持

类实例、MapSet 不支持放在结构层,尽管 TypeScript 编译会接受它们。Signal Forms 不在运行时校验模型形状,因此框架接受这些值而不抛错,随后会根据不同形状以不同方式产生错误行为:

  • 类实例会在第一次写入后丢失原型,因为 Signal Forms 更新时会浅拷贝父对象。方法、getter 和 instanceof 检查在那之后都会失效。这一点可以从源码得到印证:字段级写入通过 deepSignal 完成,其 valueForWrite 函数对对象一律执行 {...sourceValue, [prop]: newPropValue} 展开——这正是浅拷贝,类原型在此丢失。
  • 数组中的不可扩展或冻结对象会在 Signal Forms 尝试分配一个跟踪符号(tracking symbol,用于在数组重排序时保持元素身份)时抛出异常。
  • MapSet 会产生空的字段树,因为 Signal Forms 使用 Object.keys 枚举子字段——字段结构遍历代码for (const key of Object.keys(value)) 这一行就是根源,而 Map/Set 的可枚举自身属性为空。

如果应用使用类做领域建模,请在表单边界处把领域对象翻译成纯对象。可参考同系列的 模型设计指南 中关于表单模型与领域模型互转的章节。

使用 TypeScript 类型

TypeScript 虽然能从对象字面量推断类型,但显式定义类型能提升代码质量并改善 IntelliSense 体验。

interface LoginData {
  email: string;
  password: string;
}

export class LoginComponent {
  loginModel = signal<LoginData>({
    email: '',
    password: '',
  });

  loginForm = form(this.loginModel);
}

有了显式类型,字段树提供完整的类型安全:访问 loginForm.email 的类型是 FieldTree<string>,访问不存在的属性会直接编译报错。

// TypeScript knows this is FieldTree<string>
const emailField = loginForm.email;

// TypeScript error: Property 'username' does not exist
const usernameField = loginForm.username;

这种类型推导来自 FieldTree 类型定义:它对 TModel 做条件分发——数组映射为类只读数组的字段访问器,Record 映射为 Subfields(对每个键包装 MaybeFieldTree),而叶子值则退化为仅可调用访问 FieldState 的对象。Subfields 上还内置了 [Symbol.iterator],因此字段树在运行时是可迭代的。

初始化所有字段

表单模型应为希望包含在字段树中的所有字段提供初始值。

// Good: All fields initialized
const userModel = signal({
  name: '',
  email: '',
  age: 0,
});
// Avoid: Missing initial value
const userModel = signal({
  name: '',
  email: '',
  // age field is not defined - cannot access userForm.age
});

对于可选字段,显式设置为空值或 null

interface UserData {
  name: string;
  email: string;
  phoneNumber: string | null;
}

const userModel = signal<UserData>({
  name: '',
  email: '',
  phoneNumber: null,
});

注意:像 <input type="text"><textarea> 这类原生文本控件不支持 null,请用 '' 表示空值。

设置为 undefined 的字段会被排除出字段树。一个值为 {value: undefined} 的模型与 {} 行为完全一致——访问该字段返回 undefined 而非 FieldTree。这一点在 结构遍历源码 中有直接对应:遇到 childValue === undefined 时,框架不仅跳过该键,还会把此前已存在的同名字段从子字段映射中删除,从而保证动态结构下“字段消失”的语义正确。

读取模型值

获取表单值有两种方式:直接从模型信号读取,或通过各个字段读取。两种方式服务于不同目的。

从模型读取

当你需要完整表单数据时(如表单提交期间),直接访问模型信号:

async onSubmit() {
  const formData = this.loginModel();
  console.log(formData.email, formData.password);

  // Send to server
  await this.authService.login(formData);
}

模型信号返回整个数据对象,非常适合处理完整表单状态的操作。

从字段状态读取

字段树中的每个字段都是一个函数。调用字段会返回一个 FieldState 对象,其中包含该字段值、校验状态与交互状态的响应式信号。

在模板或响应式计算中处理单个字段时,访问字段状态:

@Component({
  template: `
    <p>Current email: {{ loginForm.email().value() }}</p>
    <p>Password length: {{ passwordLength() }}</p>
  `,
})
export class LoginComponent {
  loginModel = signal({email: '', password: ''});
  loginForm = form(this.loginModel);

  passwordLength = computed(() => {
    return this.loginForm.password().value().length;
  });
}

字段状态为每个字段的值提供了响应式信号,适合展示字段级信息或派生状态。FieldStatevalue() 外还包含许多信号:校验状态(如 validinvaliderrors)、交互跟踪(如 toucheddirty)、可见性(如 hiddendisabled)等,完整清单可在 FieldState 接口字段状态管理指南 中查阅。

编程方式更新表单模型

用 set() 整体替换模型

在表单模型上调用 set() 替换整个值:

loadUserData() {
  this.userModel.set({
    name: 'Alice',
    email: 'alice@example.com',
    age: 30,
  });
}

resetForm() {
  this.userModel.set({
    name: '',
    email: '',
    age: 0,
  });
}

这种模式在从 API 加载数据或重置整个表单时非常好用。

用 set() 或 update() 直接更新单个字段

在单个字段的值上调用 set(),可直接更新字段状态:

clearEmail() {
  this.userForm.email().value.set('');
}

incrementAge() {
  this.userForm.age().value.update(currentAge => currentAge + 1);
}

这些也被称为“字段级更新”,它们会自动传播回模型信号,让两者保持同步。其底层机制是 deepSignal:子字段的 value 是派生自父信号的可写信号,read.set 内部调用 source.update(...),通过 valueForWrite 生成新的纯对象(或新数组)写回源模型——写路径上的 Object.is 判断还能避免无变化写入触发多余通知。

示例:从 API 加载数据

一个常见模式是拉取数据并填充模型:

export class UserProfileComponent {
  userModel = signal({
    name: '',
    email: '',
    bio: '',
  });

  userForm = form(this.userModel);
  private userService = inject(UserService);

  ngOnInit() {
    this.loadUserProfile();
  }

  async loadUserProfile() {
    const userData = await this.userService.getUserProfile();
    this.userModel.set(userData);
  }
}

当模型变化时,表单字段会自动更新,展示拉取到的数据,无需任何额外代码。

双向数据绑定

[formField] 指令在模型、表单状态与 UI 之间建立自动的双向同步。

数据流如何运作

变更是双向流动的:

用户输入 → 模型:

  1. 用户在输入元素中输入
  2. [formField] 指令检测到变更
  3. 字段状态更新
  4. 模型信号更新

编程更新 → UI:

  1. 代码用 set()update() 更新模型
  2. 模型信号通知订阅者
  3. 字段状态更新
  4. [formField] 指令更新输入元素

这种同步是自动完成的,你不需要编写任何订阅或事件处理器来保持模型与 UI 一致。

从源码看,FormField 指令 的文档注释列出了它的四项职责:双向绑定字段状态的值与 UI 控件的值;把字段状态上与表单相关的状态(disabled、required 等)绑定到 UI 控件;把控件上的相关事件(如 blur 时标记 touched)转发到字段状态;为与响应式表单生态互操作提供一个假的 NgControl。指令通过 field = input.required<Field<T>>({alias: 'formField'}) 接收字段,并用 state = computed(() => this.field()()) 派生出字段状态。

示例:双向都走一遍

@Component({
  template: `
    <input type="text" [formField]="userForm.name" />
    <button (click)="setName('Bob')">Set Name to Bob</button>
    <p>Current name: {{ userModel().name }}</p>
  `,
})
export class UserComponent {
  userModel = signal({name: ''});
  userForm = form(this.userModel);

  setName(name: string) {
    this.userForm.name().value.set(name);
    // Input automatically displays 'Bob'
  }
}

当用户在输入框中打字时,userModel().name 会更新;点击按钮时,输入框的值变为 “Bob”。无需任何手动同步代码。

模型结构模式

表单模型可以是扁平对象,也可以包含嵌套对象和数组。你选择的结构会影响字段访问方式与校验的组织方式。

扁平模型 vs 嵌套模型

扁平表单模型把所有字段放在顶层:

// Flat structure
const userModel = signal({
  name: '',
  email: '',
  street: '',
  city: '',
  state: '',
  zip: '',
});

嵌套模型把相关字段分组:

// Nested structure
const userModel = signal({
  name: '',
  email: '',
  address: {
    street: '',
    city: '',
    state: '',
    zip: '',
  },
});

适合使用扁平结构的场景:

  • 字段之间没有清晰的语义分组
  • 希望字段访问更简单(userForm.city 对比 userForm.address.city
  • 校验规则横跨多个潜在分组

适合使用嵌套结构的场景:

  • 字段构成清晰的语义组(如地址)
  • 分组数据与 API 的结构一致
  • 希望把整组数据作为一个单元来校验

处理嵌套对象

可以沿着对象路径访问嵌套字段:

const userModel = signal({
  profile: {
    firstName: '',
    lastName: '',
  },
  settings: {
    theme: 'light',
    notifications: true,
  },
});

const userForm = form(userModel);

// Access nested fields
userForm.profile.firstName; // FieldTree<string>
userForm.settings.theme; // FieldTree<string>

在模板中,嵌套字段与顶层字段的绑定方式完全相同:

@Component({
  template: `
    <input [formField]="userForm.profile.firstName" />
    <input [formField]="userForm.profile.lastName" />

    <select [formField]="userForm.settings.theme">
      <option value="light">Light</option>
      <option value="dark">Dark</option>
    </select>
  `,
})

从源码看,嵌套访问之所以可行,是因为每个子字段的 value 都通过 deepSignal(父值信号, 键信号) 派生(见 ChildFieldNodeStructure 构造),因此任意深度的路径访问最终都落回到根模型信号上的一次读取/一次写回。

处理数组

模型中可以包含数组来表示条目集合:

const orderModel = signal({
  customerName: '',
  items: [{product: '', quantity: 0, price: 0}],
});

const orderForm = form(orderModel);

// Access array items by index
orderForm.items[0].product; // FieldTree<string>
orderForm.items[0].quantity; // FieldTree<number>

包含对象的数组项会自动获得跟踪身份(tracking identity),帮助在数组项改变位置时保持字段状态。这确保了数组重排序时,校验状态与用户交互能正确保留。

这一机制在 结构源码 中清晰可见:当父级是数组且子项是对象时,框架为每个子项分配一个合成 Symbol 作为 trackingKeychildValue[identitySymbol] ??= Symbol('id:…')),并用 byTrackingKey 映射 保证字段实例与其跟踪键 1:1 绑定、即使位置变化也保持稳定;移除项时由 maybeRemoveStaleArrayFields 依据跟踪键做差量清理。这也正是前文提到的限制来源:若数组项是不可扩展/冻结对象,无法写入该跟踪符号,就会抛错。

小结与延伸阅读

Signal Forms 的模型层可以归纳为三条主线:

  • 模型即唯一事实来源form() 不复制数据,字段树的每次写操作最终都通过浅拷贝写回模型信号(deepSignalvalueForWrite),读操作则沿派生信号链回到模型;
  • 结构层必须纯净:纯对象与纯数组是字段树遍历的边界,Object.keys 枚举 + 浅拷贝写回决定了类实例、Map/Set 等不能进入结构层,undefined 字段会被显式剔除;
  • 数组身份有底层保障:数组内对象项靠合成跟踪符号保持字段身份,重排序不丢失 touched/dirty 与校验状态。

本文覆盖的是模型创建与更新。Signal Forms 指南的其余部分(位于 adev/src/content/guide/forms/signals 目录)可作为后续学习路径:

所有 API 符号(formapplyEachapplyapplyWhenschemasubmitFormField 等)均从 @angular/forms/signals 的 public_api 统一导出,可在仓库的 packages/forms/signals 目录中进一步查看实现与测试。

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