# 注銷
本節我們開始完成熟悉的注銷功能。
## 注銷1.0
實現注銷功能前,我們先復習下用戶登錄的步驟:
1. 在login組件上增加一個可以向外彈射事件的beLogin方法。
2. 在index組件的V層中,引入login組件,并關聯beLogin方法至index組件的onLogin方法上。
3. 用戶使用正確的用戶名、密碼登錄后,login組件將登錄成功的事件向上彈出。
4. index組件的onLogin方法接收到了彈出的事件,設置自己的login屬性為true,進而完成了登錄功能。
那么注銷功能完全可以參考上述登錄功能完成:
1. 在nav組件上增加一個可以向外彈射事件的beLogout方法。
2. 在index組件的V層中,引入nav組件,并關聯beLogout方法至index組件的onLogout方法上。
3. 用戶點擊注銷按鈕后,nav組件將注銷成功的事件向上彈出。
4. index組件的onLogout方法接收到了彈出的事件,設置自己的login屬性為false,進而完成了注銷功能。
思想有了,編碼便成為了最簡單的事情:
### beLogout
來到導航組件,新增一個用于發送數據的`beLogout`,再增加一個用于鏈接V層的`onSubmit`:
```typescript
+import {Component, EventEmitter, OnInit, Output} from '@angular/core';
@Component({
selector: 'app-nav',
@@ -7,10 +7,16 @@ import {Component, OnInit} from '@angular/core';
})
export class NavComponent implements OnInit {
+ @Output()
+ beLogout = new EventEmitter<void>(); ??
+
constructor() {
}
ngOnInit(): void {
}
+ onSubmit(): void {
+ this.beLogout.emit(undefined); ??
+ }
}
```
當泛型被聲明為void時,可以將數據設置為`undefined`。當然了,在大多數時候,留空也是可以了,比如上述代碼完全可以重寫為:`this.beLogout.emit();`,趕快試試吧。
V層綁定相關方法:
```html
+++ b/first-app/src/app/nav/nav.component.html
@@ -24,7 +24,7 @@
<a class="nav-link" routerLink="personal-center">個人中心</a>
</li>
</ul>
- <form class="form-inline my-2 my-lg-0">
+ <form class="form-inline my-2 my-lg-0" (ngSubmit)="onSubmit()">
<button class="btn btn-outline-light my-2 my-sm-0" type="submit">注銷</button>
</form>
</div>
```
最后我們使用單元測試來保證上述功能的正確性,即:用代碼來測試代碼。在團隊開發中,這還可以起到保護我們當前代碼功能的作用,當其它人(也極有可能是日后的自己)在開發其它功能時,單元測試通過說明當前的功能未被破壞。這在保證項目質量是非常有幫助的。
在進行單元測試,我們應該盡量的細化測試的粒度,比如把我們剛剛的功能分為兩個測試點:測試V層點擊注銷按鈕后,C層相應的方法是否被觸發;測試C層onSubmit是否按我們的想法調用了beLogout的emit方法。
```typescript
+++ b/first-app/src/app/nav/nav.component.spec.ts
@@ -22,4 +22,10 @@ describe('NavComponent', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
+
+ fit('v層注銷按鈕', () => {
+ // 獲取V層的注銷按鈕
+ // 在c層的相關方法中安插間諜
+ // 點擊注銷按鈕,則間諜方法應該被調用
+ });
});
```
正式動手前寫寫注釋是個值得表揚的好習慣!接下來相信你已經有能力來完成該單元測試了。
```typescript
+++ b/first-app/src/app/nav/nav.component.spec.ts
@@ -29,4 +29,10 @@ describe('NavComponent', () => {
// 點擊注銷按鈕,則間諜方法應該被調用
});
+ fit('onSubmit', () => {
+ // 接收組件的beLogout發送數據的數據
+ // 調用onSubmit方法
+ // 如果的確在1步接收成功,就說明onSubmit方法成功的彈出了數據;否則,說明未成功,報異常
+
+ });
+
});
```
對`beLogout.emit`有那么點點特殊。我們在前面學習過`EventEmitter`是可以按自己的意愿向上彈射數據,該數據可以由父組件綁定相應的方法的方式接收到。而在單元測試中如何使用代碼的方式來接收呢?使用代碼接收同樣也很簡單,而且我們早早的就接觸到了它們:
```typescript
+++ b/first-app/src/app/nav/nav.component.spec.ts
@@ -31,7 +31,13 @@ describe('NavComponent', () => {
fit('onSubmit', () => {
// 接收組件的beLogout發送數據的數據
+ component.beLogout.subscribe(() => {
+ console.log('接收到了數據');
+ });
+
// 調用onSubmit方法
+ component.onSubmit();
+
// 如果的確在1步接收成功,就說明onSubmit方法成功的彈出了數據;否則,說明未成功,
報異常
});
```
沒錯由于`EventEmitter`有按自己的意愿發送數據的特性,所以我們同樣可以使用`subscribe`對其進行訂閱(關注),此時一旦`EventEmitter`有新的動態`subscribe`中的函數則會被自動執行一次。

如果我們在此多執行幾次`component.onSubmit();`,則會在控制臺中多顯示幾次`接收到了數據`,請試試看。
控制臺中同時還顯示了一個異常,該異常提示說:不能在li上綁定`routerLinkActiveOptions`屬性,因為angular不認識它。作用路由一部分的`routerLinkActiveOptions`存在于路由模塊中,所以解決該錯誤的方法是在當前測試文件中引入路由(測試)模塊:
```typescript
+++ b/first-app/src/app/nav/nav.component.spec.ts
@@ -1,6 +1,7 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {NavComponent} from './nav.component';
+import {RouterTestingModule} from '@angular/router/testing';
describe('NavComponent', () => {
let component: NavComponent;
@@ -8,7 +9,8 @@ describe('NavComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
- declarations: [NavComponent]
+ declarations: [NavComponent],
+ imports: [RouterTestingModule]
})
.compileComponents();
});
```
再次運行單元測試,錯誤消失:

繼續回到對onSubmit方法的測試中,我們說使用`ng t`來測試代碼成功與否是不應該來查看控制臺確認的,那么如何來使用代碼來保證`onSubmit方法成功的彈出了數據`呢?我們需要以下的小技巧:
```typescript
+++ b/first-app/src/app/nav/nav.component.spec.ts
@@ -33,14 +33,17 @@ describe('NavComponent', () => {
fit('onSubmit', () => {
// 接收組件的beLogout發送數據的數據
+ let called = false;
component.beLogout.subscribe(() => {
console.log('接收到了數據');
+ called = true;
});
// 調用onSubmit方法
component.onSubmit();
// 如果的確在1步接收成功,就說明onSubmit方法成功的彈出了數據;否則,說明未成功,
報異常
+ expect(called).toBeTrue();
});
});
```
如此以來,如果`subscribe`方法成功的接收到了向上彈出的空數據,則called變量必為true;反之如果called變量為true,則也能夠說明`subscribe`方法成功的接收到了空數據。
### onLogout
完成了注銷組件的彈出方法后,接下來在`index`組件來對接這個注銷事件:
```typescript
+++ b/first-app/src/app/index/index.component.ts
@@ -26,4 +26,10 @@ export class IndexComponent implements OnInit {
// 將登錄狀態寫入緩存
window.sessionStorage.setItem('login', 'true');
}
+
+ onLogout(): void {
+ console.log('接收到注銷組件的數據彈射,開始注銷');
+ this.login = false;
+ window.sessionStorage.removeItem('login');
+ }
}
```
接著在V層中綁定注銷組件的`(beLogout)`方法:
```html
+++ b/first-app/src/app/index/index.component.html
@@ -1,5 +1,5 @@
<!--登錄成功后,在上面顯示導航-->
-<app-nav *ngIf="login"></app-nav>
+<app-nav *ngIf="login" (beLogout)="onLogout()"></app-nav>
<!--在下方顯示路由對應的具體組件-->
<router-outlet *ngIf="login"></router-outlet>
```
我們再次借助單元測試來驗證上述代碼的正確與否,本著測試粒度最小化的原則,我們并不需要由nav組件的點擊注銷按鈕開始測試,而僅僅需要測試:點nav組件向外彈射數據時,index組件是否成功的接收了數據即可。
> 單元測試的粒度控制的確需要一些時日才能運用自如,但幸運的是只要我們在上面加以時日便一定能運用自如。
```typescript
+++ b/first-app/src/app/index/index.component.spec.ts
@@ -30,4 +30,11 @@ describe('IndexComponent', () => {
expect(component).toBeTruthy();
fixture.autoDetectChanges();
});
+
+ fit('與注銷組件對接', () => {
+ // 在index組件相應的方法中安插間諜
+ // nav組件彈數據
+ // index組件接收數據
+ // 斷言間諜方法被調用,則說明nav組件彈數據后,index相應的方法將被調用
+ });
});
```
基本的思路有了,我們像聊天一下分步補充功能代碼如下:
```typescript
+++ b/first-app/src/app/index/index.component.spec.ts
@@ -33,8 +33,13 @@ describe('IndexComponent', () => {
fit('與注銷組件對接', () => {
// 在index組件相應的方法中安插間諜
+ spyOn(component, 'onLogout');
+
// nav組件彈數據
+ // 如何來獲取這個nav組件呢?
+
// index組件接收數據
// 斷言間諜方法被調用,則說明nav組件彈數據后,index相應的方法將被調用
+ expect(component.onLogout).toHaveBeenCalled();
});
});
```
完成功能時,我們發現**如何來獲取NAV組件**我們還未掌握。在項目開發中,我們應該優先規整整個項目中尚未掌握的技術點,優先的來解決它們,當這個尚未掌握的點被解決后,一個項目大概什么時候能完工就能心中有點數了。
在單元測試中,我們可能通過放置測試組件的夾具`fixture.debugElement`來獲取到測試過程中其它的組件:
```typescript
+++ b/first-app/src/app/index/index.component.spec.ts
@@ -7,6 +7,7 @@ import {HttpClientModule} from '@angular/common/http';
import {FormsModule} from '@angular/forms';
import {RouterTestingModule} from '@angular/router/testing';
import {NavComponent} from '../nav/nav.component';
+import {By} from '@angular/platform-browser'; ??
describe('IndexComponent', () => {
let component: IndexComponent;
@@ -37,6 +38,8 @@ describe('IndexComponent', () => {
// nav組件彈數據
// 如何來獲取這個nav組件呢?
+ const navComponent = fixture.debugElement.query(By.directive(NavComponent));
+ console.log(navComponent);
// index組件接收數據
// 斷言間諜方法被調用,則說明nav組件彈數據后,index相應的方法將被調用
```
**注意**:整個項目中有好幾個`By`,這里需要使用 ?? 所指的這個。
運行單元測試,卻好像**意外**的報錯了:

之所以說好像,是由于我們犯了**想當然、我認為、應該**的錯誤,只所以沒有獲取到nav組件,并不是由于我們的代碼出現什么邏輯性的、關鍵的錯誤,而是當前的單元測試中的確就不存在nav組件:

向下滾動單元測試便可以輕易發現當然是用戶未登錄狀態,所以顯示了登錄組件,而nav組件由于未使用到,所以angular并沒有實例化它(在用到的時候才實例化,這是節約資源的一種有效手段),那么此時獲取不到nav組件當然是正常的。
那么是否需要使用模擬登錄的方法來顯示出nav組件呢?答案是否定的。因為我們完全不必這么做。在當前組件中,是否顯示nav組件,取決于當前index組件中的login屬性,所以預顯示nav組件,僅僅將login屬性的值設置為true便可以實現。
```typescript
+++ b/first-app/src/app/index/index.component.spec.ts
@@ -33,6 +33,10 @@ describe('IndexComponent', () => {
});
fit('與注銷組件對接', () => {
+ // 顯示民航組件
+ component.login = true;
+ fixture.detectChanges(); ??
+
// 在index組件相應的方法中安插間諜
spyOn(component, 'onLogout');
```
C層的屬性變更后,必須通知測試夾具(fixture)重新渲染V層,否則V層將保持原樣。 ??

最后我們在獲取到的nav組上發送數據:
```typescript
+++ b/first-app/src/app/index/index.component.spec.ts
@@ -44,6 +44,8 @@ describe('IndexComponent', () => {
// 如何來獲取這個nav組件呢?
const navComponent = fixture.debugElement.query(By.directive(NavComponent));
console.log('獲取到了導航組件', navComponent);
+ const navComponentInstance = navComponent.componentInstance as NavComponent; ??
+ navComponentInstance.beLogout.emit();
// index組件接收數據
// 斷言間諜方法被調用,則說明nav組件彈數據后,index相應的方法將被調用
```
通過`navComponent.componentInstance`來獲取組件的實例,使用as關鍵字來指定一個類型。如果不使用as關鍵字指定類型則navComponentInstance變量的類型將被認為是any,這也是可以的,但你不應該這樣做。
單元測試通過,說明index組件成功的獲取了注銷組件彈出的**注銷**事件,至此兩個組件的對接在單元測試的支持下被完美的完成了。

最后我們啟用`ng s`來啟動應用,使用用戶名密碼登錄后再點擊注銷按鈕,最終驗證功能的正確性,過程略。
## 后臺注銷2.0
我們剛剛看似完成了注銷功能,但實際上這是一種極不負責的方式。糾其原因則是我們犯了一個在生產中經常容易犯的毛病:不按規范行事。
在上個小節中我們給出了后臺注銷的API:
```bash
GET /teacher/logout
```
但我們剛剛好像并沒有使用到,這種不規范可以被壞人非常輕松的利用,比如我們剛剛使用了公共電腦登錄本系統:登錄、使用、注銷。然后我們放心的離開了,現在壞人登場。壞人打開瀏覽器的控制臺,來到Storge界面。

接下來加入如下信息:

key值寫login,value寫入true,接著刷新瀏覽器。噔噔噔噔,一個由軟小白開發的系統就這樣成功的被壞人利用了。
有人說那我們是否可以在注銷時把`x-auth-token`也清空,這樣用于認證的`x-auth-token`沒有了,壞人就沒有辦法訪問一些后臺對權限認證的資源(比如個人中心)了。沒錯,如果壞人很簡單,這種思想是沒有問題的。我們復習一下前后臺使用cookie的認證模式:

在認證過程中,我們使用x-auth-token替換了cookie實現了用戶認證。無論是cookie還是x-auth-token,這都像極了現實生活中的各種**會員卡**,或是沒有密碼的**信用卡**。在實際生活的日常消費中**信用卡**可做為消費憑證完成用戶與銀行的認證過程。那么我們應該如何來注銷一張**信用卡**呢?
如果我們在注銷時再聰明的把`x-auth-token`也一并清空,則實際上相當于我們在注銷銀行卡時沒有去銀行,而是直接把信用卡片仍入了垃圾桶。但是銀行方的信用卡信息并未消除,卡片信息仍然有效。所以如果這張被棄用的信用卡作用被壞人由垃圾桶中拾取的話,是完全可以繼續使用的。歷史上我們使用的銀行卡都是磁條式的,這種銀行卡具有高度的可復制功能,所以在磁條卡的時代發生過不少的銀行卡被盜刷的事件。而如果發現銀行卡被盜刷,受害人只把自己手中的銀行卡扔入垃圾桶是完全無濟于事的。
這種壞人也很容易做到,它僅需要把握好一個做案時機即可:用戶使用過程去下WC或是去吸只煙。整下過程如下:
1. 用戶登錄系統
2. 半路去吸煙
3. 壞人出場,去緩存中獲取這個`x-auth-token`,接著離場
4. 用戶繼續使用系統
5. 用戶注銷
6. 然后壞人僅需要在瀏覽器的緩存中輸入這個`x-auth-token`,同將`login`設置為`true`便可繼續的操作本系統(甚至是同步的)
上述**模擬犯罪**的過程請自行嘗試。相信你現在知道為什么我們在使用銀卡時為什么要遵循以下規則了吧:
1. 如果可以辦理芯片式的銀行卡,則不應該辦理磁條式的。因為磁條式銀行卡可有可復制的特性。壞人可以使用相關的設備在瞬間復制一張具有相同信息的副卡出來。
2. 在消費刷卡時,不應該讓銀行卡離開自己的視線,不給壞人復制的機會。
3. 現在大多數的POS機會制定一個規則:如果當前的銀行卡有芯片,則必須刷芯片才能完成支付。這是對儲戶的一種保護。
鋪墊了這么多,一是為了使你的大腦更容易接受**按規范開發**的團隊規范,使它由排斥、被動接受團隊的基本規范轉為主動接受;二是為了以下正確的代碼做準備。
```typescript
+++ b/first-app/src/app/nav/nav.component.ts
@@ -1,4 +1,5 @@
import {Component, EventEmitter, OnInit, Output} from '@angular/core';
+import {HttpClient} from '@angular/common/http';
@Component({
selector: 'app-nav',
@@ -10,13 +11,16 @@ export class NavComponent implements OnInit {
@Output()
beLogout = new EventEmitter<void>();
- constructor() {
+ constructor(private httpClient: HttpClient) {
}
ngOnInit(): void {
}
onSubmit(): void {
- this.beLogout.emit(undefined);
+ const url = 'http://angular.api.codedemo.club:81/teacher/logout';
+ this.httpClient.get(url)
+ .subscribe(() => this.beLogout.emit(undefined),
+ error => console.log('logout error', error));
}
}
```
如此我們便完成了注銷功能:后臺的注銷、前臺的注銷。使用`ng s`進行相應測試,測試通過。
## 本節作業
1. 完成nav組件`v層注銷按鈕`測試用例的編寫。
2. 移除所有的`fit`,使用`ng t`來對全局進行測試,你將發現一些錯誤,請嘗試修正它們。
| 名稱 | 鏈接 | 備注 |
| ----------------------- | ------------------------------------------------------------ | ------------------------- |
| 對嵌套組件的測試 | [https://angular.cn/guide/testing-components-scenarios#nested-component-tests](https://angular.cn/guide/testing-components-scenarios#nested-component-tests) | |
| 搭建http請求測試環境 | [https://angular.cn/guide/http#setup-for-testing](https://angular.cn/guide/http#setup-for-testing) | 你需要它來幫助你完成作業2 |
| DebugElement | [https://angular.cn/guide/testing-components-basics#debugelement](https://angular.cn/guide/testing-components-basics#debugelement) | |
| query | [https://angular.cn/api/animations/query](https://angular.cn/api/animations/query) | |
| by | [https://angular.cn/api/platform-browser/By](https://angular.cn/api/platform-browser/By) | |
| 本節源碼(含作業2答案) | [https://github.com/mengyunzhi/angular11-guild/archive/step5.6.zip](https://github.com/mengyunzhi/angular11-guild/archive/step5.6.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 發布部署
- 第九章 總結