Angular CVA 型別安全實作指南

前言

在 Angular 中實作 ControlValueAccessor (CVA) 時,預設的介面定義使用 any 型別,這會失去 TypeScript 的型別檢查優勢。本文將介紹兩種方法來改善 CVA 的型別安全性。

問題分析

原本的 CVA 實作會遇到型別不明確的問題:

// ❌ 型別不安全的寫法
writeValue(value: any): void {
  this.value = value;
}

registerOnChange(fn: any): void {
  this.onChange = fn;
}

這樣的寫法會失去:

  • 編譯時期的型別檢查

  • IDE 的自動完成功能

  • 重構時的安全性

方案一:直接型別定義(推薦)

對於單一元件,最簡單的方式是直接為每個方法定義明確的型別。

Checkbox 元件範例

export class CvaCheckboxComponent implements ControlValueAccessor {
  private value: boolean = false;

  writeValue(value: boolean | null): void {
    this.value = value ?? false;
  }

  registerOnChange(fn: (value: boolean) => void): void {
    this.onChange = fn;
  }

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

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

  // 私有回調函數也加上型別
  private onChange: (value: boolean) => void = () => {};
  private onTouched: () => void = () => {};

  onCheckboxChange(event: MatCheckboxChange) {
    this.value = event.checked;
    this.onChange(this.value); // 現在有型別檢查了!
    this.onTouched();
  }
}

其他常見元件的型別

// Input 元件
writeValue(value: string | null): void
registerOnChange(fn: (value: string | null) => void): void

// Select/Radio 元件  
writeValue(value: string | number | null): void
registerOnChange(fn: (value: string | number | null) => void): void

// File Upload 元件
writeValue(value: File | null): void
registerOnChange(fn: (value: File | null) => void): void

優點

  • 實作簡單直接

  • 適合個別元件的客製化需求

  • 不需要額外的基類

缺點

  • 每個 CVA 元件都要重複定義型別

  • 如果有很多 CVA 元件會有重複程式碼

方案二:泛型基類(適合大型專案)

當專案中有多個 CVA 元件時,可以建立一個泛型基類來避免重複程式碼。

檔案結構規劃

泛型基類應該另外創建一個 TypeScript 檔案,建議的檔案結構:

src/
└── shared/
    └── components/
        └── form/
            ├── typed-control-value-accessor.ts  👈 泛型基類
            ├── cva-checkbox/
            │   └── cva-checkbox.component.ts
            ├── cva-input/
            │   └── cva-input.component.ts
            └── cva-upload-file/
                └── cva-upload-file.component.ts

其他可考慮的位置:

  • src/core/base/typed-control-value-accessor.ts - 更通用的核心位置

  • src/lib/form/typed-control-value-accessor.ts - 如果有 shared library

建立泛型基類

typed-control-value-accessor.ts

export abstract class TypedControlValueAccessor<T> implements ControlValueAccessor {
  protected value: T | null = null;
  protected disabled = false;

  writeValue(value: T | null): void {
    this.value = value;
  }

  registerOnChange(fn: (value: T | null) => void): void {
    this.onChange = fn;
  }

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

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

  // 受保護的回調函數,子類可以使用
  protected onChange: (value: T | null) => void = () => {};
  protected onTouched: () => void = () => {};

  // 子類可以 override 這個方法來處理值的變更
  protected updateValue(newValue: T | null): void {
    this.value = newValue;
    this.onChange(this.value);
    this.onTouched();
  }
}

使用泛型基類

使用時需要先 import 基類:

// cva-checkbox.component.ts
import { TypedControlValueAccessor } from '../typed-control-value-accessor';

然後繼承基類並指定泛型型別:

// Checkbox 元件
export class CvaCheckboxComponent extends TypedControlValueAccessor<boolean> {
  constructor(@Optional() @Self() public ngControl: NgControl) {
    super();
    if (this.ngControl != null) {
      this.ngControl.valueAccessor = this;
    }
  }

  onCheckboxChange(event: MatCheckboxChange) {
    this.updateValue(event.checked); // 使用基類的方法
  }
}

// Input 元件
export class CvaInputComponent extends TypedControlValueAccessor<string> {
  onInputChange(event: Event) {
    const target = event.target as HTMLInputElement;
    this.updateValue(target.value);
  }
}

// File Upload 元件
export class CvaUploadFileComponent extends TypedControlValueAccessor<File> {
  onFileSelected(event: Event) {
    const input = event.target as HTMLInputElement;
    const file = input.files?.[0] || null;
    this.updateValue(file);
  }
}

優點

  • 避免重複程式碼

  • 統一的 CVA 實作模式

  • 型別安全且易於維護

  • 可以在基類中加入通用的驗證邏輯

缺點

  • 增加了抽象層級

  • 對於簡單的元件可能過度設計

建議的選擇策略

選擇方案一的情境

  • 專案中只有少數幾個 CVA 元件

  • 每個元件的邏輯差異很大

  • 團隊偏好簡單直接的實作方式

選擇方案二的情境

  • 專案中有多個 CVA 元件(5 個以上)

  • 希望統一 CVA 的實作模式

  • 需要在所有 CVA 元件中加入共通邏輯

實務技巧

1. 處理 null 值

writeValue(value: boolean | null): void {
  // 使用 ?? 運算子提供預設值
  this.value = value ?? false;
}

2. 型別守衛

writeValue(value: string | null): void {
  if (typeof value === 'string') {
    this.value = value.trim(); // 安全地使用字串方法
  } else {
    this.value = '';
  }
}

3. 事件處理的型別安全

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

onCheckboxChange(event: MatCheckboxChange) {
  this.value = event.checked;
  this.onChange(this.value); // TypeScript 會檢查型別匹配
}

結論

兩種方案都能有效提升 CVA 的型別安全性。方案一適合大多數情況,實作簡單且直接;方案二適合有多個 CVA 元件的大型專案,能提供更好的程式碼重用性和一致性。

選擇哪種方案主要取決於專案規模和團隊偏好,但重要的是要擺脫 any 型別,讓 TypeScript 的型別系統發揮最大效用。

Last updated