学生管理为学生模块的第一个组件。所以在生成新增学生组件前,我们先在app根路径使用`ng g m student`中生成了一个学生模块。再使用`ng g c add`来生成添加学生组件。 ``` panjiedeMac-Pro:app panjie$ ng g m student CREATE src/app/student/student.module.ts (193 bytes) panjiedeMac-Pro:app panjie$ cd student/ panjiedeMac-Pro:student panjie$ ng g c add CREATE src/app/student/add/add.component.sass (0 bytes) CREATE src/app/student/add/add.component.html (18 bytes) CREATE src/app/student/add/add.component.spec.ts (607 bytes) CREATE src/app/student/add/add.component.ts (258 bytes) UPDATE src/app/student/student.module.ts (257 bytes) ``` 其实做到这里便可以继续开发了。但为了更加的贴近于`更佳实践`,我们首先做些重构的工作。 ## 剥离路由 正式开始本节内容以前,先给上一章班级管理补个刀。仔细观察下 app模块和klass模块模块我们会发现,这两个模块在路由的配置上有所不同: 在app模块中,有一个专门来定义路由的app-routing.module.ts ![](https://img.kancloud.cn/ea/99/ea9956715f1d34f7cecfe55cff3c3706_326x74.png) 而在klass模块中,我们并没有专门的路由文件: ![](https://img.kancloud.cn/b9/ee/b9eec9b7a8fcdddd295995ac4235eed0_236x63.png) angular的[官方文档](https://www.angular.cn/guide/router#milestone-2-routing-module)对这两种方式分别进行描述,并指出并不强制使用哪种模式,但同时也指出在一个项目我们应该统一风格。要么将路由配置直接写到模块中,要么将路由配置统一写到路由模块中。在团队的实际生产环境中我们更愿意将路由模块写到单独的路由模块中,这样做最少有2个好处:① 减少模块类的代码量,更易读;② 是否在某个模块中配置了路由一目了解。 ### 重构klass模块 首先,我们对历史的klass模块进行重构,进入klass文件夹并新建`klass-routing.module.ts`,然后将路由设置的信息由`klass/klass.module.ts`转移到`klass-routing.module.ts`中。 klass/klass-routing.module.ts ``` import {RouterModule, Routes} from '@angular/router'; import {IndexComponent} from './index/index.component'; import {AddComponent} from './add/add.component'; import {EditComponent} from './edit/edit.component'; import {NgModule} from '@angular/core'; /*定义路由*/ const routes: Routes = [ { path: '', component: IndexComponent }, { path: 'add', component: AddComponent }, { path: 'edit/:id', component: EditComponent } ]; @NgModule({ imports: [RouterModule.forChild(routes)] }) export class KlassRoutingModule {} ``` 我们在此声明的路由对应的组件均属于`KlassModule`,所以想让此路由信息生效则需要将其添加到对应的`KlassModule`中。在angular中,想让其它模块使用本模块内部的东西,则需要将其添加到`export`中: klass/klass-routing.module.ts ``` @NgModule({ imports: [RouterModule.forChild(routes)], exports: [RouterModule] ① }) export class KlassRoutingModule {} ``` * ① 将使用本模块routes变量配置过的RouterModule抛出。 此操作的作用是:在`KlassRoutingModule`上捆绑`RouteModule`,其它模块在`import KlassRoutingModule`时,将自动的引入`KlassRoutingModule`身上捆绑的`RouteModule`。 最后我们在KlassModule中引入该路由模块,重构完毕。 klass/klass.module.ts ``` @NgModule({ declarations: [IndexComponent, AddComponent, EditComponent, TeacherSelectComponent], imports: [ CommonModule, FormsModule, ReactiveFormsModule, KlassRoutingModule ① ] }) export class KlassModule { } ``` * ① 引入KlassRoutingModule的同时,引用了其捆绑的`RouteModule`。该`RouteModule`已经配置了路由信息,进而使得路由信息在本模块中生效。 补刀结束,回归主题。 ### 新增student路由 参考刚刚补刀的过程为student模块来建立单独的路由模块。 student/student-routing.module.ts ```javascript import {NgModule} from '@angular/core'; import {RouterModule} from '@angular/router'; @NgModule({ exports: [RouterModule] }) export class StudentRoutingModule { } ``` 然后在student模块中引入该路由模块 student/student.module.ts ```javascript @NgModule({ declarations: [AddComponent], imports: [ CommonModule, StudentRoutingModule ] }) export class StudentModule { } ``` ## V层初始化 student/add/add.component.html ```html <h2>编辑教师①</h2> <form (ngSubmit)="onSubmit()"> <label>姓名:<input name="name"/></label> <label>学号:<input name="sno"/></label> <label>班级:todo➊</label> <button>保存</button> </form> ``` * ① 此处笔者在复制内容时发生了错误,正常的标题为:添加学生 * ➊ 此处应该用班级列表组件,由于还不存在,所以我们用TODO来标记一下。 > 此处有错误 在进行初始化时不要怕错,也不要怕界面难看。界面错了我们后面会结合C层及单元测试进行修正,而界面的好看则应该是**集成测试**的任务而非当前初始化的任务。 ## 建立实体类 angular的cli除了可以帮助我们快速的建立模块、组件以外还可以做很多我们想到的或是想不到的事实,比如创建实体。来到norm/entity文件夹,并执行`ng g class student`,则会自动生成实体及实体的测试文件。 norm/entity/student.ts (angular为我们生成的是student而不是Student,看来我们以前对Klass及Teacher的命名都错了。。) ```javascript import {NgModule} from '@angular/core'; @NgModule({}) export class Student { id: number; name: string; sno: string; constructor(data➊?➋: { id?: number; name?: string; sno?: string }) { if (!data) { ➌ return; } this.id = data.id ? data.id : null; ➍ this.name = data.name ? data.name : ''; this.sno = data.sno ? data.sno : ''; } } ``` 在此,我们在构造函数中使用了一种更优的实践。该方法将使得实例化该类具有高度的灵活性。 * ➊ 构造函数直接接收对象,而非某个字段。当实体属性发生变动时整体项目的改动最小 * ➋ `?`表示此参数为可选参数。可以传、也可以不传 * ➌ 规避未传data时可以造成的错误 * ➍ 按传入的参数值赋初值或设置默认值 ### 单元测试 norm/entity/student.spec.ts ```javascript // @ts-ignore ➊ import {Student} from './student'; describe('Student', () => { fit('should create an instance', () => { expect(new Student()).toBeTruthy(); ➋ expect(new Student({})).toBeTruthy(); ➌ expect(new Student({id: 1, name: 'test', sno: '100021'})); ➍ expect(new Student({id: 1})).toBeTruthy(); ➎ expect(new Student({name: 'hello', id: 2, sno: '123'})).toBeTruthy(); ➏ expect(new Student({sno: '456'})).toBeTruthy(); ➐ }); }); ``` * ➊ 忽略IDE报的TS语法错误(实际上并没有问题)。 * ➋ 支持空参数初始化 * ➌ 支持空object初始化 * ➍ 支持传入所有的字段初始化 * ➎ 支持传入个别字段初始化 * ➏ 支持调换字段的书写顺序初始化 * ➐ 支持只传入个别非首参数初始化 ``` Chrome 78.0.3904 (Mac OS X 10.13.6): Executed 1 of 17 (skipped 16) SUCCESS (0.04 secs / 0.003 secs) TOTAL: 1 SUCCESS TOTAL: 1 SUCCESS ``` 如我们上面的单元测试所示,在student的构造函数中我们使用一种更优的方法后,在初始化Student时便更加灵活了。不仅如此,假设有一天有了新需求:需要为学生增加入学年份字段,那么只需要在构造函数中增加`year?:number`即可,而项目中的其它代码我们完全不需要进行改动。 **小测试:** 分别为Student及Teacher类增加一个字段`createTime: number`,并将其添加到构造函数中,然后体现一下两者的区别。 ## C层 student/add/add.component.ts ```javascript import {Component, OnInit} from '@angular/core'; import {Student} from '../../norm/entity/student'; import {FormControl, FormGroup} from '@angular/forms'; @Component({ selector: 'app-add', templateUrl: './add.component.html', styleUrls: ['./add.component.sass'] }) export class AddComponent implements OnInit { student: Student; formGroup: FormGroup; constructor() { } ngOnInit() { this.student = new Student(); this.formGroup = new FormGroup({ name: new FormControl(''), sno: new FormControl('') }); } onSubmit(): void { this.student = this.formGroup.value; console.log(this.student); } } ``` ### 修正V层 C层代码完成后,我们继续修正V层。将表单与C层中的属性相关联: student/add/add.component.ts ```html <h2>编辑教师</h2> <form (ngSubmit)="onSubmit()" [formGroup]="formGroup"> <label>姓名:<input name="name" formControlName="name"/></label> <label>学号:<input name="sno" formControlName="sno" /></label> <label>班级:todo</label> <button>保存</button> </form> ``` #### 单元测试 测试初始化 student/add/add.component.spec.ts ```javascript import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {AddComponent} from './add.component'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; describe('student/AddComponent', () => { let component: AddComponent; let fixture: ComponentFixture<AddComponent>; beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [AddComponent], imports: [ ReactiveFormsModule ] }) .compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(AddComponent); component = fixture.componentInstance; fixture.detectChanges(); }); fit('should create', () => { expect(component).toBeTruthy(); }); }); ``` ### 重构测试公用类 在前面的测试中,我们开发了testing/FormTest来辅助进行一些表单的测试。在实际的使用中我们发现,进行任何表单的操作都需要一个`fixture`平具,在此我们将夹具剥离到构造函数中。 在testing/FormTest.ts的首部添加如下代码: ```javascript /** * 表单测试 */ export class FormTest<T➊> { private readonly➋ fixture: ComponentFixture<T➊>; constructor(fixture: ComponentFixture<T➊>) { this.fixture = fixture; } ``` * ➊ 在小容器ComponentFixture外面加了一个包装FormTest。实际上能装物质的还是ComponentFixture。一旦在包装上规定了要装个物质的种类,则包装中的容器只能装后该种类。 * ➋ 字段为只读属性。 在该文件的尾部我们加入以下方法: ```javascript /** * 设置input输入的值 * @param cssSelector CSS选择器 * @param value 值 */ setInputValue(cssSelector: string, value: string): boolean { return FormTest.setInputValue(this.fixture, cssSelector, value); ➊ } /** * 点击某个按钮 * @param cssSelector CSS选择器 */ clickButton(cssSelector: string): boolean { return FormTest.clickButton(this.fixture, cssSelector);➊ } ``` * ➊ 不造重复的轮子,直接调用原来存在的静态方法。 ## 完善测试 student/add/add.component.spec.ts ```javascript describe('student/AddComponent', () => { let component: AddComponent; let fixture: ComponentFixture<AddComponent>; let formTest: FormTest<AddComponent>; ➊ ... beforeEach(() => { ➋ fixture = TestBed.createComponent(AddComponent); component = fixture.componentInstance; fixture.detectChanges(); formTest = new FormTest(fixture); ➋ }); /** * 1. 向表单中输入值 * 2. 点击保存按钮 * 3. 断言输入的值传入到了C层 */ fit('should create', () => { expect(component).toBeTruthy(); formTest.setInputValue('input[name="name"]', 'testname'); ① formTest.setInputValue('input[name="sno"]', 'testno'); ① formTest.clickButton('button[type="submit"]'); ② fixture.detectChanges(); ③ expect(component.student.name).toEqual('testname'); ④ expect(component.student.sno).toEqual('testno'); ④ }); ``` * ➊ 将一些测试用例可能会公共的对象,抽离到方法上层。 * ➋ 在beforeEach中出现的代码将在每次测试用例被执行**前**,执行一次。 ``` LOG: Object{name: 'testname', sno: 'testno'} Chrome 78.0.3904 (Mac OS X 10.13.6): Executed 0 of 17 SUCCESS (0 secs / 0 secs) Chrome 78.0.3904 (Mac OS X 10.13.6): Executed 1 of 17 (skipped 16) SUCCESS (0.14 secs / 0.108 secs) TOTAL: 1 SUCCESS TOTAL: 1 SUCCESS ``` ![](https://img.kancloud.cn/e5/5a/e55a6c6f851089990df246e29ee34757_608x95.png) # 参考文档 | 名称 | 链接 | 预计学习时长(分) | | --- | --- | --- | | 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.5.1](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.5.1) | \- |