Reactive Form 驗證效能優化

問題描述

在 Angular 的 Reactive Forms 中,當在模板中直接調用方法來檢查表單控制項的有效性時,會導致效能問題。這是因為:

  1. 每次變更檢測週期,這些方法都會被多次調用

  2. 對於表單中的每個欄位,這些方法都會被重複執行

  3. 這種方式會導致不必要的計算和頻繁的 DOM 更新

原始問題 code

Template 部分:

<input
  #title
  type="text"
  name="title"
  id="title"
  [class.error]="isFieldInvalid('title')"
  formControlName="title"
/>
<div class="input-hint">
  <mat-hint>
    <span [class.error]="isFieldInvalid('title')">{{
      title.value.length.toLocaleString()
    }}</span>
    /{{ titleMaxLength.toLocaleString() }}</mat-hint
  >
  @if(isFieldInvalid('title')) {
  <p class="error-message">{{ getFieldError("title") }}</p>
  }
</div>

Component 部分:

// 如果控制項存在,且已經互動過,且其值無效,則返回 true(顯示錯誤訊息)
isFieldInvalid(fieldName: string, index: number = 0): boolean {
  if (fieldName === 'options') {
    const control = this.optionArray.controls[index];
    return !!control && control.touched && control.invalid;
  } else {
    const control = this.createEventForm.get(fieldName);
    return !!control && control.touched && control.invalid;
  }
}

// 表單定義
createEventForm = new FormGroup({
  title: new FormControl('', {
    validators: [
      Validators.required,
      Validators.maxLength(this.titleMaxLength),
    ],
  }),
  description: new FormControl('', {
    validators: [Validators.maxLength(this.descriptionMaxLength)],
  }),
  endTime: new FormControl(null, {
    validators: [Validators.required, mustEarlierThanCurrent],
  }),
  options: new FormArray([
    new FormControl('', {
      validators: [
        Validators.required,
        Validators.maxLength(this.optionMaxLength),
      ],
    }),
    new FormControl('', {
      validators: [
        Validators.required,
        Validators.maxLength(this.optionMaxLength),
      ],
    }),
  ]),
});

優化方案比較

方案 1:使用 OnPush 變更檢測策略

@Component({
  // ...
  changeDetection: ChangeDetectionStrategy.OnPush,
})

優點:

  • 實現簡單,只需添加一行程式碼

  • 減少整體元件的變更檢測頻率

缺點:

  • 只是減少了調用頻率,沒有解決重複執行的根本問題

  • 僅適用於相對簡單的表單

方案 2:使用嵌套條件檢查

@if(title.dirty) {
  @if(isFieldInvalid('title')) {
    <p class="error-message">{{ getFieldError("title") }}</p>
  }
}

優點:

  • 實現簡單,不需要更改元件邏輯

  • 只有當用戶與欄位互動後,才會執行驗證方法

  • 可以與 OnPush 變更檢測結合使用

缺點:

  • 只是減少了調用次數,同一變更檢測週期內仍有重複計算

  • 模板程式碼變得更複雜

  • 不符合反應式編程思想

方案 3:使用 getter 存取器

// 元件中定義 getter
get isTitleInvalid(): boolean {
  const control = this.createEventForm.get('title');
  return !!control && control.touched && control.invalid;
}

// 其他欄位的 getter...
<!-- 模板中使用 -->
<input [class.error]="isTitleInvalid" ... />

@if(isTitleInvalid) {
  <p class="error-message">{{ getFieldError("title") }}</p>
}

優點:

  • 模板程式碼更簡潔易讀

  • 將驗證邏輯集中在元件類中

  • 比方法調用看起來更符合聲明式編程風格

缺點:

  • 需要為每個欄位創建單獨的 getter

  • 仍然會在每次變更檢測時重新計算

  • 從效能角度看與普通方法沒有本質區別

方案 4:使用 Signal(最佳解決方案)

Angular 的 Signal API 提供了一種反應式的方式來處理數據,具有記憶化(memoization)功能,能有效減少重複計算。

步驟 1:定義表單驗證狀態介面

interface FieldValidationState {
  isInvalid: boolean;
  errorMsg: string | null;
}

interface FormValidationState {
  title: FieldValidationState;
  description: FieldValidationState;
  endTime: FieldValidationState;
  options: FieldValidationState[];
}

步驟 2:在元件中創建 Signal

// 在元件類中添加
private formValidation = signal<FormValidationState>({
  title: { isInvalid: false, errorMsg: null },
  description: { isInvalid: false, errorMsg: null },
  endTime: { isInvalid: false, errorMsg: null },
  options: [
    { isInvalid: false, errorMsg: null },
    { isInvalid: false, errorMsg: null }
  ]
});

// 提供給模板的唯讀 Signal
readonly formState = this.formValidation.asReadonly();

步驟 3:監聽表單變化並更新 Signal

ngOnInit() {
  // 監聽表單狀態變化
  this.subscribeToFormChanges();
}

private subscribeToFormChanges() {
  // 監聽常規欄位狀態變化
  this.createEventForm.get('title')?.statusChanges.subscribe(() => {
    this.updateFieldValidation('title');
  });
  
  this.createEventForm.get('description')?.statusChanges.subscribe(() => {
    this.updateFieldValidation('description');
  });
  
  this.createEventForm.get('endTime')?.statusChanges.subscribe(() => {
    this.updateFieldValidation('endTime');
  });

  // 監聽選項陣列狀態變化
  this.optionArray.statusChanges.subscribe(() => {
    this.updateOptionsValidation();
  });

  // 監聽選項陣列長度變化
  this.optionArray.valueChanges.subscribe(() => {
    if (this.optionArray.length !== this.formValidation().options.length) {
      this.updateOptionsValidation();
    }
  });
}

步驟 4:實現更新驗證狀態的方法

// 更新一般欄位的驗證狀態
private updateFieldValidation(fieldName: 'title' | 'description' | 'endTime') {
  const control = this.createEventForm.get(fieldName);
  if (!control) return;

  const isInvalid = control.touched && control.invalid;
  const errorMsg = isInvalid ? this.generateErrorMessage(fieldName) : null;

  // 更新 Signal
  this.formValidation.update(state => ({
    ...state,
    [fieldName]: { isInvalid, errorMsg }
  }));
}

// 更新選項陣列的驗證狀態
private updateOptionsValidation() {
  const optionsState = this.optionArray.controls.map((control, index) => {
    const isInvalid = control.touched && control.invalid;
    const errorMsg = isInvalid ? this.generateErrorMessage('options', index) : null;
    return { isInvalid, errorMsg };
  });

  // 更新 Signal
  this.formValidation.update(state => ({
    ...state,
    options: optionsState
  }));
}

步驟 5:在模板中使用 Signal

<input
  #title
  type="text"
  name="title"
  id="title"
  [class.error]="formState().title.isInvalid"
  formControlName="title"
/>
<div class="input-hint">
  <mat-hint>
    <span [class.error]="formState().title.isInvalid">{{
      title.value.length.toLocaleString()
    }}</span>
    /{{ titleMaxLength.toLocaleString() }}</mat-hint
  >
  @if(formState().title.isInvalid) {
  <p class="error-message">{{ formState().title.errorMsg }}</p>
  }
</div>

優點:

  • 真正解決了重複計算的問題

  • 符合反應式編程的思想

  • 表單狀態變更時才會更新 Signal,避免了不必要的計算

  • 與 OnPush 變更檢測策略完美配合

缺點:

  • 初始設置較為複雜

  • 需要手動管理訂閱(記得在 ngOnDestroy 中取消訂閱)

測試

  • 原始方法

private callCount = 0;

isFieldInvalid(fieldName: string, index: number = 0): boolean {
  this.callCount++;
  console.log(`isFieldInvalid called ${this.callCount} times,欄位: ${fieldName}`);
  
  // 原始邏輯...
}
  • Signal 方法

private updateCount = 0;

private updateFieldValidation(fieldName: 'title' | 'description' | 'endTime') {
  this.updateCount++;
    console.log(`updateFieldValidation is called ${this.callCount} times);
  
  // Signal 更新邏輯...
}

送出同樣內容的表單,以一樣的操作,可以得到以下數據

測試指標
原始方法
Signal 方法
改進百分比

函式調用總次數

478

11

97.7%

最佳實踐建議

  1. 對於簡單的表單

    • 使用 OnPush 變更檢測策略 + getter 屬性存取器可能已經足夠

  2. 對於中等複雜度的表單

    • 使用 OnPush 變更檢測策略 + 嵌套條件檢查

  3. 對於複雜的表單或高性能需求

    • 使用 Signal API 是最佳選擇,儘管設置較為複雜,但能提供最佳性能

  4. 其他優化技巧

    • 避免在模板中直接調用複雜的方法

    • 將計算密集的操作移到元件類中

    • 對於表單狀態變化,考慮使用 RxJS 的 distinctUntilChanged 運算符來減少不必要的更新

    • 善用 Angular 的 NG_VALUE_ACCESSOR 和自定義表單控制項來封裝複雜的表單邏輯

    • 考慮使用 trackBy 函數來優化 *ngFor 迴圈中的表單元素

注意事項

  1. 使用 Signal API 需要 Angular 16+ 版本

  2. 使用 Signal 時,需要妥善處理訂閱以避免記憶體洩漏

  3. 過度優化可能導致程式碼複雜性增加,應根據實際性能需求選擇適當的優化方案

  4. 在優化之前,建議先使用 Angular DevTools 或瀏覽器的性能分析工具確認瓶頸

Last updated