Chapter 06

表单系统

Angular 双模式表单:模板驱动适合简单场景,响应式表单适合复杂业务验证

1. 两种表单模式对比

特性模板驱动表单响应式表单
数据绑定双向绑定(ngModel)单向绑定(FormControl)
验证定义HTML 属性TypeScript 代码
可测试性较难(依赖 DOM)易于单元测试
动态表单较复杂原生支持 FormArray
适用场景简单登录/搜索表单复杂业务表单、动态字段
ℹ️

推荐选择:对于新项目和复杂业务表单,优先使用响应式表单(Reactive Forms)——它的表单逻辑完全在 TypeScript 中,可测试性强,与 RxJS 和 Signals 集成更好。

2. 模板驱动表单

模板驱动表单通过 HTML 属性定义验证规则,适合简单场景。需要导入 FormsModule

// login.component.ts
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-login',
  standalone: true,
  imports: [FormsModule],
  template: `
    <form (ngSubmit)="onSubmit(loginForm)" #loginForm="ngForm">
      <div>
        <label>邮箱</label>
        <input
          type="email"
          name="email"
          [(ngModel)]="formData.email"
          required email
          #emailRef="ngModel"
        >
        @if (emailRef.invalid && emailRef.touched) {
          @if (emailRef.errors?.['required']) {
            <span class="error">邮箱不能为空</span>
          }
          @if (emailRef.errors?.['email']) {
            <span class="error">邮箱格式不正确</span>
          }
        }
      </div>

      <div>
        <label>密码</label>
        <input
          type="password"
          name="password"
          [(ngModel)]="formData.password"
          required minlength="6"
          #pwdRef="ngModel"
        >
        @if (pwdRef.invalid && pwdRef.touched) {
          <span class="error">密码至少 6 位</span>
        }
      </div>

      <button type="submit" [disabled]="loginForm.invalid">登录</button>
    </form>
  `
})
export class LoginComponent {
  formData = { email: '', password: '' };

  onSubmit(form: NgForm) {
    if (form.valid) {
      console.log('提交:', this.formData);
    }
  }
}

3. 响应式表单

响应式表单的逻辑完全在 TypeScript 类中定义,模板只负责绑定,更易维护和测试。

FormBuilder + Validators

import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';

@Component({
  selector: 'app-register',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './register.component.html'
})
export class RegisterComponent {
  private fb = inject(FormBuilder);
  private authService = inject(AuthService);

  form = this.fb.group({
    name: ['', [
      Validators.required,
      Validators.minLength(2),
      Validators.maxLength(50)
    ]],
    email: ['', [
      Validators.required,
      Validators.email
    ], [this.emailExistsValidator()]],  // 第三个参数:异步验证器
    password: ['', [
      Validators.required,
      Validators.minLength(8),
      passwordStrengthValidator  // 自定义验证器
    ]],
    confirmPassword: ['', Validators.required],
    agreeTerms: [false, Validators.requiredTrue]
  }, {
    validators: passwordMatchValidator  // 跨字段验证器
  });

  // 快捷访问各字段
  get name() { return this.form.get('name')!; }
  get email() { return this.form.get('email')!; }

  onSubmit() {
    if (this.form.valid) {
      const { name, email, password } = this.form.value;
      this.authService.register({ name: name!, email: email!, password: password! })
        .subscribe();
    } else {
      this.form.markAllAsTouched(); // 触发所有字段的验证显示
    }
  }
}

模板绑定

<!-- register.component.html -->
<form [formGroup]="form" (ngSubmit)="onSubmit()">

  <div class="field">
    <label>用户名</label>
    <input formControlName="name" type="text">
    @if (name.invalid && name.touched) {
      @if (name.errors?.['required']) { <span class="error">必填</span> }
      @if (name.errors?.['minlength']) { <span class="error">至少 2 个字符</span> }
    }
  </div>

  <div class="field">
    <label>邮箱</label>
    <input formControlName="email" type="email">
    @if (email.pending) { <span>检查中...</span> }
    @if (email.errors?.['emailExists']) { <span class="error">邮箱已被注册</span> }
  </div>

  <button type="submit" [disabled]="form.invalid || form.pending">
    注册
  </button>
</form>

4. 自定义验证器

import { AbstractControl, ValidationErrors, ValidatorFn, AsyncValidatorFn } from '@angular/forms';
import { Observable, of } from 'rxjs';
import { map, catchError, debounceTime, switchMap } from 'rxjs/operators';

// 同步自定义验证器:密码强度
export function passwordStrengthValidator(control: AbstractControl): ValidationErrors | null {
  const value: string = control.value || '';
  const hasUpperCase = /[A-Z]+/.test(value);
  const hasLowerCase = /[a-z]+/.test(value);
  const hasNumber = /[0-9]+/.test(value);
  const isValid = hasUpperCase && hasLowerCase && hasNumber;
  return isValid ? null : { passwordStrength: true };
}

// 跨字段验证器:确认密码匹配
export function passwordMatchValidator(group: AbstractControl): ValidationErrors | null {
  const password = group.get('password')?.value;
  const confirmPassword = group.get('confirmPassword')?.value;
  return password === confirmPassword ? null : { passwordMismatch: true };
}

// 异步验证器:检查邮箱是否已被注册
export function emailExistsValidator(authService: AuthService): AsyncValidatorFn {
  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    return authService.checkEmailExists(control.value).pipe(
      debounceTime(400),
      map(exists => exists ? { emailExists: true } : null),
      catchError(() => of(null))
    );
  };
}

5. FormArray — 动态表单字段

@Component({
  selector: 'app-skills-form',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form">
      <div formArrayName="skills">
        @for (control of skillsArray.controls; track $index) {
          <div>
            <input [formControlName]="$index" placeholder="技能名称">
            <button type="button" (click)="removeSkill($index)">删除</button>
          </div>
        }
      </div>
      <button type="button" (click)="addSkill()">添加技能</button>
    </form>
  `
})
export class SkillsFormComponent {
  private fb = inject(FormBuilder);

  form = this.fb.group({
    skills: this.fb.array([
      this.fb.control('TypeScript', Validators.required)
    ])
  });

  get skillsArray() {
    return this.form.get('skills') as FormArray;
  }

  addSkill() {
    this.skillsArray.push(this.fb.control('', Validators.required));
  }

  removeSkill(index: number) {
    this.skillsArray.removeAt(index);
  }
}

本章小结:Angular 提供模板驱动和响应式两种表单方案。响应式表单适合大多数业务场景,支持同步/异步验证器、FormArray 动态字段,且易于测试。下一章深入 RxJS 响应式编程。