哈囉,各位邦友們!
昨天我們把 Heroes 頁面的新增與編輯流程全面重構為 Reactive Forms。
今天則要把自訂驗證訊息放進來,透過 Angular 內建的 Validators 與自訂檢查,在驗證輸入時給使用者更多指引。
Validators.maxLength、Validators.pattern 等同步檢查。mg serve,並能呼叫 in-memory API。Validators 與 FormControl 狀態 (touched, pending 等)。一、集中管理錯誤訊息與保留字
我們先補上需要的匯入、常數與 helper,避免在模板裡到處硬寫字串。
// projects/hero-journey/src/app/heroes/heroes.component.ts
import {
  AbstractControl,
  AsyncValidatorFn,
  FormBuilder,
  FormControl,
  FormGroup,
  ReactiveFormsModule,
  ValidationErrors,
  ValidatorFn,
  Validators,
} from '@angular/forms';
// ...existing code...
import {
  EMPTY,
  Subject,
  catchError,
  debounceTime,
  distinctUntilChanged,
  filter,
  finalize,
  map,
  of,
  switchMap,
  tap,
  timer,
} from 'rxjs';
const HERO_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9\s'-]{2,23}$/;
const RESERVED_HERO_NAMES = ['admin', 'root', 'unknown'] as const;
function heroNameReservedValidator(names: readonly string[]): ValidatorFn {
  const normalized = names.map((name) => name.trim().toLowerCase());
  return (control) => {
    const value = (control.value ?? '').trim().toLowerCase();
    if (!value) {
      return null;
    }
    return normalized.includes(value) ? { reserved: true } : null;
  };
}
在類別裡加入錯誤訊息對照與 helper,讓範本單純呼叫 controlError()。
export class HeroesComponent {
  // ...existing code...
  private lastEditHeroId: number | null = null;
  private readonly validationMessages: Record<string, string> = {
    required: '名稱必填,不能空白。',
    minlength: '請至少輸入 3 個字。',
    maxlength: '名稱不可超過 24 個字。',
    pattern: '僅允許英文、數字、空白與 -′ 字元。',
    reserved: '這個名稱被列為保留字,請換一個。',
    duplicated: '已有英雄使用這個名稱。',
  };
  protected controlError(control: AbstractControl | null): string | null {
    if (!control || control.disabled || !control.invalid || !control.touched) {
      return null;
    }
    const errors = control.errors as ValidationErrors | null;
    if (!errors) {
      return null;
    }
    for (const key of Object.keys(errors)) {
      const message = this.validationMessages[key];
      if (message) {
        return message;
      }
    }
    return '輸入格式不正確,請再試一次。';
  }
  // ...existing code...
}
二、擴充同步驗證規則
把新增與編輯表單的 name 控制項改成物件寫法,加入更多同步驗證並設定 updateOn: 'blur',避免每個字都觸發非同步檢查。
export class HeroesComponent {
  // ...existing code...
  protected readonly createForm: HeroFormGroup = this.fb.nonNullable.group({
    name: this.fb.nonNullable.control('', {
      validators: [
        Validators.required,
        Validators.minLength(3),
        Validators.maxLength(24),
        Validators.pattern(HERO_NAME_PATTERN),
        heroNameReservedValidator(RESERVED_HERO_NAMES),
      ],
      updateOn: 'blur',
    }),
    rank: this.fb.nonNullable.control<HeroRank>(''),
  });
  protected readonly editForm: HeroFormGroup = this.fb.nonNullable.group({
    name: this.fb.nonNullable.control('', {
      validators: [
        Validators.required,
        Validators.minLength(3),
        Validators.maxLength(24),
        Validators.pattern(HERO_NAME_PATTERN),
        heroNameReservedValidator(RESERVED_HERO_NAMES),
      ],
      updateOn: 'blur',
    }),
    rank: this.fb.nonNullable.control<HeroRank>(''),
  });
  // ...existing code...
}
三、加入自訂重複名稱驗證
接著建立一個 AsyncValidatorFn,先比對目前記憶體內的英雄,再透過 HeroService.search$ 與伺服器確認是否重複。
為了降低負擔,我們用 timer(300) 做簡單 debounce。
export class HeroesComponent {
  // ...existing code...
  private heroNameTakenValidator(): AsyncValidatorFn {
    return (control) => {
      const raw = (control.value ?? '').trim();
      if (!raw) {
        return of(null);
      }
      const value = raw.toLowerCase();
      const currentId = this.selectedId();
      const selected = this.selectedHero();
      if (
        selected &&
        selected.id === currentId &&
        selected.name.trim().toLowerCase() === value
      ) {
        return of(null);
      }
      const existsLocally = this.heroes().some(
        (hero) => hero.name.toLowerCase() === value && hero.id !== currentId
      );
      if (existsLocally) {
        return of({ duplicated: true });
      }
      return timer(300).pipe(
        switchMap(() => this.heroService.search$(raw)),
        map((heroes) => {
          const taken = heroes.some(
            (hero) => hero.name.toLowerCase() === value && hero.id !== currentId
          );
          return taken ? { duplicated: true } : null;
        }),
        catchError(() => of(null))
      );
    };
  }
  constructor() {
    // ...existing constructor code...
    for (const control of [this.createForm.controls.name, this.editForm.controls.name]) {
      control.addAsyncValidators(this.heroNameTakenValidator());
      control.updateValueAndValidity({ onlySelf: true, emitEvent: false });
    }
    effect(() => {
      const selected = this.selectedHero();
      if (!selected) {
        this.lastEditHeroId = null;
        this.editForm.reset({ name: '', rank: '' }, { emitEvent: false });
        this.editForm.markAsPristine();
        this.editForm.markAsUntouched();
        this.editFormValue.set({ name: '', rank: '' });
        this.saveError.set(null);
        return;
      }
      const desiredValue = {
        name: selected.name,
        rank: (selected.rank as HeroRank) ?? '',
      };
      const isNewSelection = this.lastEditHeroId !== selected.id;
      const currentValue = this.editForm.getRawValue();
      if (!isNewSelection && this.editForm.dirty) {
        return;
      }
      const alreadySynced =
        currentValue.name === desiredValue.name && currentValue.rank === desiredValue.rank;
      if (!isNewSelection && alreadySynced) {
        return;
      }
      this.lastEditHeroId = selected.id;
      const formValue = {
        name: desiredValue.name,
        rank: desiredValue.rank,
      };
      this.editForm.reset(formValue, { emitEvent: false });
      this.editForm.markAsPristine();
      this.editForm.markAsUntouched();
      this.editFormValue.set(formValue);
      this.saveError.set(null);
    }); 
  }
  // ...existing code...
}
四、在模板顯示錯誤與 pending 狀態
最後更新範本,改為呼叫 controlError(),並在非同步驗證進行時顯示提示。
<!-- projects/hero-journey/src/app/heroes/heroes.component.html -->
<input
  id="new-hero"
  placeholder="enter new hero"
  formControlName="name"
  type="text" />
@if (controlError(createForm.controls.name); as err) {
  <small class="error">{{ err }}</small>
} @else if (createForm.controls.name.pending) {
  <small class="hint">檢查名稱中...</small>
}
<!-- ...existing code... -->
<button type="submit" [disabled]="creating() || createForm.invalid || createForm.pending">
  @if (creating()) { Saving... } @else { Add }
</button>
同樣套用在編輯面板:
<input id="hero-name" type="text" formControlName="name" />
@if (controlError(editForm.controls.name); as err) {
  <small class="error">{{ err }}</small>
} @else if (editForm.controls.name.pending) {
  <small class="hint">檢查名稱中...</small>
}
<!-- ...existing code... -->
<button
  type="button"
  (click)="saveSelected()"
  [disabled]="saving() || editForm.invalid || editForm.pending || !dirtyCompared()">
  @if (saving()) { Saving... } @else { Save }
</button>
五、調整樣式
幫 hint 與  error 新增樣式,確保顏色區隔開來。
.error {
  color: #d92d20;
  font-size: 0.85rem;
}
.hint {
  color: #475467;
  font-size: 0.85rem;
}
驗收清單:
ad 並離開輸入框會看到最少字數提示;輸入 admin 則顯示保留字訊息。 
 
 
 
今日小結:
我們把 Reactive Forms 用內建 Validators、寫出自己的檢查邏輯,在面對多欄位、多規則的表單時,就能用一致的方式管理品質。
參考資料: