避免使用Angular中的ControlValueAccessors陷入常见的陷阱
#javascript #forms #angular

Angular的最大优势之一是开箱即用的工具和解决方案的各种工具和解决方案。其中之一是@angular/forms软件包,它带来了使用任何类型的UI控件的扎实体验。
但是,您是否想过,这在引擎盖下如何工作?为了将formcontrol与一个普通的输入联系起来,唯一需要做的事情是使用“ formControl”绑定在输入元素上,将UI元素指向FormControl的实例。

<input type="text" [formControl]="ctrl" />

和瞧,一切都起作用。

,但显然,Angular使用的组件或指令应用于使一切实现。并且可以找到“某物” here:Angular带来了一组指令,例如default_value_accessor.tsselect_control_value_accessor.tscheckbox_value_accessor.ts等。所有这些都实现了ControlValueAccessor界面,根据Docs,该界面,该界面定义了一个界面,该界面是一个界面,该接口是一个界面Angular形式API和DOM中的本地元素之间的桥梁。”

这意味着任何组件都可以通过实现此接口并注册为NG_VALUE_ACCESSOR提供商来轻松地将其定义为表单控件。实际上,它要求您定义4种方法:

interface ControlValueAccessor {
  writeValue(obj: any): void
  registerOnChange(fn: any): void
  registerOnTouched(fn: any): void
  setDisabledState(isDisabled: boolean)?: void
}

*尽管SetDisabledState是可选的,但实际上不需要

时,只有几个罕见情况

要了解一切的确切工作原理,让我们看一下非常基本的计数器组件:

<lib-counter [formControl]="counter"></lib-counter>
<div>Counter Value: {{ counter.value }}</div>

CounterComponent UI

这是组件本身的代码:

import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

const COUNTER_CONTROL_ACCESSOR = {
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => CounterControlComponent),
    multi: true,
};

@Component({
    selector: 'lib-counter',
    template: `
        <button (click)="down()" [disabled]="disabled">Down</button>
        {{ value }}
        <button (click)="up()" [disabled]="disabled">Up</button>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush,
    providers: [COUNTER_CONTROL_ACCESSOR],
})
export class CounterControlComponent implements ControlValueAccessor {
    disabled = false;
    value = 0;

    protected onTouched: () => void;
    protected onChange: (value: number) => void;

    constructor(private _cdr: ChangeDetectorRef) {}

    up() {
        this.setValue(this.value + 1, true);
    }

    down() {
        this.setValue(this.value - 1, true);
    }

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

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

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

    writeValue(value: number) {
        this.setValue(value, false);
        this._cdr.markForCheck();
    }

    protected setValue(value: number, emitEvent: boolean) {
        const parsed = parseInt(value as any);
        this.value = isNaN(parsed) ? 0 : parsed;
        if (emitEvent && this.onChange) {
            this.onChange(value);
            this.onTouched();
        }
    }
}

您在这里看到,我们将实施4种方法并提供COUNTER_CONTROL_ACCESSOR。为了让Angular知道它处理表单控件的实例。

是必需的。

因此,控制发生了什么:

  1. 一旦初始化了FormControl,它就会在计数器组件上调用writeValueregisterOnChangeregisterOnTouched方法。这将FormControl的初始状态与我们的计数器同步,并且还将OnChanged方法传递到计数器中,因此当用户与之交互时,它可以与FormControl交谈。
  2. 当更改值时,FormControl调用writeValue方法,因此反击在不触发onChange/onTouched方法的情况下更新其内部状态。
  3. 当用户与我们的计数器进行交互时,不仅需要更新内部状态,而且还需要通知parent formcontrol有关此状态更改的信息,从而调用onChange/onTouched方法。

尽管在这里并不是很多事情要做,但值得一看一些重要的实施细节。这实际上就是本文关于

的内容

CVAS的常见陷阱以及如何避免它们

onChange仅应由内部事件触发

重要的是要记住,这些方法应仅用于将组件内部触发的更改通知FormControl。换句话说,如果FormControl更改了组件的值,则绝对不要将formcontrol通知有关此更改的formcontrol。这是一个非常普遍的错误,因为它乍一看会破坏任何东西,因此您将能够通过订阅Boundemcontrol的valueChanges注意:

export class AppComponent {
  readonly animal = new FormControl(rabbit);

  constructor() {
    ctrl.valueChanges.subscribe(console.log);
    animal.setValue(hare);
    animal.setValue(cat);
  }
}

在正常情况下,通过执行上面的代码,您只会看到2个日志:hare,cat。但是,如果您的writeValue方法最终调用了onChange,则会在输出中看到两倍的控制台日志:hareâ,hareâ,cat,cat。

在这里是CounterComponent的修改代码,可以看到此问题,当FormControl调用writeValue时,我们会使用onChange方法将其通知它:

// ... code of CounterComponent
writeValue(value: number) {
    // it's convenient to reuse existing "setValue" method, right?
    // however, this will lead to the incorrect behavior
    this.setValue(value);
    this._cdr.markForCheck();
}

protected setValue(value: number) {
    const parsed = parseInt(value as any);
    this.value = isNaN(parsed) ? 0 : parsed;
    if (this.onChange) {
        this.onChange(value);
        this.onTouched();
    }
}

onChangeonTouched不应总是一起打电话在一起

onChange/onTouched方法实际上具有完全不同的目的。当组件状态内部更改时,onChange用于传递数据,但在用户与组件进行交互后,应调用onTouched。这并不意味着组件的值已更改。

onTouched方法在两种情况下使用:

对于反向组件,触摸和变更事件都是组合的,因为与之互动的唯一方法是单击按钮。但是,对于其他组件,流将不同。例如,当用户即使通过聚焦通过将输入互动时,预计将标记为带有绑定的FormControl(带有引擎盖下的DefaultValueAccessor)的普通<input />元素。因此,对于此类组件,应从输入中将发射的发射与blur事件相关。

value & state changes

正确处理零

通过介绍键入表单,表单控件现在可以从默认值中推断出类型,也可以显式地键入。不过,这是一件有趣的事情:如果我们定义控制const control = new FormControl<string>(),然后检查其类型,那将是string | null。您可能想知道:为什么此控件的类型包括null?这是因为通过在其上调用reset()方法,该控件可以随时变为null。这里是Angular Docs的一个例子:

const control = new FormControl('Hello, world!');
control.reset();
console.log(control.value); // null

尽管这在键入形式中变得很明显,但是从一开始就在形式上固有的行为是固有的。尽管新的方便类型可能会发现控制值的问题,但它并没有使您摆脱CVA内部的任何问题。此外,由于CVA组件对其内部使用的形式没有任何控制,并且无法在形式上执行某些类型的控制,因此实际上可以实际通过任何值进入控制。因此,此值最终将传递到writeValue,这可能会破坏您的组件。

让我们更改我们的反向组件如下:

// ... code of CounterComponent
writeValue(value: number) {
    // it's convenient to reuse existing "setValue" method, right?
    // however, this will lead to the incorrect behavior
    this.setValue(value, false);
    this._cdr.markForCheck();
}

protected setValue(value: number, emitEvent: boolean) {
    this.value = value;
    if (emitEvent && this.onChange) {
        this.onChange(value);
        this.onTouched();
    }
}

behavior with and without null handling

反向组件太简单了,无法与NULL遇到大问题,因为JavaScript会将null投入0(null + 1 = 1),但是如您所见,在调用reset()之后,您可以看到组件在视觉上被损坏。因此,请记住这种行为并对writeValue方法实施一些价值保护非常重要。

使用ControlValueAccessor测试套件标准化自定义UI表单组件

即使您牢记上面列出的所有潜在陷阱,也总是有可能由于将来的变化或增强而出现问题。保持组件有效行为的最佳方法是具有广泛的单位测试覆盖范围。但是,为所有CVA组件编写相同的测试列表可能会很烦人,或者某些用例可以意外留而而没有覆盖范围。因此,拥有一个统一的测试解决方案应该更好,可以确保您的组件安全。

和一个叫做ngx-cva-test-suite的人。它是一个小型NPM软件包,可提供一组广泛的测试用例,以确保您的自定义控件按预期行事。它经过设计和测试,可与Jest和Jasmine测试跑者正常使用。

主要功能之一:

  • 确保onChange函数的正确呼叫数量(不正确的用法可能会导致formControl的valueChanges的额外排放)
  • 确保正确触发onTouched函数(触摸状态的控制状态和updateOn: 'blur' strategy需要正常运行)
  • 确保禁用控制时不存在额外的排放
  • 使用AbstractControl.reset()重置的控件检查控件

配置非常容易,在此文章中我们研究的反合的使用情况:

import { runValueAccessorTests } from 'ngx-cva-test-suite';
import { CounterControlComponent } from './counter.component';

runValueAccessorTests({
    /** Component, that is being tested */
    component: CounterControlComponent,
    /** 
     * All the metadata required for this test to run. 
     * Under the hood calls TestBed.configureTestingModule with provided config.
     */
    testModuleMetadata: {
        declarations: [CounterControlComponent],
    },
    /** Whether component is able to track "onBlur" events separately */
    supportsOnBlur: false,
    /** 
     * Tests the correctness of an approach that is used to set value in the component, 
     * when the change is internal. It's optional and can be omitted by passing "null"
     */
    internalValueChangeSetter: null,
    /** Function to get the value of a component in a runtime. */
    getComponentValue: (fixture) => fixture.componentInstance.value,
    /** When component is reset by FormControl, it should either get a certain default internal value or "null" */
    resetCustomValue: { value: 0 },
    /** 
     * This test suite applies up to 3 different values on the component to test different use cases. 
     * Values can be customized using this configuration option.
     */
    getValues: () => [1, 2, 3],
});

您可以在package’s repository中了解有关使用示例的更多信息,或者通过查看一些放置within the repository here的CVA组件来获得灵感。