# 延迟 当前用户登录失败只是显示在控制台,对用户而言这是极不友好的。 本我们实现:当用户登录失败时,显示一个持续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; + }); }); ``` ![image-20210310091314803](https://img.kancloud.cn/08/87/088770b2c309f9005a1371059f41c9cb_2328x664.png) ## 延迟 相较于一直显示错误,仅显示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秒后执行: ![image-20210310092343318](https://img.kancloud.cn/41/d3/41d3fe3be2dc9ce47b7e2801a8b31097_1366x298.png) 利用此特性在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(); }); }); ``` 测试结果却让我们表示遗憾: ![image-20210310095236677](https://img.kancloud.cn/d4/2d/d42defbe6fb36b5d8ce3a37296214c6c_1768x724.png) 虽然控制台中成功的打印一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秒 👈,以更好的在控制台中观察信息: ![image-20210310104724729](https://img.kancloud.cn/2a/14/2a14c338c589de26c2c183bd70683244_1160x190.png) 此时控制台中先后打印了日志,在输出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(); }); ``` 将其变更为错误的密码 👈。 ![image-20210310105548390](https://img.kancloud.cn/8e/6f/8e6f55bcd8f476156808545e249ad025_1476x638.png) 👇 ![image-20210310105559543](https://img.kancloud.cn/aa/2d/aa2d5324813e6788dd1fc37d52ae159c_1410x670.png) ## 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`,则可以在测试的界面中看到进行了两项测试: ![image-20210315100653217](https://img.kancloud.cn/6b/e5/6be5a58b52b2cee6df684a842701ff37_1046x346.png) 同时在控制台中查看到如下信息: ![image-20210315101356042](https://img.kancloud.cn/e6/3a/e63adf89d8fa74c699e5513a3ef60cf8_1714x380.png) 由控制台可以轻易看出,在先后执行两个`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`方法。 ![image-20210315112102934](https://img.kancloud.cn/6c/82/6c82c19655dcf55a6e0056dae9e52220_2266x756.png) 其实不仅如此,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`方法,控制台信息如下: ![image-20210315111704081](https://img.kancloud.cn/ce/64/ce649fd247a61fd3bf63bcd0dc09539f_2606x722.png) 使用`tick()`模拟时钟推进的方法将不存在**单元测试结束**后组件相关代码仍然执行的情况,请自行验证。 ## 本节作业 学习firefox调试器的使用的方法,按步执行单元测试` fit('显示错误', fakeAsync(() => {`的代码,观察界面发生的变化: ![image-20210310105949943](https://img.kancloud.cn/36/50/365039c90734c56e7d7c7b70266af803_2532x680.png) | 名称 | 地址 | | --------------------------- | ------------------------------------------------------------ | | 一篇关于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) |