本节我们以教师选择组件为例,展示如何自定义一个`FormControl`。 Angular内置的`FormControl`仅支持绑定到原生的html表单项上,比如`input`、`select`等。对于一些自定义的组件,若想也像`input`一样使用响应式表单,则需要经过两步: - 继续相应的接口,以使得当前组件提供`FormControl`所需的一些方法。 - 将当前组件声明为响应式表单项,以使响应式表单能够解析当前组件对应的`selector`。 ## 测试 在写功能之前先写测试的模式被称为`TDD`,全称为:`Test-Driven Development`,即**测试驱动开发**,网上有很多关于`TDD`的讨论,有兴趣的同学可以搜索来加深下了解。在此我们尝试使用`TDD`的模式来开发当前功能。为了规避一些其它的测试代码带来的问题,最大限度的减少一些在学习初期不必要的**麻烦**,我们来到教师选择组件所在文件夹中,新建一个测试文件`klass-select-form-control.component.spec.ts`,并初始化如下: ```typescript +++ b/first-app/src/app/clazz/klass-select/klass-select-form-control.component.spec.ts import {KlassSelectComponent} from './klass-select.component'; import {TestBed} from '@angular/core/testing'; import {HTTP_INTERCEPTORS, HttpClientModule} from '@angular/common/http'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import {MockApiTestingInterceptor} from '@yunzhi/ng-mock-api/testing'; import {TeacherMockApi} from '../../mock-api/teacher.mock.api'; describe('KlassSelectComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [KlassSelectComponent], imports: [ HttpClientModule, FormsModule, ReactiveFormsModule ], providers: [ { provide: HTTP_INTERCEPTORS, multi: true, useClass: MockApiTestingInterceptor.forRoot([ TeacherMockApi ]) } ] }) .compileComponents(); }); fit('响应式表单', () => { }); }); ``` 本次测试的目的在于:当前组件作用子组件使用时,是否支持响应式表单的`FormConrol`。所以在测试过程中,我们需要来搭建当前组件为子组件的测试环境。若要实现该功能,则需要建立一个父组件。而既然是测试,我们在测试文件中来临时搭建一个父组件好了: ```typescript +++ b/first-app/src/app/clazz/klass-select/klass-select-form-control.component.spec.ts @@ -1,9 +1,17 @@ import {KlassSelectComponent} from './klass-select.component'; import {TestBed} from '@angular/core/testing'; import {HTTP_INTERCEPTORS, HttpClientModule} from '@angular/common/http'; -import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {MockApiTestingInterceptor} from '@yunzhi/ng-mock-api/testing'; import {TeacherMockApi} from '../../mock-api/teacher.mock.api'; +import {Component} from '@angular/core'; + +@Component({ + template: '<h1>Test:</h1><app-klass-select [formControl]="teacherIdFormControl"></app-klass-select>' +}) +class TestComponent { + teacherIdFormControl = new FormControl(); +} describe('KlassSelectComponent', () => { beforeEach(async () => { ``` 如上代码便创建了一个包含有`app-klass-select`组件的父组件`TestComponent`。 在定义该组件时: - 由于该组件并不会做为子组件使用,所以我们并没有设置其`selector`; - 由于该组件并不需要任何样式,所以我们并没有设置其`styleUrls`; - 由于该组件的V层代码非常的简单,所以我们移除了`templateUrl`,取而代之的是`template`,并直接在`template`书写了V层; - 由于当前组件仅在当前文件中使用,所以我们移除了`export`关键字,在定义组件时,使用的是`class TestComponent`而非`export class TestComponent`; - 由于当前测试用组件并不需要进行复杂初始化,所以删除了对`OnInit`接口的实现。 没错,这就是一个缩小版本的组件。虽然小,但功能正常。 ### 将组件加入模块 与其它被测试的模块相同,要想被动态测试模块认识,则需要将组件加入到动态测试模块中: ```typescript describe('KlassSelectComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [KlassSelectComponent], + declarations: [KlassSelectComponent, TestComponent], imports: [ HttpClientModule, FormsModule, ``` ### 创建组件 在单面的章节对动态组件进行分析时,我们已经接触过了`TestBed`是如何创建动态测试模块中的某个组件的。上于本次要创建的`TestComponent`在当前单元测试文件中仅用一次,所以将创建该组件的过程直接写到测试用例的相关方法上: ```typescript fit('响应式表单', () => { // 创建一个组件夹具(容器),这就像我们要测试显卡是否正常功能时,需要有一台供显卡工作的电脑一样。 // testFixture便是TestComponent这块显卡赖以工作的电脑 const fixture = TestBed.createComponent(TestComponent); // 获取testFixture这台电脑上的testComponent显卡 const component = fixture.componentInstance; // 调用detectChanges()渲染V层,开始渲染V层中的子组件。 // 由于当前Test组件未请求后台,所以省略了getTestScheduler().flush(); // 当然了,写上也无防 fixture.detectChanges(); 👈 // 模拟返回数据后,进行变更检测重新渲染子组件V层 getTestScheduler().flush(); fixture.detectChanges(); }); ``` 这里的`fixture.detectChanges()`很重要,该方法的作用是渲染`Test`组件的V层,而子组件正是在渲染该V层时被Angular发现的。Angular发现子组件`app-klass-select `后,才开始渲染`KlassSelectComponent`组件,即而发生数据请求。 终止`ng t`后重新启动一下`ng t`,效果如下: ![image-20210406105011967](https://img.kancloud.cn/6d/62/6d6274f243104d82dbde2fb036081660_2006x212.png) 此时单元测试报了`No value accessor`的异常,这是由于响应式表单在处理`app-klass-select`上的`formControl`时要调用相关的`value accessor`(值处理器)。 ## 实现接口 响应式表单预调用了`value accessor`被规定于`@angular/forms`中的`ControlValueAccessor`接口中,教师选择组件仅需要实现该接口,便可以借助IDE快速的填充上相关的方法: ```typescript +++ b/first-app/src/app/clazz/klass-select/klass-select.component.ts @@ -1,7 +1,7 @@ import {Component, OnInit, EventEmitter, Output, Input} from '@angular/core'; import {Teacher} from '../../entity/teacher'; import {HttpClient} from '@angular/common/http'; -import {FormControl} from '@angular/forms'; +import {ControlValueAccessor, FormControl} from '@angular/forms'; @Component({ @@ -9,7 +9,7 @@ import {FormControl} from '@angular/forms'; templateUrl: './klass-select.component.html', styleUrls: ['./klass-select.component.css'] }) -export class KlassSelectComponent implements OnInit { +export class KlassSelectComponent implements OnInit, ControlValueAccessor { teachers = new Array<Teacher>(); teacherId = new FormControl(); ``` 此时我们把鼠标移至KlassSelectComponent名称上,按提示进行点击,便可快速的生成相关方法: ![image-20210406105524867](https://img.kancloud.cn/ba/de/badeff7f9199929f0d1de135ee8507b3_2284x376.png) 生成方法如下: ```typescript @@ -25,6 +24,18 @@ export class KlassSelectComponent implements OnInit, ControlValueAccessor { constructor(private httpClient: HttpClient) { } + writeValue(obj: any): void { + throw new Error('Method not implemented.'); + } + + registerOnChange(fn: any): void { + throw new Error('Method not implemented.'); + } + + registerOnTouched(fn: any): void { + throw new Error('Method not implemented.'); + } + ngOnInit(): void { // 关注teacherId this.teacherId.valueChanges ``` > ​ 除上述三个方法外,ControValueAccessor中还存在一个可选的方法 [**setDisabledState**(isDisabled: boolean)?: void](https://angular.cn/api/forms/ControlValueAccessor#setDisabledState)用于设置组件的**disabled**状态。 在此,我们仅需要`writeValue`及`registerOnChange`方法,两个方法的作用如下: ```typescript @@ -24,11 +24,22 @@ export class KlassSelectComponent implements OnInit, ControlValueAccessor { constructor(private httpClient: HttpClient) { } - writeValue(obj: any): void { + /** + * 将FormControl中的值通过此方法写入 + * FormControl的值每变换一次,该方法将被重新执行一次 + * 相当于@Input() set xxx + * @param obj 此类型取决于当前组件的接收类型,比如此时我们接收一个类型为number的teacherId + */ + writeValue(obj: number): void { throw new Error('Method not implemented.'); } - registerOnChange(fn: any): void { + /** + * 组件需要向父组件弹值时,直接调用参数中的fn方法 + * 相当于@Output() + * @param fn 此类型取决于当前组件的弹出值类型,比如我们当前将弹出一个类型为number的teacherId + */ + registerOnChange(fn: (data: number) => void): void { throw new Error('Method not implemented.'); } ``` 成功实现接口,并添加相应的方法后,接下来我们需要通过声明的方法来使用响应式表单认识当前组件。 ## 声明 在使用拦截器时我们使用了`provide`以及`useClass`来将` MockApiTestingInterceptor.forRoot()`成功的声明成了一个HTTP拦截器(HTTP_INTERCEPTORS)。在此声明组件支持`FormControl`也是同样的道理: ```typescript +++ b/first-app/src/app/clazz/klass-select/klass-select.component.ts @@ -1,12 +1,18 @@ -import {Component, OnInit, EventEmitter, Output, Input} from '@angular/core'; +import {Component, OnInit, EventEmitter, Output, Input, forwardRef} from '@angular/core'; import {Teacher} from '../../entity/teacher'; import {HttpClient} from '@angular/common/http'; -import {ControlValueAccessor, FormControl} from '@angular/forms'; +import {ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR} from '@angular/forms'; @Component({ selector: 'app-klass-select', templateUrl: './klass-select.component.html', - styleUrls: ['./klass-select.component.css'] + styleUrls: ['./klass-select.component.css'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, multi: true, + useExisting: forwardRef(() => KlassSelectComponent) 👈 + } + ] }) ``` 在声明拦截器时,使用的是`useClass`,注意在这使用`useExisting` 👈。 `forwardRef()`是一个方法,该方法中接收了一个回调函数`() => KlassSelectComponent`,该回调函数将`KlassSelectComponent`作为了返回值。该方法的作用是防止在`KlassSelectComponent`引用`KlassSelectComponent`而引发的引用循环(了解即可)。 > 剪头函数:`() => KlassSelectComponent`等价于:`() => return KlassSelectComponent`。 响应式表单在解析`FormControl`时将调用这个回调方法 : ```typescript providers: [ { provide: NG_VALUE_ACCESSOR, multi: true, - useExisting: forwardRef(() => KlassSelectComponent) + useExisting: forwardRef(() => { + console.log('useExisting->forwardRef中的回调方法被调用一次'); + return KlassSelectComponent; + }) } ] }) ``` ![image-20210406142633858](https://img.kancloud.cn/0f/51/0f51842cf6a7acc576de3c109f5041a9_1104x210.png) 此时响应式表单便认识了当前的子组件为`FormControl`,不再报`No value accessor`异常了。 ![image-20210406144450845](https://img.kancloud.cn/04/1d/041d73bae510dbcc58511397e973b7be_1438x184.png) 该异常是我们使用IDE自动生成`writeValue()`方法时填充的语句: ```typescript writeValue(obj: number): void { throw new Error('Method not implemented.'); 👈 } ``` 报此异常说明方法被成功的调用了。 ## 完成功能 参考`@Input()`、`@Output()`书写功能代码如下: ```typescript /** * 将FormControl中的值通过此方法写入 * FormControl的值每变换一次,该方法将被重新执行一次 * 相当于@Input() set xxx * @param obj 此类型取决于当前组件的接收类型,比如此时我们接收一个类型为number的teacherId */ writeValue(obj: number): void { console.log('writeValue is called'); this.teacherId.setValue(obj); } /** * 组件需要向父组件弹值时,直接调用参数中的fn方法 * 相当于@Output() * @param fn 此类型取决于当前组件的弹出值类型,比如我们当前将弹出一个类型为number的teacherId */ registerOnChange(fn: (data: number) => void): void { console.log(`registerOnChange is called`); this.teacherId.valueChanges .subscribe(data => fn(data)); } registerOnTouched(fn: any): void { console.warn('registerOnTouched not implemented'); } ``` 如果我们在特定的方法上加一些输出,则会更加清晰的明了Angular的执行过程: ```typescript +++ b/first-app/src/app/clazz/klass-select/klass-select-form-control.component.spec.ts @@ -4,14 +4,18 @@ import {HTTP_INTERCEPTORS, HttpClientModule} from '@angular/common/http'; import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {MockApiTestingInterceptor} from '@yunzhi/ng-mock-api/testing'; import {TeacherMockApi} from '../../mock-api/teacher.mock.api'; -import {Component} from '@angular/core'; +import {Component, OnInit} from '@angular/core'; import {getTestScheduler} from 'jasmine-marbles'; @Component({ template: '<h1>Test:</h1><app-klass-select [formControl]="teacherIdFormControl"></app-klass-select>' }) -class TestComponent { +class TestComponent implements OnInit { teacherIdFormControl = new FormControl(); + + ngOnInit(): void { + console.log('父组件初始化'); + } } ``` 教师选择组件: ```typescript +++ b/first-app/src/app/clazz/klass-select/klass-select.component.ts @@ -60,12 +60,16 @@ export class KlassSelectComponent implements OnInit, ControlValueAccessor { } ngOnInit(): void { + console.log('教师选择组件初始化'); // 关注teacherId this.teacherId.valueChanges .subscribe((data: number) => this.beChange.emit(data)); // 获取所有教师 this.httpClient.get<Array<Teacher>>('teacher') .subscribe( - teachers => this.teachers = teachers); + teachers => { + this.teachers = teachers; + console.log('教师选择组件接收到了数据'); + }); } } ``` 单元测试代码: ```typescript +++ b/first-app/src/app/clazz/klass-select/klass-select-form-control.component.spec.ts @@ -4,14 +4,18 @@ import {HTTP_INTERCEPTORS, HttpClientModule} from '@angular/common/http'; import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {MockApiTestingInterceptor} from '@yunzhi/ng-mock-api/testing'; import {TeacherMockApi} from '../../mock-api/teacher.mock.api'; -import {Component} from '@angular/core'; +import {Component, OnInit} from '@angular/core'; import {getTestScheduler} from 'jasmine-marbles'; @Component({ template: '<h1>Test:</h1><app-klass-select [formControl]="teacherIdFormControl"></app-klass-select>' }) -class TestComponent { +class TestComponent implements OnInit { teacherIdFormControl = new FormControl(); + + ngOnInit(): void { + console.log('父组件初始化'); + } } describe('KlassSelectComponent', () => { @@ -38,6 +42,7 @@ describe('KlassSelectComponent', () => { fit('响应式表单', () => { // 创建一个组件夹具(容器),这就像我们要测试显卡是否正常功能时,需要有一台供显卡工 作的电脑一样。 // testFixture便是TestComponent这块显卡赖以工作的电脑 + console.log('开始创建父组件'); const fixture = TestBed.createComponent(TestComponent); // 获取testFixture这台电脑上的testComponent显卡 @@ -45,10 +50,13 @@ describe('KlassSelectComponent', () => { // 调用detectChanges()渲染V层,开始渲染V层中的子组件。 // 由于当前Test组件未请求后台,所以省略了getTestScheduler().flush(); // 当然了,写上也无防 + console.log('首次渲染组件'); fixture.detectChanges(); // 模拟返回数据后,进行变更检测重新渲染子组件V层 + console.log('触发后台模拟数据发送'); getTestScheduler().flush(); + console.log('重新渲染组件'); fixture.detectChanges(); }); }); ``` 控制台如下: ![image-20210406151908337](https://img.kancloud.cn/2f/21/2f210c45e44d52d571a0524e9d2f664f_1106x472.png) ## 测试 最后我们在父组件中完成组件的初始化,并增加一个方法来显示组件中`FormControl`的值以确认子组件工作正常: ```typescript +++ b/first-app/src/app/clazz/klass-select/klass-select-form-control.component.spec.ts @@ -8,14 +8,18 @@ import {Component, OnInit} from '@angular/core'; import {getTestScheduler} from 'jasmine-marbles'; @Component({ - template: '<h1>Test:</h1><app-klass-select [formControl]="teacherIdFormControl"></app-klass-select>' + template: '<h1 (click)="onTest()">Test:</h1><app-klass-select [formControl]="teacherIdFormControl"></app-klass-select>' }) class TestComponent implements OnInit { - teacherIdFormControl = new FormControl(); + teacherIdFormControl = new FormControl(1); ngOnInit(): void { console.log('父组件初始化'); } + + onTest(): void { + console.log('teacherId值为', this.teacherIdFormControl.value); + } } describe('KlassSelectComponent', () => { ``` 最终效果一,自动选择教师: ![image-20210406152225884](https://img.kancloud.cn/7d/66/7d669ce5e41c450f20b4fd1671076661_1050x228.png) 最终效果二,选择其它教师后点击`Test`成功打印选择的教师ID: ![image-20210406152346046](https://img.kancloud.cn/cd/f2/cdf26e47b11417f204f7d5f65075348c_542x88.png) ## 本节作业 本节中我们在测试组件中引入了子父组件`app-klass-select`,这使得`useExisting`中`forwardRef`中的回调函数被调用了一次。请尝试回答以下问题: - 如果父组件未引入子组件`app-klass-select`,`useExisting`中`forwardRef`中的回调函数会被调用吗? - 如果父组件引入了多次组件`app-klass-select`,`useExisting`中`forwardRef`中的回调函数会被调用几次? - 验证自己的猜测。 - | 名称 | 链接 | | ---------------------------------------- | ------------------------------------------------------------ | | ControlValueAccessor | [https://angular.cn/api/forms/ControlValueAccessor](https://angular.cn/api/forms/ControlValueAccessor) | | NG_VALUE_ACCESSOR | [https://angular.cn/api/forms/NG_VALUE_ACCESSOR](https://angular.cn/api/forms/NG_VALUE_ACCESSOR) | | DefaultValueAccessor | [https://angular.cn/api/forms/DefaultValueAccessor](https://angular.cn/api/forms/DefaultValueAccessor) | | 别名提供者:`useExisting` | [https://angular.cn/guide/dependency-injection-in-action#alias-providers-useexisting](https://angular.cn/guide/dependency-injection-in-action#alias-providers-useexisting) | | 使用一个前向引用(*forwardRef*)来打破循环 | [https://angular.cn/guide/dependency-injection-in-action#break-circularities-with-a-forward-class-reference-forwardref](https://angular.cn/guide/dependency-injection-in-action#break-circularities-with-a-forward-class-reference-forwardref) | | 本节源码 | [https://github.com/mengyunzhi/angular11-guild/archive/step6.4.5.zip](https://github.com/mengyunzhi/angular11-guild/archive/step6.4.5.zip) | ##