一个完善有效的单选、多选是一项较复杂的工程。在本小节中以实现以下功能为主: * 单选生效 * 全选生效 * 列表中所有的项都被单选选中时,全选自动选中 * 全选选中时,列表中所有的项自动选中 * 全选取消时,列表中所有的项自动取消 * 列表中所有的项未被单选全部选中时,全选自动取消 在初次接触某项功能时,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会出现了一个警告错误: ![](https://img.kancloud.cn/c1/d6/c1d6e3a9439b816292917bfc61fe7a39_1069x104.png) 修正该错误的方法当然也很简单:打开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`启动测试并观察效果: ![](https://img.kancloud.cn/10/19/1019dac36cf076900c144987bead1632_467x131.png) 第一条预期选中,这说明我们上述的思路和代码的书写都是正确的。接下来,继续完善单元测试以达到用代码来保障代码功能的目的。 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 但单元测试的结果却不如人意: ![](https://img.kancloud.cn/78/d7/78d729f0d184e6d5a990840b3a571c1a_479x309.png) 这就有点意思了,通过观察界面我们可以确认该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); ① } ``` * ① 未熟练使用前在控制台打印相关的对象,绝对是件事半功倍的操作 观察控制台发现,原来我们需要的东西在这: ![](https://img.kancloud.cn/82/5c/825cd5e6f85adf1ce73e809913fdc6af_363x427.png) 于是为了更加清晰的了解自己操作的数据对象,在相应的代码中补充类型并进行相应的强制转换: 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 |