Angular Signal Forms 表单模型详解:用 signal() 构建双向同步的 Form Model
本文基于 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.”
实现主体 的关键步骤是:
- 用
normalizeFormArgs归一化参数(模型、可选的 schema、可选的 options); - 通过
runInInjectionContext编译 schema,得到根路径节点; - 创建
FormFieldManager与默认的BasicFieldAdapter; - 调用
FieldNode.newRoot(fieldManager, model, pathNode, adapter)把模型信号直接交给根字段节点,最终返回fieldRoot.fieldTree。
也就是说,模型信号本身就是表单状态的数据底座,字段树只是叠加在其上的响应式访问层。form() 还支持第二、第三个可选参数:schema(以函数或 schema() 定义的规则声明校验、禁用等逻辑)以及 FormOptions(注入器、表单名、提交配置等),但模型创建本身只依赖第一个参数。
支持的模型结构
Signal Forms 通过遍历(walk)模型来构建字段树。它遍历的对象和数组(即结构层)必须是纯 JavaScript 对象和数组;而位于叶子位置(没有嵌套字段的位置)的值通常是基本类型(字符串、数字、布尔值)或 null。原生的 date、month、time 和 week 输入还接受 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 在结构层不受支持
类实例、Map、Set 不支持放在结构层,尽管 TypeScript 编译会接受它们。Signal Forms 不在运行时校验模型形状,因此框架接受这些值而不抛错,随后会根据不同形状以不同方式产生错误行为:
- 类实例会在第一次写入后丢失原型,因为 Signal Forms 更新时会浅拷贝父对象。方法、getter 和
instanceof检查在那之后都会失效。这一点可以从源码得到印证:字段级写入通过 deepSignal 完成,其valueForWrite函数对对象一律执行{...sourceValue, [prop]: newPropValue}展开——这正是浅拷贝,类原型在此丢失。 - 数组中的不可扩展或冻结对象会在 Signal Forms 尝试分配一个跟踪符号(tracking symbol,用于在数组重排序时保持元素身份)时抛出异常。
Map和Set会产生空的字段树,因为 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;
});
}
字段状态为每个字段的值提供了响应式信号,适合展示字段级信息或派生状态。FieldState 除 value() 外还包含许多信号:校验状态(如 valid、invalid、errors)、交互跟踪(如 touched、dirty)、可见性(如 hidden、disabled)等,完整清单可在 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 之间建立自动的双向同步。
数据流如何运作
变更是双向流动的:
用户输入 → 模型:
- 用户在输入元素中输入
[formField]指令检测到变更- 字段状态更新
- 模型信号更新
编程更新 → UI:
- 代码用
set()或update()更新模型 - 模型信号通知订阅者
- 字段状态更新
[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 作为 trackingKey(childValue[identitySymbol] ??= Symbol('id:…')),并用 byTrackingKey 映射 保证字段实例与其跟踪键 1:1 绑定、即使位置变化也保持稳定;移除项时由 maybeRemoveStaleArrayFields 依据跟踪键做差量清理。这也正是前文提到的限制来源:若数组项是不可扩展/冻结对象,无法写入该跟踪符号,就会抛错。
小结与延伸阅读
Signal Forms 的模型层可以归纳为三条主线:
- 模型即唯一事实来源:
form()不复制数据,字段树的每次写操作最终都通过浅拷贝写回模型信号(deepSignal的valueForWrite),读操作则沿派生信号链回到模型; - 结构层必须纯净:纯对象与纯数组是字段树遍历的边界,
Object.keys枚举 + 浅拷贝写回决定了类实例、Map/Set等不能进入结构层,undefined字段会被显式剔除; - 数组身份有底层保障:数组内对象项靠合成跟踪符号保持字段身份,重排序不丢失 touched/dirty 与校验状态。
本文覆盖的是模型创建与更新。Signal Forms 指南的其余部分(位于 adev/src/content/guide/forms/signals 目录)可作为后续学习路径:
- Field state management:字段状态的完整语义(touched、dirty、valid、errors 等);
- Validation:校验规则与 schema 的声明方式;
- Custom controls:用
FormValueControl等接口编写自定义控件; - Form submission:
submit()与服务器错误的字段级回写; - Model design:表单模型与领域模型的边界转换设计。
所有 API 符号(form、applyEach、apply、applyWhen、schema、submit、FormField 等)均从 @angular/forms/signals 的 public_api 统一导出,可在仓库的 packages/forms/signals 目录中进一步查看实现与测试。
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 StartedRust0623
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