Angular 秘籍第二版(二)
原文:
zh.annas-archive.org/md5/69fbe45134859c45b2aa58e42abe465f译者:飞龙
第三章:Angular 中依赖注入的魔法
本章全部关于 Angular 中 依赖注入(DI)的魔法。在这里,你将了解 Angular 中 DI 概念的详细信息。DI 是 Angular 用于将不同依赖项注入到组件、指令和服务的进程。你将通过几个示例进行操作,使用服务和提供者来获得一些实际经验,这些经验可以在你以后的 Angular 项目中利用。
在本章中,我们将介绍以下食谱:
-
使用 Angular DI 令牌
-
可选依赖项
-
使用
providedIn创建单例服务 -
使用
forRoot()创建单例服务 -
对同一 DI 令牌提供替代类
-
使用值提供者进行动态配置
技术要求
对于本章的食谱,请确保你的设置已按照 'Angular-Cookbook-2E' GitHub 仓库中的 '技术要求' 完成。有关设置详细信息,请访问:github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/docs/technical-requirements.md。本章的起始代码位于 github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/start/apps/chapter03。
使用 Angular DI 令牌
在这个食谱中,你将学习如何创建基本的 DI 令牌。我们将为常规 TypeScript 类创建它,以便使用 DI 作为 Angular 服务。在我们的应用程序中有一个名为 Jokes 的类,它通过手动创建该类的新实例在 AppComponent 中使用。这使得我们的代码紧密耦合且难以测试,因为 AppComponent 类直接使用 Jokes 类。
换句话说,当运行 App 组件的测试时,我们现在依赖于 Jokes 类,如果该类中发生任何变化,我们的测试将失败。由于 Angular 专注于 DI 和 服务,我们将使用 DI 令牌来使用 Jokes 类作为 Angular 服务。我们将使用 InjectionToken 方法创建 DI 令牌,然后使用 @Inject 装饰器来使我们能够在服务中使用该类。
准备工作
我们将要工作的应用位于克隆的仓库中的 start/apps/chapter03/ng-di-token:
-
在你的代码编辑器中打开代码仓库。
-
打开终端,导航到代码仓库目录,并运行以下命令以启动项目:
npm run serve ng-di-token这应该会在新浏览器标签页中打开应用,你应该会看到以下内容:
图 3.1:在 http://localhost:4200 上运行的 ng-di-token 应用
现在我们已经运行了应用,我们可以继续进行食谱的步骤。
如何操作...
我们目前拥有的应用程序向一个从 TypeScript 类 Jokes 中检索到的随机用户显示问候消息。我们通过在 AppComponent 类中使用语句 jokes = new Jokes(); 创建 Jokes 类的实例。然而,Angular 有一种内置的方式使用类作为服务通过依赖注入(DI)。所以,我们不会将其作为类使用,而是将其作为 Angular 服务使用 DI。我们将首先为我们的 Jokes 类创建一个 InjectionToken,然后将其注入到 AppComponent 类中。按照以下步骤进行操作:
-
我们将在
jokes.class.ts文件中创建一个InjectionToken。我们将命名令牌为'Jokes',使用一个新的InjectionToken实例。最后,我们将从这个文件中导出这个令牌:import { InjectionToken } from '@angular/core'; export const JOKES = new InjectionToken('Jokes', { providedIn: 'root', factory: () => new Jokes(), }); class Jokes {...} export default Jokes; -
现在,我们将使用
@angular/core包中的inject方法和jokes.class.ts文件中的JOKES令牌来使用该类,如下所示:import { Component, **inject**, OnInit } from '@angular/core'; **import** **{** **JOKES** **}** **from****'./classes/jokes.class'****;** import { IJoke } from './interfaces/joke.interface'; @Component({...}) export class AppComponent implements OnInit { joke!: IJoke; **jokes =** **inject****(****JOKES****);** ... }
就这样。你应该看到应用程序与之前一样工作。唯一的区别是,我们不是手动实例化 Jokes 类的实例,而是依赖于注入令牌来实例化它。这不仅带来了无需创建实例的便利,而且如果 Jokes 类通过 Angular DI 使用其他类作为依赖项,并且其中任何一个缺失,我们将会得到适当的错误来修复问题。因此,我们有一个更健壮的服务和组件架构,这确保在应用程序运行/构建之前满足依赖项。现在我们知道了配方,让我们更详细地看看它是如何工作的。
它是如何工作的…
Angular 不识别常规 TypeScript 类作为可注入项。然而,我们可以创建自己的注入令牌,并使用 @angular/core 包中的 inject 方法在需要的地方注入相关的类和值。Angular 在幕后识别这些令牌并找到它们的对应定义,这通常是以 factory 函数的形式。请注意,我们在令牌定义中使用 providedIn: 'root'。这意味着在整个应用程序中只有一个类的实例。
相关内容
-
Angular 中的 DI (
angular.io/guide/dependency-injection) -
InjectionToken文档 (angular.io/api/core/InjectionToken)
可选依赖
当你在 Angular 应用程序中使用或配置一个可能存在或不存在或尚未提供的依赖项时,Angular 中的可选依赖项非常强大。在这个配方中,我们将学习如何使用 @Optional 装饰器在组件和服务中配置可选依赖项。我们将与 LoggerService 一起工作,确保如果组件尚未提供 LoggerService,它们不会崩溃。
准备工作
我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter03/ng-optional-dependencies目录下:
-
在你的代码编辑器中打开代码仓库。
-
打开终端,导航到代码仓库目录,并运行以下命令来启动项目:
npm run serve ng-optional-dependencies这应该在新的浏览器标签页中打开应用程序,你应该会看到以下内容:
图 3.2:ng-optional-dependencies 应用程序在 http://localhost:4200 上运行
现在我们已经运行了应用程序,我们可以继续进行下一步骤。
如何做到这一点…
我们将有一个包含LoggerService的应用程序,该服务通过providedIn: 'root'作为其可注入配置提供。我们将看到当我们没有在任何地方提供此服务时会发生什么。然后,我们将使用@Optional装饰器识别和修复问题。按照以下步骤操作:
-
首先,让我们运行应用程序,输入一个新的版本号,
0.0.1,然后点击提交按钮。这将导致日志通过
LoggerService保存到localStorage中。打开Chrome 开发者工具,导航到应用程序,选择本地存储,然后点击http://localhost:4200。你会看到带有日志值的键vc_logs_ng_od,如下所示:图 3.3:日志被保存在 http://localhost:4200 的 localStorage 中
-
让我们在
logger.service.ts文件中尝试移除为LoggerService提供的@Injectable装饰器中的配置。更改应如下所示:import { Injectable } from '@angular/core'; **// <-- remove the above import** import { Logger } from '../interfaces/logger'; @Injectable(**{****//<-- remove this object** **providedIn: 'root'** **})** export class LoggerService implements Logger { ... }这将导致 Angular 无法识别它,并在控制台抛出错误,如下所示:
图 3.4:一个反映 Angular 无法识别 LoggerService 错误的错误
-
我们现在可以使用
@Optional装饰器将依赖项标记为可选。让我们从@angular/core包中导入它,并在vc-logs.component.ts文件的VcLogsComponent构造函数中使用装饰器,如下所示:import { Component, OnInit, Input, OnChanges, SimpleChanges**,** **Optional** } from '@angular/core'; ... export class VcLogsComponent implements OnInit { ... **constructor****(****@Optional****()** **private** **logger: LoggerService****) {** **this****.****logs** **=** **this****.****logger****?.****retrieveLogs****() || [];** **}** ... }太好了!现在,如果你刷新应用程序并查看控制台,应该会有不同的错误。太棒了,有进展!
图 3.5 显示,我们有一个新的错误,因为我们正在尝试在
ngOnChanges方法内部调用this.logger.log()语句。图 3.5:一个详细说明
this.logger现在是基本为空的错误 -
为了解决这个问题,我们可以选择完全不记录任何日志,或者如果未提供
LoggerService,则回退到console.*方法。回退到console.*方法的代码如下:... export class VcLogsComponent implements OnInit { ... constructor(@Optional() private loggerService: LoggerService) { this.logs = this.logger?.retrieveLogs() || []; } **get****log****() {** **return****this****.****logger****?.****log****.****bind****(****this****.****logger****) ||** **console****.****log****;** **}** ... -
让我们也更新
ngOnChanges块以使用此日志(获取器)函数:... export class VcLogsComponent implements OnInit { ... constructor(@Optional() private logger: LoggerService) { } get log() {} ngOnChanges(changes: SimpleChanges) { const currValue = changes['vName'].currentValue; let message; if (changes['vName'].isFirstChange()) { message = `initial version is ${currValue.trim()}`; if (!this.logs.length) { **this****.****log****(message);** this.logs.push(message); } } else { message = `version changed to ${currValue.trim()}`; **this****.****log****(message);** this.logs.push(message); } } ... -
现在,如果你更新版本并点击提交,你应该会在控制台上看到日志,如下所示:
图 3.6:当未提供 LoggerService 时,日志作为回退在控制台上的打印
太好了!我们已经完成了食谱,一切看起来都很棒。请参考下一节了解它是如何工作的。
工作原理
@Optional装饰器是@angular/core包中的一个特殊装饰器,它允许你将一个依赖项标记为可选。在幕后,当在具有依赖项的类的构造函数方法中使用时,如果依赖项不存在或未提供给应用程序,Angular 将提供值为null。由于我们从LoggerService类的@Injectable()装饰器中移除了配置对象,它不会在 Angular 中提供用于 DI。因此,我们的@Optional()装饰器在注入时将其设置为null,不会导致 Angular 抛出图 3.4中显示的NullInjectorError。在步骤 4中,我们在组件的类VcLogsComponent中创建了一个log获取器函数,这样我们就可以在服务提供时使用LoggerService的log方法;否则使用console.log。然后,在接下来的步骤中,我们只需使用我们创建的log方法。如果你回到logger.service.ts文件并将服务作为providedIn: 'root'再次提供,你现在将看不到任何控制台日志,并且会看到现在应用程序正在使用服务,即使用localStorage的LoggerService。
参考以下内容
-
Angular 中的可选依赖项(
angular.io/guide/dependency-injection#optional-dependencies) -
Angular 中的分层注入器(
angular.io/guide/hierarchical-dependency-injection)
使用providedIn创建单例服务
在本食谱中,你将学习如何确保你的 Angular 服务作为单例使用的几个技巧。这意味着在整个应用程序中,你的服务将只有一个实例。我们将使用一些技术,包括providedIn: 'root'语句,通过使用@Optional()和@SkipSelf()装饰器确保在整个应用程序中只提供一次服务。
准备工作
我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter03/ng-singleton-service内:
-
在你的代码编辑器中打开代码仓库。
-
打开终端,导航到代码仓库目录,并运行以下命令以启动项目:
npm run serve ng-singleton-service这应该在新的浏览器标签页中打开应用程序,你应该看到以下内容:
图 3.7:运行在 http://localhost:4200 上的 ng-singleton-service 应用程序
现在我们已经启动了应用程序,我们可以继续进行下一步的步骤。
如何操作
该应用程序的问题在于,如果你添加或删除任何通知,页眉中铃铛图标上的计数不会改变。这是因为我们在AppModule和HomeModule类中提供了多个NotificationsService实例。请参考以下步骤以确保应用程序中只有一个服务实例:
-
我们将使用
providedIn: 'root'为NotificationService来告诉 Angular 它只应在根模块中提供,并且在整个应用中只有一个实例。所以,让我们去notifications.service.ts文件,并在@Injectable装饰器参数中传递providedIn: 'root',如下所示:import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; @Injectable(**{** **providedIn****:** **'root'** **}**) export class NotificationsService { ... }太好了!现在,即使你刷新并尝试添加或删除通知,你仍然会看到标题中的计数没有变化。“但是为什么这样,Ahsan?” 好吧,我很高兴你问了。这是因为我们仍然在
AppModule和HomeModule类中提供了这个服务。 -
首先,让我们从
app.module.ts中的providers数组中移除NotificationsService,如下面的代码块中突出显示:... import { NotificationsButtonComponent } from './components/notifications-button/notifications-button.component'; **import** **{** **NotificationsService** **}** **from** './services/notifications.service'**;** // <-- Remove the import above @NgModule({ declarations: [... ], imports: [...], providers: [ **NotificationsService** **// <-- Remove this** ], bootstrap: [AppComponent] }) export class AppModule { } -
现在,我们将从
home.module.ts中移除NotificationsService,如下面的代码块中突出显示:... **import** **{** **NotificationsService** **}** **from****'../services/notifications.service'****;** **// <-- Remove the import above** @NgModule({ declarations: [...], imports: [...], providers: [ **NotificationsService****// <-- Remove this** ] }) export class HomeModule { }太棒了!现在,你应该能够看到标题中的计数根据你是否添加/删除通知而改变。然而,如果有人不小心在另一个懒加载的模块中错误地提供了它,会发生什么呢?
-
让我们把
NotificationsService放回home.module.ts文件中:... **import** **{** **NotificationsService** **}** **from****'../services/notifications.service'****;** @NgModule({ declarations: [HomeComponent, NotificationsManagerComponent], imports: [CommonModule, HomeRoutingModule], providers: [**NotificationsService**], }) export class HomeModule {}哗啦!我们在控制台或编译时间都没有任何错误。然而,我们有一个问题,那就是标题中的计数没有更新。那么,我们如何提醒开发者他们犯了这样的错误呢?
-
为了提醒开发者关于潜在的重复提供者,我们将在我们的
NotificationsService中使用来自@angular/core包的@SkipSelf装饰器,并抛出一个错误来通知并修改NotificationsService,如下所示:import { Injectable, **SkipSelf** } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; @Injectable({ providedIn: 'root', }) export class NotificationsService { ... **constructor****(****@SkipSelf****() existingService:** **NotificationsService****) {** **if** **(existingService) {** **throw****Error****(** **'The service has already been provided in the** **app.** **Avoid providing it again in child modules'** **);** **}** **}** ... }在完成前一步后,你会注意到我们有一个问题,那就是我们未能向我们的应用提供
NotificationsService。你应该在控制台中看到以下内容:图 3.8:一个详细说明 NotificationsService 无法注入到 NotificationsService 的错误
原因是
NotificationsService现在成为了它自己的依赖。这行不通,因为它还没有被 Angular 解析。为了解决这个问题,我们将在下一步中使用@Optional()装饰器。 -
好吧——现在,我们将在
notifications.service.ts中使用@Optional()装饰器,它位于构造函数中的依赖项旁边,与@SkipSelf装饰器一起。代码应该如下所示:import { Injectable**,** **Optional**, SkipSelf } from '@angular/core'; ... export class NotificationsService { ... constructor(**@Optional****()** @SkipSelf() existingService: NotificationsService) { if (existingService) { throw Error ('The service has already been provided in the app. Avoid providing it again in child modules'); } } ... }我们现在已经解决了
NotificationsService->NotificationsService依赖问题。你应该在控制台中看到NotificationsService被多次提供的正确错误,如下所示:图 3.9:一个详细说明 NotificationsService 已经在应用中提供的错误
-
现在,我们将安全地从
home.module.ts文件中的providers数组中移除提供的NotificationsService,正如步骤 3中所示,并检查应用是否正常工作。
哗!我们现在使用providedIn策略有一个单例服务。在下一节中,让我们讨论它是如何工作的。
它是如何工作的
每当我们尝试在某个地方注入一个服务时,默认情况下,它会尝试在注入服务的相关模块中寻找服务。当我们使用 providedIn: 'root' 来声明一个服务时,无论服务在哪里注入,Angular 都知道它必须在根作用域中找到服务定义,而不是尝试在功能模块或其他地方寻找。
然而,你必须确保整个应用中只提供一次服务。如果你在多个模块中提供它,即使使用 providedIn: 'root',你也会有多个服务实例。为了避免在多个模块或应用中的多个位置提供服务,我们可以在服务的构造函数中使用 @SkipSelf() 装饰器和 @Optional() 装饰器来检查服务是否已经在应用中提供。
参见
- Angular 中的分层依赖注入 (
angular.io/guide/hierarchical-dependency-injection)
使用 forRoot() 创建单例服务
在这个菜谱中,你将学习如何使用 ModuleWithProviders 和 forRoot() 语句来确保你的 Angular 服务在整个应用中以单例的形式使用。我们将从一个具有多个 NotificationsService 实例的应用开始,并实现必要的代码以确保我们最终在我们的应用中获得单个服务实例。
准备工作
我们将要工作的应用位于 start/apps/chapter03/ng-singleton-service-forroot,在克隆的仓库内:
-
在你的代码编辑器中打开代码仓库。
-
打开终端,导航到代码仓库目录,并运行以下命令来运行项目:
npm run serve ng-singleton-service-forroot这应该在新的浏览器标签页中打开应用,你应该看到以下内容:
图 3.10:运行在 http://localhost:4200 的 ng-singleton-service-forroot 应用
现在我们已经运行了应用,在下一节中,我们可以继续进行菜谱的步骤。
如何操作
为了确保你只使用 forRoot 方法在应用中有一个单例服务,你需要理解 ModuleWithProviders 和 static forRoot() 方法是如何创建和实现的。执行以下步骤:
-
首先,我们要确保服务有自己的模块。在许多 Angular 应用中,你可能会看到
CoreModule,其中提供了服务(假设我们没有使用providedIn: 'root'语法的原因)。为了开始,我们将使用以下命令从项目根目录创建一个名为ServicesModule的模块:cd start && nx g m services --project ng-singleton-service-forroot -
让我们在
services.module.ts文件中的ServicesModule类内创建一个静态方法forRoot()。我们将命名该方法为forRoot,并返回一个包含NotificationsService(在providers数组中提供)的ModuleWithProviders对象,如下所示:import { **ModuleWithProviders****,** NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; **import** **{** **NotificationsService** **}** **from****'./notifications.service'****;** @NgModule({ declarations: [], imports: [CommonModule], }) export class ServicesModule { **static****forRoot****():** **ModuleWithProviders****<****ServicesModule****> {** **return** **{** **ngModule****:** **ServicesModule****,** **providers****: [****NotificationsService****],** **};** **}** } -
现在,我们将从
app.module.ts文件的providers数组中移除NotificationsService,并在app.module.ts文件中包含ServicesModule。特别是,我们将使用forRoot()方法在imports数组中添加ServicesModule,如下面的代码块所示。这是因为它将
ServicesModule及其提供者注入到AppModule中,例如,提供NotificationsService,如下所示:... **import** **{** **NotificationsService** **}** **from** **'./services/notifications.service'****;** **// <-- Remove the import above** **import** **{** **ServicesModule** **}** **from****'./services/services.module'****;** @NgModule({ declarations: [...], imports: [ ..., **ServicesModule****.****forRoot****()** ], providers: [ **NotificationsService**// <-- **Remove this** ], bootstrap: [AppComponent] }) export class AppModule { }你会注意到,在添加/删除通知时,标题中的计数仍然没有改变。这是因为我们仍在
home.module.ts文件中提供NotificationsService。 -
我们将从
home.module.ts文件的providers数组中移除NotificationsService,如下所示:... **import** **{** **NotificationsService** **}** **from****'../services/notifications.service'****;** **// <-- Remove the above import** **import** **{** **ServicesModule** **}** **from****'../services/services.module'****;** @NgModule({ declarations: [HomeComponent, NotificationsManagerComponent], imports: [CommonModule, HomeRoutingModule, **ServicesModule**], providers: [ **NotificationsService****// <-- Remove this** ], }) export class HomeModule {}
干得好。现在我们已经完成了这个食谱,在下一节中,让我们讨论它是如何工作的。
工作原理
ModuleWithProviders充当NgModule的包装器,将其与providers数组捆绑在一起。它用于配置NgModule及其提供者,确保当模块在其他地方导入时,它也带来了其提供者。在我们的ServicesModule中,我们创建了一个返回ModuleWithProviders的forRoot方法。它包括我们的NotificationsService,这使得我们可以在整个应用中拥有这个服务的单个实例,避免了在ServicesModule的providers数组中提供NotificationsService并将其导入到各个模块时通常出现的多个实例。因此,为了确保单个实例,ServicesModule应该使用ModuleWithProviders方法导入,而不是标准方式。这就是为什么在使用ModuleWithProviders方法时,我们不按常规方式导入ServicesModule,如下所示:
@NgModule({
...
imports: [..., **ServicesModule**],
})
相反,我们使用forRoot方法导入它,这确保了NotificationService在整个应用中只被提供一次,如下所示:
@NgModule({
...
imports: [..., **ServicesModule****.****forRoot****()**],
})
现在你已经了解了这个食谱的工作原理,请查看下一节以获取一些有用的链接。
参见
-
ModuleWithProvidersAngular 文档(angular.io/api/core/ModuleWithProviders) -
ModuleWithProviders迁移文档(angular.io/guide/migration-module-with-providers%20)
针对相同的 DI 令牌提供备用类
在这个食谱中,你将学习如何使用别名类提供者向应用提供两个不同的服务。这在复杂的应用程序中非常有用,其中你需要为某些组件/模块缩小服务的/类的实现,即针对相同的 DI 令牌提供不同的类以实现多态行为。此外,别名在组件/服务单元测试中使用,以模拟依赖服务的实际实现,这样我们就不依赖于它了。
准备工作
我们将要工作的应用位于 start/apps/chapter03/ng-aliased-class-providers,在克隆的仓库内:
-
在您的代码编辑器中打开代码仓库。
-
打开终端,导航到代码仓库目录,并运行以下命令以启动项目:
npm run serve ng-aliased-class-providers这应该在新的浏览器标签页中打开应用,您应该看到如图 3.11 所示的应用。
-
点击 登录为管理员 按钮。您应该看到以下截图类似的内容:
图 3.11:运行在 http://localhost:4200 的 ng-aliased-class-providers 应用
现在我们已经运行了应用,让我们进入下一节,按照菜谱的步骤进行操作。
如何做到这一点
我们有一个名为 BucketComponent 的独立组件,它被用于管理员和员工组件中。BucketComponent 在幕后使用 BucketService 来添加/删除桶中的项目。对于员工,我们将通过提供一个 aliased 类提供者和名为 EmployeeBucketService 的 BucketService 替换来限制删除项目的权限。这样我们就可以覆盖删除项目功能。按照以下步骤开始:
-
我们将首先在
employee文件夹内创建EmployeeBucketService。从工作区根目录运行以下命令:cd start && nx g service employee/employee-bucket --project ng-aliased-class-providers -
接下来,我们将从
BucketService扩展EmployeeBucketService,以便我们能够获得BucketService类的所有优点。让我们按照以下方式修改代码:import { Injectable } from '@angular/core'; **import** **{** **BucketService** **}** **from****'../bucket/bucket.service'****;** ... export class EmployeeBucketService**extends****BucketService** { constructor() { **super****();** } } -
现在,我们将重写
removeItem方法以显示一个简单的alert,说明员工不能从桶中删除项目。您的代码应如下所示:... export class EmployeeBucketService extends BucketService { constructor() {...} **override****removeItem****() {** **alert****(****'Employees can not delete items'****);** **}** } -
作为最后一步,我们需要将
aliased类提供者提供给employee.component.ts文件,如下所示:... **import** **{** **BucketService** **}** **from****'../bucket/bucket.service'****;** **import** **{** **EmployeeBucketService** **}** **from****'****./employee-bucket.service'****;** @Component({ ... **providers****: [{** **provide****:** **BucketService****,** **useClass****:** **EmployeeBucketService****,** **}],** }) export class EmployeeComponent {}
如果您现在以员工身份登录应用并尝试删除项目,您将看到一个弹出窗口,上面写着“员工不能删除项目”。
它是如何工作的
当我们将服务注入到组件中时,Angular 会尝试在我们提供的依赖项的组件/模块中找到该组件,然后通过移动组件和模块的层次结构来查找。我们的 BucketService 在 'root' 中提供,使用 providedIn: 'root' 语法。因此,它位于层次结构的顶部。然而,由于在这个菜谱中,我们在 EmployeeComponent 类中对 DI 令牌 BucketService 使用了一个 aliased 类提供者,当 Angular 为 EmployeeComponent 查找 BucketService 时,它会快速找到 EmployeeComponent 中的 EmployeeBucketService 对应的令牌并停止搜索——即,它不会到达'root'以获取实际的 BucketService。这正是我们想要的。
参见
-
Angular 中的依赖注入 (
angular.io/guide/dependency-injection) -
Angular 中的分层注入器 (
angular.io/guide/hierarchical-dependency-injection)
使用值提供者的动态配置
在这个菜谱中,你将学习如何在 Angular 中使用值提供者来为你的应用提供常量和配置值。我们将从上一个菜谱中的相同示例开始,该示例涉及EmployeeComponent和AdminComponent使用BucketComponent来管理一个水果桶。我们将通过使用值提供者的配置来限制EmployeeComponent删除桶中项目的权限。因此,员工甚至看不到删除按钮。
准备工作
我们将要工作的应用位于克隆的仓库中的start/apps/chapter03/ng-value-providers目录下:
-
在你的代码编辑器中打开代码仓库。
-
打开终端,导航到代码仓库目录,并运行以下命令来运行项目:
npm run serve ng-value-providers这应该会在新浏览器标签页中打开应用,你应该会看到如图 3.12 所示的界面。
-
点击登录为管理员按钮。你应该会看到如下截图:
图 3.12:运行在 http://localhost:4200 的 ng-value-providers 应用
现在你看到应用正在运行,让我们看看下一步要遵循的菜谱。
如何操作
我们有一个名为BucketComponent的独立组件,它被用于管理员和员工组件中。BucketComponent在幕后使用BucketService来添加/删除桶中的项目。对于员工,我们将通过提供值提供者来限制删除项目的权限。这样我们就可以覆盖删除项目的功能。让我们从以下步骤开始:
-
首先,我们将在项目根目录下创建一个新的文件,命名为
app-config.ts,并在其中使用InjectionToken创建值提供者。代码应如下所示:import { InjectionToken } from '@angular/core'; export interface IAppConfig { canDeleteItems: boolean; } export const APP_CONFIG = new InjectionToken<IAppConfig>('APP_CONFIG'); export const AppConfig: IAppConfig = { canDeleteItems: true, };在我们实际上可以在
BucketComponent中使用这个AppConfig常量之前,我们需要将其注册到AppModule中,这样当我们向BucketComponent注入这个值时,提供者的值才能被解析。 -
让我们在
app.module.ts文件中添加提供者,如下所示:... **import** **{** **AppConfig****,** **APP_CONFIG** **}** **from****'./app-config'****;** @NgModule({ declarations: [AppComponent], imports: [...], **providers****: [{** **provide****:** **APP_CONFIG****,** **useValue****:** **AppConfig****,** **}],** bootstrap: [AppComponent], }) export class AppModule {}现在,应用已经知道了
AppConfig常量。下一步是在BucketComponent中使用这个常量。 -
我们将使用
inject方法将其注入到BucketComponent类中,在bucket/bucket.component.ts文件中,如下所示:import { Component, i**nject,** OnInit } from '@angular/core'; ... **import** **{** **APP_CONFIG** **}** **from****'../app-config'****;** ... export class BucketComponent implements OnInit { bucketService = inject(BucketService); **appConfig =** **inject****(****APP_CONFIG****);** ... }太好了!常量已经注入。现在,如果你刷新应用,你不应该收到任何错误。下一步是使用
BucketComponent中的config的canDeleteItems属性来显示/隐藏delete按钮。 -
现在,我们将在
bucket/bucket.component.html文件中添加一个*ngIf指令,仅在appConfig.canDeleteItems的值为true时显示delete按钮。更新具有fruites__item__delete-icon类的元素,如下所示:... <div *******ngIf****=****"appConfig.canDeleteItems"** class="fruites__item__delete-icon" (click)="deleteFromBucket(item)"> <div class="material-symbols-outlined">delete</div> </div> ...您可以通过将
AppConfig常量的canDeleteItems属性设置为false来测试是否一切正常。请注意,删除按钮现在对管理员和员工都不可见。测试完成后,请将canDeleteItems的值再次设置为true。现在,我们已经设置好了一切。让我们添加一个新的常量,以便我们只为员工隐藏删除按钮。
-
现在,让我们创建一个员工配置对象。我们将在
employee文件夹内创建一个employee.config.ts文件,并将以下代码添加到其中:import { IAppConfig } from '../app-config'; export const EmployeeConfig: IAppConfig = { canDeleteItems: false, }; -
现在,我们将这个
EmployeeConfig常量提供给EmployeeComponent,用于相同的APP_CONFIG注入令牌。employee.component.ts文件中的代码应如下所示:... **import** **{** **APP_CONFIG** **}** **from****'../app-config'****;** **import** **{** **EmployeeConfig** **}** **from****'./employee.config'****;** @Component({ ... **providers****: [{** **provide****:** **APP_CONFIG****,** **useValue****:** **EmployeeConfig****,** **}],** }) export class EmployeeComponent {}
完成了!配方现在完整了。您可以看到,删除按钮对管理员可见,但对员工隐藏。这一切都归功于值提供者的魔力。
工作原理
当我们将令牌注入到组件中时,Angular 会尝试在注入位置找到令牌的解析值,然后通过移动组件和模块的层次结构向上查找。我们在EmployeeComponent类中针对APP_CONFIG令牌提供了EmployeeConfig对象。当 Angular 尝试解析BucketComponent的令牌值时,它会在EmployeeComponent内部找到EmployeeConfig,而不是在AppModule中作为AppConfig提供的值。因此,Angular 会立即停止,不会到达AppModule。这真是太神奇了,因为我们现在可以拥有全局配置,并覆盖嵌套模块/组件内的配置。
参见
-
Angular 中的依赖注入(
angular.io/guide/dependency-injection) -
Angular 中的分层注入器(
angular.io/guide/hierarchical-dependency-injection)
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/AngularCookbook2e
第四章:理解 Angular 动画
在本章中,你将学习如何在 Angular 中处理动画。你将了解多状态动画、交错动画和关键帧动画,以及如何在 Angular 应用程序中实现切换路由的动画以及如何有条件地禁用动画。
以下是我们将在本章中涵盖的菜谱:
-
创建你的第一个两种状态的 Angular 动画
-
与多状态动画一起工作
-
使用关键帧创建复杂的 Angular 动画
-
使用交错动画在 Angular 中动画化列表
-
Angular 中的顺序动画与并行动画
-
Angular 中的路由动画
-
有条件地禁用 Angular 动画
技术要求
对于本章的菜谱,确保你的设置已按照'Angular-Cookbook-2E' GitHub 仓库中的'技术要求'完成。有关设置详细信息,请访问:github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/docs/technical-requirements.md。本章的起始代码位于github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/start/apps/chapter04。
创建你的第一个两种状态的 Angular 动画
在这个菜谱中,你将创建一个基本的两种状态 Angular 动画,它具有淡入淡出效果。我们将从一个已经内置了 UI 的 Angular 应用程序开始。然后,我们将使用 Angular 动画在应用程序中启用动画,并逐步创建我们的第一个动画。
准备工作
我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter04/ng-basic-animation:
-
在你的代码编辑器中打开代码仓库。
-
打开终端,导航到代码仓库目录,并运行以下命令以运行项目:
npm run serve ng-basic-animation这应该在新的浏览器标签页中打开应用程序,你应该看到以下内容:
图 4.1:ng-basic-animation 应用程序在 http://localhost:4200 上运行
现在我们已经运行了应用程序,我们将继续到菜谱的步骤。
如何做到这一点...
我们有一个完全没有配置 Angular 动画的应用程序。我们将使用 Angular 动画为卡片创建淡入效果。让我们继续以下步骤:
-
首先,我们将从
@angular/platform-browser/animations包中导入provideAnimations函数到我们的src/app/app.config.ts文件中,这样我们就可以在应用程序中使用动画了。我们将在providers数组中使用它,如下所示:... **import** **{ provideAnimations }** **from****'@angular/platform-browser/animations'****;** export const appConfig: ApplicationConfig = { providers: [ provideRouter(appRoutes, withEnabledBlockingInitialNavigation())**,** **provideAnimations****()** ], }; -
现在修改
app.component.ts文件,添加以下动画:... **import** **{ trigger, transition, style, animate }** **from****'@angular/animations'****;** @Component({ ... imports: [CommonModule, FbCardComponent, TwitterCardComponent], **animations****: [** **trigger****(****'fadeInOut'****, [** **transition****(****':enter'****, [** **style****({** **opacity****:** **0****,** **scale****:** **0.85** **}),** **animate****(****'200ms 100ms'****,** **style****({** **opacity****:** **1****,** **scale****:** **1** **})),** **]),** **transition****(****':leave'****, [** **style****({** **opacity****:** **1****,** **scale****:** **1** **}),** **animate****(****'100ms'****,** **style****({** **opacity****:** **0****,** **scale****:** **0.85** **})),** **]),** **]),** **],** }) ... -
最后,在
app.component.html文件中为两个卡片添加fadeInOut动画,如下所示:<!-- Toolbar --> <div class="toolbar" role="banner">...</div> <main class="content" role="main"> <div class="type-picker mb-8">...</div> <ng-container [ngSwitch]="selectedCardType"> <app-fb-card **[@****fadeInOut****]** *ngSwitchCase="'facebook'"></app-fb-card> <app-twitter-card **[@****fadeInOut****]** *ngSwitchCase="'twitter'"></app-twitter-card> </ng-container> </main>}
太好了!你现在已经为卡片实现了基本的淡入 <=> 淡出动画。简单,但很漂亮!参考下一节了解菜谱的工作原理。
它是如何工作的...
Angular 提供了自己的动画 API,允许您对 CSS 过渡支持的任何属性进行动画处理。好处是您可以根据所需条件动态配置它们。如果我们要在 CSS 中创建相同的行为,我们必须执行以下操作:
-
我们需要在 CSS 中创建以下关键帧:
@keyframes fadeIn { 0% { opacity: 0; transform: scale(0.85); } 100% { opacity: 1; transform: scale(1); } } @keyframes fadeOut { 0% { opacity: 1; transform: scale(1); } 100% { opacity: 0; transform: scale(0.85); } }创建应用这些动画的 CSS 类:
/* For elements that are entering */ .fade-in { animation: fadeIn 200ms 100ms forwards; } /* For elements that are leaving */ .fade-out { animation: fadeOut 100ms forwards; } -
然后,我们必须在每个元素上添加和删除 CSS 类,因为它们在 DOM 中 创建 和 移除 时。然而,Angular 使用内置的
:enter和:leave状态来处理此过程,这些状态分别在项目被添加到或从 DOM 中移除时触发。
即使有上述步骤,当处理此类动画时,仍可能出现更多挑战。多亏了 Angular 动画,我们可以更快地实现这些功能。
我们首先使用 trigger 函数注册名为 fadeInOut 的动画。然后我们使用 transition 函数注册 :enter 和 :leave 过渡。最后,我们使用 style 和 animate 函数定义了这些过渡的样式和动画。请注意,我们在 :enter 过渡中使用 '200ms 100ms..'。200ms 是过渡的持续时间,而 100ms 是延迟。我们添加这个延迟,以便在我们可以移动到下一个要显示的卡的 :enter 过渡之前,等待之前显示的卡的 :leave 过渡完成。让我们深入了解我们使用的每个函数:
-
trigger函数:trigger函数用于在 Angular 中定义动画触发器。第一个参数是触发器的名称,它将在模板中使用以将动画绑定到特定元素。第二个参数是状态和过渡定义的数组。例如,trigger('fadeInOut', [...])注册了一个名为'fadeInOut'的动画触发器。 -
:enter和:leave过渡::enter是void => *状态转换的别名。它表示一个元素被添加到 DOM 中的状态。:leave是* => void状态转换的别名。它表示一个元素被从 DOM 中移除的状态。这些别名对于元素进入或离开视图时常见的动画非常有用,例如淡入和淡出动画。 -
transition函数:transition函数用于定义过渡将发生的状态。它接受两个参数:第一个是一个字符串,定义了状态更改表达式;第二个是一个数组,当过渡被触发时将运行动画步骤。例如,transition(':enter', [...])定义了当元素进入视图时将执行的动画步骤。 -
style:style函数用于定义在动画中将使用的 CSS 样式集。它接受一个对象,其中键是 CSS 属性,值是这些属性的期望值。例如,style({ opacity: 0, scale: 0.85 })将透明度设置为0并将元素缩小到原始大小的 85%。 -
animate:animate函数用于定义样式之间的转换的计时和缓动。第一个参数是一个字符串,定义了持续时间、延迟和缓动曲线。例如,200ms 100ms意味着动画将持续 200 毫秒,并在延迟 100 毫秒后开始。第二个参数是动画将过渡到的样式或一组样式。例如,animate('200ms 100ms', style({ opacity: 1, scale: 1 }))将在等待 100 毫秒后,在 300 毫秒内将元素过渡到全透明度和原始大小。
参见
-
Angular 中的动画:
angular.io/guide/animations -
使用示例解释 Angular 动画:
www.freecodecamp.org/news/angular-animations-explained-with-examples/
多状态动画的制作
在这个食谱中,我们将处理包含多个状态的 Angular 动画。这意味着我们将为特定项目处理超过两个状态。我们也将使用相同的 Facebook 和 Twitter 卡片示例来完成这个食谱。
我们将为两张卡片配置以下状态:
-
卡片出现在屏幕上的状态。
-
用户悬停在卡片上时的状态。
-
用户将鼠标从卡片移开时的状态。
准备工作
我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter04/ng-multi-state-animations目录内:
-
在你的代码编辑器中打开代码仓库。
-
打开终端,导航到代码仓库目录,并运行以下命令以运行项目:
npm run serve ng-multi-state-animations这应该会在新浏览器标签页中打开应用程序,你应该会看到以下内容:
图 4.2:ng-multi-state-animations 应用程序在 http://localhost:4200 上运行
现在我们已经在本地上运行了应用程序,接下来让我们看看下一节中食谱的步骤。
如何做到这一点…
我们已经有一个工作中的应用程序,它为社交卡片制作了一个动画。当你点击 Facebook 或 Twitter 按钮时,你会看到相应的卡片从左到右出现滑动动画。为了保持食谱简单,我们将实现两个更多状态和两个动画,用于当用户将鼠标光标移至卡片上以及当用户从卡片移开时。让我们在以下步骤中添加相关代码:
-
我们首先在
components/fb-card/fb-card.component.ts文件中的FbCardComponent上添加两个@HostListener实例,一个用于卡片的mouseenter事件,另一个用于mouseleave事件。我们将这些状态分别命名为hovered和active。代码应如下所示:import { Component, **HostListener**} from '@angular/core'; ... @Component({...}) export class FbCardComponent { **cardState****:** **'active'** **|** **'hovered'** **=** **'active'****;** **@****HostListener****(****'mouseenter'****)** **onMouseEnter****() {** **this****.****cardState** **=** **'hovered'****;** **}** **@****HostListener****(****'mouseleave'****)** **onMouseLeave****() {** **this****.****cardState** **=** **'active'****;** **}** } -
现在,我们将在
components/twitter-card/twitter-card-component.ts文件中为TwitterCardComponent做同样的事情。代码应如下所示:import { Component, **HostListener**} from '@angular/core'; ... @Component({...}) export class TwitterCardComponent { **cardState****:** **'active'** **|** **'hovered'** **=** **'active'****;** **@****HostListener****(****'mouseenter'****)** **onMouseEnter****() {** **this****.****cardState** **=** **'hovered'****;** **}** **@****HostListener****(****'mouseleave'****)** **onMouseLeave****() {** **this****.****cardState** **=** **'active'****;** **}** }到目前为止,应该没有视觉变化,因为我们只是更新了
cardState变量以拥有悬停和活动状态。我们还没有为动画定义过渡。 -
现在,我们将定义当用户的鼠标进入卡片时我们的状态,即
mouseenter事件。这个状态被称为hovered,在animation.ts文件中应如下所示:... export const cardAnimation = trigger('cardAnimation', [ state('active', style({ color: 'rgb(51, 51, 51)', backgroundColor: 'white' })), **state****(****'hovered'****,** **style****({** **transform****:** **'scale3d(1.05, 1.05, 1.05)'****,** **backgroundColor****:** **'#333'****,** **color****:** **'white'** **})),** transition('void => active', [...]), ])如果你现在刷新应用,点击 Facebook 或 Twitter 按钮,并将鼠标悬停在卡片上,你会看到卡片的 UI 发生变化。这是因为我们将状态更改为
hovered。然而,在样式更改之间还没有动画效果。让我们在下一步添加动画。 -
我们现在将在
animations.ts文件中添加active => hovered过渡,这样我们就可以从active状态平滑地导航到hovered状态:... export const cardAnimation = trigger('cardAnimation', [ state('active', style(...)), state('hovered', style(...)), transition('void => active', [...]), **transition****(****'active => hovered'****, [** **animate****(****'0.3s 0s ease-out'****,** **style****({** **transform****:** **'scale3d(1.05, 1.05, 1.05)'****,** **backgroundColor****:** **'#333'****,** **color****:** **'white'** **}))** **]),** ])如果你刷新应用,现在你应该会看到
mouseenter事件上的平滑过渡。 -
最后,我们将添加最终的过渡,
hovered => active,这样当用户离开卡片时,我们可以通过平滑动画恢复到活动状态。代码应如下所示:... export const cardAnimation = trigger('cardAnimation', [ state('active', style(...)), state('hovered', style(...)), transition('void => active', [...]), transition('active => hovered', [...]), **transition****(****'hovered => active'****, [** **animate****(****'0.3s 0s ease-out'****,** **style****({** **transform****:** **'scale3d(1, 1, 1)'****,** **color****:** **'rgb(51, 51, 51)'****,** **backgroundColor****:** **'white'** **}))** **]),** ])
哇!你现在知道如何使用 Angular 动画 在单个元素上实现不同的状态和不同的动画。
它是如何工作的…
Angular 使用触发器来理解动画处于哪种状态。一个示例语法如下:
<div [@animationTriggerName]="expression">...</div>;
expression 可以是一个有效的 JavaScript 表达式,并计算为状态的名称。在我们的例子中,我们将其绑定到 cardState 属性,它包含 active 或 hovered。因此,我们最终为我们的卡片得到三个过渡:
-
void => active(当元素被添加到 DOM 中并渲染时) -
active => hovered(当卡片上的mouseenter事件触发时) -
hovered => active(当卡片上的mouseleave事件触发时)
参见
使用关键帧创建复杂的 Angular 动画
由于你已经从之前的菜谱中了解了 Angular 动画,你可能正在想,“这很简单。”好吧,现在是时候提升你的动画技能了。在这个菜谱中,你将使用 keyframes 创建一个复杂的 Angular 动画,以开始编写一些高级动画。
准备工作
我们将要工作的应用位于克隆的仓库中的start/apps/chapter04/ng-animations-keyframes:
-
在你的代码编辑器中打开代码仓库。
-
打开终端,导航到代码仓库目录,并运行以下命令来启动项目:
npm run serve ng-animations-keyframes这应该在新浏览器标签页中打开应用,你应该看到以下内容:
图 4.3:ng-animations-keyframes 应用在 http://localhost:4200 上运行
现在我们已经在本地上运行了应用,让我们在下一节中查看食谱的步骤。
如何做到这一点...
我们现在有一个应用,它有一个单一的过渡,即void => active,当元素进入 DOM 时触发。目前,动画非常简单。我们将使用keyframes函数来构建一个复杂动画:
-
让我们从向
animations.ts文件添加@angular/animations中的keyframes函数开始,如下所示:import { ..., keyframes } from '@angular/animations'; ... -
现在,我们将把
void => transition的单样式动画转换为使用关键帧,如下所示:... export const cardAnimation = trigger('cardAnimation', [ ..., transition('void => *', [ style({ // ← Remove this style transform: 'translateX(-200px)', opacity: 0 }), animate('0.2s ease', **keyframes****([** **style****({** **transform****:** **'translateX(-200px)'****,** **offset****:** **0** **}),** **style****({** **transform****:** **'translateX(0)'****,** **offset****:** **1** **})** **]))** ]), ])注意,之前我们不得不定义初始样式和
animate函数。现在我们可以在按时间顺序的keyframes函数内部定义相同的样式。如果你现在刷新应用并尝试,你仍然会看到之前的相同动画。但现在我们使用的是keyframes。 -
最后,让我们开始添加一些复杂的动画。让我们通过在
style的transform属性中添加scale3d到offset: 0来以缩小的卡片开始动画。我们还将增加动画时间为1.5s:... export const cardAnimation = trigger('cardAnimation', [ transition('void => active', [ animate('**1.5s** ease', keyframes([ style({ transform: 'translateX(-200px) **scale3d(0.4,0.4,0.4)**', offset: 0 }), style({...}) ])) ]), ])你现在应该看到卡片动画从一个小的卡片开始,它从左侧滑行并移动到右侧,逐渐增大。
-
现在我们将实现一个类似“之字形”的动画来代替卡片出现的滑动动画。让我们向
keyframes数组添加以下关键帧元素,以给我们的动画添加一个颠簸效果:... export const cardAnimation = trigger('cardAnimation', [ transition('void => *', [ animate('1.5s 0s ease', keyframes([ style({ transform: 'translateX(-200px) scale3d(0.4,0.4,0.4)', offset: 0 }), **style****({** **transform****:** **'translateX(0px) rotate(-90deg)** **scale3d(0.5, 0.5, 0.5)'****,** **offset****:** **0.25** **}),** **style****({** **transform****:** **'translateX(-200px) rotate(90deg)** **translateY(0) scale3d(0.6, 0.6, 0.6)'****,** **offset****:** **0.5** **}),** style({ transform: 'translateX(0)', offset: 1 }) ])) ]), ])如果你刷新应用并点击任何按钮,你应该看到卡片向右墙壁弹跳,然后撞到卡片的左侧墙壁,最后返回到正常状态:
图 4.4:卡片向右弹跳然后撞到左侧墙壁
-
作为最后一步,我们在卡片返回原始位置之前将其顺时针旋转。为此,我们将使用
offset: 0.75,结合rotate函数和一些额外的角度。代码应该如下所示:... export const cardAnimation = trigger('cardAnimation', [ transition('void => *', [ animate('1.5s 0s ease', keyframes([ style({...}), style({...}), style({...}), **style****({** **transform****:** **'translateX(-100px) rotate(135deg)** **translateY(0) scale3d(0.6, 0.6, 0.6)'****,** **offset****:** **0.75** **}),** style({...}) ])) ]), ])
太棒了!你现在知道如何使用keyframes函数在 Angular 中实现复杂动画。你将在下一节中看到它是如何工作的。
它是如何工作的...
对于 Angular 中的复杂动画,@angular/animations包中的keyframes函数是提供动画整个旅程中不同时间偏移的绝佳方式。我们可以使用style函数来定义偏移量,它返回一个类型为AnimationStyleMetadata的对象。style函数接受标记作为输入,这些标记是一个键值对,其中键是字符串类型,值可以是字符串或数字。本质上,一个标记代表一个 CSS 属性。这允许我们传递offset属性,如菜谱中所示,其值介于0和1之间,反映了动画从0%到100%的时间。因此,我们可以为不同的偏移量定义不同的样式来创建高级动画。
参见
-
Angular 中的动画:
angular.io/guide/animations -
使用示例解释 Angular 动画:
www.freecodecamp.org/news/angular-animations-explained-with-examples/
使用交错动画在 Angular 中动画化列表
无论你今天构建什么类型的 Web 应用程序,你很可能会在其中实现某种类型的列表。为了使这些列表更加出色,为什么不给它们实现优雅的动画呢?在这个菜谱中,你将学习如何使用交错动画在 Angular 中动画化列表。
准备工作
我们将要工作的应用程序位于克隆的仓库start/apps/chapter04/ng-animating-lists中:
-
在你的代码编辑器中打开代码仓库。
-
打开终端,导航到代码仓库目录,并运行以下命令以提供项目:
npm run serve ng-animating-lists这应该在新的浏览器标签页中打开应用程序,你应该看到以下内容:
图 4.5:ng-animating-lists 应用程序在 http://localhost:4200 上运行
现在我们已经在本地运行了应用程序,让我们在下一节中查看菜谱的步骤。
如何做…
我们现在有一个应用程序,其中包含一个桶项目列表。我们需要使用交错动画来动画化这个列表。我们将一步步完成这个操作。我很兴奋——你呢?
太棒了。我们将按照以下步骤进行菜谱:
-
首先,让我们在
src/app/app.config.ts文件中使用 Angular 的provideAnimations函数提供 Angular 动画,如下所示:... **import** **{ provideAnimations }** **from****'@angular/platform-browser/animations'****;** import { appRoutes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ provideRouter(appRoutes, withEnabledBlockingInitialNavigation()), **provideAnimations****()** ], -
现在,在项目的
app文件夹中创建一个名为animations.ts的文件,并将以下代码添加到注册一个名为listItemAnimation的基本列表项动画中:import { trigger, style, animate, transition } from '@angular/animations'; export const ANIMATIONS = { LIST_ITEM_ANIMATION: trigger('listItemAnimation', [ transition(':enter', [ style({ opacity: 0 }), animate('0.5s ease', style({ opacity: 1 })), ]), transition(':leave', [ style({ opacity: 1 }), animate('0.5s ease', style({ opacity: 0 })), ]), ]), }; -
现在,我们将动画添加到
app/bucket/bucket.component.ts文件中的BucketComponent,如下所示:... **import** **{** **ANIMATIONS** **}** **from****'../../../constants/animations'****;** @Component({ ... **animations****: [****ANIMATIONS****.****LIST_ITEM_ANIMATION****]** })由于我们已经将动画导入到组件中,现在我们可以在模板中使用它了。
-
让我们在
bucket.component.html文件中将动画添加到html元素,带有fruits__item类,如下所示:<div class="fruits__item" *ngFor="let item of bucket" **@****listItemAnimation**> ... </div>如果你现在刷新应用程序并向桶列表中添加一个项目,你应该看到它以淡入效果出现。如果你删除一个项目,你应该看到它以动画消失。
-
我们现在将修改
LIST_ITEM_ANIMATION以使用stagger函数。这是因为交错动画应用于列表,而不是列表项。首先,我们需要从@angular/animations中导入stagger函数。然后我们需要从触发器数组中删除所有内容,然后创建一个如下所示的列表通配符转换:import { ..., **stagger,** } from '@angular/animations'; export const ANIMATIONS = { LIST_ITEM_ANIMATION: trigger('listItemAnimation', [ **transition****(****'* <=> *'****, [** **// we'll add more code here** **]),** ]), }; -
现在,我们将添加一个查询,用于当列表中添加新项目时的情况。这里我们将使用交错动画。代码应该如下所示:
import { trigger, style, animate, transition, stagger, **query** } from '@angular/animations'; export const ANIMATIONS = { LIST_ITEM_ANIMATION: trigger('listItemAnimation', [ transition('* <=> *', [ **query****(** **':enter'****,** **[** **style****({** **opacity****:** **0** **}),** **stagger****(****100****, [** **animate****(****'0.5s ease'****,** **style****({** **opacity****:** **1** **}))** **]),** **],** **{** **optional****:** **true** **}** **),** ]), ]), }; -
现在我们将添加一个查询,用于当项目离开列表时的情况。代码应该如下所示:
export const ANIMATIONS = { LIST_ITEM_ANIMATION: trigger('listItemAnimation', [ transition('* <=> *', [ query(':enter', [...], { optional: true } ), **query****(** **':leave'****,** **[** **style****({** **opacity****:** **1** **}),** **animate****(****'0.5s ease'****,** **style****({** **opacity****:** **0** **}))** **],** **{** **optional****:** **true** **}** **),** ]), ]), }; -
现在我们可以将动画应用到列表本身。按照以下方式更新
bucket.component.html,将动画放置在具有fruits类的div上:... <div class="fruits" *ngIf="$bucket | async as bucket" **[@****listItemAnimation****]=****"bucket.length"**> ... </div> ...注意,我们将
[@ listAnimationlistItemAnimation]属性绑定到bucket.length。这将确保动画在桶的长度改变时触发,即当向桶中添加或从桶中删除项目时。这是由于('* <=> *')转换。
太棒了!你现在知道如何在 Angular 中实现列表的交错动画。你将在下一节中看到它是如何工作的。
它是如何工作的…
交错动画仅在query函数内部工作,并且应用于列表(包含项目)而不是项目本身。为了搜索或查询项目,我们首先使用query函数。然后我们使用stagger函数来定义在动画开始之前我们想要多少毫秒的交错。我们还在stagger函数中使用动画来定义查询中找到的每个元素的动画。请注意,我们在:enter查询和:leave查询中都使用了{ optional: true }。我们这样做是因为如果没有项目要动画化,无论是应用程序启动时还是所有项目都被删除时,Angular 都会抛出一个错误,因为它找不到可以动画化的内容。
参见
-
Angular 中的动画:
angular.io/guide/animations -
Angular 动画交错文档:
angular.io/api/animations/stagger
Angular 中的顺序与并行动画
在这个菜谱中,你将学习如何在 Angular 中按顺序运行动画与并行运行动画。这在我们需要在开始下一个动画之前完成一个动画,或者同时运行动画时非常有用。
准备工作
我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter04/ng-seq-parallel-animations目录下:
-
在你的代码编辑器中打开代码仓库。
-
打开终端,导航到代码仓库目录,并运行以下命令以启动项目:
npm run serve ng-seq-parallel-animations这应该在新的浏览器标签页中打开应用程序,你应该看到以下内容:
图 4.6:ng-seq-parallel-animations 应用程序在 http://localhost:4200 上运行
现在我们已经在本地运行了应用程序,让我们在下一节中查看菜谱的步骤。
如何操作...
我们有一个应用程序,显示我们在前面的菜谱中使用的两个社交卡片。一个用于 Facebook,一个用于 Twitter。
为了同时按顺序和并行运行两张卡片上的动画,我们将使用query函数来按顺序配置动画。然后我们将使用group函数来并行运行它们。让我们开始吧:
-
首先,让我们在
src/app/app.config.ts文件中使用 Angular 的provideAnimations函数提供 Angular 动画,如下所示:... **import** **{ provideAnimations }** **from****'@angular/platform-browser/animations'****;** export const appConfig: ApplicationConfig = { providers: [ provideRouter(appRoutes, withEnabledBlockingInitialNavigation())**,** **provideAnimations****()** ], }; -
我们将创建一个简单的包装转换来处理卡片进入和离开 DOM。之后,我们将处理当当前卡片离开视图时如何一起触发它们。在
app文件夹中创建一个名为animations.ts的新文件。将以下代码添加到其中:import { trigger, style, transition, animate, query, group, keyframes } from '@angular/animations'; const duration = '1.5s'; export const cardAnimation = trigger('cardAnimation', [ transition('* <=> *', [ // more code here later ]), ]); -
现在,让我们为卡片离开视图时添加一个查询。在
transition数组内部,按照以下方式添加以下query:export const cardAnimation = trigger('cardAnimation', [ transition('* <=> *', [ **query****(** **':leave'****, [** **style****({** **transform****:** **'translateX(0)'****,** **opacity****:** **1** **}),** **animate****(** **`****${duration}** **ease`****,** **style****({** **transform****:** **'translateX(100%)'****,** **})** **),** **animate****(** **`****${duration}** **ease`****,** **style****({** **opacity****:** **0****,** **})** **),** **],** **{** **optional****:** **true** **}** **)** ]), ]); -
我们将在
app.component.ts文件中导入动画并将其添加到animations数组中,如下所示:... **import** **{ cardAnimation }** **from****'./animation'****;** ... @Component({ ... **animations****: [cardAnimation],** ... }) export class AppComponent {...} -
现在,我们将更新
app.component.html文件以使用具有card-container类的元素的动画。按照以下方式更新文件:... <main> … <div class="card-container relative h-[600px] w-full overflow-hidden py-4" **[@****cardAnimation****]=****"selectedCardType"**> ... </div> </main>你应该可以通过点击 Facebook 和 Twitter 按钮看到动画了。也就是说,卡片从屏幕右侧的位置滑动到当前位置。然而,它看起来并不漂亮。
-
让我们为下一张卡片进入视图时添加另一个查询。我们首先确保卡片在开始进入 DOM 时是
不可见的。按照以下方式替换animations.ts文件中的动画:... export const cardAnimation = trigger('cardAnimation', [ transition('* <=> *', [ **query****(****':enter'****, [** **style****({** **opacity****:** **0** **}),** **]),** query( ':leave', [ ... ]), ]); -
现在为要进入屏幕的卡片添加第二个
query。我们将确保它从左侧滑入并缓慢变得可见。按照以下方式更新animations.ts文件:... export const cardAnimation = trigger('cardAnimation', [ transition('* <=> *', [ query(':enter', [...]), query(':leave', [...]), **query****(** **':enter'****, [** **style****({** **transform****:** **'translateX(-100%)'****,** **opacity****:** **0****,** **}),** **animate****(** **`****${duration}** **ease`****,** **style****({** **transform****:** **'translateX(0)'****,** **opacity****:** **1****,** **})** **),** **],** **{** **optional****:** **true** **}** **),** ]), ]);你会注意到动画现在正在工作。然而,它们真的很慢。也就是说,在当前卡片离开屏幕后,下一张卡片需要很长时间才能出现。这是因为它们都是按顺序运行的。
-
我们可以将第二个和第三个查询包裹在
group函数中,以并行运行它们。按照以下方式更新animations.ts文件中的代码:import { ..., **group** } from '@angular/animations'; export const cardAnimation = trigger('cardAnimation', [ transition('* <=> *', [ query(':enter', [...]), **group****([** query( ':leave', [...], { optional: true }), query(':enter', [...], { optional: true }), **]),** ]), ]);
然后,砰!你现在可以看到动画正在并行运行,并且在执行:enter转换之前不会等待:leave转换完成。
它是如何工作的...
在 Angular 中,动画默认按顺序运行。如果一个转换有多个步骤,即style和animate用法,动画将按顺序运行。group函数使我们能够并行运行动画。对于这个菜谱,我们希望:enter和:leave转换同时运行,所以我们把它们组合起来并行运行。
参见
-
Angular 中的顺序动画与并行动画对比 (
angular.io/guide/complex-animation-sequences#sequential-vs-parallel-animation) -
Angular 动画
sequence函数 (angular.io/api/animations/sequence)
Angular 中的路由动画
在这个菜谱中,你将学习如何在 Angular 中实现路由动画。你将学习如何通过将过渡状态名称作为数据属性传递给路由来配置路由动画。你还将学习如何使用RouterOutlet API 获取过渡名称并将其应用于要执行的动画。我们将实现一些 3D 过渡,这将很有趣!
准备中
我们将要工作的应用位于克隆的仓库中的start/apps/chapter04/ng-route-animations:
-
在你的代码编辑器中打开代码仓库。
-
打开终端,导航到代码仓库目录,并运行以下命令以运行项目:
npm run serve ng-route-animations这应该在新的浏览器标签页中打开应用,你应该看到以下内容:
图 4.7:ng-route-animations 应用在 http://localhost:4200 上运行
现在我们已经在本地上运行了应用,让我们在下一节中查看菜谱的步骤。
如何做到这一点…
我们现在有一个简单的应用,包含两个懒加载的路由。这些路由是针对主页和关于页面的,我们现在将开始配置应用的动画:
-
首先,让我们在
src/app/app.config.ts文件中使用 Angular 的provideAnimations函数提供 Angular 动画,如下所示:... **import** **{ provideAnimations }** **from****'@angular/platform-browser/animations'****;** export const appConfig: ApplicationConfig = { providers: [ provideRouter(appRoutes, withEnabledBlockingInitialNavigation())**,** **provideAnimations****()** ], }; -
我们现在将在
app文件夹内创建一个名为animations.ts的新文件。让我们将以下代码放入animations.ts文件中,以注册一个基本的触发器来处理从路由到其他所有路由的动画:import {trigger, transition } from '@angular/animations'; export const ROUTE_ANIMATION = trigger('routeAnimation', [ transition('* <=> *', [ // states and transitions to be added here ]) ]); -
我们现在将为动画注册一些查询和基本状态。让我们按照以下方式在
transition函数的数组中添加以下项:import { trigger, transition, **style**, **query** } from '@angular/animations'; **const** **optional = {** **optional****:** **true** **};** export const ROUTE_ANIMATION = trigger('routeAnimation', [ transition('* <=> *', [ **style****({** **position****:** **'relative'****,** **perspective****:** **'1000px'** **}),** **query****(** **':enter, :leave'****,** **[****style****({** **position****:** **'absolute'****,** **width****:** **'100%'** **})],** **optional** **),** ]), ]);好的!我们已经注册了从路由到其他所有路由的
routeAnimation触发器。现在,让我们在路由中提供这些过渡状态。 -
我们可以使用每个路由的唯一标识符来提供过渡状态。有许多方法可以做到这一点,但最简单的方法是在
app.routes.ts文件中使用路由配置中的data属性来提供它们,如下所示:export const appRoutes: Route[] = [ ..., { path: 'home', **data****: {** **transitionState****:** **'HomePage'** **},** loadComponent: () => import('./home/home.component').then( (m) => m.HomeComponent), }, { path: 'about', **data****: {** **transitionState****:** **'****AboutPage'** **},** loadComponent: () => import('./about/about.component').then( (m) => m.AboutComponent), }, ]; -
现在,我们需要在
app.component.html文件中提供这个transitionState属性,从当前路由到路由动画触发器。为此,在app.component.ts文件中创建一个@ViewChild属性。这个ViewChild将针对app.component.html模板中的<router-outlet>元素。这样我们就可以获取当前路由的data和提供的transitionState值。app.component.ts文件中的代码应该如下所示:import { CommonModule } from '@angular/common'; import { Component, **ViewChild**} from '@angular/core'; import { RouterModule, **RouterOutlet**} from '@angular/router'; export class AppComponent { **@****ViewChild****(****RouterOutlet****) routerOutlet!:** **RouterOutlet****;** } -
我们还将从
animations.ts文件中导入ROUTE_ANIMATION到app.component.ts中,如下所示:... **import** **{** **ROUTE_ANIMATION** **}** **from****'./animations'****;** @Component({ selector: "app-root", templateUrl: "./app.component.html", styleUrls: ["./app.component.scss"], **animations****: [** **ROUTE_ANIMATION** **]** })我们现在将创建一个名为
getRouteAnimationState的方法,该方法将获取当前路由的数据和transitionState值,并返回它。这个函数将在app.component.html中使用。按照以下方式修改你的app.component.ts代码:... @Component({ ... }) export class AppComponent { @ViewChild(RouterOutlet) routerOutlet!: RouterOutlet; **getRouteAnimationState****() {** **return** **(** **this****.****routerOutlet** **&&** **this****.****routerOutlet****.****activatedRouteData** **&&** **this****.****routerOutlet****.****activatedRouteData****[****'transitionState'****]** **);** **}** } -
最后,让我们在
app.component.html中使用getRouteAnimationState方法和@routeAnimation触发器,以便我们可以看到动画的播放效果:... <div class="content" role="main"> <div class="router-container" **[@****routeAnimation****]=****"getRouteAnimationState()"**> <router-outlet></router-outlet> </div> </div> -
现在我们已经设置好了一切,让我们最终确定动画。我们将为路由离开视图添加一个查询。更新
animations.ts文件如下:import { trigger, style, transition, query, **animate, keyframes** } from '@angular/animations'; ... export const ROUTE_ANIMATION = trigger('routeAnimation', [ transition('* <=> *', [ style({ position: 'relative', perspective: '1000px' }), query( ':enter, :leave', [...] ,optional), **query****(** **':leave'****, [** **animate****(** **'1s ease-in'****,** **keyframes****([** **style****({** **opacity****:** **1****,** **offset****:** **0****,** **transform****:** **'rotateY(0) translateX(0)** **translateZ(0)'****,** **}),** **style****({** **offset****:** **0.25****,** **transform****:** **'rotateY(45deg) translateX(25%)** **translateZ(100px) translateY(5%)'****,** **}),** **style****({** **offset****:** **0.5****,** **transform****:** **'rotateY(90deg) translateX(75%)** **translateZ(400px) translateY(10%)'****,** **}),** **style****({** **offset****:** **0.75****,** **transform****:** **'rotateY(135deg) translateX(75%)** **translateZ(800px) translateY(15%)'****,** **}),** **style****({** **opacity****:** **0****,** **offset****:** **1****,** **transform****:** **'rotateY(180deg) translateX(0)** **translateZ(1200px) translateY(25%)'****,** **}),** **])** **),** **],** **optional** **),** ]), ]);如果你在不同路由之间导航,你会注意到离开路由在进入路由之后以动画形式退出。让我们也为进入路由添加动画。
-
我们将为进入视图的路由添加动画。更新
animations.ts如下:... export const ROUTE_ANIMATION = trigger('routeAnimation', [ transition('* <=> *', [ style({ position: 'relative', perspective: '1000px' }), query(':enter, :leave', ...), query(':leave', ...), **query****(** **':enter'****, [** **animate****(** **'****1s ease-out'****,** **keyframes****([** **style****({** **opacity****:** **0****,** **offset****:** **0****,** **transform****:** **'rotateY(180deg) translateX(25%)** **translateZ(1200px)'****,** **}),** **style****({** **offset****:** **0.25****,** **transform****:** **'rotateY(225deg) translateX(-25%) translateZ(1200px)'****,** **}),** **style****({** **offset****:** **0.5****,** **transform****:** **'rotateY(270deg) translateX(-50%) translateZ(400px)'****,** **}),** **style****({** **offset****:** **0.75****,** **transform****:** **'rotateY(315deg) translateX(-50%) translateZ(25px)'****,** **}),** **style****({** **opacity****:** **1****,** **offset****:** **1****,** **transform****:** **'rotateY(360deg) translateX(0) translateZ(0)'****,** **}),** **])** **),** **],** **optional** **),** ]), ]);如果你查看导航时的动画路由,你会注意到进入路由立即出现,然后我们看到离开路由的动画,之后我们看到进入路由的动画。让我们将进入和离开动画组合在一起,以并行运行它们。
-
更新
animations.ts如下,以并行运行进入和离开路由的动画:import {..., **group** } from '@angular/animations'; export const ROUTE_ANIMATION = trigger('routeAnimation', [ transition('* <=> *', [ style({ position: 'relative', perspective: '1000px' }), query( ':enter, :leave', ...), **group****([** query( ':leave', [...], optional ), query( ':enter', [...], optional), **]),** ]), ]);
哇!刷新应用,看看魔法。现在,当你从主页导航到关于页面,反之亦然时,你应该会看到进入和离开路由的 3D 动画。在 Angular 中使用关键帧和动画,你可以做到的事情没有极限。
它是如何工作的…
在animations.ts文件中,我们首先定义了一个名为routeAnimation的动画触发器。然后我们确保默认情况下,触发器分配的 HTML 元素具有position: 'relative'样式:
transition('* <=> *', [
**style****({**
**position****:** **'relative'**
**}),**
...
])
然后,我们按照所述,使用:enter和:leave将样式position: 'absolute'应用到子元素上:
query(':enter, :leave', [
style({
**position****:** **'absolute'****,**
width: '100%'
})
], {optional: true}),
这样确保这些元素,即要加载的路由,具有position: 'absolute'样式和全宽使用width: '100%',以便它们可以相互叠加。你可以通过注释其中任何一个样式来随意尝试,看看会发生什么(尽管这样做有风险!)。
然后,我们定义了我们的路由转换,作为两个动画的组合,第一个是query :leave,第二个是query :enter。对于离开视图的路由,我们通过动画将opacity设置为0,而对于进入视图的路由,我们也通过动画将opacity设置为1。请注意,Angular 动画的动画是按顺序运行的:
query(':leave', [
...
], {optional: true}),
query(':enter', [
...
], {optional: true}),
你会注意到在我们的代码中,我们正在使用 keyframes 函数进行动画。对于离开路由,"keyframes 函数" 从 opacity 1 开始,最初没有任何变换。然后它结束于 opacity 0,但变换设置为 'rotateY(180deg) translateX(0) translateZ(1200px) translateY(25%)'。这与进入路由相反。
最后,我们使用 group 函数将离开和进入动画一起包裹起来,这样它们可以并行运行而不是按顺序运行。这使得进入路由在离开路由消失时进入。
参见
-
Angular 中的动画 (
angular.io/guide/animations) -
Angular 路由过渡动画 (
angular.io/guide/route-animations)
有条件地禁用 Angular 动画
在这个菜谱中,你将学习如何在 Angular 中有条件地禁用动画。这在各种情况下都很有用,例如在特定设备上禁用动画。
小贴士:使用 ngx-device-detector 来识别你的 Angular App 是否在手机、平板电脑等设备上运行(一个不再是秘密的秘密……我创建了它!)
不再是秘密的推广,在这个菜谱中,我们将禁用应用程序中员工的动画,考虑到我们目前只对管理员推出动画。
准备工作
我们将要工作的 App 位于克隆的仓库中的 start/apps/chapter04/ng-disable-animations 目录内:
-
在你的代码编辑器中打开代码仓库。
-
打开终端,导航到代码仓库目录,并运行以下命令来运行项目:
npm run serve ng-disable-animations这应该在新的浏览器标签页中打开 App。以管理员身份登录,添加一些 bucket 项目,你应该会看到以下内容:
图 4.8:ng-disable-animations App 在 http://localhost:4200 上运行
现在我们已经让 App 运行起来,我们将继续进行下一步骤。
如何做到这一点…
我们有一个已经配置了一些 Angular 动画的 App。你会注意到管理员和员工页面都启用了动画。我们将使用一个 config 来禁用员工页面的动画。接下来,我们按照以下步骤继续操作:
-
首先,我们将在
src/app/app.config.ts文件中为我们的IEmployeeConfig接口和EMPLOYEE_CONFIG变量添加一个名为disableAnimations的新属性,如下所示:... export interface IEmployeeConfig { canDeleteItems: boolean; **disableAnimations****: boolean;** } ... export const employeeConfig: IEmployeeConfig = { canDeleteItems: true, **disableAnimations****:** **false** }; ...如果你保存文件,TypeScript 将在控制台中开始抛出错误,因为我们还需要在
employee.config.ts文件中添加相同的disableAnimations属性。 -
按照以下方式更新
src/app/employee/employee.config.ts文件:import { IEmployeeConfig } from '../app.config'; export const employeeConfig: IEmployeeConfig = { canDeleteItems: false, **disableAnimations****:** **true** }; -
最后,在
bucket.component.ts中添加一个HostBinding来根据配置禁用 bucket 组件中的动画。更新bucket/bucket.component.ts文件如下:import { CommonModule } from '@angular/common'; import { Component, inject, OnInit, **HostBinding** } from '@angular/core'; ... @Component({...}) export class BucketComponent implements OnInit { ... fruits: string[] = Object.values(Fruit); **@****HostBinding****(****'@.disabled'****)** **animationsDisabled =** **this****.****appConfig****.****disableAnimations****;** ngOnInit(): void {...} ... }
太好了!如果你现在刷新应用程序并查看 Admin 页面,你会看到动画正在工作。如果你转到 Employee 页面,你会看到那里的动画被禁用了。魔法!查看下一节以了解配方是如何工作的。
它是如何工作的...
Angular 提供了一种使用 [@.disabled] 绑定来禁用动画的方法。你可以在模板的任何位置放置一个表达式,该表达式评估一个布尔值。在这种情况下,所有在其嵌套 HTML 树中应用的子动画都将被禁用。我们有一个应用程序级别的配置,该配置通过 EmployeeConfig 对象在 employee 组件中被覆盖。因此,我们首先在 IAppConfig 接口中创建了一个 disableAnimations 属性。此接口由 app-config.ts 文件中的 AppConfig 变量和 employee.config.ts 文件中的 employeeConfig 变量使用。正如你所见,我们将 disabledAnimations 的值设置为 false,用于 app.config.ts 中定义的配置,以及 true,用于 employee.config.ts 中定义的配置。然后,我们在 BucketComponent 类中使用 @HostBinding() 装饰器,通过将其值分配给提供的配置的 disabledAnimations 属性。由于 AdminComponent 类通过 EMPLOYEE_CONFIG 标记获取 app.config.ts 中定义的配置,而 EmployeeComponent 类通过 EMPLOYEE_CONFIG 标记获取 employee.config.ts 中定义的配置,因此这些组件的动画分别被启用和禁用。
参考以下内容
-
Angular 中的动画:
angular.io/guide/animations -
使用示例解释 Angular 动画:
www.freecodecamp.org/news/angular-animations-explained-with-examples/
在 Discord 上了解更多
要加入此书的 Discord 社区——在那里你可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/AngularCookbook2e
第五章:Angular 和 RxJS – 强强联合
Angular 和 RxJS 构成了一个令人惊叹的强大组合。通过结合这些技术,你可以在 Angular 应用程序中以响应式的方式处理数据,处理流,并在 Angular 应用程序中实现复杂的企业逻辑。这正是本章将要介绍的内容。
本章我们将介绍以下食谱:
-
在 Angular 中使用 RxJS 进行顺序和并行 HTTP 请求
-
监听多个可观察流
-
取消订阅流以避免内存泄漏
-
使用 Angular 的
async管道自动取消订阅流 -
使用
map操作符转换数据 -
使用
switchMap和debounceTime操作符与自动完成功能以获得更好的性能 -
创建自定义 RxJS 操作符
-
使用 RxJS 重试失败的 HTTP 请求
技术要求
对于本章的食谱,确保你的设置已按照 'Angular-Cookbook-2E' GitHub 仓库中的 'Technical Requirements'(技术要求)完成。有关设置详情,请访问:github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/docs/technical-requirements.md。本章的起始代码位于 github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/start/apps/chapter05。
在 Angular 中使用 RxJS 进行顺序和并行 HTTP 请求
在这个食谱中,你将学习如何使用不同的 RxJS 操作符在 Angular 应用程序中进行顺序和并行 HTTP 请求。我们将使用著名的星球大战 API(swapi)获取一些数据以在 UI 上显示。
准备工作
我们将要工作的应用程序位于克隆的仓库中的 start/apps/chapter05/rx-seq-parallel-http:
-
在你的代码编辑器中打开代码仓库。
-
打开终端,导航到代码仓库目录,并运行以下命令以运行项目:
npm run serve rx-seq-parallel-http这应该会在新浏览器标签页中打开应用程序,你应该会看到以下内容:
图 5.1:运行在 http://localhost:4200 的 rx-seq-parallel-http 应用程序
现在我们已经运行了应用程序,我们将继续进行食谱的步骤。
如何做到这一点…
我们有一个使用 Star Wars API (swapi) 从星球大战中获取人物及其参与的电影的 Angular 应用程序。所有这些操作都通过大量的 HTTP 请求完成,而我们的代码到目前为止完全是垃圾。这是因为我们首先显示加载器,但在我们检索所有数据之前就将其隐藏了。此外,如果你不断刷新页面,你会看到电影序列每次都会改变。因此,我们看到了 UI 跳动很多。我们希望的方法是先获取人物,然后获取所有电影,然后隐藏加载器。我们将使用 RxJS 实现这种方法。让我们开始吧:
-
首先,我们将避免使用
setTimeout函数,而是依赖于在1500ms内获取到人员数据。我们更愿意将此操作移至subscribe块内部,并且也会适当地处理错误。按照以下方式更新app.component.ts中的fetchData方法:fetchData() { this.loadingData = true; this.swapi.fetchPerson('1').subscribe({ next: (person) => { this.person = person; this.person.filmObjects = []; this.person.films.forEach((filmUrl) => { this.swapi.fetchFilm(filmUrl).subscribe({ next: (film) => { this.person.filmObjects.push(film); this.loadingData = false; }, error: (err) => { console.error('Error while fetching film', err); }, }); }); }, error: (err) => { console.error('Error while fetching person', err); } }); }这存在一个潜在问题。那就是,一旦检索到第一部电影,加载器就会隐藏,因为我们把
this.loadingData设置为false。 -
现在,我们将使用
pipe方法添加mergeMap操作符,以便以后能够链式调用。目前,我们只需将添加filmObjects数组到this.person对象的代码移至mergeMap回调中。现在按照以下方式更新fetchData方法:... **import** **{ mergeMap,** **of** **}** **from****'****rxjs'****;** ... fetchData() { this.loadingData = true; this.swapi .fetchPerson('1') .pipe( **mergeMap****((person) => {** **const** **personObj = {** **...person,** **filmObjects****: [],** **};** **return** **of****(personObj);** **})** **)** .subscribe({ next: (person) => { this.person = person; this.person.films.forEach((filmUrl) => { this.swapi.fetchFilm(filmUrl).subscribe({ next: (film) => { this.person.filmObjects.push(film); this.loadingData = false; }, error: (err) => { console.error('Error while fetching film', err); }, }); }); }, error: (err) => { console.error('Error while fetching person', err); }, }); }注意到 UI 变得稍微好一些。加载器仍然会在从服务器检索到任何一部电影后立即隐藏。然而,我们希望在所有电影都检索完毕后隐藏加载器。此外,电影的顺序仍然不可预测。
-
现在,我们将使用
forkJoin函数并行地对电影进行 API 调用,并等待合并后的响应。我们这样做而不是使用of操作符,因为of操作符只是从mergeMap函数传递电影 URL。按照以下方式更新fetchData方法,并更新顶部的导入:import { **forkJoin,** mergeMap, of **(//<-- remove of)** } from 'rxjs'; ... fetchData() { this.loadingData = true; this.swapi .fetchPerson('1') .pipe( mergeMap((person) => { const personObj = { ...person, filmObjects: [], }; this.person = personObj; **return** **forkJoin****(** **this****.person.films.****map****((filmUrl) =>** **this****.swapi****.****fetchFilm****(filmUrl))** **);** }), catchError((err) => { console.error('Error while fetching films', err); alert('Could not get films. Please try again.'); return of([]); }) ) .subscribe({ next: (films) => { this.person.filmObjects = films; this.loadingData = false; }, error: (err) => { console.error('Error while fetching person', err); }, }); }
哇哦!现在如果你刷新应用,你会注意到两件事。首先,加载器只有在所有数据都检索完毕后才会停止。其次,电影的顺序总是相同(并且正确)。
现在你已经完成了食谱,让我们继续到下一部分,了解这一切是如何工作的。
它是如何工作的...
mergeMap 操作符允许我们通过从其回调中返回一个 可观察对象 来链式连接可观察对象。您可以将它想象成我们链式调用 Promise.then,但这是针对可观察对象的。一个流行的替代方案是 switchMap 操作符,它的工作方式类似于 mergeMap 操作符,但在第一次调用/执行完成之前被调用两次或更多次时,也会取消之前的调用/执行。我们首先移除了 setTimeout 函数(将这些情况放入代码中通常没有意义,因为结果在时间上并不总是可预测的),并将获取人物信息的逻辑移动到获取人物信息的 subscribe 块中。我们还使用了 of 操作符从 mergeMap 函数的回调中返回 personObject 对象。mergeMap 函数用于将可观察对象链式连接起来,在我们的上下文中,它可以链式等待一个 HTTP 调用完成,以便我们可以执行其他的调用。在 步骤 3 中,我们打算并行执行所有人物的电影的多个 HTTP 调用。我们使用 forkJoin 操作符来完成这项工作,它接受一个可观察对象的数组。在这种情况下,这些可观察对象是针对每部电影的 HTTP 调用。forkJoin 还使得等待所有并行调用完成并触发 subscribe 块的回调成为可能。forkJoin 还做的一件事是,它以与可观察对象相同的顺序提供响应的数组形式给我们。这使得响应可预测,并且我们总是在 UI 上显示相同的数据。
参见
-
捕捉点游戏—RxJS 文档 (
www.learnrxjs.io/learn-rxjs/recipes/catch-the-dot-game) -
RxJS
mergeMap操作符文档 (www.learnrxjs.io/learn-rxjs/operators/transformation/mergemap) -
RxJS
merge操作符文档 (www.learnrxjs.io/learn-rxjs/operators/combination/forkjoin)
监听多个可观察流
在这个菜谱中,我们将使用 combineLatest 操作符一次性监听多个可观察流。使用此操作符将导致输出为一个数组,合并所有流。当您希望从所有流中获取最新的输出并合并到一个订阅中时,这种方法是合适的。
准备中
我们将要工作的应用程序位于克隆的仓库中的 start/apps/chapter05/rx-multiple-streams 目录下:
-
在您的代码编辑器中打开代码仓库。
-
打开终端,导航到代码仓库目录,并运行以下命令以启动项目:
npm run serve rx-multiple-streams这应该在新的浏览器标签页中打开应用程序,您应该看到以下内容:
图 5.2:在 http://localhost:4200 上运行的 rx-multiple-streams 应用程序
现在我们已经在本地运行了应用,让我们在下一节中查看食谱的步骤。
如何做到这一点...
对于这个食谱,我们有一个显示盒子的应用。这个盒子有一个大小(宽度和高度)、边框半径、背景颜色和文本颜色。它还有四个使用Reactive Forms API 来修改所有这些因素的输入。目前,即使输入发生变化,我们也必须手动点击按钮来应用更改。如果我们能够订阅输入的变化并立即更新盒子,而不需要用户点击按钮,那会怎么样?这正是我们要在这里做的:
-
我们将首先创建一个名为
listenToInputChanges的方法。我们将创建一个我们想要工作的控件数组。更新home.component.ts的代码,如下所示:... export class HomeComponent implements OnInit { ... ngOnInit() { this.applyChanges(); } **listenToInputChanges****() {** **const****controls****:** **AbstractControl****[] = [** **this****.****boxForm****.****controls****.****size****,** **this****.****boxForm****.****controls****.****borderRadius****,** **this****.****boxForm****.****controls****.****textColor****,** **this****.****boxForm****.****controls****.****backgroundColor****,** **];** **}** ... } -
现在,我们将遍历控件,给它们赋予初始值,这样当 Observable 流被订阅时,它们就有值可以工作了。进一步更新
listenToInputChanges方法,如下所示:**import** **{ startWith }** **from****'rxjs'****;** ... export class HomeComponent implements OnInit { ... listenToInputChanges() { const controls: AbstractControl[] = [...]; controls.map((control) => control.valueChanges.pipe(**startWith****(control.value)**) ); } } -
现在,我们将用名为
boxStyles$的Observable替换boxStyles属性。然后,我们将每个表单控制的valueChanges流包裹在combineLatest操作符中,以将它们连接起来。最后,我们将连接流的输出分配给boxStyles$Observable。更新home.component.ts文件,如下所示:... import { **Observable**, **combineLatest**, startWith} from 'rxjs'; ... export class HomeComponent implements OnInit, OnDestroy { ... **boxStyles$!:** **Observable****<****BoxStyles****>;** ... listenToInputChanges() { **this****.****boxStyles$** **=** **combineLatest****(** controls.map((control) => control.valueChanges.pipe(startWith(control.value)) ); **);** **}** ... } -
现在我们将在组合流上使用
map操作符和pipe来将其映射到BoxStyle类型值。更新home/home.component.ts文件中的listenToInputChanges方法,如下所示:import { combineLatest, **map**, Observable, startWith } from 'rxjs'; export class HomeComponent implements OnInit { listenToInputChanges() { const controls: AbstractControl[] = [...]; this.boxStyles$ = combineLatest(...)**.****pipe****(** **map****(****(****[size, borderRadius, textColor,** **backgroundColor]****) =>** **{** **return** **{** **width****:** **`****${size}****px`****,** **height****:** **`****${size}****px`****,** **backgroundColor****: backgroundColor,** **color****: textColor,** **borderRadius****:** **`****${borderRadius}****px`****,** **};** **})** **);** } } -
我们需要从
home.component.ts文件中移除setBoxStyles和applyChanges方法以及applyChanges方法的用法。更新文件,如下所示:export class HomeComponent implements OnInit { ... ngOnInit() { **this****.****listenToInputChanges****();** **//← Add this call** ... **this****.****applyChanges****();** **//← Remove this call** **}** **...** **setBoxStyles****(****...****) {...}** **//← Remove this method** **applyChanges****() {...}** **//← Remove this method** ... } -
我们还需要从模板中移除
applyChanges方法的用法。从home.component.html文件中的<form>元素移除(ngSubmit)处理器,使其看起来像这样:<div class="home" [formGroup]="boxForm" **(****ngSubmit****)=****"applyChanges()"****<!--← Remove this-->** ... </div> -
我们还需要从
home.component.html模板中移除submit-btn-container元素,因为我们不再需要它了。从文件中删除以下部分:<div class="row submit-btn-container" **<!--← Remove this element -->** <button class="btn btn-primary" type="submit" (click)="applyChanges()">Change Styles</button> </div> -
现在我们可以使用
boxStyles$Observable 了,让我们在模板中使用它,即home.component.html文件,而不是boxStyles属性:... **<****div****class****=****"row"** *******ngIf****=****"boxStyles$ | async as** **boxStyles"****>** **<****div****class****=****"box"** **[****ngStyle****]=****"boxStyles"****>** **<****div****class****=****"box__text"****>** **Hello World!** **</****div****>** **</****div****>** **</****div****>** ...
哇!如果你刷新应用,你应该能看到带有默认样式的盒子出现。如果你更改了任何选项,你也会看到相应的变化。
恭喜你完成了这个食谱。你现在已经是使用combineLatest操作符处理多个流的专家了。查看下一节以了解它是如何工作的。
它是如何工作的...
Reactive Forms 的美丽之处在于,它们比常规的 ngModel 绑定或模板驱动的表单提供了更多的灵活性。对于每个表单控件,我们可以订阅其 valueChanges 可观察对象,每当输入改变时,它都会接收到一个新的值。因此,我们不需要依赖于 提交 按钮的点击,而是直接订阅每个 表单控件 的 valueChanges 属性。在常规场景中,这会导致四个不同的流对应四个输入,这意味着我们需要处理四个订阅并确保取消订阅它们。这就是 combineLatest 操作符发挥作用的地方。我们使用了 combineLatest 操作符将这四个流合并为一个,这意味着我们只需要在组件销毁时取消订阅一个流。但是,嘿!记得如果我们使用 async 管道,我们就不需要这样做吗?这正是我们做的。我们从 home.component.ts 文件中移除了订阅,并使用 pipe 方法与 map 操作符。map 操作符根据我们的需求转换数据,然后将转换后的数据返回设置到 boxStyles$ 可观察对象。最后,我们在模板中使用 async 管道订阅 boxStyles$ 可观察对象,并将其值作为 [ngStyle] 分配给我们的盒子元素。由于 valueChanges 是一个 Subject 而不是一个 ReplaySubject,我们还通过 startWith 将 valueChanges 管道化,以提供一个初始值。如果我们不使用 startWith,盒子将不会显示,除非所有输入至少手动更改一次值。试试看!
参见
-
combineLatest操作符文档(www.learnrxjs.io/learn-rxjs/operators/combination/combinelatest) -
combineLatest操作符的视觉表示(rxjs-dev.firebaseapp.com/api/index/function/combineLatest)
取消订阅流以避免内存泄漏
流式处理很有趣,它们很棒。当你完成这一章时,你会对 RxJS 和流有更多的了解。一个现实是,当不小心使用流时,会遇到一些未预见的问题。使用流时犯的最大错误之一是在不再需要它们时没有取消订阅,在这个菜谱中,你将学习如何取消订阅流以避免 Angular 应用中的内存泄漏。
准备工作
我们将要工作的应用程序位于克隆的仓库中的 start/apps/chapter05/rx-unsubscribing-streams:
-
在你的代码编辑器中打开代码仓库。
-
打开终端,导航到代码仓库目录,并运行以下命令以启动项目:
npm run serve rx-unsubscribing-streams这应该在新的浏览器标签页中打开应用程序,你应该看到以下内容:
图 5.3:在 http://localhost:4200 上运行的 rxjs-unsubscribing-streams 应用程序
现在我们已经在本地运行了应用,让我们在下一节中查看食谱的步骤。
如何做到这一点…
我们目前有一个有两个路由的应用——即主页和关于。这是为了向你展示未处理的订阅可能会在应用中引起内存泄漏。默认路由是主页,在HomeComponent类中,我们使用interval操作符函数处理一个输出数据的流:
-
点击开始流按钮,你应该会看到流正在发出值。
-
然后,通过点击页眉(右上角)的关于按钮导航到关于页面,然后返回到主页页面。
你看到什么奇怪的吗?没有?一切看起来都正常,对吧?嗯,并不完全是这样。
-
为了查看我们是否有未处理的订阅,让我们在
home.component.ts文件中的startStream方法内放置console.log——具体来说,在subscribe函数的块内,如下所示:... export class HomeComponent implements OnInit { ... startStream() { const streamSource = interval(1500); this.subscription = streamSource.subscribe((input) => { this.outputStreamData.push(input); **console****.****log****({ input });** }); } stopStream() {...} }如果你现在按照步骤 1 中提到的步骤操作,你将在控制台上看到以下输出,如图图 5.4所示:
![img/B18469_05_04.png]
图 5.4:在关于页面上间隔发出值
想要更多乐趣吗?尝试多次执行步骤 1,甚至一次都不刷新页面。你将看到的将是混乱!
-
因此,为了解决这个问题,我们将使用最简单的方法——即在用户离开路由时取消订阅流。让我们为它实现
ngOnDestroy生命周期方法,如下所示:import { Component, **OnDestroy** } from '@angular/core'; ... @Component({...}) export class HomeComponent implements **OnDestroy** { ... startStream() {...} **ngOnDestroy****() {** **this****.****stopStream****();** **}** stopStream() {...} }
太好了!如果你再次按照步骤 1 的说明操作,你会发现一旦你离开主页页面,控制台上就没有进一步的日志了,而且我们的应用现在没有未处理的流导致内存泄漏。阅读下一节以了解它是如何工作的。
它是如何工作的…
当我们创建一个Observable/stream并订阅它时,RxJS 会自动将我们提供的subscribe函数块作为处理程序添加到Observable。所以,每当Observable发出值时,我们的方法都应该被调用。有趣的部分是,Angular 不会在组件卸载或你离开路由时自动销毁那个订阅/处理程序。这是因为可观察的核心是RxJS,而不是 Angular;因此,这不是 Angular 的责任来处理它。
Angular 提供了一些生命周期方法,我们使用了OnDestroy (ngOnDestroy)方法。因此,我们使用了ngOnDestroy方法来调用stopStream方法,以便在用户离开页面时立即销毁订阅。这是可能的,因为当我们离开一个路由时,Angular 会销毁该路由,因此我们可以执行我们的stopStream方法。
还有更多…
在一个复杂的 Angular 应用中,可能会出现一个组件中有多个订阅的情况,当组件被销毁时,你希望一次性清理所有这些订阅。同样,你可能希望根据某些事件/条件来取消订阅,而不是使用 OnDestroy 生命周期。以下是一个例子,其中你手头有多个订阅,并且希望在组件销毁时一起清理它们:
startStream() {
const streamSource = interval(1500);
**const** **secondStreamSource =** **interval****(****3000****);**
**const** **fastestStreamSource =** **interval****(****500****);**
streamSource.subscribe((input) => {
this.outputStreamData.push(input);
**console****.****log****(****'first stream output'****, input);**
});
**secondStreamSource.****subscribe****(****input** **=>** **{**
**this****.****outputStreamData****.****push****(input);**
**console****.****log****(****'second stream output'****, input)**
**});**
**fastestStreamSource.****subscribe****(****input** **=>** **{**
**this****.****outputStreamData****.****push****(input);**
**console****.****log****(****'fastest stream output'****, input)**
**});**
}
stopStream() {
**// remove code from here**
}
注意,我们不再将 streamSource 中的 订阅 保存到 this.subscription 中,并且也从 stopStream 方法中移除了代码。这样做的原因是我们没有为每个订阅设置单独的属性/变量。相反,我们将有一个单独的变量来处理。让我们看看以下步骤来开始操作:
-
首先,我们在
HomeComponent类中创建一个名为isStreamActive的属性:import { Component, OnDestroy } from '@angular/core'; ... export class HomeComponent implements OnDestroy { isStreamActive = true; ... } -
现在,我们将从
rxjs/operators中导入takeWhile操作符,如下所示:import { Component, OnInit, OnDestroy } from '@angular/core'; ... import { interval, Subscription, **takeWhile** } from 'rxjs'; -
我们现在将使用
takeWhile操作符与每个流一起使用,使它们仅在isStreamActive属性设置为true时工作。由于takeWhile接受一个predicate方法,它应该看起来像这样:startStream() { ... streamSource **.****pipe****(****takeWhile****(****() =>****this****.****isStreamActive****))** .subscribe(input => {...}); secondStreamSource **.****pipe****(****takeWhile****(****() =>****this****.****isStreamActive****))** .subscribe(input => {...}); fastestStreamSource **.****pipe****(****takeWhile****(****() =>****this****.****isStreamActive****))** .subscribe(input => {...}); }如果你现在点击 开始流 按钮在 主页 上,你仍然看不到任何输出或日志,因为
isStreamActive属性仍然是 未定义 的。 -
要使流工作,我们在
startStream方法中将isStreamActive属性设置为true。代码应该看起来像这样:ngOnDestroy() { this.stopStream(); } startStream() { **isStreamActive =** **true****;** const streamSource = interval(1500); const secondStreamSource = interval(3000); const fastestStreamSource = interval(500); ... }在这一步之后,如果你现在尝试开始流并离开页面,你仍然会看到流的问题——也就是说,它们没有被取消订阅。
-
要一次性取消所有流的订阅,我们在
stopStream方法中将isStreamActive的值设置为false,如下所示:stopStream() { **this****.****isStreamActive** **=** **false****;** } -
最后,更新模板以根据
isStreamActive属性而不是subscription来处理哪个按钮被禁用。按照以下方式更新home.component.html文件:<div class="home"> <div class="buttons-container"> <button [disabled]="**isStreamActive**" class="btn btn- primary" (click)="startStream()">Start Stream</button> <button [disabled]="**!isStreamActive**" class="btn btn-dark" (click)="stopStream()">Stop Stream</button> </div> ... </div>
然后,当你在流正在发出值时离开路由,流将立即停止。哇!
参见
-
了解 RxJS 订阅 (
www.learnrxjs.io/learn-rxjs/concepts/rxjs-primer#subscription) -
takeWhile操作符文档 (www.learnrxjs.io/learn-rxjs/operators/filtering/takewhile)
使用 Angular 的异步管道自动取消订阅流
如你在前面的食谱中所学,取消订阅你订阅的流是至关重要的。如果我们有一种更简单的方法在组件销毁时取消订阅它们——也就是说,让 Angular 以某种方式处理它——会怎样?在这个食谱中,你将学习如何使用 Angular 的async管道与可观察对象直接绑定流中的数据到 Angular 模板,而不是需要在*.component.ts文件中进行订阅。
准备工作
我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter05/ng-async-pipe:
-
在你的代码编辑器中打开代码仓库。
-
打开终端,导航到代码仓库目录,并运行以下命令以运行项目:
npm run serve ng-async-pipe这应该在新的浏览器标签页中打开应用程序,你应该看到以下内容:
图 5.5:运行在 http://localhost:4200 上的 ng-async-pipe 应用程序
现在我们已经在本地上运行了应用程序,接下来让我们看看下一节中食谱的步骤。
如何做到这一点...
我们目前拥有的应用程序有三个流/可观察对象在不同的间隔观察值。我们依赖于isStreamActive属性来保持订阅活跃或当属性设置为false时停止它。我们将删除takeWhile的使用,并设法让一切工作得和现在一样。
-
首先,在
HomeComponent类中添加一个名为streamOutput$的类型为Observable的属性。按照以下方式更新home.component.ts文件中的代码:... import { interval, **Observable**, takeWhile } from 'rxjs'; ... export class HomeComponent implements OnDestroy { ... isStreamActive!: boolean; **streamsOutput$!:** **Observable****<****number****>;** constructor() { } ... } -
我们现在将所有流合并以输出单个输出——即
outputStreamData数组。我们将从startStream方法中删除所有现有的pipe和subscribe函数,因此代码现在应该看起来像这样:... import { interval, **merge**, **scan**, Observable, takeWhile } from 'rxjs'; ... export class HomeComponent implements OnDestroy { ... startStream() { ... const fastestStreamSource = interval(500); **this****.****streamsOutput$** **=** **merge****(** **streamSource,** **secondStreamSource,** **fastestStreamSource** **).****pipe****(** **scan****(****(****acc, next****) =>** **{** **return** **[...acc, next];** **}, []** **as****number****[])** **);** } ... } -
由于我们希望在点击停止流按钮时停止流,我们将在流中使用
takeWhile操作符来与流一起工作,只有在点击开始流按钮时才发出值,并在点击停止流按钮时停止。按照以下方式更新home.component.ts中的startStream方法:startStream() { ... this.streamsOutput$ = merge(...).pipe( **takeWhile****(****() =>****this****.****isStreamActive****),** scan((acc, next) => { return [...acc, next]; }, [] as number[]) **)** } -
删除
ngOnDestroy方法,因为当我们将离开组件(转到另一个路由)时,我们的流将自动取消订阅。这是因为我们正在使用async管道,Angular 本身在使用async管道时会为我们处理订阅和取消订阅。此外,我们应该删除implements OnDestroy语句和OnDestroy导入。 -
最后,修改
home.component.html中的模板,以使用streamOutput$可观察对象和async管道来循环输出数组:<div class="output-stream"> <div class="input-stream__item" *ngFor="let item of **streamsOutput$ | async**"> {{item}} </div> </div> -
为了验证在组件销毁时订阅确实被销毁,让我们在
startStream方法中的tap操作符内添加console.log,如下所示:import { ..., takeWhile, **tap** } from 'rxjs'; startStream() { ... this.streamsOutput$ = merge(...).pipe( takeWhile(...), scan(...)**,** **tap****(****(****output****) =>****console****.****log****(****'output'****, output))** ) }
哈哈!随着这个更改,你可以尝试刷新应用;离开主页路由,你会看到一旦你离开主页,控制台日志就会停止。此外,你还可以开始和停止流以在控制台看到输出。你对刚刚通过移除所有额外代码所得到的结果感到满意吗?我当然满意。在下一节中,我们将看到这一切是如何工作的。
它是如何工作的…
Angular 的async管道会在组件销毁时自动销毁/取消订阅,这为我们提供了一个很好的机会在可能的地方使用它。在菜谱中,我们基本上使用merge操作符组合了所有流。有趣的部分是,对于streamsOutput$属性,我们想要一个输出数组的可观察对象,我们可以遍历它。然而,合并流只会将它们组合起来,并发出任何流发出的最新值。因此,我们添加了一个带有scan操作符的pipe函数,以获取组合流的最新输出并将其添加到之前发出的所有输出数组中。这有点像 JavaScript 数组中的reduce函数。
有趣的事实——流在未被订阅的情况下不会发出任何值。“但是 Ahsan,我们没有订阅流,我们只是合并并映射了数据。订阅在哪里?”很高兴你问了。Angular 的async管道会自动订阅流本身,这会触发console.log,这是我们使用tap函数在步骤 6中添加的。
重要提示
async管道有一个限制,就是你不能在组件销毁之前停止订阅。对于想要有条件地订阅和取消订阅的情况,你可能需要选择像takeWhile/takeUntil这样的操作符,或者当组件销毁时自己使用常规的unsubscribe函数。
参见
- Angular
async管道文档(angular.io/api/common/AsyncPipe)
使用 map 操作符转换数据
在 Web 应用中制作 API/HTTP 调用时,通常服务器不会以易于直接渲染到 UI 的形式返回数据。我们通常需要将服务器接收到的数据进行某种转换,以便将其映射到我们的 UI 可以处理的内容。在这个菜谱中,你将学习如何使用map操作符来转换 HTTP 调用的响应。
准备工作
我们将要工作的应用位于克隆的仓库中的start/apps/chapter05/rx-map-operator目录内:
-
在你的代码编辑器中打开代码仓库。
-
打开终端,导航到代码仓库目录,并运行以下命令以启动项目:
npm run serve rx-map-operator这应该在新的浏览器标签页中打开应用,你应该看到以下内容:
图 5.6:运行在 http://localhost:4200 的 rx-map-operator 应用
现在我们已经在本地上运行了应用,让我们在下一节中查看菜谱的步骤。
如何操作…
我们的应用模板(app.component.html)已经设置好了。同样,我们的app.component.ts文件和所需的appData数据结构也已经设置。
-
我们将首先在
swapi.service.ts文件中创建一个方法来获取数据。我们希望只有一个函数能够从不同的 API 调用中获取数据,将其合并,并返回。按照以下方式更新文件:... import { delay, forkJoin, **Observable** } from 'rxjs'; import { IFilm, **IPerson** } from './interfaces'; ... export class SwapiService { ... **fetchData****(****personId****:** **string****):** **Observable****<{****person****:** **IPerson****}> {** **}** fetchPerson(id: string) {...} fetchPersonFilms(films: string[]) {...} }你将看到 TypeScript 对我们很生气。不用担心,我们会在适当的时候让它高兴起来。
-
让我们在
fetchData函数中添加以下代码,首先获取人物,然后获取该人物的影片:... export class SwapiService { ... fetchData(personId: string): Observable<{person: IPerson}> { let personInfo: IPerson; **return****this****.****fetchPerson****(personId)** **.****pipe****(** **mergeMap****(****(****person****) =>** **{** **personInfo = person;** **return****this****.****fetchPersonFilms****(person.****films****);** **})** **)** **}** ... }现在我们可以在收到影片后决定要做什么。
-
我们将遍历
filmsHTTP 调用返回的响应,并将其添加到personInfo对象中。按照以下方式更新swapi.service.ts文件:... import { delay, forkJoin, **map**, mergeMap, Observable } from 'rxjs'; ... export class SwapiService { ... fetchData(personId: string): Observable<{ person: IPerson }> { let personInfo: IPerson; return this.fetchPerson(personId).pipe( mergeMap((person) => { personInfo = person; return this.fetchPersonFilms(person.films); })**,** **map****(****(****films: IFilm[]****) =>** **{** **personInfo.****filmObjects** **= films;** **return** **{** **person****: personInfo,** **};** **})** ); } ... } -
最后,让我们在
app.component.ts文件中使用SwapiService的fetchData方法。按照以下方式更新文件中的fetchData方法,并确保从文件中删除未使用的依赖项:... export class AppComponent implements OnInit { ... fetchData() { this.loadingData = true; **this****.****swapi****.****fetchData****(****'1'****).****subscribe****(****(****response****) =>** **{** **this****.****appData** **= response;** **this****.****loadingData** **=** **false****;** **});** } }是的!如果你现在刷新应用,你会注意到数据正在视图中显示:
图 5.7:显示从 swapi 接收到的数据的 UI
现在你已经完成了配方,请查看下一节了解它是如何工作的。
它是如何工作的…
map运算符是所有时间中最常用的 RxJS 运算符之一。特别是在 Angular 中,当我们进行 HTTP 调用时。在这个配方中,我们的目标是尽可能少地在app.component.ts文件中做工作。这是因为作为社区采纳的实践之一,组件应该从服务请求数据,服务应该以这种方式提供数据,以便它可以绑定到 UI 变量。Angular 文档也鼓励将组件的代码保持尽可能小。通常,将代码分布到不同的层,即组件、服务、管道等,也是一个好主意。这是为了能够轻松地扩展应用程序,有更好的测试可能性,并且能够轻松地用完全不同的事物替换层。因此,我们在SwapiService类中创建了fetchData方法,使用fetchPerson和fetchPersonFilms方法首先进行 HTTP 调用,然后我们使用了map运算符将数据转换成组件/UI 期望的确切数据结构。
参见
使用 switchMap 和 debounceTime 运算符以及自动完成功能以获得更好的性能
对于许多应用程序,我们具有用户键入时搜索内容等特性。这对于用户体验(UX)来说非常好,因为用户不需要按按钮就可以进行搜索。然而,如果我们每次按键都向服务器发送 HTTP 调用,这将导致发送大量的 HTTP 调用,我们无法知道哪个 HTTP 调用会首先完成;因此,我们无法确定是否会在视图中显示正确的数据。在本食谱中,您将学习如何使用switchMap操作符取消最后一个订阅并创建一个新的订阅。这将导致取消之前的 HTTP 调用,并保留一个 HTTP 调用——最后一个。我们将使用debounceTime操作符等待输入空闲后再尝试进行调用。
准备工作
我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter05/rx-switchmap-operator目录内:
-
在您的代码编辑器中打开代码仓库。
-
打开终端,导航到代码仓库目录,并运行以下命令以运行项目:
npm run serve rx-switchmap-operato这应该在新的浏览器标签页中打开应用程序,您应该看到以下内容:
图 5.8:在 http://localhost:4200 上运行的 rx-switchmap-operator 应用程序
现在我们已经在本地上运行了应用程序,打开Chrome DevTools并转到网络标签页。在搜索输入中键入wolf,您会看到向 API 服务器发送了四个调用,如下所示:
图 5.9:为每次输入更改发送单独的 HTTP 调用
如何做到这一点...
您可以在主页上的搜索框中开始键入以查看过滤后的用户,如果您看到网络标签页,您会注意到每当输入更改时,我们都会发送一个新的 HTTP 调用。让我们通过使用switchMap操作符来避免在每次按键时发送调用。
-
首先,在
users/users.component.ts文件中从rxjs/operators导入switchMap操作符,如下所示:... import { mergeMap, startWith, takeWhile, **switchMap** } from 'rxjs/operators'; -
我们现在将修改对
username表单控制的订阅——具体来说,是使用switchMap操作符来调用this.userService.searchUsers(query)方法的valueChanges可观察对象。这返回一个包含 HTTP 调用结果的Observable。代码应该看起来像这样:ngOnInit() { ... this.searchForm.controls['username'].valueChanges .pipe( startWith(''), takeWhile(() => this.componentAlive), **switchMap**((query) => this.userService.searchUsers(query)) ) .subscribe((users) => {...}); }如果您现在刷新应用程序,打开Chrome DevTools,在快速输入
wolf时检查网络类型,您会看到所有之前的调用都被取消,我们只有最新的 HTTP 调用成功:图 5.10:switchMap 取消之前的 HTTP 调用
好吧,看起来不错,但
backend/api端点仍然接收那些调用。 -
现在我们将使用
debounceTime操作符等待搜索输入空闲后再开始执行调用。按照以下方式更新users.component.ts文件:... import { **debounceTime, ..**.} from 'rxjs/operators'; ... export class UsersComponent implements OnInit { ... ngOnInit() { ... this.searchForm.controls['username'].valueChanges .pipe( startWith(''), **debounceTime****(****500****),** takeWhile(() => this.componentAlive), switchMap((query) => this.userService.searchUsers(query)) ) .subscribe((users) => {...}); } }图 5.11显示,即使在搜索输入中键入四个字母之后,也只向服务器发送了一个调用:
图 5.11:等待输入空闲的 debounceTime
哇!我们现在只有一个调用会成功,处理数据,并最终显示在视图中;请看下一节了解它是如何工作的。
它是如何工作的...
switchMap操作符取消之前的(内部)订阅,并订阅一个新的可观察对象。在我们的例子中,父级可观察对象(输入元素的valueChanges发射器)发出一个值,switchMap操作符取消正在进行的上一个操作。这就是为什么它会取消我们例子中之前发送的所有 HTTP 调用,并仅订阅最后一个。然而,调用仍然到达 API 端点。如果这是我们自己的服务器,我们可能仍然会收到 API 调用,所以我们使用debounceTime操作符在表单控件上等待输入空闲(500 毫秒),然后我们才发送第一个调用。
参见
-
switchMap操作符文档(www.learnrxjs.io/learn-rxjs/operators/transformation/switchmap) -
debounceTime操作符文档(www.learnrxjs.io/learn-rxjs/operators/filtering/debouncetime)
创建自定义 RxJS 操作符
通过遵循本章中的其他食谱,我必须问你是否已经成为 RxJS 的粉丝了?*你成为了吗?*好吧,我是。在这个食谱中,你将提升你的 RxJS 技能。你将创建自己的自定义 RxJS 操作符,它可以直接连接到任何可观察流并在控制台上记录值。我们将称之为logWithLabel操作符。
准备工作
我们将要工作的应用位于克隆的仓库中的start/apps/chapter05/rx-custom-operator:
-
在你的代码编辑器中打开代码仓库。
-
打开终端,导航到代码仓库目录,并运行以下命令来提供项目服务:
npm run serve rx-custom-operator这应该在新的浏览器标签页中打开应用。如果你在打开 DevTools 的同时点击开始流按钮,你应该看到以下内容:
图 5.12:在 http://localhost.4200 上运行的 rx-custom-operator 应用
让我们在下一节中跳转到食谱步骤。
如何做到这一点...
我们将创建一个名为logWithLabel的自定义 RxJS 操作符,它将在控制台上带有标签记录可观察流中的值。
-
在
app文件夹内创建一个新文件,并将其命名为log-with-label.ts。然后在文件中添加以下代码:import { Observable } from 'rxjs/internal/Observable'; import { tap } from 'rxjs/operators'; const logWithLabel = <T>( label: string ): ((source$: Observable<T>) => Observable<T>) => { return (source$) => source$.pipe(tap((value) => console.log(label, value))); }; export default logWithLabel; -
现在我们可以从
home/home.component.ts文件中的log-with-label.ts文件导入logWithLabel操作符,如下所示:... **import** **logWithLabel** **from****'../log-with-label'****;** @Component({...}) export class HomeComponent { ... startStream() { ... this.streamsOutput$ = merge(...).pipe( takeWhile(...), scan(...), **logWithLabel****(****'stream-output'****)** ); } ... }就这样!如果你刷新应用并点击开始流按钮,你可以使用
logWithLabel操作符查看输出,如下所示:图 5.13:使用 logWithLabel 自定义 RxJS 操作符记录的日志
请参阅下一节了解它是如何工作的。
它是如何工作的...
一个自定义 RxJS 操作符是一个函数,它应该接受一个可观察源流并返回某物。那个某物通常是可观察的。在这个菜谱中,我们希望深入到流中,每次流发出值时在控制台记录一些内容。我们还希望为这个流的日志添加一个自定义标签。这就是我们最终创建自定义操作符作为工厂函数的原因,它可以接受label作为输入,即当我们调用logWithLabel函数(让我们称它为函数 A)时,它返回一个函数(让我们称它为函数 B)。返回的函数(B)是 RxJS 在我们使用pipe函数中的logWithLabel方法时与可观察流一起调用的。在函数 B内部,我们使用 RxJS 的tap操作符来拦截源可观察流并在控制台使用提供的label记录值。
参见
-
tap操作符文档(rxjs.dev/api/operators/tap) -
Observable文档(rxjs.dev/guide/observable)
使用 RxJS 重试失败的 HTTP 请求
在这个菜谱中,你将学习如何使用 RxJS 操作符智能地重试 HTTP 请求。我们将使用一种称为指数退避的技术。这意味着我们将重试 HTTP 请求,但每次后续调用都比前一次尝试的延迟更长,并在尝试几次最大次数后停止。听起来很激动人心吗?让我们开始吧。
准备工作
我们将要与之合作的应用程序位于克隆的仓库中的start/apps/chapter05/rx-retry-http-calls:
-
在你的代码编辑器中打开代码仓库。
-
打开终端,导航到代码仓库目录,并运行以下命令以使用后端服务器提供项目:
npm run serve rx-retry-http-calls with-server这应该在新的浏览器标签页中打开应用程序,你应该看到以下内容:
图 5.14:在 http://localhost.4200 上运行的 rx-retry-http-calls
让我们跳到下一节中的菜谱步骤。
如何做到这一点...
我们将创建一个名为backoff的自定义 RxJS 操作符,它将使用指数退避策略为我们重试 HTTP 请求。
-
在
app文件夹内创建一个新文件,并将其命名为retry-backoff.ts。然后在文件中添加以下代码:import { of, pipe, throwError } from 'rxjs'; import { retry } from 'rxjs/operators'; export function retryBackoff(maxTries: number, delay: number) { return pipe( retry({ delay: (error, retryCount) => { return retryCount > maxTries ? throwError(() => error) : of(retryCount); }, }) ); } -
现在,让我们在
app.component.ts中使用这个操作符来重试 HTTP 请求。按照以下方式更新文件:... **import** **{ retryBackoff }** **from****'./retry-backoff'****;** ... export class AppComponent implements OnInit { ... ngOnInit(): void { this.isMakingHttpCall = true; this.http .get('http://localhost:3333/api/bad-request') .pipe( **retryBackoff****(****3****,** **300****),** catchError(...) ) .subscribe(...); } }如果你刷新应用程序,你会注意到现在我们正在重试 HTTP 请求。但所有的重试都是立即完成的(注意瀑布列),如图 5.15 所示。我们不想这样。我们希望每次尝试都以递增的延迟完成。
图 5.15:立即多次重试 HTTP 请求
-
将
retry-backoff.ts文件更新为使用timer操作符和一些计算来添加延迟,如下所示:import { of, pipe, throwError, **timer** } from 'rxjs'; import { **map, mergeMap**, retry } from 'rxjs/operators'; export function retryBackoff(maxTries: number, delay: number) { return pipe( retry({ delay: (error, retryCount) => { return ( retryCount > maxTries ? throwError(() => error) : of(retryCount) )**.****pipe****(** **map****(****(****count****) =>** **count * count),** **mergeMap****(****(****countSq****) =>****timer****(countSq * delay))** **);** }, }) ); }就这样!如果您刷新应用程序,您会看到每次 HTTP 调用的后续重试的延迟都比前一次增加。注意最后一个 HTTP 调用在 Waterfall 列中的位置有多远(它在 图 5.16 的右边缘):
图 5.16:使用指数退避重试 HTTP 调用
查看下一节以了解它是如何工作的。
它是如何工作的…
retry 操作符有两个重载(在撰写本书时)。其中一个接受 number 参数,RxJS 将仅重试观察者指定次数(直到抛出异常)。另一个重载是它接受一个配置对象。在配置对象中,我们使用 delay 函数来处理我们的逻辑。delay 函数接收来自 RxJS 的 error 和 retryCount,我们使用它们来抛出错误,如果我们已经尝试了最大次数,或者传递 retryCount。我们从 retryBackoff 函数的参数中获取最大尝试次数。最后,我们使 map 和 mergeMap 操作符与 delay 一起工作。使用 map 操作符,我们取 retryCount 变量值的平方。然后,在 mergeMap 操作符中,我们将平方值与传递给 retryBackoff 函数的延迟相乘。结果,每次后续请求的延迟等于 ((retryCount * retryCount) * delay)。请注意,我们使用 timer 函数让 RxJS 在再次重试 HTTP 调用之前等待。
另请参阅
-
RxJS 自定义操作符 (
indepth.dev/posts/1421/rxjs-custom-operators) -
指数退避文档 (
angular.io/guide/practical-observable-usage#exponential-backoff)
在 Discord 上了解更多信息
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问以及了解新版本——请扫描下面的二维码: