一個完善有效的單選、多選是一項較復雜的工程。在本小節中以實現以下功能為主:
* 單選生效
* 全選生效
* 列表中所有的項都被單選選中時,全選自動選中
* 全選選中時,列表中所有的項自動選中
* 全選取消時,列表中所有的項自動取消
* 列表中所有的項未被單選全部選中時,全選自動取消
在初次接觸某項功能時,TDD并不適用。在開發過程中仍需按先實現功能后進行測試的開發步驟進行。
# 單選
使用`<input type="checkbox" />`便能很輕松的生成一個選擇框,該選擇框的狀態有兩個:選中、未選中,對應的值為true、false。實現單選的方案有很多種,本文使用筆者認為實現較為簡單的一種。
在每個需要進行單選的實體上增加一個字段:`isChecked`,類型設置為boolean,默認值設置為false。這樣以來,該字段的值便可以很好的與選擇框的值相對應。比如在本例中:
student/index/index.component.html
```
<tr *ngFor="let student of pageStudent.content; index as index">
<td><input type="checkbox" [ngModel]="student.isChecked" ?/></td>
<td>{{index + 1}}</td>
```
* ? 將student中的isChecked字段與checkbox相綁定。
但student中我們并未規定isChecked字段,所以此時IDE會出現了一個警告錯誤:

修正該錯誤的方法當然也很簡單:打開Student實體類,添加對應的字段即可。
norm/entity/student.ts
```
export class Student {
...
/* 是否選中,輔助實現V層的 選中 功能① */
isChecked = false; ②
```
* ① 注釋內容:它的**作用**是什么 > 它是什么
* ② 使用`=`進行賦值操作
> 直接在前臺實體中增加isChecked字段會有一個弊端:當真實的后臺實體中存在該字段時,會與前臺的字段定義產生沖突。在筆者所在的團隊中,由于規定后臺字段不能以`is`打頭,所以原則上不會出現該字段與后臺實體沖突的問題。在實際的開發中一旦產生沖突,那么可以考慮通過以下方案之一來解決:方案一,對使用選擇框的實體,在傳入V層前進行格式化,比如在格式化過程中再增加了個字段`isCheckedClone`,使用該字段來存儲真實的后臺值;方案二,單元的定義選擇組件,將`isChecked`字段設置為可變更的,比如<yzSelect fieldName='你自己定義的字段' />。
## 單元測試
寫點功能就測試一下,不會吃虧。找到對應的測試文件,并新建測試用例:
student/index/index.component.spec.ts
```
describe('Student -> IndexComponent', () => {
...
fit('單選', () => {
/* 設置第一個學生的isCheck為true,并重新渲染V層 */
component.pageStudent.content[0].isChecked = true;
fixture.detectChanges();
});
});
```
使用`ng test`啟動測試并觀察效果:

第一條預期選中,這說明我們上述的思路和代碼的書寫都是正確的。接下來,繼續完善單元測試以達到用代碼來保障代碼功能的目的。
student/index/index.component.spec.ts
```
describe('Student -> IndexComponent', () => {
...
fit('單選', () => {
const trDebugElement = fixture.debugElement.query(By.css(`table tr:nth-child(2)`));
const checkBoxDebugElement = trDebugElement.query(By.css('input[type=checkBox]'));
const checkBoxElement: HTMLInputElement = checkBoxDebugElement.nativeElement;
console.log(checkBoxElement);
console.log(checkBoxElement.checked); ?
expect(checkBoxElement.checked).toBe(false); ?
/* 設置第一個學生的isCheck為true,并重新渲染V層 */
component.pageStudent.content[0].isChecked = true;
fixture.detectChanges();
/* 斷言checkBox的值為true */
expect(checkBoxElement.checked).toEqual(true); ①
});
});
```
* ? checkBox選中則checked屬性為true,未選中則為false
* ? 默認未選中,值為false
* ① 選中后checkBox的值為true
但單元測試的結果卻不如人意:

這就有點意思了,通過觀察界面我們可以確認該checkBox就是選中了,這種狀態下checkBox的值應該為true。但最終checkbox的value卻為false。
> 這可能是angular的一個bug。筆者開始嘗試由官方文檔中找到答案,但官方文檔并未對checkbox進行ngModel的使用進行單獨說明。且通過其它的示例代碼,我們確認當前使用ngModel來進行數據綁定是沒有問題的。可能開發ngModel的人并沒有充分的測試此問題,也可能是開發ngModel的人并不推薦我們在checkBox中使用ngModel(但官方文檔卻未同步)。問題產生的原因筆者通過萬能的google也沒有找到答案,但有幸運的找到了另一種寫法。
可能是官方并不推薦我們這么做吧,我們在綁定checkBox時換一種寫法,由ngModel變更為`checked`:
student/index/index.component.html
```
<tr *ngFor="let student of pageStudent.content; index as index">
<td><input type="checkbox" [checked]="student.isChecked" /></td>
<td>{{index + 1}}</td>
```
單元測試順利通過。由此我們得出來一個非常重要的結論:**對checkBox進行數據綁定時,應該用checked而非ngModel**。而該checked即為HTMLInputElement的checked屬性,在此相當于直接改變了checkbox的checked屬性。如此我們又得到了以下結論:**當angular缺失某此功能時,可以直接使用`[xxx]`的方法,設置其原生`xxx`屬性**
## 雙向綁定
剛剛測試了C層向V層綁定是成功的,那么當V層中checkBox被用戶點擊后,是否可以自動將值傳給對應的student.isChecked呢?
student/index/index.component.spec.ts
```
describe('Student -> IndexComponent', () => {
...
fit('單選點擊后綁定到C層', () => {
const trDebugElement = fixture.debugElement.query(By.css(`table tr:nth-child(2)`));
const checkBoxDebugElement = trDebugElement.query(By.css('input[type=checkBox]'));
const checkBoxElement: HTMLInputElement = checkBoxDebugElement.nativeElement;
expect(checkBoxElement.checked).toBe(false);
/* 點擊第一個學生對應的checkBox,斷言checkBox的值是true,同時對應學生的相應字段值為true */
checkBoxElement.click(); ?
expect(checkBoxElement.checked).toBeTruthy(); ①
expect(component.pageStudent.content[0].isChecked).toBeTruthy(); ②
});
});
```
* ? 點擊該checkBox,該方法將自動重新渲染該checkbox,但不會重新渲染整個V層
* ① 斷言checkBox的值為true
* ② 斷言對應的字段值為true
單元測試結果:
```
Error: Expected false to be truthy.
```
同時提示該錯誤發生在`② 斷言對應的字段值為true`,也就是說`[checked]`屬性無法實現雙向數據綁定。的確,在前面我們講過`[xxx]`代表當C層的值單向綁定到V層,而V層的值綁定到C層則需要通過`()`觸發C層的相關方法。checkbox的傳值也是如此,當checkbox被點擊時,會通過`change($event)`向外彈射最新的值。
student/index/index.component.html
```
<tr *ngFor="let student of pageStudent.content; index as index">
<td><input type="checkbox" [checked]="student.isChecked" (change)="onCheckBoxChange($event?, student?)"? /></td>
<td>{{index + 1}}</td>
```
* ? change事件綁定到C層的checkBoxChanged方法
* ? $event即為change彈射的值
* ? 對應當前學生
對應C層代碼為:
student/index/index.component.ts
```
export class IndexComponent implements OnInit {
...
/**
* 單選框被用戶點擊時
* @param $event 彈射值
* @param student 當前學生
*/
onCheckBoxChange($event, student: Student) {
console.log($event); ①
}
```
* ① 未熟練使用前在控制臺打印相關的對象,絕對是件事半功倍的操作
觀察控制臺發現,原來我們需要的東西在這:

于是為了更加清晰的了解自己操作的數據對象,在相應的代碼中補充類型并進行相應的強制轉換:
student/index/index.component.ts
```
export class IndexComponent implements OnInit {
...
/**
* 單選框被用戶點擊時
* @param $event 彈射值
* @param student 當前學生
*/
onCheckBoxChange($event: Event①, student: Student) {
const checkbox = $event.target as HTMLInputElement②;
student.isChecked = checkbox.checked; ③
}
```
* ① 聲明類型為Event
* ② 將類型強制轉換為HTMLInputElement
* ③ 使用checkbox的值來設置student的isChecked字段
單元測試如期通過。
# 全選
有了單選的經驗,全選初始化便相對簡單了。
student/index/index.component.html
```
<table>
<tr>
<th>選擇</th> ?
<th><input type="checkbox" [checked]="isCheckedAll" (change)="onCheckAllBoxChange($event)" /></th> ?
<th>序號</th>
```
對應增加C層的屬性及方法:
student/index/index.component.ts
```
export class IndexComponent implements OnInit {
...
/* 是否全部選中 */
isCheckedAll = false;
...
/**
* 全選框被用戶點擊時觸發
* @param $event checkBox彈射值
*/
onCheckAllBoxChange($event: Event) {
const checkbox = $event.target as HTMLInputElement;
this.isCheckedAll = checkbox.checked;
}
```
## 單元測試
單元測試就是功能點的測試,在此暫不考慮全選與單選的關聯信息,僅就多選進行雙向數據綁定測試。
### C->V
student/index/index.component.spec.ts
```
describe('Student -> IndexComponent', () => {
...
fit('多選C->V', () => {
/* 獲取到 全選 并斷言其狀態為:未選中 */
const trDebugElement = fixture.debugElement.query(By.css(`table tr:nth-child(1)`));
const checkBoxDebugElement = trDebugElement.query(By.css('input[type=checkBox]'));
const checkBoxElement: HTMLInputElement = checkBoxDebugElement.nativeElement;
expect(component.isCheckedAll).toBeFalsy();
expect(checkBoxElement.checked).toBe(false);
/* 改變C層的值,斷言綁定生效 */
component.isCheckedAll = true;
fixture.detectChanges();
expect(component.isCheckedAll).toBeTruthy();
});
});
```
* ★ 因代碼與前面測試單元時高度重度,不再重復說明。
### V->C
student/index/index.component.spec.ts
```
describe('Student -> IndexComponent', () => {
...
fit('多選V->C', () => {
/* 獲取到 全選 并斷言其狀態為:未選中 */
const trDebugElement = fixture.debugElement.query(By.css(`table tr:nth-child(1)`));
const checkBoxDebugElement = trDebugElement.query(By.css('input[type=checkBox]'));
const checkBoxElement: HTMLInputElement = checkBoxDebugElement.nativeElement;
expect(component.isCheckedAll).toBeFalsy();
/* 第一次點擊 false -> true */
checkBoxElement.click();
expect(component.isCheckedAll).toBeTruthy();
/* 再次點擊 true -> false */
checkBoxElement.click();
expect(component.isCheckedAll).toBeFalsy();
});
});
```
* ★ 因代碼與前面測試單元時高度重度,不再重復說明。
# 單選、多選聯動
既然是聯動,則說明兩者互相影響。單元會影響多選,多選也會影響單選。那么在進行開發時便可以將此功能的粒度進一步縮小為:單元對多選的影響、多選對單元的影響。
## 多選對單選的影響
當多選選中或是取消選中時,單選應該全部選中或是全部取消選中。也就是說多選的狀態的變更應該對單選產生影響,而單元的狀態又與C層進行綁定,所以此問題便轉換為:多選產生事件時,應該對應student.isChecked字段的值。
student/index/index.component.ts
```
export class IndexComponent implements OnInit {
...
onCheckAllBoxChange($event: Event) {
const checkbox = $event.target as HTMLInputElement;
this.isCheckedAll = checkbox.checked;
this.pageStudent.content.forEach((student) => { ①
student.isChecked = checkbox.checked;
});
}
```
* ① 循環對學生的isChecked字段賦值
### 單元測試
student/index/index.component.spec.ts
```
describe('Student -> IndexComponent', () => {
...
fit('多選V->C', () => {
/* 獲取到 全選 并斷言其狀態為:未選中 */
const trDebugElement = fixture.debugElement.query(By.css(`table tr:nth-child(1)`));
const checkBoxDebugElement = trDebugElement.query(By.css('input[type=checkBox]'));
const checkBoxElement: HTMLInputElement = checkBoxDebugElement.nativeElement;
expect(component.isCheckedAll).toBeFalsy();
/* 第一次點擊 false -> true */
checkBoxElement.click();
expect(component.isCheckedAll).toBeTruthy();
component.pageStudent.content
.forEach((student) => {
expect(student.isChecked).toBeTruthy(); ①
});
/* 再次點擊 true -> false */
checkBoxElement.click();
expect(component.isCheckedAll).toBeFalsy();
component.pageStudent.content
.forEach((student) => {
expect(student.isChecked).toBeFalsy(); ①
});
});
});
```
* ① 斷言每個學生的選中狀態與全選的相同
## 單選對多選的影響
用戶點擊某個單選框時,單選對多選的影響有兩種:① 如果當前單元值為false,則應該取消全選,全選值也應該為false ② 如果當前單選值為true,則應該對所有的學生進行遍歷,只要有一個學生的isChecked值為false,則全選值為false。
> 還有很多的算法能夠滿足當前要求。比如建立個選定學生數量計數器,每次單選選中,計數器+1,取消選中-1。然后計算計數器與當前學生的總數值是否相等?相等,則全選選中,不相等則取消選中;或是也可以建立個數組,把選中的學生添加到這個單獨的數組中。
則功能代碼如下:
student/index/index.component.ts
```
export class IndexComponent implements OnInit {
...
onCheckBoxChange($event: Event, student: Student) {
const checkbox = $event.target as HTMLInputElement;
student.isChecked = checkbox.checked;
if (checkbox.checked) {
let checkedAll = true; ①
this.pageStudent.content.forEach((value) => {
if (!value.isChecked) {
checkedAll = false; ②
}
});
this.isCheckedAll = checkedAll; ③
} else {
this.isCheckedAll = false; ④
}
}
```
* ① 定義臨時變量
* ② 如果有學生未選中,則設置該臨時變量的值為false。該值可能被多次冗余執行,不過這并不會影響到代碼的執行效果
* ③ 設置全選的值
* ④ 如果為取消選中,則直接設置全選的值為false
### 單元測試
student/index/index.component.spec.ts
```
describe('Student -> IndexComponent', () => {
...
fit('點擊單選對多選值的影響', () => {
for (let i = 2; i <= 3; i++) {
/* 依次點擊2個student的單選 */
const trDebugElement = fixture.debugElement.query(By.css(`table tr:nth-child(${i})`));
const checkBoxDebugElement = trDebugElement.query(By.css('input[type=checkBox]'));
const checkBoxElement: HTMLInputElement = checkBoxDebugElement.nativeElement;
checkBoxElement.click();
/* 按是否為最后一個學生進行不同的斷言 */
if (i === 3) {
expect(component.isCheckedAll).toBeTruthy(); ①
checkBoxElement.click(); ②
expect(component.isCheckedAll).toBeFalsy();
} else {
expect(component.isCheckedAll).toBeFalsy(); ③
}
}
});
```
* ① 點擊最后一個學生,則全選選中
* ② 再次點擊(在全選情況下取消一個),全選取消選中
* ③ 點擊非最后的學生,全選不選中
最后進行整個項目的單元測試,以保證當前功能的新增未對歷史功能或單元測試功能造成影響。
# 參考文檔
| 名稱 | 鏈接 | 預計學習時長(分) |
| --- | --- | --- |
| 源碼地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.6.9](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.6.9) | - |
| HTMLInputElement | [https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement) | 5 |
- 序言
- 第一章:Hello World
- 第一節:Angular準備工作
- 1 Node.js
- 2 npm
- 3 WebStorm
- 第二節:Hello Angular
- 第三節:Spring Boot準備工作
- 1 JDK
- 2 MAVEN
- 3 IDEA
- 第四節:Hello Spring Boot
- 1 Spring Initializr
- 2 Hello Spring Boot!
- 3 maven國內源配置
- 4 package與import
- 第五節:Hello Spring Boot + Angular
- 1 依賴注入【前】
- 2 HttpClient獲取數據【前】
- 3 數據綁定【前】
- 4 回調函數【選學】
- 第二章 教師管理
- 第一節 數據庫初始化
- 第二節 CRUD之R查數據
- 1 原型初始化【前】
- 2 連接數據庫【后】
- 3 使用JDBC讀取數據【后】
- 4 前后臺對接
- 5 ng-if【前】
- 6 日期管道【前】
- 第三節 CRUD之C增數據
- 1 新建組件并映射路由【前】
- 2 模板驅動表單【前】
- 3 httpClient post請求【前】
- 4 保存數據【后】
- 5 組件間調用【前】
- 第四節 CRUD之U改數據
- 1 路由參數【前】
- 2 請求映射【后】
- 3 前后臺對接【前】
- 4 更新數據【前】
- 5 更新某個教師【后】
- 6 路由器鏈接【前】
- 7 觀察者模式【前】
- 第五節 CRUD之D刪數據
- 1 綁定到用戶輸入事件【前】
- 2 刪除某個教師【后】
- 第六節 代碼重構
- 1 文件夾化【前】
- 2 優化交互體驗【前】
- 3 相對與絕對地址【前】
- 第三章 班級管理
- 第一節 JPA初始化數據表
- 第二節 班級列表
- 1 新建模塊【前】
- 2 初識單元測試【前】
- 3 初始化原型【前】
- 4 面向對象【前】
- 5 測試HTTP請求【前】
- 6 測試INPUT【前】
- 7 測試BUTTON【前】
- 8 @RequestParam【后】
- 9 Repository【后】
- 10 前后臺對接【前】
- 第三節 新增班級
- 1 初始化【前】
- 2 響應式表單【前】
- 3 測試POST請求【前】
- 4 JPA插入數據【后】
- 5 單元測試【后】
- 6 惰性加載【前】
- 7 對接【前】
- 第四節 編輯班級
- 1 FormGroup【前】
- 2 x、[x]、{{x}}與(x)【前】
- 3 模擬路由服務【前】
- 4 測試間諜spy【前】
- 5 使用JPA更新數據【后】
- 6 分層開發【后】
- 7 前后臺對接
- 8 深入imports【前】
- 9 深入exports【前】
- 第五節 選擇教師組件
- 1 初始化【前】
- 2 動態數據綁定【前】
- 3 初識泛型
- 4 @Output()【前】
- 5 @Input()【前】
- 6 再識單元測試【前】
- 7 其它問題
- 第六節 刪除班級
- 1 TDD【前】
- 2 TDD【后】
- 3 前后臺對接
- 第四章 學生管理
- 第一節 引入Bootstrap【前】
- 第二節 NAV導航組件【前】
- 1 初始化
- 2 Bootstrap格式化
- 3 RouterLinkActive
- 第三節 footer組件【前】
- 第四節 歡迎界面【前】
- 第五節 新增學生
- 1 初始化【前】
- 2 選擇班級組件【前】
- 3 復用選擇組件【前】
- 4 完善功能【前】
- 5 MVC【前】
- 6 非NULL校驗【后】
- 7 唯一性校驗【后】
- 8 @PrePersist【后】
- 9 CM層開發【后】
- 10 集成測試
- 第六節 學生列表
- 1 分頁【后】
- 2 HashMap與LinkedHashMap
- 3 初識綜合查詢【后】
- 4 綜合查詢進階【后】
- 5 小試綜合查詢【后】
- 6 初始化【前】
- 7 M層【前】
- 8 單元測試與分頁【前】
- 9 單選與多選【前】
- 10 集成測試
- 第七節 編輯學生
- 1 初始化【前】
- 2 嵌套組件測試【前】
- 3 功能開發【前】
- 4 JsonPath【后】
- 5 spyOn【后】
- 6 集成測試
- 7 @Input 異步傳值【前】
- 8 值傳遞與引入傳遞
- 9 @PreUpdate【后】
- 10 表單驗證【前】
- 第八節 刪除學生
- 1 CSS選擇器【前】
- 2 confirm【前】
- 3 功能開發與測試【后】
- 4 集成測試
- 5 定制提示框【前】
- 6 引入圖標庫【前】
- 第九節 集成測試
- 第五章 登錄與注銷
- 第一節:普通登錄
- 1 原型【前】
- 2 功能設計【前】
- 3 功能設計【后】
- 4 應用登錄組件【前】
- 5 注銷【前】
- 6 保留登錄狀態【前】
- 第二節:你是誰
- 1 過濾器【后】
- 2 令牌機制【后】
- 3 裝飾器模式【后】
- 4 攔截器【前】
- 5 RxJS操作符【前】
- 6 用戶登錄與注銷【后】
- 7 個人中心【前】
- 8 攔截器【后】
- 9 集成測試
- 10 單例模式
- 第六章 課程管理
- 第一節 新增課程
- 1 初始化【前】
- 2 嵌套組件測試【前】
- 3 async管道【前】
- 4 優雅的測試【前】
- 5 功能開發【前】
- 6 實體監聽器【后】
- 7 @ManyToMany【后】
- 8 集成測試【前】
- 9 異步驗證器【前】
- 10 詳解CORS【前】
- 第二節 課程列表
- 第三節 果斷
- 1 初始化【前】
- 2 分頁組件【前】
- 2 分頁組件【前】
- 3 綜合查詢【前】
- 4 綜合查詢【后】
- 4 綜合查詢【后】
- 第節 班級列表
- 第節 教師列表
- 第節 編輯課程
- TODO返回機制【前】
- 4 彈出框組件【前】
- 5 多路由出口【前】
- 第節 刪除課程
- 第七章 權限管理
- 第一節 AOP
- 總結
- 開發規范
- 備用