Skip to content
⚠️ This article was written in 2022. Some content may be outdated.

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