ControlValueAccessor

ControlValueAccessor 是一个连接表单模型和视图(DOM元素)的接口,自定义的表单控件必须实现这个接口,它的作用是:

  • 把 form 模型中值映射到视图中
  • 当视图发生变化时,通知 form directives 或 form controls

Angular 引入这个接口的原因是,不同的输入控件数据更新方式是不一样的。例如,对于我们常用的文本输入框来说,我们是设置它的 value 值,而对于复选框 (checkbox) 我们是设置它的 checked 属性。实际上,不同类型的输入控件都有一个 ControlValueAccessor,用来更新视图

Angular 中常见的 ControlValueAccessor 有:

  • DefaultValueAccessor - 用于 text 和 textarea 类型的输入控件
  • SelectControlValueAccessor - 用于 select 选择控件
  • CheckboxControlValueAccessor - 用于 checkbox 复选控件

实现ControlValueAccessor接口

首先我们先看一下 ControlValueAccessor 接口,具体如下:

export interface ControlValueAccessor {
  writeValue(obj: any): void;
  registerOnChange(fn: any): void;
  registerOnTouched(fn: any): void;
  setDisabledState?(isDisabled: boolean): void;
}
  • writeValue(obj: any):该方法用于将模型中的新值写入视图或 DOM 属性中,即model->view
  • registerOnChange(fn: any):设置当控件接收到 change 事件后,调用的函数,可以用来通知外部,组件已经发生变化,即view->model
  • registerOnTouched(fn: any):设置当控件接收到 touched 事件后,调用的函数
  • setDisabledState?(isDisabled: boolean):当控件状态变成 DISABLED 或从 DISABLED 状态变化成 ENABLE 状态时,会调用该函数。该函数会根据参数值,启用或禁用指定的 DOM 元素。

当表单初始化的时候,将会使用表单模型中对应的初始值作为参数,调用 writeValue() 方法

<form #form="ngForm">
  <exe-counter name="counter" ngModel></exe-counter>
  <button type="submit">Submit</button>
</form>

你会发现,我们没有为 CounterComponent 组件设置初始值,因此我们要调整一下 writeValue() 中的代码,具体如下:

writeValue(value: any) {
  if (value) {
    this.count = value;//接收从表单模型层传进来的数据
  }
}

现在,只有当合法值 (非 undefined、null、"") 写入控件时,它才会覆盖默认值。接下来,我们来实现 registerOnChange() 和 registerOnTouched() 方法。registerOnChange() 可以用来通知外部,组件已经发生变化。registerOnChange() 方法接收一个 fn 参数,用于设置当控件接收到 change 事件后,调用的函数。而对于 registerOnTouched() 方法,它也支持一个 fn 参数,用于设置当控件接收到 touched 事件后,调用的函数。示例中我们不打算处理 touched 事件,因此 registerOnTouched() 我们设置为一个空函数。具体如下:

@Component(...)
class CounterComponent implements ControlValueAccessor {
  ...
  propagateChange = (_: any) => {};

  registerOnChange(fn: any) {
    this.propagateChange = fn;//每次控件view层的值发生改变,都要调用该方法通知外部
  }

  registerOnTouched(fn: any) {}
}

注册成为表单控件

  • NG_VALUE_ACCESSOR:token类型为ControlValueAccessor,将控件本身注册到DI框架成为一个可以让表单访问其值的控件
  • NG_VALIDATORS:将控件注册成为一个可以让表单得到其验证状态的控件,NG_VALIDATORS的token类型为function或Validator,配合useExisting,可以让控件只暴露对应的function或Validator的validate方法。针对token为Validator类型来说,控件实现了validate方法就可以实现表单控件验证
  • forwardRef:向前引用,允许我们引用一个尚未定义的对象
  • multi:设为true,表示这个token对应多个依赖项,使用相同的token去获取依赖项的时候,获取的是已注册的依赖对象列表。如果不设置multi为true,那么对于相同token的提供商来说,后定义的提供商会覆盖前面已定义的提供商

注册表单控件:

步骤一:创建Token为NG_VALUE_ACCESSOR的提供商

@Component({
    selector: 'exe-counter',
    ...
    providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => CounterComponent ),
      multi: true
    }
  ]
})

步骤二:创建Token为NG_VALIDATORS的表单控件验证器

@Component({
    selector: 'exe-counter',
    ...
    providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => CounterComponent ),
      multi: true
    },
    {
      provide: NG_VALIDATORS,
      useValue: validateCounterRange,
      multi: true
    }
  ]

CounterComponent 组件的完整代码如下:

import { Component, Input, forwardRef } from '@angular/core';
import {
    ControlValueAccessor, NG_VALUE_ACCESSOR, NG_VALIDATORS,
    AbstractControl, ValidatorFn, ValidationErrors, FormControl
} from '@angular/forms';

export const EXE_COUNTER_VALUE_ACCESSOR: any = {
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => CounterComponent),
    multi: true
};

export const validateCounterRange: ValidatorFn = (control: AbstractControl): 
  ValidationErrors => {
    return (control.value > 10 || control.value < 0) ?
        { 'rangeError': { current: control.value, max: 10, min: 0 } } : null;
};

export const EXE_COUNTER_VALIDATOR = {
    provide: NG_VALIDATORS,
    useValue: validateCounterRange,
    multi: true
};

@Component({
    selector: 'exe-counter',
    template: `
    <div>
      <p>当前值: {{ count }}</p>
      <button (click)="increment()"> + </button>
      <button (click)="decrement()"> - </button>
    </div>
    `,
    providers: [EXE_COUNTER_VALUE_ACCESSOR, EXE_COUNTER_VALIDATOR]
})
export class CounterComponent implements ControlValueAccessor {
    @Input() _count: number = 0;

    get count() {
        return this._count;
    }

    set count(value: number) {
        this._count = value;
        this.propagateChange(this._count);
    }

    propagateChange = (_: any) => { };

    writeValue(value: any) {
        if (value) {
            this.count = value;
        }
    }

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

    registerOnTouched(fn: any) { }

    increment() {
        this.count++;
    }

    decrement() {
        this.count--;
    }
}