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 {
}