本文是一份关于 Angular 中反应式表单(Reactive Forms)的各种可能性的全面指南。首先,在这一部分中,我们将了解反应式表单的优势、如何创建一个表单、不同的验证方法,以及反应式表单与 Observables 之间的联系。
为什么用响应式表单?
为了开始,我们先来理解一下为什么会出现响应式表单。这里有一个基本的响应式表单的例子。
- HTML模版
<div class="form-wrapper">
<h1>注册表单范例</h1>
<form (submit)="handleSubmit($event)">
<input type="text" placeholder="用户名称" id="username" class="form-field-input" />
<input type="password" placeholder="密码" id="password" class="form-field-input"/>
<button type="submit" class="button-primary">去注册</button>
</form>
</div>
- TypeScript 组件
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: './app.component.scss'
})
export class AppComponent implements OnInit {
usernameInput!: HTMLInputElement;
passwordInput!: HTMLInputElement;
ngOnInit(): void {
// 在 UI 上出现后获取输入元素
this.usernameInput = document.getElementById('username') as HTMLInputElement;
this.passwordInput = document.getElementById('password') as HTMLInputElement;
}
handleSubmit(event: Event) {
// 阻止表单在提交时重新加载
event.preventDefault();
// 处理提交的数据
const submitData = {
username: this.usernameInput.value,
password: this.passwordInput.value
}
// 在控制台打印提交的数据
console.log('submitData :>> ', submitData);
// 例如:{ username: "PezoInTheHouse", password: ****** }
}
没问题!
这种方法对于非常简单的表单来说没问题。然而,在实际中,我们构建的表单要复杂很多。
- TypeScript 组件
usernameErrorMessage = ''
passwordErrorMessage = ''
handleSubmit(event: Event) {
// 阻止表单在提交时重新加载
event.preventDefault();
// 验证用户名:
if (!this.usernameInput.value) {
this.usernameErrorMessage = '用户名是必填的!';
return;
}
if (this.usernameInput.value.length > 20) {
this.usernameErrorMessage = '用户名太长了!';
return;
}
// 验证密码:
if (!this.passwordInput.value) {
this.passwordErrorMessage = '密码是必填的!';
return;
}
if (this.passwordInput.value.length > 20) {
this.passwordErrorMessage = '密码太长了!';
return;
}
// 处理表单数据如下:
const submitData = {
username: this.usernameInput.value,
password: this.passwordInput.value
}
console.log('提交的数据:', submitData);
}
- HTML模版
<div class="form-wrapper">
<h1>注册表单示例</h1>
<form (submit)="handleSubmit($event)">
<input type="text" placeholder="用户名" id="username" class="form-field-input" />
@if (!!usernameErrorMessage) {
<p class="error-text">{{ usernameErrorMessage }}</p>
}
<input type="password" placeholder="密码" id="password" class="form-field-input"/>
@if (!!passwordErrorMessage) {
<p class="error-text">{{ passwordErrorMessage }}</p>
}
<button type="submit" class="button-primary">注册</button>
</form>
</div>
它确实能工作。然而,今天你几乎找不到这样的情况,即在提交后却发现每个字段都显示错误。
更直观的做法是在输入框输入无效内容(在用户交互之后)时显示错误。这意味着我们需要手动处理输入框上的各种事件变化,以跟踪这些变化。
<input type="text" placeholder="用户名:" class="form-field-input"
(blur)="handleTouchEvent()" (change)="handleChange()"
[value]="latestValue"/>
- 我们可以为密码字段设置不同的验证规则,比如要求包含字母、数字和特殊字符的组合。
- 此外,用户名字段可以与一个API交互,以检查该用户名是否已经存在。
- 同时,我们还需要考虑性能问题。我们不想在每次按键时都进行文本验证或调用API,应该设置节流机制。
- 如果有密码和重复密码两个字段,我们需要验证这两个字段的值是否一致。
这些小要求使得手动编写这一切变成了一场活生生的噩梦。我们需要手动检查所有字段间的验证规则。监听 DOM 事件来确定触发的是什么以及何时触发之类的。
肯定有更好的办法来处理这个问题。你知道吗?响应式表单就是答案。
开始使用反应式表单现在咱们来看看响应式表单和其他表单有什么不一样。
步骤 1
在你的 AppModule 中导入 ReactiveFormsModule:
import { ReactiveFormsModule } from '@angular/forms';
@NgModule({
declarations: [AppComponent],
bootstrap: [AppComponent],
imports: [
BrowserModule,
AppRoutingModule,
ReactiveFormsModule, // <-- 这里就是它所在的位置
],
providers: [],
})
export class AppModule {}
如果你使用的是独立组件的话,这样的话,你需要将该模块导入到 imports 数组里。
import { Component } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-root',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule // <-- 这里就是了
],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
})
export class AppComponent
步骤2
反应式表单采用基于模型的表单创建方式,您可以在TypeScript组件中创建表单模型、控件和验证逻辑,并将它们绑定到模板。
这可以通过在Angular中使用FormBuilder类来实现,通过将其添加到组件构造函数中,可以注入该类。
constructor(private fb: FormBuilder) {}
此表单是一个由多个 FormControl
(每个表单字段一个)或 FormGroup
组成的 FormGroup
。
signUpForm!: FormGroup;
this.signUpForm = this.fb.group({
username: [''],
密码: [''],
});
每个表单字段都是一个在创建表单时添加的 FormControl
。也就是说,表单控件和组之后可以通过 FormArrays
添加或移除,我们将在第二部分更详细地了解 FormArrays
。
示例如下:
import { FormBuilder, FormGroup } from '@angular/forms';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
})
export class AppComponent implements OnInit {
registerForm!: FormGroup;
constructor(private fb: FormBuilder) {}
ngOnInit(): void {
this.registerForm = this.fb.group({ // 表单对象
username: [''], // 表单控件
password: [''], // 表单控件
});
}
handleSubmission() {
const formValue = this.registerForm.value;
console.log('表单值:', formValue);
}
## 步骤三
绑定 TypeScript 组件:模板
* 将表单组控件绑定到表单 HTML 元素: `[formGroup]="signUpForm"`
* 将每个表单控件绑定到输入框:
(`formControlName="username"`,`formControlName="password"`)
* 将提交按钮事件绑定到在组件中创建的提交处理方法: `(submit)="handleSubmit()"`
<div class="form-wrapper">
<h1>注册表单示例</h1>
<form (submit)="handleSubmit()" [formGroup]="signUpForm">
<input type="text" placeholder="用户名(请输入用户名)" formControlName="username" class="form-field-input" />
<input type="password" placeholder="密码(请输入密码)" formControlName="password" class="form-field-input"/>
<button type="submit" class="button-primary">立即注册</button>
</form>
</div>
一旦表单模型创建并与模板关联,对表单控件所做的任何更改都会自动反映在表单状态上,而无需手动处理显式的事件。
![](https://imgapi.imooc.com/674fb05809c095fe05520511.jpg)
我们也无需担心在提交时防止表单重复加载,因为 Angular 自动帮我们处理了这个问题。
# 表单验证
让我们通过增加表单验证来扩展这个例子。响应式表单提供了一系列常用的验证函数:
* 必填项
* 最小/最大长度(字符)
* 最小/最大值(数字)
* 正则匹配(使用正则表达式)
一个将所需验证器添加到控件元素中的示例:
// 导入 Angular 表单模块
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
// 初始化注册表单
ngOnInit(): void {
this.signUpForm = this.fb.group({
// 用户名字段,必填验证
username: ['', Validators.required],
// 密码字段
password: [''],
});
}
我们可以通过 `FromGroup` 的 `valid` 属性来检查它的有效性。
提交表单() {
const 表单有效 = this.signUpForm.valid;
console.log('表单有效 :>> ', 表单有效);
if (!表单有效) {
return;
}
const 表单值 = this.signUpForm.value;
console.log('表单值 :>> ', 表单值);
}
在添加用户名之前点击提交按钮会使表单无效。如果添加了用户名,表单就会有效。
![](https://imgapi.imooc.com/674fb05a09691ef308780587.jpg)
有效性可以通过验证 `FormControl` 来确认。首先,我们需要从表单中提取 `FormControl` 用户名,这可以通过在 `FormGroup` 上使用 `get()` 属性来实现。
import {
AbstractControl,
FormBuilder,
FormGroup,
Validators,
} from '@angular/forms';
// 获取 'username' 字段
const usernameControl = this.signUpForm.get('username') as AbstractControl;
现在,我们来看看 `username` 控制的有效性和值如何。
handleSubmit() {
const usernameControl = this.signUpForm.get('username') as AbstractControl;
console.log(usernameControl.value); // 用户名控件的值
console.log(usernameControl.valid); // 为空时为 false,非空时为 true
}
## 扩展控制和验证功能:
```python
# Example code segment remains unchanged
每个 FormControl
都可以拥有一组同步验证器和异步验证器。它们还可以有一个默认值会在字段中显示。
this.fb.group({
username: [
'默认值(DEFAULT VALUE)',
[
/* 同步验证器: */
],
[
/* 异步验证器: */
],
],
...
});
现在应用到注册表:
ngOnInit(): void {
this.signUpForm = this.fb.group({
username: ['', [Validators.required, Validators.minLength(3), Validators.maxLength(24)]],
password: ['', [Validators.required, Validators.minLength(10)]],
age: ['', [Validators.min(12), Validators.pattern("^[0-9]*$")]], // 只允许输入数字
rememberMe: [true] // 记住我 [复选框]
});
}
这
请提供要翻译的具体英文文本。删除此行。
正确的翻译应在提供完整英文文本后给出。
username
是必填项,长度需在 3 到 24 个字符之间。password
同样是必填项,长度需至少 10 个字符。age
是可选的,但只接受数字。rememberMe
是可选的,默认选中(true)。
我们来用新的组件更新模板中的控件
<div class="form-wrapper">
<h1>注册示例</h1>
<form (submit)="handleSubmit()" [formGroup]="signUpForm">
<input type="text" placeholder="用户名" formControlName="username" class="form-field-input" />
<input type="password" placeholder="密码" formControlName="password" class="form-field-input"/>
<input type="number" placeholder="年龄" formControlName="age" class="form-field-input"/>
<div>
<input type="checkbox" formControlName="rememberMe" /> 记住我?
</div>
<button type="submit" class="button-primary">注册</button>
</form>
</div>
在元素检查器视图中,你也能清楚地看到组件中的控制设置。
正如开发工具所显示的,username
和 password
这两个输入框带有 ng-invalid 类,这表明它们无效,导致整个表单无法通过验证。
你可以通过在模板里加上无效的验证检查来验证这一点。
<input type="text" placeholder="用户名" formControlName="username" class="form-field-input" />
@if (signUpForm.get('username')?.invalid) {
<p class="error-text">用户名不能为空!</p>
}
<input type="password" placeholder="密码" formControlName="password" class="form-field-input"/>
@if (signUpForm.get('password')?.invalid) {
<p class="error-text">密码不能为空!</p>
}
页面加载时,这样的错误就会出现。
这不是一个很好的用户体验。如果在用户与这些字段互动后才显示错误,体验会更好。我们再来看看检查元素的视图,看看发生了什么。
页面加载时,与表单组件相关的每个元素都会有一组 Angular 表单类:
- 未输入
- 初始状态
这表明表单字段未被改动,也没有任何内容填写。相反的情况则是:
- ng-触(用户点击或触碰了字段)
- ng-脏了(用户在表单字段中输入了东西)
在与字段交互之后,错误消失了,新的类就出现了。
这又告诉我们什么呢?而不是在页面加载时立即显示这些错误,我们可以在用户通过表单控件的touched
和dirty
状态与表单互动后才显示错误。
<input type="text" placeholder="请输入用户名" formControlName="username" class="form-field-input" />
@if (signUpForm.get('username')?.invalid &&
(signUpForm.get('username')?.dirty || signUpForm.get('username')?.touched))
{
<p class="error-text">用户名必填项!</p>
}
<input type="password" placeholder="请输入密码" formControlName="password" class="form-field-input"/>
@if (signUpForm.get('password')?.invalid &&
(signUpForm.get('password')?.dirty || signUpForm.get('password')?.touched))
{
<p class="error-text">密码必填项!</p>
}
这会告诉 Angular 只在满足以下条件时显示该字段的错误:
- 该字段无效了和
- 要么被修改过,要么被输入过
我们也可以在那页上确认一下。
错误会在你点击该字段,然后移开鼠标(类从_ng-untouched_变成_ng-touched_)时出现,这正是我们所期待的。
要试试不同类型的数据验证到目前为止,我们只讨论了必填项的情况。其他验证规则同样可以按这种方式验证,但在继续之前,我想先调整一下我们在代码中检查错误的方式。
迄今为止,我们从模板中获取控件并检查验证情况。我们可以通过将这些样板代码移到名为 getter 的函数中来改进这个过程。
export class AppComponent implements OnInit {
signUpForm!: FormGroup;
constructor(private fb: FormBuilder) {}
ngOnInit(): void {
this.signUpForm = this.fb.group({
...
});
}
// 获取用户名的输入框
get usernameControl(): AbstractControl {
return this.signUpForm.get('username') as AbstractControl;
}
}
_获取器_函数的作用是简化我们访问控制的方式。不再需要调用 this.signUpForm.get('username')
来检查 username
控制是否有效、是否被触或读取值等,我们只需调用 usernameControl
方法即可。
<input type="text" placeholder="用户名" formControlName="username" class="form-field-input" />
@if (usernameControl.invalid && (usernameControl.dirty || usernameControl.touched))
{
<p class="error-text">请输入用户名!</p>
}
看起来好多了。现在,我们将使用 FormControl
的 hasError()
方法来检查每个验证控件,我们在 Typescript 类中进行了这些设置:
Validators.required
=>usernameControl
存在“required”错误Validators.minLength(3)
=>usernameControl
存在“最小长度”错误Validators.maxLength(24)
=>usernameControl
存在“最大长度”错误
<input type="text" placeholder="用户名" formControlName="username" class="form-field-input" />
@if (usernameControl.invalid && (usernameControl.dirty || usernameControl.touched))
{
@if (usernameControl.hasError('required')) {
<p class="error-text">用户名是必填的!</p>
} @else if (usernameControl.hasError('maxlength')) {
<p class="error-text">用户名太长了哦!</p>
} @else {
<p class="error-text">用户名太短了哦!</p>
}
}
当控制正常时,错误就会消失。
这些getter方法也可以用在表单中的其他控件上。
ngOnInit(): void {
this.注册表单 = this.fb.group({
...
});
}
获取用户名控件(): AbstractControl {
return this.注册表单.get('username') as AbstractControl;
}
获取密码控件(): AbstractControl {
return this.注册表单.get('password') as AbstractControl;
}
获取年龄控件(): AbstractControl {
return this.注册表单.get('age') as AbstractControl;
}
获取记住我控件(): AbstractControl {
return this.注册表单.get('rememberMe') as AbstractControl;
}
年龄字段默认是可选的,因此这里不需要使用必填验证器。不过,我们可以检查最小值和模式验证器。
<input type="text" placeholder="年龄" formControlName="age" class="form-field-input"/>
@if (ageControl.invalid && (ageControl.dirty || ageControl.touched))
{
@if (ageControl.hasError('min')) {
<p class="error-text">您必须年满12岁!</p>
} @else if (ageControl.hasError('pattern')) {
<p class="error-text">请输入数字!</p>
}
}
我故意将输入类型从“number”
改成“text”
,这样格式错误就会出现了。
当验证表单数据与后端API时会应用异步验证功能。
为了让这个功能运作起来,我创建了一个基于Express.js的简单服务器。这个服务器提供了一个API,用于检查提供的用户名是否在现有用户列表中。
const express = require('express');
const app = express();
const cors = require('cors');
app.use(express.json())
app.use(cors());
const users = ['Mystic', 'Phantom', 'Twingi', 'MornarPopaj'];
app.get('/api/user/:username', (req, res) => {
const { username } = req.params;
const isExistingUser = !!users.find(user => user === username);
res.status(200).send({ isExistingUser })
});
app.listen(3000, () => console.log('服务器启动了!'));
如果有匹配,API 会返回 true 或 false。否则的话,返回 false。
测试 API 的功能
Angular 服务模块我们需要创建一个 Angular 服务来调用这个 API,比如调用下面的 API:
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
// 检查用户是否存在的接口
export interface ICheckExistingUser {
isExistingUser: boolean;
}
// 依赖注入,提供在根服务中
@Injectable({
providedIn: 'root'
})
export class UsersService {
constructor(private http: HttpClient) {}
// 检查用户是否存在
checkIfUserExists(username: string): Observable<ICheckExistingUser> {
return this.http.get<ICheckExistingUser>(`http://localhost:3000/api/user/${username}`);
}
}
自定义验证规则:
下一步是创建一个自定义表单验证器来调用此服务。
import { Injectable } from '@angular/core';
import { ICheckExistingUser, UsersService } from './features/core/services/users.service';
import {
AbstractControl,
AsyncValidatorFn,
ValidationErrors,
} from '@angular/forms';
import { Observable, catchError, map, of } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class UsersValidator {
constructor(private usersService: UsersService) {}
userExistsValidator(): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!control.value) {
return of(null);
// 如果用户名输入框为空,则返回 null
}
return this.usersService
.checkIfUserExists(control.value)
.pipe(
map((data: ICheckExistingUser) =>
data.isExistingUser ? { isExistingUser: true } : null), // 如果用户已存在
catchError(() => of(null))
// 发生错误时返回 null
);
};
}
}
这个验证器调用 API 并检查响应,如果响应为 true
(表示用户名已存在),则返回 { isExistingUser: true }
(表示已存在用户)。否则,返回 null(表示用户不存在)。
这里,我们将自定义验证器功能应用到用户名控件上:
构造函数 (constructor)(
private fb: FormBuilder,
private readonly usersValidator: UsersValidator
) {}
ngOnInit(): void {
this.signUpForm = this.fb.group({
username: [
'',
[Validators.required, Validators.minLength(3), Validators.maxLength(24)],
[this.usersValidator.userExistsValidator()] // 异步验证器,检查用户是否存在
],
password: ['', [Validators.required, Validators.minLength(10)]],
age: ['', [Validators.min(12), Validators.pattern("^[0-9]*$")]], // 正则表达式验证输入是否为数字
rememberMe: [true] // 默认值为true
});
}
然后,我们只需检查组件是否有 isExistingUser
错误(表示已存在用户)。
- HTML模板
<input type="text" placeholder="用户名" formControlName="username" class="form-field-input" />
@if (usernameControl.invalid && (usernameControl.dirty || usernameControl.touched))
{
@if (usernameControl.hasError('required')) {
<p class="error-text">用户名是必填的!</p>
} @else if (usernameControl.hasError('maxlength')) {
<p class="error-text">用户名太长了!</p>
} @else if (usernameControl.hasError('minlength')) {
<p class="error-text">用户名太短了!</p>
<!-- 异步验证 👇 -->
} @else if (usernameControl.hasError('isExistingUser')) {
<p class="error-text">用户名已被使用!</p>
}
}
使用可观察与响应式表单
这样使用异步验证器会有一点性能的影响——每次按键时都会调用一次 API。
天哪!
其实你可以这样来处理:按照这些规则。
- 不要在用户停止输入之前调用 API,而是等待大约 300 毫秒。
- 如果发出了新的搜索请求,取消现有的搜索请求。
这就是Rx.js Observables发挥作用的地方。
Angular中的响应式表单大量使用了Observables流。每个表单控件都是一个你可以操作的价值流(使用Rx.js操作符)。
valueChanges
属性将表单控制的实时值变化转换为你可以订阅的可观察对象。
import { debounceTime, distinctUntilChanged } from 'rxjs';
ngOnInit(): void {
this.signUpForm = this.fb.group({
username: ['', [Validators.required, Validators.minLength(3), Validators.maxLength(24)]],
password: ['', [Validators.required, Validators.minLength(10)]],
age: ['', [Validators.min(12), Validators.pattern("^[0-9]*$")]],
rememberMe: [true]
});
}
//
this.signUpForm.get('username')?.valueChanges
.pipe(
// 在用户停止输入500毫秒后再发出
debounceTime(500),
// 仅当值发生变化时才发出
distinctUntilChanged()
)
.subscribe((data: string) => {
console.log('data :>> ', data);
})
}
使用 Observables 来减少对 API 的调用
现在回到自定义验证器那里,应用限流策略。
import { Observable, catchError, map, of, switchMap, timer } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class UsersValidator {
constructor(private usersService: UsersService) {}
userExistsValidator(): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!control.value) {
return of(无);
}
// 开始一个300毫秒的计时器,在调用API之前等待300毫秒
return timer(300).pipe(
// 等待计时结束后调用API
switchMap(() => this.usersService.checkIfUserExists(control.value)),
// 检查是否有效
map((response: ICheckExistingUser) => (response.isExistingUser ? { 用户已存在: true } : 无)),
// 如果有错误则处理
catchError(() => of(无))
);
};
}
}
这样设置后,API调用会在用户停止输入后300毫秒会触发。
探索不同表单属性这里有一些属性和方法,可以用来检查表单或其任何一个控件的状态,或更改它们的行为。
查看表单读取表单值
this.signUpForm.value
- 确认表单是否有效:
this.signUpForm.valid // true / false
// 该代码行检查注册表单的有效性,返回 true 或 false。
- 检查表单互动
this.signUpForm.status // 无效状态 / 有效状态
this.signUpForm.dirty // 已修改 / 未修改
this.signUpForm.touched // 已交互 / 未交互
this.signUpForm.pristine // 未修改 / 未交互
this.signUpForm.untouched // 未交互 / 未修改
- 查找表单控件
this.signUpForm.get('username') // Abstract Control
this.signUpForm.get('password') // Abstract Control
改变表现形式
- 点击以清除表单中的值
this.signUpForm.reset(); // 该语句用于重置 signUpForm 表单。
纠正错误。
this.signUpForm.setErrors(null);
- 标记为污损
this.signUpForm.markAsDirty(); // 这行代码标记了signUpForm为脏数据。
- 标记为已处理
this.signUpForm.markAsTouched();
// 这里标记表单已触碰
- 更新有效期
this.signUpForm.updateValueAndValidity();
使用相同的方法,你可以查看或修改某个控件。
this.signUpForm.get('username')?.value;
this.signUpForm.get('username')?.valid;
this.signUpForm.get('username')?.touched;
this.signUpForm.get('username')?.dirty;
this.signUpForm.get('username')?.markAsDirty();
this.signUpForm.get('username')?.patchValue('新值') // 例如新值
this.signUpForm.get('username')?.setErrors(null) // 清除错误() 或者 设置错误(null)
this.signUpForm.get('username')?.hasError('required') // true / false 或者 false
// 等等...
收尾
Angular的响应式表单库提供了一种强大且直观的方式来创建和操作表单。这仅仅是冰山一角。在下一章,我们将深入探讨表单数组、自定义表单控件,以及如何用响应式表单和Angular Material搭配使用。
先聊了 👋