Angular表单处理(响应式)

本章目标:掌握Angular响应式表单的核心概念,学会创建复杂表单、实现表单验证、处理动态表单和表单数组等高级功能。

响应式表单三大核心

FormControl

单个表单控件

FormGroup

表单控件组

FormArray

表单控件数组

什么是响应式表单?

响应式表单是Angular提供的两种表单处理方式之一,它使用显式的、不可变的方式来管理表单状态,提供了更强的类型安全和更灵活的表单控制。

响应式表单特点
  • 在组件类中显式创建表单模型
  • 表单状态不可变,变化时返回新对象
  • 支持同步和异步验证
  • 提供强大的类型检查和自动补全
  • 适合复杂表单场景
与模板驱动表单对比
特性 响应式表单 模板驱动表单
表单模型 显式(组件中) 隐式(模板中)
验证方式 函数式验证器 指令验证器
可变性 不可变 可变
复杂度 适合复杂表单 适合简单表单

1. 创建响应式表单

响应式表单需要从@angular/forms模块导入ReactiveFormsModule

// 1. 在模块中导入ReactiveFormsModule
import { ReactiveFormsModule } from '@angular/forms';

@NgModule({
  imports: [
    // ... 其他导入
    ReactiveFormsModule  // 引入响应式表单模块
  ],
  // ...
})
export class AppModule { }

// 2. 在组件中创建表单
import { Component, OnInit } from '@angular/core';
import {
  FormBuilder,
  FormGroup,
  FormControl,
  Validators,
  FormArray
} from '@angular/forms';

@Component({
  selector: 'app-reactive-form',
  templateUrl: './reactive-form.component.html'
})
export class ReactiveFormComponent implements OnInit {
  // 表单组定义
  userForm: FormGroup;

  // 使用FormBuilder注入(推荐)
  constructor(private fb: FormBuilder) {
    // 在构造函数中初始化表单
    this.userForm = this.fb.group({
      // 表单控件定义
    });
  }

  ngOnInit(): void {
    // 或者在这里初始化表单
    this.initForm();
  }

  private initForm(): void {
    this.userForm = this.fb.group({
      // 表单控件将在后面详细定义
    });
  }
}

使用FormBuilder创建表单

FormBuilder是创建表单的便捷工具,语法更简洁:

// 使用FormBuilder创建基本表单
this.userForm = this.fb.group({
  // 文本输入框
  username: ['', [
    Validators.required,
    Validators.minLength(3),
    Validators.maxLength(20)
  ]],

  // 邮箱输入框
  email: ['', [
    Validators.required,
    Validators.email
  ]],

  // 密码输入框
  password: ['', [
    Validators.required,
    Validators.minLength(8),
    Validators.pattern('^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$')
  ]],

  // 选择框
  gender: ['male', Validators.required],

  // 复选框
  agreeTerms: [false, Validators.requiredTrue],

  // 地址组(嵌套FormGroup)
  address: this.fb.group({
    street: [''],
    city: ['', Validators.required],
    zipCode: ['', [
      Validators.required,
      Validators.pattern('^\\d{5,6}$')
    ]]
  }),

  // 兴趣数组(FormArray)
  hobbies: this.fb.array([
    this.fb.control('')
  ])
});
基础表单实时演示

2. 表单控件类型

响应式表单提供三种核心类型:FormControl、FormGroup和FormArray。

表单控件层级结构
FormGroup

顶级表单容器

FormGroup

嵌套表单组(地址)

FormControl

表单字段(用户名)

FormControl

管理单个表单控件的值和验证状态。

// 创建FormControl的三种方式
const control1 = new FormControl();           // 空值
const control2 = new FormControl('初始值');    // 带初始值
const control3 = new FormControl('', [
  Validators.required,
  Validators.email
]);  // 带初始值和验证器

// 获取和设置值
const value = control1.value;          // 获取值
control1.setValue('新值');             // 设置值
control1.patchValue('部分更新值');      // 部分更新

// 监听值变化
control1.valueChanges.subscribe(newValue => {
  console.log('值已变化:', newValue);
});

// 监听状态变化
control1.statusChanges.subscribe(status => {
  console.log('状态已变化:', status);
});

// 获取错误信息
const errors = control1.errors;

// 检查状态
const isValid = control1.valid;
const isInvalid = control1.invalid;
const isPending = control1.pending;
const isDirty = control1.dirty;
const isPristine = control1.pristine;
const isTouched = control1.touched;
const isUntouched = control1.untouched;
FormGroup

管理一组FormControl的集合,用于分组相关的表单字段。

// 创建FormGroup
const userGroup = new FormGroup({
  firstName: new FormControl(''),
  lastName: new FormControl(''),
  age: new FormControl(0)
});

// 使用FormBuilder创建
const userGroup = this.fb.group({
  firstName: '',
  lastName: '',
  age: 0
});

// 获取和设置值
const groupValue = userGroup.value;      // 获取整个组的值
userGroup.setValue({                     // 设置整个组的值(必须包含所有控件)
  firstName: '张',
  lastName: '三',
  age: 25
});

userGroup.patchValue({                   // 部分更新组的值
  firstName: '李'
});

// 获取单个控件
const firstNameControl = userGroup.get('firstName');

// 监听组的值变化
userGroup.valueChanges.subscribe(value => {
  console.log('表单组值变化:', value);
});

// 检查组的状态
const isGroupValid = userGroup.valid;
const isGroupInvalid = userGroup.invalid;

// 获取嵌套组
const addressGroup = userGroup.get('address') as FormGroup;
FormArray

管理动态数量的FormControl,用于创建列表或数组类型的表单字段。

// 创建FormArray
const skills = new FormArray([
  new FormControl('Angular'),
  new FormControl('TypeScript')
]);

// 使用FormBuilder创建
const skills = this.fb.array([
  this.fb.control('Angular'),
  this.fb.control('TypeScript')
]);

// 添加新控件
skills.push(new FormControl('RxJS'));
skills.push(this.fb.control('Node.js'));

// 插入控件
skills.insert(1, new FormControl('React'));

// 移除控件
skills.removeAt(0);           // 移除指定索引的控件
skills.removeAt(skills.length - 1);  // 移除最后一个控件

// 清空数组
skills.clear();

// 获取控件
const firstSkill = skills.at(0);  // 获取第一个控件

// 获取所有控件
const allControls = skills.controls;

// 获取数组长度
const arrayLength = skills.length;

// 设置值
skills.setValue(['Vue', 'Svelte']);

// 监听值变化
skills.valueChanges.subscribe(value => {
  console.log('技能列表变化:', value);
});

// 检查验证状态
const isValid = skills.valid;

3. 表单验证

响应式表单提供强大的验证功能,包括内置验证器和自定义验证器。

内置验证器 用途 示例
Validators.required 必填字段验证 Validators.required
Validators.email 邮箱格式验证 Validators.email
Validators.minLength 最小长度验证 Validators.minLength(3)
Validators.maxLength 最大长度验证 Validators.maxLength(20)
Validators.pattern 正则表达式验证 Validators.pattern('[a-zA-Z ]*')
Validators.min 最小值验证 Validators.min(0)
Validators.max 最大值验证 Validators.max(100)
Validators.requiredTrue 必须为true(用于复选框) Validators.requiredTrue

自定义验证器

创建自定义验证器来处理特定业务逻辑:

// 1. 同步验证器(返回ValidationErrors或null)
export function passwordStrengthValidator(control: FormControl) {
  const value = control.value || '';

  if (!value) {
    return null; // 如果没有值,不验证
  }

  const hasUpperCase = /[A-Z]/.test(value);
  const hasLowerCase = /[a-z]/.test(value);
  const hasNumber = /\d/.test(value);
  const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(value);

  const errors: any = {};

  if (!hasUpperCase) {
    errors.noUpperCase = '密码必须包含大写字母';
  }

  if (!hasLowerCase) {
    errors.noLowerCase = '密码必须包含小写字母';
  }

  if (!hasNumber) {
    errors.noNumber = '密码必须包含数字';
  }

  if (!hasSpecialChar) {
    errors.noSpecialChar = '密码必须包含特殊字符';
  }

  // 如果密码长度小于8,也返回错误
  if (value.length < 8) {
    errors.minLength = '密码长度至少8位';
  }

  // 如果没有任何错误,返回null
  return Object.keys(errors).length ? errors : null;
}

// 2. 异步验证器(返回Promise或Observable)
export function usernameAvailabilityValidator(
  userService: UserService
): AsyncValidatorFn {
  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    const username = control.value;

    if (!username) {
      return of(null); // 如果没有值,不验证
    }

    return userService.checkUsernameAvailability(username).pipe(
      map(isAvailable => {
        return isAvailable ? null : { usernameTaken: '用户名已被占用' };
      }),
      catchError(() => of(null)) // 发生错误时不显示验证错误
    );
  };
}

// 3. 在表单中使用验证器
this.userForm = this.fb.group({
  username: ['', [
    Validators.required,
    Validators.minLength(3)
  ], [
    usernameAvailabilityValidator(this.userService)  // 异步验证器
  ]],

  password: ['', [
    Validators.required,
    passwordStrengthValidator  // 自定义同步验证器
  ]],

  confirmPassword: ['', Validators.required]
}, {
  // 表单级别的验证器(验证多个字段的关系)
  validators: [passwordMatchValidator]
});

// 4. 表单级别验证器(验证多个字段的关系)
export function passwordMatchValidator(group: FormGroup) {
  const password = group.get('password')?.value;
  const confirmPassword = group.get('confirmPassword')?.value;

  return password === confirmPassword ? null : { passwordMismatch: true };
}

在模板中显示验证错误

<!-- 响应式表单模板 -->
<form [formGroup]="userForm" (ngSubmit)="onSubmit()">
  <!-- 用户名输入 -->
  <div class="mb-3">
    <label for="username" class="form-label">用户名</label>
    <input
      type="text"
      id="username"
      class="form-control"
      formControlName="username"
      [class.is-invalid]="username.invalid && username.touched"
      [class.is-valid]="username.valid && username.touched">

    <!-- 验证错误信息 -->
    <div *ngIf="username.invalid && username.touched" class="error-message">
      <div *ngIf="username.errors?.['required']">
        用户名是必填项
      </div>
      <div *ngIf="username.errors?.['minlength']">
        用户名至少需要{{username.errors?.['minlength']?.requiredLength}}个字符
      </div>
      <div *ngIf="username.errors?.['usernameTaken']">
        {{username.errors?.['usernameTaken']}}
      </div>
      <div *ngIf="username.errors?.['maxlength']">
        用户名不能超过{{username.errors?.['maxlength']?.requiredLength}}个字符
      >/div>
    </div>

    <!-- 显示验证状态 -->
    <div class="form-text">
      <span *ngIf="username.pending" class="text-warning">
        <i class="fas fa-spinner fa-spin"></i> 验证中...
      </span>
      <span *ngIf="username.valid && username.touched" class="text-success">
        <i class="fas fa-check-circle"></i> 用户名可用
      </span>
    </div>
  </div>

  <!-- 密码输入 -->
  <div class="mb-3">
    <label for="password" class="form-label">密码</label>
    <input
      type="password"
      id="password"
      class="form-control"
      formControlName="password"
      [class.is-invalid]="password.invalid && password.touched">

    <div *ngIf="password.invalid && password.touched" class="error-message">
      <div *ngIf="password.errors?.['required']">密码是必填项</div>
      <div *ngIf="password.errors?.['noUpperCase']">
        {{password.errors?.['noUpperCase']}}
      </div>
      <div *ngIf="password.errors?.['noLowerCase']">
        {{password.errors?.['noLowerCase']}}
      </div>
      <div *ngIf="password.errors?.['noNumber']">
        {{password.errors?.['noNumber']}}
      </div>
      <div *ngIf="password.errors?.['minLength']">
        {{password.errors?.['minLength']}}
      </div>
    </div>
  </div>

  <!-- 确认密码输入 -->
  <div class="mb-3">
    <label for="confirmPassword" class="form-label">确认密码</label>
    <input
      type="password"
      id="confirmPassword"
      class="form-control"
      formControlName="confirmPassword"
      [class.is-invalid]="userForm.errors?.['passwordMismatch'] &&
                          confirmPassword.touched">

    <div *ngIf="userForm.errors?.['passwordMismatch'] &&
                confirmPassword.touched" class="error-message">
      两次输入的密码不一致
    </div>
  </div>

  <!-- 提交按钮 -->
  <button
    type="submit"
    class="btn btn-primary"
    [disabled]="userForm.invalid">
    提交
  </button>
</form>

4. 动态表单

使用FormArray创建动态表单,允许用户动态添加/删除表单字段。

动态表单优势
动态添加字段

用户可以根据需要添加更多输入字段

动态删除字段

用户可以删除不需要的字段

灵活的表单结构

适应不同业务需求的动态表单

验证每个字段

为每个动态字段单独验证

动态表单实现

// 组件代码 - 动态表单实现
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms';

@Component({
  selector: 'app-dynamic-form',
  templateUrl: './dynamic-form.component.html'
})
export class DynamicFormComponent implements OnInit {
  dynamicForm: FormGroup;

  constructor(private fb: FormBuilder) {
    this.dynamicForm = this.fb.group({
      // 其他字段...
      experiences: this.fb.array([])  // 空的FormArray
    });
  }

  ngOnInit(): void {
    // 初始化时添加一个经验字段
    this.addExperience();
  }

  // 获取experiences FormArray
  get experiences(): FormArray {
    return this.dynamicForm.get('experiences') as FormArray;
  }

  // 创建单个经验表单组
  createExperience(): FormGroup {
    return this.fb.group({
      company: ['', Validators.required],
      position: ['', Validators.required],
      startDate: ['', Validators.required],
      endDate: [''],
      current: [false],
      description: ['']
    });
  }

  // 添加经验字段
  addExperience(): void {
    this.experiences.push(this.createExperience());
  }

  // 移除经验字段
  removeExperience(index: number): void {
    this.experiences.removeAt(index);
  }

  // 清空所有经验字段
  clearExperiences(): void {
    while (this.experiences.length !== 0) {
      this.experiences.removeAt(0);
    }
  }

  // 移动经验字段位置
  moveExperienceUp(index: number): void {
    if (index > 0) {
      const experience = this.experiences.at(index);
      this.experiences.removeAt(index);
      this.experiences.insert(index - 1, experience);
    }
  }

  moveExperienceDown(index: number): void {
    if (index < this.experiences.length - 1) {
      const experience = this.experiences.at(index);
      this.experiences.removeAt(index);
      this.experiences.insert(index + 1, experience);
    }
  }

  // 表单提交
  onSubmit(): void {
    if (this.dynamicForm.valid) {
      console.log('表单数据:', this.dynamicForm.value);
      // 提交到服务器...
    } else {
      // 标记所有字段为touched以显示错误
      this.markFormGroupTouched(this.dynamicForm);
    }
  }

  // 标记所有字段为touched
  private markFormGroupTouched(formGroup: FormGroup | FormArray): void {
    Object.values(formGroup.controls).forEach(control => {
      control.markAsTouched();

      if (control instanceof FormGroup || control instanceof FormArray) {
        this.markFormGroupTouched(control);
      }
    });
  }

  // 重置表单
  resetForm(): void {
    this.dynamicForm.reset();
    this.clearExperiences();
    this.addExperience(); // 重新添加一个空字段
  }
}

动态表单模板

<form [formGroup]="dynamicForm" (ngSubmit)="onSubmit()">
  <!-- 工作经验动态字段 -->
  <div formArrayName="experiences">
    <h4>工作经验</h4>
    <div class="mb-3">
      <button type="button" class="btn btn-sm btn-outline-primary me-2"
              (click)="addExperience()">
        <i class="fas fa-plus me-1"></i>添加经历
      </button>

      <button type="button" class="btn btn-sm btn-outline-danger"
              (click)="clearExperiences()"
              [disabled]="experiences.length === 0">
        <i class="fas fa-trash me-1"></i>清空所有
      </button>
    </div>

    <!-- 动态生成经验字段 -->
    <div *ngFor="let experience of experiences.controls; let i = index"
         [formGroupName]="i"
         class="card mb-3">
      <div class="card-header d-flex justify-content-between align-items-center">
        <span>工作经验 {{ i + 1 }}</span>
        <div>
          <button type="button" class="btn btn-sm btn-outline-secondary me-1"
                  (click)="moveExperienceUp(i)"
                  [disabled]="i === 0">
            <i class="fas fa-arrow-up"></i>
          </button>
          <button type="button" class="btn btn-sm btn-outline-secondary me-1"
                  (click)="moveExperienceDown(i)"
                  [disabled]="i === experiences.length - 1">
            <i class="fas fa-arrow-down"></i>
          </button>
          <button type="button" class="btn btn-sm btn-outline-danger"
                  (click)="removeExperience(i)"
                  [disabled]="experiences.length === 1">
            <i class="fas fa-times"></i>
          </button>
        </div>
      </div>

      <div class="card-body">
        <div class="row">
          <div class="col-md-6">
            <div class="mb-3">
              <label class="form-label">公司名称</label>
              <input type="text" class="form-control"
                     formControlName="company"
                     [class.is-invalid]="experience.get('company')?.invalid &&
                                         experience.get('company')?.touched">
              <div *ngIf="experience.get('company')?.invalid &&
                         experience.get('company')?.touched" class="error-message">
                公司名称是必填项
              </div>
            </div>
          </div>

          <div class="col-md-6">
            <div class="mb-3">
              <label class="form-label">职位</label>
              <input type="text" class="form-control"
                     formControlName="position"
                     [class.is-invalid]="experience.get('position')?.invalid &&
                                         experience.get('position')?.touched">
              <div *ngIf="experience.get('position')?.invalid &&
                         experience.get('position')?.touched" class="error-message">
                职位是必填项
              </div>
            </div>
          </div>

          <div class="col-md-6">
            <div class="mb-3">
              <label class="form-label">开始日期</label>
              <input type="date" class="form-control"
                     formControlName="startDate"
                     [class.is-invalid]="experience.get('startDate')?.invalid &&
                                         experience.get('startDate')?.touched">
            </div>
          </div>

          <div class="col-md-6">
            <div class="mb-3">
              <label class="form-label">结束日期</label>
              <input type="date" class="form-control"
                     formControlName="endDate"
                     [disabled]="experience.get('current')?.value">
            </div>
          </div>

          <div class="col-md-12">
            <div class="form-check mb-3">
              <input class="form-check-input" type="checkbox"
                     formControlName="current" id="current{{i}}">
              <label class="form-check-label" for="current{{i}}">
                目前在此工作
              </label>
            </div>
          </div>

          <div class="col-md-12">
            <div class="mb-3">
              <label class="form-label">工作描述</label>
              <textarea class="form-control" rows="3"
                        formControlName="description"></textarea>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>

  <!-- 表单提交按钮 -->
  <div class="mt-4">
    <button type="submit" class="btn btn-primary me-2"
            [disabled]="dynamicForm.invalid">
      提交表单
    </button>
    <button type="button" class="btn btn-secondary"
            (click)="resetForm()">
      重置表单
    </button>
  </div>
</form>

5. 表单状态管理

响应式表单提供了丰富的状态管理功能,可以监控表单的变化过程。

表单状态类型
VALID / INVALID

表单验证状态:有效/无效

form.valid / form.invalid
PENDING

异步验证进行中

form.pending
DIRTY / PRISTINE

表单是否被修改过:已修改/未修改

form.dirty / form.pristine
TOUCHED / UNTOUCHED

表单是否被访问过:已访问/未访问

form.touched / form.untouched

监听表单状态变化

// 监听表单值变化
this.userForm.valueChanges.subscribe(value => {
  console.log('表单值变化:', value);
  // 可以在这里执行实时验证或自动保存
});

// 监听表单状态变化
this.userForm.statusChanges.subscribe(status => {
  console.log('表单状态变化:', status);

  switch(status) {
    case 'VALID':
      console.log('表单验证通过');
      break;
    case 'INVALID':
      console.log('表单验证失败');
      break;
    case 'PENDING':
      console.log('表单验证中...');
      break;
    case 'DISABLED':
      console.log('表单已禁用');
      break;
  }
});

// 监听特定字段变化
const usernameControl = this.userForm.get('username');
usernameControl?.valueChanges.subscribe(value => {
  console.log('用户名变化:', value);
});

usernameControl?.statusChanges.subscribe(status => {
  console.log('用户名验证状态:', status);
});

// 获取表单状态快照
getFormSnapshot(): void {
  const snapshot = {
    value: this.userForm.value,
    status: this.userForm.status,
    valid: this.userForm.valid,
    invalid: this.userForm.invalid,
    pending: this.userForm.pending,
    dirty: this.userForm.dirty,
    pristine: this.userForm.pristine,
    touched: this.userForm.touched,
    untouched: this.userForm.untouched,
    errors: this.userForm.errors
  };

  console.log('表单快照:', snapshot);
}

// 重置表单状态
resetFormState(): void {
  // 重置表单值但保持状态
  this.userForm.reset();

  // 重置表单值到初始状态
  this.userForm.reset({
    username: '',
    email: '',
    gender: 'male'
  });

  // 重置并清除状态标记
  this.userForm.reset({}, { emitEvent: false });
  this.userForm.markAsPristine();
  this.userForm.markAsUntouched();
}

// 启用/禁用表单
toggleForm(): void {
  if (this.userForm.disabled) {
    this.userForm.enable();
  } else {
    this.userForm.disable();
  }
}

// 启用/禁用特定字段
toggleField(fieldName: string): void {
  const control = this.userForm.get(fieldName);
  if (control?.disabled) {
    control.enable();
  } else {
    control?.disable();
  }
}
表单状态实时监控

响应式表单最佳实践

推荐做法
  • 使用FormBuilder创建表单,代码更简洁
  • 为复杂表单创建自定义验证器
  • 使用valueChanges进行实时验证和交互
  • 为动态表单使用FormArray
  • 在模板中正确显示验证错误信息
  • 使用markAsTouched在提交时显示所有错误
  • 合理使用表单状态(dirty、pristine等)
  • 为表单创建可复用的组件
应避免的做法
  • 避免在模板中编写复杂逻辑
  • 不要忘记取消订阅valueChanges
  • 避免过度验证影响用户体验
  • 不要在组件中直接修改表单值(使用setValue/patchValue)
  • 避免创建过于庞大的表单组
  • 不要忽略异步验证的处理
  • 避免在验证器中执行耗时操作
  • 不要忘记处理表单提交失败的情况

常用表单模式速查表

模式 用途 示例
基础表单 简单数据收集 this.fb.group({username: ''})
嵌套表单 复杂数据结构 address: this.fb.group({...})
动态表单 可变字段数量 this.fb.array([...])
条件表单 根据条件显示字段 使用valueChanges监听
分步表单 多步骤表单流程 多个FormGroup + 路由
搜索表单 实时搜索过滤 valueChanges + debounceTime
上传表单 文件上传 FormData + HttpClient
向导表单 多页面表单向导 状态管理 + 路由守卫

本章总结

通过本章学习,你应该掌握了:

  • 响应式表单创建:使用ReactiveFormsModuleFormBuilder
  • 表单控件类型FormControlFormGroupFormArray
  • 表单验证:内置验证器、自定义验证器、异步验证器
  • 动态表单:使用FormArray创建可增删的表单字段
  • 表单状态管理:监听表单变化、管理表单状态
  • 模板集成:在模板中显示表单和验证错误
  • 最佳实践:编写高效、可维护的响应式表单