在 Angular Signal Forms 中以现代方式提交表单
Source: Dev.to
用 Angular Signal Forms 以现代方式提交表单
在 Angular 16 中引入了 Signal Forms,它们提供了一种更具响应式的方式来管理表单状态。与传统的 FormGroup/FormControl API 相比,Signal Forms 让我们能够直接使用 signals(信号)来读取和写入表单值,从而简化代码并提升可读性。本文将展示如何使用 Signal Forms 实现表单提交,并对比传统做法的差异。
目录
为什么选择 Signal Forms?
- 更直观的 API:使用
signal来获取和设置值,而不必调用getValue()、setValue()等方法。 - 自动变更检测:Signal 本身会在值变化时触发更新,无需手动调用
markAsDirty()、updateValueAndValidity()等。 - 更好的 TypeScript 支持:表单结构可以直接映射为 TypeScript 接口,IDE 能提供更精准的类型提示。
注意:Signal Forms 仍然兼容 Angular 的传统表单指令(如
ngModel、formControlName),所以可以逐步迁移。
创建一个 Signal Form
首先,定义表单模型的接口:
export interface LoginForm {
email: string;
password: string;
}
然后,在组件中使用 signalForm 创建表单:
import { Component, signal } from '@angular/core';
import { signalForm } from '@angular/forms';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss']
})
export class LoginComponent {
// 使用 signalForm 创建一个强类型的表单
loginForm = signalForm<LoginForm>({
email: '',
password: ''
});
// 读取表单值的 signal(只读)
readonly formValue = this.loginForm.value;
}
在模板中,直接绑定 formControlName:
<form (ngSubmit)="onSubmit()">
<label>
Email
<input type="email" formControlName="email" />
</label>
<label>
Password
<input type="password" formControlName="password" />
</label>
<button type="submit" [disabled]="loginForm.invalid()">Login</button>
</form>
loginForm.invalid()是 Signal Forms 为我们自动生成的 getter,用来检查表单整体的有效性。
处理表单提交
Signal Forms 让提交逻辑变得非常简洁。我们只需要读取 loginForm.value()(注意这里是函数调用,因为 value 本身是一个 signal),然后将数据发送到后端:
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
export class LoginComponent {
private http = inject(HttpClient);
// 省略前面的代码 ...
async onSubmit() {
// 读取当前表单值
const payload = this.loginForm.value();
// 发送请求(这里使用 async/await 只是示例,实际项目可自行决定)
try {
const response = await this.http.post('/api/login', payload).toPromise();
console.log('登录成功', response);
} catch (error) {
console.error('登录失败', error);
}
}
}
与传统方式的对比
传统 FormGroup | Signal Forms |
|---|---|
this.form.get('email')?.value | this.loginForm.value().email |
this.form.valid | this.loginForm.valid() |
需要手动调用 markAsTouched()、updateValueAndValidity() | 自动处理,无需额外调用 |
| 代码冗长,类型推断有限 | 简洁、强类型,IDE 自动补全更友好 |
完整示例代码
下面给出一个可直接运行的完整示例(包括组件、模板和样式):
// login.component.ts
import { Component, signal } from '@angular/core';
import { signalForm } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
export interface LoginForm {
email: string;
password: string;
}
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss']
})
export class LoginComponent {
private http = inject(HttpClient);
// 创建 Signal Form
loginForm = signalForm<LoginForm>({
email: '',
password: ''
});
// 提交处理
async onSubmit() {
const payload = this.loginForm.value();
try {
const result = await this.http.post('/api/login', payload).toPromise();
console.log('登录成功', result);
} catch (err) {
console.error('登录失败', err);
}
}
}
<!-- login.component.html -->
<form (ngSubmit)="onSubmit()">
<div class="field">
<label for="email">Email</label>
<input id="email" type="email" formControlName="email" />
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" type="password" formControlName="password" />
</div>
<button type="submit" [disabled]="loginForm.invalid()">登录</button>
</form>
/* login.component.scss */
.field {
margin-bottom: 1rem;
label {
display: block;
margin-bottom: 0.5rem;
}
input {
width: 100%;
padding: 0.5rem;
box-sizing: border-box;
}
}
button {
padding: 0.75rem 1.5rem;
}
小结
- Signal Forms 为 Angular 表单提供了更现代、更响应式的编程模型。
- 通过
signalForm<T>()可以一次性获得强类型的表单对象,省去繁琐的FormControl手动创建。 - 表单状态(如
valid、invalid、dirty)均以 signal 形式暴露,直接在模板中使用函数调用即可。 - 迁移成本低,兼容旧的指令和 API,适合作为新项目的默认表单实现。
如果你还在使用传统的 FormGroup,不妨尝试在一个小模块中引入 Signal Forms,感受一下代码简洁度和类型安全的提升。祝编码愉快!
Signal Forms 如何处理客户端验证
完全使用 Signal Forms API 构建的简易注册表单
<!-- Username field -->
<div>
Username
</div>
@if (form.username().touched() && form.username().invalid()) {
<ul>
@for (err of form.username().errors(); track $index) {
<li>- {{ err.message }}</li>
}
</ul>
}
<!-- Email field -->
<div>
Email
</div>
@if (form.email().touched() && form.email().invalid()) {
<ul>
@for (err of form.email().errors(); track $index) {
<li>- {{ err.message }}</li>
}
</ul>
}
<!-- Submit button -->
<button type="submit" [disabled]="form.invalid()">Create account</button>
初始状态 – “Create account” 按钮被禁用,因为表单无效(未输入用户名或电子邮件)。
客户端验证 – 当你聚焦一个字段并失去焦点时,验证错误会立即出现:
- 用户名字段:必填 → “Please enter a username”
- 电子邮件字段:必填 → “Please enter an email address”
输入有效值后,错误消失,提交按钮变为可用。到目前为止,客户端验证一切如预期工作。
问题:没有提交逻辑
点击 Create account 时,浏览器会刷新页面。没有异步处理,无法显示服务器端验证错误,也没有加载状态。
模板片段(没有提交处理器)
<form>
…
</form>
<form> 元素没有绑定 (ngSubmit) 或 submit() 处理器,因此会执行默认的浏览器行为(页面重新加载)。
字段绑定
<input type="text" field="username" />
<input type="email" field="email" />
两个输入都使用 field 指令连接到 Signal Form。
条件错误渲染
@if (form.username().touched() && form.username().invalid()) {
@for (err of form.username().errors(); track $index) {
- {{ err.message }}
}
}
电子邮件字段使用相同的模式。
禁用的提交按钮
<button type="submit" [disabled]="form.invalid()">Create account</button>
上述所有内容在客户端验证方面都运行良好;我们只需要添加提交逻辑。
组件 TypeScript
模型信号
protected readonly model = signal({
username: '',
email: '',
});
基于 Signal 的表单定义
protected readonly form = form(this.model, s => {
required(s.username, { message: 'Please enter a username' });
minLength(s.username, 3, { message: 'Your username must be at least 3 characters' });
required(s.email, { message: 'Please enter an email address' });
});
所有这些都是客户端验证:快速、同步,并在任何提交尝试之前运行。
模拟注册服务(模拟后端调用)
import { Injectable } from '@angular/core';
export interface SignupModel {
username: string;
email: string;
}
export type SignupResult =
| { status: 'ok' }
| {
status: 'error';
fieldErrors: Partial<SignupModel>;
};
@Injectable({ providedIn: 'root' })
export class SignupService {
async signup(value: SignupModel): Promise<SignupResult> {
await new Promise(r => setTimeout(r, 700));
const fieldErrors: Partial<SignupModel> = {};
// Username rules
if (value.username.trim().toLowerCase() === 'brian') {
fieldErrors.username = 'That username is already taken.';
}
// Email rules
if (value.email.trim().toLowerCase() === 'brian@test.com') {
fieldErrors.email = 'That email is already taken.';
}
return Object.keys(fieldErrors).length
? { status: 'error', fieldErrors }
: { status: 'ok' };
}
}
该服务返回成功结果,或返回一个包含字段特定服务器错误的对象。
- 客户端验证 – 检查形状/格式(必填、电子邮件格式、最小长度)。
- 服务器端验证 – 强制业务规则(保留的用户名、受限域名、唯一性)。
Source: …
实现正确的表单提交
Signal Forms 提供了一个新的 submit() API,帮助我们处理许多繁琐的工作。
1. 注入注册服务
import { inject } from '@angular/core';
import { SignupService } from './signup.service';
export class SignupComponent {
// …
private readonly signupService = inject(SignupService);
}
2. 添加 onSubmit 方法
protected onSubmit(event: Event) {
// 阻止浏览器默认的表单提交(页面刷新)
event.preventDefault();
// 使用新的 submit() API
this.form.submit(async () => {
// 将所有字段标记为已触摸,以便在需要时显示验证信息
this.form.markAllAsTouched();
// 如果在客户端检查后表单仍然无效,则中止
if (this.form.invalid()) return;
// 调用模拟服务
const result = await this.signupService.signup(this.model());
// 处理服务器端错误
if (result.status === 'error') {
// 将每个字段错误映射到对应的 Signal Form 字段
for (const [field, message] of Object.entries(result.fieldErrors)) {
// `field` 是 SignupModel 的键(例如 'username' 或 'email')
// 强制转换为 any 以满足索引签名
(this.form as any)[field].setServerError(message);
}
return;
}
// 成功路径 – 你可以导航、显示 toast 等
console.log('Signup successful!');
});
}
3. 在模板中绑定该方法
<form (ngSubmit)="onSubmit($event)">
…
<button type="submit" [disabled]="form.submitting()">
{{ form.submitting() ? 'Creating…' : 'Create account' }}
</button>
</form>
form.submitting()是submit()提供的内置加载状态信号。- 当异步操作进行时,按钮文字会自动切换为加载状态。
submit() API 为你提供的功能
| 功能 | 工作原理 |
|---|---|
| 异步处理 | 接受一个异步回调;表单会保持在“提交中”状态,直到 Promise 解析完成。 |
| 加载状态 | form.submitting() 是一个信号,你可以绑定到 UI(例如,禁用按钮或显示加载指示器)。 |
| 已触摸字段处理 | markAllAsTouched() 确保在用户尝试提交时,未触摸字段也会显示验证信息。 |
| 服务器错误映射 | 在字段信号上调用 setServerError(message) 会添加服务器端错误,并与现有错误 UI 集成。 |
| 阻止默认行为 | 当通过 (ngSubmit) 使用时,API 会自动调用 event.preventDefault()。 |
完整组件示例
import { Component, signal, inject } from '@angular/core';
import { form, required, minLength } from '@angular/forms';
import { SignupService, SignupModel } from './signup.service';
@Component({
selector: 'app-signup',
templateUrl: './signup.component.html',
standalone: true,
})
export class SignupComponent {
// Model signal
protected readonly model = signal({ username: '', email: '' });
// Signal‑based form
protected readonly form = form(this.model, s => {
required(s.username, { message: 'Please enter a username' });
minLength(s.username, 3, { message: 'Your username must be at least 3 characters' });
required(s.email, { message: 'Please enter an email address' });
});
// Service injection
private readonly signupService = inject(SignupService);
// Submit handler
protected onSubmit(event: Event) {
this.form.submit(async () => {
this.form.markAllAsTouched();
if (this.form.invalid()) return;
const result = await this.signupService.signup(this.model());
if (result.status === 'error') {
for (const [field, message] of Object.entries(result.fieldErrors)) {
(this.form as any)[field].setServerError(message);
}
return;
}
console.log('Signup successful!');
});
}
}
回顾
- 客户端验证 开箱即用,适用于 Signal Forms。
- 提交 需要使用新的
submit()API,以防止页面刷新、管理加载状态并处理服务器端错误。 - 实现步骤 – 注入服务,添加调用
form.submit(...)的onSubmit方法,将字段标记为已触摸,在客户端错误时中止,调用后端,映射服务器错误,并处理成功。
有了这些要素,您的 Angular Signal Forms 将实现完全响应式、用户友好,并准备好进行真实场景的服务器交互。祝编码愉快!
防止浏览器执行完整页面刷新
protected onSubmit(event: Event) {
event.preventDefault();
}
使用新的 submit() 函数
import { ..., submit } from '@angular/forms/signals';
protected onSubmit(event: Event) {
…
submit(this.form);
}
异步回调(第二个参数)
protected onSubmit(event: Event) {
…
submit(this.form, async f => {
// …
});
}
Angular 为您做的事
- 仅在表单有效时才调用回调。
- 自动将所有字段标记为已触摸。
- 为您跟踪提交状态。
在回调内部,您可以通过 f 获取表单的字段树。
访问当前表单值
submit(this.form, async f => {
const value = f().value(); // validated client‑side value
});
调用注册服务
submit(this.form, async f => {
…
const result = await this.signupService.signup(value);
});
这模拟了一个后端调用。
Source: …
处理服务器端验证错误
如果服务器拒绝提交,返回验证错误 而不是抛出异常或手动设置状态。
submit(this.form, async f => {
…
if (result.status === 'error') {
// …
}
});
创建错误数组
import { ..., ValidationError } from '@angular/forms/signals';
submit(this.form, async f => {
…
if (result.status === 'error') {
const errors: ValidationError.WithOptionalField[] = [];
}
});
ValidationError.WithOptionalField 表示一种可以(可选)针对特定字段的验证错误。
为特定字段添加错误
submit(this.form, async f => {
…
if (result.status === 'error') {
if (result.fieldErrors.username) {
errors.push({
field: f.username,
kind: 'server',
message: result.fieldErrors.username,
});
}
}
});
邮箱错误(相同模式)
submit(this.form, async f => {
…
if (result.status === 'error') {
if (result.fieldErrors.email) {
errors.push({
field: f.email,
kind: 'server',
message: result.fieldErrors.email,
});
}
}
});
返回错误(或 undefined)
submit(this.form, async f => {
…
if (result.status === 'error') {
…
return errors.length ? errors : undefined;
}
});
- 返回 errors 告诉 Angular:“不要提交表单;显示这些错误。”
- 返回
undefined告诉 Angular:“一切正常——表单已成功提交。”
在模板中绑定 onSubmit 方法
<form (ngSubmit)="onSubmit($event)">
…
</form>
在提交时禁用提交按钮
<button type="submit" [disabled]="form.submitting()">
{{ form.submitting() ? 'Creating…' : 'Create account' }}
</button>
测试完整流程
- 客户端验证 在字段失去焦点时工作。
- 输入能够 通过客户端验证但未通过服务器验证 的值。
- 客户端错误消失(表单在技术上是有效的)。
- 按钮变为可用;点击它。
- 按钮被禁用并显示 “Creating…”,期间模拟服务器响应。
- 服务器验证错误以与客户端错误相同的 UI 显示。
- 没有控制台日志出现,因为我们返回了错误,所以表单 未 提交。
- 修复用户名和电子邮件后,再次提交。
- 表单现在成功提交,数据被记录/处理。
完整组件代码
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
Field,
form,
minLength,
required,
submit,
ValidationError
} from '@angular/forms/signals';
import { SignupModel, SignupService } from './signup.service';
@Component({
selector: 'app-form',
imports: [CommonModule, Field],
templateUrl: './form.component.html',
styleUrl: './form.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FormComponent {
protected readonly model = signal({
// … (model properties)
});
// … (rest of the component: form definition, onSubmit implementation, etc.)
}
上面的代码片段展示了完整的组件结构;请使用前面章节中展示的代码填充省略的部分(模型字段、表单定义、onSubmit 逻辑)。
组件 (TypeScript)
username: '',
email: '',
});
protected readonly form = form(this.model, s => {
required(s.username, { message: 'Please enter a username' });
minLength(s.username, 3, { message: 'Your username must be at least 3 characters' });
required(s.email, { message: 'Please enter an email address' });
});
private readonly signupService = inject(SignupService);
protected onSubmit(event: Event) {
event.preventDefault();
submit(this.form, async f => {
const value = f().value();
const result = await this.signupService.signup(value);
if (result.status === 'error') {
const errors: ValidationError.WithOptionalField[] = [];
if (result.fieldErrors.username) {
errors.push({
field: f.username,
kind: 'server',
message: result.fieldErrors.username,
});
}
if (result.fieldErrors.email) {
errors.push({
field: f.email,
kind: 'server',
message: result.fieldErrors.email,
});
}
return errors.length ? errors : undefined;
}
console.log('Submitted:', value);
return undefined;
});
}
模板 (HTML)