本节我们以教师选择组件为例,展示如何自定义一个`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) |
##
- 序言
- 第一章 Hello World
- 1.1 环境安装
- 1.2 Hello Angular
- 1.3 Hello World!
- 第二章 教师管理
- 2.1 教师列表
- 2.1.1 初始化原型
- 2.1.2 组件生命周期之初始化
- 2.1.3 ngFor
- 2.1.4 ngIf、ngTemplate
- 2.1.5 引用 Bootstrap
- 2.2 请求后台数据
- 2.2.1 HttpClient
- 2.2.2 请求数据
- 2.2.3 模块与依赖注入
- 2.2.4 异步与回调函数
- 2.2.5 集成测试
- 2.2.6 本章小节
- 2.3 新增教师
- 2.3.1 组件初始化
- 2.3.2 [(ngModel)]
- 2.3.3 对接后台
- 2.3.4 路由
- 2.4 编辑教师
- 2.4.1 组件初始化
- 2.4.2 获取路由参数
- 2.4.3 插值与模板表达式
- 2.4.4 初识泛型
- 2.4.5 更新教师
- 2.4.6 测试中的路由
- 2.5 删除教师
- 2.6 收尾工作
- 2.6.1 RouterLink
- 2.6.2 fontawesome图标库
- 2.6.3 firefox
- 2.7 总结
- 第三章 用户登录
- 3.1 初识单元测试
- 3.2 http概述
- 3.3 Basic access authentication
- 3.4 着陆组件
- 3.5 @Output
- 3.6 TypeScript 类
- 3.7 浏览器缓存
- 3.8 总结
- 第四章 个人中心
- 4.1 原型
- 4.2 管道
- 4.3 对接后台
- 4.4 x-auth-token认证
- 4.5 拦截器
- 4.6 小结
- 第五章 系统菜单
- 5.1 延迟及测试
- 5.2 手动创建组件
- 5.3 隐藏测试信息
- 5.4 规划路由
- 5.5 定义菜单
- 5.6 注销
- 5.7 小结
- 第六章 班级管理
- 6.1 新增班级
- 6.1.1 组件初始化
- 6.1.2 MockApi 新建班级
- 6.1.3 ApiInterceptor
- 6.1.4 数据验证
- 6.1.5 教师选择列表
- 6.1.6 MockApi 教师列表
- 6.1.7 代码重构
- 6.1.8 小结
- 6.2 教师列表组件
- 6.2.1 初始化
- 6.2.2 响应式表单
- 6.2.3 getTestScheduler()
- 6.2.4 应用组件
- 6.2.5 小结
- 6.3 班级列表
- 6.3.1 原型设计
- 6.3.2 初始化分页
- 6.3.3 MockApi
- 6.3.4 静态分页
- 6.3.5 动态分页
- 6.3.6 @Input()
- 6.4 编辑班级
- 6.4.1 测试模块
- 6.4.2 响应式表单验证
- 6.4.3 @Input()
- 6.4.4 FormGroup
- 6.4.5 自定义FormControl
- 6.4.6 代码重构
- 6.4.7 小结
- 6.5 删除班级
- 6.6 集成测试
- 6.6.1 惰性加载
- 6.6.2 API拦截器
- 6.6.3 路由与跳转
- 6.6.4 ngStyle
- 6.7 初识Service
- 6.7.1 catchError
- 6.7.2 单例服务
- 6.7.3 单元测试
- 6.8 小结
- 第七章 学生管理
- 7.1 班级列表组件
- 7.2 新增学生
- 7.2.1 exports
- 7.2.2 自定义验证器
- 7.2.3 异步验证器
- 7.2.4 再识DI
- 7.2.5 属性型指令
- 7.2.6 完成功能
- 7.2.7 小结
- 7.3 单元测试进阶
- 7.4 学生列表
- 7.4.1 JSON对象与对象
- 7.4.2 单元测试
- 7.4.3 分页模块
- 7.4.4 子组件测试
- 7.4.5 重构分页
- 7.5 删除学生
- 7.5.1 第三方dialog
- 7.5.2 批量删除
- 7.5.3 面向对象
- 7.6 集成测试
- 7.7 编辑学生
- 7.7.1 初始化
- 7.7.2 自定义provider
- 7.7.3 更新学生
- 7.7.4 集成测试
- 7.7.5 可订阅的路由参数
- 7.7.6 小结
- 7.8 总结
- 第八章 其它
- 8.1 打包构建
- 8.2 发布部署
- 第九章 总结