本節我們將分頁近一步的剝離為組件的形式,以使其在后期能夠被更多的組件重復使用。
## 初始化
在clazz模塊中初始化分頁組件:
```bash
panjie@panjie-de-Mac-Pro clazz % pwd
/Users/panjie/github/mengyunzhi/angular11-guild/first-app/src/app/clazz
panjie@panjie-de-Mac-Pro clazz % ng g c page
CREATE src/app/clazz/page/page.component.css (0 bytes)
CREATE src/app/clazz/page/page.component.html (19 bytes)
CREATE src/app/clazz/page/page.component.spec.ts (612 bytes)
CREATE src/app/clazz/page/page.component.ts (267 bytes)
UPDATE src/app/clazz/clazz.module.ts (593 bytes)
```
然后把我們在clazz列表組件中與分頁相關的代碼復制到V層中:
```typescript
+++ b/first-app/src/app/clazz/page/page.component.html
<nav class="row justify-content-md-center">
<ul class="pagination col-md-auto">
<li [ngClass]="{disabled: pageData.first}" class="page-item"><span class="page-link">上一頁</span></li>
<li *ngFor="let p of pages" [ngClass]="{active: page === p}" class="page-item">
<span class="page-link" (click)="onPage(p)">{{p + 1}}</span>
</li>
<li [ngClass]="{disabled: pageData.last}" class="page-item"><span class="page-link">下一頁</span></li>
</ul>
</nav>
```
根據V層初始化C層的屬性及方法:
```typescript
+++ b/first-app/src/app/clazz/page/page.component.ts
export class PageComponent implements OnInit {
pageData: Page<any>;
pages: number[];
page: number;
constructor() {
}
ngOnInit(): void {
}
onPage(page: number): void {
}
}
```
最后對屬性進行初始化:
```typescript
+++ b/first-app/src/app/clazz/page/page.component.ts
export class PageComponent implements OnInit {
pageData: Page<any> = new Page({
content: [],
number: 0,
size: 10,
numberOfElements: 0
});
pages: number[] = [];
page = 0;
```
代碼完成后,找到對應的單元測試代碼,啟用自動變更檢測:
```typescript
+++ b/first-app/src/app/clazz/page/page.component.spec.ts
fit('should create', () => {
expect(component).toBeTruthy();
fixture.autoDetectChanges();
});
```
由于`pages`為空數組,所以對應僅生成了**上一頁**、**下一頁**。又由于當前共0頁、0條數據,所以下一頁、下一頁均為不可點擊狀態:

## Output()
我們把這種嵌套于其它組件中使用的組件稱為**嵌套組件**,有時也會把**嵌套**該組件的組件稱為父組件,把自己稱為子組件。
在開發嵌套組件的過程中,最重要就是兩項信息:輸入、輸出。所以初始化工作完成后,下一步便是思索該組件的輸入及輸出。
而在輸入與輸出中,應該先站在父組件的角度上思索:當前組件應該輸出什么樣的內容,才能滿足父組件的需求。然后才是站在子組件的角度上思索,預實現輸出需求,需要父組件給自己什么信息。
對于新手而言,弄清父組件需求最簡單的辦法是在開發中將子組件添加到父組件。比如我們將當前page組件應用到clazz列表組件中:
```html
+++ b/first-app/src/app/clazz/clazz.component.html
@@ -30,6 +30,7 @@
</tbody>
</table>
+<app-page></app-page>
<nav class="row justify-content-md-center">
```
然后我們暫時把單元測試的重點移到父組件`clazz`組件上,啟用其對應的單元測試用例:

控制臺報錯說不認識`app-page`組件,該問題已然不是第一次出現,請自行解決后繼續。如果解決該錯誤時你并沒有迅速的想到解決方案,則需要復習教程關于模塊組件關系的相關內容;如果看了教程前面的內容還不知道如何解決,則可以參考本節最后的源碼,參考源碼后再與教程前面類似的內容相對照,爭取再以后遇到此類問題時能夠快速的定位錯誤。
接下來我們需要觀察位于clazz列表組件上分頁的相關代碼,觸發C層的哪些方法:
```html
<nav class="row justify-content-md-center">
<ul class="pagination col-md-auto">
<li [ngClass]="{disabled: pageData.first}" class="page-item"><span class="page-link">上一頁</span></li>
<li *ngFor="let p of pages" [ngClass]="{active: page === p}" class="page-item">
<span class="page-link" (click)="onPage(p)??">{{p + 1}}</span>
</li>
<li [ngClass]="{disabled: pageData.last}" class="page-item"><span class="page-link">下一頁</span></li>
</ul>
</nav>
```
這個觸發C層的方法,即為父組件對子組件的輸出需求。對于當前分頁組件而言,父組件想要的輸出為:某個被點擊的頁碼。所以我們的分頁組件需要有這樣一個`Output()`:
```typescript
+++ b/first-app/src/app/clazz/page/page.component.ts
@@ -1,4 +1,4 @@
-import {Component, OnInit} from '@angular/core';
+import {Component, OnInit, Output, EventEmitter} from '@angular/core';
import {Page} from '../../entity/page';
@Component({
@@ -16,6 +16,9 @@ export class PageComponent implements OnInit {
pages: number[] = [];
page = 0;
+ @Output()
+ bePageChange = new EventEmitter<number>();
+
constructor() {
}
```
`EventEmitter`在使用時,需要指定一個泛型,對于當前需求該泛型的類型為`number`,即點擊的分頁頁碼 。如此便可在clazz列表組件中的page組件上添加對應的方法了:
```html
-<app-page></app-page>
+<app-page (bePageChange)="onPage($event)"></app-page>
```
- `$event`為Angular的一個關鍵字,表示子組件彈出的內容。
## Input()
輸出的需求確定后,我們開始思索若要滿足該需求則需要什么樣的支撐數據,該支撐數據是需要由父組件傳入還是可以通過其它的方式獲取,或是可以通過計算得出。
由于我們剛剛已經在教師列表組件實現了分頁功能,所以在此我們清楚的知道如果想生成動態的分頁信息,則需要:當前頁、共幾頁兩項信息,除此以外如果還可以獲取到當前頁是否為首頁、尾頁等信息就更好了。而這些信息,完全可以由父組件集中傳入`Page`類型。
在Angular中可以使用`@Input()`來規定該組件的傳入值,比如我們在`pageData`上使用`@Input()`注解:
```typescript
+import {Component, OnInit, Output, EventEmitter, Input} from '@angular/core';
import {Page} from '../../entity/page';
@Component({
@@ -7,6 +7,7 @@ import {Page} from '../../entity/page';
styleUrls: ['./page.component.css']
})
export class PageComponent implements OnInit {
+ @Input()
pageData: Page<any> = new Page({
```
如此便可以在使用page組件時,加入`pageData`作為輸入的屬性了:
```html
<app-page [pageData]="xxx"></app-page>
```
### 易懂的代碼
當前使用了`pageData`來表示分頁數據 ,類型為`Page`;使用了`page`來表示當前頁,類型為`number`。這很容易給團隊成員帶來混淆,因為大家往往會想當然的認為`page`的類型應該是`Page`。為了使我們編寫的代碼更易懂,在此將`page`變量名變更為`currentPage`,表示當前頁碼;`pageData`的變量名稱變更為`page`,表示分頁數據:
```typescript
+++ b/first-app/src/app/clazz/page/page.component.ts
@@ -8,14 +8,14 @@ import {Page} from '../../entity/page';
})
export class PageComponent implements OnInit {
@Input()
- pageData: Page<any> = new Page({
+ page: Page<any> = new Page({
content: [],
number: 0,
size: 0,
numberOfElements: 0
});
pages: number[] = [];
- page = 0;
+ currentPage = 0;
```
同步變更V層:
```html
+++ b/first-app/src/app/clazz/page/page.component.html
- <li [ngClass]="{disabled: pageData.first}" class="page-item"><span class="page-link">上一頁</span></li>
- <li *ngFor="let p of pages" [ngClass]="{active: page === p}" class="page-item">
+ <li [ngClass]="{disabled: page.first}" class="page-item"><span class="page-link">上一頁</span></li>
+ <li *ngFor="let p of pages" [ngClass]="{active: currentPage === p}" class="page-item">
<span class="page-link" (click)="onPage(p)">{{p + 1}}</span>
</li>
- <li [ngClass]="{disabled: pageData.last}" class="page-item"><span class="page-link">下一頁</span></li>
+ <li [ngClass]="{disabled: page.last}" class="page-item"><span class="page-link">下一頁</span></li>
```
最后,在clazz列表組件中為page組件設置`page`數據輸入,同時刪除原分頁信息:
```html
+++ b/first-app/src/app/clazz/clazz.component.html
@@ -30,13 +30,4 @@
</tbody>
</table>
-<app-page (bePageChange)="onPage($event)"></app-page>
-<nav class="row justify-content-md-center">
- <ul class="pagination col-md-auto">
- <li [ngClass]="{disabled: pageData.first}" class="page-item"><span class="page-link">上一頁</span></li>
- <li *ngFor="let p of pages" [ngClass]="{active: page === p}" class="page-item">
- <span class="page-link" (click)="onPage(p)">{{p + 1}}</span>
- </li>
- <li [ngClass]="{disabled: pageData.last}" class="page-item"><span class="page-link">下一頁</span></li>
- </ul>
-</nav>
+<app-page [page]="pageData" (bePageChange)="onPage($event)"></app-page>
```
**注意:**當前我們分別在clazz列表組件、page分頁組件中操作,請注意代碼變更的位置。
## 變更檢測
剛剛我們使用`@Input()`獲取了父組件輸入的分頁信息,該信息中包括了當前頁、總頁數、總條數、每頁大小等。接下來希望能通過當前頁及總頁數來動態的生成頁碼。
為了更清晰的明了父子組間的初始化、傳值過程,我們在分別在`clazz`組件及`page`組件中打幾個斷點:
父組件:
```typescript
+++ b/first-app/src/app/clazz/clazz.component.ts
ngOnInit(): void {
+ console.log('clazz組件調用ngOnInit()');
// 使用默認值 page = 0 調用loadByPage()方法
this.loadByPage();
}
loadByPage(page = 0): void {
+ console.log('觸發loadByPage方法');
const httpParams = new HttpParams().append('page', page.toString())
.append('size', this.size.toString());
this.httpClient.get<Page<Clazz>>('/clazz/page', {params: httpParams})
.subscribe(pageData => {
// 在請求數據之后設置當前頁
this.page = page;
+ console.log('clazz組件接收到返回數據,重新設置pageData');
this.pageData = pageData;
```
子組件:
```typescript
+++ b/first-app/src/app/clazz/page/page.component.ts
ngOnInit(): void {
console.log('page組件調用ngOnInit()方法');
console.log('當前頁', this.page.number);
console.log('總頁數', this.page.totalPages);
}
```
然后執行查看具體的執行過程:

通過控制臺打印的信息不難得出,當clazz父組件調用page子組件時,執行過程如下:
分頁組件是根據父組件傳入:共多少頁、當前是第幾頁兩個關鍵信息來生成分頁按鈕的。現在面臨的問題時當父組件接收到后臺返回的數據后,未觸發子組件的`ngOnInit()`方法。
> ? 真實的過程是:實例化子組件、設置子組件的page屬性,最后再調用ngOnInit()方法。
此時如若我們在子組件的`ngOnInit()`方法中根據總頁數、當前頁來生成分頁,則由于總頁數為0,當前頁為0而只能生成一個空分頁。所以現在我們面臨的問題是:當父組件更新分頁數據時,如何去調用子組件中的某個方法。

在Angular中,`@Input()`除了可以做為組件屬性的注解外,還可以做為`set`方法的注解。當做為屬性的注解時,`@Input()`注解下的值將在組件初始化時被賦值1次;當做為`set`方法的注解時,`@Input()`注解下的方法將在組件初化時被賦值1次,同時父組件中對應的值每變更一次,`@Input()`注解下的方法便執行1次。
新建`set page()`方法,并移除原`page`屬性上的`@Input()`注解:
```typescript
+++ b/first-app/src/app/clazz/page/page.component.ts
@@ -7,7 +7,6 @@ import {Page} from '../../entity/page';
styleUrls: ['./page.component.css']
})
export class PageComponent implements OnInit {
- @Input()
page: Page<any> = new Page({
content: [],
number: 0,
@@ -17,7 +16,11 @@ export class PageComponent implements OnInit {
pages: number[] = [];
currentPage = 0;
-
+ @Input()
+ set page(page: Page<any>) {
+ this.page = page;
+ }
+
```
新增`set page()`方法后,使用分頁組件的方法與原來完全相同:
```html
<app-page [page]="xxx"></app-page>
```
不同的是,原來的`page`屬性僅會在子組件`page`初始化賦值一次;而當下父組件中的`xxx`每變化一次,子組件對應的方法便會執行一次。同時TypeScript的語法要求類中的屬性名與方法不能重復:

為此我們將原`page`屬性重新命名為`inputPage`:
```typescript
+++ b/first-app/src/app/clazz/page/page.component.ts
@@ -7,7 +7,7 @@ import {Page} from '../../entity/page';
styleUrls: ['./page.component.css']
})
export class PageComponent implements OnInit {
- page: Page<any> = new Page({
+ inputCache: Page<any> = new Page({
content: [],
number: 0,
size: 0,
@@ -17,8 +17,8 @@ export class PageComponent implements OnInit {
currentPage = 0;
@Input()
- set page(page: Page<any>): void {
- this.page = page;
+ set page(page: Page<any>) {
+ this.inputCache = page;
}
@Output()
@@ -29,8 +29,8 @@ export class PageComponent implements OnInit {
ngOnInit(): void {
console.log('page組件調用ngOnInit()方法');
- console.log('當前頁', this.page.number);
- console.log('總頁數', this.page.totalPages);
+ console.log('當前頁', this.inputCache.number);
+ console.log('總頁數', this.inputCache.totalPages);
}
```
最后,在`set page`方法中同樣打印一些輔助信息:
```typescript
+++ b/first-app/src/app/clazz/page/page.component.ts
@@ -19,6 +19,9 @@ export class PageComponent implements OnInit {
@Input()
set page(page: Page<any>) {
this.inputPage = page;
+ console.log('set page被調用');
+ console.log('當前頁', this.inputPage.number);
+ console.log('總頁數', this.inputPage.totalPages);
}
@Output()
```
然后查看控制臺的打印信息:

加入`set page`方法后整個調用過程如下:

## 生成分頁
核心的未知問題全部都解決以后,現在可以愉快的在分頁組件中完成其核心功能:根據當前頁、總頁數,生成分頁頁碼了:
```typescript
+++ b/first-app/src/app/clazz/page/page.component.ts
@@ -22,6 +22,13 @@ export class PageComponent implements OnInit {
console.log('set page被調用');
console.log('當前頁', this.inputPage.number);
console.log('總頁數', this.inputPage.totalPages);
+ // 生成頁數數組
+ this.pages = [];
+ for (let i = 0; i < this.inputPage.totalPages; i++) {
+ this.pages.push(i);
+ }
+ // 設置當前頁
+ this.currentPage = this.inputPage.number;
}
```
最終效果:

### Output()
輸入完成后,開始完成輸出。對本組件而言,輸出相對是比較簡單的,我們僅僅需要在頁碼被點擊時彈出被點擊的頁碼即可:
```typescript
+++ b/first-app/src/app/clazz/page/page.component.ts
@@ -44,6 +44,7 @@ export class PageComponent implements OnInit {
}
onPage(page: number): void {
-
+ // 點擊頁碼時彈出該頁碼
+ this.bePageChange.emit(page);
}
}
```
## 完善
最后,我們刪除父clazz列表組件中關于生成分頁數據的相關代碼:
```typescript
+++ b/first-app/src/app/clazz/clazz.component.ts
@@ -15,9 +15,6 @@ export class ClazzComponent implements OnInit {
// 每頁默認為3條
size = 3;
- // 分頁數組
- pages = [] as number[];
-
// 初始化一個有0條數據的
pageData = new Page<Clazz>({
content: [],
@@ -50,11 +47,6 @@ export class ClazzComponent implements OnInit {
console.log('clazz組件接收到返回數據,重新設置pageData');
this.pageData = pageData;
console.log(pageData);
- // 根據返回的值生成分頁數組
- this.pages = [];
- for (let i = 0; i < pageData.totalPages; i++) {
- this.pages.push(i);
- }
});
}
}
```
在生產的項目中,我們還會對`console.log()`方法進行處理,以防止在控制臺打印過多的冗余信息。
## 總結
本節中我們又學習Angular的又一個重要特性:`@Input()`。`@Input()`可以作用的組件屬性上,也可以作用在組件的`set xxx()`方法上。當作用在屬性上時,父組件將僅在子組件初始化時傳值一次;在作用在`set`方法上時,父組件綁定到子組件上的值每變更一次,都將調用一次對應的方法。
在Angular應用開發中,應該視情況進行組件的拆分。是否拆分的原則一般為:是否重復使用。如果某些功能被重復使用,則應該拆分為組件;如果某些功能不被重復使用,則可以不拆分組件。在有些時間,如果一個界面的邏輯功能比較復雜,我們也會使用組件拆分的方法來降低單個組件的開發難度。
在子組件的首次開發中,我們剛開始往往搞不清組件應該有的輸入及輸出。這時候就需要先在父組件中開發,就像我們在前面兩個小節中直接在clazz列表組件中開發了分頁功能一樣;等開發成功,再建立子組件,進行功能的遷移。當然,等開發的子組件多了,在能力提升的情況下,后期也可以直接開發子組件。
在本節中父子組件交互中,我們使用`console.log()`在控制臺輸出了大量的內容,這在開發時是個應該保持的好習慣。本節中我們便是借助 `console.log()`弄明白父子組件在交互時各個方法的執行順序的。
另外編寫代碼雖然更多是使用鍵盤,但在開發中遇到一些較難解決的問題的時,最有效率的工具卻是紙筆。借助控制臺的信息在筆上寫一寫,畫一畫,圈出當前需要解決的問題,往往可能幫助我們聚集問題所在。
## 本節作業
1. 請上網查詢typescript的`get`、`set`方法。
2. 開發分頁組件時,啟用了clazz列表的單元測試用例,請嘗試啟用page對應的單元測試用例,模擬輸入值完成分頁組件的測試。
| 名稱 | 鏈接 |
| -------------------------------- | ------------------------------------------------------------ |
| 把數據發送到子組件 | [https://angular.cn/guide/inputs-outputs#sending-data-to-a-child-component](https://angular.cn/guide/inputs-outputs#sending-data-to-a-child-component) |
| 通過 setter 截聽輸入屬性值的變化 | [https://angular.cn/guide/component-interaction#intercept-input-property-changes-with-a-setter](https://angular.cn/guide/component-interaction#intercept-input-property-changes-with-a-setter) |
| TypeScript 類 | [https://typescript.bootcss.com/classes.html](https://typescript.bootcss.com/classes.html) |
| 本節源碼 | [https://github.com/mengyunzhi/angular11-guild/archive/step6.3.6.zip](https://github.com/mengyunzhi/angular11-guild/archive/step6.3.6.zip) |
- 序言
- 第一章 Hello World
- 1.1 環境安裝
- 1.2 Hello Angular
- 1.3 Hello World!
- 第二章 教師管理
- 2.1 教師列表
- 2.1.1 初始化原型
- 2.1.2 組件生命周期之初始化
- 2.1.3 ngFor
- 2.1.4 ngIf、ngTemplate
- 2.1.5 引用 Bootstrap
- 2.2 請求后臺數據
- 2.2.1 HttpClient
- 2.2.2 請求數據
- 2.2.3 模塊與依賴注入
- 2.2.4 異步與回調函數
- 2.2.5 集成測試
- 2.2.6 本章小節
- 2.3 新增教師
- 2.3.1 組件初始化
- 2.3.2 [(ngModel)]
- 2.3.3 對接后臺
- 2.3.4 路由
- 2.4 編輯教師
- 2.4.1 組件初始化
- 2.4.2 獲取路由參數
- 2.4.3 插值與模板表達式
- 2.4.4 初識泛型
- 2.4.5 更新教師
- 2.4.6 測試中的路由
- 2.5 刪除教師
- 2.6 收尾工作
- 2.6.1 RouterLink
- 2.6.2 fontawesome圖標庫
- 2.6.3 firefox
- 2.7 總結
- 第三章 用戶登錄
- 3.1 初識單元測試
- 3.2 http概述
- 3.3 Basic access authentication
- 3.4 著陸組件
- 3.5 @Output
- 3.6 TypeScript 類
- 3.7 瀏覽器緩存
- 3.8 總結
- 第四章 個人中心
- 4.1 原型
- 4.2 管道
- 4.3 對接后臺
- 4.4 x-auth-token認證
- 4.5 攔截器
- 4.6 小結
- 第五章 系統菜單
- 5.1 延遲及測試
- 5.2 手動創建組件
- 5.3 隱藏測試信息
- 5.4 規劃路由
- 5.5 定義菜單
- 5.6 注銷
- 5.7 小結
- 第六章 班級管理
- 6.1 新增班級
- 6.1.1 組件初始化
- 6.1.2 MockApi 新建班級
- 6.1.3 ApiInterceptor
- 6.1.4 數據驗證
- 6.1.5 教師選擇列表
- 6.1.6 MockApi 教師列表
- 6.1.7 代碼重構
- 6.1.8 小結
- 6.2 教師列表組件
- 6.2.1 初始化
- 6.2.2 響應式表單
- 6.2.3 getTestScheduler()
- 6.2.4 應用組件
- 6.2.5 小結
- 6.3 班級列表
- 6.3.1 原型設計
- 6.3.2 初始化分頁
- 6.3.3 MockApi
- 6.3.4 靜態分頁
- 6.3.5 動態分頁
- 6.3.6 @Input()
- 6.4 編輯班級
- 6.4.1 測試模塊
- 6.4.2 響應式表單驗證
- 6.4.3 @Input()
- 6.4.4 FormGroup
- 6.4.5 自定義FormControl
- 6.4.6 代碼重構
- 6.4.7 小結
- 6.5 刪除班級
- 6.6 集成測試
- 6.6.1 惰性加載
- 6.6.2 API攔截器
- 6.6.3 路由與跳轉
- 6.6.4 ngStyle
- 6.7 初識Service
- 6.7.1 catchError
- 6.7.2 單例服務
- 6.7.3 單元測試
- 6.8 小結
- 第七章 學生管理
- 7.1 班級列表組件
- 7.2 新增學生
- 7.2.1 exports
- 7.2.2 自定義驗證器
- 7.2.3 異步驗證器
- 7.2.4 再識DI
- 7.2.5 屬性型指令
- 7.2.6 完成功能
- 7.2.7 小結
- 7.3 單元測試進階
- 7.4 學生列表
- 7.4.1 JSON對象與對象
- 7.4.2 單元測試
- 7.4.3 分頁模塊
- 7.4.4 子組件測試
- 7.4.5 重構分頁
- 7.5 刪除學生
- 7.5.1 第三方dialog
- 7.5.2 批量刪除
- 7.5.3 面向對象
- 7.6 集成測試
- 7.7 編輯學生
- 7.7.1 初始化
- 7.7.2 自定義provider
- 7.7.3 更新學生
- 7.7.4 集成測試
- 7.7.5 可訂閱的路由參數
- 7.7.6 小結
- 7.8 總結
- 第八章 其它
- 8.1 打包構建
- 8.2 發布部署
- 第九章 總結