為了更加清晰的認識組件的使用方法,我們暫時停止單元測試的步代,來到教師增加組件,并在該組件中引入教師列表組件。
klass/add/add.component.html
```
<h3>新增班級</h3>
<form (ngSubmit)="onSubmit()">
<label for="name">名稱:<input id="name" type="text" [formControl]="name"/></label>
<label for="teacherId">教師:<app-teacher-select id="teacherId"></app-teacher-select?></label>
<button>保存</button>
</form>
```
* ? 對應教師列表組件klass/teacher-select/teacher-select.component.ts定義的`selector: 'app-teacher-select'`
然后我們啟動前后臺,在教師管理中添加兩個測試教師后來到班級新增界面:

當前的界面中有兩個基礎組件:班級新增組件及選擇教師組件。

我們現在面臨的問題是:當用戶選擇了某個教師后,如何將選擇的教師由`教師選擇組件`傳遞給`班級新增組件`,近而在`班級新增組件`中獲取這個選擇上的教師最終完成班級的保存功能。
# 事件彈射器
在前面我們提起到,用戶與瀏覽器的一切交互都可以認為是在觸發一個事件,比如鼠標點擊事件,鍵盤輸入事件。同樣的,用戶選擇select中的某個option也是一個事件。在angular中組件通過定義`EventEmitter 事件彈射器`的方式來向處發送數據。從本質上來講,`EventEmitter 事件彈射器`也是個可被觀察者,它提供的功能是:如果本組件發生了某個事件,就會通過`EventEmitter 事件彈射器`來發送通知,如果你想獲取到這些通知,那么只需要訂閱我即可。
## 初始化
klass/teacher-select/teacher-select.component.html
```
<select id="teacherSelect" [formControl]="teacherSelect" (change)="onChange()?">
<option *ngFor="let teacher of teachers" [ngValue]="teacher">
{{teacher.name}}
</option>
</select>
```
* ? 當用戶選擇select中的某個option時,會觸發change事件。
klass/teacher-select/teacher-select.component.ts
```
@Output()? selected① = new EventEmitter<Teacher>();?
...
onChange() {
console.log(this.teacherSelect.value); ?
this.selected.emit(this.teacherSelect.value); ?
}
```
* ? 此屬性為組件的輸出屬性
* ① 此處的命名不能與angular自帶的相沖突,比如說我們**不**能使用**change**來命名。
* ? 實例化事件發射器,此發射器發送的內容為`Teacher`
* ? 控制臺打印當前select的值(用戶進行選擇時,會實時的綁定到fromControl上),用于測試
* ? 向組件外發(彈)射數據
#### 測試

## 獲取彈射的值
在班級新增組件中,我們嘗試獲取該組件彈射出的值
klass/add/add.component.html
```
<h3>新增班級</h3>
<form (ngSubmit)="onSubmit()">
<label for="name">名稱:<input id="name" type="text" [formControl]="name"/></label>
<label for="teacherId">教師:<app-teacher-select id="teacherId" (selected)?="onTeacherSelected($event)①"></app-teacher-select></label>
<button>保存</button>
</form>
```
* ? 使用我們剛剛在教師組件中定義的`@Output() selected`,這也是為什么我們不能夠命名為`@Output() change`的原因。如果我們命名為:`@Output() change`,則此時在新增班的V層中便應該如下使用:`<app-teacher-select id="teacherId" (change)="onTeacherSelected($event)">`。此時angular會把`change()`解析為內置指令,進而使得該彈射功能失效(**請自行測試**)
* ① 方法名需要在C層中定義,而參數名可以隨性起,但我們一般為起名為$event以示此處為該組件的一個彈射器。
klass/add/add.component.ts
```
/**
* 當選擇某個教師時觸發
* @param {Teacher} teacher 教師
*/
onTeacherSelected(teacher: Teacher) {
console.log(teacher);
}
```
#### 測試

如上所示,用戶選擇教師后,我們分別在教師列表組件及班級新增組件中獲取到了該項的值。
# 單元測試
完成了功能以后我們來**補充**單元測試,學習如何在單元測試中來測試組件事件彈射器。由于事件彈射器本質上來講是一個可觀察者,所以我們在測試的代碼中直接來訂閱這個觀察者:
klass/teacher-select/teacher-select.component.spec.ts
```
/**
* 1. 模擬返回數據給教師列表
* 2. 觀察彈射器
* 3. 模擬點擊第0個option
* 4. 斷言觀察到的數據是教師列表的第一個教師
*/
fit('測試組件彈射器', () => {
const httpTestingController: HttpTestingController = TestBed.get(HttpTestingController);
const req = httpTestingController.expectOne('http://localhost:8080/Teacher');
req.flush(teachers);
fixture.detectChanges();
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('#teacherSelect')).nativeElement;
htmlSelectElement.value = htmlSelectElement.options[0].value; ?
htmlSelectElement.dispatchEvent(new Event('change')); ?
fixture.detectChanges(); ②
});
```
* ? 觀察彈射器,并定義了彈射器彈出的數據類型為Teacher
* ① 測試語句,有數據彈出時該執行此語句。若該語句未被執行,說明數據未彈出
* ? 設置select值的值為第一個option
* ? 執行change事件來選中該option
* ② 多寫幾個fixture.detectChanges(); 不吃虧
測試結果:
```
LOG: Teacher{id: 1, name: '潘杰', username: 'panjie', email: undefined, sex: undefined}
Chrome 78.0.3904 (Mac OS X 10.13.6): Executed 0 of 13 SUCCESS (0 secs / 0 secs)
LOG: 'data emit', Teacher{id: 1, name: '潘杰', username: 'panjie', email: undefined, sex: undefined}
Chrome 78.0.3904 (Mac OS X 10.13.6): Executed 0 of 13 SUCCESS (0 secs / 0 secs)
Chrome 78.0.3904 (Mac OS X 10.13.6): Executed 1 of 13 (skipped 12) SUCCESS (0.123 secs / 0.099 secs)
TOTAL: 1 SUCCESS
TOTAL: 1 SUCCESS
```
### 重構測試
本代碼使用了與前面代碼段相同的代碼,本著**不造重復的輪子**的原則,對整體的文件進行重構后如下:
klass/teacher-select/teacher-select.component.spec.ts
```
import {async, ComponentFixture, flush, TestBed} from '@angular/core/testing';
import {TeacherSelectComponent} from './teacher-select.component';
import {BrowserModule, By} from '@angular/platform-browser';
import {ReactiveFormsModule} from '@angular/forms';
import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing';
import {Teacher} from '../../norm/entity/Teacher';
describe('TeacherSelectComponent', () => {
let component: TeacherSelectComponent;
let fixture: ComponentFixture<TeacherSelectComponent>;
const teachers = new Array(new Teacher(1, 'panjie', '潘杰'),
new Teacher(2, 'zhangxishuo', '張喜碩'));
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [TeacherSelectComponent],
imports: [
BrowserModule,
ReactiveFormsModule,
HttpClientTestingModule
]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(TeacherSelectComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
/*斷言發請了后臺請求,模擬返回數據后,斷言V層的select個數為2*/
it('獲取教師列表后選擇教師', () => {
expectInit();
const htmlSelectElement: HTMLSelectElement = fixture.debugElement.query(By.css('#teacherSelect')).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. 斷言觀察到的數據是教師列表的第一個教師
*/
fit('測試組件彈射器', () => {
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('#teacherSelect')).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();
};
});
```
# 參考文檔
| 名稱 | 鏈接 | 預計學習時長(分) |
| --- | --- | --- |
| 源碼地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step3.5.4](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step3.5.4) | - |
| 事件彈射器 | [EventEmitter](https://www.angular.cn/api/core/EventEmitter) | 15 |
- 序言
- 第一章: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
- 總結
- 開發規范
- 備用