Skip to content

Angular 响应式表单进阶:动态表单与自定义验证器

Angular 的响应式表单(Reactive Forms)在复杂表单场景下远比模板驱动表单更可控。这篇文章聚焦三个进阶场景:动态添加/删除表单项(FormArray)、跨字段联合验证器、以及实现自定义可重用的表单控件(ControlValueAccessor)。

FormArray:动态表单项

typescript
@Component({
  selector: "app-invoice-form",
  template: `
    <form [formGroup]="form">
      <div formArrayName="items">
        <div
          *ngFor="let item of items.controls; let i = index"
          [formGroupName]="i"
        >
          <input formControlName="name" placeholder="商品名称" />
          <input formControlName="qty" type="number" placeholder="数量" />
          <input formControlName="price" type="number" placeholder="单价" />
          <button type="button" (click)="removeItem(i)">删除</button>
        </div>
      </div>
      <button type="button" (click)="addItem()">添加商品</button>
      <p>合计:{{ total | currency: "CNY" }}</p>
    </form>
  `,
})
export class InvoiceFormComponent {
  form = new FormGroup({
    customer: new FormControl("", Validators.required),
    items: new FormArray<FormGroup>([this.createItem()]),
  });

  get items() {
    return this.form.get("items") as FormArray;
  }

  get total() {
    return this.items.controls.reduce((sum, ctrl) => {
      const { qty, price } = ctrl.value;
      return sum + (qty || 0) * (price || 0);
    }, 0);
  }

  createItem(): FormGroup {
    return new FormGroup({
      name: new FormControl("", Validators.required),
      qty: new FormControl(1, [Validators.required, Validators.min(1)]),
      price: new FormControl(0, [Validators.required, Validators.min(0)]),
    });
  }

  addItem() {
    this.items.push(this.createItem());
  }
  removeItem(i: number) {
    this.items.removeAt(i);
  }
}

跨字段验证器

typescript
// validators/password-match.validator.ts
export function passwordMatchValidator(): ValidatorFn {
  return (group: AbstractControl): ValidationErrors | null => {
    const password = group.get("password")?.value;
    const confirm = group.get("confirmPassword")?.value;

    if (password && confirm && password !== confirm) {
      // 在 confirmPassword 控件上设置错误
      group.get("confirmPassword")?.setErrors({ passwordMismatch: true });
      return { passwordMismatch: true };
    }
    // 如果之前设置了 passwordMismatch 错误,清除它
    const errors = group.get("confirmPassword")?.errors;
    if (errors?.["passwordMismatch"]) {
      const { passwordMismatch, ...rest } = errors;
      group
        .get("confirmPassword")
        ?.setErrors(Object.keys(rest).length ? rest : null);
    }
    return null;
  };
}

// 注册表单
const registerForm = new FormGroup(
  {
    email: new FormControl("", [Validators.required, Validators.email]),
    password: new FormControl("", [
      Validators.required,
      Validators.minLength(8),
    ]),
    confirmPassword: new FormControl("", Validators.required),
  },
  {
    validators: passwordMatchValidator(),
  },
);

异步验证器(用户名唯一性检查)

typescript
// validators/username-taken.validator.ts
export function usernameTakenValidator(
  userService: UserService,
): AsyncValidatorFn {
  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    if (!control.value) return of(null);

    return timer(400).pipe(
      // 防抖 400ms
      switchMap(() => userService.checkUsername(control.value)),
      map((isTaken) => (isTaken ? { usernameTaken: true } : null)),
      catchError(() => of(null)), // API 失败时不阻塞提交
    );
  };
}

// 在组件中使用
username = new FormControl("", {
  validators: [Validators.required, Validators.minLength(3)],
  asyncValidators: [usernameTakenValidator(this.userService)],
  updateOn: "blur", // 失去焦点时才触发验证
});

ControlValueAccessor:自定义表单控件

实现一个星级评分组件,可以在 [formControl][(ngModel)] 中使用:

typescript
@Component({
  selector: "app-star-rating",
  template: `
    <span
      *ngFor="let star of [1, 2, 3, 4, 5]"
      [class.filled]="star <= value"
      [class.disabled]="isDisabled"
      (click)="!isDisabled && writeValue(star)"
      >★</span
    >
  `,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => StarRatingComponent),
      multi: true,
    },
  ],
})
export class StarRatingComponent implements ControlValueAccessor {
  value = 0;
  isDisabled = false;

  private onChange: (value: number) => void = () => {};
  private onTouched: () => void = () => {};

  // Angular 调用此方法将值写入组件(外部 → 内部)
  writeValue(value: number) {
    this.value = value || 0;
  }

  // 注册外部监听器(内部 → 外部)
  registerOnChange(fn: (value: number) => void) {
    this.onChange = fn;
  }

  registerOnTouched(fn: () => void) {
    this.onTouched = fn;
  }

  setDisabledState(disabled: boolean) {
    this.isDisabled = disabled;
  }
}

// 使用
// <app-star-rating [formControl]="ratingControl"></app-star-rating>
// <app-star-rating [(ngModel)]="rating"></app-star-rating>

表单状态可视化

typescript
// 在开发阶段快速展示表单状态(Debug 用)
@Component({
  template: `
    <form [formGroup]="form">
      <!-- 表单内容 -->
    </form>
    <!-- 开发调试面板 -->
    <pre *ngIf="isDevMode">{{ form.value | json }}</pre>
    <p>
      Valid: {{ form.valid }}, Dirty: {{ form.dirty }}, Touched:
      {{ form.touched }}
    </p>
  `,
})
export class FormDebugComponent {
  isDevMode = !environment.production;
}

总结

Angular 响应式表单的进阶能力——FormArray 的动态增删、跨字段验证器、ControlValueAccessor 自定义控件——覆盖了绝大多数复杂业务表单场景。这套 API 虽然比 ngModel 啰嗦,但换来的是完整的类型安全和可测试性(Angular 14 之后表单还将引入更强的类型推断,敬请期待)。

MIT Licensed