我们已经习惯了使用 <input>, <select> 等标准的表单控件。但如果,你的产品经理提出了一个「天马行空」的需求呢?

  • 「我想要一个可以拖拽打分的星级评分控件!」
  • 「这里需要一个能拾取颜色的调色盘!」

这时,你会发现,Angular 的内置控件已经不够用了。你需要的,是创造一个属于你自己的、能与 Angular 表单体系(无论是模板驱动还是响应式)无缝协作的自定义表单控件

实现这一「独家定制」魔法的「秘密契约」,就是 ControlValueAccessor

「翻译官」的使命:ControlValueAccessor 是什么?#

ControlValueAccessor (简称 CVA) 是一个接口。任何一个组件,只要实现了这个接口,就等于在告诉 Angular:「嘿,我虽然外表特立独行,但我懂得你们表单世界的『官方语言』!你可以像对待普通 <input> 一样对待我。」

比喻一下FormControl 是一个标准的「PS5游戏手柄」,而你的「星级评分组件」是一个你亲手打造的、独一無二的「高达模型」。ControlValueAccessor 就是那个「万能转换器」,它负责将手柄的通用信号(「设置值」、「禁用」),翻译成高达能听懂的指令;同时,当用户手动掰动高达的机械臂时(用户交互),它又负责将这个动作,翻译成手柄能理解的「值已改变」的信号,并报告回去。

文生图:一个PS5游戏手柄(FormControl),通过一个发光的、标有“CVA”的适配器,连接到一个复杂的机器人模型上。手柄发出的信号通过适配器转换后,控制着机器人的动作。风格:科技感、概念图解。

「万能转换器」的四个接口#

要实现这个「转换器」,你需要实现 ControlValueAccessor 接口中最多四个关键方法。

  1. writeValue(obj: any): void

    • 方向:FormControl -> 你的组件

    • 作用:当表单模型从外部被修改时(比如调用了 form.patchValue()),Angular 会调用此方法,并将新值 obj 传给你。你的任务,就是接收这个值,并用它来更新你组件的内部 UI。

    • 翻译:「手柄说:目标值是 5。」

  2. registerOnChange(fn: any): void

    • 方向:你的组件 -> FormControl

    • 作用:在初始化时,Angular 会传给你一个回调函数 fn(我们通常叫它 onChange)。你必须把它保存起来。当你的组件内部因为用户交互而导致值发生变化时,你必须调用这个保存好的 onChange(newValue) 函数,把新值报告给 FormControl

    • 翻译:「手柄说:这是我的『报告专线』,有情况随时打给我。」

  3. registerOnTouched(fn: any): void

    • 方向:你的组件 -> FormControl

    • 作用:与 registerOnChange 类似,Angular 会传给你另一个回调函数 fn(我们通常叫它 onTouched)。你应该在你的组件被视为「已接触」(通常是在 blur 事件或首次交互后)时,调用一次这个 onTouched() 函数。

    • 翻译:「手柄说:这是我的『碰触』报告线,碰过了就打一声招呼。」

  4. setDisabledState?(isDisabled: boolean): void (可选)

    • 方向:FormControl -> 你的组件

    • 作用:当 FormControl 的禁用状态被改变时(如调用 form.disable()),Angular 会调用此方法。你可以在这里实现让你组件的 UI 呈现「禁用」样式的逻辑。

    • 翻译:「手柄说:现在进入/解除禁用模式。」

实战:打造一个「星级评分」控件#

让我们从零开始,创建一个 <app-star-rating> 组件。

第一步:创建组件基础 UI

// star-rating.component.ts
@Component({
    selector: 'app-star-rating',
    standalone: true,
    imports: [CommonModule],
    template: `
    <div class="rating-container">
      @for (star of stars; track i; let i = $index) {
        <span (click)="rate(i + 1)">
          {{ i < rating ? '★' : '☆' }}
        </span>
      }
    </div>
  `,
    styles: [`...`]
})
export class StarRatingComponent {
    stars = [1, 2, 3, 4, 5];
    rating = 0;
    rate(newRating: number) {
        this.rating = newRating;
    }
}

第二步:签署「魔法契约」

让我们的组件 implements ControlValueAccessor,并提供 NG_VALUE_ACCESSOR

// star-rating.component.ts
import {forwardRef} from '@angular/core';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
@Component({
    // ...
    providers: [
        {
            provide: NG_VALUE_ACCESSOR,
            // `useExisting` 和 `forwardRef` 是固定写法,告诉 DI 系统,「我」就是那个 CVA 的实现
            useExisting: forwardRef(() => StarRatingComponent),
            multi: true // 因为一个表单元素上可能还有其他 CVA,所以设为 multi
        }
    ]
})
export class StarRatingComponent implements ControlValueAccessor {
    // ...
}

第三步:实现接口方法

export class StarRatingComponent implements ControlValueAccessor {
    stars = [1, 2, 3, 4, 5];
    rating = 0;
    // 保存 Angular 给我们的回调函数
    onChange: (value: number) => void = () => {
    };
    onTouched: () => void = () => {
    };
    // 1. 实现 writeValue
    writeValue(value: number): void {
        this.rating = value ?? 0;
    }
    // 2. 实现 registerOnChange
    registerOnChange(fn: any): void {
        this.onChange = fn;
    }
    // 3. 实现 registerOnTouched
    registerOnTouched(fn: any): void {
        this.onTouched = fn;
    }
    // 4. 修改我们自己的交互逻辑
    rate(newRating: number) {
        this.rating = newRating;
        this.onChange(this.rating); // !! 关键:当内部值变化时,调用 onChange 报告给 FormControl
        this.onTouched(); // 顺便标记为已接触
    }
}

第四步:像普通控件一样使用它!

现在,你的 <app-star-rating> 已经是一个合格的 Angular 表单控件了!

<!-- 在响应式表单中使用 -->
<form [formGroup]="myForm">
    <app-star-rating formControlName="rating"></app-star-rating>
</form>
<!-- 在模板驱动表单中使用 -->
<app-star-rating name="rating" [(ngModel)]="myRating"></app-star-rating>

结语#

ControlValueAccessor 是连接你天马行空的创造力与 Angular 强大表单工程体系之间的「魔法桥梁」。掌握了它,你就能将任何复杂的交互组件,都驯服成表单世界里温顺、可靠的一员。

从此,你不再仅仅是表单控件的「消费者」,更是「创造者」。那些为你的应用量身打造的、独一无二的输入体验,正等待着你用这把「钥匙」去开启。

正如古人所言:「工欲善其事,必先利其器。」意指工匠想要做好工作,必须首先使他的工具精良。掌握 ControlValueAccessor,正是我们前端工程师「利其器」的重要一步,它让我们的「工具箱」不再受限于标准,而是能够根据实际需求,「独家定制」最趁手的「神兵利器」,从而更好地「善其事」。