Angular Signal Forms:让表单更易理解、构建和维护
通过用状态和推导而非编排和响应来表达表单行为,Angular Signal Forms 让表单更易于理解、构建和维护。下面让我们一探究竟。
抽象地理解响应式模型是有帮助的,但如果不了解它如何塑造实际应用代码,这种理解终究是不完整的。像状态、推导和显式依赖这些概念,只有在影响表单的实际构建、验证和维护时才有意义。在之前的两篇文章《Angular Signal Forms:从事件管道到信号驱动状态》和《Angular 信号解析:基于拉取的响应式如何改变我们对状态的建模》中,将表单行为重新定义为一个状态驱动的问题,并探讨了 Angular Signals 作为一种基于拉取的响应式模型,非常适合处理这类工作。接下来自然是将这些理念应用到实际的 Angular 表单中,观察当状态成为主要关注点时,表单架构会发生怎样的变化。
聚焦具体示例:简单注册表单
本文聚焦于一个具体示例:一个简单但真实的注册表单。这里的目标不是引入新概念,而是让之前的想法变得更具体。将看到由信号支持的模型如何重塑验证、交互状态和提交逻辑,以及当表单行为以声明式表达时,有多少协调逻辑会直接消失。这里的重点不是追求新颖性或完整性,而是让底层理念更易于理解。通过从模型定义到表单提交,逐步构建一个以信号为先的表单,可以评估这种方法是否真的降低了复杂度,以及它在哪些方面引入了新的权衡,以便团队在更广泛采用之前有所了解。
实现以信号为先的注册表单
有了概念基础,现在可以将理论转化为具体实现。在这部分,将使用 Angular 的 Signal Forms API 构建一个功能完备的注册表单。这个示例的范围有意设置得比较简单,但它将作为本系列后续内容的基础。后续每篇文章都会基于这个示例进行扩展,而不是引入新的示例。
该表单收集电子邮件地址、密码、确认密码以及对条款的明确接受。虽然表面上很简单,但这种结构让我们可以探索字段级验证、跨字段约束、交互状态和提交行为,而无需采用事件驱动的表单逻辑。
项目设置与结构
此示例假设是一个使用 Angular CLI 创建的标准 Angular 应用程序,并配置为使用 Signals(Angular 17+)。Signal Forms API(Angular 21+)位于 "@angular/forms/signals" 下,必须显式导入。
文件夹结构有意采用保守设计:
src/ app/ registration/ registration.component.ts registration.component.html registration.model.ts
将模型与组件分离,可使表单状态独立于展示层。随着表单的扩展或在多个组件中复用,这种分离的价值会愈发凸显。
定义表单模型
首先定义表单收集的数据结构。这是一个普通的 TypeScript 接口,不依赖 Angular。将表单模型视为简单的数据结构,强化了表单值只是状态的概念。
typescript// registration.model.tsexport interface RegistrationData { email: string; password: string; confirmPassword: string; acceptedTerms: boolean;}这个接口与通常发送到后端 API 的数据结构一致。没有状态的重复,没有单独的 "表单值" 对象,提交时也无需进行映射。
创建由信号支持的表单
表单本身在组件中使用可写信号作为真实数据源创建。`form()` 函数将表单语义(验证、字段状态和提交)附加到该信号上。
typescript// registration.component.tsimport { CommonModule } from "@angular/common";import { Component, signal } from "@angular/core";import { email, form, FormField, required, submit } from "@angular/forms/signals";import { RegistrationData } from "./registration.model";@Component({ selector: "app-registration", imports: [FormField, CommonModule], templateUrl: "./registration.html", styleUrls: ["./registration.css"],})export class Registration { readonly model = signal({ email: "", password: "", confirmPassword: "", acceptedTerms: false, }); readonly registrationForm = form(this.model, (schema) => { required(schema.email, { message: "Email is required" }); email(schema.email, { message: "Enter a valid email address" }); required(schema.password, { message: "Password is required" }); required(schema.confirmPassword, { message: "Please confirm your password" }); required(schema.acceptedTerms, { message: "You must accept the terms to continue" }); }); async onSubmit(event?: Event) { event?.preventDefault(); await submit(this.registrationForm, (value) => { console.log(value()); // 模拟服务器调用 return Promise.resolve([ { kind: "EmailAlreadyExists", field: this.registrationForm.email, error: { kind: "server", message: "Email already taken" }, }, ]); }); }}有几个设计决策值得注意。首先,模型信号被定义为只读。对模型的所有修改都通过表单绑定进行,而不是在组件中进行临时赋值。这使组件保持声明式,避免了以命令式方式操作表单状态的诱惑。其次,验证在一处声明。`schema` 函数描述了模型的约束,而无需引入控件树、验证器数组或可观察管道。只要模型发生变化,Angular 就会负责重新运行验证。最后,提交逻辑是显式的。`submit()` 辅助函数确保在调用回调之前表单是有效的,并直接传递当前模型值。无需检查标志或手动提取值。
将表单绑定到模板
表单定义好后,下一步是将其绑定到模板。Signal Forms 提供了 `[formField]` 指令,它将输入元素直接连接到表单架构中的字段。
htmlEmail@if (registrationForm.email().invalid() && registrationForm.email().touched()) { {{ registrationForm.email().errors()[0].message }} }Password@if (registrationForm.password().invalid() && registrationForm.password().touched()) { {{ registrationForm.password().errors()[0].message }} }Confirm Password@if (registrationForm.confirmPassword().invalid() && registrationForm.confirmPassword().touched()) { {{ registrationForm.confirmPassword().errors()[0].message }} }I accept the terms and conditions@if (registrationForm.acceptedTerms().invalid() && registrationForm.acceptedTerms().touched()) { {{ registrationForm.acceptedTerms().errors()[0].message }} }@if (registrationForm().errors().length > 0) { @for (error of registrationForm().errors(); track error.message) { {{ error.kind }} }}Register这里的突出特点是没有间接操作。每个输入直接绑定到一个字段。验证状态通过 `invalid()` 和 `touched()` 等信号访问。错误消息从结构化的错误对象中读取,而不是手动重建。这个模板没有订阅、没有异步管道,也没有处理值变化的事件处理程序。UI 只是反映当前表单状态。
交互状态与用户体验
声明式表单模型常见的批评之一是它们会掩盖用户交互逻辑。Signal Forms 通过将交互元数据作为信号公开,直接解决了这个问题。`touched()` 信号确定一个字段是否被交互过。通过将其与 `invalid()` 结合,可以控制验证消息何时显示。这种逻辑仍然是纯粹声明式的:模板描述了错误何时应该可见,Angular 确保信号保持最新。提交按钮的禁用状态由 `registrationForm.invalid()` 推导得出。无需根据事件手动启用或禁用它。如果表单变得有效,按钮会自动启用。
为何这种方式可扩展
即使在早期阶段,以信号为先的表单模型的几个优点也很明显。表单的行为用状态和推导而非事件来表达。模型、验证规则和 UI 绑定清晰分离。组件和模板之间没有逻辑重复。随着表单的扩展,这种结构依然适用。新增字段只会引入新的架构条目和模板绑定,而不会引入新的订阅逻辑。跨字段验证可以声明式添加。异步验证和持久化可以在不重写核心模型的情况下进行分层处理。最重要的是,表单仍然可检查。在执行的任何阶段,模型信号都反映表单的当前状态。派生状态(有效性、错误和 UI 标志)可以通过阅读代码来理解,而无需跟踪运行时行为。
尚未解决的问题(及原因)
在这个阶段,很容易让人觉得 Signal Forms 解决了表单处理中的大部分难题。但这种印象是误导性的。目前构建的内容有意不完整,不是因为这种方法有缺陷,而是因为过早引入过多内容会掩盖底层模型的价值。
有意推迟处理的一个领域是表达更丰富业务规则的跨字段验证。许多实际表单依赖于字段之间的关系,而不是孤立的约束。密码确认是一个常见的例子,但企业应用中很快会出现更复杂的场景。虽然 Signal Forms 支持这些模式,但在明确理解派生状态之前引入它们,可能会使验证重新变成命令式操作,而不是声明式操作。
也避免了异步验证。服务器端检查会引入延迟、部分失败、取消和竞态条件。这些问题并非微不足道,随意处理往往会导致微妙的错误和令人困惑的用户体验。虽然 Signal Forms 提供了对异步行为建模的必要钩子,但要负责任地处理这些问题,需要仔细讨论待处理状态、副作用和生命周期边界。这个讨论应该单独成文。
另一个遗漏的方面是持久化和同步。许多表单需要自动保存草稿、与本地存储同步状态,或者通过触发外部副作用来响应变化。这些行为不是表单状态本身的一部分,而是状态变化的结果。将它们视为这样的结果对于保持架构的可理解性至关重要。过早引入持久化会模糊本文努力建立的状态和响应之间的区别。
最后,本文没有涉及迁移和互操作性。很少有团队是从零开始的。大多数团队会在已经依赖响应式表单或模板驱动表单的应用程序中逐步采用 Signal Forms。混合方法、桥接策略和渐进式重构都是关键话题,但它们都预设了对两种范式的熟悉。在建立坚实的以信号为先的思维模型之前处理迁移问题,会破坏这个基础。
这些遗漏是有意为之的。一个试图一次性解决所有问题的表单架构往往最终什么都解决不好。通过专注于状态、推导和声明式验证的核心概念,创建了一个可以承受额外复杂性而不崩溃的基础。
Angular 演变背景下的 Signal Forms
要充分理解 Signal Forms,不妨退后一步,将其视为 Angular 设计理念更广泛转变的一部分,而不是一个孤立的特性。在其大部分发展历程中,Angular 强调声明式模板与组件类中的命令式协调相结合。RxJS 成为这种协调的支柱,为处理异步工作流、用户输入和外部事件提供了强大的抽象。这种模型扩展性很好,但也鼓励开发者通过流和订阅间接表达状态。
Signals 代表了一种有意的调整。它们将 Angular 的响应式模型重新围绕状态和推导,而不是事件和发射。这种转变在整个框架中都很明显:在组件输入、变更检测以及现在的表单中。Signal Forms 并不是试图取代之前的一切,而是试图让最常见的用例(建模和推导状态)更简单、更明确。
从这个角度看,Signal Forms 的设计更符合状态驱动的表单行为。从模型信号开始的要求反映了状态应该有一个单一、可检查的真实数据源的理念。基于架构的验证与约束是状态属性而非事件触发行为的概念相契合。以信号形式公开的字段状态强化了有效性、错误和交互元数据是派生值,应该被读取而不是被管理的理念。
值得注意的是,Signal Forms 并不试图抽象掉表单行为。它们不会将表单状态隐藏在不透明的类或生命周期钩子后面。它们也不要求开发者从控件层次结构或订阅图的角度思考。相反,它们直接公开表单行为,使开发者更容易理解值、验证和 UI 反馈之间的关系。这种方法与 Angular 最近的其他变化密切一致,包括引入现代模板控制流和更加强调显式数据依赖。
重要的是,Signal Forms 仍在不断发展。它们的 API 可能会改变,其功能范围几乎肯定会扩大。这正是将它们建立在第一原则基础上的重要性所在。理解 Signal Forms 工作原理的开发者将更有能力在 API 成熟时进行适应。
本文有意避免重复文档内容或列举每个可用的特性。相反,它专注于建立一个概念框架,使官方 API 感觉直观而非令人惊讶。从这个角度看,Signal Forms 不是一种编写表单的新方式,而是对表单本质的更清晰表达。
一种思考表单的新方式
本文构建注册表单的过程揭示了一个悄然但重要的转变。复杂度的降低并非来自更少的特性或更简单的需求,而是来自用状态和推导而非编排和响应来表达表单行为。通过将数据模型视为唯一的真实数据源,将验证规则视为声明式约束,将 UI 行为视为从当前条件派生而来,通常围绕表单的许多协调逻辑变得不再必要。需要管理的订阅更少,需要同步的标志更少,需要考虑的生命周期问题也更少。表单行为变得更易于检查,因为它直接体现在值之间的关系中。
这种方法并没有消除与表单相关的难题。异步验证、持久化以及与现有 Angular Forms API 的互操作性仍然需要精心设计。变化的是复杂度的所在位置。这些问题不再与状态表示交织在一起,而是明确地分层构建在一个清晰的基础之上。
以信号为先的表单并不是现有模式的通用替代品,也不是构建更简单应用程序的捷径。然而,它们是一个很好的例子,展示了如何将 API 与第一原则对齐,从而随着时间的推移降低认知负担并提高可维护性。对于构建大型、状态密集型表单的团队来说,这种对齐方式可以让代码从仅仅能工作转变为能够无摩擦地持续发展。