相对新增而言,编辑功能有一些小小的复杂,在开始编码前让我们共同复习一下其流程: - 获取路由中的参数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); }); ``` 效果如下: ![image-20210402103203365](https://img.kancloud.cn/df/7b/df7bb717495586bd0adf23425c41983c_758x92.png) 接下来便可以继续开发其它的功能了。 ## 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)); } ``` 测试: ![image-20210402104537321](https://img.kancloud.cn/4c/72/4c72556ba25d01fcdb06ffc0a9de13e7_2004x118.png) > 由于作者粗心的原因,上述提供信息并不完全正确。 ## 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`值为: ![image-20210402110444885](https://img.kancloud.cn/6c/68/6c68f19a6744f7373f33c5808017b43e_600x64.png) 如此,我们便可以通过`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> ``` 效果如下: ![image-20210402111557980](https://img.kancloud.cn/17/66/176645ab7e84a292f57226564a2354d9_1004x174.png) ## 验证非空 可以通过向响应式表单中加入验证器的方式来实现对某个表单项的验证,比如此时要求名称不能为空,则可以为其添加一个非空验证器: ```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**的译文为:无效的。 ## 测试 但当我们查看效果时,好像并没有起作用,错误的提示信息仍然存在。 ![image-20210402134104568](https://img.kancloud.cn/bd/2c/bd2c04c04f80cabf11f110aae6387a95_874x170.png) 莫非是刚刚的代码写错了?为了验证这个观点,我们在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`。 ![image-20210402134339760](https://img.kancloud.cn/24/0f/240fc7c3150eb139c6f29a523173753c_632x180.png) 而我们的想法时,当班级名称为空时`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(); }); ``` ![image-20210402140859348](https://img.kancloud.cn/cf/f0/cff0595b32f86e01888fdbc47f2e433e_2426x362.png) 列表值为空且校验未生效,说明V层的确没有进行渲染。 ### autoDetectChanges() 那么既然启动自动变更检,那么为什么将其放在数据发送后就能够生效呢? ```typescript fit('should create', () => { expect(component).toBeTruthy(); // 在该代码前进行了组件初始化,模拟请求了教师列表数据。 // 此代码将返回还未响应的所有请求,包含:教师列表数据 getTestScheduler().flush(); // 启动变更检测,此时教师列表生效 fixture.autoDetectChanges(); 👈 component.loadById(123); // loadByIdy方法中触发了请求123班级数据 // 此代码将返回还未响应的所有请求,包含:请求ID为123的班级数据 getTestScheduler().flush(); }); ``` 如上代码将`autoDetectChanges()`放到了第一次触发返回数据下方,则教师列表在V层中被重新渲染: ![image-20210402141158466](https://img.kancloud.cn/36/40/3640fef279dadc00fd59133a5b10a5a5_1330x354.png) 如果将其放到最下方,则校验也会随之生效: ```typescript fit('should create', () => { expect(component).toBeTruthy(); // 在该代码前进行了组件初始化,模拟请求了教师列表数据。 // 此代码将返回还未响应的所有请求,包含:教师列表数据 getTestScheduler().flush(); component.loadById(123); // loadByIdy方法中触发了请求123班级数据 // 此代码将返回还未响应的所有请求,包含:请求ID为123的班级数据 getTestScheduler().flush(); // 最后启动变更检测,则formControl也会被重新渲染 fixture.autoDetectChanges(); 👈 }); ``` ![image-20210402141257647](https://img.kancloud.cn/41/c4/41c449607e91db70ec131b15f69e56ef_1326x334.png) 这是由于`autoDetectChanges()`实际上等于`detectChanges()` + `自动检测`。也就是说每执行一次`autoDetectChanges()`将首先执行`detectChanges()`,然后才是开启自动变更检测功能。 总而言之,如果我们想在`ng t`中实时的查看组件的一些交互效果,则应该在单元测试的最后两行放置如下代码: ```typescript getTestScheduler().flush(); fixture.autoDetectChanges(); ``` ### FormControl 如果你刚刚跟上了教程在不停的思索的话,相信应该在下图有所疑问: ![image-20210402140859348](https://img.kancloud.cn/cf/f0/cff0595b32f86e01888fdbc47f2e433e_2426x362.png) 此图片出现在如下测试代码中: ```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) |