Angular 设计模式和最佳实践(二)
原文:
zh.annas-archive.org/md5/45ce755fc65c79b26ac858559b9855ab译者:飞龙
第五章:Angular 服务和单例模式
静态网页和单页应用之间的一大区别是用户浏览器中的处理能力和交互,给人一种在设备上安装了应用程序的感觉。在 Angular 框架中,进行这种处理和交互的元素,不仅与后端,而且与用户,是服务。
这个元素对 Angular 来说非常重要,以至于团队创建了一个依赖管理系统,它允许以简化的方式在组件中创建、组合和使用服务。
在本章中,我们将探讨这个元素,了解它使用的模式以及在你的项目中应遵循的最佳实践。
在这里,我们将涵盖以下主题:
-
创建服务
-
理解依赖注入模式
-
使用服务在组件之间进行通信
-
消费 REST API
到本章结束时,你将能够创建可重用和可维护的服务,同时了解将提高你生产力的实践。
技术要求
要遵循本章的说明,你需要以下内容:
-
Visual Studio Code (
code.visualstudio.com/Download) -
Node.js 18 或更高版本 (
nodejs.org/en/download/)
本章的代码文件可在github.com/PacktPublishing/Angular-Design-Patterns-and-Best-Practices/tree/main/ch5找到。
创建服务
Angular 中的服务是 TypeScript 类,旨在实现我们接口的业务逻辑。在前端项目中,业务逻辑可能是一个有争议的问题,因为理想情况下,所有逻辑和处理都应该在后台进行,这是正确的。
这里我们使用的是业务规则;这些规则是通用的行为,不依赖于视觉组件,可以在其他组件中重用。
前端业务规则的例子可能如下所示:
-
应用状态控制
-
与后端的通信
-
使用固定规则(如电话号码中的数字数量)进行信息验证
我们将把这个概念付诸实践,并在我们的健身房日记应用程序中创建第一个服务。在命令行中,我们将使用 Angular CLI:
ng generate service diary/services/ExerciseSets
与组件不同,我们可以看到 Angular CLI 创建的元素仅由一个 TypeScript 文件(及其相应的单元测试文件)组成。
在这个文件中,我们将看到 Angular CLI 生成的样板代码:
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class ExerciseSetsService {
constructor() { }
}
在这里,我们有一个名为ExerciseSetsService的 TypeScript 类,它有一个名为@Injectable的装饰器。正是这个装饰器定义了 Angular 中的服务;我们将在本章后面了解更多关于它的细节。
让我们重构我们的项目,并将日记的初始系列设置放在这个服务中。
首先,我们将创建获取初始列表并在后端刷新它的方法:
private setList?: ExerciseSetList;
getInitialList(): ExerciseSetList {
this.setList = [
{ id: 1, date: new Date(), exercise: 'Deadlift', reps: 15, sets: 3 },
{ id: 2, date: new Date(), exercise: 'Squat', reps: 15, sets: 3 },
{ id: 3, date: new Date(), exercise: 'Barbell row', reps: 15, sets: 3 },
];
return this.setList;
}
refreshList(): ExerciseSetList {
this.setList = [
{ id: 1, date: new Date(), exercise: 'Deadlift', reps: 15, sets: 3 },
{ id: 2, date: new Date(), exercise: 'Squat', reps: 15, sets: 3 },
{ id: 3, date: new Date(), exercise: 'Barbell row', reps: 15, sets: 3 },
{ id: 4, date: new Date(), exercise: 'Leg Press', reps: 15, sets: 3 },
];
return this.setList;
}
在服务中,我们将日记组件的初始化和刷新操作移动到服务中,使用 getInitialList 和 refreshList 方法。
当我们看到与后端的通信时,这些方法将得到改进,但在这里,我们已经在将管理练习列表的业务规则从渲染用户界面的组件中解耦,创建了一个特定的服务。
现在让我们考虑向练习列表添加项的方法:
addNewItem(item: ExerciseSet): ExerciseSetList {
if (this.setList) {
this.setList = [...this.setList, item];
} else {
this.setList = [item];
}
return this.setList;
}
服务的 setList 属性可以是 null,因此在这里我们使用 TypeScript 类型守卫概念(更多详情见 第三章,Angular 的 TypeScript 模式)来操作数组。在这里,我们也使用不可变性的概念,在添加新元素后返回一个新的数组。
在 DiaryComponent 组件中,我们将使用我们创建的服务:
export class DiaryComponent {
constructor(private exerciseSetsService: ExerciseSetsService) {}
exerciseList = this.exerciseSetsService.getInitialList();
newList() {
this.exerciseList = this.exerciseSetsService.refreshList();
}
addExercise(newSet: ExerciseSet) {
this.exerciseList = this.exerciseSetsService.addNewItem(newSet);
}
}
在组件中,我们首先可以观察到的是类构造函数的使用,声明了一个类型为 ExerciseSetsService 的私有属性 exerciseSetsService。通过这个声明,我们实例化了一个对象,并重构了我们的组件,用服务方法替换了列表的初始化和刷新操作。
从现在起,组件不再关心如何获取和管理练习列表;这是服务的责任,如果需要,我们现在可以在其他组件中使用这个服务。在这段代码中,你可能想知道为什么我们使用了 ExerciseSetsService 服务,如果我们没有实例化该类的对象。
这里,Angular 有一个很好的特性,即依赖注入机制,我们将在下一节深入探讨这个话题。
理解依赖注入模式
在面向对象的软件开发中,优先考虑组合而非继承是一个好的实践,这意味着一个类应该由其他类(最好是接口)组成。
在我们之前的例子中,我们可以看到 service 类包含了 DiaryComponent 组件。另一种使用此服务的方法如下:
. . .
export class DiaryComponent {
private exerciseSetsService: ExerciseSetsService;
exerciseList: ExerciseSetList;
constructor() {
this.exerciseSetsService = new ExerciseSetsService();
this.exerciseList = this.exerciseSetsService.getInitialList();
}
. . .
}
在这里,我们修改我们的代码,明确地将服务类对象的创建留在了组件的构造函数方法中。再次运行我们的代码,我们可以看到界面保持不变。
这种方法虽然功能齐全,但存在一些问题,例如以下内容:
-
组件和服务之间的高耦合,这意味着如果我们需要更改服务的实现,例如构建单元测试,我们可能会遇到问题。
-
如果服务依赖于另一个类,正如我们将要在 Angular 的 HTTP 请求服务
HttpClient类中看到的那样,我们将在我们的组件中实现这个依赖,从而增加其复杂性。
为了简化开发并解决我们所描述的问题,Angular 有一个依赖注入机制。这个特性允许我们仅通过在构造函数中声明所需的对象来组合一个类。
Angular 利用 TypeScript,将使用在此声明中定义的类型来组装我们所需的类的依赖树,并创建所需的对象。
让我们回到我们的代码,分析这个机制是如何工作的:
. . .
export class DiaryComponent {
constructor(private exerciseSetsService: ExerciseSetsService) {}
exerciseList = this.exerciseSetsService.getInitialList();
. . .
}
在代码中,我们在构造函数中声明了我们类的依赖,创建了exerciseSetsService属性。有了这个,我们就可以在它的声明中初始化exerciseList属性。
在第十章,为测试而设计:最佳实践中,我们将替换测试运行时中此服务的实现。所有这一切都得益于 Angular 的依赖注入功能。
从 Angular 的 14 版开始,我们有了一个依赖注入的替代方案,我们将在下一节中看到。
使用 inject()函数
inject()函数允许你以更简单的方式使用相同的依赖注入功能。
让我们重构我们的组件代码:
import { Component, inject } from '@angular/core';
import { ExerciseSet } from '../interfaces/exercise-set';
import { ExerciseSetsService } from '../services/exercise-sets.service';
. . .
export class DiaryComponent {
private exerciseSetsService = inject(ExerciseSetsService);
exerciseList = this.exerciseSetsService.getInitialList();
. . .
}
在这里,我们移除了依赖注入的构造函数声明,并直接声明了exerciseSetsService服务。对于对象的创建,我们使用inject函数。
需要注意的是,我们使用的是@angular/core模块中的inject函数,而不是@angular/core/testing模块中存在的函数,后者将用于其他目的。
这种方法,除了更简单、更清晰(服务是通过函数注入的)之外,如果需要为特定组件使用继承,还可以简化开发。记住,良好的实践建议我们应优先选择组合而非继承,但在库中,这个特性可能很有趣。
关于inject函数的一个需要注意的点是其只能在组件的构造阶段使用,即在方法的属性声明或类的构造方法中。
在其他上下文中的任何使用都将生成以下编译错误:
inject() must be called from an injection context
such as a constructor, a factory function, a field initializer,
or a function used with `runInInjectionContext`.
现在,让我们深入探讨 Angular 服务的另一个方面,即单例设计模式的使用,以及我们如何利用这种能力在组件之间进行通信。
使用服务进行组件间的通信
关于 Angular 服务,我们必须理解的一个特点是,默认情况下,由依赖注入机制实例化的每个服务都有相同的引用;也就是说,不会创建新的对象,而是重用。
这是因为依赖注入机制实现了单例设计模式来创建和传递对象。单例模式是一种创建型设计模式,允许创建在系统中具有全局访问权限的对象。
这个特性对于服务很重要,因为服务处理可重用的业务规则,我们可以在组件之间使用相同的实例,而无需重建整个对象。此外,我们可以利用这个特性,将服务用作组件之间通信的替代方案。
让我们修改我们的健身房日记,使ListEntriesComponent组件通过服务而不是@Input接收初始列表:
export class ListEntriesComponent {
private exerciseSetsService = inject(ExerciseSetsService);
exerciseList = this.exerciseSetsService.getInitialList();
itemTrackBy(index: number, item: ExerciseSet) {
return item.id;
}
}
在DiaryComponent组件中,我们将从输入中删除列表:
<main class="mx-auto mt-8 max-w-6xl px-4">
<app-list-entries />
<app-new-item-button (newExerciseEvent)="addExercise($event)" />
<br />
<br />
<button
class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
(click)="newList()"
>
Server Sync
</button>
</main>
再次运行它,我们可以看到列表继续出现。这是因为两个组件中使用的服务实例是相同的。然而,这种通信形式需要我们使用 RxJS 通过日记屏幕上的按钮来更新值。我们将在第九章中更深入地探讨这个主题,使用 RxJS 探索反应性。
我们看到,默认情况下,服务是单例的,但在 Angular 中,如果需要解决应用程序中的某些边缘情况,可以更改此配置以用于其他服务。
当我们创建一个服务时,它有一个@Injectable装饰器,就像我们的例子一样:
@Injectable({
providedIn: 'root',
})
export class ExerciseSetsService {
provideIn元数据决定了服务的范围。值'root'表示每个应用程序都将有一个唯一的服务实例;这就是为什么默认情况下,Angular 服务是单例的。
要更改此行为,让我们首先回到ListEntriesComponent组件以接收@Input:
export class ListEntriesComponent {
@Input() exerciseList!: ExerciseSetList;
itemTrackBy(index: number, item: ExerciseSet) {
return item.id;
}
}
让我们回到DiaryComponent组件中通知属性:
<main class="mx-auto mt-8 max-w-6xl px-4">
<app-list-entries [exerciseList]="exerciseList" />
<app-new-item-button (newExerciseEvent)="addExercise($event)" />
<br />
<br />
<button
class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
(click)="newList()"
>
Server Sync
</button>
</main>
在ExerciseSetsService服务中,我们将删除provideIn元数据:
@Injectable()
export class ExerciseSetsService {
如果我们现在运行我们的应用程序,将发生以下错误:
ERROR Error: Uncaught (in promise): NullInjectorError: R3InjectorError(DiaryModule)[ExerciseSetsService -> ExerciseSetsService -> ExerciseSetsService -> ExerciseSetsService]: NullInjectorError: No provider for ExerciseSetsService!
这个错误发生在我们通知 Angular 服务不应该在应用程序范围内实例化时。为了解决这个问题,让我们直接在DiaryComponent组件中声明对服务的使用:
@Component({
templateUrl: './diary.component.html',
styleUrls: ['./diary.component.css'],
providers: [ExerciseSetsService],
})
export class DiaryComponent {
因此,我们的系统再次工作,并且组件有自己的服务实例。
然而,这种技术必须在特定情况下使用,其中组件必须使用它自己的服务实例;建议在服务中保留provideIn。
现在,让我们开始使用 Angular 探索我们的应用程序与后端之间的通信。
REST API 消费
毫无疑问,Angular 服务的主要用途之一是与应用程序的后端通信,使用表示状态传输(REST)协议。
让我们通过准备我们的项目以使用其后端来实际了解这个功能。
首先,让我们通过访问gym-diary-backend文件夹并在您的命令行提示符中使用以下命令来本地上传后端:
npm start
我们可以保留这个命令运行,并现在可以创建用于消费 API 的服务。
为了执行这种消费,Angular 有一个专门的服务——HttpClient。要使用它,我们首先将其模块导入到app.module.ts文件中:
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module';
import { HttpClientModule } from '@angular/common/http';
import { AppComponent } from './app.component';
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, AppRoutingModule, HttpClientModule],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule {}
我们项目的后端 API 返回一些 JSON,包含当天的练习列表。作为良好的实践,我们应该创建一个界面来简化我们在前端应用程序中输入和操作结果。在exercise-set.ts文件中,我们将添加以下接口:
export interface ExerciseSetListAPI {
hasNext: boolean;
items: ExerciseSetList;
}
现在我们可以重构我们的ExerciseSetsService服务以使用HttpClient:
export class ExerciseSetsService {
private httpClient = inject(HttpClient);
private url = 'http://localhost:3000/diary';
getInitialList(): Observable<ExerciseSetListAPI> {
return this.httpClient.get<ExerciseSetListAPI>(this.url);
}
refreshList(): Observable<ExerciseSetListAPI> {
return this.httpClient.get<ExerciseSetListAPI>(this.url);
}
}
首先,我们使用inject函数将HttpClient服务注入到我们的类中。然后我们创建url变量来包含该服务将用于其方法的端点。
最后,我们将getInitialList和refreshList方法重构为消费项目的 API。最初,它们有相同的实现,但我们将在整个书中改进这段代码。
进行了一个重要的更改,使得该方法不返回练习列表,而是一个包含练习列表的 Observable。这是因为涉及消费 REST API 的操作是异步的,通过使用 RxJS 及其 Observables,Angular 处理这种异步性。我们将在第九章中更深入地探讨这个主题,使用 RxJS 探索反应性。
使用HttpClient服务消费GET 类型API,我们声明由ExerciseSetListAPI类型表示的返回类型和服务的get方法,将我们要消费的端点的 URL 作为参数传递。
现在我们添加其他方法以完善我们的服务:
addNewItem(item: ExerciseSet): Observable<ExerciseSet> {
return this.httpClient.post<ExerciseSet>(this.url, item);
}
updateItem(id: string, item: ExerciseSet): Observable<ExerciseSet> {
return this.httpClient.put<ExerciseSet>(`${this.url}/${id}`, item);
deleteItem(id: string): Observable<boolean> {
return this.httpClient.delete<boolean>(`${this.url}/${id}`);
}
}
对于包含一个新的集合,我们使用服务中的POST方法,该方法使用同名的动词调用 API。我们始终传递 URL,在这种情况下,请求正文将是新的练习集合。
要更新集合,我们使用带有正文的PUT方法,并使用字符串插值传递 API 合同中要求的id值。最后,为了删除,我们使用DELETE方法,并使用插值传递我们想要删除的元素的id值。
让我们调整我们的DiaryComponent组件以消费重构后的服务。我们的挑战是如何处理通过 HTTP 请求消费 REST API 的异步性。
首先,让我们调整练习列表的初始化:
@Component({
templateUrl: './diary.component.html',
styleUrls: ['./diary.component.css'],
})
export class DiaryComponent implements OnInit {
private exerciseSetsService = inject(ExerciseSetsService);
exerciseList!: ExerciseSetList;
ngOnInit(): void {
this.exerciseSetsService
.getInitialList()
.subscribe((dataApi) => (this.exerciseList = dataApi.items));
}
}
在DiaryComponent类中,我们将实现OnInit接口并创建onInit方法。这是 Angular 组件的生命周期事件之一,这意味着 Angular 将在构建和渲染界面时在某个时刻调用它。
onInit方法在构建组件后、渲染组件之前被调用。我们需要实现这个方法,因为练习列表的填充将异步发生。在onInit方法中实现这个初始化将确保当 Angular 开始渲染屏幕时数据已经存在。
在这个方法中,我们正在使用该服务,但由于它现在返回一个 Observable,我们需要调用 subscribe 方法,并在其中实现列表的初始化。由于我们正在使用智能和展示组件架构,我们可以在 DiaryComponent 智能组件中实现按钮方法如下:
newList() {
this.exerciseSetsService
.refreshList()
.subscribe((dataApi) => (this.exerciseList = dataApi.items));
}
addExercise(newSet: ExerciseSet) {
this.exerciseSetsService
.addNewItem(newSet)
.subscribe((_) => this.newList());
}
deleteItem(id: string) {
this.exerciseSetsService.deleteItem(id).subscribe(() => {
this.exerciseList = this.exerciseList.filter(
(exerciseSet) => exerciseSet.id !== id
);
});
}
newRep(updateSet: ExerciseSet) {
const id = updateSet.id ?? '';
this.exerciseSetsService
.updateItem(id, updateSet)
.subscribe();
}
在 newList 方法中,我们将它重构为通过 refreshList 方法获取列表元素。
在 addExercise、deleteItem 和 newRep 方法中,我们将之前的逻辑重构为使用 exerciseSetsService 服务。
摘要
在本章中,我们学习了 Angular 服务以及如何以简单和可重用的方式从我们的应用程序中正确隔离业务规则,以及 Angular 服务如何使用单例模式进行内存和性能优化。
我们与 Angular 的依赖注入机制进行了合作并研究,注意到能够组织和重用组件和其他服务之间的服务是多么重要。我们还学习了如何使用 inject 函数作为 Angular 服务的替代,以通过 Angular 的构造函数进行依赖注入。
最后,我们与服务的其中一个主要用途——与后端通信——进行了合作,并在本章中,我们开始探索将我们的前端应用程序与后端集成的过程。
在下一章中,我们将研究使用表单的最佳实践,这是用户将信息输入到我们系统中的主要方式。
第二部分:利用 Angular 的功能
在本部分中,你将使用 Angular 的更高级功能,并了解你如何使用此框架的常见任务。你将了解表单的最佳实践,如何正确使用 Angular 的路由机制,以及最后如何使用拦截器设计模式和 RxJS 库优化 API 消费。
本部分包含以下章节:
-
第六章*,处理用户输入:表单*
-
第七章*,路由和路由器*
-
第八章*,改进后端集成:拦截器模式*
-
第九章*,使用 RXJS 探索响应性*
第六章:处理用户输入:表单
自从 Web 应用程序的早期以来,在<form>标签的概念被用来创建、组织和将表单发送到后端之前。
在常见的应用程序中,例如银行系统和健康应用程序,我们使用表单来组织用户需要在我们的系统中执行的操作。由于 Web 应用程序中这样一个常见的元素,Angular 这样的框架,其哲学是“内置电池”,自然为开发者提供了这一功能。
在本章中,我们将深入探讨 Angular 中的以下表单功能:
-
模板驱动表单
-
响应式表单
-
数据验证
-
自定义验证
-
打字响应式表单
到本章结束时,您将能够为您的用户创建可维护且流畅的表单,同时通过此类任务提高您的生产力。
技术要求
要遵循本章中的说明,您需要以下内容:
-
Visual Studio Code (
code.visualstudio.com/Download) -
Node.js 18 或更高版本(
nodejs.org/en/download/)
本章的代码文件可在github.com/PacktPublishing/Angular-Design-Patterns-and-Best-Practices/tree/main/ch6找到。
在学习本章内容时,请记住使用npm start命令运行位于gym-diary-backend文件夹中的应用程序的后端。
模板驱动表单
Angular 有两种不同的方式处理表单:模板驱动和响应式。首先,让我们探索模板驱动表单。正如其名所示,我们最大限度地利用 HTML 模板的能力来创建和管理与表单关联的数据模型。
我们将演进我们的健身日记应用程序,以更好地说明这一概念。在以下命令行中,我们使用 Angular CLI 创建新的页面组件:
ng g c diary/new-entry-form-template
要访问新的分配表单,我们将重构日记页面组件,使添加新条目按钮将用户带到我们创建的组件。
让我们在DiaryModule模块中添加对负责管理应用程序路由的框架模块的导入:
. . .
import { RouterModule } from '@angular/router';
@NgModule({
declarations: [
DiaryComponent,
EntryItemComponent,
ListEntriesComponent,
NewItemButtonComponent,
NewEntryFormTemplateComponent,
],
imports: [CommonModule, DiaryRoutingModule, RouterModule],
})
export class DiaryModule {}
导入RouterModule模块后,我们将能够使用 Angular 的路由服务。有关路由的更多详细信息,请参阅第七章,路由和路由器。我们将在DiaryRoutingModule模块中添加新组件到路由:
. . .
import { NewEntryFormTemplateComponent } from './new-entry-form-template/new-entry-form-template.component';
const routes: Routes = [
{
path: '',
component: DiaryComponent,
},
{
path: 'new-template',
component: NewEntryFormTemplateComponent,
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class DiaryRoutingModule {}
为了能够比较两种表单创建方法,我们将为将要创建的每个示例组件创建一个路由。在这里,URL /home/new-template将引导我们到模板驱动表单路由。
现在,我们将重构DiaryComponent以修改添加新条目按钮的行为:
. . .
import { Router } from '@angular/router';
@Component({
templateUrl: './diary.component.html',
styleUrls: ['./diary.component.css'],
})
export class DiaryComponent implements OnInit {
private exerciseSetsService = inject(ExerciseSetsService);
private router = inject(Router)
. . .
addExercise(newSet: ExerciseSet) {
this.router.navigate(['/home/new-template'])
}
. . .
}
首先,我们需要注入 Angular 的路由服务。我们将addExercise方法改为使用该服务,并使用navigate方法导航到页面。
我们可以继续在 new-entry-form-template.component.html 文件中的表单 HTML 模板,并仅放置表单的元素:
<div class="flex h-screen items-center justify-center bg-gray-200">
<form class="mx-auto max-w-sm rounded bg-gray-200 p-4">
. . .
<input
type="date"
id="date"
name="date"
/>
. . .
<input
type="text"
id="exercise"
name="exercise"
/>
. . .
<input
type="number"
id="sets"
name="sets"
/>
</div>
<input
type="number"
id="reps"
name="reps"
/>
</div>
<div class="flex items-center justify-center">
<button
type="submit"
>
Add Entry
</button>
...
Angular 使用 HTML 最佳实践,因此我们现在将在 HTML <form> 标签下创建表单字段。在输入字段中,我们尊重 HTML 语义,并创建与客户端所需信息类型正确的 <input> 字段。
让我们使用 ng serve 命令运行我们的应用程序。通过点击 新条目 按钮,我们将能够注意到我们的日记条目添加表单。
图 6.1 – 健身日记表单 UI
这里,我们有表单的结构和模板。现在,我们将准备让 Angular 通过模板中的用户输入来管理字段的状态。要使用模板驱动表单,我们需要将 FormModule 模块导入到我们的功能模块 DiaryModule 中:
import { FormsModule } from '@angular/forms';
@NgModule({
declarations: [
DiaryComponent,
EntryItemComponent,
ListEntriesComponent,
NewItemButtonComponent,
NewEntryFormTemplateComponent,
],
imports: [CommonModule, DiaryRoutingModule, RouterModule, FormsModule],
})
export class DiaryModule {}
在我们的表单模板中,我们将添加创建和链接表单信息到其数据模型的指令:
. . .
<form
(ngSubmit)="newEntry()"
class="mx-auto max-w-sm rounded bg-gray-200 p-4">
<div class="mb-4">
. . .
<input type="date" id="date" name="date"
. . .
[(ngModel)]="entry.date"
/>
</div>
<div class="mb-4">
. . .
<input type="text" id="exercise" name="exercise"
[(ngModel)]="entry.exercise"
. . . />
</div>
<div class="mb-4">
. . .
<input type="number" id="sets" name="sets" [(ngModel)]="entry.sets"
. . ./>
</div>
<div class="mb-4">
. . .
<input type="number" id="reps" name="reps" [(ngModel)]="entry.reps"
. . ./>
. . .
</form>
</div>
ngSubmit parameter to state which method will be called by Angular when the user submits the form. Then, we link the HTML input elements with the data model that will represent the form. We do this through the [(ngModel)] directive.
`ngModel` is an object managed by the `FormModule` module that represents the form’s data model. The use of square brackets and parentheses signals to Angular that we are performing a two-way data binding on the property.
This means that the `ngModel` property will both receive the `form` property and emit events. Finally, for development and debugging purposes, we are placing the content of the entry object in the footer and formatting it with the JSON pipe.
Let’s finish the form by changing the component’s TypeScript file:
export class NewEntryFormTemplateComponent {
private exerciseSetsService = inject(ExerciseSetsService);
private router = inject(Router);
entry: ExerciseSet = { date: new Date(), exercise: '', reps: 0, sets: 0 };
newEntry() {
const newEntry = { ...this.entry };
this.exerciseSetsService
.addNewItem(newEntry)
.subscribe((entry) => this.router.navigate(['/home']));
}
}
First, we inject the `ExerciseSetsService` service for the backend communication and the router service because we want to return to the diary as soon as the user creates a new entry.
Soon after we create the entry object that represents the form’s data model, it is important that we start it with an empty object because Angular makes the binding as soon as the form is loaded. Finally, we create the `newEntry` method, which will send the form data to the backend through the `ExerciseSetsService` service.
For more details about Angular services, see *Chapter 5*, *Angular Services and the Singleton Pattern*. If we run our project and fill in the data, we can see that we are back to the diary screen with the new entry in it.
Notice that at no point did we need to interact with the entry object, as Angular’s form template engine took care of that for us! This type of form can be used for simpler situations, but now we will see the way recommended by the Angular team to create all types of forms: reactive forms!
Reactive forms
Reactive forms use a declarative and explicit approach to creating and manipulating form data. Let’s put this concept into practice by creating a new form for our project.
First, on the command line, let’s use the Angular CLI to generate the new component:
ng g c diary/new-entry-form-reactive
In the same way as we did with the template-driven form, let’s add this new component to the `DiaryRoutingModule` routing module:
import { NewEntryFormReactiveComponent } from './new-entry-form-reactive/new-entry-form-reactive.component';
const routes: Routes = [
{
path: '',
component: DiaryComponent,
},
{
path: 'new-template',
component: NewEntryFormTemplateComponent,
},
{
path: 'new-reactive',
component: NewEntryFormReactiveComponent,
},
];
In the `DiaryModule` module, we need to add the `ReactiveFormsModule` module responsible for all the functionality that Angular makes available to us for this type of form:
@NgModule({
declarations: [
. . .
],
imports: [
. . .
ReactiveFormsModule,
],
})
To finalize the component’s route, let’s change the main screen of our application, replacing the route that the **New Entry** button will call:
addExercise(newSet: ExerciseSet) {
this.router.navigate(['/home/new-reactive']);
}
We will now start creating the reactive form. First, let’s configure the component elements in the `new-entry-form-reactive.component.ts` TypeScript file:
export class NewEntryFormReactiveComponent implements OnInit {
public entryForm!: FormGroup;
private formBuilder = inject(FormBuilder);
ngOnInit() {
this.entryForm = this.formBuilder.group({
date: [''],
exercise: [''],
sets: [''],
reps: [''],
});
}
}
Note that the first attribute is `entryForm` of type `FormGroup`. It will represent our form—not just the data model, but the whole form—as validations, field structure, and so on.
Then, we inject the `FormBuilder` service responsible for assembling the `entryForm` object. Note the name of the service that Angular uses from the `Builder` design pattern, which has the objective of creating complex objects, such as a reactive form.
To initialize the `entryForm` attribute, we’ll use the `onInit` component lifecycle hook. Here, we’ll use the `group` method to define the form’s data model. This method receives the object, and each attribute receives an array that contains the characteristics of that attribute in the form. The first element of the array is the initial value of the attribute.
In the component’s template, we will create the structure of the form, which, in relation to the template-driven form example, is very similar:
[formGroup]="entryForm"
<input
type="date"
id="date"
name="date"
formControlName="date"
/>
<input
type="text"
id="exercise"
name="exercise"
formControlName="exercise"
/>
<input
type="number"
id="sets"
name="sets"
formControlName="sets"
/>
<input
type="number"
id="reps"
name="reps"
formControlName="reps"
/>
添加条目
将 formGroup 属性与之前创建的对象关联。要将每个模板字段关联到 FormGroup 属性,我们使用 formControlName 元素。
为了调试数据模型,我们也在使用 JSON 管道,但请注意,为了获取用户填写的数据模型,我们使用 entryForm 对象的 value 属性。最后,我们将使用项目的 API 功能和记录输入来完善表单。
下一步是更改组件:
export class NewEntryFormReactiveComponent implements OnInit {
. . .
private exerciseSetsService = inject(ExerciseSetsService);
private router = inject(Router);
. . .
newEntry() {
const newEntry = { ...this.entryForm.value };
this.exerciseSetsService
.addNewItem(newEntry)
.subscribe((entry) => this.router.navigate(['/home']));
}
}
在这里,我们注入了 ExerciseSetsService API 的消费者服务和 Angular 路由服务路由。
在 newEntry 方法中,就像前面的例子一样,我们捕获用户输入的数据。然而,在响应式表单中,它位于 value 属性中,我们通过服务将此属性发送到 API。
运行项目后,我们可以看到界面工作得像为模板驱动表单编写的对应界面一样。
图 6.2 – 使用响应式表单的健身房日记表单 UI
你可能想知道,使用响应式表单的优势是什么?为什么 Angular 社区和团队推荐使用它?接下来,我们将看到如何使用表单的内置验证以及如何将它们集成到我们的响应式表单中。
数据验证
一个好的用户体验实践是在用户离开填写字段时立即验证用户在表单中输入的信息。这可以最小化用户的挫败感,同时提高将发送到后端的信息。
使用响应式表单,我们可以使用 Angular 团队创建的实用类来添加在表单中常用到的验证。让我们改进我们的项目,首先在 NewEntryFormReactiveComponent 组件中:
. . .
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
. . .
export class NewEntryFormReactiveComponent implements OnInit {
. . .
ngOnInit() {
this.entryForm = this.formBuilder.group({
date: ['', Validators.required],
exercise: ['', Validators.required],
sets: ['', [Validators.required, Validators.min(0)]],
reps: ['', [Validators.required, Validators.min(0)]],
});
}
newEntry() {
if (this.entryForm.valid) {
const newEntry = { ...this.entryForm.value };
this.exerciseSetsService
.addNewItem(newEntry)
.subscribe((entry) => this.router.navigate(['/home']));
}
}
}
在前面的例子中,我们正在从 Angular 导入 Validators 包,该包将为我们的报告的基本验证提供 utility 类。在创建响应式表单对象的 ngOnInit 方法中,验证位于定义表单字段的数组中的第二个位置。
我们在表单的所有字段中使用必填验证,并在 sets 和 reps 字段中添加另一个验证以确保数字是正数。要添加多个验证,我们可以添加另一个包含验证的数组。
我们对组件所做的另一个更改是,现在它在开始与后端交互之前检查表单是否有效。我们通过检查对象的 valid 属性来完成此操作。Angular 会自动根据用户输入更新此字段。
在模板文件中,让我们为用户添加错误信息:
<div
*ngIf="entryForm.get('date')?.invalid && entryForm.get('date')?.touched"
class="mt-1 text-red-500"
>
Date is required.
</div>
<div
*ngIf="
entryForm.get('exercise')?.invalid &&
entryForm.get('exercise')?.touched
"
class="mt-1 text-red-500"
>
Exercise is required.
</div>
. . .
<div
*ngIf="entryForm.get('sets')?.invalid && entryForm.get('sets')?.touched"
class="mt-1 text-red-500"
>
Sets is required and must be a positive number.
</div>
<div
*ngIf="entryForm.get('reps')?.invalid && entryForm.get('reps')?.touched"
class="mt-1 text-red-500"
>
Reps is required and must be a positive number.
</div>
<button
type="submit"
[disabled]="entryForm.invalid"
[class.opacity-50]="entryForm.invalid"
>
Add Entry
</button>
要在模板中显示验证,我们使用包含我们想要的消息的 div 元素。为了决定消息是否显示,我们使用 ngIf 指令,检查字段的状况。
为了做到这一点,我们首先使用 GET 方法获取字段并检查以下两个属性:
-
invalid属性检查字段是否根据组件中配置的规则无效。 -
touched属性检查用户是否访问了字段。建议在界面加载时不要显示所有验证。
除了每个字段的验证之外,为了提高可用性,我们通过在表单无效时禁用提交按钮并应用 CSS 来使其对用户清晰可见。
运行项目,我们可以看到验证访问了所有字段,而没有任何字段被填写。
图 6.3 – 健身日记表单 UI 验证
我们已经学习了如何使用 Angular 的实用类进行验证,所以让我们探索如何创建我们自己的自定义验证。
自定义验证
我们可以扩展验证的使用,并创建可以接收参数的自定义函数,以最大化在项目中的重用。为了说明这一点,让我们创建一个自定义验证来评估重复次数或组数是否分别是 2 和 3 的倍数。
让我们创建一个名为custom-validation.ts的新文件,并添加以下函数:
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function multipleValidator(multiple: number): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const isNotMultiple = control.value % multiple !== 0;
return isNotMultiple ? { isNotMultiple: { value: control.value } } : null;
};
}
为了让 Angular 识别表单验证函数,它必须返回一个具有ValidatorFn接口中描述的签名的函数。这个签名定义了它将接收AbstractControl,并且必须返回一个类型为ValidationErrors的对象,允许模板解释新的验证类型。
在这里,我们使用control.value获取输入值,如果它不是 3 的倍数,我们将返回error对象。否则,我们将返回null,这将向 Angular 指示值是正确的。
要使用这个函数,我们将按照以下方式重构我们的表单组件:
. . .
ngOnInit() {
this.entryForm = this.formBuilder.group({
date: ['', Validators.required],
exercise: ['', Validators.required],
sets: [
'',
[Validators.required, Validators.min(0), multipleValidator(2)],
],
reps: [
'',
[Validators.required, Validators.min(0), multipleValidator(3)],
],
});
}
. . .
要使用我们的自定义函数,我们需要从新创建的文件中导入它,并在构建表单对象时将其用于验证数组中,就像标准 Angular 验证一样。
最后,让我们更改表单模板以添加错误信息:
. . .
<div
*ngIf="
entryForm.get('sets')?.errors?.['isNotMultiple'] &&
entryForm.get('sets')?.touched
"
class="mt-1 text-red-500"
>
sets is required and must be multiple of 2.
</div>
. . .
<div
*ngIf="
entryForm.get('reps')?.errors?.['isNotMultiple'] &&
entryForm.get('reps')?.touched
"
class="mt-1 text-red-500"
>
Reps is required and must be multiple of 3.
</div>
. . .
我们包括新的div元素,但为了特别验证输入的倍数错误,我们使用error属性,并在其中使用我们自定义函数的新isNotMultiple属性。
我们使用这个参数是因为它在运行时定义的,Angular 将在编译时警告它不存在。
运行我们的项目,我们可以看到新的验证:
图 6.4 – 健身日记表单 UI 自定义验证
除了验证之外,从 Angular 14 版本开始,响应式表单可以更好地进行类型化,以确保在项目开发中提高生产力和安全性。我们将在下一节中介绍这个功能。
类型化响应式表单
在我们的项目中,如果我们查看对象和值的类型,我们可以看到它们都是any类型。虽然功能性强,但通过更好地使用 TypeScript 的类型检查,我们可以改善这种开发体验。
让我们按照以下方式重构组件中的代码:
export class NewEntryFormReactiveComponent {
private formBuilder = inject(FormBuilder);
private exerciseSetsService = inject(ExerciseSetsService);
private router = inject(Router);
public entryForm = this.formBuilder.group({
date: [new Date(), Validators.required],
exercise: ['', Validators.required],
sets: [0, [Validators.required, Validators.min(0), multipleValidator(2)]],
reps: [0, [Validators.required, Validators.min(0), multipleValidator(3)]],
});
newEntry() {
if (this.entryForm.valid) {
const newEntry = { ...this.entryForm.value };
this.exerciseSetsService
.addNewItem(newEntry)
.subscribe((entry) => this.router.navigate(['/home']));
}
}
}
我们将表单对象的创建移动到了组件的构造函数中,并使用 API 将接受的类型初始化字段。使用 Visual Studio Code 的 IntelliSense,我们可以看到 Angular 推断出类型,现在我们有一个非常接近 ExerciseSet 类型的对象。
然而,随着这个更改,addNewItem 方法抛出了一个错误,这实际上是个好事,因为它意味着我们现在正在使用 TypeScript 的类型检查来发现那些只能在运行时出现的潜在错误。为了解决这个问题,我们首先需要将服务修改为接收一个可以包含 ExerciseSet 的一些属性的对象。
在服务中更改 addNewItem 方法:
addNewItem(item: Partial<ExerciseSet>): Observable<ExerciseSet> {
return this.httpClient.post<ExerciseSet>(this.url, item);
}
在这里,我们使用 TypeScript 的 Partial 类型来告知函数它可以接收一个包含部分接口属性的对象。回到我们的组件中,我们可以看到它仍然有一个错误。这是因为它可以接收表单属性中的 null 值。
为了解决这个问题,让我们将 FormBuilder 服务更改为 NonNullableFormBuilder 类型,如下所示:
export class NewEntryFormReactiveComponent {
. . .
private formBuilder = inject(NonNullableFormBuilder);
. . .
}
通过这个更改,Angular 本身执行了这个验证。唯一的要求是所有表单字段都已初始化,这在我们这里已经完成了。
这样,我们的响应式表单就正常工作了,现在我们可以更有效地使用 TypeScript 的类型检查了!
摘要
在本章中,我们探讨了 Angular 表单以及如何使用它们来提升我们的用户体验和团队的生产力。我们学习了如何使用模板表单来满足更简单的需求,并探讨了 Angular 如何使用 ngModel 对象在 HTML 和数据模型之间执行绑定。
我们还使用响应式表单,这为创建和操作表单提供了许多可能性。关于响应式表单,我们研究了如何对字段应用验证以及如何创建我们自己的自定义验证函数。最后,我们重构了我们的响应式表单,使用带类型的表单来利用 TypeScript 类型检查。
在下一章中,我们将探讨 Angular 的路由机制以及它为我们的应用带来的可能性。
第七章:路由和路由器
一个 index.html 页面,并且从那里,所有 Web 应用程序的内容都使用 JavaScript 渲染。
然而,从用户的角度来看,他们正在与登录屏幕、主页和购买表单等不同界面(或页面)进行交互。技术上,它们都在 index.html 页面上渲染,但对于用户来说,它们是不同的体验。
负责这种客户端在单页应用(SPA)中与界面交互流程的机制是路由引擎。Angular 框架自带此功能,在本章中,我们将详细探讨它。
本章我们将涵盖以下主题:
-
路由和导航
-
定义错误页面和标题
-
动态路由 – 通配符和参数
-
保护路由 – 守卫
-
优化体验 – 解析
到本章结束时,您将能够使用 Angular 的路由机制创建改进用户体验的导航流程。
技术要求
要遵循本章的说明,您需要以下内容:
-
Visual Studio Code (
code.visualstudio.com/Download) -
Node.js 18 或更高版本 (
nodejs.org/en/download/)
本章的代码文件可在 github.com/PacktPublishing/Angular-Design-Patterns-and-Best-Practices/tree/main/ch7 找到。
在遵循本章内容时,请记得使用 npm start 命令运行位于 gym-diary-backend 文件夹中的应用程序的后端。
路由和导航
让我们通过创建一个具有简化菜单的用户界面主页来改进我们的项目,从而探索我们可以使用 Angular 路由实现的可能。在命令行中,我们将使用 Angular CLI 创建一个新的模块和组件页面:
ng g m home --routing
在前面的代码片段中,我们首先创建了一个新的模块,并通过使用 --routing 参数,我们指示 Angular CLI 创建模块及其路由文件。以下命令创建了我们在工作的组件:
ng g c home
更多关于 Angular CLI 和模块的详细信息,您可以参考 第二章,组织 您的应用程序。
首先,让我们在我们刚刚创建的组件的 HTML 文件中创建模板:
<div class="flex h-screen">
<aside class="w-1/6 bg-blue-500 text-white">
<nav class="mt-8">
<ul class="flex flex-col items-center space-y-4">
<li>
<a class="flex items-center space-x-2 text-white">
<span>Diary</span>
</a>
</li>
<li>
<a class="flex items-center space-x-2 text-white">
<span>New Entry</span>
</a>
</li>
</ul>
</nav>
</aside>
<main class="flex-1 bg-gray-200 p-4">
<router-outlet></router-outlet>
</main>
</div>
在这个模板示例中,我们使用 <aside> 和 <main> HTML 元素来创建菜单和将要投影所选页面的区域。为此,我们使用 <router-outlet> 指令来指示 Angular 正确的区域。
要使主页成为主页面,我们需要修改我们的应用程序在 app-routing.module.ts 文件中的主要路由模块:
. . .
const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'home' },
{
path: 'home',
loadChildren: () =>
import('./home/home.module').then((file) => file.HomeModule),
},
];
. . .
export class AppRoutingModule {}
routes 数组是 Angular 路由机制的主要元素。我们在其中定义对象,这些对象对应于用户将能够访问的路由。在这个例子中,我们定义了应用程序的根路由("/")将通过 redirectTo 属性重定向用户到 home 路由。
在这里,我们应该使用 pathMatch 属性,并设置为 "full" 值。这是因为它决定了 Angular 路由引擎是否会匹配第一个与模式匹配的路由(默认行为,即 "prefix"),或者是否会匹配整个路由。
在第二个对象中,我们正在定义 home 路由并懒加载 Home 模块。有关懒加载的更多详细信息,您可以参考 第二章,组织 您的应用程序。
当运行我们的应用程序时,我们有菜单和显示我们的锻炼日记页面的区域。
要在主页上包含锻炼日记,我们需要修改 HomeRoutingModule 模块:
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeComponent } from './home.component';
const routes: Routes = [
{
path: '',
component: HomeComponent,
children: [
{
path: 'diary',
loadChildren: () =>
import('../diary/diary.module').then((file) => file.DiaryModule),
},
{
path: '',
redirectTo: 'diary',
pathMatch: 'full',
},
],
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class HomeRoutingModule {}
在这个路由文件中,类似于上一个文件,我们定义了主路由将导向 HomeComponent 组件。然而,在这里,我们希望路由和模块在组件的 router outlet 中渲染,而不是 AppModule。
在这里,children 属性发挥作用,我们将定义该模块的嵌套路由。由于我们想使用 DiaryComponent,我们正在对该模块进行懒加载。这遵循了 Angular 在应用程序中分离功能模块的最佳实践。
现在,当我们再次运行应用程序时,我们又有了日记页面。
图 7.1 – 带有日记的健身房日记主页
为了结束这个环节,让我们在 Home 模板中添加新的锻炼条目链接。进行以下修改:
<li>
<a routerLink="./diary" class="flex items-center space-x-2 text-white">
<span>Diary</span>
</a>
</li>
<li>
<a routerLink="./diary/new-reactive" class="flex items-center space-x-2 text-white">
<span>New Entry</span>
</a>
</li>
我们正在使用 Angular 的 routerLink 指令在模板中创建链接,指定它应该导航到的 URL。
一个需要注意的重要细节是,我们正在使用项目的相对路径来创建链接,使用 ./。因为条目表单路由位于日记模块中,Angular 解释为该模块已经被加载,并允许链接,无需在 HomeRoutingModule 组件中声明额外的声明。
在下一节中,让我们探讨如何处理用户输入一个不存在的日期的场景。
定义错误页面和标题
在我们的当前项目中,如果用户输入一个没有映射路由的路径,他们将面临一个空白屏幕。这不是一个好的 用户体验(UX)实践;理想情况下,我们需要通过显示错误页面来处理这个错误,以便将其重定向到正确的页面。
首先,让我们使用 Angular CLI 创建组件:
ng generate component ErrorPage
在这里,我们直接在 AppModule 中创建组件,因为我们想将这种处理应用于整个系统,而不是特定的功能模块。
让我们为这个组件创建一个带有错误信息的模板:
<div class="flex h-screen flex-col items-center justify-center">
<h1 class="mb-4 text-6xl font-bold text-red-500">Oops!</h1>
<h2 class="mb-2 text-3xl font-bold text-gray-800">Looks like you're lost!</h2>
<p class="mb-6 text-gray-600">
We couldn't find the page you're looking for.
</p>
<p class="text-gray-600">
But don't worry! Go back to the Gym Diary and continue your progress!
</p>
<a
routerLink="/home"
class="mt-4 rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-600"
>
Go back to the Gym Diary
</a>
</div>
注意,我们有一个链接到主页的号召性用语,让用户返回主页。
下一步是更新AppRoutingModule路由文件:
. . .
import { ErrorPageComponent } from './error-page/error-page.component';
const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'home' },
{
path: 'home',
loadChildren: () =>
import('./home/home.module').then((file) => file.HomeModule),
},
{ path: 'error', component: ErrorPageComponent },
{ path: '**', redirectTo: '/error' },
];
. . .
到这一点,Angular 将完成其工作。只需定义错误页面路由并在数组中创建另一个条目,我们就已经定义了'**'路径并将其重定向到错误路由。
当我们运行项目时,如果用户输入了错误的页面,将会显示以下信息:
图 7.2 - 错误路由错误页面
我们还可以在我们的应用程序中改进的另一个点是浏览器标签页中的页面标题。
为了做到这一点,我们还可以再次使用 Angular 的路由机制。在DiaryRoutingModule中,我们需要更改以下代码片段:
. . .
const routes: Routes = [
{
path: '',
component: DiaryComponent,
title: 'Diary',
},
{
path: 'new-template',
component: NewEntryFormTemplateComponent,
},
{
path: 'new-reactive',
component: NewEntryFormReactiveComponent,
title: 'Entry Form',
},
];
. . .
要更改标题,我们只需在路由定义中通知title属性。另一种可能的方法(但更长)是使用 Angular 的Title服务。
让我们在NewEntryFormTemplateComponent组件中举例说明:
import { Title } from '@angular/platform-browser';
. . .
export class NewEntryFormTemplateComponent implements OnInit {
. . .
private titleService = inject(Title);
. . .
ngOnInit(): void {
this.titleService.setTitle('Template Form');
}
. . .
}
注入Title服务后,我们在OnInit生命周期钩子中使用它。虽然路由方法更简单、更直观,但如果标题可以动态更改,则可以使用Title服务。
我们将在下一节学习如何从一个路由传递信息到另一个路由。
动态路由 - 通配符和参数
我们希望更改新重复按钮的功能,使其不再是向条目添加重复,而是用户实际上可以编辑条目,打开填写了数据的表单。
首先,让我们向ExerciseSetsService服务中添加一个新方法:
export class ExerciseSetsService {
. . .
updateItem(id: string, item: Partial<ExerciseSet>): Observable<ExerciseSet> {
return this.httpClient.put<ExerciseSet>(`${this.url}/${id}`, item);
}
getItem(id: string): Observable<ExerciseSet> {
return this.httpClient.get<ExerciseSet>(`${this.url}/${id}`);
}
}
除了通过获取特定项创建新方法外,我们还准备了update方法来接受ExerciseSet对象的Partial。
编辑日记条目的表单将与添加新条目时的表单相同,不同之处在于它将被填写,并将调用update方法。因此,让我们重用NewEntryFormReactiveComponent组件来完成这项工作。
我们将首先编辑DiaryRoutingModule路由文件:
const routes: Routes = [
. . .
. . .
{
path: 'entry',
component: NewEntryFormReactiveComponent,
title: 'Entry Form',
},
{
path: 'entry/:id',
component: NewEntryFormReactiveComponent,
title: 'Edit Entry',
},
];
在route数组中,我们将新表单的路由更改为entry并创建entry/:id路由。
此路由指向相同的组件,但请注意:id告诉 Angular 这是一个动态路由——也就是说,它将接收一个变量值,必须指向该路由。
随着这一变化,我们需要重构我们应用程序的一些部分。在HomeComponent菜单中,让我们调整应用程序路由:
<li>
<a
routerLink="./diary/entry"
class="flex items-center space-x-2 text-white"
>
<span>New Entry</span>
</a>
</li>
我们还需要调整日记和输入组件以调用新路由而不是增加重复次数。在EntryItemComponent组件中,我们将调整组件的方法和Output实例:
export class EntryItemComponent {
@Input('exercise-set') exerciseSet!: ExerciseSet;
@Output() editEvent = new EventEmitter<ExerciseSet>();
@Output() deleteEvent = new EventEmitter<string>();
delete() {
this.deleteEvent.emit(this.exerciseSet.id);
}
editEntry() {
this.editEvent.emit(this.exerciseSet);
}
}
在这里,我们移除了处理并仅发出事件。在模板中,我们将调整 HTML 内容:
. . .
<button
class="rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700"
(click)="editEntry()"
>
Edit
</button>
. . .
我们还将调整 ListEntriesComponent 组件以正确传播 editEvent:
export class ListEntriesComponent {
@Input() exerciseList!: ExerciseSetList;
@Output() editEvent = new EventEmitter<ExerciseSet>();
@Output() deleteEvent = new EventEmitter<string>();
. . .
}
<app-entry-item
[exercise-set]="item"
(deleteEvent)="deleteEvent.emit($event)"
(editEvent)="editEvent.emit($event)"
/>
我们将对日记进行一些小的更改以反映新路由。我们首先在模板中这样做:
<app-list-entries
[exerciseList]="exerciseList"
(deleteEvent)="deleteItem($event)"
(editEvent)="editEntry($event)"
/>
在组件中,我们将更改 newRep 方法,除了名称更改外,它还将重定向到新路由:
addExercise(newSet: ExerciseSet) {
this.router.navigate(['/home/diary/entry']);
}
deleteItem(id: string) {
this.exerciseSetsService.deleteItem(id).subscribe();
}
editEntry(updateSet: ExerciseSet) {
const id = updateSet.id ?? '';
this.router.navigate([`/home/diary/entry/${id}`]);
}
为了重定向到新路由,我们正在进行字符串插值以包含由列表项输出发出的 id。最后,让我们将注意力集中在表单上。在 NewEntryFormReactiveComponent 组件中,让我们调整模板中的 button 标签:
<button
type="submit"
[disabled]="entryForm.invalid"
[class.opacity-50]="entryForm.invalid"
class="rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700"
>
Add Entry
</button>
在 NewEntryFormReactiveComponent 组件中,我们将对其进行调整,使其现在成为创建和编辑条目的表单:
. . .
export class NewEntryFormReactiveComponent implements OnInit {
. . .
private route = inject(ActivatedRoute);
private entryId?: string | null;
. . .
ngOnInit(): void {
this.entryId = this.route.snapshot.paramMap.get('id');
if (this.entryId) {
this.exerciseSetsService
.getItem(this.entryId)
.subscribe((entry) => this.updateForm(entry));
}
}
updateForm(entry: ExerciseSet): void {
let { id: _, ...entryForm } = entry;
this.entryForm.setValue(entryForm);
}
. . .
}
在示例中,我们使用 OnInit 生命周期钩子根据被调用的路由配置表单。为此,Angular 有一个名为 ActivatedRoute 的服务。
在 ngOnInit 方法中,我们捕获调用我们应用程序的路由参数,如果组件接收到 ID,它将从后端获取条目并根据返回值更新表单。
这里的一个细节是,我们正在使用解构赋值来从对象中移除 id 字段,因为它在表单的数据模型中不存在。
在相同的组件中,我们需要更改日记条目的记录:
newEntry() {
if (this.entryForm.valid) {
const newEntry = { ...this.entryForm.value };
if (this.entryId) {
this.exerciseSetsService
.updateItem(this.entryId, newEntry)
.subscribe((entry) => this.router.navigate(['/home']));
} else {
this.exerciseSetsService
.addNewItem(newEntry)
.subscribe((entry) => this.router.navigate(['/home']));
}
}
}
在 newEntry 方法中,如果组件通过路由接收到了对象的 id,它将表现为编辑并调用 exerciseSetsService 服务的相应方法。
当我们运行项目时,我们现在有了输入编辑表单。
图 7.3 – 健身日记编辑条目表单
从 Angular 的第 16 版开始,我们在路由参数的使用上有了改进。除了 ActivatedRoute 服务外,我们还可以直接将页面组件的输入映射到我们应用程序的路由变量中。
让我们重构我们的示例,首先更改主路由模块 AppRoutingModule:
. . .
@NgModule({
imports: [
RouterModule.forRoot(routes, {
bindToComponentInputs: true,
}),
],
exports: [RouterModule],
})
export class AppRoutingModule {}
要使用此资源,我们需要在应用程序路由的一般配置中添加 bindToComponentInputs 属性。
在我们的表单页面中,我们将进行如下重构:
export class NewEntryFormReactiveComponent implements OnInit {
@Input('id') entryId?: string;
. . .
ngOnInit(): void {
if (this.entryId) {
this.exerciseSetsService
.getItem(this.entryId)
.subscribe((entry) => this.updateForm(entry));
}
}
. . .
}
我们为 entryId 属性创建 Input 并定义路由的通配符变量将是 id。我们这样做是为了防止需要重构组件的其余部分,但我们也可以将属性名称更改为 id,如下例所示:
@Input() id?: string;
这里重要的是 Angular 自动将来自路由的信息绑定到属性中,从而进一步简化通过 URL 将参数传递到组件的过程。
在下一节中,我们将通过学习路由守卫来了解如何保护路由免受错误访问。
保护路由 – 守卫
到目前为止,我们已经看到了如何通过路由获取数据来确定page组件的行为。然而,Angular 创建的路由非常灵活,还允许您通过基于业务规则的条件资源来塑造客户的旅程。
为了说明这个功能,我们将创建一个具有简化认证机制的登录屏幕。为了创建组件,我们将使用 Angular CLI。
在您操作系统的命令提示符下,使用以下命令:
ng g m login --routing
ng g c login
ng g s login/auth
第一个命令创建了一个带有routes文件的Login模块。第二个命令创建了login页面组件,最后,我们有了将管理后端认证交互的服务。
在Login模块中,我们将配置新模块的依赖项:
. . .
@NgModule({
declarations: [
LoginComponent
],
imports: [
CommonModule,
LoginRoutingModule,
ReactiveFormsModule
]
})
export class LoginModule { }
接下来,让我们将新模块添加到AppRoutingModule:
const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'home' },
{
path: 'home',
loadChildren: () =>
import('./home/home.module').then((file) => file.HomeModule),
},
{
path: 'login',
loadChildren: () =>
import('./login/login.module').then((file) => file.LoginModule),
},
{ path: 'error', component: ErrorPageComponent },
{ path: '**', redirectTo: '/error' },
];
在LoginRoutingModule模块中,我们将配置我们创建的组件:
const routes: Routes = [
{ path: '', component: LoginComponent },
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class LoginRoutingModule { }
为了简化处理认证服务的请求和响应有效负载,让我们使用新的类型创建一个接口:
export interface LoginForm {
username: string;
password: string;
}
export interface Token {
access_token: string;
}
LoginForm接口对应我们要发送的数据,而Token接口是 API 返回的,基本上是应用将发送给客户端的 JWT 访问令牌。
创建了接口后,让我们创建一个将协调与后端交互的服务:
export class AuthService {
private httpClient = inject(HttpClient);
private url = 'http://localhost:3000/auth/login';
private token?: Token;
login(loginForm: Partial<LoginForm>): Observable<Token> {
return this.httpClient
.post<Token>(this.url, loginForm)
.pipe(tap((token) => (this.token = token)));
}
get isLogged() {
return this.token ? true : false;
}
logout() {
this.token = undefined;
}
}
在这个服务中,我们使用HttpClient服务向后端发送请求(更多详情,请参阅第五章,Angular 服务和单例模式)。我们使用 RxJS 的 tap 操作符,以便在请求成功后立即将令牌保存到service变量中。
正是通过这个变量,我们创建了isLogged属性,这对于控制路由非常重要。创建了服务后,我们可以开发Login页面模板:
<div class="flex justify-center items-center h-screen bg-blue-500">
<div class="bg-blue-200 rounded shadow p-6">
<h2 class="text-2xl font-bold text-gray-800 mb-6">Login</h2>
<form class="space-y-4"
[formGroup]="loginForm"
(ngSubmit)="login()"
>
<div>
<label for="username" class="text-gray-700">Username</label>
<input type="text" id="username" class="block w-full rounded border-gray-300 p-2 focus:border-blue-500 focus:outline-none" formControlName="username">
</div>
<div>
<label for="password" class="text-gray-700">Password</label>
<input type="password" id="password" class="block w-full rounded border-gray-300 p-2 focus:border-blue-500 focus:outline-none" formControlName="password">
</div>
<div>
<button
type="submit"
class="bg-blue-500 text-white rounded px-4 py-2 w-full"
[disabled]="loginForm.invalid"
[class.opacity-50]="loginForm.invalid"
>Login</button>
</div>
</form>
</div>
</div>
在创建Login页面时,一个重要点是正确使用 HTML input字段类型以正确处理 UX 和可访问性。
完成模板后,让我们开发组件:
export class LoginComponent {
private formBuilder = inject(NonNullableFormBuilder);
private loginService = inject(AuthService);
private router = inject(Router);
public loginForm = this.formBuilder.group({
username: ['', [Validators.required]],
password: ['', [Validators.required]],
});
login() {
const loginValue = { ...this.loginForm.value };
this.loginService.login(loginValue).subscribe({
next: (_) => {
this.router.navigate(['/home']);
},
error: (e) => alert('User not Found'),
});
}
}
在这个例子中,我们正在创建响应式表单,并在login方法中使用AuthService服务。运行项目后,在url /login,我们将有我们的登录屏幕。要使用该屏幕,我们有用户名mario和密码1234:
图 7.4 – 登录页面
要创建注销处理,我们将在HomeComponent组件菜单中创建一个链接,并在其中创建logout方法,将其重定向到登录页面:
<li>
<a
(click)="logout()"
class="flex items-center space-x-2 text-white"
>
<span>Logout</span>
</a>
</li>
export class HomeComponent {
private authService = inject(AuthService);
private router = inject(Router);
logout() {
this.authService.logout();
this.router.navigate(['./login']);
}
}
页面创建后,现在我们需要一种方式来确保只有用户登录时才能访问日记。对于这种类型的路由检查,我们应该使用 Angular 的路由****守卫功能。
要创建它,我们可以依靠 Angular CLI 的帮助;在命令行中,使用以下命令:
ng g guard login/auth
将会显示一个选择列表;选择CanActivate。在新文件中,让我们创建以下函数:
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isLogged) {
return true;
} else {
return router.parseUrl('/login');
}
};
从版本 14 开始,创建路由守卫的推荐方式是通过函数而不是类。
我们正在创建一个具有CanActivateFn接口的authGuard函数,这是一个期望返回布尔值或UrlTree类对象的函数,用于将用户重定向到指定的路由。
在函数中,我们首先注入AuthService和Router服务;注意在这个上下文中inject函数是强制性的,因为在函数中我们没有构造函数来注入服务。
配置好服务后,我们做一个评估isLogged服务属性的if语句。如果用户已登录,我们返回true,允许导航到该路由。否则,我们返回一个包含登录页面路由的UrlTree类的对象。
要使用守卫,让我们改变DiaryRoutingModule:
const routes: Routes = [
{
path: '',
component: DiaryComponent,
title: 'Diary',
canActivate: [authGuard],
},
{
path: 'new-template',
component: NewEntryFormTemplateComponent,
},
{
path: 'entry',
component: NewEntryFormReactiveComponent,
title: 'Entry Form',
},
{
path: 'entry/:id',
component: NewEntryFormReactiveComponent,
title: 'Edit Entry',
},
];
通过使用canActivate属性,我们可以传递一个或多个路由守卫。
运行应用程序后,我们可以看到我们被导向登录页面。但如果我们直接调用/home/diary/entry路由,我们会发现它并没有被保护。这是因为我们只在/diary路由上设置了guard。
为了解决这个问题,我们可以在所有路由上设置canActivate属性,但更有效的方法是将路由的类型改为CanActivateChild。
回到route函数,让我们改变它的类型:
export const authGuard: CanActivateChildFn = (route, state) => {
. . .
};
我们现在需要重构DiaryRoutingModule:
const routes: Routes = [
{
path: '',
children: [
{
path: '',
component: DiaryComponent,
title: 'Diary',
},
{
path: 'new-template',
component: NewEntryFormTemplateComponent,
},
{
path: 'entry',
component: NewEntryFormReactiveComponent,
title: 'Entry Form',
},
{
path: 'entry/:id',
component: NewEntryFormReactiveComponent,
title: 'Edit Entry',
},
],
canActivateChild: [authGuard],
},
];
这里,我们使用了一个无组件的路由模式;基本上,我们创建了一个没有组件的路由,并将所有路由作为它的子路由。
然后,我们使用canActivateChild属性来调用路由的守卫,这样我们就不需要在这个模块中重复所有路由。
路由守卫功能可以为您的应用程序做更多的事情,而不仅仅是流程控制;我们可以提高它的感知性能,就像我们将在下一节中看到的那样。
优化体验 – 解析
性能是影响用户体验和满意度最大的变量之一;因此,最佳性能应该是网络开发者的一个持续目标。
感知感知是我们想要赢得的游戏,在 Angular 生态系统中我们有丰富的选择。我们可以在页面渲染之前加载页面所需的信息,为此我们将使用 Resolveroute 保存资源。
与我们之前研究的守卫不同,它的目的是返回由路由导向的页面所需的信息。
我们将使用 Angular CLI 创建这个守卫。在您的命令提示符中,使用以下命令:
ng g resolver diary/diary
在新创建的文件中,让我们改变 Angular CLI 生成的函数:
export const diaryResolver: ResolveFn<ExerciseSetListAPI> = (route, state) => {
const exerciseSetsService = inject(ExerciseSetsService);
return exerciseSetsService.getInitialList();
};
函数注入了ExerciseSetsService服务,并返回getInitialList方法返回的 Observable。
我们将使用这个新解析器配置DiaryRoutingModule:
{
path: '',
component: DiaryComponent,
title: 'Diary',
resolve: { diaryApi: diaryResolver },
},
我们使用resolve属性,就像配置路由指南一样,不同之处在于我们将一个对象与函数关联起来,这对于组件消耗由它生成数据将非常重要。
在DiaryComponent组件中,我们将对该组件进行重构,使其从解析器中获取数据,而不是直接从服务中获取信息:
. . .
private route = inject(ActivatedRoute);
. . .
ngOnInit(): void {
this.route.data.subscribe(({ diaryApi }) => {
this.exerciseList = diaryApi.items;
});
}
. . .
组件现在正在消耗路由的data属性。它返回一个包含diaryApi属性的对象的可观察对象——这是我们之前在routes模块中配置的。
当我们再次运行我们的项目时,我们会看到屏幕的行为在外部没有改变;然而,在内部,我们在组件加载之前从健身房日记中获取信息。在我们这个例子中的这种变化可能不易察觉,但在一个更大、更复杂的应用中,这可能是你和你的团队所寻找的差异。
重要的是要记住,这不会加快对后端请求的速度。它将花费与之前一样的时间,但你的用户可能会感受到的性能可能会受到影响。
我们将对加载日记条目编辑页面进行相同的处理;在同一个resolve文件中,我们将创建一个新的函数:
export const entryResolver: ResolveFn<ExerciseSet> = (route, state) => {
const entryId = route.paramMap.get('id')!;
const exerciseSetsService = inject(ExerciseSetsService);
return exerciseSetsService.getItem(entryId);
};
函数注入了服务,但这次我们使用route参数来提取条目的id以加载它。这个参数由 Angular 提供,以便你可以从你将配置解析器的路由中提取任何属性。
在route模块中,我们将resolve函数添加到编辑路由:
{
path: 'entry/:id',
component: NewEntryFormReactiveComponent,
title: 'Edit Entry',
resolve: { entry: entryResolver },
},
现在,我们需要重构组件以使用路由守卫信息:
private route = inject(ActivatedRoute);
. . .
ngOnInit(): void {
if (this.entryId) {
this.route.data.subscribe(({ entry }) => {
this.updateForm(entry);
});
}
}
就像我们在日记页面中所做的那样,这里我们用路由的消耗来替换服务的消耗。
摘要
在本章中,我们与路由及其资源一起工作,以引导和组织我们应用中的用户流程。我们了解了 Angular 框架中的路由器概念,并为用户使用了不存在路由的情况创建了一个错误页面。我们通过重用表单创建了编辑日记条目页面,并利用动态路由功能学习了如何捕获页面设置所需的路由数据。
最后,我们了解了路由守卫功能,创建了简化的登录流程,并看到了如何通过在页面加载之前使用守卫解析功能来加载后端信息来优化用户体验。
在下一章中,我们将学习如何使用资源通过拦截器设计模式来简化我们对后端的请求。
第八章:改进后端集成:拦截器模式
在一个 Service 中。然而,许多辅助任务对所有与后端的通信都是通用的,例如头部处理、身份验证和加载。
我们可以按服务逐个执行这个辅助任务,但除了这是一个低效的活动外,团队可能由于新成员的疏忽或无知而无法对请求实施一些控制。
为了简化与后端通信的辅助任务开发,Angular 框架实现了拦截器设计模式,我们将在本章中探讨这一模式。在此,我们将涵盖以下主题:
-
使用拦截器将令牌附加到请求
-
更改请求路由
-
创建一个加载器
-
通知成功
-
测量请求的性能
到本章结束时,你将能够创建能够隐式执行后端通信所需任务的拦截器。
技术要求
要遵循本章的说明,你需要以下内容:
-
Visual Studio Code (
code.visualstudio.com/Download) -
Node.js 18 或更高版本 (
nodejs.org/en/download/)
本章的代码文件可在 github.com/PacktPublishing/Angular-Design-Patterns-and-Best-Practices/tree/main/ch8 找到。
在阅读本章时,请记住使用 npm start 命令运行位于 gym-diary-backend 文件夹中的应用程序的后端。
使用拦截器将令牌附加到请求
到目前为止,我们的后端没有任何形式的身份验证控制,这在现实世界中不会发生(或者至少不应该发生)。后端被修改以执行身份验证,但这也反映在前端,因为如果我们尝试登录,就会发生以下错误:
ERROR Error: Uncaught (in promise): HttpErrorResponse:
{"headers":{"normalizedNames":{},"lazyUpdate":null},"status":401,"statusText":"Unauthorized","url":"http://localhost:3000/diary","ok":false,"name":"HttpErrorResponse","message":"Http failure response for http://localhost:3000/diary: 401 Unauthorized","error":{"message":"Unauthorized","statusCode":401}}
这个错误意味着我们的请求被服务器拒绝,因为它没有得到授权。这是因为我们的服务器实现了一种非常常见的安全形式,即在每次请求中都要求提供授权令牌。
这个令牌是在用户登录应用程序时创建的,并且它必须在 HTTP 请求的头部传递。
我们首先通过更改 AuthService 服务来解决这个问题:
export class AuthService {
private httpClient = inject(HttpClient);
private url = 'http://localhost:3000/auth/login';
#token?: Token;
login(loginForm: Partial<LoginForm>): Observable<Token> {
return this.httpClient
.post<Token>(this.url, loginForm)
.pipe(tap((token) => (this.#token = token)));
}
get isLogged() {
return this.#token ? true : false;
}
logout() {
this.#token = undefined;
}
get token() {
return this.#token?.access_token;
}
}
首先,我们更改 token 属性的访问模式。我们使用 # 符号,这是在标准 JavaScript 中声明 private 属性的方式。我们希望令牌只能被其他 component 读取,但永远不会被覆盖,使用令牌可以确保即使消费者类强制修改也能实现这一点。
我们将类名更改为新的属性名,并在最后创建一个 token() 访问器方法来返回服务存储的令牌。
我们将重构ExerciseSetsService服务,以便在返回日记条目的请求中发送令牌:
. . .
private authService = inject(AuthService);
private url = 'http://localhost:3000/diary';
getInitialList(): Observable<ExerciseSetListAPI> {
const headers = new HttpHeaders({
Authorization: `Bearer ${this.authService.token}`,
});
return this.httpClient.get<ExerciseSetListAPI>(this.url, { headers });
}
. . .
在这里,我们使用 Angular 的辅助类HttpHeaders创建一个头,通过Authorization属性传递令牌。然后,我们将此头传递给 Angular 的HttpClient服务的get方法。
当我们再次运行应用程序时,它再次工作(mario,和1234):
图 8.1 – 健身日记主页
这种方法存在一个问题,因为我们需要为所有服务的方法复制此操作,并且随着应用程序的增长,我们需要记住执行此令牌处理。
一个好的软件架构应该考虑随着项目的增长,新团队成员的不同背景甚至新团队的创建。因此,我们系统的此类横向要求必须以更智能的方式处理。
进入Angular 拦截器,这是一个特定类型的服务,用于处理 HTTP 请求流程。该组件基于同名的设计模式,旨在改变处理周期。
让我们通过以下图表来说明此模式:
图 8.2 – 拦截器设计模式
在此图表中,我们有发出 HTTP 请求到后端的 Angular 应用程序;在拦截器模式中,我们有一个位于请求中间的 Angular 服务,可以更改请求和后端的返回。
我们将重构我们的前一个解决方案,以看到此模式在实际中的应用。我们将通过从Authorization头中删除处理来清理ExerciseSetsService服务:
export class ExerciseSetsService {
private httpClient = inject(HttpClient);
private url = 'http://localhost:3000/diary';
getInitialList(): Observable<ExerciseSetListAPI> {
return this.httpClient.get<ExerciseSetListAPI>(this.url);
}
. . .
}
为了创建拦截器,我们将使用 Angular CLI 为 Angular 创建整个服务的模板:
ng g interceptor login/auth
创建了AuthInterceptor服务后,让我们创建我们的逻辑来附加Authorization头:
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
private authService = inject(AuthService);
intercept(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
const token = this.authService.token;
if (request.url.includes('auth')) {
return next.handle(request);
}
if (token) {
const reqAuth = request.clone({
headers: request.headers.set(`Authorization`, `Bearer ${token}`),
});
return next.handle(reqAuth);
}
return next.handle(request);
}
}
我们首先可以注意到,拦截器是一个常见的 Angular 服务,因此它具有@Injectable注解;有关 Angular 服务的更多详细信息,请参阅第五章**, Angular 服务和单例模式。
此服务实现了HttpInterceptor接口,要求类必须具有inject方法。此方法接收我们想要处理的请求,并期望返回一个可观察对象。此签名表明了拦截器的特征,因为此类始终位于发出请求的组件和后端之间的流程中间。
因此,服务从流程中接收信息,并必须返回由可观察对象表示的流程。在我们的案例中,我们使用AuthService服务来获取令牌。服务不能将令牌附加到登录端点,因为那里我们将获取令牌,所以我们通过分析请求使用的 URL 来创建一个if语句。
如果我们有令牌,我们克隆请求,但这次,我们使用令牌来设置头信息。我们需要使用clone方法来获取新对象的原因是请求对象是不可变的——也就是说,它不能被更改;我们需要创建一个新的,与旧的完全相同,但这次,我们添加了头信息。
最后,流程被返回,但这次,带有新的请求对象。为了配置拦截器,我们需要更改AppModule模块:
@NgModule({
declarations: [AppComponent, ErrorPageComponent],
imports: [BrowserModule, AppRoutingModule, HttpClientModule],
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
],
bootstrap: [AppComponent],
})
export class AppModule {}
我们将AuthInterceptor服务包含在HTTP_INTERCEPTORS令牌中。这告诉框架每当组件使用 Angular 的HttpClient服务时调用该服务。multi属性通知框架我们可以有多个拦截器,因为默认情况下,Angular 只添加一个。
再次运行应用程序,我们可以看到它现在正在工作,新增的是所有资源都在附加头信息,但隐式地,不需要更改每个HttpClient调用。
让我们进一步探讨这个功能,通过我们项目中的一个非常常见的任务,即在 API 调用中的 URL 路由。
更改请求路由
在我们到目前为止的项目中,我们有两个服务会向后端发送请求。如果我们分析它们,我们会看到它们都直接指向后端 URL。这不是一个好的做法,因为随着项目的规模和复杂性的增长,指向错误的 URL 可能会导致错误。除了需要更改主机之外,我们还需要更改许多文件。
处理这个问题的方法有很多,但在这个问题中一个非常有用的工具是 Angular 拦截器。让我们从 Angular CLI 开始,我们将创建新的拦截器:
ng g interceptor shared/host
使用生成的文件,让我们创建intercept函数:
@Injectable()
export class HostInterceptor implements HttpInterceptor {
intercept(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
const url = 'http://localhost:3000';
const resource = request.url;
if (request.url.includes('http')) {
return next.handle(request);
}
const urlsReq = request.clone({
url: `${url}/${resource}`,
});
return next.handle(urlsReq);
}
}
在这个函数中,我们有后端的 URL,在resource变量中,我们接收我们想要拦截和修改的原始请求 URL。我们使用if语句是因为我们想要避免错误,以防某些服务需要直接调用另一个 API。
最后,我们创建一个新的请求对象(这次,URL 已更改)并将这个新对象传递给请求流程。为了让这个拦截器被 Angular 触发,我们需要将其添加到AppModule模块的providers数组中:
@NgModule({
declarations: [AppComponent, ErrorPageComponent],
imports: [BrowserModule, AppRoutingModule, HttpClientModule],
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: HostInterceptor, multi: true },
],
bootstrap: [AppComponent],
})
export class AppModule {}
我们将重构我们的服务,使其只关注它们需要的特性,从ExerciseSetsService服务开始:
export class ExerciseSetsService {
private httpClient = inject(HttpClient);
private url = 'diary';
. . .
}
接下来,我们使用Authentication服务:
export class AuthService {
private httpClient = inject(HttpClient);
private url = 'auth/login';
. . .
}
我们可以看到,如果我们需要新的服务或更改 URL,HTTP 请求就不需要重构,因为我们创建了一个拦截器来处理这个问题。
接下来,我们将学习如何让用户在请求耗时过长时获得更好的体验。
创建一个加载器
在前端项目中,性能不仅关乎拥有更快的请求,还关乎提高用户对应用程序的感知。没有任何反馈信号的空白屏幕会向用户传达页面没有加载,他们的互联网有问题,或其他任何负面感知。
正因如此,我们始终需要向用户发出信号,表明他们期望的操作正在执行。展示这一点的其中一种方式是加载指示器,这正是我们在这个会话中将要做的。在我们的操作系统命令行中,我们将使用 Angular CLI:
ng generate component loading-overlay
ng generate service loading-overlay/load
ng generate interceptor loading-overlay/load
通过这样,我们创建了overlay组件,控制加载状态的服务,以及根据 HTTP 请求控制加载开始和结束的拦截器。
让我们在LoadingOverlayComponent组件的 HTML 模板中创建加载覆盖屏幕:
<div class="fixed inset-0 flex items-center justify-center bg-gray-800 bg-opacity-75 z-50">
<div class="text-white text-xl">
Loading...
</div>
</div>
我们将实现LoadService服务,它将维护和控制加载状态:
@Injectable({
providedIn: 'root',
})
export class LoadService {
#showLoader = false;
showLoader() {
this.#showLoader = true;
}
hideLoader() {
this.#showLoader = false;
}
get isLoading() {
return this.#showLoader;
}
}
我们创建了两个方法来开启和关闭加载状态,以及一个属性来公开此状态。
在加载拦截器中,我们将实现以下功能:
@Injectable()
export class LoadInterceptor implements HttpInterceptor {
private loadService = inject(LoadService);
intercept(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
if (request.headers.get('X-LOADING') === 'false') {
return next.handle(request);
}
this.loadService.showLoader();
return next
.handle(request)
.pipe(finalize(() => this.loadService.hideLoader()));
}
}
intercept方法首先开启加载状态,并返回未修改的请求。
然而,在请求流程中,我们放置了 RxJs 的finalize操作符,它具有在可观察者到达完成状态时执行函数的特征 – 在这里,关闭加载状态。有关 RxJS 的更多详细信息,请参阅第九章,使用 RxJS 探索反应性。
要激活拦截器,我们将将其添加到AppModule:
@NgModule({
declarations: [AppComponent, ErrorPageComponent, LoadingOverlayComponent],
imports: [BrowserModule, AppRoutingModule, HttpClientModule],
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: HostInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: LoadInterceptor, multi: true },
],
bootstrap: [AppComponent],
})
export class AppModule {}
我们希望覆盖层在整个应用程序中执行,因此我们将overlay组件包含在AppComponent组件中:
export class AppComponent {
loadService = inject(LoadService);
title = 'gym-diary';
}
我们只需要注入LoadService服务,因为那里我们将拥有加载状态。
最后,让我们将overlay组件放置在 HTML 模板中:
<app-loading-overlay *ngIf="loadService.isLoading"></app-loading-overlay>
<router-outlet></router-outlet>
运行我们的应用程序,因为我们是在机器上运行带有后端的程序,我们可能不会注意到加载屏幕。然而,对于这些情况,我们可以使用 Chrome 的一个功能来模拟慢速 3G 网络。
打开Chrome 开发者工具,在网络标签页中,使用如下所示的节流选项:
图 8.3 – 模拟慢速 3G 网络以注意加载屏幕
在下一节中,我们将学习如何通知用户后端请求的成功。
通知成功
除了通知用户系统正在寻找他们所需的信息的加载屏幕外,在处理完一个项目后通知用户也同样重要。我们可以直接从服务或组件中处理此通知,但也可以通过拦截器以通用和隐式的方式实现它。
我们将重构我们的应用程序以添加这种处理。但首先,让我们安装一个库,在屏幕上使用动画显示toaster组件。在我们的操作系统命令行中,我们将在前端项目的main文件夹中使用以下命令:
npm install ngx-toastr
为了使包正常工作,我们需要通过编辑angular.json文件将我们的 CSS 添加到项目中:
. . .
"build": {
. . .
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": ["src/styles.css", "node_modules/ngx-toastr/toastr.css"],
. . .
},
为了使 toaster 动画工作,我们需要更改AppModule模块:
imports: [
BrowserAnimationsModule,
AppRoutingModule,
HttpClientModule,
ToastrModule.forRoot(),
],
在我们应用程序的main模块中,我们正在添加来自库的ToastrModule模块,并将BrowserModule更改为BrowserAnimationsModule,这为库添加了 Angular 动画服务。
配置了新包后,我们可以使用 Angular CLI 创建新的拦截器:
ng interceptor notification/notification
创建拦截器后,我们将更改通知的处理文件:
. . .
import { ToastrService } from 'ngx-toastr';
@Injectable()
export class NotificationInterceptor implements HttpInterceptor {
private toaster = inject(ToastrService);
intercept(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
return next.handle(request).pipe(
tap((event: HttpEvent<any>) => {
if (event instanceof HttpResponse && event.status === 201) {
this.toaster.success('Item Created!');
}
})
);
}
}
正如在创建加载器部分中,我们正在利用请求被视为流的事实,使用 RxJS 及其可观察对象来验证请求的特征。我们使用tap操作符,该操作符旨在对请求执行副作用而不改变它。
此操作符将执行一个匿名函数,该函数将检查 HTTP 事件,这带我们到一个有趣的观点。由于我们对请求的返回感兴趣,我们只选择类型为HttpResponse的事件,事件代码为201-Created。
在开发拦截器时,我们必须记住它在请求和响应时被调用,因此使用条件执行我们需要的操作是很重要的。
我们需要配置的最后一点是主要的AppModule模块:
providers: [
. . .
{
provide: HTTP_INTERCEPTORS,
useClass: NotificationInterceptor,
multi: true,
},
. . .
]
运行我们的项目并创建一个条目,我们注意到配置的消息在屏幕上显示为 toast。
图 8.4 – 成功通知
拦截器的一个用途是测量我们的应用程序的性能和稳定性,我们将在下一节中了解。
测量请求的性能
作为一支开发团队,我们必须始终寻求为用户提供最佳体验,除了开发高质量的产品外,我们还必须允许应用程序在生产过程中被监控以维持质量。
市面上有几种工具可供选择,其中许多需要一定程度的仪器来准确测量用户体验。我们将开发一个更简单的遥测示例,但它可以应用于你们团队使用的监控工具。
使用 Angular CLI,我们将创建一个新的拦截器:
ng g interceptor telemetry/telemetry
在由 Angular CLI 生成的文件中,我们将开发我们的拦截器:
@Injectable()
export class TelemetryInterceptor implements HttpInterceptor {
intercept(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
if (request.headers.get('X-TELEMETRY') !== 'true') {
return next.handle(request);
}
const started = Date.now();
return next.handle(request).pipe(
finalize(() => {
const elapsed = Date.now() - started;
const message = `${request.method} "${request.urlWithParams}" in ${elapsed} ms.`;
console.log(message);
})
);
}
}
为了说明自定义拦截器的能力,我们同意只有当请求带有名为X-TELEMETRY的自定义头时,才会使用遥测,并在函数的开始处进行此验证。
就像在加载器示例中做的那样,我们使用了finalize运算符以简化的方式测量请求的性能,并在console.log中展示。你可以在这里放置你的遥测提供者调用,甚至你的自定义后端。
为了举例说明,我们使用console.log来展示信息。就像在其他部分一样,我们需要在主AppModule模块中配置拦截器:
. . .
providers: [
. . .
{
provide: HTTP_INTERCEPTORS,
useClass: TelemetryInterceptor,
multi: true,
},
],
. . .
最后,在ExerciseSetsService服务中,我们将发送定制的头以执行此请求的遥测:
. . .
getInitialList(): Observable<ExerciseSetListAPI> {
const headers = new HttpHeaders().set('X-TELEMETRY', 'true');
return this.httpClient.get<ExerciseSetListAPI>(this.url, { headers });
}
. . .
头部传递是配置拦截器以根据不同情况表现不同的方式。
运行我们的项目,我们可以在浏览器日志中看到消息:
GET "http://localhost:3000/diary" in 5 ms. telemetry.interceptor.ts:25:16
通过这次开发,配置了头的 HTTP 请求将在console.log中记录。你可以用集成到遥测服务的拦截器替换这个拦截器,从而提高你应用程序的监控能力。
摘要
在本章中,我们探讨了 Angular 中的拦截器功能以及这个功能可以为我们的团队带来的可能性。我们学习了如何在不改变我们项目中所有服务的情况下将身份验证令牌附加到请求中。我们还致力于更改请求的 URL,使我们的项目对执行环境更加灵活。
我们还通过创建一个加载器来改善用户的体验,以防他们的网络速度慢,并在他们的健身房日记中注册新条目时在屏幕上通知他们。最后,我们使用自定义头创建了一个简单的遥测示例,以便团队能够选择哪些请求具有遥测能力。
在下一章中,我们将探索 RxJS,这是 Angular 工具包中最强大的库。