# 延遲
當前用戶登錄失敗只是顯示在控制臺,對用戶而言這是極不友好的。 本我們實現:當用戶登錄失敗時,顯示一個持續1.5S的錯誤提示。
## 初始化
首先我們在V層增加提示信息:
```html
+++ b/first-app/src/app/login/login.component.html
@@ -11,5 +11,8 @@
<input type="password" class="form-control" id="exampleInputPassword1"
[(ngModel)]="teacher.password" name="password">
</div>
+ <div class="mb-3">
+ <p class="alert alert-danger" *ngIf="showError">用戶名或密碼錯誤,請重新輸入!</p>
+ </div>
<button type="submit" class="btn btn-primary">登錄</button>
</form>
```
并在C層中初始化屬性:
```typescript
+++ b/first-app/src/app/login/login.component.ts
@@ -13,6 +13,11 @@ export class LoginComponent implements OnInit {
@Output()
beLogin = new EventEmitter<Teacher>();
+ /**
+ * 是否顯示錯誤信息
+ */
+ showError = false;
+
constructor(private httpClient: HttpClient) {
}
```
### 測試
增加相應的單元測試代碼:
```typescript
+++ b/first-app/src/app/login/login.component.spec.ts
@@ -53,4 +53,9 @@ describe('LoginComponent', () => {
component.teacher = {username: '張三', password: 'codedemo.club'} as Teacher;
component.onSubmit();
});
+
+ fit('顯示錯誤', () => {
+ fixture.autoDetectChanges();
+ component.showError = true;
+ });
});
```

## 延遲
相較于一直顯示錯誤,僅顯示1.5秒的錯誤信息,可以有效的提升用戶的使用感受。在技術上,我們需要一項能夠延時1.5S的方法。在此我們給出原生的`Timer`方法。
### setTimeout
js中有兩個原生的延時執行方法,分別是`setTimeout`以及`setInterval`。在執行該延時方法時js自動啟動異步機制。我們前面的章節中多次使用了http請求。在javascript中,我們把這種http請求又稱為資源請求,javascript會有兩種情況下啟用異步機制,分別為:我們這里即將使用的延時(`setTimeout`、`setInterval`),以及資源請求。
所以如果在筆試中被問及javascript的異步機制時,我們大概知道怎么開頭了吧。
`setTimeout(function, delayTime)`方法,將在`delayTime(毫秒)`后執行`function`,比如:
```javascript
console.log(new Date());
setTimeout(() => console.log(new Date(), 'run'), 1000);
```
則`console.log`將在1秒后執行:

利用此特性在C層中定義`setShowError()`方法如下:
```typescript
+++ b/first-app/src/app/login/login.component.ts
@@ -41,4 +41,15 @@ export class LoginComponent implements OnInit {
.subscribe(teacher => this.beLogin.emit(teacher),
error => console.log('發生錯誤, 登錄失敗', error));
}
+
+ /**
+ * 延遲顯示錯誤信息
+ */
+ showErrorDelay(): void {
+ this.showError = true;
+ setTimeout(() => {
+ console.log('1.5秒后觸發');
+ this.showError = false;
+ }, 1500);
+ }
}
```
### 測試
**注意**:在測試延遲時,必須保證當前項目中我們僅使用一個`fit`,即僅有一個測試用例生效,多個`fit`的情況下將看不到延遲效果。
```typescript
+++ b/first-app/src/app/login/login.component.spec.ts
@@ -56,6 +56,6 @@ describe('LoginComponent', () => {
fit('顯示錯誤', () => {
fixture.autoDetectChanges();
- component.showError = true;
+ component.showErrorDelay();
});
});
```
測試結果卻讓我們表示遺憾:

雖然控制臺中成功的打印一1.5秒后觸發了相應的方法,但V層中的錯誤提示卻未消失。在此,我們簡單的解釋下為什么單元測試中并未很好的支持`setTimeout`等延遲的延時。
## 單元測試延遲
如果被測的代碼包含延遲方法,則在單元測試中該方法看似會**失敗**。這是由以下幾點決定的:
### 測試定位
單元測試的定位為:使用代碼來測試代碼的正確性,使用代碼來保證代碼的正確性。該測試代碼在生產項目中是被自動執行的,單元測試的目的是防止在生產項目開發了一個新功能的同時誤殺掉歷史的老功能。從這個角度上來,單元測試的代碼將在開發新功能的代碼在投交給團隊長被執行一遍,如果執行沒有報錯,則說明新功能沒有破壞歷史功能。
這個過程往往是自動化的。而這個自動化的過程,應該規避項目中的延遲。比如我們在生產中有個鎖屏功能,實現的是如果用戶10分鐘內未操作系統 ,則進行鎖屏。如果在單元測試中支持這個10分鐘的鎖屏操作,那么再驗證其功能是否正常時,則需要等待10分鐘。如果項目中有多個10分鐘呢?
顯示我們不能接觸一個單元測試跑上2個的小時的情況,所以在單元測試中并不會等待代碼中的setTimeout方法。
### 變更檢測
我們在單元測試中習慣性的加入了`fixture.autoDetectChanges();`,我們前面講過它的功能是當C層發生變化時重新對V層進行渲染,在`ng s`時該功能默認開啟,而在`ng t`時該功能默認關閉。
在開啟該功能時,angular是借助了一個叫做`zone.js`的偉大軟件實現了變更檢測,這種檢測即不損失效率,又可以在數據變化的第一時間內得到通知。該`zone.js`對`setTimeout`等方法做了些手腳,以達到監聽的目的。所以即使我們在`ng t`中開啟了`fixture.autoDetectChanges();`,但由于一些**特殊的設置**,在`ng t`時,Angular也無法感知到`setTimeout`中變更的組件屬性`showError`。
## 解決方案
鑒于剛剛提出的原因,如果我們想在單元測試中感知到`setTimeout`中的屬性變化,則需要Angular提供了`ngZone`,實際上Angular官方也是推薦我們這么做的:
```typescript
+++ b/first-app/src/app/login/login.component.ts
@@ -1,4 +1,4 @@
-import {Component, EventEmitter, OnInit, Output} from '@angular/core';
+import {Component, EventEmitter, NgZone, OnInit, Output} from '@angular/core';
import {HttpClient, HttpHeaders} from '@angular/common/http';
import {Teacher} from '../entity/teacher';
@@ -18,7 +18,7 @@ export class LoginComponent implements OnInit {
*/
showError = false;
- constructor(private httpClient: HttpClient) {
+ constructor(private httpClient: HttpClient, private ngZone: NgZone) {
}
ngOnInit(): void {
@@ -47,9 +47,11 @@ export class LoginComponent implements OnInit {
*/
showErrorDelay(): void {
this.showError = true;
- setTimeout(() => {
- console.log('1.5秒后觸發');
- this.showError = false;
- }, 1500);
+ this.ngZone.run(() => {
+ setTimeout(() => {
+ console.log('1.5秒后觸發');
+ this.showError = false;
+ }, 1500);
+ });
}
}
```
## tick()
實際上,即使我們使用上述方法達到了在`ng t`中觀測`setTimeout`方法中屬性變化的目的。但這也單元測試的定位相違背,因為我們并沒有解決使用代碼再短時間內測試代碼的效果。
Angular提供的`tick()`允許我們在單元測試中模擬將時鐘向前推進一些時間,從而馬上執行在本應該在某些時間后才能執行的代碼。預使用該方法,則需要將`fakeAsync()`方法傳入`fit`:
```typescript
+++ b/first-app/src/app/login/login.component.spec.ts
@@ -1,4 +1,4 @@
-import {ComponentFixture, TestBed} from '@angular/core/testing';
+import {ComponentFixture, fakeAsync, TestBed} from '@angular/core/testing';
import {LoginComponent} from './login.component';
import {FormsModule} from '@angular/forms';
@@ -54,8 +54,8 @@ describe('LoginComponent', () => {
component.onSubmit();
});
- fit('顯示錯誤', () => {
+ fit('顯示錯誤', fakeAsync(() => {
component.showErrorDelay();
fixture.autoDetectChanges();
- });
+ }));
});
```
然后加入`tick()`以模似時鐘推進:
```typescript
+++ b/first-app/src/app/login/login.component.spec.ts
@@ -1,4 +1,4 @@
-import {ComponentFixture, fakeAsync, TestBed} from '@angular/core/testing';
+import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
import {LoginComponent} from './login.component';
import {FormsModule} from '@angular/forms';
@@ -55,7 +55,23 @@ describe('LoginComponent', () => {
});
fit('顯示錯誤', fakeAsync(() => {
+ // 初始化不顯示錯誤提醒
+ expect(component.showError).toBe(false);
+ fixture.detectChanges();
+
+ // 立即顯示錯誤提醒
component.showErrorDelay();
+ expect(component.showError).toBe(true);
+ console.log(new Date());
+ fixture.detectChanges();
+
+ // 將時鐘模擬向前推進15000MS
+ tick(15000); ??
+ console.log(new Date());
+ fixture.detectChanges();
+
+ // 斷言錯誤提醒消息
+ expect(component.showError).toBe(false);
fixture.autoDetectChanges();
}));
});
```
這里我們模擬推進15秒 ??,以更好的在控制臺中觀察信息:

此時控制臺中先后打印了日志,在輸出date時發現時鐘的確被推進了。而我們清楚,先后兩條日志打印的時間間隔絕對沒有15秒,這就是`tick()`方法的作用。
## 功能完成
最后,我們在用戶登錄失敗時,調用`showErrorDelay()`方法:
```typescript
+++ b/first-app/src/app/login/login.component.ts
@@ -39,7 +39,10 @@ export class LoginComponent implements OnInit {
'http://angular.api.codedemo.club:81/teacher/login',
{headers: httpHeaders})
.subscribe(teacher => this.beLogin.emit(teacher),
- error => console.log('發生錯誤, 登錄失敗', error));
+ error => {
+ console.log('發生錯誤, 登錄失敗', error);
+ this.showErrorDelay();
+ });
}
/**
```
然后啟用用戶登錄單元測試,嘗試使用個錯誤的用戶名密碼來查看效果吧:
```typescript
fit('onSubmit 用戶登錄', () => {
// 啟動自動變更檢測
fixture.autoDetectChanges();
component.teacher = {username: '張三', password: 'codedemo.club' ??} as Teacher;
component.onSubmit();
});
```
將其變更為錯誤的密碼 ??。

??

## ng t與生命周期
我們在前面的章節中接觸了組件生命周期的概念,所謂的生命周期即組件由出生到死記的整個過程。用術語來講是組件由實例化到被銷毀的整個過程:
* ? 執行構造函數,實例化組件實例;
* ? 檢測是否存在`ngOnInit()`方法,有則執行一次。
* ? 解析V層代碼;
* ? 解析在V層中使用的變量。
既然是由實例化到組件被銷毀,那么生命周期中也必然存在銷毀組件一步:
* ? 執行構造函數,實例化組件實例;
* ? 檢測是否存在`ngOnInit()`方法,有則執行一次。
* ? 解析V層代碼;
* ? 解析在V層中使用的變量。
* ? 在當前組件不被需要的時,銷毀組件。
本節我們結結合當前的單元測試情況來查看下組件的銷毀情況以及何時銷毀組件:
`ng t`在執行時,如果遇到多個測試單元(`it`或`fit`)被執行時,`ng t`在執行某個單元測試當,會銷毀前面已執行單元測試中創建的組件。在組件被銷毀時,Angular將自動調用組件的`ngOnDestroy`方法,該方法被聲明在`OnDestroy`接口中,在Angular的開發規范,當組件中存在`ngOnDestroy`時,則需要聲明實現了`OnDestroy`接口:
```typescript
+++ b/first-app/src/app/login/login.component.ts
@@ -1,4 +1,4 @@
-import {Component, EventEmitter, NgZone, OnInit, Output} from '@angular/core';
+import {Component, EventEmitter, NgZone, OnDestroy, OnInit, Output} from '@angular/core';
import {HttpClient, HttpHeaders} from '@angular/common/http';
import {Teacher} from '../entity/teacher';
@@ -7,7 +7,7 @@ import {Teacher} from '../entity/teacher';
templateUrl: './login.component.html',
styleUrls: ['./login.component.css']
})
-export class LoginComponent implements OnInit {
+export class LoginComponent implements OnInit, OnDestroy {
teacher = {} as Teacher;
@Output()
@@ -22,6 +22,11 @@ export class LoginComponent implements OnInit {
}
ngOnInit(): void {
+ console.log('組件初始化執行1次: ngOnInit');
+ }
+
+ ngOnDestroy(): void {
+ console.log('組件被銷毀時執行一次:ngOnDestroy');
}
onSubmit(): void {
```
此時,讓我們隨意將另一個`it`變更為`fit`,比如將`login.component.spec.ts`中對用戶登錄的測試的`it`變更為`fit`,則可以在測試的界面中看到進行了兩項測試:

同時在控制臺中查看到如下信息:

由控制臺可以輕易看出,在先后執行兩個`ng t`時,組件初始化方法被執行了兩次,同時被銷毀了一次。這是由于我們單元測試中的`beforeEach`語句決定的,`beforeEach`意為在執行每個測試單元前執行:
```typescript
beforeEach(() => {
// 實例化組件
fixture = TestBed.createComponent(LoginComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
```
為了保證每個測試單元與其它的測試單元互不干擾,Jasmine會在每個測試單元執行前都執行一次`beforeEach`中的代碼。同時Angular中有個節約資源的原則:當組件被檢測到**不被使用**時,將發起對組件的銷毀操作。而每個測試單元執行前的`component = fixture.componentInstance;`都會導致`component`被重新賦值,這也直接使得`component`變量以前指向的組件實例被Angular認為是**不被使用**的,既而Angular對組件進行銷毀,從而觸發了`ngOnDestroy`方法。

其實不僅如此,Jasmine在執行多個單元測試時,其最終也將釋放對V層`dom`的控制權。其直接導致的后果是當用戶名密碼錯誤時,V層并沒有顯示相應的錯誤提示。
除`beforEach`方法外,Jasmine還支持在每個測試單元測試結束后執行的`afterEach`方法:
```typescript
+++ b/first-app/src/app/login/login.component.spec.ts
@@ -74,4 +74,8 @@ describe('LoginComponent', () => {
expect(component.showError).toBe(false);
fixture.autoDetectChanges();
}));
+
+ afterEach(() => {
+ console.log('after all');
+ });
});
```
加入`afterAll`方法,控制臺信息如下:

使用`tick()`模擬時鐘推進的方法將不存在**單元測試結束**后組件相關代碼仍然執行的情況,請自行驗證。
## 本節作業
學習firefox調試器的使用的方法,按步執行單元測試` fit('顯示錯誤', fakeAsync(() => {`的代碼,觀察界面發生的變化:

| 名稱 | 地址 |
| --------------------------- | ------------------------------------------------------------ |
| 一篇關于ngZone的文章 | [https://blog.kevinyang.net/2019/02/14/ng-ngzone/](https://blog.kevinyang.net/2019/02/14/ng-ngzone/) |
| ngZone官方文檔(原文) | [https://angular.io/guide/zone](https://angular.io/guide/zone) |
| JS定時器 | [https://www.runoob.com/w3cnote/js-timer.html](https://www.runoob.com/w3cnote/js-timer.html) |
| fakeAsync | [https://www.angular.cn/api/core/testing/fakeAsync](https://www.angular.cn/api/core/testing/fakeAsync) |
| Tick | [https://www.angular.cn/api/core/testing/tick](https://www.angular.cn/api/core/testing/tick) |
| 在firefox中debug TypeScript | [https://hacks.mozilla.org/2019/09/debugging-typescript-in-firefox-devtools/](https://hacks.mozilla.org/2019/09/debugging-typescript-in-firefox-devtools/) |
| what is zone | [https://www.youtube.com/watch?v=3IqtmUscE_U&t=150s](https://www.youtube.com/watch?v=3IqtmUscE_U&t=150s) |
| 本節源碼 | [https://github.com/mengyunzhi/angular11-guild/archive/step5.1.zip](https://github.com/mengyunzhi/angular11-guild/archive/step5.1.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 發布部署
- 第九章 總結