Angular通过ControlValueAccessor自定义组件中实现双向绑定

今天俺滴前端mentor布置了一个小任务:利用ControlValueAccessor将组件改为双向绑定的自定义组件。起初以为a piece of cake,真正实现的时候却由于理解不透彻出现了大问题。经过婷姐答疑解惑,也算照葫芦画瓢做出来了,但事后也模棱两可。晚上加班加点查阅大量博客,总算弄懂一些皮毛,以下是笔者的学习笔记。

1、表单与控件介绍

在大部分Web应用中,用表单来处理输入是非常基础的功能,通常会在控件类中定义一个FormControl属性,然后在模板中通过[formControl]将DOM与数据进行双向绑定,如下所示:

import { Component } from '@angular/core';
import { FormControl } from '@angular/forms';

@Component({
  selector: 'app-my-control',
  template: `
    <input type="text" [formControl]="myControl">
  `
})
export class MyControlComponent {
  myrControl = new FormControl('');
}

在一些复杂场景中,考虑到代码复用以及安全性,难免需要自定义一些表单组件,也就不可避免的会涉及到数据的双向绑定问题。

一个简单粗暴的方法就是通过@Input来将父组件的表单数据传到子组件(自定义表单组件),然后在控件中写入对应的逻辑。其本质原理其实就是父子组件的事件绑定机制。假设我们现在有一个控件my-component,具体的调用可能长成这样:

<my-component 
 	[(myModel)]="inputValue"
	(myModelChange)="onInputValueChange($event)">
</my-component>

又臭又烂,对吧?显然不够简洁与优雅。我们希望它能够像其他的表单类元素一样,直接通过 formControlName 来绑定数据:

<form [formGroup]="form">
  <input formControlName="" />
  <my-component type="text" formControlName="myFormConotrol"></my-component> 
</form>

这时候我们就需要ControlValueAccessor来实现,下面步入正题!!

2、ControlValueAccessor API 介绍

ControlValueAccessor的官方定义是

Implement this interface to create a custom form control directive that integrates with Angular forms.

简单来说就是,它可以在Angular的 FormControl 实例和原生 DOM 元素之间创建一个桥梁,进行双向绑定。任何一个控件或指令都可以通过实现 ControlValueAccessor 接口并注册为 NG_VALUE_ACCESSOR,从而转变成 ControlValueAccessor 类型的对象。

我们需要实现该接口的以下方法:

interface ControlValueAccessor {
    /**
   * 给外部formControl写入数据时调用该方法。
   * 其作用是设置原生表单控件的值
   * @param {*} value 
   */
  writeValue(obj: any): void
    
  /**
   * 注册 onChange 事件,在初始化的时候被调用
   * 我们首先要在 registerOnChange 中将该事件触发函数保存起来,等到合适的时候(比如控件收    * 到用户输入,需要作出响应时)调用该函数以触发事件。
   * @param {*} fn 
   */
  registerOnChange(fn: any): void
    
  /**
   * 注册 onTouched 事件,即用户和控件交互时触发的回调函数。
   * 该函数用于通知表单控件已经处于 touched 状态,以更新绑定的 FormContorl 的内部状态。
   * @param {*} fn 
   */
  registerOnTouched(fn: any): void
    
  /**
   * 当表单状态变为 DISABLED 或从 DISABLED 变更时,表单 API 会调用该方法,以启用或禁用    * 对应的 DOM 元素。
   */
  setDisabledState(isDisabled: boolean)?: void
}

3、 自定义表单控件实现

3.1、初始化自定义表单控件

假设我们需要实现一个demo组件,包含了ts脚本和一个html组件:

demo.component.html

<div>
    <p>当前值: {{ value }}</p>
    <button (click)="increment()"> + </button>
    <button (click)="decrement()"> - </button>
</div>

demo.component.ts

import { Component, Input } from '@angular/core';
    
@Component({
    selector: 'app-demo',
    templateUrl: './demo.component.html',
    styleUrls: ['./demo.component.css'],
})
export class Demo {
    @Input() value: number = 0;

    ngOnInit() {}

    increment() {
        this.value++;
    }

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

3.2 注册 NG_VALUE_ACCESSOR 提供者

NG_VALUE_ACCESSOR 提供者用来指定实现了 ControlValueAccessor 接口的类,并且被 Angular 用来和 formControl 同步,通常是使用控件类或指令来注册。所有表单指令都是使用NG_VALUE_ACCESSOR 标识来注入控件值访问器,然后选择合适的访问器。具体分以下几个步骤:

步骤1:创建 EXE_COUNTER_VALUE_ACCESSOR

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

步骤2:配置控件 providers 信息

@Component({
  ...
  providers: [EXE_COUNTER_VALUE_ACCESSOR],
  ...
})
export class Demo implements OnInit, ControlValueAccessor {
  ...
}

3.3 完善 ControlValueAccessor 接口

根据 ControlValueAccessor 定义的 API 接口,当合法值输入控件时,我们需要更新控件内的 count 值,所以我们这样完善 writeValue 方法:

writeValue(value: any) {
    if (value!==undefined) {
        this._value = value;
    }
}

然后我们注册两个事件,涉及到 registerOnChange 方法和 registerOnTouched 方法:

propagateOnChange: (value: any) => void = (_: any) => {};
propagateOnTouched: (value: any) => void = (_: any) => {};

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

registerOnTouched(fn: any) {
    this.propagateOnTouched = fn;
}

由于控件内 value 改变时我们需要调用事件触发函数将数据传递给 Angular form,于是针对控件内部的 value 我们做一些改造:

_value: number = 0;

get value() {
    return this._value;
}

set value(value: number) {
    this._value = value;
    this.propagateOnChange(this._value);
}

其余内容不变。

3.4、在响应式表单使用

假设我们是在一个叫home的 component 中调用它,那么代码分以下两步,首先是 HTML:

<form [formGroup]="form">
        <app-counter formControlName="counter"></app-counter>
</form>

其次,我们完善 ts 代码:

export class HomeComponent implements OnInit {
    form: FormGroup;

    constructor(private fb: FormBuilder) { }

    ngOnInit() {
        this.form = this.fb.group({
            counter: 5 // 设置初始值
        });
    }
}

附counter.component.ts源码

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

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

@Component({
  selector: 'app-counter',
  templateUrl: './counter.component.html',
  styleUrls: ['./counter.component.css'],
  providers: [EXE_COUNTER_VALUE_ACCESSOR]
})
export class Counter implements ControlValueAccessor {
   _value: number = 0;

    get value() {
        return this._value;
    }

    set value(value: number) {
        this._value = value;
        this.propagateOnChange(this._value);
    }

    propagateOnChange: (value: any) => void = (_: any) => {};
    propagateOnTouched: (value: any) => void = (_: any) => {};

    ngOnInit() {}

    writeValue(value: any) {
        if (value!==undefined) {
            this._value = value;
        }
    }

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

    registerOnTouched(fn: any) {
        this.propagateOnTouched = fn;
    }

    increment() {
        this._value ++;
    }

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

4、进阶例子

假设我们有一个名为education的自定义表单控件,而add也是一个表单组件,我们需要在add.component中引用education.component,大概长这样: 源码(截取部分)如下:

education.component.html

<form nz-form [style.flex]="1" [formGroup]="formArray">
    <nz-form-item *ngFor="let fg of formArray.controls; let i = index">
      <nz-form-control>
        <div nz-row [nzGutter]="24" [formGroup]="fg">
          
            <div nz-col [nzSpan]="5">
            	<nz-select formControlName="degree">
              		<nz-option nzValue="本科" nzLabel="本科"></nz-option>
              		<nz-option nzValue="研究生" nzLabel="研究生"></nz-option>
            	</nz-select>
          	</div>
          
            <div nz-col [nzSpan]="7">
            	<input nz-input formControlName="school" />
            </div>
            
             <div nz-col [nzSpan]="4">
                <nz-select formControlName="bachelor">
                  <nz-option nzValue="学士" nzLabel="学士"></nz-option>
                  <nz-option nzValue="硕士" nzLabel="硕士"></nz-option>
                  <nz-option nzValue="博士" nzLabel="博士"></nz-option>
                  <nz-option nzValue="无" nzLabel="无"></nz-option>
                </nz-select>
             </div>

             <nz-form-control nz-col [nzSpan]="7">
                <nz-date-picker formControlName="graduationTime" (ngModelChange)="onChange($event)"></nz-date-picker>
             </nz-form-control>

              <div nz-col [nzSpan]="1">
                <i nz-icon nzType="minus-circle-o" class="my-input-suffix-icon"
                  (click)="removeField($event, i, formArray)"></i>
              </div>
          </div>
        </nz-form-control>
    </nz-form-item>
</form>

education.component.ts

import { Component, OnInit, forwardRef } from '@angular/core';
import { FormArray, FormBuilder, FormControl} from '@angular/forms';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'app-education',
  templateUrl: './education.component.html',
  styleUrls: ['./education.component.css'],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => EducationComponent),
      multi: true,
    },
  ],

})
export class EducationComponent implements OnInit, ControlValueAccessor {
  formArray: FormArray;
  _value: any[];

  private onTouchedCallback = (_: any) => { };
  private onChangeCallback = (_: any) => { };

  constructor(private fb: FormBuilder) { }

  ngOnInit(): void {
  }

  removeField(e: MouseEvent, i: number, form: FormArray): void {
    e.preventDefault();
    const fa = form;
    if (fa.length > 1) {
      fa.removeAt(i);
    }
  }

  private initForm() {
    this.formArray = this.fb.array([]);
    this.formArray.push(this.fb.group({
      degree: [null, [Validators.required]],
      school: [null, [Validators.required]],
      bachelor: [null, [Validators.required]],
      graduationTime: [null, [Validators.required]],
    }
    ));
      
    this.formArray.valueChanges.subscribe(value => {
      if (this.formArray.valid) {
        this._value = this.formArray.value;
      }
      this.onChangeCallback(this._value);
    });
  }

  get value(): any {
    return this._value;
  }

  set value(value: any) {
    this._value = value;
    this.onChangeCallback(this._value);
    this.initForm();
  }

  writeValue(value: any) {
    console.log("write value");
    if (value !== undefined) {
      this.value = value;
    }
  }

  registerOnChange(fn: any) {
    this.onChangeCallback = fn;
  }
    
  registerOnTouched(fn: any) {
    this.onTouchedCallback = fn;
  }
}

add.component.html

<form nz-form  [formGroup]="validateForm">
    <div nz-col>
        <nz-form-item>
            <nz-form-label [nzSpan]="7" nzRequired>姓名</nz-form-label>
            <nz-form-control [nzSpan]="12">
                <input nz-input formControlName="name" />
            </nz-form-control>
        </nz-form-item>
    </div>

    <div nz-col>
        <nz-form-item>
            <nz-form-label [nzSpan]="7" nzRequired>性别</nz-form-label>
            <nz-radio-group formControlName="sex">
                <label nz-radio nzValue="男"></label>
                <label nz-radio nzValue="女"></label>
            </nz-radio-group>
        </nz-form-item>
    </div>
    
    <div nz-col>
        <nz-form-item>
            <nz-form-label [nzSpan]="7" nzRequired>年龄</nz-form-label>
            <nz-form-control>
                <nz-input-number formControlName="age">
                </nz-input-number>
            </nz-form-control>
        </nz-form-item>
    </div>
    
    <div nz-col>
        <nz-form-item>
            <nz-form-label [nzSpan]="7" nzRequired>手机号</nz-form-label>
            <nz-form-control [nzSpan]="12">
                <input nz-inpu formControlName="phone" />
            </nz-form-control>
        </nz-form-item>
    </div>
    
    <div nz-col>
        <nz-form-item>
            <nz-form-label [nzSpan]="7" nzRequired>Email</nz-form-label>
            <nz-form-control [nzSpan]="12">
                <input nz-input formControlName="email" />
            </nz-form-control>
        </nz-form-item>
    </div>
    
    <div nz-col>
        <nz-form-item>
            <nz-form-label [nzSpan]="7" nzRequired>地址</nz-form-label>
            <nz-form-control [nzSpan]="12">
                <textarea nz-input formControlName="address">
                </textarea>
            </nz-form-control>
        </nz-form-item>
    </div>
    
    <div id="education">
        <nz-form-item>
            <nz-form-label>教育情况</nz-form-label>
            <nz-form-control>
                <app-education formControlName="educations"></app-education>
            </nz-form-control>
        </nz-form-item>
    </div>
</form>

add.component.ts

import { Component, OnInit, Input } from '@angular/core';
import { EducationComponent } from 'src/app/components/education/education.component';
import { AbstractControl, Form, FormArray, FormBuilder, FormControl, FormGroup} from '@angular/forms';

@Component({
  selector: 'app-add',
  templateUrl: './add.component.html',
  styleUrls: ['./add.component.css']
})
export class AddComponent implements OnInit {
  validateForm: FormGroup;
  public educations: any;

  constructor(private fb: FormBuilder) {
    this.validateForm = this.fb.group({
      name: ['', [required, maxLength(5), minLength(2)], [this.userNameAsyncValidator]],
      sex: ['', [required]],
      age: ['', [required]],
      phone: ['', [required, phone]],
      email: ['', [required, email]],
      address: ['', [required]],
      educations: ['', [required]],
    });
  }

  ngOnInit(): void {
  }

front-end

967 Words

26267-726-40 23:38 +0800