在上个小节中,我们自行完了选择班级组件的开发。在开发的过程中相信大家大量的参考了教师选择组件。那不难会发现几个共同点:
* [ ] 两者均显示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) | - |
- 序言
- 第一章: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
- 总结
- 开发规范
- 备用