 构建双向同步的 Form Model)
Angular Signal Forms 表单模型详解用 signal() 构建双向同步的 Form Model【免费下载链接】angularDeliver web apps with confidence 项目地址: https://gitcode.com/GitHub_Trending/an/angular本文基于 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 typeemail [formField]loginForm.email / input typepassword [formField]loginForm.password / , }) export class LoginComponent { loginModel signal({ email: , password: , }); loginForm form(this.loginModel); }其中form()接受模型信号并创建一个field tree字段树——一种镜像模型形状的特殊对象结构。字段树既是可导航的用点号访问子字段如loginForm.email又是可调用的把字段当函数调用访问其状态。\[formField\]指令把每个输入元素绑定到字段树中对应的字段实现 UI 与模型之间自动的双向同步。form()的源码级实现从源码结构看form()并不复制数据。其 JSDoc 明确声明“formuses the given model as the source of truth anddoes notmaintain its own copy of the data. This means that updating the value on aFieldStateupdates 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 signalUserFormModel({ 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 signalLoginData({ email: , password: , }); loginForm form(this.loginModel); }有了显式类型字段树提供完整的类型安全访问loginForm.email的类型是FieldTreestring访问不存在的属性会直接编译报错。// TypeScript knows this is FieldTreestring 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 });对于可选字段显式设置为空值或nullinterface UserData { name: string; email: string; phoneNumber: string | null; } const userModel signalUserData({ name: , email: , phoneNumber: null, });注意像input typetext和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: pCurrent email: {{ loginForm.email().value() }}/p pPassword 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: aliceexample.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.requiredFieldT({alias: formField})接收字段并用state computed(() this.field()())派生出字段状态。示例双向都走一遍Component({ template: input typetext [formField]userForm.name / button (click)setName(Bob)Set Name to Bob/button pCurrent 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; // FieldTreestring userForm.settings.theme; // FieldTreestring在模板中嵌套字段与顶层字段的绑定方式完全相同Component({ template: input [formField]userForm.profile.firstName / input [formField]userForm.profile.lastName / select [formField]userForm.settings.theme option valuelightLight/option option valuedarkDark/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; // FieldTreestring orderForm.items[0].quantity; // FieldTreenumber包含对象的数组项会自动获得跟踪身份tracking identity帮助在数组项改变位置时保持字段状态。这确保了数组重排序时校验状态与用户交互能正确保留。这一机制在 结构源码 中清晰可见当父级是数组且子项是对象时框架为每个子项分配一个合成Symbol作为trackingKeychildValue[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 submissionsubmit()与服务器错误的字段级回写Model design表单模型与领域模型的边界转换设计。所有 API 符号form、applyEach、apply、applyWhen、schema、submit、FormField等均从angular/forms/signals的 public_api 统一导出可在仓库的 packages/forms/signals 目录中进一步查看实现与测试。【免费下载链接】angularDeliver web apps with confidence 项目地址: https://gitcode.com/GitHub_Trending/an/angular创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考