上個小節中我們共同學習了如何自定義驗證器,我們把上節中那種可以馬上得到驗證結果的驗證器稱為同步驗證器。有些時候,我們在進行數據驗證時,還需要去請求后臺相關的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`。得出結論:③異步驗證器在組件非空時工作。

測試結果三:填寫學號,然后刪除學號,異步驗證器不工作。得出結論:④異步驗證器在組件值為空時不工作,所以測試結果一中得出的結論①可能是錯誤的。
測試結果四:輸入學號后,異步驗證器開始工作,在尚未返回數據期間,保存按鈕處于可用狀態。得出結論:⑤異步驗證器在未接收到后臺的返回值前,會將表單對應的`FormGroup`的`invalid`值置于`true`。

測試結果五:快速的輸入學號,比如輸入1234567,異步驗證器將被調用7次,但只會訂閱一個返回結果。得出結論:⑥`FormControl`的值每改變一次,則會調用一次異步驗證器,但如果異步驗證器沒有及時地接收到后臺的結果,則只會獲取最后一次的值。

上述結論只是我們根據現像的猜想,其實大多數時候猜想如果能解決我們當下遇到的問題,也是完完全全可以的。而如果能在猜想以后再了解到真實的原因,則會對我們知識的提升大有幫助!
真實的原因是這樣:
- 當`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`則會碰到一個根本的問題:無法在靜態方法上調用對象中的屬性:

若想在異步驗證器上使用注入到對象的`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`關鍵字
單元測試錯誤消失:

## 應用驗證器
調用`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`類型來處理。這明顯是有風險的,在啟用單元測試對組件進行測試時,則會顯露出該風險點:??

上圖報了一個類型錯誤說:你不是說`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) |
- 序言
- 第一章 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 發布部署
- 第九章 總結