本节我们将分页近一步的剥离为组件的形式,以使其在后期能够被更多的组件重复使用。
## 初始化
在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条数据,所以下一页、下一页均为不可点击状态:
![image-20210331144515844](https://img.kancloud.cn/99/64/99647ac66c3270db6a2130c362b2bfbf_478x122.png)
## 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`组件上,启用其对应的单元测试用例:
![image-20210331145536253](https://img.kancloud.cn/04/4e/044e681833d23230336c39b9615f66e1_2112x158.png)
控制台报错说不认识`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);
}
```
然后执行查看具体的执行过程:
![image-20210401091049025](https://img.kancloud.cn/26/2a/262a7d72ff4960bdb760d81d52f9fa92_978x304.png)
通过控制台打印的信息不难得出,当clazz父组件调用page子组件时,执行过程如下:
![image-20210401092004125](https://img.kancloud.cn/25/bb/25bb40711819030f15122d798a1bc01c_854x768.png)分页组件是根据父组件传入:共多少页、当前是第几页两个关键信息来生成分页按钮的。现在面临的问题时当父组件接收到后台返回的数据后,未触发子组件的`ngOnInit()`方法。
> 真实的过程是:实例化子组件、设置子组件的page属性,最后再调用ngOnInit()方法。
此时如若我们在子组件的`ngOnInit()`方法中根据总页数、当前页来生成分页,则由于总页数为0,当前页为0而只能生成一个空分页。所以现在我们面临的问题是:当父组件更新分页数据时,如何去调用子组件中的某个方法。
![image-20210401092519595](https://img.kancloud.cn/ed/03/ed032e876769a430ab946548080e1497_924x456.png)
在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的语法要求类中的属性名与方法不能重复:
![image-20210401093738675](https://img.kancloud.cn/c4/9c/c49c7e5563570bda887d825d78fa31bc_783x394.png)
为此我们将原`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()
```
然后查看控制台的打印信息:
![image-20210401094051279](https://img.kancloud.cn/2b/4b/2b4ba3b8bc6d1782d4dc4d13888539a6_1178x560.png)
加入`set page`方法后整个调用过程如下:
![image-20210401100646484](https://img.kancloud.cn/f3/42/f342db8812907f54591a9379af4ee1cd_1422x756.png)
## 生成分页
核心的未知问题全部都解决以后,现在可以愉快的在分页组件中完成其核心功能:根据当前页、总页数,生成分页页码了:
```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;
}
```
最终效果:
![image-20210401104133480](https://img.kancloud.cn/f1/61/f161b834b7af0100e269f384b1035f3d_1290x176.png)
### 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 发布部署
- 第九章 总结