上个小节中我们共同学习了如何自定义验证器,我们把上节中那种可以马上得到验证结果的验证器称为同步验证器。有些时候,我们在进行数据验证时,还需要去请求后台相关的API,比如后台不允许两个学生使用相同的学号,这时候验证器则需要后台的协助。我们把这种需要后台协助的验证器称为异步验证器。 ## Api 当前后台提供了一个校验学号是否可用的方法,其API如下: ```bash GET /student/numberIsExist ``` | **类型Type** | **名称Name** | **描述Description** | 必填 | **类型Schema** | 默认值 | | :------------ | :----------- | :------------------ | ---- | :---------------------------------------------------- | ------ | | Param请求参数 | `number` | 学号 | 是 | `string` | | | Response响应 | | Status Code: 200 | | 学号已存在,则返回`true`;学号不存在,则返回`false`。 | | ### MockApi 依据API我们建立MockApi,模拟实现如下逻辑,传入的学号为`032282`时,则返回`true`,表示该学号已存在;传入其它的学号时,返回`false`,表示该学号尚不存在。 我们来到`mock-api`文件夹,新建`student.mock.api.ts`: ```bash panjiedeMacBook-Pro:mock-api panjie$ pwd /Users/panjie/github/mengyunzhi/angular11-guild/first-app/src/app/mock-api panjiedeMacBook-Pro:mock-api panjie$ touch student.mock.api.ts panjiedeMacBook-Pro:mock-api panjie$ ls clazz.mock.api.ts student.mock.api.ts mock-api-testing.module.ts teacher.mock.api.ts ``` 然后初始化如下: ```typescript import {ApiInjector, MockApiInterface} from '@yunzhi/ng-mock-api'; /** * 学生模拟API. */ export class StudentMockApi implements MockApiInterface { getInjectors(): ApiInjector<any>[] { return []; } } ``` 最后加入模拟信息: ```typescript export class StudentMockApi implements MockApiInterface { getInjectors(): ApiInjector<any>[] { return [{ method: 'GET', url: '/student/numberIsExist', result: (urlMatches: any, options: RequestOptions): boolean => { const params = options.params as HttpParams; if (!params.has('number')) { throw new Error('未接收到查询参数number'); } const stuNumber = params.get('number') as string; if (stuNumber === '032282') { return true; } else { return false; } } }]; } ``` 此时一个MockApi便建立完成了。若想使其生效,还要保证将其加入到`MockApiTestingModule`中: ```typescript +++ b/first-app/src/app/mock-api/mock-api-testing.module.ts @@ -4,6 +4,7 @@ import {HTTP_INTERCEPTORS, HttpClientModule} from '@angular/common/http'; import {MockApiTestingInterceptor} from '@yunzhi/ng-mock-api/testing'; import {ClazzMockApi} from './clazz.mock.api'; import {TeacherMockApi} from './teacher.mock.api'; +import {StudentMockApi} from './student.mock.api'; @NgModule({ @@ -17,7 +18,8 @@ import {TeacherMockApi} from './teacher.mock.api'; provide: HTTP_INTERCEPTORS, multi: true, useClass: MockApiTestingInterceptor.forRoot([ ClazzMockApi, - TeacherMockApi + TeacherMockApi, + StudentMockApi ]) } ] ``` ## 创建异步验证器 在`src/app`文件夹中创建一个`YzAsyncValidators`来存放所有的异步验证器: ```bash panjiedeMacBook-Pro:app panjie$ pwd /Users/panjie/github/mengyunzhi/angular11-guild/first-app/src/app panjiedeMacBook-Pro:app panjie$ ng g class YzAsyncValidators CREATE src/app/yz-async-validators.spec.ts (200 bytes) CREATE src/app/yz-async-validators.ts (35 bytes) ``` 初始化的类如下: ```typescript export class YzAsyncValidators { } ``` 与同步验证器相同,异步验证器同样需要符合某些规范。作为异步验证器的方法而言,该方法需要返回一个`Observable`,即可被观察的对象,如果最终该`Observable`发送的为`null`,则表示验证通过;如果该`Observable`最终发送的为`ValidationErrors`,则说明验证失败。 ```typescript import {AbstractControl, ValidationErrors} from '@angular/forms'; import {Observable, of} from 'rxjs'; import {delay} from 'rxjs/operators'; /** * 异步验证器. */ export class YzAsyncValidators { /** * 验证方法,学号不存在验证通过 * @param control FormControl */ static numberNotExist(control: AbstractControl): Observable<ValidationErrors | null> { return of(null) ① .pipe②(delay(1000)③); } } ``` 上述代码使用了几个小技巧: - ① `of(null)`方法返回了一个可被订阅的(可)观察者`Observable`,该`Observable`发送的数据为`null`。 - ② 在发送数据以前,加入`pipe()`进行处理。 - ③ 处理的方法是延迟1秒钟再发送数据。 - 所以最终订阅该`Observable`将在1秒后得到一个值为`null`的数据,这个1秒的延迟模拟了后台的异步请求。 ## 使用异步验证器 `FormControl`支持多个验证器,可以同时使用同步或异步验证器,同步与异步验证器的使用方法相同: ```typescript +++ b/first-app/src/app/student/add/add.component.ts @@ -1,6 +1,7 @@ import {Component, OnInit} from '@angular/core'; import {FormControl, FormGroup, Validators} from '@angular/forms'; import {YzValidators} from '../../yz-validators'; +import {YzAsyncValidators} from '../../yz-async-validators'; @Component({ selector: 'app-add', @@ -10,7 +11,7 @@ import {YzValidators} from '../../yz-validators'; export class AddComponent implements OnInit { formGroup = new FormGroup({ name: new FormControl('', Validators.required), - number: new FormControl('', Validators.required), + number: new FormControl('', Validators.required, YzAsyncValidators.numberNotExist👈), phone: new FormControl('', YzValidators.phone), email: new FormControl(), clazzId: new FormControl(null, Validators.required) ``` 异步验证器,放到`FormControl`的第三个参数上👈。**如果**只存在异步验证器而不存在同步验证器,则可以向第二个参数传入一个空数组`number: new FormControl('', []👈, YzAsyncValidators.numberNotExist)`。 接下来让我们在异步验证器上打两个断点,来查看异步验证器的调用时机: ```typescript static numberNotExist(control: AbstractControl): Observable<ValidationErrors | null> { + console.log('异步验证器被调用'); return of(null) .pipe(delay(1000), tap(data => console.log('验证器返回数据', data)); } ``` 启用学生添加组件对应的单元测试: 测试结果一:组件启动时,学号为空,此时异步验证器未被调用。得出结论:①异步验证器在组件启动时不工作或②异步验证器在组件值为空时不工作。 测试结果二:输入学号后,异步验证器工作,并在1S后接收到返回null时,此时错误提示消失,说明学号对应的`FormControl`的`invalid`值为`false`。得出结论:③异步验证器在组件非空时工作。 ![image-20210413112641398](https://img.kancloud.cn/df/95/df952468070cb04919476a7142c6fd7f_2366x144.png) 测试结果三:填写学号,然后删除学号,异步验证器不工作。得出结论:④异步验证器在组件值为空时不工作,所以测试结果一中得出的结论①可能是错误的。 测试结果四:输入学号后,异步验证器开始工作,在尚未返回数据期间,保存按钮处于可用状态。得出结论:⑤异步验证器在未接收到后台的返回值前,会将表单对应的`FormGroup`的`invalid`值置于`true`。 ![image-20210413112850653](https://img.kancloud.cn/15/f6/15f64b19166b881b60330d84f722ee3c_620x166.png) 测试结果五:快速的输入学号,比如输入1234567,异步验证器将被调用7次,但只会订阅一个返回结果。得出结论:⑥`FormControl`的值每改变一次,则会调用一次异步验证器,但如果异步验证器没有及时地接收到后台的结果,则只会获取最后一次的值。 ![image-20210413113312856](https://img.kancloud.cn/71/61/71617b64c06df2222218d8a83dad6a24_1000x112.png) 上述结论只是我们根据现像的猜想,其实大多数时候猜想如果能解决我们当下遇到的问题,也是完完全全可以的。而如果能在猜想以后再了解到真实的原因,则会对我们知识的提升大有帮助! 真实的原因是这样: - 当`FormControl`即存在同步验证器,又存在异步验证器时。只有当所有的同步验证器全部通过后,才会调用异步验证器。所以组件初始化时异步验证器未调用的真实原因是:存在同步验证器`Validators.required`,当内容为空时,该验证器未通过,所以异步验证器未被调用。 - 在异步验证器发起请求而未接收到返回值前,`FormControl`的`pending`字段的值将被设置为`true`,接收到返回值后,`FormControl`的`pending`字段的值将被设置为`false`。 - 一个`FormGroup`中的任意`FormControl`的`pending`值为`true`时,该`FormGroup`中的`pengding`为`true`。 - `FormGroup`中的`pending`值为`true`时,其`invalid`值为`false`。 - 异步验证器在进行验证时,将忽略历史上尚未返回的请求值,只使用最近一次请求成功的值。 以上便是发生上述问题的原因。 ## 返回类型为方法 真实的异步验证器需要进行后台的请求,所以必然需要`HttpClient`来帮忙发起请求。所以我们在验证器上需要注入`HttpClient`: ```typescript export class YzAsyncValidators { + + constructor(private httpClient: HttpClient) { + } + ``` 但在`numberNotExist`方法上使用`httpClient`则会碰到一个根本的问题:无法在静态方法上调用对象中的属性: ![image-20210413134910123](https://img.kancloud.cn/b0/10/b010155968180b43601992ee062a3ef1_2014x348.png) 若想在异步验证器上使用注入到对象的`HttpClient`,则需要一些小技巧: ```typescript export class YzAsyncValidators { constructor(private httpClient: HttpClient) { } /** * 验证方法,学号不存在验证通过 * @param control FormControl */ 👉 numberNotExist(): (control: AbstractControl) => Observable<ValidationErrors | null> ① { return ②(control: AbstractControl): Observable<ValidationErrors | null> => { console.log(this.httpClient); console.log('异步验证器被调用'); return of(null) .pipe(delay(1000), tap(data => console.log('验证器返回数据', data))); }; } } ``` - 👉该方法不再声明为`static` - ① 将返回值的类型设置为**方法**。该方法接收一个参数,类型为`AbstractControl`,该方法的返回值类型为:`Observable<ValidationErrors | null>` - ② `return`返回一个**方法**,**该方法**的参数类型与返回值类型与`numberNotExist()`方法声明的**返回方法**相同。 如果你是第一次将某个方法做为返回值可能会有些不适应。其实返回值类型为方法或是对象或是普通的类型并没什么不同: ```typescript // 返回值类型为number a(): number { return 1; } // 返回值类型为string a(): string { return '1'; } // 返回值类型为对象 a(): {a: number} { return {a: 123} } // 返回值类型为方法。返回方法的参数为空,返回方法的返回值类型为number a(): () => number { return (): number => { return 3; } } // 返回值类型为方法。返回方法的参数为空,返回方法的返回值类型为string a(): () => string { return (): string => { return '3'; } } // 返回值类型为方法。返回方法的参数有1个,参数类型为number,返回方法的返回值类型为number a(): (a: number) => number { return (a: number): number => { return a + a; } } // 返回值类型为方法。返回方法的参数有1个,参数类型为number,返回方法的返回值类型为string a(): (a: number) => string { return (a: number): string => { return a.toString(); } } ``` ## 单元测试 在`YzAsyncValidators`的构造函数中加入`HttpClient`后,原来的单元测试会一个构造函数异常: ```typescript describe('YzAsyncValidators', () => { it('should create an instance', () => { expect(new YzAsyncValidators()).toBeTruthy(); }); }); ``` 出错的原因也很简单:我们刚刚为`YzAsyncValidators`的构造函数指定了一个`HttpClient`参数,但在单元测试时进行实例化时却没有传入这个参数。下面让我们启用该单元测试,获取一个`HttpClient`实例后解决该错误: ```typescript +++ b/first-app/src/app/yz-async-validators.spec.ts @@ -1,7 +1,16 @@ import { YzAsyncValidators } from './yz-async-validators'; +import {TestBed} from '@angular/core/testing'; +import {HttpClient, HttpClientModule} from '@angular/common/http'; describe('YzAsyncValidators', () => { - it('should create an instance', () => { - expect(new YzAsyncValidators()).toBeTruthy(); + fit('should create an instance', async👈 () => { + // 配置动态测试模块 + await TestBed.configureTestingModule({ + imports: [HttpClientModule] + }); + // 获取动态测试模块中可被注入的HttpClient实例 + const httpClient = TestBed.inject(HttpClient); + + expect(new YzAsyncValidators(httpClient)).toBeTruthy(); }); }); ``` - 在此偷偷的加入了与`await`配合使用的`async`关键字 单元测试错误消失: ![image-20210413151249679](https://img.kancloud.cn/dc/1b/dc1b644506f50489aa4ad95dc6305051_746x102.png) ## 应用验证器 调用`YzAsyncValidators`上的`numberNotExist()`则可获取到一个异步验证器。去除`numberNotExist()`方法前的`static`关键字后,该方法由一个类的静态方法变更为了一个对象的非静态方法,所以以下代码已经不适用了: ```typescript number: new FormControl('', Validators.required, YzAsyncValidators.numberNotExist👈), ``` 为此,我们删除实例化中的相关代码: ```typescript +++ b/first-app/src/app/student/add/add.component.ts formGroup = new FormGroup({ name: new FormControl('', Validators.required), - number: new FormControl('', Validators.required, YzAsyncValidators.numberNotExist), + number: new FormControl('', Validators.required), phone: new FormControl('', YzValidators.phone), email: new FormControl(), clazzId: new FormControl(null, Validators.required) ``` 当`numberNotExist()`方法变更为对象的方法后,若想调用该方法,则首先需要一个对象,然后将该对象`numberNotExist()`方法值应用到`FormControl`上。为此,我们对组件的代码进行简单的改造: ```typescript export class AddComponent implements OnInit { formGroup: FormGroup; constructor() { this.formGroup = new FormGroup({ name: new FormControl('', Validators.required), number: new FormControl('', Validators.required), phone: new FormControl('', YzValidators.phone), email: new FormControl(), clazzId: new FormControl(null, Validators.required) }); } ``` 然后我们尝试获取一个`YzAsyncValidators`实例,并将其方法的返回值做为异步验证器来设置学号对应的`FormControl`: ```typescript +++ b/first-app/src/app/student/add/add.component.ts @@ -1,6 +1,7 @@ import {Component, OnInit} from '@angular/core'; import {FormControl, FormGroup, Validators} from '@angular/forms'; import {YzValidators} from '../../yz-validators'; +import {YzAsyncValidators} from '../../yz-async-validators'; @Component({ selector: 'app-add', @@ -11,9 +12,10 @@ export class AddComponent implements OnInit { formGroup: FormGroup; constructor() { + const yzAsyncValidators = null as unknown as YzAsyncValidators; 👈 this.formGroup = new FormGroup({ name: new FormControl('', Validators.required), - number: new FormControl('', Validators.required), + number: new FormControl('', Validators.required, yzAsyncValidators.numberNotExist()①), phone: new FormControl('', YzValidators.phone), email: new FormControl(), clazzId: new FormControl(null, Validators.required) ``` - ① 这是使用的是`yzAsyncValidators.numberNotExist()`而非`yzAsyncValidators.numberNotExist` - 我们在这里使用了`as unknown as YzAsyncValidators`进行类型的强制转换,这使得原本为`null`的值被做为了`YzAsyncValidators`类型来处理。这明显是有风险的,在启用单元测试对组件进行测试时,则会显露出该风险点:👈 ![image-20210413151503548](https://img.kancloud.cn/7e/2a/7e2ad2baa4832015b94c2e938f39f85c_1456x214.png) 上图报了一个类型错误说:你不是说`yzAsyncValidators`的类型是`YzAsyncValidators`吗?怎么在这个类型上执行①`numberNotExist()`方法时却不行?所以在开发过程中,除非我们对类型非常的有把握,否则一定**不**要使用`as unknown as Xxxx`来进行强制转换。 好了,暂时就到这里。下一节中,我们来展示如何在组件中注入`YzAsyncValidators`并完善一下细节。 | 名称 | 链接 | | -------------- | ------------------------------------------------------------ | | 创建异步验证器 | [https://angular.cn/guide/form-validation#creating-asynchronous-validators](https://angular.cn/guide/form-validation#creating-asynchronous-validators) | | 本节源码 | [https://github.com/mengyunzhi/angular11-guild/archive/step7.2.3.zip](https://github.com/mengyunzhi/angular11-guild/archive/step7.2.3.zip) |