在上个小节中,我们自行完了选择班级组件的开发。在开发的过程中相信大家大量的参考了教师选择组件。那不难会发现几个共同点: * [ ] 两者均显示name字段 * [ ] 两者在初始化时,均请求了一个获取全部数据的地址 * [ ] 两者在判断应该选中某个对象时,均采用的是对`id`的值进行对比 * [ ] 两者大量的代码都是相同的 那么问题来了:我们是否可以建立一个公用的组件,然后利用向该组件中传入的不同的参数以达到复用的目的呢? ---- **抽离**。在前面的章节的学习过程中,我们已经使用过**抽离**方法进行过代码的重构。所谓**抽离**简单来讲就是从原来的方法中把一些共用的代码拿出来。就像极了现实生活的大规模生产,比如生产汽车时会把各个车型**共用**的发动机、变速箱拿出来单独的进行开发。各个车型在使用时,只需要按照发动机和变速箱的接口规则与其进行对接即可。我们当前的情景也是一样,把两个选择组件共用的部分单独的抽离出来进行单独的开发符合我们**不造重复的轮子**的思想。 一般我们**抽离**的步骤如下(假设有A、B组件共用了大量的代码): * [ ] 初始化新单元(组件、方法、类等)C * [ ] 复制任意原单元中(A或B)的所有代码 * [ ] 在C中,A、B共用的代码保持不变 * [ ] 在C中,A、B不同的代码做接口(输入或输出) ## 初始化新单元 我们在app下新建一个新模块core,并在该模块中建立一个select组件。 ``` panjiedeMac-Pro:core panjie$ tree . ├── core.module.ts └── select ├── select.component.html ├── select.component.sass ├── select.component.spec.ts └── select.component.ts ``` ### 复制调整代码 接下来我们把教师选择组件的代码拿过来,并适当的改改变量的名字。 core/select/select.component.html ``` <select id="objectSelect" [formControl]="objectSelect" (change)="onChange()" [compareWith]="compareFn"> <option *ngFor="let object of objects" [ngValue]="object"> {{object.name}} </option> </select> ``` core/select/select.component.ts ``` import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; import {Teacher} from '../../norm/entity/Teacher'; import {FormControl} from '@angular/forms'; import {HttpClient} from '@angular/common/http'; @Component({ selector: 'app-select', templateUrl: './select.component.html', styleUrls: ['./select.component.sass'] }) export class SelectComponent implements OnInit { /*所有教师*/ objects: Array<{id: number, name: string}>; ★ objectSelect: FormControl; @Output() selected = new EventEmitter<{id: number, name: string}>(); ★ ➊ @Input() object: { id: number }; ➋ constructor(private httpClient: HttpClient) { } /** * 获取所有的教师,并传给V层 */ ngOnInit() { this.objectSelect = new FormControl(this.object); const url = 'http://localhost:8080/Teacher'; this.httpClient.get(url) .subscribe((teachers: Array<Teacher>) => { this.objects = teachers; }); } /** * 比较函数,标识用哪个字段来比较两个教师是否为同一个教师 * @param t1 源 * @param t2 目标 */ compareFn(t1: {id: number}, t2: {id: number}) { return t1 && t2 ? t1.id === t2.id : t1 === t2; } onChange() { this.selected.emit(this.objectSelect.value); } } ``` * ➊ 定义了输出的最小格式(实际可能输出的字段数会更多) * ➋ 定义了输入的最小格式(实际输入的字段数多也是允许的) core/select/select.component.spec.ts ``` import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { SelectComponent } from './select.component'; import {Teacher} from '../../norm/entity/Teacher'; import {BrowserModule, By} from '@angular/platform-browser'; import {ReactiveFormsModule} from '@angular/forms'; import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing'; fdescribe('core select SelectComponent', () => { let component: SelectComponent; ★ let fixture: ComponentFixture<SelectComponent>; ★ const teachers = new Array(new Teacher(1, 'panjie', '潘杰'), new Teacher(2, 'zhangxishuo', '张喜硕')); beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [SelectComponent], ★ imports: [ BrowserModule, ReactiveFormsModule, HttpClientTestingModule ] }) .compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(SelectComponent); ★ component = fixture.componentInstance; fixture.detectChanges(); }); /*断言发请了后台请求,模拟返回数据后,断言V层的select个数为2*/ it('获取教师列表后选择教师', () => { expectInit(); const htmlSelectElement: HTMLSelectElement = fixture.debugElement.query(By.css('#objectSelect')).nativeElement; ★ expect(htmlSelectElement.length).toBe(2); testOptionValue(htmlSelectElement); }); /** * 断言option的值与teacher中name的相同 * 循环teachers数组。断言与option的值一一相等 * @param htmlSelectElement html元素 */ const testOptionValue = (htmlSelectElement: HTMLSelectElement) => { const htmlOptionElements: HTMLCollectionOf<HTMLOptionElement> = htmlSelectElement.options; for (let i = 0; i < teachers.length; i++) { const htmlOptionElement: HTMLOptionElement = htmlOptionElements.item(i); console.log(htmlOptionElement.text); expect(htmlOptionElement.text).toEqual(teachers[i].name); } }; /** * 1. 模拟返回数据给教师列表 * 2. 观察弹射器 * 3. 模拟点击第0个option * 4. 断言观察到的数据是教师列表的第一个教师 */ it('测试组件弹射器', () => { expectInit(); component.selected.subscribe((teacher: Teacher) => { console.log('data emit', teacher); expect(teacher.name).toEqual(teachers[0].name); }); const htmlSelectElement: HTMLSelectElement = fixture.debugElement.query(By.css('#objectSelect')).nativeElement; ★ htmlSelectElement.value = htmlSelectElement.options[0].value; htmlSelectElement.dispatchEvent(new Event('change')); fixture.detectChanges(); }); /** * 断言组件进行了初始化 * 访问了正确的后台地址 */ const expectInit = () => { const httpTestingController: HttpTestingController = TestBed.get(HttpTestingController); const req = httpTestingController.expectOne('http://localhost:8080/Teacher'); expect(req.request.method).toEqual('GET'); req.flush(teachers); fixture.detectChanges(); }; }); ``` 你可能一定性更改不到位,那么可以一点点的复制过来。按单元测试提示的错误进行修正。单元测试通过,说明整体的复制是有效的。 ### 剥离后台请求地址(AB不同) 程序开发的过程其实就是抽象的过程,是总结相同的部分或是总结不同的部分的过程。相同的部分`<{id: number, name: string}>`我们已经剥离的出来,不同的部分是请求的地址,我们将其做为输入项呈现。 core/select/select.component.ts ``` @Input() url: string;➊ /** * 获取所有的对象,并传给V层 */ ngOnInit() { this.objectSelect = new FormControl(this.object); this.httpClient.get(this.url➊) .subscribe((objects: Array<{id: number; name: string}>) => { this.objects = objects; }); } ``` * ➊ 将请求地址做为input传入 同步修正单元测试: ``` beforeEach(() => { fixture = TestBed.createComponent(SelectComponent); component = fixture.componentInstance; component.url = 'http://localhost:8080/Teacher'; ✚ fixture.detectChanges(); }); ``` * 设置组件的URL地址 最后我们去除单元测试中的teacher痕迹。 ``` import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {SelectComponent} from './select.component'; import {BrowserModule, By} from '@angular/platform-browser'; import {ReactiveFormsModule} from '@angular/forms'; import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing'; fdescribe('core select SelectComponent', () => { let component: SelectComponent; let fixture: ComponentFixture<SelectComponent>; const url = 'http://localhost:8080/test'; ★ const objects = new Array({id: 1, name: '潘杰'}, {id: 2, name: '张喜硕'}); ★ beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [SelectComponent], imports: [ BrowserModule, ReactiveFormsModule, HttpClientTestingModule ] }) .compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(SelectComponent); component = fixture.componentInstance; component.url = url; ★ fixture.detectChanges(); }); /*断言发请了后台请求,模拟返回数据后,断言V层的select个数为2*/ it('获取教师列表后选择对象', () => { expectInit(); const htmlSelectElement: HTMLSelectElement = fixture.debugElement.query(By.css('#objectSelect')).nativeElement; expect(htmlSelectElement.length).toBe(2); testOptionValue(htmlSelectElement); }); /** * 断言option的值与对象中name的相同 * 循环teachers数组。断言与option的值一一相等 * @param htmlSelectElement html元素 */ const testOptionValue = (htmlSelectElement: HTMLSelectElement) => { const htmlOptionElements: HTMLCollectionOf<HTMLOptionElement> = htmlSelectElement.options; for (let i = 0; i < objects.length; i++) { const htmlOptionElement: HTMLOptionElement = htmlOptionElements.item(i); console.log(htmlOptionElement.text); expect(htmlOptionElement.text).toEqual(objects[i].name); } }; /** * 1. 模拟返回数据给教师列表 * 2. 观察弹射器 * 3. 模拟点击第0个option * 4. 断言观察到的数据是教师列表的第一个教师 */ it('测试组件弹射器', () => { expectInit(); component.selected.subscribe((object: { id: number, name: string }) => { ★ console.log('data emit', object); expect(object.name).toEqual(objects[0].name); }); const htmlSelectElement: HTMLSelectElement = fixture.debugElement.query(By.css('#objectSelect')).nativeElement; htmlSelectElement.value = htmlSelectElement.options[0].value; htmlSelectElement.dispatchEvent(new Event('change')); fixture.detectChanges(); }); /** * 断言组件进行了初始化 * 访问了正确的后台地址 */ const expectInit = () => { const httpTestingController: HttpTestingController = TestBed.get(HttpTestingController); const req = httpTestingController.expectOne(url); ★ expect(req.request.method).toEqual('GET'); req.flush(objects); fixture.detectChanges(); }; }); ``` ### 将参数抽离为对象 在前面的代码中,我们大量的使用`{id:number; name: string}`类型。原则上,只要这个类型出现的频率大于1次,那么我们就应该向上抽搞了为对象。为此,我们在core/select/select.component.ts建立Select对象。 ``` export class SelectComponent implements OnInit { /*所有对象*/ objects: Array<Select>; ★ objectSelect: FormControl; @Output() selected = new EventEmitter<Select>(); ★ /** * 获取所有的对象,并传给V层 */ ngOnInit() { this.objectSelect = new FormControl(this.object); this.httpClient.get(this.url) .subscribe((objects: Array<Select>) => { ★ this.objects = objects; }); } } /** * 选择 */ export➊ class Select { id: number; name: string; constructor(id: number, name: string) { ➋ this.id = id; this.name = name; } } ``` * ➊ 使用export后其它外部文件中的类才可以使用import将其引用,否则只能在本类内部使用。 * ➋ 使用此方法构造以保证后期一旦该select发生变更,可以借助angular编译器来快速的定位其它引用该组件的代码。 ## 较验效果 如果想使得其它模块引用Core模块中的Select组件,则需要将Select进行export。 core/core.module.ts ``` @NgModule({ declarations: [SelectComponent], imports: [ CommonModule, ReactiveFormsModule ★ ], exports: [ SelectComponent ★ ] }) export class CoreModule { } ``` 然后我们来到选择班级组件,直接在该组件中引用选择组件。 ## 引入Select组件 以选择班级组件为例,我们将刚刚的Select公用组件进行引入。 student/klass-select/klass-select.component.ts ``` import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; import {Klass} from '../../norm/entity/Klass'; @Component({ selector: 'app-klass-select', templateUrl: './klass-select.component.html', styleUrls: ['./klass-select.component.sass'] }) export class KlassSelectComponent implements OnInit { @Output() selected = new EventEmitter<Klass>(); ① @Input() klass: Klass; ② url = 'http://localhost:8080/Klass?name='; ➊ constructor() { } ngOnInit() { } onSelected(klass: Klass): void { ① this.selected.emit(klass); } } ``` * ① Select组件弹射给本组件,则本组件继续往上弹 * ② 接收变量变直接赋值给Select组件 * ➊ 定义url数据初始化地址 student/klass-select/klass-select.component.html ``` <app-select [url]="url" (selected)="onSelected($event)" [object]="klass"></app-select> ``` student/klass-select/klass-select.component.spec.ts ``` import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {KlassSelectComponent} from './klass-select.component'; import {CoreModule} from '../../core/core.module'; import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing'; import {Klass} from '../../norm/entity/Klass'; import {By} from '@angular/platform-browser'; import Spy = jasmine.Spy; import SpyObj = jasmine.SpyObj; describe('student KlassSelectComponent', () => { let component: KlassSelectComponent; let fixture: ComponentFixture<KlassSelectComponent>; let httpTestingController: HttpTestingController; beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [KlassSelectComponent], imports: [CoreModule➊, HttpClientTestingModule] }) .compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(KlassSelectComponent); component = fixture.componentInstance; fixture.detectChanges(); }); /** * 1. 断言发请了请求 * 2. 模拟返回数据 * 3. 订阅弹出的班级 * 4. 改变select的值 * 5. 断言订阅的语句被成功的执行过了 */ fit('should create', () => { expect(component).toBeTruthy(); httpTestingController = TestBed.get(HttpTestingController); const req = httpTestingController.expectOne(component.url); req.flush(new Array( new Klass(1, '测试1', null), new Klass(2, '测试2', null))); fixture.detectChanges(); let called = false; ➋ component.selected.subscribe((klass: Klass) => { expect(klass.id).toBe(1); ① called = true; ➋ }); const htmlSelectElement: HTMLSelectElement = fixture.debugElement.query(By.css('select')).nativeElement; htmlSelectElement.value = htmlSelectElement.options[0].value; htmlSelectElement.dispatchEvent(new Event('change')); fixture.detectChanges(); expect(called).toBe(true); ➋➌ }); }); ``` * ➊ 引用CoreModule * ➋ 断言①是被执行过的。如果①没有被执行过,则最终called值为false,从而➌将无法通过 ![](https://img.kancloud.cn/eb/67/eb67cdec699fe23e41ebfffdb57e4bd9_526x156.png) ![](https://img.kancloud.cn/63/d1/63d18622d9dd7c819a23996b093153f3_141x65.png) 测试通过。 **小测试**请结合本小节的内容,完成选择教师组件的重构,以使选择教师组件共用Select组件。 ## 测试整个项目 最后,我们测试整个项目,在测试过程中我们发现了两处错误。 ![](https://img.kancloud.cn/61/e6/61e6397bc4f196c3390245b440947508_392x61.png) 但我们好像按提示找不到这个IndexComponent,因为我们有好多这样的组件。最后找遍了整个项目,发现其位于klass模块下,我们找到它并在它的测试描述中给它起个更容易识别的名字: klass/index/index.component.spec.ts ``` describe('klass -> IndexComponent', () => { ``` 这样下次它再报错的时候,就会这样显示: ![](https://img.kancloud.cn/6e/e9/6ee99a5d6e152584a243df5440c70c43_309x43.png) 这样我们就能快速的找到它了。 我们在特定报错的方法前加个`f`来修正下这个错误,看是单元测试没有及时变更还是的确组件发生了异常。 提示我们不能够在undefined上读取一个name属性。经排查:我在单元测试中没有出现`name`属性;在C层中也未使用该属性;最终在V层中找到两处使用该属性的地方: klass/index/index.component.html ``` <td>{{klass.name}}</td> <td>{{klass.object.name}}</td> ``` 而报这种错误,要不然klass为undefined,否则klass.object为undefined。然后我突然联想起了,在前面进行代码复制的时候,我使用快捷方式修改过变量的名称,将teacher修改为了object,而webstorm则进行了误操作将此V层中的字段也一并修改了。天,如果没有单元测试我相信我们是发现不了该错误的。这就是单元测试的魅力,当我们怀着冲动的心情瞎搞时,它站在后面能够给我们坚而有力的保障! 修正为: ``` <td>{{klass.name}}</td> <td>{{klass.teacher.name}}</td> ``` ![](https://img.kancloud.cn/0b/d1/0bd132ad39f5aaa2010cac7259b03836_455x112.png) 再测试整个效果,通过!此时可以放心的提交代码了。 # 参考文档 | 名称 | 链接 | 预计学习时长(分) | | --- | --- | --- | | 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.5.3](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.5.3) | - |