本节我们将分页近一步的剥离为组件的形式,以使其在后期能够被更多的组件重复使用。 ## 初始化 在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) |