相對新增而言,編輯功能有一些小小的復雜,在開始編碼前讓我們共同復習一下其流程:
- 獲取路由中的參數id
- 根據獲取的參數id請求后臺的班級數據
- 使用班級數據填充V層表單
- 編輯V層
- 將編輯后的表單提交到后臺
有了個大概的流程后,但可以嘗試進行開發了。
## 獲取路由參數
路由參數位于`ActivatedRoute`中:
```typescript
+++ b/first-app/src/app/clazz/edit/edit.component.ts
- constructor() { }
+ constructor(private activatedRoute: ActivatedRoute) { }
```
在`ng t`中,使用`RouterTestingModule`提供`RouterState`:
```typescript
+++ b/first-app/src/app/clazz/edit/edit.component.spec.ts
+import {RouterTestingModule} from '@angular/router/testing';
- ReactiveFormsModule
+ ReactiveFormsModule,
+ RouterTestingModule
]
```
參考在編輯教師組件關于獲取路由參數的代碼,可以調用`activatedRoute`上的`snapshot`來獲取到請求的`id`信息:
```typescript
+++ b/first-app/src/app/clazz/edit/edit.component.ts
@@ -12,6 +12,7 @@ export class EditComponent implements OnInit {
}
ngOnInit(): void {
+ const id = this.activatedRoute.snapshot.params.id;
}
```
## 測試
`ActivatedRoute`在獲取`id`值時,依賴于`url`中路由信息,在當前`ng t`的環境下,URL地址固定為`http://localhost:9876/?id=xxxxx`,所以在單元測試中并沒有辦法直觀的感覺到代碼`const id = this.activatedRoute.snapshot.params.id;`的執行情況。
為此,我們暫時放棄對路由的測試,待clazz模塊中全部的組件完成后啟用`ng s` 時,再來觀察路由情況。但是獲取路由中的參數`id`卻是我們開發組件的第一項,如果不完成這項,后續的操作好像無法進行。在`ng s`進行集成開發、測試時是這樣的,但當前是`ng t`單元測試,它可以在測試過程中按我們的需求變更組件的屬性值或是調用組件中的某些方法。利用`ng t`的此特性,我們在當前組件中添加如下代碼:
```typescript
+++ b/first-app/src/app/clazz/edit/edit.component.ts
@@ -13,6 +13,15 @@ export class EditComponent implements OnInit {
ngOnInit(): void {
const id = this.activatedRoute.snapshot.params.id;
+ // 調用loadById方法,獲取預編輯的班級
+ }
+
+ /**
+ * 由后臺加載預編輯的班級.
+ * @param id 班級id.
+ */
+ loadById(id: number): void {
+ console.log('loadById');
}
}
```
此時,我們在單元測試中便可以直接調用`loadById()`方法,從而模似獲取在要編輯的`id`值后的后續操作:
```typescript
+++ b/first-app/src/app/clazz/edit/edit.component.spec.ts
@@ -33,5 +33,8 @@ describe('EditComponent', () => {
expect(component).toBeTruthy();
getTestScheduler().flush();
fixture.autoDetectChanges();
+
+ // 手動觸發loadById方法,模擬組件獲取路由參數后的操作
+ component.loadById(123);
});
```
效果如下:

接下來便可以繼續開發其它的功能了。
## Api
后臺提供了獲取某個班級的地址,信息如下:
```bash
GET /clazz/{id}
```
| **類型Type** | **名稱Name** | **描述Description** | **類型Schema** |
| :----------- | :----------- | :------------------ | :----------------------------------------------------------- |
| Response | | 班級 | `{id: number, name: string, teacher: {id: number, name: string}}` |
我們在2.4.3小節中給出過獲取教師信息的接口地址,稍加觀察我們可以總結出以下規律:
- 獲取某個X時,請求方法為`GET`
- 獲取某個X時,根據X的類型不同,地址前綴會有所不同。比如獲取某個教師的前綴是`/teacher`,而獲取某個班級的前綴是`/clazz`。以此累推在后面的章節中,獲取學生的前綴將是`/student`。
- 獲取某個X時,必須指名X的關鍵字(一般是id),并其關鍵字以`/xx`的形式放到最后。比如獲取id為1的班級的URL為`/clazz/1` 。
而遵循上述規則的后臺接口,我們稱其為`RESTful API`;反之如果某個后臺接口遵循了`RESTful API`風格,則必然符合上述3點規則。
> ? `RESTful API`還規定了其它的后臺接口規則,教程中的API也符合這種規則。
## 獲取班級
調用`httpClient`來獲取某個班級的操作相信大家已經輕車熟路了,代碼如下:
```typescript
- constructor(private activatedRoute: ActivatedRoute) {
+ constructor(private activatedRoute: ActivatedRoute,
+ private httpClient: HttpClient) {
}
loadById(id: number): void {
console.log('loadById');
+ this.httpClient.get<Clazz>('/clazz/' + id.toString())
+ .subscribe(clazz => {
+ console.log('接收到了clazz', clazz);
+ }, error => console.log(error));
}
```
測試:

> 由于作者粗心的原因,上述提供信息并不完全正確。
## MockApi
有了后臺的API規范,便可以對應增加一個模擬API了:
```typescript
+++ b/first-app/src/app/mock-api/clazz.mock.api.ts
@@ -3,6 +3,7 @@ import {Clazz} from '../entity/clazz';
import {Teacher} from '../entity/teacher';
import {Page} from '../entity/page';
import {HttpParams} from '@angular/common/http';
+import {randomString} from '@yunzhi/ng-mock-api/testing';
/**
* 班級模擬API
@@ -75,6 +76,21 @@ export class ClazzMockApi implements MockApiInterface {
numberOfElements: size * 10
});
}
+ },
+ {
+ method: 'GET',
+ url: `/clazz/(\\d+)`,
+ result: (urlMatches: Array<string>) => {
+ console.log(urlMatches);
+ // 使用 + 完成字符串向數字的轉換
+ const id = +urlMatches[1];
+ return {
+ id,
+ name: randomString('班級名稱'),
+ teacher: {
+ id: randomNumber(),
+ name: randomString('教師')
+ }
+ } as Clazz;
+ }
}
```
上述方法中,我們把`result`設置成了`function` ,該函數中的第一個參數的的類型為`Array<string>`,也可以書寫為`string[]`。`urlMatches`中的第0個參數為請求的URL信息;第1至n個參數為正則表式達的匹配值。
比如當我們對`/clazz/123`發起請求時,按正則表達式`/clazz/(\\d+)`來匹配,最終的`urlMatches`值為:

如此,我們便可以通過`urlMatches`來獲取請求的`id`信息了。在這里特別需要注意`urlMatches`數組元素的類型為`string` ,所以要使用時要進行適當的轉換。比如我們這里輸出數字`123`而非字符串`123` ,則使用了`+`將字符串轉換為了數字。
上述代碼中我們還引用了`@yunzhi/ng-mock-api/testing`中的`randomString()`方法來快速的生成隨機字符串, 這樣一來保證了每刷新一次請求都會接收到不同的響應信息。
## 響應式表單
接收到后臺的返回值后,接下來將接收到的值綁定到V層的表單中。自本節開始,我們將全面啟用更加面向對象的響應式表單,所以使用`([ngModel])`在V層中綁定數據的方式已然成為歷史。
### 初始化表單
V層中共使用了兩個字段信息,分別為班級名稱及教師。教師我們使用了教師選擇組件,選擇的教師可以通過相關的方法進行綁定。所以在這僅需要初始化一個響應式表單來處理班級名稱即可:
```typescript
+++ b/first-app/src/app/clazz/edit/edit.component.ts
@@ -2,6 +2,7 @@ import {Component, OnInit} from '@angular/core';
import {ActivatedRoute} from '@angular/router';
import {HttpClient} from '@angular/common/http';
import {Clazz} from '../../entity/clazz';
+import {FormControl} from '@angular/forms';
@Component({
selector: 'app-edit',
@@ -9,6 +10,10 @@ import {Clazz} from '../../entity/clazz';
styleUrls: ['./edit.component.css']
})
export class EditComponent implements OnInit {
+ /**
+ * 班級名稱.
+ */
+ nameFormControl = new FormControl('');
constructor(private activatedRoute: ActivatedRoute,
private httpClient: HttpClient) {
@@ -28,6 +33,7 @@ export class EditComponent implements OnInit {
this.httpClient.get<Clazz>('/clazz/' + id.toString())
.subscribe(clazz => {
console.log('接收到了clazz', clazz);
+ this.nameFormControl.setValue(clazz.name);
}, error => console.log(error));
}
```
### 綁定
接著將其綁定到V層的name輸入框上:
```html
+++ b/first-app/src/app/clazz/edit/edit.component.html
@@ -2,7 +2,7 @@
<div class="mb-3 row">
<label class="col-sm-2 col-form-label">名稱</label>
<div class="col-sm-10">
- <input type="text" class="form-control">
+ <input type="text" class="form-control" [formControl]="nameFormControl">
<small class="text-danger">
班級名稱不能為空
</small>
```
效果如下:

## 驗證非空
可以通過向響應式表單中加入驗證器的方式來實現對某個表單項的驗證,比如此時要求名稱不能為空,則可以為其添加一個非空驗證器:
```typescript
-import {FormControl} from '@angular/forms';
+import {FormControl, Validators} from '@angular/forms';
@Component({
selector: 'app-edit',
@@ -13,7 +13,7 @@ export class EditComponent implements OnInit {
/**
* 班級名稱.
*/
- nameFormControl = new FormControl('');
+ nameFormControl = new FormControl('', Validators.required);
```
此時將`nameFormControl`中的`value`為空時,其`invalid`屬性則將為`true`,利用該特定在V層中定制錯誤提示信息:
```html
+++ b/first-app/src/app/clazz/edit/edit.component.html
@@ -3,7 +3,7 @@
<label class="col-sm-2 col-form-label">名稱</label>
<div class="col-sm-10">
<input type="text" class="form-control" [formControl]="nameFormControl">
- <small class="text-danger">
+ <small class="text-danger" *ngIf="nameFormControl.invalid">
班級名稱不能為空
</small>
</div>
```
**invalid**的譯文為:無效的。
## 測試
但當我們查看效果時,好像并沒有起作用,錯誤的提示信息仍然存在。

莫非是剛剛的代碼寫錯了?為了驗證這個觀點,我們在V層中打印下這個`nameFormControl.invalid`。
```html
<div class="col-sm-10">
+ {{nameFormControl.invalid}}
<input type="text" class="form-control" [formControl]="nameFormControl">
<small class="text-danger" *ngIf="nameFormControl.invalid">
```
打印發現其值的確為`true`。

而我們的想法時,當班級名稱為空時`nameFormControl.invalid`的值為`true`,如果非空的時候應該為`false`才對。這時候就要再把前面講過的`zone.js`與Angular的變更檢測機制般出來了。
Angular的變更檢測是建立在`zone.js`的基礎上的,`zone.js`使用通知的方式來發送數據變更的通知。這樣做可以有效的提升數據監聽的效率。只所以叫做數據監聽,是由于在非`zone.js`的模式下,若要感知某個數據的變化,則需要時時的**釘**著這個數據,這就像間諜片中對X進行監視一下;而在`zone.js`模式下,被監聽的數據由被動監聽變成了主動通知,這就像間諜片中對X進行了策反一樣,一旦有了新情況X會主動的告訴我們。這種通知的模塊有效的提升的應用效率,我們再也不需要費勁地**釘**著某個數據是否產生變化了。
`zone.js` 實現監聽的原理是在原方法上打補丁(Monkey patch),我們也可以理解為在原方法中放置間諜。在沒有`zone.js`以前`setTimeout` 就是`setTimeout`,而在引入`zone.js`以后,`setTimeout`就是有間諜的`setTimeout`了。正是這個間諜的存在,所以`zone.js`能夠通知Angular:異步的方法被調用了,V層對應的數據**可能**發生了變化。Angular接收到這個通知后,開始進行變更檢測,發現變化則重新渲染V層的界面。
在模擬后臺Api時,為了能夠正綜的手動控制后臺的返回值`@yunzhi/ng-mock-api/testing`在返回模擬數據時,按以下兩種情況使用了兩種模式返回數據:
1. 如果當前是執行的測試代碼觸發數據請求,則使用的是**彈珠測試**的模式來返回數據。此方案`zone.js` 感知不到,所以在此模式下即使在單元測試中啟用了自動變更檢測,也不會有效。
2. 如果當前的數據觸發由開發人員在V層中交互引起的,則使用非彈珠測試的模式來返回數據,此方案`zone.js`能夠感知到,所以在此模式下單元測試若啟用了自動變更檢測,則V層會重新渲染。
所以如果我們如下改寫測試代碼的話,變更檢測將失效:
```typescript
fit('should create', () => {
expect(component).toBeTruthy();
// 先啟動變更檢測
fixture.autoDetectChanges(); ??
// 在該代碼前進行了組件初始化,模擬請求了教師列表數據。
// 此代碼將返回還未響應的所有請求,包含:教師列表數據
getTestScheduler().flush();
component.loadById(123);
// loadByIdy方法中觸發了請求123班級數據
// 此代碼將返回還未響應的所有請求,包含:請求ID為123的班級數據
getTestScheduler().flush();
});
```

列表值為空且校驗未生效,說明V層的確沒有進行渲染。
### autoDetectChanges()
那么既然啟動自動變更檢,那么為什么將其放在數據發送后就能夠生效呢?
```typescript
fit('should create', () => {
expect(component).toBeTruthy();
// 在該代碼前進行了組件初始化,模擬請求了教師列表數據。
// 此代碼將返回還未響應的所有請求,包含:教師列表數據
getTestScheduler().flush();
// 啟動變更檢測,此時教師列表生效
fixture.autoDetectChanges(); ??
component.loadById(123);
// loadByIdy方法中觸發了請求123班級數據
// 此代碼將返回還未響應的所有請求,包含:請求ID為123的班級數據
getTestScheduler().flush();
});
```
如上代碼將`autoDetectChanges()`放到了第一次觸發返回數據下方,則教師列表在V層中被重新渲染:

如果將其放到最下方,則校驗也會隨之生效:
```typescript
fit('should create', () => {
expect(component).toBeTruthy();
// 在該代碼前進行了組件初始化,模擬請求了教師列表數據。
// 此代碼將返回還未響應的所有請求,包含:教師列表數據
getTestScheduler().flush();
component.loadById(123);
// loadByIdy方法中觸發了請求123班級數據
// 此代碼將返回還未響應的所有請求,包含:請求ID為123的班級數據
getTestScheduler().flush();
// 最后啟動變更檢測,則formControl也會被重新渲染
fixture.autoDetectChanges(); ??
});
```

這是由于`autoDetectChanges()`實際上等于`detectChanges()` + `自動檢測`。也就是說每執行一次`autoDetectChanges()`將首先執行`detectChanges()`,然后才是開啟自動變更檢測功能。
總而言之,如果我們想在`ng t`中實時的查看組件的一些交互效果,則應該在單元測試的最后兩行放置如下代碼:
```typescript
getTestScheduler().flush();
fixture.autoDetectChanges();
```
### FormControl
如果你剛剛跟上了教程在不停的思索的話,相信應該在下圖有所疑問:

此圖片出現在如下測試代碼中:
```typescript
fit('should create', () => {
expect(component).toBeTruthy();
// 先啟動變更檢測
fixture.autoDetectChanges(); ??
// 在該代碼前進行了組件初始化,模擬請求了教師列表數據。
// 此代碼將返回還未響應的所有請求,包含:教師列表數據
getTestScheduler().flush();
component.loadById(123);
// loadByIdy方法中觸發了請求123班級數據
// 此代碼將返回還未響應的所有請求,包含:請求ID為123的班級數據
getTestScheduler().flush();
});
```
我想你的疑問應該出現在班級的名稱上。既然我們說上述代碼將導致Angular無法獲取V層發生了變化,也不會重新對V層進行渲染的話。那么為什么V層中會出現**班級名稱**?
這個原因主要有兩個,簡單解釋如下:
1. 請求某個班級的數據返回后,在C層中調用了`formControl`的`setValue()`方法,而`Angular`可以在我們調用該方法時,得到一個通知。
2. Angular的**局部渲染**功能使得其在得知到這個通知后,僅僅渲染了其對應的`input`的值。
至于更深入的原因已經超出了本教程的范圍。
## 本節作業
1. 刪除班級名稱,看非空驗證器是否生效。
2. 除非空驗證外,`Validators`中還存在很多內置驗證器,請找到它們并猜猜其具體作用,最后嘗試驗證自己的猜想。
3. 為教師列表增加一個`@Input()`,使其接收請求班級返回數據的`clazz.teacher.id`。在設置過程中,應該將`@Input()`注釋到屬性上還是`set`方法上,為什么?
| 名稱 | 鏈接 |
| ------------------------------- | ------------------------------------------------------------ |
| ZoneJS 的原理與應用 | [https://juejin.cn/post/6859348400463314951](https://juejin.cn/post/6859348400463314951) |
| 翻閱源碼后,我終于理解了Zone.js | [https://zhuanlan.zhihu.com/p/50835920](https://zhuanlan.zhihu.com/p/50835920) |
| 編寫彈珠測試 | [https://cloud.tencent.com/developer/section/1489402](https://cloud.tencent.com/developer/section/1489402) |
| 本節源碼 | [https://github.com/mengyunzhi/angular11-guild/archive/step6.4.2.zip](https://github.com/mengyunzhi/angular11-guild/archive/step6.4.2.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 發布部署
- 第九章 總結