Angular-秘籍-全-

Angular 秘籍(全)

原文:zh.annas-archive.org/md5/B1FA96EFE213EFF9E25A2BF507BCADB7

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Angular 是世界上最受欢迎的框架之一,不仅用于构建 Web 应用程序,甚至还用于移动应用程序和桌面应用程序。由 Google 支持并被 Google 使用,这个框架被数百万个应用程序使用。尽管该框架非常适合任何规模的应用程序,但企业特别喜欢 Angular,因为它具有明确的观点,并且因为其一致的生态系统包括您创建基于 Web 技术的应用程序所需的所有工具。

虽然学习核心技术如 JavaScript、HTML 和 CSS 对于成为 Web 开发人员至关重要,但是当涉及到框架时,学习框架本身的核心概念也非常重要。当我们使用 Angular 时,通过学习并使用 Angular 生态系统中的正确工具,我们可以为我们的 Web 应用程序做很多令人惊叹的事情。这就是本书的用武之地。

本书是为中级和高级 Angular 开发人员编写的,以便通过可以轻松遵循、玩耍并练习自己变化的食谱来提高他们的 Angular 开发技能。您不仅会从食谱本身中学到东西,还会从与食谱相关的实际项目中学到东西。因此,这些食谱和项目中有很多隐藏的宝石等待着您。

编码愉快!

本书适合谁

本书适用于中级水平的 Angular Web 开发人员,他们正在寻找在 Angular 企业开发中常见问题的可行解决方案。使用 Angular 技术的移动开发人员也会发现本书很有用。理解 JavaScript 和 TypeScript 的工作经验对更有效地理解本书中涵盖的主题是必要的。

本书涵盖的内容

第一章, 获胜的组件通信,解释了在 Angular 中实现组件之间通信的不同技术。还涵盖了@Input()@Output()修饰符、服务和生命周期钩子。还有一个关于如何创建动态 Angular 组件的示例。

第二章, 理解和使用 Angular 指令,介绍了 Angular 指令,并提供了一些使用 Angular 指令的示例,包括属性指令和结构指令。

第三章,Angular 中依赖注入的魔力,包括覆盖了可选依赖项,配置注入令牌,使用providedIn: 'root'元数据为 Angular 服务提供者,值提供者和别名类提供者的示例。

第四章,理解 Angular 动画,包括实现多状态动画,交错动画,关键帧动画以及在 Angular 应用程序中切换路由时的动画的示例。

第五章,Angular 和 RxJS - 组合的精华,涵盖了 RxJS 实例和静态方法的用法。它还包括一些关于combineLatestflatMapswitchMap操作符的用法的示例,并介绍了一些关于使用 RxJS 流的技巧和窍门。

第六章,使用 NgRx 进行响应式状态管理,涵盖了关于著名的 NgRX 库及其核心概念的示例。它涵盖了 NgRx 动作,减速器,选择器和效果等核心概念,并介绍了如何使用@ngrx/store-devtools@component/store等包。

第七章,理解 Angular 导航和路由,探讨了有关延迟加载路由,路由守卫,预加载路由策略以及与 Angular 路由一起使用的一些有趣技术的示例。

第八章,精通 Angular 表单,涵盖了模板驱动表单,响应式表单,表单验证,测试表单以及创建自己的表单控件的示例。

第九章,Angular 和 Angular CDK,包括许多很酷的 Angular CDK 示例,包括虚拟滚动,键盘导航,覆盖 API,剪贴板 API,CDK 拖放,CDK 步进器 API 和 CDK 文本框 API。

第十章,使用 Jest 在 Angular 中编写单元测试,涵盖了使用 Jest 进行单元测试的示例,探索 Jest 中的全局模拟,模拟服务/子组件/管道,使用 Angular CDK 组件挽具进行单元测试等内容。

第十一章**,使用 Cypress 进行 Angular 的 E2E 测试,介绍了在 Angular 应用中使用 Cypress 进行 E2E 测试的示例。它涵盖了验证表单、等待 XHR 调用、模拟 HTTP 调用响应、使用 Cypress 捆绑包以及在 Cypress 中使用固定装置。

第十二章Angular 中的性能优化,包含一些通过使用 OnPush 变更检测策略、延迟加载特性路由、从组件中分离变更检测器、使用 Angular 的 Web Workers、使用纯管道、向 Angular 应用添加性能预算以及使用webpack-bundle分析器来改善 Angular 应用性能的酷技巧。

第十三章使用 Angular 构建 PWA,包含了创建一个 PWA 的示例。它涵盖了为 PWA 指定主题颜色、使用设备的深色模式、提供自定义 PWA 安装提示、使用 Angular 的服务工作器预缓存请求以及使用 App Shell。

要充分利用本书

本书的示例是基于 Angular v12 构建的,Angular 遵循语义化版本控制发布。由于 Angular 不断改进,为了稳定性,Angular 团队为更新提供了可预测的发布周期。发布频率如下:

  • 每 6 个月发布一个重大版本。

  • 每个重大版本有 1 到 3 个次要版本。

  • 几乎每周发布一个补丁版本和预发布版本(下一个或 rc)构建。

来源:angular.io/guide/releases#release-frequency

如果您正在使用本书的数字版本,我们建议您自己输入代码或从书的 GitHub 存储库中访问代码(链接在下一节中提供)。这样做将有助于避免与复制和粘贴代码相关的任何潜在错误。

阅读完本书后,请务必在ahsanayaz.com/twitter上发推文,让我知道您对本书的反馈。此外,您可以根据自己的喜好修改本书提供的代码,将其上传到您的 GitHub 存储库并分享。我会确保转发它 😃

下载示例代码文件

您可以从 GitHub 上下载本书的示例代码文件github.com/PacktPublishing/Angular-Cookbook。如果代码有更新,将在 GitHub 存储库中更新。

我们还有来自丰富书籍和视频目录的其他代码捆绑包,可在github.com/PacktPublishing/上找到。去看看吧!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图和图表的彩色图像。您可以在这里下载:static.packt-cdn.com/downloads/9781838989439_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

文本中的代码:表示文本中的代码词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄。例如:“现在,我们将把代码从the-amazing-list-component.html文件移动到the-amazing-list-item.component.html文件,用于项目的标记。”

一块代码设置如下:

openMenu($event, itemTrigger) {
    if ($event) {
      $event.stopImmediatePropagation();
    }
    this.popoverMenuTrigger = itemTrigger;
    this.menuShown = true;
  }

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

.menu-popover {
  ...
  &::before {...}
  &--up {
    transform: translateY(-20px);
    &::before {
      top: unset !important;
      transform: rotate(180deg);
      bottom: -10px;
    }
  }
  &__list {...}
}

粗体:表示新术语,重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。例如:“您会注意到我们无法看到输入内容的全部内容-这在最好的时候有点烦人,因为在按下操作按钮之前,您无法真正审查它。”

提示或重要说明

出现如下。

第一章:第一章:获胜的组件通信

在本章中,您将掌握 Angular 中的组件通信。您将学习建立组件之间通信的不同技术,并了解哪种技术适用于哪种情况。您还将学习如何在本章中创建一个动态的 Angular 组件。

以下是本章将要涵盖的配方:

  • 使用组件@Input(s)@Output(s)进行组件通信

  • 使用服务进行组件通信

  • 使用 setter 拦截输入属性的更改

  • 使用ngOnChanges拦截输入属性的更改

  • 通过模板变量在父模板中访问子组件

  • 通过ViewChild在父组件类中访问子组件

  • 在 Angular 中创建你的第一个动态组件

技术要求

在本章的配方中,请确保您的计算机上安装了GitNode.js。您还需要安装@angular/cli包,可以在终端中使用npm install -g @angular/cli来安装。本章的代码可以在github.com/PacktPublishing/Angular-Cookbook/tree/master/chapter01找到。

使用组件@Input(s)和@Output(s)进行组件通信

您将从一个具有父组件和两个子组件的应用程序开始。然后,您将使用 Angular 的@Input@Ouput装饰器,使用属性和EventEmitter(s)在它们之间建立通信。

准备工作

我们将要使用的项目位于克隆存储库中的chapter01/start_here/cc-inputs-outputs中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。完成后,运行ng serve -o

这应该在新的浏览器标签页中打开应用程序,你应该看到以下内容:

图 1.1 - 运行在 http://localhost:4200 上的 cc-inputs-outputs 应用程序

图 1.1 - 运行在 http://localhost:4200 上的 cc-inputs-outputs 应用程序

如何做…

到目前为止,我们有一个带有AppComponentNotificationsButtonComponentNotificationsManagerComponent的应用程序。虽然AppComponent是其他两个组件的父组件,但它们之间绝对没有组件通信来同步通知计数值。让我们使用以下步骤建立它们之间的适当通信:

  1. 我们将从NotificationsManagerComponent中移除notificationsCount变量,并将其放在AppComponent中。为此,只需在app.component.ts中创建一个notificationsCount属性即可:
export class AppComponent {
  notificationsCount = 0;
}
  1. 然后,将notifications-manager.component.ts中的notificationsCount属性转换为@Input(),并将其重命名为count,并替换其用法如下:
import { Component, OnInit, Input } from '@angular/core';
@Component({
  selector: 'app-notifications-manager',
  templateUrl: './notifications-manager.component.html',
  styleUrls: ['./notifications-manager.component.scss']
})
export class NotificationsManagerComponent implements OnInit {
  @Input() count = 0
  constructor() { }
  ngOnInit(): void {
  }
  addNotification() {
    this.count++;
  }
  removeNotification() {
    if (this.count == 0) {
      return;
    }
    this.count--;
  } 
  resetCount() {
    this.count = 0;
  }
}
  1. 更新notifications-manager.component.html以使用count而不是notificationsCount
 <div class="notif-manager">
  <div class="notif-manager__count">
    Notifications Count: {{count}}
  </div>
  ...
</div>
  1. 接下来,将app.component.html中的notificationsCount属性作为输入传递给<app-notifications-manager>元素:
 <div class="content" role="main">
  <app-notifications-manager
    [count]="notificationsCount">
  </app-notifications-manager>
</div>

您现在可以通过将app.component.ts中的notificationsCount的值分配为10来测试是否正确地从app.component.html传递到app-notifications-manager。您将看到,在NotificationsManagerComponent中,显示的初始值将为10

export class AppComponent {
  notificationsCount = 10;
}
  1. 接下来,在notifications-button.component.ts中创建一个@Input(),命名为count
import { Component, OnInit, Input } from '@angular/core';
...
export class NotificationsButtonComponent implements OnInit {
  @Input() count = 0;
  ...
}
  1. 同时也将notificationsCount传递给<app-notifications-button>,并在app.component.html中进行相应设置:
<!-- Toolbar -->
<div class="toolbar" role="banner">
  ...
  <span>@Component Inputs and Outputs</span>
  <div class="spacer"></div>
  <div class="notif-bell">
    <app-notifications-button     [count]="notificationsCount">
    </app-notifications-button>
  </div>
</div>
...
  1. notifications-button.component.html中使用count输入与通知图标:
<div class="bell">
  <i class="material-icons">notifications</i>
  <div class="bell__count">
    <div class="bell__count__digits">
      {{count}}
    </div>
  </div>
</div>

现在,您还应该看到通知图标计数为10的值。

现在,如果您通过从NotificationsManagerComponent中添加/删除通知来更改计数,通知图标上的计数将不会改变。

  1. 为了将来自NotificationsManagerComponentNotificationsButtonComponent的更改进行通信,我们现在将使用 Angular 的@Output。在notifications-manager.component.ts中使用@Output@EventEmitter来自'@angular/core'
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
...
export class NotificationsManagerComponent implements OnInit {
  @Input() count = 0
  @Output() countChanged = new EventEmitter<number>();
  ...
  addNotification() {
    this.count++;
    this.countChanged.emit(this.count);
  }
  removeNotification() {
    ...
    this.count--;
    this.countChanged.emit(this.count);
  }
  resetCount() {
    this.count = 0;
    this.countChanged.emit(this.count);
  }
}
  1. 然后,我们将在app.component.html中监听来自NotificationsManagerComponent的先前发出的事件,并相应地更新notificationsCount属性:
<div class="content" role="main">
  <app-notifications-manager   (countChanged)="updateNotificationsCount($event)"   [count]="notificationsCount"></app-notifications-  manager>
</div>
  1. 由于我们先前已经监听了countChanged事件并调用了updateNotificationsCount方法,我们需要在app.component.ts中创建这个方法,并相应地更新notificationsCount属性的值:
export class AppComponent {
  notificationsCount = 10;
  updateNotificationsCount(count: number) {
    this.notificationsCount = count;
  }
}

工作原理…

为了使用@Input@Output在组件之间进行通信,数据流将始终从子组件 父组件,父组件可以将新的(更新的)值作为输入提供给所需的子组件。因此,NotificationsManagerComponent发出countChanged事件。AppComponent(作为父组件)监听该事件并更新notificationsCount的值,这将自动更新NotificationsButtonComponent中的count属性,因为notificationsCount被传递为@Input() count 到NotificationsButtonComponent图 1.2显示了整个过程:

图 1.2 - 使用输入和输出进行组件通信的工作原理

图 1.2 - 使用输入和输出进行组件通信的工作原理

另请参阅

使用服务进行组件通信

在这个配方中,您将从一个具有父组件和子组件的应用程序开始。然后,您将使用 Angular 服务来建立它们之间的通信。我们将使用BehaviorSubject和 Observable 流来在组件和服务之间进行通信。

准备就绪

此处的配方项目位于chapter01/start_here/cc-services中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这将在新的浏览器标签中打开应用程序,您应该看到应用程序如下所示:

图 1.3 - cc-services 应用程序运行在 http://localhost:4200

图 1.3 - cc-services 应用程序运行在 http://localhost:4200

如何做…

与之前的配方类似,我们有一个带有AppComponentNotificationsButtonComponentNotificationsManagerComponent的应用程序。AppComponent是前面提到的另外两个组件的父组件,我们需要使用以下步骤在它们之间建立适当的通信:

  1. chapter01/start_here/cc-services/src/app项目中创建一个名为services的新文件夹。这将是我们新服务的所在地。

  2. 从终端中,导航到项目中,即chapter01/start_here/cc-services内,并创建一个名为NotificationService的新服务,如下所示:

ng g service services/Notifications
  1. notifications.service.ts中创建一个名为countBehaviorSubject,并将其初始化为0,因为BehaviorSubject需要一个初始值:
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({
  providedIn: 'root'
})
export class NotificationsService {
  private count: BehaviorSubject<number> = new   BehaviorSubject<number>(0);
  constructor() { }
}

注意BehaviorSubject是一个private属性,我们稍后将仅从服务内部使用public方法来更新它。

  1. 现在,使用countBehaviorSubject上的.asObservable()方法创建一个名为count$Observable
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
...
export class NotificationsService {
  private count: BehaviorSubject<number> = new   BehaviorSubject<number>(0);
  count$: Observable<number> = this.count.asObservable();
  ...
}
  1. notifications-manager.component.ts中的notificationsCount属性转换为名为notificationsCount$的 Observable。在组件中注入NotificationsService并将服务的count$ Observable 分配给组件的notificationsCount$变量:
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { NotificationsService } from '../services/notifications.service';
...
export class NotificationsManagerComponent implements OnInit {
  notificationsCount$: Observable<number>;
  constructor(private notificationsService:   NotificationsService) { }

  ngOnInit(): void {
    this.notificationsCount$ = this.notificationsService.    count$;
  }
  ...
}
  1. 暂时注释掉更新通知计数的代码;我们稍后会回来处理它:
...
export class NotificationsManagerComponent implements OnInit {
  ...
  addNotification() {
    // this.notificationsCount++;
  }
  removeNotification() {
    // if (this.notificationsCount == 0) {
    //   return;
    // }
    // this.notificationsCount--;
  }
  resetCount() {
    // this.notificationsCount = 0;
  }
}
  1. notifications-manager.component.html中使用notificationsCount$ Observable 和async管道来显示其值:
<div class="notif-manager">
  <div class="notif-manager__count">
    Notifications Count: {{notificationsCount$ | async}}
  </div>
  ...
</div>
  1. 现在,类似地在notifications-button.component.ts中注入NotificationsService,在NotificationsButtonComponent中创建一个名为notificationsCount$的 Observable,并将服务的count$ Observable 分配给它:
import { Component, OnInit } from '@angular/core';
import { NotificationsService } from '../services/notifications.service';
import { Observable } from 'rxjs';
 ...
export class NotificationsButtonComponent implements OnInit {
  notificationsCount$: Observable<number>;
  constructor(private notificationsService:   NotificationsService) { }

  ngOnInit(): void {
    this.notificationsCount$ = this.notificationsService.    count$;
  }
}
  1. notifications-button.component.html中使用notificationsCount$ Observable 和async管道:
<div class="bell">
  <i class="material-icons">notifications</i>
  <div class="bell__count">
    <div class="bell__count__digits">
      {{notificationsCount$ | async}}
    </div>
  </div>
</div>

如果现在刷新应用程序,您应该能够看到通知管理器组件和通知按钮组件的值都为0

  1. countBehaviorSubject的初始值更改为10,并查看是否在两个组件中都反映出来:
...
export class NotificationsService {
  private count: BehaviorSubject<number> = new   BehaviorSubject<number>(10);
  ...
}
  1. 现在,在notifications.service.ts中创建一个名为setCount的方法,这样我们就能够更新countBehaviorSubject的值:
...
export class NotificationsService {
  …
  constructor() {}
  setCount(countVal) {
    this.count.next(countVal);
  }
}
  1. 现在我们已经有了setCount方法,让我们在notifications-manager.component.ts中使用它来根据按钮点击更新其值。为了这样做,我们需要获取notificationsCount$ Observable 的最新值,然后执行一些操作。我们首先在NotificationsManagerComponent中创建一个getCountValue方法,如下所示,并在notificationsCount$ Observable 上使用subscribefirst操作符来获取其最新值:
...
import { first } from 'rxjs/operators';
...
export class NotificationsManagerComponent implements OnInit {
  ngOnInit(): void {
    this.notificationsCount$ = this.notificationsService.    count$;
  }
  ...
  getCountValue(callback) {
    this.notificationsCount$
      .pipe(
        first()
      ).subscribe(callback)
  }
  ...
}
  1. 现在,我们将在我们的addNotificationremoveNotificationresetCount方法中使用getCountValue方法。我们将不得不从这些方法中将回调函数传递给getCountValue方法。让我们先从addNotification方法开始:
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { NotificationsService } from '../services/notifications.service';
import { first } from 'rxjs/operators';

...
export class NotificationsManagerComponent implements OnInit {
  ...
  addNotification() {
    this.getCountValue((countVal) => {
      this.notificationsService.setCount(++countVal)
    });
  }
  ...
}

有了上述代码,每当我们点击添加通知按钮时,您应该已经看到两个组件正确地反映了更新的值。

  1. 现在让我们实现removeNotificationresetCount的相同逻辑:
...
export class NotificationsManagerComponent implements OnInit {
  ...
  removeNotification() {
    this.getCountValue((countVal) => {
      if (countVal === 0) {
        return;
      }
      this.notificationsService.setCount(--countVal);
    })
  }
  resetCount() {
    this.notificationsService.setCount(0);
  }
}

工作原理…

BehaviorSubject是一种特殊类型的Observable,它需要一个初始值,并且可以被多个订阅者使用。在这个食谱中,我们创建了一个BehaviorSubject,然后使用BehaviorSubject上的.asObservable()方法创建了一个Observable。虽然我们本来可以直接使用BehaviorSubject,但是社区推荐使用.asObservable()方法。

一旦我们在NotificationsService中创建了名为count$的 Observable,我们就在我们的组件中注入NotificationsService,并将count$ Observable 分配给组件的一个本地属性。然后,我们直接在NotificationsButtonComponent的模板(html)和NotificationsManagerComponent的模板中使用async管道订阅这个本地属性(它是一个 Observable)。

然后,每当我们需要更新count$ Observable 的值时,我们使用NotificationsServicesetCount方法来使用BehaviorSubject.next()方法更新实际的值。这将通过count$ Observable 自动发出新值,并在两个组件中更新视图的新值。

另请参阅

使用 setter 拦截输入属性更改

在这个食谱中,您将学习如何拦截从父组件传递的@Input的更改,并对此事件执行一些操作。我们将拦截从VersionControlComponent父组件传递给VcLogsComponent子组件的vName输入。我们将使用 setter 在vName的值更改时生成日志,并在子组件中显示这些日志。

准备工作

这个食谱的项目位于chapter01.start_here/cc-setters中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install以安装项目的依赖项。

  3. 完成后,运行ng serve -o。这应该会在新的浏览器选项卡中打开应用程序,您应该看到应用程序如下所示:

图 1.4 – cc-setters 应用程序在 http://localhost:4200 上运行

图 1.4 – cc-setters 应用程序在 http://localhost:4200 上运行

如何做…

  1. 首先,我们将在VcLogsComponent中创建一个日志数组,以存储稍后我们将使用模板显示的所有日志:
export class VcLogsComponent implements OnInit {
  @Input() vName;
  logs: string[] = [];
  constructor() { }
...
}
  1. 让我们创建 HTML 来显示日志的位置。使用以下代码将日志容器和日志项添加到vc-logs.component.html中:
<h5>Latest Version = {{vName}}</h5>
<div class="logs">
  <div class="logs__item" *ngFor="let log of logs">
    {{log}}
  </div>
</div>
  1. 然后,我们将为要显示的日志容器和日志项添加一些样式。更改后,视图应如图 1.5所示。更新vc-logs.component.scss文件如下:
h5 {
  text-align: center;
}
.logs {
  padding: 1.8rem;
  background-color: #333;
  min-height: 200px;
  border-radius: 14px;
  &__item {
    color: lightgreen;
  }
}

以下截图显示了具有日志容器样式的应用程序:

图 1.5 – 具有日志容器样式的 cc-setters 应用程序

图 1.5 – 具有日志容器样式的 cc-setters 应用程序

  1. 现在,我们将把vc-logs.component.ts中的@Input()转换为使用 getter 和 setter,以便我们可以拦截输入更改。为此,我们还将创建一个名为_vName的内部属性。代码应如下所示:
...
export class VcLogsComponent implements OnInit {
  _vName: string;
@Input() 
  get vName() {
    return this._vName;
  };
  set vName(name: string) {
   this._vName = name;
  }
  logs: string[] = [];
  constructor() { }
...
}
  1. 通过步骤 4中的更改,应用程序的工作方式与以前完全相同,即完美。现在,让我们修改 setter 以创建这些日志。对于初始值,我们将有一个日志,说'初始版本是 x.x.x':
export class VcLogsComponent implements OnInit {
  ...
  set vName(name: string) {
    if (!name) return;
    if (!this._vName) {
      this.logs.push('initial version is ${name.trim()}')
    }
    this._vName = name;
  }
...
}
  1. 现在,作为最后一步,每当我们更改版本名称时,我们需要显示一个不同的消息,说'版本更改为 x.x.x'。图 1.6显示了最终输出。对于所需的更改,我们将在vName setter 中编写一些进一步的代码如下:
export class VcLogsComponent implements OnInit {
  ...
  set vName(name: string) {
    if (!name) return;
    if (!this._vName) {
      this.logs.push('initial version is ${name.trim()}')
    } else {
      this.logs.push('version changed to ${name.trim()}')
    }
    this._vName = name;
  }

以下截图显示了最终输出:

图 1.6 – 使用 setter 的最终输出

图 1.6 – 使用 setter 的最终输出

它是如何工作的…

Getter 和 setter 是 JavaScript 的内置功能的组成部分。许多开发人员在使用原始 JavaScript 或 TypeScript 时在其项目中使用它们。幸运的是,Angular 的@Input()也可以使用 getter 和 setter,因为它们基本上是提供的类的属性。

对于这个示例,我们使用一个 getter,更具体地说,是一个 setter 来处理我们的输入,所以每当输入发生变化时,我们使用 setter 方法来执行额外的任务。此外,我们在 HTML 中使用相同输入的 setter,所以当更新时,我们直接在视图中显示值。

始终使用私有变量/属性与 getter 和 setter 是一个好主意,以便在组件接收输入和在组件本身中存储输入方面有一个关注点的分离。

另请参阅

使用ngOnChanges来拦截输入属性的更改

在这个示例中,您将学习如何使用ngOnChanges来拦截使用SimpleChanges API 的更改。我们将监听从VersionControlComponent父组件传递给VcLogsComponent子组件的vName输入。

准备工作

这个示例的项目位于chapter01/start_here/cc-ng-on-changes中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o。这应该会在新的浏览器标签中打开应用程序,您应该会看到应用程序如下所示:

图 1.7 - cc-ng-on-changes 应用程序在 http://localhost:4200 上运行

图 1.7 - cc-ng-on-changes 应用程序在 http://localhost:4200 上运行

如何做…

  1. 首先,在VcLogsComponent中创建一个 logs 数组,以便稍后在模板中显示所有的日志:
export class VcLogsComponent implements OnInit {
  @Input() vName;
  logs: string[] = [];
  constructor() { }
...
}
  1. 让我们创建一个用于显示日志的 HTML。让我们使用以下代码在vc-logs.component.html中添加日志容器和日志项:
<h5>Latest Version = {{vName}}</h5>
<div class="logs">
  <div class="logs__item" *ngFor="let log of logs">
    {{log}}
  </div>
</div>
  1. 然后,我们将在vc-logs.component.scss中添加一些样式,以便显示日志容器和日志项,如下所示:
h5 {
  text-align: center;
}
.logs {
  padding: 1.8rem;
  background-color: #333;
  min-height: 200px;
  border-radius: 14px;
  &__item {
    color: lightgreen;
  }
}

您应该会看到类似于这样的东西:

图 1.8 - cc-ng-on-changes 应用程序带有日志容器样式

图 1.8 - cc-ng-on-changes 应用程序带有日志容器样式

  1. 现在,让我们在vc-logs.component.ts文件中实现VcLogsComponent中的ngOnChanges,使用简单的更改如下:
import { Component, OnInit, Input, OnChanges, SimpleChanges } from '@angular/core';
...
export class VcLogsComponent implements OnInit, OnChanges {
  @Input() vName;
  logs: string[] = [];
  constructor() {}
  ngOnInit(): void {}
  ngOnChanges(changes: SimpleChanges) {
  }
}
  1. 现在,我们可以为vName输入的初始值添加一个日志,内容为'initial version is x.x.x'。我们通过使用.isFirstChange()方法来检查是否为初始值来实现这一点,如下所示:
...
export class VcLogsComponent implements OnInit, OnChanges {
  ...
  ngOnChanges(changes: SimpleChanges) {
    const currValue = changes.vName.currentValue;
    if (changes.vName.isFirstChange()) {
      this.logs.push('initial version is       ${currValue.trim()}')
    }
  }
}
  1. 让我们处理在分配初始值后更新版本的情况。为此,我们将添加另一个日志,使用else条件,内容为'version changed to x.x.x',如下所示:
...
export class VcLogsComponent implements OnInit, OnChanges {
  ...
  ngOnChanges(changes: SimpleChanges) {
    const currValue = changes.vName.currentValue;
    if (changes.vName.isFirstChange()) {
      this.logs.push('initial version is       ${currValue.trim()}')
    } else {
      this.logs.push('version changed to       ${currValue.trim()}')
    }
  }
}

工作原理…

ngOnChanges是 Angular 提供的许多生命周期钩子之一。它甚至在ngOnInit钩子之前触发。因此,您在第一次调用时获得初始值,稍后获得更新后的值。每当任何输入发生更改时,都会使用SimpleChanges触发ngOnChanges回调,并且您可以获取先前的值、当前的值以及表示这是否是输入的第一次更改的布尔值(即初始值)。当我们在父级更新vName输入的值时,ngOnChanges会使用更新后的值进行调用。然后,根据情况,我们将适当的日志添加到我们的logs数组中,并在 UI 上显示它。

另请参阅

通过模板变量在父模板中访问子组件

在这个示例中,您将学习如何使用Angular 模板引用变量来访问父组件模板中的子组件。您将从一个具有AppComponent作为父组件和GalleryComponent作为子组件的应用程序开始。然后,您将在父模板中为子组件创建一个模板变量,以便访问它并在组件类中执行一些操作。

准备工作

我们要处理的项目位于克隆存储库内的chapter01/start_here/cc-template-vars中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install以安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该在新的浏览器选项卡中打开应用程序,并且您应该看到类似以下内容的东西:

![图 1.9 - 在 http://localhost:4200 上运行的 cc-template-vars 应用程序的运行情况]

](image/Figure_1.09_B15150.jpg)

图 1.9 - 运行在 http://localhost:4200 上的 cc-template-vars 应用程序

  1. 点击顶部的按钮以查看各自的控制台日志。

如何做...

  1. 我们将从在app.component.html文件中的<app-gallery>组件上创建一个名为#gallery的模板变量开始:
...
<div class="content" role="main">
  ...
  <app-gallery #gallery></app-gallery>
</div>
  1. 接下来,我们修改app.component.ts中的addNewPicture()removeFirstPicture()方法,以接受一个名为gallery的参数,这样当我们点击按钮时,它们可以接受来自app.component.html的模板变量。代码应该如下所示:
import { Component } from '@angular/core';
import { GalleryComponent } from './components/gallery/gallery.component';
...
export class AppComponent {
  ...
  addNewPicture(gallery: GalleryComponent) {
    console.log('added new picture');
  }
  removeFirstPicture(gallery: GalleryComponent) {
    console.log('removed first picture');
  }
}
  1. 现在,让我们将app.component.html中的#gallery模板变量传递给两个按钮的点击处理程序,如下所示:
…
<div class="content" role="main">
  <div class="gallery-actions">
    <button class="btn btn-primary"     (click)="addNewPicture(gallery)">Add Picture</button>
    <button class="btn btn-danger"     (click)="removeFirstPicture(gallery)">Remove     First</button>
  </div>
  ...
</div>
  1. 现在,我们可以实现添加新图片的代码。为此,我们将访问GalleryComponentgenerateImage()方法,并将一个新项添加到pictures数组中作为第一个元素。代码如下:
...
export class AppComponent {
  ...
  addNewPicture(gallery: GalleryComponent) {
    gallery.pictures.unshift(gallery.generateImage());
  }
  ...
}
  1. 要从数组中删除第一个项目,我们将在GalleryComponent类中的pictures数组上使用数组的shift方法来删除第一个项目,如下所示:
...
export class AppComponent {
   ...
  removeFirstPicture(gallery: GalleryComponent) {
    gallery.pictures.shift();
  }
}

它是如何工作的...

模板引用变量通常是模板中的 DOM 元素的引用。它也可以引用指令(其中包含一个组件)、元素、TemplateRef或 Web 组件(来源:angular.io/guide/template-reference-variables)。

实质上,我们可以引用我们的<app-gallery>组件,它在 Angular 中是一个指令。一旦我们在模板中有了这个变量,我们将引用传递给我们组件中的函数作为函数参数。然后,我们可以从那里访问GalleryComponent的属性和方法。您可以看到,我们能够直接从AppComponent中添加和删除GalleryComponent中的pictures数组中的项目,而AppComponent是整个流程中的父组件。

另请参阅

使用 ViewChild 在父组件类中访问子组件

在这个示例中,您将学习如何使用ViewChild装饰器来访问父组件类中的子组件。您将从一个具有AppComponent作为父组件和GalleryComponent作为子组件的应用程序开始。然后,您将在父组件类中为子组件创建一个ViewChild来访问它并执行一些操作。

准备工作

我们要处理的项目位于克隆存储库内的chapter01/start_here/cc-view-child中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。完成后,运行ng serve -o

  3. 这将在新的浏览器标签中打开应用程序,您应该会看到类似以下内容的内容:图 1.10 - 在 http://localhost:4200 上运行的 cc-view-child 应用程序

图 1.10 - 在 http://localhost:4200 上运行的 cc-view-child 应用程序

  1. 点击顶部的按钮查看相应的控制台日志。

如何做…

  1. 我们将从将GalleryComponent导入到我们的app.component.ts文件开始,以便我们可以为其创建一个ViewChild
import { Component } from '@angular/core';
import { GalleryComponent } from './components/gallery/gallery.component';
...
export class AppComponent {
  ...
}
  1. 然后,我们将使用ViewChild()装饰器为GalleryComponent创建ViewChild,如下所示:
import { Component, ViewChild } from '@angular/core';
import { GalleryComponent } from './components/gallery/gallery.component';
export class AppComponent {
  title = 'cc-view-child';
  @ViewChild(GalleryComponent) gallery;
  ...
}
  1. 现在,我们将实现添加新图片的逻辑。为此,在AppComponent内的addNewPicture方法中,我们将使用步骤 2中创建的gallery属性。这是为了访问子组件中的pictures数组。完成后,我们将使用GalleryComponentgenerateImage方法将新图片添加到该数组的顶部,如下所示:
...
export class AppComponent {
  title = 'cc-view-child';
  @ViewChild(GalleryComponent) gallery: GalleryComponent;
  addNewPicture() {
    this.gallery.pictures.unshift(    this.gallery.generateImage());
  }
  ...
}
  1. 为了处理删除图片,我们将在AppComponent类内的removeFirstPicture方法中添加逻辑。我们也将使用视图子组件。我们将简单地在pictures数组上使用Array.prototype.shift方法来删除第一个元素,如下所示:
...
export class AppComponent {
...
  removeFirstPicture() {
    this.gallery.pictures.shift();
  }
}

它是如何工作的…

ViewChild() 基本上是 @angular/core 包提供的装饰器。它为 Angular 变更检测器配置了一个视图查询。变更检测器尝试找到与查询匹配的第一个元素,并将其分配给与 ViewChild() 装饰器关联的属性。在我们的示例中,我们通过将 GalleryComponent 作为查询参数来创建一个视图子元素,即 ViewChild(GalleryComponent)。这允许 Angular 变更检测器在 app.component.html 模板中找到 <app-gallery> 元素,然后将其分配给 AppComponent 类中的 gallery 属性。重要的是将 gallery 属性的类型定义为 GalleryComponent,这样我们稍后可以在组件中轻松使用 TypeScript 魔法。

重要提示

视图查询在 ngOnInit 生命周期钩子之后和 ngAfterViewInit 钩子之前执行。

另请参阅

在 Angular 中创建您的第一个动态组件

在这个示例中,您将学习如何在 Angular 中创建动态组件,这些组件根据不同的条件动态创建。为什么?因为您可能有几个复杂的条件,并且您希望根据这些条件加载特定的组件,而不是只将每个可能的组件放在模板中。我们将使用 ComponentFactoryResolver 服务、ViewChild() 装饰器和 ViewContainerRef 服务来实现动态加载。我很兴奋,你也是!

准备就绪

我们将要处理的项目位于克隆存储库中的 chapter01/start_here/ng-dynamic-components 中。

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行 npm install 来安装项目的依赖项。

  3. 完成后,运行 ng serve -o

这应该在新的浏览器选项卡中打开应用程序,您应该看到类似以下内容:

图 1.11 - ng-dynamic-components 应用程序在 http://localhost:4200 上运行

图 1.11 - ng-dynamic-components 应用程序在 http://localhost:4200 上运行

  1. 点击顶部的按钮以查看相应的控制台日志。

如何做…

  1. 首先,让我们从我们的social-card.component.html文件中删除带有[ngSwitch]*ngSwitchCase指令的元素,并将它们替换为一个简单的带有模板变量命名为#vrfdiv。我们将使用这个div作为容器。代码应该如下所示:
<div class="card-container" #vrf></div>
  1. 接下来,我们将在social-card.component.ts中添加ComponentFactoryResolver服务,如下所示:
import { Component, OnInit, Input, ComponentFactoryResolver } from '@angular/core';
...
export class SocialCardComponent implements OnInit {
  @Input() type: SocialCardType;
  cardTypes = SocialCardType;
  constructor(private componentFactoryResolver:   ComponentFactoryResolver) { }
  ...
}
  1. 现在,在同一个文件中为ViewContainerRef创建一个ViewChild,这样我们就可以从模板中引用#vrf div,如下所示:
import { Component, OnInit, Input, ComponentFactoryResolver, ViewChild, ViewContainerRef } from '@angular/core';
...
export class SocialCardComponent implements OnInit {
  @Input() type: SocialCardType;
  @ViewChild('vrf', {read: ViewContainerRef}) vrf:   ViewContainerRef;
  cardTypes = SocialCardType;
  ...
}
  1. 为了动态创建组件,我们需要监听类型输入的变化。所以,每当它发生变化时,我们就动态加载适当的组件。为此,我们将在SocialCardComponent中实现ngOnChanges钩子,并暂时在控制台上记录更改。一旦实现,您应该在点击 Facebook 或 Twitter 按钮时在控制台上看到日志。
import { Component, OnInit, OnChanges, Input, ComponentFactoryResolver, ViewChild, ViewContainerRef, SimpleChanges } from '@angular/core';
...
export class SocialCardComponent implements OnInit, OnChanges {
  ...
  ngOnChanges(changes: SimpleChanges) {
    if (changes.type.currentValue !== undefined) {
      console.log('card type changed to:       ${changes.type.currentValue}')
    }
  }
}
  1. 现在,我们将在SocialCardComponent中创建一个名为loadDynamicComponent的方法,该方法接受社交卡的类型,即SocialCardType,并决定动态加载哪个组件。我们还将在方法内部创建一个名为component的变量,以选择要加载的组件。代码应该如下所示:
import {...} from '@angular/core';
import { SocialCardType } from 'src/app/constants/social-card-type';
import { FbCardComponent } from '../fb-card/fb-card.component';
import { TwitterCardComponent } from '../twitter-card/twitter-card.component';
...
export class SocialCardComponent implements OnInit {
  ...
  ngOnChanges(changes: SimpleChanges) {
    if (changes.type.currentValue !== undefined) {
      this.loadDynamicComponent(      changes.type.currentValue)
    }
  }
  loadDynamicComponent(type: SocialCardType) {
    let component;
    switch (type) {
      case SocialCardType.Facebook:
        component = FbCardComponent;
        break;
      case SocialCardType.Twitter:
        component = TwitterCardComponent;
        break;
    }
  }
}
  1. 现在我们知道要动态加载哪个组件,让我们使用componentFactoryResolver来解析组件,然后在ViewContainerRef(vrf)中创建组件,如下所示:
...
export class SocialCardComponent implements OnInit {
  ...
  loadDynamicComponent(type: SocialCardType) {
    let component;
    switch (type) {
      ...
    }
    const componentFactory = this.componentFactory     Resolver.resolveComponentFactory(component);
    this.vrf.createComponent(componentFactory);
  }
}

通过前面的更改,我们已经接近成功了。当您第一次点击 Facebook 或 Twitter 按钮时,您应该看到适当的组件被动态创建。

但是…如果你再次点击其中任何一个按钮,你会看到组件被添加到视图中作为一个额外的元素。

检查后,它可能看起来像这样:

图 1.12 - 预览多个元素被添加到 ViewContainerRef

图 1.12 - 预览多个元素被添加到 ViewContainerRef

阅读它是如何工作的…部分,了解为什么会发生这种情况。但要解决这个问题,我们只需在创建动态组件之前在ViewContainerRef上执行clear(),如下所示:

...
export class SocialCardComponent implements OnInit {
  ...
  loadDynamicComponent(type: SocialCardType) {
    ...
    const componentFactory = this.    componentFactoryResolver.    resolveComponentFactory(component);
    this.vrf.clear();
    this.vrf.createComponent(componentFactory);
  }
}

它是如何工作的…

ComponentFactoryResolver是一个 Angular 服务,允许您在运行时动态解析组件。在我们的示例中,我们使用resolveComponentFactory方法,该方法接受一个组件并返回一个ComponentFactory。我们可以始终使用ComponentFactorycreate方法来创建组件的实例。但在这个示例中,我们使用了ViewContainerRefcreateComponent方法,该方法接受ComponentFactory作为输入。然后它在后台使用ComponentFactory来生成组件,然后将其添加到附加的ViewContainerRef中。每次您创建一个组件并将其附加到ViewContainerRef时,它都会将新组件添加到现有元素列表中。对于我们的示例,我们只需要一次显示一个组件,即FBCardComponentTwitterCardComponent。因此,在添加元素之前,我们在ViewContainerRef上使用了clear()方法,以便只存在单个元素。

另请参阅

第二章:第二章:理解和使用 Angular 指令

在本章中,您将深入了解 Angular 指令。您将学习关于属性指令,使用一个非常好的真实世界示例来使用高亮指令。您还将编写您的第一个结构指令,并了解ViewContainerTemplateRef服务如何一起工作,以从文档对象模型DOM)中添加/删除元素,就像*ngIf的情况一样,并创建一些真正酷炫的属性指令来执行不同的任务。最后,您将学习如何在同一个超文本标记语言HTML)元素上使用多个结构指令,以及如何增强自定义指令的模板类型检查。

以下是本章我们将要涵盖的食谱:

  • 使用属性指令来处理元素的外观

  • 创建一个用于计算文章阅读时间的指令

  • 创建一个基本指令,允许您垂直滚动到一个元素

  • 编写您的第一个自定义结构指令

  • 如何同时使用*ngIf*ngSwitch

  • 增强自定义指令的模板类型检查

技术要求

对于本章的食谱,请确保您的机器上安装了GitNode.js。您还需要安装@angular/cli包,您可以在终端中使用npm install -g @angular/cli来安装。本章的代码可以在github.com/PacktPublishing/Angular-Cookbook/tree/master/chapter02找到。

使用属性指令来处理元素的外观

在这个食谱中,您将使用名为highlight的 Angular 属性指令。使用这个指令,您将能够在段落中搜索单词和短语,并在进行搜索时将它们高亮显示。当我们进行搜索时,整个段落的容器背景也会改变。

准备工作

我们将要使用的项目位于克隆存储库中的chapter02/start_here/ad-attribute-directive中:

  1. Visual Studio CodeVS Code)中打开项目。

  2. 打开终端,并运行npm install来安装项目的依赖。

  3. 完成后,运行ng serve -o

这应该在新的浏览器标签中打开应用程序,你应该会看到类似这样的东西:

图 2.1 - ad-attribute-directives 应用程序运行在 http://localhost:4200

图 2.1 - 在 http://localhost:4200 上运行的 ad-attribute-directives 应用程序

如何做…

到目前为止,该应用程序具有搜索输入框和段落文本。我们需要能够在搜索框中输入搜索查询,以便我们可以在段落中突出显示匹配的文本。以下是我们实现这一点的步骤:

  1. 我们将在app.component.ts文件中创建一个名为searchText的属性,我们将用作搜索文本输入的模型
...
export class AppComponent {
  title = 'ad-attribute-directive';
  searchText = '';
}
  1. 然后,我们在app.component.html文件中使用searchText属性作为ngModel的搜索输入,如下所示:
…
<div class="content" role="main">
  ...
    <input [(ngModel)]="searchText" type="text"     class="form-control" placeholder="Search Text"     aria-label="Username" aria-describedby=    "basic-addon1">
  </div>

重要提示

请注意,ngModel没有FormsModule无法工作,因此我们已经将FormsModule导入到我们的app.module.ts文件中。

  1. 现在,我们将通过在ad-attributes-directive项目中使用以下命令来创建一个名为highlight属性指令
 ng g d directives/highlight
  1. 上述命令生成了一个具有名为appHighlight的选择器的指令。请参阅它是如何工作的…部分,了解为什么会发生这种情况。现在我们已经放置了指令,我们将为指令创建两个输入,以从AppComponent(从app.component.html)传递 - 一个用于搜索文本,另一个用于突出显示颜色。在highlight.directive.ts文件中,代码应如下所示:
 import { Directive, Input } from '@angular/core';
@Directive({
  selector: '[appHighlight]'
})
export class HighlightDirective {
  @Input() highlightText = '';
  @Input() highlightColor = 'yellow';
  constructor() { }
}
  1. 由于我们现在已经放置了输入,让我们在app.component.html中使用appHighlight指令,并将searchText模型从那里传递到appHighlight指令:
<div class="content" role="main">
  ...
  <p class="text-content" appHighlight   [highlightText]="searchText">
    ...
  </p>
</div>
  1. 现在我们将监听searchText输入的输入更改,使用ngOnChanges。请参阅第一章,Winning Components Communication,中的使用 ngOnChanges 拦截输入属性更改一节,了解如何监听输入更改。现在,当输入更改时,我们只会执行console.log
import { Directive, Input, SimpleChanges, OnChanges } from '@angular/core';
@Directive({
  selector: '[appHighlight]'
})
export class HighlightDirective implements OnChanges {
  ...
  ngOnChanges(changes: SimpleChanges) {
    if (changes.highlightText.firstChange) {
      return;
    }
    const { currentValue } = changes.highlightText;
    console.log(currentValue);
  }
}
  1. 现在,我们将编写一些逻辑,以便在实际有东西要搜索时该怎么做。为此,我们将首先导入ElementRef服务,以便我们可以访问应用指令的模板元素。以下是我们将如何做到这一点:
import { Directive, Input, SimpleChanges, OnChanges, ElementRef } from '@angular/core';
@Directive({
  selector: '[appHighlight]'
})
export class HighlightDirective implements OnChanges {
  @Input() highlightText = '';
  @Input() highlightColor = 'yellow';
  constructor(private el: ElementRef) { }
  ...
}
  1. 现在,我们将用一些硬编码的样式替换el元素中的每个匹配文本。更新highlight.directive.ts中的ngOnChanges代码如下,并查看结果:
ngOnChanges(changes: SimpleChanges) {
    if (changes.highlightText.firstChange) {
      return;
    }
    const { currentValue } = changes.highlightText;
    if (currentValue) {
      const regExp = new RegExp(`(${currentValue})`,       'gi')
      this.el.nativeElement.innerHTML =       this.el.nativeElement.innerHTML.replace       (regExp, `<span style="background-color:       ${this.highlightColor}">\$1</span>`)
    }
 }

提示

您会注意到,如果您输入一个单词,它仍然只会显示一个字母被突出显示。这是因为每当我们替换innerHTML属性时,我们最终会改变原始文本。让我们在下一步中修复这个问题。

  1. 为了保持原始文本不变,让我们创建一个名为originalHTML的属性,并在第一次更改时为其分配一个初始值。我们还将在替换值时使用originalHTML属性:
...
export class HighlightDirective implements OnChanges {
  @Input() highlightText = '';
  @Input() highlightColor = 'yellow';
  originalHTML = '';
  constructor(private el: ElementRef) { }
  ngOnChanges(changes: SimpleChanges) {
    if (changes.highlightText.firstChange) {
      this.originalHTML = this.el.nativeElement.      innerHTML;
      return;
    }
    const { currentValue } = changes.highlightText;
    if (currentValue) {
      const regExp = new RegExp(`(${currentValue})`,       'gi')
      this.el.nativeElement.innerHTML =       this.originalHTML.replace(regExp, `<span       style="background-color: ${this.      highlightColor}">\$1</span>`)
    }
  }
}
  1. 现在,我们将编写一些逻辑,当我们删除搜索查询时(当搜索文本为空时),将一切重置回originalHTML属性。为了这样做,让我们添加一个else条件,如下所示:
...
export class HighlightDirective implements OnChanges {
  ...
  ngOnChanges(changes: SimpleChanges) {
   ...
    if (currentValue) {
      const regExp = new RegExp(`(${currentValue})`,       'gi')
      this.el.nativeElement.innerHTML = this.      originalHTML.replace(regExp, `<span       style="background-color: ${this.      highlightColor}">\$1</span>`)
    } else {
      this.el.nativeElement.innerHTML =       this.originalHTML;
    }
  }
}

它是如何工作的...

我们创建一个属性指令,接受highlightTexthighlightColor输入,然后使用SimpleChanges 应用程序编程接口 (API) 和ngOnChanges生命周期钩子监听highlightText输入的更改。

首先,我们要确保通过使用ElementRef服务获取附加的元素来保存目标元素的原始内容,使用元素上的.nativeElement.innerHTML,然后将其保存到指令的originalHTML属性中。然后,每当输入发生变化时,我们将文本替换为一个额外的 HTML 元素(一个<span>元素),并将背景颜色添加到这个span元素。然后,我们用这个修改后的内容替换目标元素的innerHTML属性。就是这样神奇!

另请参阅

创建一个指令来计算文章的阅读时间

在这个示例中,您将创建一个属性指令来计算文章的阅读时间,就像 Medium 一样。这个示例的代码受到了我在 GitHub 上现有存储库的启发,您可以在以下链接查看:github.com/AhsanAyaz/ngx-read-time

准备工作

这个示例的项目位于chapter02/start_here/ng-read-time-directive中:

  1. 在 VS Code 中打开项目。

  2. 打开终端,运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该会在新的浏览器标签中打开应用程序,您应该会看到类似于这样的东西:

图 2.2 - ng-read-time-directive 应用程序运行在 http://localhost:4200

图 2.2 - ng-read-time-directive 应用正在 http://localhost:4200 上运行

如何做…

现在,在我们的app.component.html文件中有一个段落,我们需要计算阅读时间(以分钟为单位)。让我们开始吧:

  1. 首先,我们将创建一个名为read-time的属性指令。为此,请运行以下命令:
ng g directive directives/read-time
  1. 上面的命令创建了一个appReadTime指令。我们首先将这个指令应用到app.component.html文件中id属性设置为mainContentdiv上,如下所示:
...
<div class="content" role="main" id="mainContent" appReadTime>
...
</div>
  1. 现在,我们将为我们的appReadTime指令创建一个配置对象。这个配置将包含一个wordsPerMinute值,我们将根据这个值来计算阅读时间。让我们在read-time.directive.ts文件中创建一个输入,其中包含一个导出的ReadTimeConfig接口,用于配置,如下所示:
import { Directive, Input } from '@angular/core';
export interface ReadTimeConfig {
  wordsPerMinute: number;
}
@Directive({
  selector: '[appReadTime]'
})
export class ReadTimeDirective {
  @Input() configuration: ReadTimeConfig = {
    wordsPerMinute: 200
  }
  constructor() { }
}
  1. 现在我们可以继续获取文本以计算阅读时间。为此,我们将使用ElementRef服务来检索元素的textContent属性。我们将提取textContent属性并将其分配给ngOnInit生命周期钩子中的一个名为text的局部变量,如下所示:
import { Directive, Input, ElementRef, OnInit } from '@angular/core';
...
export class ReadTimeDirective implements OnInit {
  @Input() configuration: ReadTimeConfig = {
    wordsPerMinute: 200
  }
  constructor(private el: ElementRef) { }
  ngOnInit() {
    const text = this.el.nativeElement.textContent;
  }
}
  1. 现在我们的文本变量已经填满了元素的整个文本内容,我们可以计算阅读这段文本所需的时间。为此,我们将创建一个名为calculateReadTime的方法,并将text属性传递给它,如下所示:
...
export class ReadTimeDirective implements OnInit {
  ...
  ngOnInit() {
    const text = this.el.nativeElement.textContent;
    const time = this.calculateReadTime(text);
  }
  calculateReadTime(text: string) {
    const wordsCount = text.split(/\s+/g).length;
    const minutes = wordsCount / this.configuration.    wordsPerMinute;
    return Math.ceil(minutes);
  }
}
  1. 现在我们已经得到了以分钟为单位的时间,但目前它还不是一个用户可读的格式,因为它只是一个数字。我们需要以一种用户可以理解的方式显示它。为此,我们将进行一些小的计算,并创建一个适当的字符串来显示在用户界面UI)上。代码如下所示:
...
@Directive({
  selector: '[appReadTime]'
})
export class ReadTimeDirective implements OnInit {
...
  ngOnInit() {
    const text = this.el.nativeElement.textContent;
    const time = this.calculateReadTime(text);
    const timeStr = this.createTimeString(time);
    console.log(timeStr);
  }
...
  createTimeString(timeInMinutes) {
    if (timeInMinutes === 1) {
      return '1 minute';
    } else if (timeInMinutes < 1) {
      return '< 1 minute';
    } else {
      return `${timeInMinutes} minutes`;
    }
  }
}

请注意,到目前为止,当您刷新应用程序时,您应该能够在控制台上看到分钟数。

  1. 现在,让我们在指令中添加一个@Output(),这样我们就可以在父组件中获取阅读时间并在 UI 上显示它。让我们在read-time.directive.ts文件中添加如下内容:
import { Directive, Input, ElementRef, OnInit, Output, EventEmitter } from '@angular/core';
...
export class ReadTimeDirective implements OnInit {
  @Input() configuration: ReadTimeConfig = {
    wordsPerMinute: 200
  }
  @Output() readTimeCalculated = new   EventEmitter<string>();
  constructor(private el: ElementRef) { }
...
}
  1. 让我们使用readTimeCalculated输出来在我们计算出阅读时间时从ngOnInit()方法中发出timeStr变量的值:
...
export class ReadTimeDirective {
...
  ngOnInit() {
    const text = this.el.nativeElement.textContent;
    const time = this.calculateReadTime(text);
    const timeStr = this.createTimeString(time);
    this.readTimeCalculated.emit(timeStr);
  }
...
}
  1. 由于我们使用 readTimeCalculated 输出来发出阅读时间值,我们必须在 app.component.html 文件中监听这个输出的事件,并将其分配给 AppComponent 类的一个属性,以便我们可以在视图中显示它。但在此之前,我们将在 app.component.ts 文件中创建一个本地属性来存储输出事件的值,并且我们还将创建一个在输出事件触发时调用的方法。代码如下所示:
...
export class AppComponent {
  readTime: string;
  onReadTimeCalculated(readTimeStr: string) {
    this.readTime = readTimeStr;
} 
}
  1. 我们现在可以在 app.component.html 文件中监听输出事件,然后当 readTimeCalculated 输出事件被触发时调用 onReadTimeCalculated 方法:
...
<div class="content" role="main" id="mainContent" appReadTime (readTimeCalculated)="onReadTimeCalculated($event)">
...
</div>
  1. 现在,我们可以在 app.component.html 文件中显示阅读时间,如下所示:
<div class="content" role="main" id="mainContent" appReadTime (readTimeCalculated)="onReadTimeCalculated($event)">
  <h4>Read time = {{readTime}}</h4>
  <p class="text-content">
    Silent sir say desire fat him letter. Whatever     settling goodness too and honoured she building     answered her. ...
  </p>
...
</div>

它是如何工作的…

appReadTime 指令是这个示例的核心。我们在指令内部使用 ElementRef 服务来获取指令附加到的原生元素,然后取出它的文本内容。然后,我们只需要进行计算。我们首先使用 /\s+/g 正则表达式 (regex) 将整个文本内容分割成单词,从而计算出文本内容中的总单词数。然后,我们将单词数除以配置中的 wordsPerMinute 值,以计算阅读整个文本需要多少分钟。轻而易举

另请参阅

创建一个基本指令,允许您垂直滚动到一个元素

在这个示例中,您将创建一个指令,允许用户点击时滚动到页面上的特定元素。

准备工作

这个示例的项目位于 chapter02/start_here/ng-scroll-to-directive

  1. 在 VS Code 中打开项目。

  2. 打开终端,并运行 npm install 来安装项目的依赖项。

  3. 完成后,运行 ng serve -o

这应该在新的浏览器标签中打开应用程序,您应该看到类似于这样的东西:

图 2.3 – ng-scroll-to-directive 应用程序运行在 http://localhost:4200

图 2.3 – ng-scroll-to-directive 应用程序运行在 http://localhost:4200

如何做…

  1. 首先,我们将创建一个scroll-to指令,以便我们可以通过平滑滚动到不同的部分来增强我们的应用程序。我们将使用以下命令在项目中实现这一点:
ng g directive directives/scroll-to
  1. 现在,我们需要使指令能够接受一个包含我们将在元素的click事件上滚动到的目标部分的层叠样式表CSS查询选择器@Input()。让我们将输入添加到我们的scroll-to.directive.ts文件中,如下所示:
import { Directive, Input } from '@angular/core';
@Directive({
  selector: '[appScrollTo]'
})
export class ScrollToDirective {
  @Input() target = '';
  constructor() { }
}
  1. 现在,我们将appScrollTo指令应用到app.component.html文件中的链接上,同时还指定了相应的目标,以便我们可以在接下来的步骤中实现滚动逻辑。代码应该如下所示:
...
<div class="content" role="main">
  <div class="page-links">
    <h4 class="page-links__heading">
      Links
    </h4>
    <a class="page-links__link" appScrollTo     target="#resources">Resources</a>
    <a class="page-links__link" appScrollTo     target="#nextSteps">Next Steps</a>
    <a class="page-links__link" appScrollTo     target="#moreContent">More Content</a>
    <a class="page-links__link" appScrollTo     target="#furtherContent">Further Content</a>
    <a class="page-links__link" appScrollTo     target="#moreToRead">More To Read</a>
  </div>
  ...
  <div class="to-top-button">
    <a appScrollTo target="#toolbar" class=    "material-icons">
      keyboard_arrow_up
    </a>
  </div>
</div>
  1. 现在,我们将实现HostListener()装饰器,将click事件绑定到附加了指令的元素上。当我们点击链接时,我们将在控制台上记录target输入的值。让我们实现这个,然后你可以尝试点击链接,看看控制台上target输入的值:
import { Directive, Input, HostListener } from '@angular/core';
@Directive({
  selector: '[appScrollTo]'
})
export class ScrollToDirective {
  @Input() target = '';
  @HostListener('click')
  onClick() {
    console.log(this.target);
  }
  ...
}
  1. 由于我们已经设置了click处理程序,现在我们可以实现滚动到特定目标的逻辑。为此,我们将使用document.querySelector方法,使用target变量的值来获取元素,然后使用Element.scrollIntoView() web API 来滚动目标元素。通过这个改变,当你点击相应的链接时,页面应该已经滚动到目标元素了:
...
export class ScrollToDirective {
  @Input() target = '';
  @HostListener('click')
  onClick() {
    const targetElement = document.querySelector     (this.target);
    targetElement.scrollIntoView();
  }
  ...
}
  1. 好了,我们让滚动起作用了。"但是,阿赫桑,有什么新鲜事吗?这不是我们以前使用 href 实现的吗?" 好吧,你是对的。但是,我们将使滚动非常平滑。我们将使用scrollIntoViewOptions作为scrollIntoView方法的参数,使用{behavior: "smooth"}值在滚动过程中使用动画。代码应该如下所示:
...
export class ScrollToDirective {
  @Input() target = '';
  @HostListener('click')
  onClick() {
    const targetElement = document.querySelector     (this.target);
    targetElement.scrollIntoView({behavior: 'smooth'});
  }
  constructor() { }
}

工作原理...

这个食谱的精髓是我们在 Angular 指令中使用的 web API,即Element.scrollIntoView()。我们首先将我们的appScrollTo指令附加到应该在点击时触发滚动的元素上。我们还通过为每个附加的指令使用target输入来指定要滚动到哪个元素。然后,我们在指令内部实现click处理程序,使用scrollIntoView()方法滚动到特定目标,并且为了在滚动时使用平滑动画,我们将{behavior: 'smooth'}对象作为参数传递给scrollIntoView()方法。

还有更多...

编写您的第一个自定义结构指令

在这个示例中,您将编写您的第一个自定义结构指令,名为 *appIfNot,它将执行与 *ngIf 相反的操作 - 也就是说,您将向指令提供一个布尔值,当该值为 false 时,它将显示附加到指令的内容,而不是 *ngIf 指令在提供的值为 true 时显示内容。

准备工作

此示例中的项目位于 chapter02/start_here/ng-if-not-directive

  1. 在 VS Code 中打开项目。

  2. 打开终端,并运行 npm install 来安装项目的依赖项。

  3. 完成后,运行 ng serve -o

这将在新的浏览器选项卡中打开应用程序,您应该看到类似于这样的内容:

图 2.4 - ng-if-not-directive 应用程序在 http://localhost:4200 上运行

图 2.4 - ng-if-not-directive 应用程序在 http://localhost:4200 上运行

如何做…

  1. 首先,我们将使用以下命令在项目根目录中创建一个指令:
ng g directive directives/if-not
  1. 现在,在 app.component.html 文件中,我们可以使用我们的 *appIfNot 指令,而不是 *ngIf 指令。我们还将条件从 visibility === VISIBILITY.Off 反转为 visibility === VISIBILITY.On,如下所示:
...
<div class="content" role="main">
  ...
  <div class="page-section" id="resources"   *appIfNot="visibility === VISIBILITY.On">
    <!-- Resources -->
    <h2>Content to show when visibility is off</h2>
  </div>
</div>
  1. 现在,我们已经设置了条件,我们需要在 *appIfNot 指令内部创建一个接受布尔值的 @Input。我们将使用一个 setter 来拦截值的变化,并暂时将值记录在控制台上:
import { Directive, Input } from '@angular/core';
@Directive({
  selector: '[appIfNot]'
})
export class IfNotDirective {
  constructor() { }
  @Input() set appIfNot(value: boolean) {
    console.log(`appIfNot value is ${value}`);
  }
}
  1. 如果现在点击Visibility OnVisibility Off按钮,您应该看到值的变化并反映在控制台上,如下所示:图 2.5 - 控制台日志显示 appIfNot 指令值的更改

图 2.5 - 控制台日志显示 appIfNot 指令值的更改

  1. 现在,我们将朝着根据值为 falsetrue 显示和隐藏内容的实际实现前进,为此,我们首先需要将 TemplateRef 服务和 ViewContainerRef 服务注入到 if-not.directive.ts 的构造函数中。让我们按照以下方式添加这些内容:
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
@Directive({
  selector: '[appIfNot]'
})
export class IfNotDirective {
  constructor(private templateRef: TemplateRef<any>,   private viewContainerRef: ViewContainerRef) { }
  @Input() set appIfNot(value: boolean) {
    console.log(`appIfNot value is ${value}`);
  }
}
  1. 最后,我们可以添加逻辑来根据appIfNot输入的值添加/删除 DOM 中的内容,如下所示:
...
export class IfNotDirective {
  constructor(private templateRef: TemplateRef<any>,   private viewContainerRef: ViewContainerRef) { }
  @Input() set appIfNot(value: boolean) {
    if (value === false) {
      this.viewContainerRef.      createEmbeddedView(this.templateRef);
    } else {
      this.viewContainerRef.clear()
    }
  }
}

它是如何工作的...

在 Angular 中,结构指令有多个特殊之处。首先,它们允许您操作 DOM 元素,即根据您的需求添加/删除/操作。此外,它们具有*前缀,该前缀绑定到 Angular 在幕后执行的所有魔法。例如,*ngIf*ngFor都是结构指令,它们在幕后使用包含您绑定指令的内容的<ng-template>指令,并为您在ng-template的作用域中创建所需的变量/属性。在这个示例中,我们做同样的事情。我们使用TemplateRef服务来访问 Angular 在幕后为我们创建的包含应用appIfNot指令的宿主元素<ng-template>指令。然后,根据指令作为输入提供的值,我们决定是将神奇的ng-template添加到视图中,还是清除ViewContainerRef服务以删除其中的任何内容。

另请参阅

如何同时使用ngIf 和ngSwitch

在某些情况下,您可能希望在同一个宿主上使用多个结构指令,例如*ngIf*ngFor的组合。在这个示例中,您将学习如何做到这一点。

准备工作

我们将要处理的项目位于克隆存储库内的chapter02/start_here/multi-structural-directives中。

  1. 在 VS Code 中打开项目。

  2. 打开终端,并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该会在新的浏览器标签中打开应用程序,你应该会看到类似这样的东西:

图 2.6-多结构指令应用程序在 http://localhost:4200 上运行

图 2.6-多结构指令应用程序在 http://localhost:4200 上运行

现在我们的应用程序正在运行,让我们在下一节中看看这个食谱的步骤。

如何做…

  1. 我们将首先将带有桶中没有物品。添加一些水果!文本的元素移入自己的<ng-template>元素,并给它一个名为#bucketEmptyMessage的模板变量。代码应该在app.component.html文件中如下所示:
…
<div class="content" role="main">
 ...
  <div class="page-section">
    <h2>Bucket <i class="material-icons">shopping_cart     </i></h2>
    <div class="fruits">
      <div class="fruits__item" *ngFor="let item of       bucket;">
        <div class="fruits__item__title">{{item.name}}        </div>
        <div class="fruits__item__delete-icon"         (click)="deleteFromBucket(item)">
          <div class="material-icons">delete</div>
        </div>
      </div>
    </div>
  </div>
  <ng-template #bucketEmptyMessage>
    <div class="fruits__no-items-msg">
      No items in bucket. Add some fruits!
    </div>
  </ng-template>
</div>
  1. 请注意,我们将整个div移出了.page-section div。现在,我们将使用ngIf-Else语法根据桶的长度显示桶列表或空桶消息。让我们修改代码,如下所示:
...
<div class="content" role="main">
  ...
  <div class="page-section">
    <h2>Bucket <i class="material-icons">shopping_cart     </i></h2>
    <div class="fruits">
      <div *ngIf="bucket.length > 0; else       bucketEmptyMessage" class="fruits__item"       *ngFor="let item of bucket;">
        <div class="fruits__item__title">{{item.name}}        </div>
        <div class="fruits__item__delete-icon"         (click)="deleteFromBucket(item)">
          <div class="material-icons">delete</div>
        </div>
      </div>
    </div>
  </div>
...
</div>

一旦保存了上述代码,您会看到应用程序崩溃,并提到我们不能在一个元素上使用多个模板绑定。这意味着我们不能在一个元素上使用多个结构指令:

图 2.7 - 控制台上的错误,显示我们不能在一个元素上使用多个指令

图 2.7 - 控制台上的错误,显示我们不能在一个元素上使用多个指令

  1. 现在,作为最后一步,让我们通过将带有*ngFor="let item of bucket;"的 div 包装在<ng-container>元素内,并在<ng-container>元素上使用*ngIf指令来解决这个问题,如下所示:
...
<div class="content" role="main">
  ...
  <div class="page-section">
    <h2>Bucket <i class="material-icons">shopping_cart     </i></h2>
    <div class="fruits">
      <ng-container *ngIf="bucket.length > 0; else       bucketEmptyMessage">
        <div class="fruits__item" *ngFor="let item         of bucket;">
          <div class="fruits__item__title">{{item.          name}}</div>
          <div class="fruits__item__delete-icon"           (click)="deleteFromBucket(item)">
            <div class="material-icons">delete</div>
          </div>
        </div>
      </ng-container>
    </div>
  </div>
</div>

工作原理…

由于我们不能在单个元素上使用两个结构指令,我们总是可以使用另一个 HTML 元素作为父元素来使用另一个结构指令。然而,这会向 DOM 添加另一个元素,并根据您的实现可能会导致元素层次结构出现问题。然而,<ng-container>是 Angular 核心中的一个神奇元素,它不会添加到 DOM 中。相反,它只是包装您应用于它的逻辑/条件,这使得我们可以很容易地在现有元素上添加*ngIf*ngSwitchCase指令。

另请参阅

增强自定义指令的模板类型检查

在这个食谱中,您将学习如何使用 Angular 最近版本引入的静态模板保护来改进自定义 Angular 指令模板的类型检查。我们将增强我们的appHighlight指令的模板类型检查,以便它只接受一组缩小的输入。

准备工作

我们要处理的项目位于克隆存储库中的chapter02/start_here/enhanced-template-type-checking中:

  1. 在 VS Code 中打开项目。

  2. 打开终端,并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该在新的浏览器选项卡中打开应用程序,你应该看到类似这样的东西:

图 2.8-增强模板类型检查应用程序正在 http://localhost:4200 上运行

图 2.8-增强模板类型检查应用程序正在 http://localhost:4200 上运行

现在应用程序正在运行,让我们在下一节中看看这个配方的步骤。

如何做…

  1. 首先,我们将尝试识别问题,这归结为能够将任何字符串作为appHighlight指令的highlightColor属性/输入的颜色。试一试。将'#dcdcdc'值作为输入,你会有一个破碎的高亮颜色,但没有任何错误:
...
<div class="content" role="main">
  ...
  <p class="text-content" appHighlight   [highlightColor]="'#dcdcdc'"   [highlightText]="searchText">
    ...
  </p>
</div>
  1. 好吧,我们该怎么解决呢?通过向我们的tsconfig.json文件添加一些angularCompileOptions。我们将通过将名为strictInputTypes的标志添加为true来实现这一点。停止应用程序服务器,修改代码如下,并重新运行ng serve命令以查看更改:
{
  "compileOnSave": false,
  "compilerOptions": {
    ...
  },
  "angularCompilerOptions": {
    "strictInputTypes": true
  }
}

你应该看到类似这样的东西:

图 2.9-strictInputTypes 帮助构建时错误不兼容类型

图 2.9-strictInputTypes 帮助构建时错误不兼容类型

  1. 好了,太棒了!Angular 现在识别出提供的'#dcdcdc'值不可分配给HighlightColor类型。但是,如果有人尝试提供null作为值会发生什么?还好吗?答案是否定的。我们仍然会有一个破碎的体验,但没有任何错误。为了解决这个问题,我们将为我们的angularCompilerOptions启用两个标志-strictNullChecksstrictNullInputTypes
{
  "compileOnSave": false,
  "compilerOptions": {
    ...
  },
  "angularCompilerOptions": {
    "strictInputTypes": true,
    "strictNullChecks": true,
    "strictNullInputTypes": true
  }
}
  1. 更新app.component.html文件,将null作为[highlightColor]属性的值,如下所示:
...
<div class="content" role="main">
  ...
  <p class="text-content" appHighlight   [highlightColor]="null" [highlightText]="searchText">
   ...
</div>
  1. 停止服务器,保存文件,并重新运行ng serve,你会看到我们现在有另一个错误,如下所示:图 2.10-使用 strictNullInputTypes 和 strictNullChecks 进行错误报告

图 2.10-使用 strictNullInputTypes 和 strictNullChecks 进行错误报告

  1. 现在,我们不再需要为更多情况设置如此多的标志,实际上我们只需要两个标志就可以为我们完成所有的魔术并覆盖大多数应用程序——strictNullChecks标志和strictTemplates标志:
{
  "compileOnSave": false,
  "compilerOptions": {
   ...
  },
  "angularCompilerOptions": {
    "strictNullChecks": true,
    "strictTemplates": true
  }
}
  1. 最后,我们可以将HighlightColor枚举导入到我们的app.component.ts文件中。我们将在AppComponent类中添加一个hColor属性,并将其赋值为HighlightColor枚举中的一个值,如下所示:
import { Component } from '@angular/core';
import { HighlightColor } from './directives/highlight.directive';
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  searchText = '';
  hColor: HighlightColor = HighlightColor.LightCoral;
}
  1. 现在,我们将在app.component.html文件中使用hColor属性将其传递给appHighlight指令。这应该解决所有问题,并使浅珊瑚色成为我们指令的指定高亮颜色:
<div class="content" role="main">
...
  <p class="text-content" appHighlight   [highlightColor]="hColor" [highlightText]="searchText">
    ...
  </p>
</div>

另请参阅

第三章:第三章:Angular 中的依赖注入的魔力

本章主要讲解 Angular 中依赖注入DI)的魔力。在这里,您将学习有关 Angular 中 DI 概念的详细信息。DI 是 Angular 用来将不同的依赖项注入到组件、指令和服务中的过程。您将使用几个示例来使用服务和提供程序,以获得一些实践经验,这些经验可以在以后的 Angular 项目中使用。

在本章中,我们将涵盖以下内容:

  • 使用 DI 令牌配置注入器

  • 可选依赖项

  • 使用providedIn创建单例服务

  • 使用forRoot()创建单例服务

  • 使用相同的别名类提供程序为应用程序提供不同的服务

  • Angular 中的值提供程序

技术要求

对于本章的示例,请确保您的机器上安装了GitNodeJS。您还需要安装@angular/cli包,可以在终端中使用npm install -g @angular/cli来安装。本章的代码可以在github.com/PacktPublishing/Angular-Cookbook/tree/master/chapter03找到。

使用 DI 令牌配置注入器

在这个示例中,您将学习如何为常规的 TypeScript 类创建一个基本的 DI 令牌,以便用作 Angular 服务。我们的应用程序中有一个服务(UserService),它当前使用Greeter类来创建一个具有greet方法的用户。由于 Angular 完全依赖于 DI 和服务,我们将实现一种方式来使用这个常规的 TypeScript 类,名为Greeter,作为 Angular 服务。我们将使用InjectionToken来创建一个 DI 令牌,然后使用@Inject装饰器来使我们能够在我们的服务中使用该类。

准备工作

我们将要处理的项目位于chapter03/start_here/ng-di-token中,该项目位于克隆存储库内。执行以下步骤:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该在新的浏览器标签中打开应用程序;您应该看到类似以下截图的内容:

图 3.1- ng-di-token 应用程序在 http://localhost:4200 上运行

图 3.1- ng-di-token 应用程序在 http://localhost:4200 上运行

现在我们的应用程序正在运行,我们可以继续进行食谱的步骤。

如何做...

我们现在的应用程序向从我们的UserService中检索到的随机用户显示问候消息。而UserService使用Greeter类就像它是的。我们将不再将其作为类使用,而是使用 DI 将其作为 Angular 服务使用。我们将首先为我们的Greeter类创建一个InjectionToken,然后将其注入到我们的服务中。按照以下步骤进行:

  1. 我们将在greeter.class.ts文件中创建一个InjectionToken,名为'Greeter',使用@angular/core包中的InjectionToken类。此外,我们将从文件中导出此令牌:
import { InjectionToken } from '@angular/core';
import { User } from '../interfaces/user.interface';
export class Greeter implements User {
  ...
}
export const GREETER = new InjectionToken('Greeter', {
  providedIn: 'root',
  factory: () => Greeter
});
  1. 现在,我们将使用@angular/core包中的Inject装饰器和greeter.class.ts中的GREETER令牌,以便我们可以在下一步中使用它们:
import { Inject, Injectable } from '@angular/core';
import { GREETER, Greeter } from '../classes/greeter.class';
@Injectable({
  providedIn: 'root'
})
export class UserService {
  ...
} 
  1. 我们现在将使用@Inject装饰器在UserServiceconstructor中注入Greeter类作为 Angular 服务。

请注意,我们将使用typeof Greeter而不是只使用Greeter,因为我们需要稍后使用构造函数。

...
export class UserService {
  ...
  constructor(@Inject(GREETER) public greeter: typeof    Greeter) { }
  ...
}
  1. 最后,我们可以通过使用注入的服务来替换getUser方法中new Greeter(user)的用法,如下所示:
...
export class UserService {
  ...
  getUser() {
    const user = this.users[Math.floor(Math.random()     * this.users.length)]
    return new this.greeter(user);
  }
}

现在我们知道了方法,让我们更仔细地看看它是如何工作的。

它是如何工作的

Angular 在服务中不认识普通的 TypeScript 类作为可注入的对象。然而,我们可以创建自己的注入令牌,并使用@Inject装饰器在可能的情况下注入它们。Angular 在后台识别我们的令牌并找到其对应的定义,通常是以工厂函数的形式。请注意,我们在令牌定义中使用了providedIn: 'root'。这意味着整个应用程序中只会有一个类的实例。

另请参阅

可选依赖

在 Angular 中,可选依赖项在您使用或配置可能存在或已在 Angular 应用程序中提供的依赖项时非常强大。在本示例中,我们将学习如何使用@Optional装饰器来配置组件/服务中的可选依赖项。我们将使用LoggerService,并确保我们的组件在未提供时不会中断。

准备工作

该示例项目位于chapter03/start_here/ng-optional-dependencies中。执行以下步骤:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端,并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这将在新的浏览器选项卡中打开应用程序。您应该看到类似以下截图的内容:

图 3.2 - ng-optional-dependencies 应用程序在 http://localhost:4200 上运行

图 3.2 - ng-optional-dependencies 应用程序在 http://localhost:4200 上运行

现在我们的应用程序正在运行,我们可以继续进行该示例的步骤。

如何操作

我们将从一个具有providedIn: 'root'设置为其可注入配置的LoggerService的应用程序开始。我们将看到当我们没有在任何地方提供此服务时会发生什么。然后,我们将使用@Optional装饰器来识别和解决问题。按照以下步骤进行操作:

  1. 首先,让我们运行应用程序并更改输入中的版本。

这将导致日志通过LoggerService保存在localStorage中。打开Chrome Dev Tools,导航到Application,选择Local Storage,然后点击localhost:4200。您将看到具有日志值的key log_log,如下所示:

图 3.3 - 日志保存在 http://localhost:4200 的 localStorage 中

图 3.3 - 日志保存在 http://localhost:4200 的 localStorage 中

  1. 现在,让我们尝试删除@Injectable装饰器中提供的LoggerService的配置,如下面的代码中所突出显示的那样:
import { Injectable } from '@angular/core';
import { Logger } from '../interfaces/logger';
@Injectable({ 
  providedIn: 'root' ← Remove
})
export class LoggerService implements Logger {
  ...
} 

这将导致 Angular 无法识别它,并向VcLogsComponent抛出错误:

图 3.4 - 详细说明了 Angular 无法识别 LoggerService 的错误

图 3.4 - 详细说明了 Angular 无法识别 LoggerService 的错误

  1. 现在,我们可以使用@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 loggerService:   LoggerService) {
    this.logger = this.loggerService;
  }
  ...
} 

太好了!现在,如果您刷新应用程序并查看控制台,就不应该有任何错误。但是,如果您更改版本并点击提交按钮,您将看到它抛出以下错误,因为组件无法检索LoggerService作为依赖项:

图 3.5 - 一个错误,详细说明此时 this.logger 实质上为 null

图 3.5 - 一个错误,详细说明此时 this.logger 实质上为 null

  1. 为了解决这个问题,我们可以决定根本不记录任何东西,或者如果未提供LoggerService,我们可以回退到console.*方法。回退到console.*方法的代码应该如下所示:
...
export class VcLogsComponent implements OnInit {
  ...
  constructor(@Optional() private loggerService:   LoggerService) {
    if (!this.loggerService) {
      this.logger = console;
    } else {
      this.logger = this.loggerService;
    }
  }
  ... 

现在,如果您更新版本并点击提交,您应该在控制台上看到日志,如下所示:

图 3.6 - 作为 LoggerService 未提供的回退而在控制台上打印的日志

图 3.6 - 作为 LoggerService 未提供的回退而在控制台上打印的日志

太好了!我们已经完成了这个示例,一切看起来都很好。请参考下一节以了解它是如何工作的。

它是如何工作的

@Optional装饰器是来自@angular/core包的特殊参数,它允许您将一个依赖项的参数标记为可选的。在幕后,当依赖项不存在或未提供给应用程序时,Angular 将提供值为null

另请参阅

使用 providedIn 创建单例服务

在这个示例中,您将学习如何确保您的 Angular 服务被用作单例的几个技巧。这意味着整个应用程序中只会有一个服务实例。在这里,我们将使用一些技术,包括providedIn: 'root'语句,并确保我们只在整个应用程序中提供服务一次,使用@Optional()@SkipSelf()装饰器。

准备就绪

此配方的项目位于chapter03/start_here/ng-singleton-service路径中。执行以下步骤:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端,并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这将在新的浏览器选项卡中打开应用程序。您应该看到类似于以下截图的内容:

图 3.7 - ng-singleton-service 应用程序在 http://localhost:4200 上运行

图 3.7 - ng-singleton-service 应用程序在 http://localhost:4200 上运行

现在您的应用程序正在运行,让我们继续并查看这个配方的步骤。

如何做到

该应用程序的问题在于,如果您添加或删除任何通知,标题中的铃铛图标上的计数不会改变。这是因为我们有多个NotificationsService的实例。请参考以下步骤,以确保我们在应用程序中只有一个服务实例:

  1. 首先,作为 Angular 开发人员,我们已经知道我们可以使用providedIn: 'root'来告诉 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以及VersioningModule中提供了该服务。

  1. 首先,在app.module.ts中的providers数组中删除NotificationsService,如下面的代码块所示:
...
import { NotificationsButtonComponent } from './components/notifications-button/notifications-button.component';
import { NotificationsService } from './services/notifications.service'; ← Remove this
@NgModule({
  declarations: [... ],
  imports: [...],
  providers: [
    NotificationsService ← Remove this
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }
  1. 现在,我们将从versioning.module.ts中删除NotificationsService,如下面的代码块所示:
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { VersioningRoutingModule } from './versioning-routing.module';
import { VersioningComponent } from './versioning.component';
import { NotificationsManagerComponent } from './components/notifications-manager/notifications-manager.component';
import { NotificationsService } from '../services/notifications.service'; ← Remove this
@NgModule({
  declarations: [VersioningComponent,   NotificationsManagerComponent],
  imports: [
    CommonModule,
    VersioningRoutingModule,
  ],
  providers: [
    NotificationsService  ← Remove this
  ]
})
export class VersioningModule { }

太棒了!现在您应该能够看到标题中的计数根据您添加/删除通知而改变。但是,如果有人仍然错误地在另一个懒加载的模块中提供它会发生什么呢?

  1. 让我们把NotificationsService放回versioning.module.ts文件中:
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { VersioningRoutingModule } from './versioning-routing.module';
import { VersioningComponent } from './versioning.component';
import { NotificationsManagerComponent } from './components/notifications-manager/notifications-manager.component';
import { NotificationsService } from '../services/notifications.service';
@NgModule({
  declarations: [VersioningComponent,   NotificationsManagerComponent],
  imports: [
    CommonModule,
    VersioningRoutingModule,
  ],
  providers: [
    NotificationsService
  ]
})
export class VersioningModule { }

砰!控制台或编译时没有任何错误。然而,我们的问题是标题中的计数没有更新。那么,如果开发人员犯了这样的错误,我们该如何通知他们呢?请参考下一步。

  1. 为了提醒开发人员可能存在重复提供者,我们在NotificationsService中使用@angular/core包中的@SkipSelf装饰器,并抛出错误来通知和修改NotificationsService,如下所示:
import { Injectable, SkipSelf } from '@angular/core';
...
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 中的错误

图 3.8 - 详细说明 NotificationsService 无法注入到 NotificationsService 中

原因是NotificationsService现在是NotificationsService本身的一个依赖项。这是行不通的,因为它还没有被 Angular 解析。为了解决这个问题,我们在下一步中也将使用@Optional()装饰器。

  1. 好了,现在我们将在notifications.service.ts中的构造函数中,与@SkipSelf装饰器一起使用@Optional()装饰器。代码应该如下所示:
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 已经在应用程序中提供

图 3.9 - 详细说明 NotificationsService 已经在应用程序中提供

  1. 现在,我们将安全地从versioning.module.ts文件的providers数组中移除提供的NotificationsService,并检查应用程序是否正常工作:
...
import { NotificationsManagerComponent } from './components/notifications-manager/notifications-manager.component';
import { NotificationsService } from '../services/notifications.service'; ← Remove this
@NgModule({
  declarations: [...],
  imports: [...],
  providers: [
    NotificationsService ← Remove this
  ]
})
export class VersioningModule { }

砰!我们现在使用了providedIn策略来创建一个单例服务。在下一节中,让我们讨论它是如何工作的。

它是如何工作的

每当我们尝试在某个地方注入一个服务时,默认情况下,它会尝试在注入服务的相关模块中查找服务。当我们使用providedIn: 'root'来声明一个服务时,无论在应用程序的任何地方注入服务,Angular 都知道它只需在根模块中找到服务定义,而不是在功能模块或其他任何地方。

但是,您必须确保该服务在整个应用程序中只提供一次。如果您在多个模块中提供它,即使使用了providedIn: 'root',您也会有多个服务实例。为了避免在应用程序中的多个模块或多个位置提供服务,我们可以在服务的构造函数中使用@SkipSelf()装饰器和@Optional()装饰器来检查服务是否已经在应用程序中提供。

另请参阅

使用 forRoot()创建一个单例服务

在这个食谱中,您将学习如何使用ModuleWithProvidersforRoot()语句来确保您的 Angular 服务在整个应用程序中作为单例使用。我们将从一个具有多个NotificationsService实例的应用程序开始,并实现必要的代码,以确保最终得到一个应用程序的单个实例。

准备工作

这个食谱的项目位于chapter03/start_here/ng-singleton-service-forroot路径下。执行以下步骤:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端,运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该会在新的浏览器标签中打开应用程序。应用程序应该如下所示:

图 3.10 – ng-singleton-service-forroot 应用程序运行在 http://localhost:4200

图 3.10 – ng-singleton-service-forroot 应用程序运行在 http://localhost:4200

现在我们的应用程序正在运行,在接下来的部分,我们可以继续进行食谱的步骤。

操作步骤

为了确保我们在应用程序中只有一个单例服务使用forRoot()方法,您需要了解如何创建和实现ModuleWithProvidersstatic forRoot()方法。执行以下步骤:

  1. 首先,我们要确保服务有自己的模块。在许多 Angular 应用程序中,您可能会看到CoreModule,其中提供了服务(假设我们没有出于某种原因使用providedIn: 'root'语法)。首先,我们将使用以下命令创建一个名为ServicesModule的模块:
ng g m services
  1. 现在我们已经创建了模块,让我们在services.module.ts文件中创建一个静态方法。我们将命名该方法为forRoot,并返回一个包含在providers数组中提供的NotificationsServiceModuleWithProviders对象,如下所示:
 import { ModuleWithProviders, NgModule } from  '@angular/core';
import { CommonModule } from '@angular/common';
import { NotificationsService } from '../services/notifications.service';
@NgModule({
    ...
})
export class ServicesModule {
  static forRoot(): ModuleWithProviders<ServicesModule> {
    return {
      ngModule: ServicesModule,
      providers: [
        NotificationsService
      ]
    };
  }
}
  1. 现在我们将从app.module.ts文件的imports数组中删除NotificationsService,并在app.module.ts文件中包含ServicesModule;特别是,我们将在imports数组中使用forRoot()方法添加,如下面的代码块中所示。

这是因为它在AppModule中用ServicesModule注入了提供者,例如,NotificationsService的提供方式如下:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { NotificationsButtonComponent } from './components/notifications-button/notifications-button.component';
import { NotificationsService } from './services/notifications.service'; ← Remove this
import { ServicesModule } from './services/services.module';
@NgModule({
  declarations: [
    AppComponent,
    NotificationsButtonComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    ServicesModule.forRoot()
  ],
  providers: [
    NotificationsService ← Remove this
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

您会注意到,当添加/删除通知时,标题中的计数仍然不会改变。这是因为我们仍然在versioning.module.ts文件中提供了NotificationsService

  1. 我们将从versioning.module.ts文件的providers数组中删除NotificationsService,如下所示:
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { VersioningRoutingModule } from './versioning-routing.module';
import { VersioningComponent } from './versioning.component';
import { NotificationsManagerComponent } from './components/notifications-manager/notifications-manager.component';
import { NotificationsService } from '../services/notifications.service'; ← Remove
@NgModule({
  declarations: [VersioningComponent,   NotificationsManagerComponent],
  imports: [
    CommonModule,
    VersioningRoutingModule,
  ],
  providers: [
    NotificationsService ← Remove
  ]
})
export class VersioningModule { }

好的,到目前为止,你做得很好。现在我们已经完成了这个教程,在下一节中,让我们讨论它是如何工作的。

它是如何工作的

ModuleWithProvidersNgModule的包装器,与NgModule中使用的providers数组相关联。它允许您声明带有提供者的NgModule,因此导入它的模块也会得到这些提供者。我们在ServicesModule类中创建了一个forRoot()方法,它返回包含我们提供的NotificationsServiceModuleWithProviders。这使我们能够在整个应用程序中只提供一次NotificationsService,这导致应用程序中只有一个服务实例。

另请参阅

使用相同的别名类提供者为应用程序提供不同的服务

在这个教程中,您将学习如何使用Aliased类提供者为应用程序提供两种不同的服务。这在复杂的应用程序中非常有帮助,其中您需要缩小一些组件/模块的基类实现。此外,别名在组件/服务单元测试中用于模拟依赖服务的实际实现,以便我们不依赖于它。

准备工作

我们将要处理的项目位于 chapter03/start_here/ng-aliased-class-providers 路径中,该路径位于克隆存储库内。执行以下步骤:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行 npm install 来安装项目的依赖项。

  3. 完成后,运行 ng serve -o

这将在新的浏览器选项卡中打开应用程序。

  1. 单击以管理员身份登录按钮。您应该看到类似以下截图的内容:

图 3.11 - ng-aliased-class-providers 应用程序在 http://localhost:4200 上运行

图 3.11 - ng-aliased-class-providers 应用程序在 http://localhost:4200 上运行

现在应用程序正在运行,让我们转到下一节,按照食谱的步骤进行操作。

如何做到这一点

我们有一个名为 BucketComponent 的共享组件,它在管理员和员工模块中都在使用。BucketComponent 在后台使用 BucketService 来向桶中添加/删除物品。对于员工,我们将通过提供一个 aliased 类提供程序和一个不同的 EmployeeBucketService 来限制删除物品的能力。这样我们就可以覆盖删除物品的功能。执行以下步骤:

  1. 我们将首先在 employee 文件夹中创建 EmployeeBucketService,如下所示:
ng g service employee/services/employee-bucket
  1. 接下来,我们将从 BucketService 扩展 EmployeeBucketService,以便我们获得 BucketService 的所有好处。让我们修改代码如下:
import { Injectable } from '@angular/core';
import { BucketService } from 'src/app/services/bucket.service';
@Injectable({
  providedIn: 'root'
})
export class EmployeeBucketService extends BucketService {
  constructor() {
    super();
  }
}
  1. 我们现在将覆盖 removeItem() 方法,简单地显示一个简单的 alert(),说明员工无法从桶中删除物品。您的代码应如下所示:
import { Injectable } from '@angular/core';
import { BucketService } from 'src/app/services/bucket.service';
@Injectable({
  providedIn: 'root'
})
export class EmployeeBucketService extends BucketService {
  constructor() {
    super();
  }
  removeItem() {
    alert('Employees can not delete items');
  }
}
  1. 最后一步,我们需要在 employee.module.ts 文件中提供 aliased 类提供程序,如下所示:
import { NgModule } from '@angular/core';
...
import { BucketService } from '../services/bucket.service';
import { EmployeeBucketService } from './services/employee-bucket.service';
@NgModule({
  declarations: [...],
  imports: [
   ...
  ],
  providers: [{
    provide: BucketService,
    useClass: EmployeeBucketService
  }]
})
export class EmployeeModule { }

如果您现在以员工身份登录应用程序并尝试删除物品,您将看到一个警报弹出,其中写着员工无法删除物品

它是如何工作的

当我们将一个服务注入到一个组件中时,Angular 会尝试从注入的位置向上移动组件和模块的层次结构来找到该组件。我们的BucketService是使用providedIn: 'root'语法在'root'中提供的。因此,它位于层次结构的顶部。然而,在这个示例中,我们在EmployeeModule中使用了一个别名类提供者,当 Angular 搜索BucketService时,它很快就在EmployeeModule中找到了它,并在甚至到达'root'之前停在那里获取实际的BucketService

另请参阅

Angular 中的值提供者

在这个示例中,您将学习如何在 Angular 中使用值提供者为应用程序提供常量和配置值。我们将从上一个示例中的相同示例开始,即EmployeeModuleAdminModule使用名为BucketComponent的共享组件。我们将使用值提供者限制员工从桶中删除项目,这样员工甚至看不到删除按钮。

准备就绪

我们将要处理的项目位于chapter03/start_here/ng-value-providers路径中,该路径位于克隆的存储库内。执行以下步骤:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端,并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该在新的浏览器标签页中打开应用程序。

  1. 点击以管理员身份登录按钮。您应该看到类似以下截图:

图 3.12 - ng-value-providers 应用程序在 http://localhost:4200 上运行

图 3.12 - ng-value-providers 应用程序在 http://localhost:4200 上运行

我们有一个名为BucketComponent的共享组件,它在管理员和员工模块中都在使用。对于员工,我们将通过在EmployeeModule中提供一个值提供者来限制删除项目的能力。这样我们就可以根据其值隐藏删除按钮。

如何做

  1. 首先,我们将通过在app/constants文件夹中的新文件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中注入它时,提供者的值得到解析。

  1. 让我们在app.module.ts文件中添加提供者,如下所示:
...
import { AppConfig, APP_CONFIG } from './constants/app-config';
@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
  ],
  providers: [{
    provide: APP_CONFIG,
    useValue: AppConfig
  }],
  bootstrap: [AppComponent]
})
export class AppModule { }

现在应用程序知道了AppConfig常量。下一步是在BucketComponent中使用这个常量。

  1. 我们将使用@Inject()装饰器在BucketComponent类中注入它,在shared/components/bucket/bucket.component.ts文件中,如下所示:
import { Component, Inject, OnInit } from '@angular/core';
...
import { IAppConfig, APP_CONFIG } from '../../../constants/app-config';
...
export class BucketComponent implements OnInit {
  ...
  constructor(private bucketService: BucketService,   @Inject(APP_CONFIG) private config: IAppConfig) { }
  ...
}

太棒了!常量已经被注入。现在,如果您刷新应用程序,就不应该出现任何错误。下一步是在BucketComponent中使用config中的canDeleteItems属性来显示/隐藏删除按钮。

  1. 我们首先将属性添加到shared/components/bucket/bucket.component.ts文件中,并将其分配给ngOnInit()方法,如下所示:
...
export class BucketComponent implements OnInit {
  $bucket: Observable<IFruit[]>;
  selectedFruit: Fruit = '' as null;
  fruits: string[] = Object.values(Fruit);
  canDeleteItems: boolean;
  constructor(private bucketService: BucketService,   @Inject(APP_CONFIG) private config: IAppConfig) { }
  ngOnInit(): void {
    this.$bucket = this.bucketService.$bucket;
    this.bucketService.loadItems();
    this.canDeleteItems = this.config.canDeleteItems;
  }
  ...
}
  1. 现在,我们将在shared/components/bucket/bucket.component.html文件中添加一个*ngIf指令,只有当canDeleteItems的值为true时才显示删除按钮:
<div class="buckets" *ngIf="$bucket | async as bucket">
  <h4>Bucket <i class="material-icons">shopping_cart   </i></h4>
  <div class="add-section">
    ...
  </div>
  <div class="fruits">
    <ng-container *ngIf="bucket.length > 0; else     bucketEmptyMessage">
      <div class="fruits__item" *ngFor="let item of       bucket;">
        <div class="fruits__item__title">{{item.name}}        </div>
        <div *ngIf="canDeleteItems" class="fruits__        item__delete-icon"         (click)="deleteFromBucket(item)">
          <div class="material-icons">delete</div>
        </div>
      </div>
    </ng-container>
  </div>
</div>
<ng-template #bucketEmptyMessage>
  ...
</ng-template>

您可以通过将AppConfig常量的canDeleteItems属性设置为false来测试一切是否正常。请注意,删除按钮现在对管理员和员工都是隐藏的。测试完成后,将canDeleteItems的值再次设置为true

现在我们已经设置好了一切。让我们添加一个新的常量,这样我们就可以只为员工隐藏删除按钮。

  1. 我们将在employee文件夹内创建一个名为constants的文件夹。然后,我们将在employee/constants路径下创建一个名为employee-config.ts的新文件,并向其中添加以下代码:
import { IAppConfig } from '../../constants/app-config';
export const EmployeeConfig: IAppConfig = {
  canDeleteItems: false
} 
  1. 现在,我们将为相同的APP_CONFIG注入令牌,将这个EmployeeConfig常量提供给EmployeeModuleemployee.module.ts文件中的代码应该如下所示:
...
import { EmployeeComponent } from './employee.component';
import { APP_CONFIG } from '../constants/app-config';
import { EmployeeConfig } from './constants/employee-config';
@NgModule({
  declarations: [EmployeeComponent],
  imports: [
    ...
  ],
  providers: [{
    provide: APP_CONFIG,
    useValue: EmployeeConfig
  }]
})
export class EmployeeModule { }

我们完成了!配方现在已经完成。您可以看到删除按钮对管理员可见,但对员工隐藏。这都归功于值提供者的魔力。

它是如何工作的

当我们向组件注入一个标记时,Angular 会尝试从组件和模块的层次结构中向上移动,找到标记的解析值。我们在EmployeeModule中将EmployeeConfig提供为APP_CONFIG。当 Angular 尝试解析BucketComponent的值时,它在EmployeeModule中早早地找到了EmployeeConfig。因此,Angular 就停在那里,没有到达AppComponent。请注意,AppComponentAPP_CONFIG的值是AppConfig常量。

另请参阅

第四章:第四章:理解 Angular 动画

在本章中,您将学习如何在 Angular 中使用动画。您将学习多状态动画、阶段动画、关键帧动画,以及如何为 Angular 应用程序中的路由切换实现动画。

以下是本章将要涵盖的教程:

  • 创建您的第一个双状态 Angular 动画

  • 使用多状态动画

  • 使用关键帧创建复杂的 Angular 动画

  • 使用阶段动画在 Angular 中为列表添加动画

  • 使用动画回调

  • 在 Angular 中进行基本路由动画

  • 使用关键帧在 Angular 中创建复杂的路由动画

技术要求

在本章的教程中,请确保您的计算机上安装了GitNode.js。您还需要安装@angular/cli包,可以通过在终端中使用npm install -g @angular/cli来安装。本章的代码可以在github.com/PacktPublishing/Angular-Cookbook/tree/master/chapter04找到。

创建您的第一个双状态 Angular 动画

在这个教程中,您将使用淡入淡出效果创建一个基本的双状态 Angular 动画。我们将从一个带有一些 UI 的新的 Angular 项目开始,启用应用程序中的动画,然后开始创建我们的第一个动画。

准备工作

我们将要使用的项目位于克隆存储库中的chapter04/start_here/ng-basic-animation中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该会在新的浏览器标签页中打开应用程序,您应该会看到以下内容:

图 4.1 - ng-basic-animation 应用程序运行在 http://localhost:4200

图 4.1 - ng-basic-animation 应用程序运行在 http://localhost:4200

现在应用程序正在运行,我们将继续进行教程的步骤。

如何做…

我们有一个应用程序,没有配置 Angular 动画。因此,我们将从启用 Angular 动画开始。然后,我们将用 Angular 动画替换 CSS 动画。让我们按照以下步骤继续:

  1. 首先,我们将从@angular/platform-browser/animations包中在我们的app.module.ts中注入BrowserAnimationsModule,这样我们就可以在我们的 Angular 应用程序中使用动画。我们还将在imports数组中导入BrowserAnimationsModule,如下所示:
...
import { FbCardComponent } from './components/fb-card/fb-card.component';
import { TwitterCardComponent } from './components/twitter-card/twitter-card.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
@NgModule({
  declarations: [
    AppComponent,
    SocialCardComponent,
    FbCardComponent,
    TwitterCardComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    BrowserAnimationsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
  1. 现在,我们将删除 CSS 样式转换,这样我们就可以默认看到 Facebook 和 Twitter 按钮的完整按钮(图标和文本)。让我们从app.component.scss中删除以下代码块中突出显示的样式:
.type-picker {
   ...
  &__options {
   ...
    &__option {
      ...
      &__btn {
        ...
        min-width: 40px;
        // Remove the following lines
        transition: all 1s ease; 
        &__text {
          transition: all 1s ease;
          width: 0;
          visibility: hidden;
        }
        &--active {
          [class^="icon-"], [class*=" icon-"] {
            margin-right: 10px;
          }
          // Remove the following lines
          .type-picker__options__option__btn__text { 
            width: auto;
            visibility: visible;
          }
        }
      }
    }
  }
}
  1. 我们还将在app.component.scss文件中删除&--active选择器下的&__btn,并将[class^="icon-"], [class*=" icon-"]的样式移动到&__btn选择器内。这样做是为了所有图标都有右边距。您的代码应如下所示:
  .type-picker {
    ...
    &__options {
      ...
      &__option {
        ...
        &__btn {
          display: flex;
          align-items: center;
          min-width: 40px;
          justify-content: center;
          &--active { ← Remove this
            [class^='icon-'],
            [class*=' icon-'] {
              margin-right: 10px;
          }
        } ← Remove this
        }
      }
    }
  }
  1. 现在让我们将要创建的动画添加到模板中。我们将动画应用于两个按钮的文本元素。修改app.component.html如下:
 ...
<div class="content" role="main">
  <div class="type-picker">
    <h5>Pick Social Card Type</h5>
    <div class="type-picker__options">
      <div class="type-picker__options__option"       (click)="setCardType(cardTypes.Facebook)">
        <button class="btn type-picker__options__option__        btn" [ngClass]="selectedCardType === cardTypes.        Facebook ? 'btn-primary type-picker__options__        option__btn--active' : 'btn-light'">
          <div class="icon-facebook"></div>
          <div class="type-picker__options__option__btn__          text" [@socialBtnText]="selectedCardType ===           cardTypes.Facebook ? 'btn-active-text' :           'btn-inactive-text'">
            Facebook
          </div>
        </button>
      </div>
      <div class="type-picker__options__option"       (click)="setCardType(cardTypes.Twitter)">
        <button class="btn type-picker__options__option__        btn" [ngClass]="selectedCardType === cardTypes.        Twitter ? 'btn-primary type-picker__options__        option__btn--active' : 'btn-light'">
          <div class="icon-twitter"></div>
          <div class="type-picker__options__option__btn__          text" [@socialBtnText]="selectedCardType ===           cardTypes.Twitter ? 'btn-active-text' :           'btn-inactive-text'">
            Twitter
          </div>
        </button>
      </div>
    </div>
  </div>
  <app-social-card [type]="selectedCardType">  </app-social-card>
</div>

现在,我们将开始创建名为socialBtnText的动画,为此,我们将从@angular/animations包中导入一些函数到我们的app.component.ts中,这样我们就可以为按钮文本创建两个状态。

  1. 将以下导入添加到您的app.component.ts中:
import {
  trigger,
  state,
  style,
  animate,
  transition
} from '@angular/animations';
  1. 现在,让我们使用trigger方法将名为socialBtnText的动画添加到AppComponent元数据的animations数组中:
...
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
  animations: [
    trigger('socialBtnText', [])
  ]
})
export class AppComponent {
  ...
}
  1. 现在,我们将创建名为btn-active-textbtn-inactive-text的两个状态。我们将为这些状态设置widthvisibility,如下所示:
...
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
  animations: [
    trigger('socialBtnText', [
      state('btn-active-text', style({
        width: '80px',
        visibility: 'visible',
      })),
      state('btn-inactive-text', style({
        width: '0px',
        visibility: 'hidden',
      })),
    ])
  ]
})
export class AppComponent {
  ...
}

现在我们已经配置了状态,我们可以开始编写转换。

  1. 我们首先实现'btn-inactive-text => btn-active-text'转换,该转换在单击任一按钮时触发。由于此转换将显示文本,因此我们将首先增加文本元素的width值,然后将文本设置为visibleanimations[]数组中的内容应如下所示:
animations: [
    trigger('socialBtnText', [
      state('btn-active-text', style({...})),
      state('btn-inactive-text', style({...})),
      transition('btn-inactive-text => btn-active-text', [
        animate('0.3s ease', style({
          width: '80px'
        })),
        animate('0.3s ease', style({
          visibility: 'visible'
        }))
      ]),
    ])
  ]

现在,您应该看到按钮的活动状态有一个平滑的动画。让我们在下一步中实现非活动状态。

  1. 现在我们将实现'btn-active-text => btn-inactive-text'转换。这应该将可见性变为'hidden',并将宽度再次设置为'0px'。代码应该如下所示:
animations: [
    trigger('socialBtnText', [
      ...
      state('btn-inactive-text', style({...})),
      transition('btn-active-text =>       btn-inactive-text', [
        animate('0.3s', style({
          width: '80px'
        })),
        animate('0.3s', style({
          visibility: 'hidden'
        }))
      ]),
      transition('btn-inactive-text =>       btn-active-text', [
        ...
    ])
  ]

您会注意到当按钮变为不活动状态时会有轻微的抖动/延迟。这是因为宽度的动画触发先于visibility: 'hidden'的动画。因此,我们看到它们都是按顺序发生的。

  1. 为了使两个动画一起工作,我们将使用@angular/animations包中的group方法。我们将为过渡组合我们的animate()方法。app.components.ts文件中的更新应如下所示:
...
import {
  ...
  transition,
  group
} from '@angular/animations';
...
animations: [
    trigger('socialBtnText', [
      ...
      transition('btn-active-text =>       btn-inactive-text', [
        group([
          animate('0.3s', style({
            width: '0px'
          })),
          animate('0.3s', style({
            visibility: 'hidden'
          }))
        ])
      ]),
      ...
    ])
  ]
  1. 由于我们希望这个过程非常快速,所以我们将为'btn-active-text => btn-inactive-text'过渡的animate()方法设置为零秒(0s)。更改如下:
transition('btn-active-text => btn-inactive-text', [
        group([
          animate('0s', style({
            width: '0px'
          })),
          animate('0s', style({
            visibility: 'hidden'
          }))
        ])
      ]),
  1. 最后,当按钮不活动时,我们可以去掉按钮图标的额外margin-right。我们将通过将[class^="icon-"], [class*=" icon-"]选择器的代码移动到另一个名为&--active的选择器内,这样它只在按钮处于活动状态时应用。

  2. 修改app.component.scss文件中&__btn选择器中的以下样式,如下所示:

 &__btn {
          display: flex;
          align-items: center;
          min-width: 40px;
          justify-content: center;
          &--active {
            [class^="icon-"], [class*=" icon-"] {
              margin-right: 10px;
            }
          }
        }

太棒了!您现在已经在应用程序中实现了一些看起来不错的动画按钮。请查看下一节,了解这个方法是如何工作的。

它是如何工作的…

Angular 提供了自己的动画 API,允许您对 CSS 过渡适用的任何属性进行动画处理。好处是您可以根据要求动态配置它们。我们首先使用trigger方法将动画注册到状态和过渡中。然后我们分别使用statetransition方法定义这些状态和过渡。我们还看到了如何使用group方法并行运行动画。如果我们没有将动画分组,它们将按顺序运行。最后,我们使用组件中的一些标志应用了这些状态以反映变化。

还有更多…

你可能已经注意到,Twitter 按钮看起来比应该的要大一些。这是因为到目前为止,我们已经将文本的宽度设置为常量80px,用于我们的状态和动画。虽然这对 Facebook 按钮看起来不错,但对 Twitter 按钮来说就不太好看了。因此,我们实际上可以通过为按钮提供不同宽度的两种不同过渡来使其可配置。以下是你要做的:

  1. 在应用程序文件夹中创建一个新文件,命名为animations.ts

  2. app.component.ts文件中动画数组中的代码移动到这个新文件中;它应如下所示:

import {
  trigger,
  state,
  style,
  animate,
  transition,
  group
} from '@angular/animations';
export const buttonTextAnimation = (animationName: string, textWidth: string) => {
  return trigger(animationName, [
    state('btn-active-text', style({
      width: textWidth,
      visibility: 'visible',
    })),
    state('btn-inactive-text', style({
      width: '0px',
      visibility: 'hidden',
    })),
  ])
}
  1. 现在,我们还将添加过渡效果:
...
export const buttonTextAnimation = (animationName: string, textWidth: string) => {
  return trigger(animationName, [
    state('btn-active-text', style({...})),
    state('btn-inactive-text', style({...})),
    transition('btn-active-text => btn-inactive-text', [
      group([
        animate('0s', style({
          width: '0px'
        })),
        animate('0s', style({
          visibility: 'hidden'
        }))
      ])
    ]),
    transition('btn-inactive-text => btn-active-text', [
      animate('0.3s ease', style({
        width: textWidth
      })),
      animate('0.3s ease', style({
        visibility: 'visible'
      }))
    ]),
  ])
}
  1. 现在,我们将在app.component.ts中为我们的 Facebook 和 Twitter 按钮使用buttonTextAnimation方法如下。请注意,我们将创建两个不同的动画:
import { Component } from '@angular/core';
import { SocialCardType } from './constants/social-card-type';
import { buttonTextAnimation } from './animations';
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
  animations: [
    buttonTextAnimation('fbButtonTextAnimation', '80px'),
    buttonTextAnimation('twButtonTextAnimation', '60px'),
  ]
})
export class AppComponent {
  ...
}
  1. 最后,我们将在app.component.html中为 Facebook 和 Twitter 按钮使用相应的动画,如下所示:
…
<div class="type-picker__options__option" (click)="setCardType(cardTypes.Facebook)">
        <button class="btn type-picker__options__option__        btn" [ngClass]="selectedCardType === cardTypes.        Facebook ? 'btn-primary type-picker__options__        option__btn--active' : 'btn-light'">
          <div class="icon-facebook"></div>
          <div class="type-picker__options__option__          btn__text" [@ fbButtonTextAnimation]=          "isFBBtnActive ? 'btn-active-text' :           'btn-inactive-text'">
            Facebook
          </div>
        </button>
      </div>
      <div class="type-picker__options__option"       (click)="setCardType(cardTypes.Twitter)">
        <button class="btn type-picker__options__option__        btn" [ngClass]="selectedCardType === cardTypes.        Twitter ? 'btn-primary type-picker__options__        option__btn--active' : 'btn-light'">
          <div class="icon-twitter"></div>
          <div class="type-picker__options__option__          btn__text" [@twButtonTextAnimation]=          "isTwBtnActive ? 'btn-active-text' :           'btn-inactive-text'">
            Twitter
          </div>
        </button>
      </div>

另请参阅

使用多状态动画

在这个食谱中,我们将使用包含多个状态的 Angular 动画。这意味着我们将为特定项目使用两个以上的状态。我们将继续使用相同的 Facebook 和 Twitter 卡片示例。但是我们将配置卡片的状态,以便它们在屏幕上出现之前的状态,当它们在屏幕上时的状态,以及当它们即将再次从屏幕上消失时的状态。

准备工作

此食谱的项目位于chapter04/start_here/ng-multi-state-animations中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install以安装项目的依赖项。

  3. 完成后,运行ng serve -o

这将在新的浏览器标签中打开应用程序,您应该看到应用程序如下所示:

图 4.2 - ng-multi-state-animations 应用程序在 http://localhost:4200 上运行

图 4.2 - ng-multi-state-animations 应用程序在 http://localhost:4200 上运行

现在我们的应用程序在本地运行,让我们在下一节中看一下食谱的步骤。

如何做…

我们已经有一个工作中的应用程序,为社交卡片的到达构建了一个单一动画。当您点击 Facebook 或 Twitter 按钮时,您将看到相应的卡片以从左到右的滑入动画出现。为了保持食谱简单,我们将为用户在卡片上移动鼠标和用户远离卡片时实现两个更多的状态和动画。让我们按照以下步骤添加相关代码:

  1. 我们将从在components/fb-card/fb-card.component.ts文件中的FbCardComponent中添加两个@HostListener实例开始,一个用于卡片的mouseenter事件,另一个用于mouseleave事件。我们分别将状态命名为hoveredactive。代码应如下所示:
import { Component, HostListener, OnInit } from '@angular/core';
import { cardAnimation } from '../../animations';
@Component({
  selector: 'app-fb-card',
  templateUrl: './fb-card.component.html',
  styleUrls: ['./fb-card.component.scss'],
  animations: [cardAnimation]
})
export class FbCardComponent implements OnInit {
  cardState;
  constructor() { }
  @HostListener('mouseenter')
  onMouseEnter() {
    this.cardState = 'hovered'
  }
  @HostListener('mouseleave')
  onMouseLeave() {
    this.cardState = 'active'
  }
  ngOnInit(): void {
    this.cardState = 'active'
  }
}
  1. 现在,我们将在twitter-card-component.ts文件中为TwitterCardComponent执行相同的操作。 代码应如下所示:
import { Component, HostListener, OnInit } from '@angular/core';
import { cardAnimation } from '../../animations';
@Component({
  selector: 'app-twitter-card',
  templateUrl: './twitter-card.component.html',
  styleUrls: ['./twitter-card.component.scss'],
  animations: [cardAnimation]
})
export class TwitterCardComponent implements OnInit {
  cardState
  constructor() { }
  @HostListener('mouseenter')
  onMouseEnter() {
    this.cardState = 'hovered'
  }
  @HostListener('mouseleave')
  onMouseLeave() {
    this.cardState = 'active'
  }
  ngOnInit(): void {
    this.cardState = 'active'
  }
}
  1. 到目前为止,由于我们只是更新cardState变量以具有悬停和活动状态,因此不应该有视觉变化。 我们还没有定义过渡。

  2. 我们现在将定义当用户光标进入卡片时的状态,即mouseenter事件。 该状态称为悬停,应在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', [
    style({
      transform: 'translateX(-200px)',
      opacity: 0
    }),
    animate('0.2s ease', style({
      transform: 'translateX(0)',
      opacity: 1
    }))
  ]),
])

如果您现在刷新应用程序,点击 Facebook 或 Twitter 按钮,并将光标悬停在卡片上,您将看到卡片的 UI 发生变化。 这是因为我们将状态更改为悬停。 但是,目前还没有动画。 让我们在下一步中添加一个。

  1. 我们现在将在animations.ts文件中添加活动=>悬停过渡,以便我们可以平稳地从活动过渡到悬停状态:
...
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 事件上看到平滑的过渡。

  1. 最后,我们将添加最终的过渡,即悬停=>活动,因此当用户离开卡片时,我们将以平滑的动画恢复到活动状态。 代码应如下所示:
...
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/animations在单个元素上实现不同状态和不同动画。

它是如何工作的...

Angular 使用触发器来了解动画所处的状态。 一个示例语法如下:

<div [@animationTriggerName]="expression">...</div>;

expression可以是有效的 JavaScript 表达式,并且评估为状态的名称。 在我们的情况下,我们将其绑定到cardState属性,该属性包含'active''hovered'。 因此,我们为我们的卡片定义了三个过渡:

  • void=>活动(当元素添加到 DOM 并呈现时)

  • 活动=>悬停(当卡片上触发 mouseenter 事件时)

  • 悬停=>活动(当卡片上触发 mouseleave 事件时)

另请参阅

使用关键帧创建复杂的 Angular 动画

由于你已经了解了上一个教程中关于 Angular 动画的知识,你可能会想,“嗯,这很容易。” 现在是时候在这个教程中提升你的动画技能了。在这个教程中,你将使用关键帧创建一个复杂的 Angular 动画,以便开始编写一些高级动画。

准备工作

本教程的项目位于 chapter04/start_here/animations-using-keyframes 中。

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行 npm install 来安装项目的依赖项。

  3. 完成后,运行 ng serve -o

这应该在新的浏览器标签中打开应用程序,你应该看到应用程序如下:

图 4.3 – 在 http://localhost:4200 上运行的使用关键帧的动画应用程序

图 4.3 – 在 http://localhost:4200 上运行的使用关键帧的动画应用程序

现在我们的应用程序在本地运行,让我们在下一节中看一下这个教程的步骤。

如何做…

我们现在有一个应用程序,它只有一个过渡,即 void => *,当元素在 DOM 上渲染时触发。现在,动画非常简单,使用 animate 函数来定义动画。我们将首先将其转换为关键帧,然后将其变得稍微复杂一些:

  1. 让我们从 @angular/animations 中添加 keyframes 方法到 animations.ts 文件中,如下所示:
import {
  trigger,
  state,
  style,
  animate,
  transition,
  keyframes
} from '@angular/animations';
export const cardAnimation = trigger('cardAnimation', [
  ...
])
  1. 现在,我们将把单一样式动画转换为关键帧,如下所示:
import {
  trigger,
  state,
  style,
  animate,
  transition,
  keyframes
} from '@angular/animations';
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
      })
    ]))
  ]),
])

请注意,在这个代码块中,我们已经删除了 state('active', …) 部分,因为我们不再需要它了。此外,我们将 style({transform: 'translateX(-200px)', opacity: 0}) 移到了 keyframes 数组内,因为它现在是关键帧动画本身的一部分。如果你现在刷新应用并尝试,你仍然会看到与之前相同的动画。但现在我们使用了 keyframes

  1. 最后,让我们开始添加一些复杂的动画。我们将通过在 offset: 0styletransform 属性中添加 scale3d 来以缩小的卡片开始动画。我们还将增加动画时间到 1.5s
...
export const cardAnimation = trigger('cardAnimation', [
  transition('void => *', [
    animate('1.5s ease', keyframes([
      style({
        transform: 'translateX(-200px) scale3d(0.4, 0.4,         0.4)',
        offset: 0
      }),
      style({
        transform: 'translateX(0)',
        offset: 1
      })
    ]))
  ]),
])

现在你应该看到卡片动画从左侧滑动并向右移动,逐渐增大。

  1. 现在,我们将为卡片的出现实现一种类似之字形的动画,而不是滑入动画。让我们向 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-卡片向右墙然后向左墙移动

图 4.4-卡片向右墙然后向左墙移动

  1. 作为最后一步,我们将在卡片返回到原始位置之前顺时针旋转卡片。为此,我们将使用offset: 0.75,使用rotate方法以及一些额外的角度。代码应如下所示:
...
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(-100px) rotate(135deg)         translateY(0) scale3d(0.6, 0.6, 0.6)',
        offset: 0.75
      }),
      style({
        transform: 'translateX(0) rotate(360deg)',
        offset: 1
      })
    ]))
  ]),
])

太棒了!现在您知道如何使用@angular/common包中的keyframes方法在 Angular 中实现复杂的动画。在下一节中看看它是如何工作的。

它是如何工作的…

对于在 Angular 中进行复杂的动画,keyframes方法是定义动画在其整个过程中不同偏移的一个非常好的方法。我们可以使用styles方法来定义偏移,该方法以AnimationStyleMetadata作为参数。AnimationStyleMetadata还允许我们传递offset属性,该属性的值可以在01之间。因此,我们可以为不同的偏移定义不同的样式,以创建高级动画。

另请参阅

使用交错动画在 Angular 中为列表添加动画

无论您今天构建什么样的 Web 应用程序,您很可能会实现某种列表。为了使这些列表变得更好,为什么不为它们实现一个优雅的动画呢?在这个食谱中,您将学习如何使用交错动画在 Angular 中为列表添加动画。

准备工作

此食谱的项目位于chapter04/start_here/animating-lists中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该在新的浏览器选项卡中打开应用程序。以员工身份登录应用程序,您应该看到应用程序如下:

图 4.5-在 http://localhost:4200 上运行的 animating-lists 应用程序

图 4.5-在 http://localhost:4200 上运行的 animating-lists 应用程序

现在我们已经在本地运行了应用程序,让我们在下一节中看看食谱的步骤。

如何做…

我们现在有一个具有桶项目列表的应用程序。我们需要使用交错动画对列表进行动画处理。我们将一步一步地完成这个过程。我很兴奋 - 你呢?

好的。我们将按照以下步骤进行操作:

  1. 首先,在我们的app.module.ts中,让我们从@angular/platform-browser/animations包中添加BrowserAnimationsModule,以便我们可以为应用程序启用动画。代码应该如下所示:
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { FormsModule } from '@angular/forms';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    FormsModule,
    BrowserAnimationsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
  1. 现在,在constants文件夹中创建一个名为animations.ts的文件,并添加以下代码以向 Angular 注册一个名为listItemAnimation的基本列表项动画:
import {
  trigger,
  style,
  animate,
  transition,
} from '@angular/animations';
export const ANIMATIONS = {
  LIST_ITEM_ANIMATION: trigger('listItemAnimation', [
    transition('void => *', [
      style({
        opacity: 0
      }),
      animate('0.5s ease', style({
        opacity: 1
      }))
    ]),
    ,
    transition('* => void', [
      style({
        opacity: 1
      }),
      animate('0.5s ease', style({
        opacity: 0
      }))
    ])
  ])
}
  1. 请注意,void => *过渡是用于当列表项进入视图(或出现)时。* => void过渡是用于当项目离开视图(或消失)时。

  2. 现在,我们将在app/shared/bucket/bucket.component.ts文件中为BucketComponent添加动画,如下所示:

import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs/internal/Observable';
import { BucketService } from 'src/app/services/bucket.service';
import { Fruit } from '../../../constants/fruit';
import { IFruit } from '../../../interfaces/fruit.interface';
import { ANIMATIONS } from '../../../constants/animations';
@Component({
  selector: 'app-bucket',
  templateUrl: './bucket.component.html',
  styleUrls: ['./bucket.component.scss'],
  animations: [ANIMATIONS.LIST_ITEM_ANIMATION]
})
export class BucketComponent implements OnInit {
  ...
}

由于我们已经在组件中导入了动画,现在可以在模板中使用它。

  1. 让我们在bucket.component.html中按照以下方式将动画添加到列表项中:
<div class="buckets" *ngIf="$bucket | async as bucket">
  <h4>Bucket <i class="material-icons">shopping_cart   </i></h4>
  <div class="add-section">
    <div class="input-group">
    ...  
  </div>
  <div class="fruits">
    <ng-container *ngIf="bucket.length > 0; else     bucketEmptyMessage">
      <div class="fruits__item" *ngFor="let item of       bucket;" @listItemAnimation>
        <div class="fruits__item__title">{{item.name}}        </div>
        <div class="fruites__item__delete-icon"         (click)="deleteFromBucket(item)">
          <div class="material-icons">delete</div>
        </div>
      </div>
    </ng-container>
  </div>
</div>
...
  1. 如果您现在刷新应用程序并将项目添加到桶列表中,您应该会看到它以淡入效果出现。如果您删除一个项目,您也应该看到它以动画方式消失。

您会注意到的一件事是,当您刷新应用程序时,所有列表项会同时出现。然而,我们可以使用stagger动画使它们一个接一个地出现。我们将在下一步中完成这一点。

  1. 我们现在将修改LIST_ITEM_ANIMATION以使用stagger方法。这是因为我们可以让每个列表项依次出现。首先,我们需要从@angular/animations中导入stagger方法,然后我们需要在stagger方法内部包装我们的animate方法。更新animations.ts文件如下:
import {
  trigger,
  style,
  animate,
  transition,
  stagger
} from '@angular/animations';
export const ANIMATIONS = {
  LIST_ITEM_ANIMATION: trigger('listItemAnimation', [
    transition('void => *', [
      style({
        opacity: 0
      }),
      stagger(100, [
        animate('0.5s ease', style({
          opacity: 1
        }))
      ])
    ]),
    ,
    transition('* => void', [
      style({
        opacity: 1
      }),
      stagger(100, [
        animate('0.5s ease', style({
          opacity: 0
        }))
      ])
    ])
  ])
}

然而,这样是行不通的。那是因为stagger方法只能在query方法内部使用。因此,我们需要稍微修改我们的代码以在下一步中使用query方法。

  1. 让我们从@angular/animations中导入query方法,并稍微修改我们的代码,以便它可以与stagger方法一起使用。我们将做一些改变。

  2. 我们将将动画重命名为listAnimation,因为动画现在将应用于列表而不是单个列表项。

  3. 我们将在适当的query方法中包装我们的stagger方法。

  4. 我们将仅使用一个转换,即* => *,用于两个查询,:enter:leave,因此每当列表项发生变化时,动画就会触发。

  5. 我们将style({ opacity: 0 })移动到query(':enter')块内,因为它需要在交错动画之前隐藏项目。

代码应该如下所示:

import {
  trigger,
  style,
  animate,
  transition,
  stagger,
  query
} from '@angular/animations';
export const ANIMATIONS = {
  LIST_ANIMATION: trigger('listAnimation', [
    transition('* <=> *', [
      query(':enter', [
        style({
          opacity: 0
        }),
        stagger(100, [
          animate('0.5s ease', style({
            opacity: 1
          }))
        ])
      ], { optional: true }),
      query(':leave', [
        stagger(100, [
          animate('0.5s ease', style({
            opacity: 0
          }))
        ])
      ], {optional: true})
    ]),
  ])
}
  1. 现在我们需要修复shared/components/bucket/bucket.component.ts中动画的导入如下:
...
@Component({
  selector: 'app-bucket',
  templateUrl: './bucket.component.html',
  styleUrls: ['./bucket.component.scss'],
  animations: [ANIMATIONS.LIST_ANIMATION]
})
export class BucketComponent implements OnInit {
  ...
}
  1. 自从我们改变了动画的名称,让我们也在桶组件的模板中进行修复。更新shared/components/bucket/bucket.component.html如下:
<div class="buckets" *ngIf="$bucket | async as bucket">
  <h4>Bucket <i class="material-icons">shopping_cart   </i></h4>
  <div class="add-section">...
  </div>
  <div class="fruits" [@listItemAnimation]="bucket.  length">
    <ng-container *ngIf="bucket.length > 0; else     bucketEmptyMessage">
      <div class="fruits__item" *ngFor="let item of       bucket;"  @listItemAnimation ← Remove this>
        <div class="fruits__item__title">{{item.name}}        </div>
        <div class="fruites__item__delete-icon"        (click)="deleteFromBucket(item)">
          <div class="material-icons">delete</div>
        </div>
      </div>
    </ng-container>
  </div>
</div>
...

请注意,我们将[@listAnimation]属性绑定到bucket.length。这将确保每当桶的长度发生变化时动画触发,也就是说,当向桶中添加或移除项目时。

太棒了!现在你知道如何在 Angular 中为列表实现交错动画。在下一节中看看它是如何工作的。

它是如何工作的...

交错动画只能在query方法内部工作。这是因为交错动画通常应用于列表本身,而不是单个项目。为了搜索或查询项目,我们首先使用query方法。然后我们使用stagger方法来定义在下一个列表项的动画开始之前我们想要多少毫秒的交错。我们还在stagger方法中提供animation来定义在查询中找到的每个元素的动画。请注意,我们对:enter查询和:leave查询都使用了{ optional: true }。这是因为如果列表绑定发生变化(bucket.length),如果没有新元素进入 DOM 或没有元素离开 DOM,我们不会收到错误。

另请参阅

使用动画回调

在这个示例中,您将学习如何在 Angular 中被通知并对动画状态变化做出反应。作为一个简单的例子,我们将使用相同的桶列表应用程序,并且当动画完成添加项目时,我们将重置item-to-add选项。

准备工作

我们将要使用的项目位于克隆存储库中的chapter04/start_here/animation-callbacks中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖。

  3. 完成后,运行ng serve -o

这应该会在新的浏览器标签中打开应用程序。

  1. 点击以管理员身份登录按钮,你应该会看到类似以下的内容:

图 4.6-动画回调应用程序在 http://localhost:4200 上运行

图 4.6-动画回调应用程序在 http://localhost:4200 上运行

现在我们已经在本地运行了应用程序,让我们在下一节中看看食谱的步骤。

如何做...

我们在这个食谱中使用了之前食谱中使用的相同的桶应用程序。为了看看如何使用动画回调,我们将简单地在列表项进入 DOM 的动画完成后执行一个动作,并在列表项离开 DOM 时执行一个动作。让我们开始吧:

  1. 首先,在shared/components/bucket/bucket.component.ts文件中的BucketComponent内部创建两个名为onAnimationStartedonAnimationDone的方法。这些方法将在后续步骤中触发动画的相应阶段:
...
import { AnimationEvent } from '@angular/animations';
@Component({...})
export class BucketComponent implements OnInit {
  ...
  ngOnInit(): void {
    this.$bucket = this.bucketService.$bucket;
    this.bucketService.loadItems();
  }
  onAnimationStarted( event: AnimationEvent ) {
    console.log(event);
  }
  onAnimationDone( event: AnimationEvent ) {
    console.log(event);
  }
  ...
}
  1. 现在我们将把动画的startdone事件绑定到模板中的onAnimateEvent方法。修改shared/components/bucket/bucket.component.html文件如下:
<div class="buckets" *ngIf="$bucket | async as bucket">
  <h4>Bucket <i class="material-icons">shopping_cart   </i></h4>
  <div class="add-section">
    ...
  </div>
  <div class="fruits" [@listAnimation]="bucket.length"   (@listAnimation.start)="onAnimationStarted($event)"
  (@listAnimation.done)="onAnimationDone($event)">
    <ng-container *ngIf="bucket.length > 0; else     bucketEmptyMessage">
      <div class="fruits__item" *ngFor="let item of       bucket;">
        <div class="fruits__item__title">{{item.name}}        </div>
        <div class="fruites__item__delete-icon"        (click)="deleteFromBucket(item)">
          <div class="material-icons">delete</div>
        </div>
      </div>
    </ng-container>
  </div>
</div>
<ng-template #bucketEmptyMessage>
  ...
</ng-template>
  1. 注意,.start.done事件都与触发器名称listAnimation相关联。如果现在刷新应用程序,你应该会在控制台上看到如下日志:图 4.7-控制台上反映.start 和.done 动画事件的日志

图 4.7-控制台上反映.start 和.done 动画事件的日志

  1. 既然我们现在已经有了事件,我们将在动画期间用保存图标替换shopping_cart图标。这类似于模拟如果我们需要进行 HTTP 调用来保存数据会发生什么。让我们修改shared/components/bucket/bucket.component.ts如下:
...
export class BucketComponent implements OnInit {
  $bucket: Observable<IFruit[]>;
  selectedFruit: Fruit | null = null;
  fruits: string[] = Object.values(Fruit);
  isSaving: boolean;
  constructor(private bucketService: BucketService) { }
  ngOnInit(): void {
    ...
  }
  onAnimationStarted( event: AnimationEvent ) {
    this.isSaving = true;
  }
  onAnimationDone( event: AnimationEvent ) {
    this.isSaving = false;
    this.selectedFruit = null;
  }
  addSelectedFruitToBucket() {
    ...
  }
  deleteFromBucket(fruit: IFruit) {
    ...
  }
}
  1. 最后,我们可以修改我们的模板,根据isSaving属性的值显示相应的图标。代码应该如下所示:
<div class="buckets" *ngIf="$bucket | async as bucket">
  <h4>Bucket <i class="material-icons">{{isSaving ?   'save' : 'shopping_cart'}}</i></h4>
   ...
</div>
...

砰!食谱现在已经完成。如果刷新页面或添加/删除项目,你会注意到在整个动画过程中,桶图标都被保存图标替换,这都归功于动画回调。

工作原理...

当使用trigger方法在 Angular 中注册动画时,Angular 本身会在作用域内创建一个名为@triggerName的本地属性。它还为动画创建了.start.done子属性作为EventEmitter实例。因此,我们可以轻松地在模板中使用它们来捕获 Angular 传递的AnimationEvent实例。每个AnimationEvent包含phaseName属性,我们可以使用它来识别是start事件还是done事件。我们还可以从AnimationEvent中了解动画从哪个状态开始和结束在哪个状态。

另请参阅

在 Angular 中进行基本的路由动画

在这个教程中,您将学习如何在 Angular 中实现基本的路由动画。虽然这些是基本动画,但它们需要一些设置才能正确执行。您将学习如何通过将转换状态名称传递给路由作为数据属性来配置路由动画。您还将学习如何使用RouterOutlet API 来获取转换名称并将其应用于要执行的动画。

准备就绪

我们要处理的项目位于克隆存储库内的chapter04/start_here/route-animations中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该在新的浏览器选项卡中打开应用程序,您应该看到类似以下内容的内容:

图 4.8 - route-animations 应用程序正在 http://localhost:4200 上运行

图 4.8 - route-animations 应用程序正在 http://localhost:4200 上运行

现在我们的应用程序在本地运行,让我们在下一节中看看这个教程的步骤。

如何做…

目前,我们有一个非常简单的应用程序,其中有两个惰性加载的路由。这些路由是主页关于页面,现在我们将开始为应用程序配置动画:

  1. 首先,我们需要将BrowserAnimationsModule导入app.module.ts作为导入。代码应如下所示:
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    BrowserAnimationsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
  1. 现在,我们将在app文件夹内创建一个名为constants的新文件夹。我们还将在constants文件夹内创建一个名为animations.ts的文件。让我们将以下代码放入animations.ts文件中以注册一个简单的触发器:
import {trigger,  style, animate, transition, query,
 } from '@angular/animations';
export const ROUTE_ANIMATION = trigger('routeAnimation', [
  transition('* <=> *', [
    // states and transitions to be added here
  ])
])
  1. 现在,我们将注册我们的查询和动画的状态。让我们在transition()方法的数组中添加以下项目:
...
export const ROUTE_ANIMATION = trigger('routeAnimation', [
    style({
      position: 'relative'
    }),
    query(':enter, :leave', [
      style({
        position: 'absolute',
        width: '100%'
      })
    ], {optional: true}),
    query(':enter', [
      style({
        opacity: 0,
      })
    ], {optional: true}),
    query(':leave', [
      animate('300ms ease-out', style({ opacity: 0 }))
    ], {optional: true}),
    query(':enter', [
      animate('300ms ease-in', style({ opacity: 1 }))
    ], {optional: true}),
]);

好了!我们现在已经为从每个路由到其他每个路由的过渡注册了routeAnimation触发器。现在,让我们在路由中提供这些过渡状态。

  1. 我们可以使用每个路由的唯一标识符为过渡提供状态。有许多方法可以做到这一点,但最简单的方法是在app-routing.module.ts中的路由配置中使用data属性进行提供,如下所示:
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
const routes: Routes = [
  {
    path: '',
    pathMatch: 'full',
    redirectTo: 'home',
  },
  {
    path: 'home',
    data: {
      transitionState: 'HomePage',
    },
    loadChildren: () => import('./home/home.module').    then(m => m.HomeModule),
  },
  {
    path: 'about',
    data: {
      transitionState: 'AboutPage',
    },
    loadChildren: () => import('./about/about.module').    then(m => m.AboutModule),
  },
];
@NgModule({
  ...
})
export class AppRoutingModule {}
  1. 现在,我们需要以某种方式从当前路由向app.component.html中的@routeAnimation触发器提供transitionState属性。

  2. 为此,在app.component.html中使用的<router-outlet>元素创建一个@ViewChild实例,以便我们可以获取当前路由的data和提供的transitionState值。app.component.ts文件中的代码应如下所示:

import { Component, ViewChild } from "@angular/core";
import { RouterOutlet } from '@angular/router';
@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.scss"]
})
export class AppComponent {
  @ViewChild(RouterOutlet) routerOutlet;
}
  1. 我们还将从animations.ts文件中将ROUTE_ANIMATION导入到app.component.ts中,如下所示:
import { Component, ViewChild } from "@angular/core";
import { RouterOutlet } from '@angular/router';
import { ROUTE_ANIMATION } from './constants/animations';
@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.scss"],
  animations: [
    ROUTE_ANIMATION
  ]
})
export class AppComponent {
  ...
}
  1. 现在我们将创建一个名为getRouteAnimationTransition()的函数,它将获取当前路由的数据和transitionState值并将其返回。稍后将在app.component.html中使用此函数。按照以下方式修改app.component.ts中的代码:
...
@Component({
 ...
})
export class AppComponent {
  @ViewChild(RouterOutlet) routerOutlet;
  getRouteAnimationState() {
    return this.routerOutlet && this.routerOutlet.    activatedRouteData && this.routerOutlet.    activatedRouteData.transitionState;
  }
}
  1. 最后,让我们在app.component.html中使用getRouteAnimationState()方法与@routeAnimation触发器,以便我们可以看到动画的播放:
...
<div class="content" role="main">
  <div class="router-container"   [@routeAnimation]="getRouteAnimationState()">
    <router-outlet></router-outlet>
  </div>
</div>

看哪!刷新应用程序,看到魔法发生。现在,当您从主页导航到关于页面,反之亦然,您应该看到淡出和淡入动画发生。

它是如何工作的...

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%' 的全宽度,这样它们可以彼此叠加显示。您可以随时尝试注释其中一个样式来查看发生了什么(尽管有风险!)。

无论如何,一旦样式设置好了,我们就定义了将要进入视图的路由的动作,使用 :enter 查询。我们将样式设置为 opacity: 0,这样看起来就像路由正在淡入:

    query(':enter', [
      style({
        opacity: 0,
      })
    ], {optional: true}),

最后,我们将我们的路由过渡定义为两个连续动画的组合,第一个是 query :leave,第二个是 query :enter。对于离开视图的路由,我们通过动画将不透明度设置为 0,对于进入视图的路由,我们也通过动画将不透明度设置为 1

    query(':leave', [
      animate('300ms ease-out', style({ opacity: 0 }))
    ], {optional: true}),
    query(':enter', [
      animate('300ms ease-in', style({ opacity: 1 }))
    ], {optional: true}),

另请参阅

在 Angular 中使用关键帧进行复杂的路由动画

在上一篇教程中,您学会了如何创建基本的路由动画,在这一篇中,我们将提升我们的动画水平。在这篇教程中,您将学习如何在 Angular 中使用关键帧实现一些复杂的路由动画。

准备工作

我们要处理的项目位于克隆存储库中的 chapter04/start_here/complex-route-animations 中。它与 在 Angular 中进行基本路由动画 教程的最终代码处于相同状态,只是我们还没有配置任何动画:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行 npm install 来安装项目的依赖项。

  3. 完成后,运行 ng serve -o

这将在新的浏览器标签中打开应用程序,您应该看到类似以下的内容:

图 4.9 – complex-route-animations 应用程序运行在 http://localhost:4200

图 4.9 – complex-route-animations 应用程序运行在 http://localhost:4200

现在我们的应用程序在本地运行,让我们在下一节中看看这个教程的步骤。

如何做…

我们有一个基本的应用程序,有两个路由,HomePage路由和AboutPage路由。与之前的食谱在 Angular 中的基本路由动画类似,我们使用路由数据参数进行配置。但是,我们还没有编写任何动画。此外,我们已经在app.module.ts文件中导入了BrowserAnimationsModule

  1. 首先,我们将在animations.ts文件中编写一个简单的动画,用于路由进入视图和离开视图,如下所示:
import {
  ...
  query,
  animate,
} from '@angular/animations';
const optional = { optional: true };
export const ROUTE_ANIMATION = trigger('routeAnimation', [
  transition('* <=> *', [
    style({...}),
    query(':enter, :leave', [...], optional),
    query(':enter', [
      style({
        opacity: 0,
      })
    ], optional),
    query(':leave', [
      animate('1s ease-in', style({
        opacity: 0
      }))
    ], optional),
    query(':enter', [
      animate('1s ease-out', style({
        opacity: 1
      }))
    ], optional),
  ])
])

您会注意到我们现在为进入和离开路由都有淡入/淡出的动画。但是,您会注意到进入路由直到当前路由离开视图后才出现。这是因为我们的两个动画都是按顺序运行的。

  1. 我们将使用group方法对:enter:leave查询的动画进行分组,如下所示:
import {
  ...
  animate,
  group
} from '@angular/animations';
...
export const ROUTE_ANIMATION = trigger('routeAnimation', [
  transition('* <=> *', [
    style({...}),
    query(':enter, :leave', [...], optional),
    query(':enter', [...], optional),
    group([
      query(':leave', [
        animate('1s ease-in', style({
          opacity: 0
        }))
      ], optional),
      query(':enter', [
        animate('1s ease-out', style({
          opacity: 1
        }))
      ], optional),
    ])
  ])
])

现在,您应该看到两个动画一起触发。虽然现在看起来不是很好,但相信我,它会的!

  1. 提升游戏水平,我们将为我们的路由进入视图编写一个复杂的动画。我们想创建一个3D 动画,因此,我们将使用一些translateZ()转换:
import {
  ...
  keyframes,
} from '@angular/animations';
...
export const ROUTE_ANIMATION = trigger('routeAnimation', [
  transition('* <=> *', [
    ...
    group([
      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),
    ])
  ])

如果您现在刷新应用程序,您可能会说:“Pffttt,这是 3D 吗,Ahsan?怎么回事?”好吧,是的。但是,我们只看到从左到右的滑动动画。这是因为我们需要改变我们的perspective

  1. 要查看所有元素被转换为 3D,我们需要将perspective样式应用于动画的宿主元素。我们将通过在animations.ts文件中的第一个style定义中添加perspective: '1000px'样式来实现:
...
export const ROUTE_ANIMATION = trigger('routeAnimation', [
  transition('* <=> *', [
    style({
      position: 'relative',
      perspective: '1000px'
    }),
    query(':enter, :leave', [
      ...
    ], optional),
    query(':enter', [
      ...
    ], optional),
    group([
      ...
    ])
  ])
])

砰!现在我们有了 3D 的:enter查询动画。

  1. 现在让我们更新:leave查询的动画如下,这样我们就可以看到它在z轴上向后滑动离开视图:
...
export const ROUTE_ANIMATION = trigger('routeAnimation', [
  transition('* <=> *', [
    style({
      ...
    }),
    query(':enter, :leave', [
      ...
    ], optional),
    query(':enter', [
      ...
    ], optional),
    group([
      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),
      query(':enter', [
        ...
      ], optional),
    ])
  ])
])

哇哇!我们现在为我们的路由创建了一个绝对令人惊叹的 3D 动画。当然,这还不是结束。当涉及到在 Angular 中使用关键帧和动画时,天空就是限制。

它是如何工作的...

由于我们想要在这个示例中实现 3D 动画,我们首先确保动画主机元素具有“透视”样式的值,这样我们就可以在 3D 中看到所有的魔法。然后我们使用keyframes方法定义了我们的动画,每个偏移都有一个动画状态,这样我们可以在这些状态下设置不同的角度和旋转,让一切看起来很酷。我们做的一个重要的事情是使用group方法对我们的:enter:leave查询进行分组,我们在那里定义了动画。这确保了我们的路由同时进入和离开视图。

参见

第五章:第五章:Angular 和 RxJS - 令人敬畏的组合

Angular 和 RxJS 结合起来,创造了一种令人敬畏的组合。通过结合它们,您可以以响应式的方式处理数据,处理流,并在 Angular 应用程序中执行非常复杂的操作。这正是您将在本章中学到的内容。

以下是本章将要涵盖的示例:

  • 使用实例方法处理 RxJS 操作符

  • 使用静态方法处理 RxJS 操作符

  • 取消订阅流以避免内存泄漏

  • 使用async管道与 Observable 同步绑定数据到您的 Angular 模板

  • 使用combineLatest同时订阅多个流

  • 使用flatMap操作符创建顺序的超文本传输协议HTTP)调用

  • 使用switchMap操作符将最后一个订阅切换为新的订阅

  • 使用 RxJS 进行去抖动 HTTP 请求

技术要求

对于本章的示例,请确保您的计算机上已安装了GitNode.js。您还需要安装@angular/cli包,可以在终端中使用npm install -g @angular/cli来安装。本章的代码可以在以下链接找到:github.com/PacktPublishing/Angular-Cookbook/tree/master/chapter05

使用实例方法处理 RxJS 操作符

在这个示例中,您将学习如何使用 RxJS 操作符的实例方法来处理流。我们将从一个基本应用程序开始,在该应用程序中,您可以使用interval方法开始监听流。然后,我们将在订阅中引入一些实例方法来修改输出。

准备工作

我们将要处理的项目位于chapter05/start_here/rxjs-operators-instance-methods,在克隆的存储库中。

  1. Visual Studio CodeVS Code)中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该会在新的浏览器标签中打开应用程序。点击开始流按钮,您应该会看到类似这样的东西:

图 5.1 - rxjs-operators-instance-methods 应用程序在 http://localhost:4200 上运行

图 5.1 - rxjs-operators-instance-methods 应用程序在 http://localhost:4200 上运行

现在应用程序正在运行,我们将继续进行示例的步骤。

操作步骤…

我们有一个 Angular 应用程序,已经设置了一些东西。通过点击开始流按钮,我们可以开始查看使用 RxJS 的interval方法创建输出从0开始的数字序列的 Observable 的流输出。我们将使用一些操作符来显示来自我们的inputStreamData数组的元素,这是本教程的目标。让我们开始吧。

  1. 首先,我们将使用map操作符确保我们将从interval Observable 生成的数字映射到我们数组的有效索引。为此,我们将更新app.component.ts文件。

我们必须确保映射的数字不大于或等于inputStreamData的长度。我们将使用map操作符每次对数字取模来做到这一点,如下所示:

import { Component } from '@angular/core';
import { interval, Subscription } from 'rxjs';
import { map } from 'rxjs/operators';
@Component({...})
export class AppComponent {
...
  startStream() {
    this.subscription = streamSource
    .pipe(
      map(output => output % this.inputStreamData.      length),
    )
    .subscribe(input => {
      this.outputStreamData.push(input);
    });
...
}

如果现在点击开始流按钮,您会看到我们得到的输出是0, 1, 2, 0, 1, 2...等等。这确保我们始终可以使用数字作为索引从inputStreamData数组中获取项目:

图 5.2 - 流使用 inputStreamData.length 上的模数输出 0,1,2..序列

图 5.2 - 流使用 inputStreamData.length 上的模数输出 0,1,2..序列

  1. 现在,我们将使用另一个map方法来获取数组中每个流输出的元素,如下所示:
  startStream() {
    const streamSource = interval(1500);
    this.subscription = streamSource
    .pipe(
      map(output => output % this.inputStreamData.      length),
      map(index => this.inputStreamData[index])
    )
    .subscribe(element => {
      this.outputStreamData.push(element);
    });
  }

请注意,我们已将subscribe方法的参数重命名为element而不是input。这是因为最终我们得到了一个元素。请参阅以下屏幕截图,演示了流如何使用索引输出来自inputStreamData的元素:

图 5.3 - 流使用索引从 inputStreamData 输出元素

图 5.3 - 流使用索引从 inputStreamData 输出元素

  1. 现在,为了使事情更有趣,我们将创建另一个流,使用相同的interval方法来发出卡通标题,但间隔为1000ms。将以下代码添加到您的startStream方法中:
  startStream() {
    const streamSource = interval(1500);
    const cartoonStreamSource = interval(1000)
      .pipe(
        map(output => output % this.cartoonsStreamData.        length),
        map(index => this.cartoonsStreamData[index]),
      )
    this.subscription = streamSource
    .pipe(...)
    .subscribe(...);
  }
  1. 我们还将在AppComponent类中创建名为cartoonStreamData的流数据(在先前的代码中使用)。代码应该是这样的:
export class AppComponent {
  subscription: Subscription = null;
  inputStreamData = ['john wick', 'inception',   'interstellar'];
  cartoonsStreamData = ['thunder cats', 'Dragon Ball Z',   'Ninja Turtles'];
  outputStreamData = [];
  ...
}
  1. 现在我们已经将cartoonsStreamData流数据放在了适当的位置,我们还可以将其添加到模板中,以便我们也可以在视图上显示它。在app.component.html<div class="input-stream">元素的子元素应该是这样的:
    <div class="input-stream">
      <div class="input-stream__item" *ngFor="let item       of inputStreamData">
        {{item}}
      </div>
      <hr/>
      <div class="input-stream__item" *ngFor="let item       of cartoonsStreamData">
        {{item}}
      </div>
    </div>
  1. 现在,我们将使用 merge(实例)方法来合并这两个流,并在流发出值时从各自的流数据数组中添加一个元素。有趣,对吧?

我们将使用以下代码来实现这一点:

...
import { map, merge } from 'rxjs/operators';
export class AppComponent {
  ...
  startStream() {
    ...
    this.subscription = streamSource
    .pipe(
      map(output => output % this.inputStreamData.      length),
      map(index => this.inputStreamData[index]),
      merge(cartoonStreamSource)
    )
    .subscribe(element => {
      this.outputStreamData.push(element);
    });
  }
}

重要提示

使用 merge 方法作为实例方法的用法已被弃用,推荐使用静态的 merge 方法。

太棒了!您现在已经实现了整个食谱,实现了两个流的有趣合并。以下截图显示了最终输出:

图 5.4 – 食谱的最终输出

图 5.4 – 食谱的最终输出

让我们继续下一节,了解它是如何工作的。

工作原理…

map 操作符为您提供了流的输出值,您应该返回要将其映射到的值。我们确保通过取数组长度的模数将自动生成的顺序数字转换为数组的索引。然后,我们在这些索引之上使用另一个 map 操作符来获取数组中的实际元素。最后,我们创建了另一个流,并使用 merge 方法来合并这两个流的输出,并将其添加到 outputStreamData 数组中。

另请参阅

使用静态方法处理 RxJS 操作符

在这个食谱中,您将学习使用 RxJS 操作符的静态方法来处理流。我们将从一个基本应用程序开始,在该应用程序中,您可以使用 interval 方法开始监听流。然后,我们将在订阅中引入一些静态方法来修改输出,以在用户界面UI)上看到它。之后,我们将使用 partition 静态操作符来拆分流。最后,我们将使用 merge 静态操作符来合并分区流,以查看它们的输出。

准备工作

此食谱的项目位于 chapter05/start_here/rxjs-operators-static-methods

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行 npm install 来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这将在新的浏览器标签中打开应用程序,你应该能够看到类似这样的东西:

图 5.5 – rxjs-operators-static-methods 应用程序在 http://localhost:4200 上运行

图 5.5 – rxjs-operators-static-methods 应用程序在 http://localhost:4200 上运行

我们还有以下数据,其中包括电影和卡通,这将是流的输出结果:

combinedStreamData = [{
    type: 'movie',
    title: 'john wick'
  }, {
    type: 'cartoon',
    title: 'Thunder Cats'
  }, {
    type: 'movie',
    title: 'inception'
  }, {
    type: 'cartoon',
    title: 'Dragon Ball Z'
  }, {
    type: 'cartoon',
    title: 'Ninja Turtles'
  }, {
    type: 'movie',
    title: 'interstellar'
  }];

现在应用程序在本地运行,让我们在下一节中看一下配方的步骤。

如何做…

我们手头有一个 Angular 应用程序,其中有一个名为combinedStreamData的数组中有一些数据。通过点击开始流按钮,我们可以开始查看流在电影输出部分和卡通输出部分的输出。我们将使用partitionmerge操作符来获得期望的输出,并且还会显示当前输出的电影和卡通数量。让我们开始吧。

  1. 首先,我们将从 RxJS 中导入partitionmerge操作符(与之前的配方不同,我们不是从rxjs/operators中导入)。在app.component.ts文件中,导入应该如下所示:
import { Component } from '@angular/core';
import { interval, partition, merge, Subscription } from 'rxjs';
  1. 现在,我们将在AppComponent类中创建两个属性,moviescartoons,一个用于保存电影,一个用于保存卡通:
import { Component } from '@angular/core';
import { interval, partition, merge, Subscription } from 'rxjs';
import { map, tap } from 'rxjs/operators';
export class AppComponent {
  …
  outputStreamData = [];
  movies= []
  cartoons= [];
  startStream() {
  }
  ...
}
  1. 现在,我们将在模板中使用适当的变量来表示电影和卡通,步骤如下:
<div class="cards-container">
    <div class="input-stream">
      ...
    <div class="output-stream">
      <h6>Movies</h6>
      <div class="input-stream__item" *ngFor="let movie       of movies">
        {{movie}}
      </div>
    </div>
    <div class="output-stream">
      <h6>Cartoons</h6>
      <div class="input-stream__item" *ngFor="let cartoon       of cartoons">
        {{cartoon}}
      </div>
    </div>
  </div>
  1. 现在我们将使用partition操作符从streamSource属性创建两个流。你的startStream方法应该如下所示:
startStream() {
    const streamSource = interval(1500).pipe(
      map(input => {
        const index = input % this.combinedStreamData.        length;
        return this.combinedStreamData[index];
      })
    );
    const [moviesStream, cartoonsStream] = partition(
      streamSource, item => item.type === 'movie'
    );
    this.subscription = streamSource
      .subscribe(input => {
        this.outputStreamData.push(input);
      });
  }

现在我们已经将流拆分,我们可以合并它们以订阅单个流,推送到适当的输出数组,并将值记录到控制台输出。

  1. 现在让我们合并这些流,然后使用tap操作符将它们添加到适当的输出数组中,步骤如下:
startStream() {
   ...
    this.subscription = merge(
      moviesStream.pipe(
        tap(movie => {
          this.movies.push(movie.title);
        })
      ),
      cartoonsStream.pipe(
        tap(cartoon => {
          this.cartoons.push(cartoon.title);
        })
      ),
    )
      .subscribe(input => {
        this.outputStreamData.push(input);
      });
  }

通过这个改变,你应该能够在适当的容器中看到正确的数值——也就是说,无论是电影还是卡通。请参考以下截图,显示了分区流如何向适当的 Observables 发出数值:

图 5.6 – 分区流将数据输出到适当的视图

图 5.6 – 分区流将数据输出到适当的视图

  1. 最后,由于我们已经合并了流,我们可以使用console.log来查看每个输出的值。我们将从AppComponent中删除outputStreamData属性,并在subscribe块中使用console.log语句而不是推送到outputStreamData,如下所示:
...
@Component({...})
export class AppComponent {
  ...
  outputStreamData = []; ← Remove
  movies = [];
  cartoons = [];
  ngOnInit() {}
  startStream() {
    const streamSource = interval(1500).pipe(
      map(...)
    );
    const [moviesStream, cartoonsStream] =     partition(...);
    this.subscription = merge(
      moviesStream.pipe(...),
      cartoonsStream.pipe(...)
    ).subscribe((output) => {
      console.log(output);
    });
  }
  ...
}

一旦刷新应用程序,您应该在控制台上看到如下日志:

图 5.7 - 合并流中订阅块中每个输出的控制台日志

图 5.7 - 合并流中订阅块中每个输出的控制台日志

太棒了!现在你知道如何使用 RxJS 的静态操作符(特别是partitionmerge)来处理实际用例中的流。请参阅下一节,了解其工作原理。

工作原理…

RxJS 有一堆静态操作符/方法,我们可以根据特定的用例来使用。在这个示例中,我们使用partition操作符根据作为第二个参数提供的predicate函数创建了两个不同的流,它返回一个包含两个 Observables 的数组。第一个将包含满足谓词的所有值,第二个将包含不满足谓词的所有值。为什么要分割流?很高兴你问。因为我们需要在不同的输出容器中显示适当的输出。而且很棒的是,我们后来合并了这些流,这样我们只需要订阅一个流,然后也可以取消订阅这个流。

另请参阅

取消订阅流以避免内存泄漏

流很有趣,而且很棒,当你完成这一章时,你会对 RxJS 有更多了解,尽管在不小心使用流时会出现问题。在处理流时最大的错误之一是在不再需要时不取消订阅它们,而在这个示例中,您将学习如何取消订阅流以避免在 Angular 应用程序中出现内存泄漏。

准备工作

此配方的项目位于chapter05/start_here/rxjs-unsubscribing-streams中。

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行 npm install 来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该会在新的浏览器标签中打开应用程序,您应该会看到类似于这样的东西:

图 5.8 – rxjs-unsubscribing-streams 应用程序在 http://localhost:4200 上运行

图 5.8 – rxjs-unsubscribing-streams 应用程序在 http://localhost:4200 上运行

现在我们的应用程序在本地运行,让我们在下一节中看一下配方的步骤。

如何做…

我们目前有一个具有两个路由的应用程序,即主页关于。这是为了向您展示未处理的订阅可能会导致应用程序内存泄漏。默认路由是主页,在HomeComponent类中,我们处理一个使用interval方法输出数据的单个流。

  1. 点击开始流按钮,您应该看到流发出值。

  2. 然后,通过点击页眉(右上角)的关于按钮导航到关于页面,然后返回到主页

你看到了什么?什么都没有?一切看起来都很好,对吧?嗯,并不完全是这样。

  1. 为了查看我们是否有未处理的订阅(这是一个问题),让我们在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('stream output', input)
    });
  }
  stopStream() {...}
}

如果您现在执行与步骤 1中提到的相同步骤,您将在控制台上看到以下输出:

图 5.9 – rxjs-unsubscribing-streams 应用程序在 http://localhost:4200 上运行

图 5.9 – rxjs-unsubscribing-streams 应用程序在 http://localhost:4200 上运行

想要再玩一些吗?尝试执行步骤 1几次,甚至不刷新页面一次。你将看到混乱

  1. 因此,为了解决这个问题,我们将使用最简单的方法,即在用户从路由中导航离开时取消订阅流。让我们实现ngOnDestroy生命周期方法,如下所示:
import { Component, OnInit, OnDestroy } from '@angular/core';
...
@Component({
  selector: 'app-home',
  templateUrl: './home.component.html',
  styleUrls: ['./home.component.scss']
})
export class HomeComponent implements OnInit, OnDestroy {
  ...
  ngOnInit() {
  }
  ngOnDestroy() {
    this.stopStream();
  }
  startStream() {
    const streamSource = interval(1500);
    this.subscription = streamSource.subscribe(input => {
      this.outputStreamData.push(input);
      console.log('stream output', input)
    });
  }
  stopStream() {
    this.subscription.unsubscribe();
    this.subscription = null;
  }
}

太好了!如果您再次按照步骤 1的说明操作,您会发现一旦从主页导航离开,控制台上就不会再有进一步的日志输出,我们的应用程序现在没有未处理的流导致内存泄漏。阅读下一节以了解其工作原理。

工作原理…

当我们创建一个 Observable/流并订阅它时,RxJS 会自动将我们提供的.subscribe方法块添加为 Observable 的处理程序。因此,每当 Observable 发出值时,我们的方法应该被调用。有趣的是,当组件卸载或从路由导航离开时,Angular 不会自动销毁该订阅/处理程序。这是因为 Observable 的核心是 RxJS,而不是 Angular,因此处理它不是 Angular 的责任。

Angular 提供了某些生命周期方法,我们使用了OnDestroyngOnDestroy)方法。这是因为当我们从一个路由导航离开时,Angular 会销毁该路由,这时我们希望取消订阅所有已订阅的流。

还有更多...

在一个复杂的 Angular 应用程序中,会有一些情况下,您可能会在一个组件中有多个订阅,并且当组件被销毁时,您希望一次清理所有这些订阅。同样,您可能希望根据某些事件/条件取消订阅,而不是OnDestroy生命周期。这是一个例子,您手头有多个订阅,并且希望在组件销毁时一起清理所有这些订阅:

startStream() {
    const streamSource = interval(1500);
    const secondStreamSource = interval(3000);
    const fastestStreamSource = interval(500);
    streamSource.subscribe(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() {
  }

请注意,我们不再将streamSourceSubscription保存到this.subscription中,我们还从stopStream方法中删除了代码。原因是因为我们没有为每个 Subscription 拥有单独的属性/变量。相反,我们将有一个单一的变量来处理。让我们看一下以下的步骤来开始工作。

  1. 首先,我们将在HomeComponent类中创建一个名为isComponentAlive的属性:
...
export class HomeComponent implements OnInit, OnDestroy {
  isComponentAlive: boolean;
  ...
}
  1. 现在,我们将从rxjs/operators中导入takeWhile操作符,如下所示:
import { Component, OnInit, OnDestroy } from '@angular/core';
import { interval } from 'rxjs/internal/observable/interval';
import { Subscription } from 'rxjs/internal/Subscription';
import { takeWhile } from 'rxjs/operators';
  1. 现在,我们将使用takeWhile操作符与我们的每个流,使它们只在isComponentAlive属性设置为true时工作。由于takeWhile需要一个predicate方法,它应该是这样的:
startStream() {
    ...
    streamSource
      .pipe(
        takeWhile(() => !!this.isComponentAlive)
      ).subscribe(input => {...});
    secondStreamSource
      .pipe(
        takeWhile(() => !!this.isComponentAlive)
      ).subscribe(input => {...});
    fastestStreamSource
      .pipe(
        takeWhile(() => !!this.isComponentAlive)
      ).subscribe(input => {...});
  }

如果您现在在主页上按下开始流按钮,您仍然看不到任何输出或日志,因为isComponentAlive属性仍然是undefined

  1. 为了使流工作,我们将在ngOnInit方法以及startStream方法中将isComponentAlive属性设置为true。代码应该是这样的:
  ngOnInit() {
    this.isComponentAlive = true;
  }
  ngOnDestroy() {
    this.stopStream();
  }
  startStream() {
    this.isComponentAlive = true;
    const streamSource = interval(1500);
    const secondStreamSource = interval(3000);
    const fastestStreamSource = interval(500);
    ...
  }

在此步骤之后,如果您现在尝试启动流并从页面导航离开,您仍将看到与流相同的问题-即它们尚未取消订阅。

  1. 要一次取消订阅所有流,我们将在stopStream方法中将isComponentAlive的值设置为false,如下所示:
  stopStream() {
    this.isComponentAlive = false;
  }

然后! 现在,如果您在流发出值时导航离开路由,流将立即停止,就在您离开主页路由时。 瞧!

另请参阅

使用 Observable 和 async 管道将数据同步绑定到您的 Angular 模板

正如您在上一个配方中所学到的,取消订阅您订阅的流至关重要。 如果我们有一种更简单的方法在组件被销毁时取消订阅它们-也就是说,让 Angular 以某种方式来处理它,那该多好? 在这个配方中,您将学习如何使用 Angular 的async管道与 Observable 直接将流中的数据绑定到 Angular 模板,而无需在*.component.ts文件中订阅。

做好准备

此配方的项目位于chapter05/start_here/using-async-pipe

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行npm install以安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该在新的浏览器选项卡中打开应用程序。 一旦页面打开,您应该看到类似于这样的东西:

![图 5.10-使用异步管道应用程序在 http://localhost:4200 上运行

](image/Figure_5.10_B15150.jpg)

图 5.10-使用异步管道应用程序在 http://localhost:4200 上运行

现在我们的应用程序在本地运行,让我们在下一节中看到该配方的步骤。

如何做…

我们现在的应用程序有三个流/可观察对象在不同的时间间隔观察值。 我们依赖于isComponentAlive属性来保持订阅活动或在属性设置为false时停止它。 我们将删除对takeWhile的使用,并以某种方式使一切都与我们现在拥有的类似地工作。

  1. 首先,从home.component.ts文件中删除subscription属性,并添加一个名为streamOutput$Observable类型属性。 代码应如下所示:
...
import { Observable } from 'rxjs';
...
export class HomeComponent implements OnInit, OnDestroy {
  isComponentAlive: boolean;
  subscription: Subscription = null ← Remove this;
  inputStreamData = ['john wick', 'inception',   'interstellar']; 
  streamsOutput$: Observable<number[]> ← Add this
  outputStreamData = []
  constructor() { }
  ...
}

通过这种改变,应用程序会因为一些缺少的变量而崩溃。 不要害怕! 我在这里帮助您。

  1. 现在我们将组合所有的流以输出单个输出,即outputStreamData数组。我们将从startStream()方法中删除所有现有的.pipe.subscribe方法,所以代码现在应该是这样的:
import { Component, OnInit, OnDestroy } from '@angular/core';
import { merge, Observable } from 'rxjs';
import { map, takeWhile } from 'rxjs/operators';
...
export class HomeComponent implements OnInit, OnDestroy {
  ...
  startStream() {
    const streamSource = interval(1500);
    const secondStreamSource = interval(3000);
    const fastestStreamSource = interval(500);
    this.streamsOutput$ = merge(
      streamSource,
      secondStreamSource,
      fastestStreamSource
    )
  }
  ...
}

有了这个改变,linters 仍然会抱怨。为什么?因为merge操作符会合并所有流并输出最新的值。这是一个Observable<number>数据类型,而不是Observable<string[]>,这是streamsOutput$的类型。

  1. 由于我们想要分配包含从流中发出的每个输出的整个数组,我们将使用map操作符,并将每个输出添加到outputStreamData数组中,并返回outputStreamData数组的最新状态,如下所示:
startStream() {
    const streamSource = interval(1500);
    const secondStreamSource = interval(3000);
    const fastestStreamSource = interval(500);
    this.streamsOutput$ = merge(
      streamSource,
      secondStreamSource,
      fastestStreamSource
    ).pipe(
      takeWhile(() => !!this.isComponentAlive),
      map(output => {
        this.outputStreamData = [...this.        outputStreamData, output]
        return this.outputStreamData;
      })
    )
  }
  1. HomeComponent类中删除stopStream方法,因为我们不再需要它。同时,从ngOnDestroy方法中删除它的使用。

  2. 最后,修改home.component.html模板,使用streamOutput$ Observable 和async管道来循环输出数组:

    <div class="output-stream">
      <div class="input-stream__item" *ngFor="let item       of streamsOutput$ | async">
        {{item}}
      </div>
    </div>
  1. 为了验证订阅在组件销毁时确实被销毁,让我们在startStream方法中的map操作符中放置一个console.log,如下所示:
startStream() {
    const streamSource = interval(1500);
    const secondStreamSource = interval(3000);
    const fastestStreamSource = interval(500);
    this.streamsOutput$ = merge(
      streamSource,
      secondStreamSource,
      fastestStreamSource
    ).pipe(
      takeWhile(() => !!this.isComponentAlive),
      map(output => {
        console.log(output)
        this.outputStreamData = [...this.        outputStreamData, output]
        return this.outputStreamData;
      })
    )
  }

万岁!有了这个改变,你可以尝试刷新应用程序,离开Home路由,你会发现控制台日志会在你这样做时立即停止。你感受到我们通过删除所有那些额外代码所获得的成就了吗?我当然感受到了。好吧,接下来看看它是如何工作的。

它是如何工作的…

Angular 的async管道在组件销毁时会自动销毁/取消订阅。这给了我们一个很好的机会在可能的情况下使用它。在这个示例中,我们基本上使用merge操作符组合了所有的流。有趣的是,对于streamsOutput$属性,我们希望得到一个输出数组的 Observable,我们可以对其进行循环。然而,合并流只是将它们组合在一起并发出任何一个流发出的最新值。因此,我们添加了一个.pipe()方法和.map()操作符,以从组合的流中取出最新的输出,将其添加到outputStreamData数组中以进行持久化,并从.map()方法中返回它,这样我们在模板中使用async管道时就可以得到数组。

有趣的事实-流不会发出任何值,除非它们被订阅。"但是,阿赫桑,我们没有订阅流,我们只是合并和映射数据。订阅在哪里?"很高兴你问。Angular 的async管道订阅了流本身,这也触发了我们在步骤 6中添加的console.log

重要提示

async 管道有一个限制,即在组件销毁之前无法停止订阅。在这种情况下,您可能希望使用类似takeWhile/takeUntil操作符的组件内订阅,或者在组件销毁时自己执行常规的.unsubscribe方法。

另请参阅

使用 combineLatest 订阅多个流

在上一个示例中,我们不得不合并所有流,这导致最后由任何一个流发出的单个输出。在这个示例中,我们将使用combineLatest,它的输出是一个数组,结合了所有的流。这种方法适用于当您想要来自所有流的最新输出,组合在一个单独的订阅中。

准备工作

我们要使用的项目位于克隆存储库内的chapter05/start_here/using-combinelatest-operator中。

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该在新的浏览器标签中打开应用程序,你应该看到类似这样的东西:

图 5.11 - 使用 combinelatest-operator 应用程序在 http://localhost:4200 上运行

图 5.11 - 使用 combinelatest-operator 应用程序在 http://localhost:4200 上运行

现在我们的应用程序在本地运行,让我们在下一节中看看这个示例的步骤。

如何做…

对于这个示例,我们有一个显示框的应用程序。框有一个大小(宽度和高度),一个边框半径,一个背景颜色,以及文本的颜色。它还有四个输入来修改所有提到的因素。现在,我们必须手动点击按钮来应用更改。如果我们可以订阅输入的更改并立即更新框呢?这就是我们要做的。

  1. 我们将首先创建一个名为listenToInputChanges的方法,在其中我们将订阅每个输入的更改,并使用combineLatest操作符组合这些流。更新home/home.component.ts文件如下:
...
import { combineLatest, Observable } from 'rxjs';
...
export class HomeComponent implements OnInit, OnDestroy {
  ...
  ngOnInit() {
    ...
    this.applyChanges();
    this.listenToInputChanges(); ← Add this
  }
  listenToInputChanges() {
    combineLatest([
      this.boxForm.get('size').valueChanges,
      this.boxForm.get('borderRadius').valueChanges,
      this.boxForm.get(      'backgroundColor').valueChanges,
      this.boxForm.get('textColor').valueChanges
    ]).subscribe(() => {
      this.applyChanges();
    });
  }
  ...
}
  1. 记住不取消订阅流是一个坏主意吗?这就是我们在这里的情况:一个已订阅的流。我们将使用async管道代替home.component.ts文件中当前使用的订阅。为此,让我们创建一个名为boxStyles$的 Observable 属性,并删除boxStyles属性。然后,将combineLatest的流分配给它,如下所示:
...
import { map} from 'rxjs/operators';
...
export class HomeComponent implements OnInit, OnDestroy {
  ...
  boxStyles: {...}; ← Remove this
  boxForm = new FormGroup({...});
  boxStyles$: Observable<{
    width: string,
    height: string,
    backgroundColor: string,
    color: string
    borderRadius: string
  }>;
   ...
  listenToInputChanges() {
    this.boxStyles$ = combineLatest([...]).    pipe(map(([size, borderRadius, backgroundColor,     textColor]) => {
      return {
        width: `${size}px`,
        height: `${size}px`,
        backgroundColor,
        color: textColor,
        borderRadius: `${borderRadius}px`
      }
    }));
  }
  ...
}
  1. 我们需要从home.component.ts文件中删除setBoxStyles()applyChanges()方法以及applyChanges()方法的使用。更新文件如下:
export class HomeComponent implements OnInit, OnDestroy {
  ...
  ngOnInit() {
    ...
    this.applyChanges(); ← Remove this
    this.listenToInputChanges(); ← Add this
  }
  ...
  setBoxStyles(size, backgroundColor, color,   borderRadius) {...}  ← Remove this
  applyChanges() {...} ← Remove this
  ...
}
  1. 我们还需要从模板中删除applyChanges()方法的使用。从home.component.html文件中的<form>元素中删除(ngSubmit)处理程序,使其如下所示:
<div class="home" [formGroup]="boxForm" (ngSubmit)="applyChanges()" ← Remove this>
  ...
</div>
  1. 我们还需要从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>

如果刷新应用程序,你会注意到框根本不显示。我们将在下一步中修复这个问题。

  1. 由于我们在应用程序启动时使用了combineLatest操作符,但我们没有触发它,因为没有一个输入发生了变化,我们需要使用startWith操作符和初始值来初始化框。为此,我们将使用startWith操作符和初始值,如下所示:
...
import { map, startWith } from 'rxjs/operators';
@Component({...})
export class HomeComponent implements OnInit, OnDestroy {
  ...
  ngOnInit() {
    this.listenToInputChanges();
  }
  listenToInputChanges() {
    this.boxStyles$ = combineLatest([
      this.boxForm
        .get('size')
        .valueChanges.pipe(startWith(this.        sizeOptions[0])),
      this.boxForm
        .get('borderRadius')
        .valueChanges.pipe(startWith(        this.borderRadiusOptions[0])),
      this.boxForm
        .get('backgroundColor')
        .valueChanges.pipe(startWith(        this.colorOptions[1])),
      this.boxForm
        .get('textColor')
        .valueChanges.pipe(startWith(        this.colorOptions[0])),
    ]).pipe(
      map(...);
  }
  ngOnDestroy() {}
}
  1. 现在我们已经有了boxStyles$ Observable,让我们在模板中使用它,而不是boxStyles属性:
  ...
  <div class="row" *ngIf="boxStyles$ | async as bStyles">
    <div class="box" [ngStyle]="bStyles">
      <div class="box__text">
        Hello World!
      </div>
    </div>
  </div>
  ...

大功告成!现在一切都运行得很完美。

恭喜完成了食谱。现在你是流和combineLatest操作符的大师了。查看下一节以了解它是如何工作的。

它是如何工作的…

响应式表单的美妙之处在于它们提供比常规的ngModel绑定或者甚至模板驱动表单更灵活的功能。对于每个表单控件,我们可以订阅它的valueChanges Observable,每当输入发生变化时就会接收到一个新的值。因此,我们不再依赖于提交按钮的点击,而是直接订阅了每个表单控件valueChanges属性。在常规情况下,这将导致四个不同的流用于四个输入,这意味着我们需要处理四个订阅并确保取消订阅。这就是combineLatest操作符发挥作用的地方。我们使用combineLatest操作符将这四个流合并为一个,这意味着我们只需要在组件销毁时取消订阅一个流。但是!记住如果我们使用async管道就不需要这样做了?这正是我们所做的。我们从home.component.ts文件中移除了订阅,并使用了.pipe()方法和.map()操作符。.map()操作符将数据转换为我们需要的格式,然后将转换后的数据返回给boxStyles$ Observable。最后,我们在模板中使用 async 管道订阅boxStyles$ Observable,并将其值分配为我们盒子元素的[ngStyle]

重要提示

combineLatest方法在每个 Observable 至少发出一个值之前不会发出初始值。因此,我们使用startWith操作符与每个单独的表单控件的valueChanges流来提供一个初始发出的值。

另请参阅

使用 flatMap 操作符创建顺序的 HTTP 调用

使用Promises的日子很棒。并不是说那些日子已经过去了,但我们作为开发者肯定更喜欢Observables而不是Promises,有很多原因。我真的很喜欢 Promises 的一件事是你可以链接 Promises 来做一些事情,比如顺序的 HTTP 调用。在这个教程中,你将学习如何使用flatMap操作符来使用Observables做同样的事情。

准备就绪

我们要处理的项目位于克隆存储库中的chapter05/start_here/using-flatmap-operator中。

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行 npm install 来安装项目的依赖项。

  3. 完成后,运行 ng serve -o

这应该在新的浏览器标签中打开应用程序,你应该会看到类似这样的东西:

图 5.12 – 使用-flatmap-operator 应用程序正在 http://localhost:4200 上运行

图 5.12 – 使用-flatmap-operator 应用程序正在 http://localhost:4200 上运行

现在应用程序看起来完美,实际上。没有什么可疑的,对吧?嗯,不完全是。按照以下步骤找出问题所在。

  1. 打开 Chrome DevTools。

  2. 转到网络选项卡,并模拟慢 3G网络,如下所示:图 5.13 – 在 Chrome DevTools 中模拟慢 3G 网络

图 5.13 – 在 Chrome DevTools 中模拟慢 3G 网络

如果你点击主页上的任何卡片,你应该能够到达特定用户的详细信息页面。

  1. 现在刷新应用程序,查看网络选项卡,你会看到 HTTP 调用并行执行,如下所示:

图 5.14 – 并行调用异步加载数据

图 5.14 – 并行调用异步加载数据

问题在于我们不确定由于两个 HTTP 调用并行执行,哪个数据会先到来。因此,用户可能会在主用户加载之前看到类似的用户。让我们看看如何避免这种情况。

如何做…

为了解决类似用户可能在主用户之前加载的问题,我们将不得不顺序加载数据,并分别显示相应的内容,而在内容加载时,我们将显示一个加载器。让我们开始吧。

  1. 首先,让我们修改我们的user-detail/user-detail.component.html文件,以便在加载时显示加载器,以及在加载类似的用户时也显示加载器。代码应该如下所示:
<div class="user-detail">
  <div class="main-content user-card">
    <app-user-card *ngIf="user$ | async as user; else     loader" [user]="user"></app-user-card>
  </div>
  <div class="secondary-container">
    <h4>Similar Users</h4>
    <div class="similar-users">
      <ng-container *ngIf="similarUsers$ | async as       users; else loader">
        <app-user-card class="user-card" *ngFor="let user         of users" [user]="user"></app-user-card>
      </ng-container>
    </div>
  </div>
</div>
<ng-template #loader>
  <app-loader></app-loader>
</ng-template>

如果刷新应用程序,你应该会看到在进行调用之前两个加载器都出现。

我们希望进行顺序调用,为此,我们不能直接将流绑定到UserDetailComponent类中的 Observables。也就是说,我们甚至不能使用async管道。

  1. 让我们将UserDetailComponent类中的 Observable 属性转换为常规属性,如下所示:
...
export class UserDetailComponent implements OnInit, OnDestroy {
  user: IUser;
  similarUsers: IUser[];
  isComponentAlive: boolean;
  ...
}

只要保存上述更改,应用程序就会立即崩溃。

  1. 让我们在模板中使用我们在上一步中修改的新变量。修改user-detail.component.html文件,如下所示:
<div class="user-detail">
  <div class="main-content user-card">
    <app-user-card *ngIf="user; else loader"     [user]="user"></app-user-card>
  </div>
  <div class="secondary-container">
    <h4>Similar Users</h4>
    <div class="similar-users">
      <ng-container *ngIf="similarUsers; else loader">
        <app-user-card class="user-card" *ngFor="let user         of similarUsers" [user]="user"></app-user-card>
      </ng-container>
    </div>
  </div>
</div>
...
  1. 最后,让我们现在使用flatMap运算符按顺序执行调用,并将接收到的值分配给相应的变量,如下所示:
...
import { takeWhile, flatMap } from 'rxjs/operators';
export class UserDetailComponent implements OnInit, OnDestroy {
  ...
  ngOnInit() {
    this.isComponentAlive = true;
    this.route.paramMap.pipe(
      takeWhile(() => !!this.isComponentAlive),
      flatMap(params => {
        this.user = null;
        this.similarUsers = null;
        const userId = params.get('uuid');
        return this.userService.getUser(userId)
          .pipe(
            flatMap((user: IUser) => {
              this.user = user;
              return this.userService.              getSimilarUsers(userId);
            })
          );
      })
    ).subscribe((similarUsers: IUser[]) => {
      this.similarUsers = similarUsers;
    })
  }
  ...
}

是的!如果您现在刷新应用程序,您会注意到调用是顺序的,因为我们首先获取主用户,然后获取相似用户。要确认,您可以打开 Chrome DevTools 并查看应用程序编程接口API)调用的网络日志。您应该会看到类似以下内容:

图 5.15 – API 调用同步执行

图 5.15 – API 调用同步执行

现在您已经完成了这个步骤,请查看下一节,了解其工作原理。

工作原理…

flatMap运算符获取前一个 Observable 的输出,并应返回一个新的 Observable。这有助于我们按顺序执行 HTTP 调用,以确保数据根据其优先级或我们的业务逻辑加载。

由于我们希望在选择新用户时执行调用,这可以从UserDetailComponent类本身发生,我们直接在route.paramsMap上放置了flatMap运算符。每当发生这种情况时,我们首先将usersimilarUsers属性设置为null。"但为什么?"嗯,因为如果我们在UserDetailsComponent页面上并单击任何相似用户,页面不会更改,因为我们已经在上面。这意味着用户和similarUsers变量仍将包含其先前的值。而且由于它们已经有值(即它们不是null),在点击任何相似用户时,加载程序将不会显示在这种情况下。聪明,对吧?

无论如何,在将变量分配为null之后,我们将 Observable 从this.userService.getUser(userId)块返回,这将导致执行第一个 HTTP 调用以获取主用户。然后,我们在第一个调用的 Observable 上使用管道和flatMap来获取主用户,将其分配给this.user块,然后返回第二个调用的 Observable——即this.userService.getSimilarUsers(userId)代码。最后,我们使用.subscribe方法从getSimilarUsers(userId)接收值,一旦接收到值,我们将其分配给this.similarUsers

另请参见

使用 switchMap 操作符来切换最后一个订阅与新的订阅

对于许多应用程序,我们有诸如用户输入时搜索内容的功能。这是一个非常好的用户体验UX),因为用户不必按按钮进行搜索。然而,如果我们在每次按键时向服务器发送调用,那将导致大量的 HTTP 调用被发送,我们无法知道哪个 HTTP 调用会首先完成;因此,我们无法确定我们是否会在视图上看到正确的数据。在这个示例中,您将学习如何使用switchMap操作符来取消上一个订阅并创建一个新的订阅。这将导致取消以前的调用并保留一个调用 - 最后一个调用。

准备工作

我们要处理的项目位于克隆存储库中的chapter05/start_here/using-switchmap-operator中。

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该会在新的浏览器标签中打开应用程序,你应该会看到类似于这样的东西:

图 5.16 - 使用 switchmap-operator 应用程序在 http://localhost:4200 上运行

图 5.16 - 使用 switchmap-operator 应用程序在 http://localhost:4200 上运行

现在我们的应用程序在本地运行,打开 Chrome DevTools 并转到Network选项卡。在搜索输入框中输入'huds',你会看到有四个调用被发送到 API 服务器,如下所示:

图 5.17 - 每次输入更改都发送一个单独的调用

图 5.17 - 每次输入更改都发送一个单独的调用

如何做…

您可以在主页的搜索框中开始输入以查看筛选后的用户,如果您查看Network选项卡,您会注意到每次输入更改时,我们都会发送一个新的 HTTP 调用。让我们通过使用switchMap操作符来避免在每次按键时发送调用。

  1. 首先,在home/home.component.ts文件中从rxjs/operators中导入switchMap操作符,如下所示:
...
import { switchMap, takeWhile } from 'rxjs/operators';
  1. 我们现在将修改对username表单控件的订阅,具体来说是使用switchMap操作符来调用this.userService.searchUsers(query)方法的valueChanges Observable。这将返回一个包含 HTTP 调用结果的Observable。代码应该如下所示:
...
  ngOnInit() {
    this.componentAlive = true;
    this.searchForm = new FormGroup({
      username: new FormControl('', [])
    })
    this.searchUsers();
    this.searchForm.get('username').valueChanges
      .pipe(
        takeWhile(() => !!this.componentAlive),
        switchMap((query) => this.userService.        searchUsers(query))
      )
      .subscribe((users) => {
        this.users = users;
      })
  }

如果现在刷新应用程序,打开 Chrome DevTools,并在输入'huds'时检查网络类型,您会看到所有先前的调用都被取消,我们只有最新的 HTTP 调用成功:

图 5.18 – switchMap 取消先前的 HTTP 调用

图 5.18 – switchMap 取消先前的 HTTP 调用

哇!现在我们只有一个调用会成功,处理数据,并最终显示在视图中。请参阅下一节了解其工作原理。

它是如何工作的…

switchMap操作符会取消先前(内部)的订阅,并订阅一个新的 Observable。这就是为什么它会取消我们示例中之前发送的所有 HTTP 调用,只订阅最后一个的原因。这是我们应用程序的预期行为。

另请参阅

使用 RxJS 进行 HTTP 请求去抖

在上一个示例中,我们学习了如何使用switchMap操作符来取消先前的 HTTP 调用,如果有新的 HTTP 调用。这很好,但是为什么在我们可以使用一种技术在发送 HTTP 调用之前等待一段时间呢?理想情况下,我们将继续监听一段时间的重复请求,然后继续进行最新的请求。在这个示例中,我们将使用debounceTime操作符来确保我们只在用户停止输入一段时间后才发送 HTTP 调用。

准备工作

我们将要处理的项目位于克隆存储库中的chapter05/start_here/using-debouncetime-operator中。

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该会在新的浏览器选项卡中打开应用程序,并且您应该会看到类似于这样的内容:

图 5.19 – 使用 debouncetime 操作符的应用程序运行在 http://localhost.4200 上

图 5.19 – 使用 debouncetime 操作符的应用程序运行在 http://localhost.4200

现在应用程序正在运行,打开 Chrome DevTools,转到网络选项卡,然后在用户搜索栏中输入'Irin'。您应该会看到类似于这样的内容:

图 5.20 - 每次键盘输入都会发送到服务器的新调用

图 5.20 - 每次键盘输入都会发送到服务器的新调用

注意第三次调用的响应是在第四次调用之后吗?这就是我们试图通过使用某种防抖来解决的问题。

让我们在下一节中跳转到食谱步骤。

如何做…

当我们在主页的搜索框中输入时(也就是说,每当输入发生变化时),我们会发送一个新的 HTTP 调用。

为了确保在输入搜索后处于空闲状态时只发送一次调用,我们将在this.searchForm.get('username').valueChanges Observable 上放置一个debounceTime操作符。更新home/home.component.ts文件,如下所示:

...
import { debounceTime, takeWhile } from 'rxjs/operators';
...
export class HomeComponent implements OnInit, OnDestroy {
  ...
  ngOnInit() {
    ...
    this.searchForm.get('username').valueChanges
      .pipe(
        takeWhile(() => !!this.componentAlive),
        debounceTime(300),
      )
      .subscribe(() => {
        this.searchUsers();
      })
  }
  searchUsers() {...}
  ngOnDestroy() {}
}

就是这样!如果您在检查网络选项卡时在搜索输入框中输入'irin',您应该只看到一次调用被发送到服务器,如下所示:

图 5.21 - debounceTime 只导致一次调用发送到服务器

图 5.21 - debounceTime 只导致一次调用发送到服务器

请查看下一节以了解它是如何工作的。

它是如何工作的…

debounceTime操作符在从源 Observable 发出值之前等待一段时间,而且只有在没有更多的源发射时才会发出值。这使我们能够在输入的valueChanges Observable 上使用该操作符。当您在输入框中输入内容时,debounceTime操作符会等待 300 毫秒,以查看您是否仍在输入。如果您在这 300 毫秒内没有输入,它将继续发出值,导致最终进行 HTTP 调用。

另请参阅

第六章:第六章:使用 NgRx 进行响应式状态管理

Angular 和响应式编程是最好的朋友,以响应式方式处理应用程序的状态是您可以为应用程序做的最好的事情之一。NgRx 是一个为 Angular 提供一组库作为响应式扩展的框架。在本章中,您将学习如何使用 NgRx 生态系统以响应式地管理应用程序的状态,并且您还将学习 NgRx 生态系统将帮助您完成的一些很酷的事情。

以下是本章我们将要涵盖的食谱:

  • 使用动作和减速器创建你的第一个 NgRx 存储

  • 使用@ngrx/store-devtools来调试状态变化

  • 创建一个效果来获取第三方应用程序编程接口API)数据

  • 使用选择器从多个组件中的存储中获取数据

  • 使用@ngrx/component-store来在组件内进行本地状态管理

  • 使用@ngrx/router-store以响应式方式处理路由更改

技术要求

对于本章的食谱,请确保您的计算机上已安装GitNode.js。您还需要安装@angular/cli包,可以在终端中使用npm install -g @angular/cli来安装。本章的代码可以在 https://github.com/PacktPublishing/Angular-Cookbook/tree/master/chapter06 找到。

使用动作和减速器创建你的第一个 NgRx 存储

在这个食谱中,您将通过设置您的第一个 NgRx 存储来逐步了解 NgRx 的基础知识。您还将创建一些动作以及一个减速器,并且为了查看减速器中的变化,我们将放入适当的控制台日志。

准备工作

我们将要使用的项目位于chapter06/start_here/ngrx-actions-reducer中,位于克隆存储库内:

  1. Visual Studio Code (VS Code)中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该会在新的浏览器标签中打开应用程序。点击以管理员身份登录按钮,您应该会看到以下屏幕:

图 6.1 – ngrx-actions-reducers 应用程序在 http://localhost:4200 上运行

图 6.1 – ngrx-actions-reducers 应用程序在 http://localhost:4200 上运行

现在我们的应用程序正在运行,我们将继续进行食谱的步骤。

如何做…

我们有一个现有的 Angular 应用程序,我们在之前的示例中也使用过。如果您以管理员用户身份登录,您可以向购物篮中添加和移除物品。但是,如果您以员工身份登录,您只能添加物品而不能移除物品。现在我们将开始将 NgRx 集成到应用程序中,并创建一个 reducer 和一些动作:

  1. 首先通过Node Package Manager (npm)在您的项目中安装@ngrx/store package。打开终端(Mac/Linux)或命令提示符(Windows),导航到项目根目录,并运行以下命令:
npm install @ngrx/store@12.0.0 --save

如果您已经在运行,请确保重新运行ng-serve命令。

  1. 更新app.module.ts文件以包括StoreModule,如下所示:
...
import { StoreModule } from '@ngrx/store';
@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    FormsModule,
    BrowserAnimationsModule,
    StoreModule.forRoot({})
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

注意,我们已经向forRoot方法传递了一个空对象{};我们将在以后进行更改。

  1. 现在,我们将创建一些动作。在app文件夹内创建一个名为store的文件夹。然后,在store文件夹内创建一个名为app.actions.ts的文件,并最后向新创建的文件中添加以下代码:
import { createAction, props } from '@ngrx/store';
import { IFruit } from '../interfaces/fruit.interface';
export const addItemToBucket = createAction(
  '[Bucket] Add Item',
  props<IFruit>()
);
export const removeItemFromBucket = createAction(
  '[Bucket] Remove Item',
  props<IFruit>()
);

由于我们现在已经有了动作,我们必须创建一个 reducer。

  1. store文件夹内创建一个新文件,命名为app.reducer.ts,并向其中添加以下代码以定义必要的导入:
import { Action, createReducer, on } from '@ngrx/store';
import { IFruit } from '../interfaces/fruit.interface';
import * as AppActions from './app.actions';
  1. 现在,定义一个AppState接口以反映应用程序的状态,并定义一个initialState变量以反映应用程序启动时应用程序状态的外观。在app.reducer.ts文件中添加以下代码:
import { Action, createReducer, on } from '@ngrx/store';
import { IFruit } from '../interfaces/fruit.interface';
import * as AppActions from './app.actions';

export interface AppState {
  bucket: IFruit[];
}

const initialState: AppState = {
  bucket: []
}
  1. 现在是时候实际创建一个 reducer 了。在app.reducer.ts文件中添加以下代码以创建一个 reducer:
...
const initialState: AppState = {
  bucket: []
}
const appReducer = createReducer(
  initialState,
  on(AppActions.addItemToBucket, (state, fruit) =>   ({ ...state, bucket: [fruit, ...state.bucket] })),
  on(AppActions.removeItemFromBucket, (state, fruit) => {
    return {
      ...state,
      bucket: state.bucket.filter(bucketItem => {
        return bucketItem.id !== fruit.id;
      }) }
  }),
);

export function reducer(state: AppState = initialState, action: Action) {
  return appReducer(state, action);
}
  1. 我们还将在reducer方法中添加一些console.logs调用,以查看控制台上所有动作的触发情况。在app.reducer.ts文件中添加如下日志:
export function reducer(state: AppState = initialState, action: Action) {
  console.log('state', state);
  console.log('action', action);
  return appReducer(state, action);
}
  1. 最后,在app.module.ts文件中使用StoreModule.forRoot()方法注册此 reducer,以便我们可以看到事情的运行情况:
...
import { StoreModule } from '@ngrx/store';
import * as appStore from './store/app.reducer';
@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    StoreModule.forRoot({app: appStore.reducer})
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

如果现在刷新应用程序,您应该在应用程序启动时立即在控制台上看到以下日志:

图 6.2 - 显示应用启动时的初始状态和@ngrx/store/init 动作的日志

图 6.2 - 显示应用启动时的初始状态和@ngrx/store/init 动作的日志

  1. 现在我们可以看到 reducer 起作用了,让我们在添加和移除购物篮中的物品时分派我们的动作。为此,在shared/components/bucket/bucket.component.ts文件中按以下方式分派动作:
...
import { Store } from '@ngrx/store';
import { AppState } from 'src/app/store/app.reducer';
import { addItemToBucket, removeItemFromBucket } from 'src/app/store/app.actions';
export class BucketComponent implements OnInit {
  ...
  constructor(
    private bucketService: BucketService,
    private store: Store<AppState>
  ) { }
  ngOnInit(): void {...}
  addSelectedFruitToBucket() {
const newItem: IFruit = {
      id: Date.now(),
      name: this.selectedFruit
    }
    this.bucketService.addItem(newItem);
    this.store.dispatch(addItemToBucket(newItem));
  }
  deleteFromBucket(fruit: IFruit) {
    this.bucketService.removeItem(fruit);
    this.store.dispatch(removeItemFromBucket(fruit));
  }
}
  1. 以管理员身份登录应用程序,向桶中添加一些项目,然后删除一些项目。您会在控制台上看到类似这样的内容:

图 6.3 - 显示从桶中添加和删除项目的操作日志

图 6.3 - 显示从桶中添加和删除项目的操作日志

至此,这个教程就结束了!您现在知道如何将 NgRx 存储集成到 Angular 应用程序中,以及如何创建 NgRx 操作并分发它们。您还知道如何创建一个 reducer,定义它的状态,并监听操作以对分发的操作进行操作。

另请参阅

使用@ngrx/store-devtools 调试状态更改

在这个教程中,您将学习如何设置和使用@ngrx/store-devtools来调试应用程序的状态、操作分发以及操作分发时状态的差异。我们将使用一个我们熟悉的现有应用程序来了解这个过程。

准备工作

这个教程的项目位于chapter06/start_here/using-ngrx-store-devtool

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该会在新的浏览器选项卡中打开应用程序。

  1. 以管理员用户身份登录,并且您应该看到这样的屏幕:

图 6.4 - 在 http://localhost:4200 上运行的使用 ngrx-store-devtools 应用程序

图 6.4 - 在 http://localhost:4200 上运行的使用 ngrx-store-devtools 应用程序

现在我们已经设置好了应用程序,让我们在下一节中看看这个教程的步骤。

如何做…

我们有一个 Angular 应用程序,已经集成了@ngrx/store包。我们还设置了一个 reducer,并且有一些操作,当您添加或删除项目时,这些操作会立即在控制台上记录。让我们开始配置应用程序的存储开发工具:

  1. 首先在项目中安装@ngrx/store-devtools包,如下所示:
npm install @ngrx/store-devtools@12.0.0 --save
  1. 现在,更新您的app.module.ts文件,包括StoreDevtoolsModule.instrument条目,如下所示:
...
import * as appStore from './store/app.reducer';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    StoreModule.forRoot({app: appStore.reducer}),
    StoreDevtoolsModule.instrument({
      maxAge: 25, // Retains last 25 states
    }),
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
  1. 现在,从github.com/zalmoxisus/redux-devtools-extension/下载 Redux DevTools 扩展,安装到您特定的浏览器上。在本书中,我将一直使用 Chrome 浏览器。

  2. 打开 Chrome DevTools。应该会有一个名为Redux的新标签。点击它并刷新页面。您会看到类似于这样的内容:图 6.5 - Redux DevTools 显示初始的 Redux 动作已经分发

图 6.5 - Redux DevTools 显示初始的 Redux 动作已经分发

  1. 要查看当前应用程序状态,请点击State按钮,如下截图所示,您应该会看到我们当前的状态是app > bucket: []图 6.6 - 在 Redux DevTools 扩展中查看当前状态

图 6.6 - 在 Redux DevTools 扩展中查看当前状态

  1. 现在,向桶里加入一个樱桃🍒和一个香蕉🍌,然后从桶里移除香蕉🍌。您应该看到所有相关的动作被分发,如下所示:

图 6.7 - Redux DevTools 显示 addItemToBucket 和 removeItemFromBucket 动作

图 6.7 - Redux DevTools 显示 addItemToBucket 和 removeItemFromBucket 动作

如果您展开状态中的桶数组,您会看到它反映了桶的当前状态,就像我们在以下截图中看到的那样:

图 6.8 - Redux DevTools 显示桶的当前状态

图 6.8 - Redux DevTools 显示桶的当前状态

太棒了!您刚刚学会了如何使用 Redux DevTools 扩展来查看您的 NgRx 状态和已分发的动作。

它是如何工作的...

重要的是要理解 NgRx 是 Angular 和 Redux(使用 RxJS)的组合。通过使用 Store Devtools 包和 Redux DevTools 扩展,我们能够轻松调试应用程序,这有助于我们发现潜在的错误,预测状态变化,并且更透明地了解@ngrx/store包后台发生的情况。

还有更多...

您还可以看到动作在应用程序状态中引起的差异。也就是说,当我们使用水果分发addItemToBucket动作时,桶中会增加一个项目,当我们分发removeItemFromBucket动作时,桶中会移除一个项目。请参见以下截图和图 6.10

图 6.9 - addItemToBucket 操作导致向桶中添加项目

图 6.9 - addItemToBucket 操作导致向桶中添加项目

请注意图 6.9中数据{id:1605205728586,name:'Banana 🍌``'}周围的绿色背景。这代表对状态的添加。您可以在这里看到removeItemFromBucket操作:

图 6.10 - removeItemFromBucket 操作导致从桶中移除项目

图 6.10 - removeItemFromBucket 操作导致从桶中移除项目

同样,注意图 6.10中数据{id:16052057285… 🍌``'}周围的红色背景和删除线。这代表从状态中移除。

另请参阅

创建一个用于获取第三方 API 数据的效果

在这个食谱中,您将学习如何使用@ngrx/effects包来使用 NgRx 效果。您将创建并注册一个效果,该效果将监听一个事件。然后,我们将对该操作做出反应,以获取第三方 API 数据,并作出成功或失败的响应。这将会很有趣。

准备工作

这个食谱的项目位于chapter06/start_here/using-ngrx-effect中:

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行npm install以安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该会在新的浏览器标签中打开应用程序,并且您应该会看到应用程序,如下所示:

图 6.11 - 使用 ngrx-effects 应用程序在 http://localhost:4200 上运行

图 6.11 - 使用 ngrx-effects 应用程序在 http://localhost:4200 上运行

现在我们的应用程序在本地运行,让我们在下一节中看看食谱的步骤。

如何做…

我们有一个名为Home页面的单一路由的应用程序。在HomeComponent类中,我们使用UserService发送超文本传输协议HTTP)调用以获取用户,然后在浏览器上显示出来。正如您在图 6.1中所看到的,我们已经集成了@ngrx/store@ngrx/store-devtools包。

  1. 在项目中安装@ngrx/effects包,如下所示:
npm install --save @ngrx/effects@12.0.0
  1. 现在我们将创建用于从 HTTP 调用获取用户的动作。我们将有一个动作用于获取用户,一个用于成功获取用户时分派,以及一个用于在出现错误时分派的动作。将以下代码添加到store/app.actions.ts文件中:
import { createAction, props } from '@ngrx/store';
import { IUser } from '../core/interfaces/user.interface';
export const APP_ACTIONS = {
  GET_USERS: '[Users] Get Users',
  GET_USERS_SUCCESS: '[Users] Get Users Success',
  GET_USERS_FAILURE: '[Users] Get Users Failure',
}
export const getUsers = createAction(
  APP_ACTIONS.GET_USERS,
);
export const getUsersSuccess = createAction(
  APP_ACTIONS.GET_USERS_SUCCESS,
  props<{users: IUser[]}>()
);
export const getUsersFailure = createAction(
  APP_ACTIONS.GET_USERS_FAILURE,
  props<{error: string}>()
);

现在让我们创建一个效果,以便我们可以监听GET_USERS动作,执行 API 调用,并在成功获取数据时分派成功动作。

  1. store文件夹中创建一个名为app.effects.ts的文件,并将以下代码添加到其中:
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, mergeMap, catchError } from 'rxjs/operators';
import { UserService } from '../core/services/user.service';
import { APP_ACTIONS, getUsersFailure, getUsersSuccess } from './app.actions';
@Injectable()
export class AppEffects {
  constructor(
    private actions$: Actions,
    private userService: UserService
  ) {}
}
  1. 现在我们将在app.effects.ts文件中创建一个新的效果,以注册GET_USERS动作的监听器,如下所示:
...
@Injectable()
export class AppEffects {
  getUsers$ = createEffect(() =>
    this.actions$.pipe(
      ofType(APP_ACTIONS.GET_USERS),
      mergeMap(() => this.userService.getUsers()
        .pipe(
          map(users => {
            return getUsersSuccess({
              users
            })
          }),
          catchError((error) => of(getUsersFailure({
            error
          })))
        )
      )
    )
  );
  ...
}
  1. 现在我们将在app.module.ts文件中将我们的效果注册为应用程序的根效果,如下所示:
...
import { EffectsModule } from '@ngrx/effects';
import { AppEffects } from './store/app.effects';
@NgModule({
  declarations: [...],
  imports: [
    ...
    StoreDevtoolsModule.instrument({
      maxAge: 25, // Retains last 25 states
    }),
    EffectsModule.forRoot([AppEffects])
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

一旦我们注册了效果,您应该在 Redux DevTools 扩展中看到一个名为@ngrx/effects/init的额外动作触发,如下所示:

图 6.12 - @ngrx/effects/init 动作在应用启动时触发

图 6.12 - @ngrx/effects/init 动作在应用启动时触发

  1. 现在我们已经让效果监听动作,让我们从HomeComponent类中分派GET_USERS动作,我们应该看到成功调用后返回GET_USERS_SUCCESS动作。添加以下代码以从home/home.component.ts中分派动作:
...
import { AppState } from '../store/app.reducer';
import { Store } from '@ngrx/store';
import { getUsers } from '../store/app.actions';
@Component({...})
export class HomeComponent implements OnInit, OnDestroy {
  users$: Observable<IUser[]>;
  constructor(
    private userService: UserService,
    private store: Store<AppState>
  ) {}
  ngOnInit() {
    this.store.dispatch(getUsers())
    this.users$ = this.userService.getUsers();
  }
  ngOnDestroy() {}
}

如果现在刷新应用程序,您应该看到[Users] Get Users动作被分派,并且作为成功 HTTP 调用的返回,[Users] Get Users Success动作也被分派:

图 6.13 - 分派 GET_USERS 和 GET_USERS_SUCCESS 动作

图 6.13 - 分派 GET_USERS 和 GET_USERS_SUCCESS 动作

请注意图 6.13中,在分派GET_USERS_SUCCESS动作后,Diff为空。这是因为到目前为止我们还没有使用 reducer 更新状态。

  1. 让我们在app.reducer.ts文件中更新状态,以监听GET_USERS_SUCCESS动作并相应地将用户分配到状态中。代码应该如下所示:
import { Action, createReducer, on } from '@ngrx/store';
import { IUser } from '../core/interfaces/user.interface';
import { getUsersSuccess } from './app.actions';
export interface AppState {
  users: IUser[];
}
const initialState: AppState = {
  users: []
}
const appReducer = createReducer(
  initialState,
  on(getUsersSuccess, (state, action) => ({
    ...state,
    users: action.users
  }))
);
export function reducer(state: AppState = initialState, action: Action) {
  return appReducer(state, action);
}

如果现在刷新应用程序,您应该看到用户被分配到状态中,如下所示:

图 6.14 - GET_USERS_SUCCESS 动作将用户添加到状态

图 6.14 - GET_USERS_SUCCESS 动作将用户添加到状态

如果您现在查看应用程序的状态,您应该看到类似于这样的内容:

图 6.15 - 在 GET_USERS_SUCCESS 动作后包含用户的应用程序状态

图 6.15 - 在 GET_USERS_SUCCESS 操作后包含用户的应用程序状态

现在,我们向服务器发送了两个调用 - 一个通过 effect,另一个通过HomeComponent类的ngOnInit方法,直接使用UserService实例。让我们从HomeComponent类中删除UserService。现在我们看不到任何数据,但这是我们将在下一个示例中要做的事情。

  1. HomeComponent类中删除UserService,你的home.component.ts文件现在应该是这样的:
...
@Component({...})
export class HomeComponent implements OnInit, OnDestroy {
  users$: Observable<IUser[]>;
  constructor(
  private userService: UserService, ← Remove this
    private store: Store<AppState>
  ) {}
  ngOnInit() {
    this.store.dispatch(getUsers());
    this.users$ = this.userService.getUsers();  ← Remove     this
  }
  ngOnDestroy() {}
}

太棒了!现在你知道如何在你的 Angular 应用程序中使用 NgRx 效果。请查看下一节,了解 NgRx 效果的工作原理。

重要说明

现在我们有一个输出,如图 6.15所示 - 也就是说,即使用户数据已经设置在存储中,我们仍然保持显示加载程序。这个示例的主要目的是使用@ngrx/effects,这已经完成了。我们将在下一个示例中显示适当的数据,使用选择器从多个组件中的存储中获取数据

它是如何工作的...

为了使 NgRx 效果起作用,我们需要安装@ngrx/effects包,创建一个效果,并在AppModule类中将其注册为一组效果(根效果)。当你创建一个效果时,它必须监听一个动作。当从任何组件甚至另一个效果向存储分派一个动作时,注册的效果会触发,执行你希望它执行的工作,并应该返回另一个动作。对于 API 调用,通常有三个动作 - 即主要动作,以及以下成功和失败动作。理想情况下,在成功动作(也许在失败动作上),你会想要更新一些状态变量。

另请参阅

在多个组件中使用选择器从存储中获取数据

在上一个示例中,我们创建了一个 NgRx 效果来获取第三方 API 数据作为用户,并将其保存在 Redux 存储中。这是我们在这个示例中的起点。我们有一个效果,从api.randomuser.me获取用户并将其存储在状态中,目前在用户界面UI)上没有显示任何内容。在这个示例中,你将创建一些 NgRx 选择器,以在主页用户详细信息页面上显示相似的用户。

做好准备

此示例的项目位于chapter06/start_here/using-ngrx-selector中:

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该会在新的浏览器标签中打开应用程序。一旦页面打开,你应该能够看到应用程序,如下所示:

图 6.16 - 在 http://localhost:4200 上运行的 ngrx-selectors 应用程序

图 6.16 - 在 http://localhost:4200 上运行的 ngrx-selectors 应用程序

现在我们的应用程序在本地运行,让我们在下一节中看看食谱的步骤。

如何做…

在这个食谱中,我们所要做的就是使用 NgRx 选择器、我们已经有的 reducer 和 Redux 状态。非常简单。让我们开始吧!

我们将首先在主页上显示用户,并为此创建我们的第一个 NgRx 选择器:

  1. store文件夹中创建一个新文件。命名为app.selectors.ts并添加以下代码:
import { createSelector, createFeatureSelector } from '@ngrx/store';
import { AppState } from './app.reducer';
export const selectApp = createFeatureSelector<AppState>('app');
export const selectUsers = createSelector(
  selectApp,
  (state: AppState) => state.users
);

现在我们已经有了选择器,让我们在HomeComponent类中使用它。

  1. 修改home.component.ts文件中的ngOnInit方法。它应该是这样的:
...
import { getUsers } from '../store/app.actions';
import { selectUsers } from '../store/app.selectors';
@Component({...})
export class HomeComponent implements OnInit, OnDestroy {
  ...
  ngOnInit() {
    this.users$ = this.store.select(selectUsers);
    this.store.dispatch(getUsers())
  }
  ngOnDestroy() {}
}

现在刷新应用程序,你应该能够看到用户。如果你点击任何一个用户,你将导航到用户详情,但看不到任何有价值的数据。页面应该是这样的:

图 6.17 - 无法显示当前用户和相似用户

图 6.17 - 无法显示当前用户和相似用户

  1. 为了查看当前用户和相似用户,我们首先在UserDetailComponent类中创建两个 Observables,以便稍后订阅它们各自的 store 选择器。在user-detail.component.ts文件中添加 Observables,如下所示:
...
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs/internal/Observable';
@Component({...})
export class UserDetailComponent implements OnInit, OnDestroy {
  user: IUser = null; ← Remove this
  similarUsers: IUser[] = []; ← Remove this
  user$: Observable<IUser> = null; ← Add this
  similarUsers$: Observable<IUser[]> = null; ← Add this
  isComponentAlive: boolean;
  constructor( ) {}
  ngOnInit() {
    this.isComponentAlive = true;
  }
  ngOnDestroy() {
    this.isComponentAlive = false;
  }
}
  1. 更新user-detail.component.html模板以使用新的 Observable 属性,如下所示:
<div class="user-detail">
  <div class="main-content user-card">
    <app-user-card *ngIf="user$ | async as user;     else loader" [user]="user"></app-user-card>
  </div>
  <div class="secondary-container">
    <h4>Similar Users</h4>
    <div class="similar-users">
      <ng-container *ngIf="similarUsers$ | async       as similarUsers; else loader">
        <app-user-card class="user-card" *ngFor="let user         of similarUsers" [user]="user"></app-user-card>
      </ng-container>
    </div>
  </div>
</div>
...
  1. 更新app.selectors.ts文件以添加两个选择器,如下所示:
...
import { IUser } from '../core/interfaces/user.interface';
export const selectUsers = createSelector(...);
export const selectCurrentUser = (uuid) => createSelector(
  selectUsers,
  (users: IUser[]) => users ? users.find(user => {
    return user.login.uuid === uuid;
  }) : null
);
export const selectSimilarUsers = (uuid) => createSelector(
  selectUsers,
  (users: IUser[]) => users ? users.filter(user => {
    return user.login.uuid !== uuid;
  }): null
);

由于我们使用用户的通用唯一标识符UUID)导航到用户详情页面,我们将监听活动路由的paramsMap并分配适当的选择器。

  1. 首先,在user-detail.component.ts文件中添加正确的导入,如下所示:
...
import { takeWhile } from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { AppState } from '../store/app.reducer';
import { selectCurrentUser, selectSimilarUsers } from '../store/app.selectors';
import { ActivatedRoute } from '@angular/router';
  1. 现在,在相同的user-detail.component.ts文件中,使用Store服务并更新ngOnInit方法,如下所示:
@Component({...})
export class UserDetailComponent implements OnInit, OnDestroy {
  ...
  constructor(
    private route: ActivatedRoute,
    private store: Store<AppState>
  ) {}
  ngOnInit() {
    this.isComponentAlive = true;
    this.route.paramMap.pipe(
      takeWhile(() => !!this.isComponentAlive)
    )
    .subscribe(params => {
      const uuid = params.get('uuid');
      this.user$ = this.store.      select(selectCurrentUser(uuid))
      this.similarUsers$ = this.store.      select(selectSimilarUsers(uuid))
    });
  }
  ...
}

我们将在UserDetailComponent类中添加另一个方法,如果应用程序中还没有获取用户,它将获取用户。

  1. 按照以下方式向 user-detail.component.ts 文件添加 getUsersIfNecessary 方法:
...
import { first, takeWhile } from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { AppState } from '../store/app.reducer';
import { selectCurrentUser, selectSimilarUsers, selectUsers } from '../store/app.selectors';
import { getUsers } from '../store/app.actions';
@Component({...})
export class UserDetailComponent implements OnInit, OnDestroy {
  ...
  ngOnInit() {
    …
    this.getUsersIfNecessary();
  }
  getUsersIfNecessary() {
    this.store.select(selectUsers)
    .pipe(
      first ()
    )
    .subscribe((users) => {
      if (users === null) {
        this.store.dispatch(getUsers())
      }
    })
  }
}

刷新应用程序… 突然!您现在可以看到当前用户和相似用户。请查看下一节以了解它是如何工作的。

工作原理…

在这个教程中,我们已经有了一个 reducer 和一个从第三方 API 获取用户数据的 effect。我们首先创建了一个用于主屏幕用户的选择器。这很容易——我们只需要创建一个简单的选择器。请注意,reducer 的状态如下所示:

  app: {
    users: []
  }

这就是为什么我们首先使用 createFeatureSelector 来获取 app 状态,然后使用 createSelector 来获取 users 状态。

困难的部分是获取当前用户和相似用户。为此,我们创建了可以以 uuid 作为输入的选择器。然后,我们在 UserDetailComponent 类中监听 paramMapuuid,一旦它发生变化,我们就会获取它。然后,我们通过将 uuid 传递给选择器来使用它们,以便选择器可以过滤当前用户和相似用户。

最后,我们遇到了一个问题,即如果有人直接着陆到用户详情页面并带有 uuid,他们将看不到任何东西,因为我们没有获取用户。这是因为我们只在主页上获取用户,所以任何直接着陆到用户详情页面的人都不会触发 effect。这就是为什么我们创建了一个名为 getUsersIfNecessary 的方法,以便它可以检查状态并在没有获取用户时获取用户。

另请参阅

使用 @ngrx/component-store 在组件内进行本地状态管理

在这个教程中,您将学习如何使用 NgRx Component Store,以及如何使用它来代替基于推送的 Subject/BehaviorSubject 模式与服务一起维护组件的本地状态。

请记住,@ngrx/component-store 是一个独立的库,与 Redux@ngrx/store 等没有关联。

准备工作

我们要处理的项目位于克隆存储库中的 chapter06/start_here/ngrx-component-store 目录中:

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行 npm install 来安装项目的依赖项。

  3. 完成后,运行 ng serve -o

这应该在新的浏览器标签页中打开应用程序。以管理员身份登录,您应该能看到它,如下所示:

图 6.18 - ngrx-component-store 应用程序运行在 http://localhost:4200

图 6.18 - ngrx-component-store 应用程序运行在 http://localhost:4200

现在我们的应用程序在本地运行,让我们在下一节中看一下这个配方的步骤。

如何做…

到目前为止,我们已经在许多配方中使用了我们喜爱的桶应用程序。目前桶的状态存储在BucketService中,它使用了BehaviorSubject模式。我们将用 NgRx Component Store 替换它。让我们开始吧:

  1. 通过在项目根目录中运行以下命令,将@ngrx/component-store包添加到项目的依赖项中:
npm install @ngrx/component-store@12.0.0 --save
  1. 我们首先要使我们的BucketServiceComponentStore兼容。为了做到这一点,我们将为桶状态创建一个接口,将BucketServiceComponentStore扩展,并通过调用super方法来初始化服务。更新file services/bucket.service.ts文件,如下所示:
...
import { IBucketService } from '../interfaces/bucket-service';
import { ComponentStore } from '@ngrx/component-store';
export interface BucketState {
  bucket: IFruit[]
}
@Injectable({
  providedIn: 'root'
})
export class BucketService extends ComponentStore<BucketState>  implements IBucketService {
  bucketSource = new BehaviorSubject([]);
  bucket$: Observable<IFruit[]> =   this.bucketSource.asObservable();
  constructor() {
    super({
      bucket: []
    })
  }
  ...
}

在我们实际显示ComponentStore中的数据之前,这一切都没有意义。现在让我们来做这件事。

  1. 修改bucket$ Observable,使用ComponentStore状态,而不是依赖于BehaviorSubject模式,如下所示:
...
export class BucketService extends ComponentStore<BucketState>  implements IBucketService {
  bucketSource = new BehaviorSubject([]);
  readonly bucket$: Observable<IFruit[]> =   this.select(state => state.bucket);
  constructor() {
    super({
      bucket: []
    })
  }
  ...
}

你应该能够看到没有桶项目显示了,或者即使你添加了一个项目,它也不会显示。这是因为它仍然需要一些工作。

  1. 首先,让我们确保不是用空数组从组件存储中初始化bucket,而是用localStorage中的值来初始化它。即使它们还没有显示出来,也试着添加一些项目。然后,修改loadItems()方法,使用BucketService上的setState方法。代码应该如下所示:
  loadItems() {
    const bucket = JSON.parse(window.localStorage.    getItem('bucket') || '[]');
    this.bucketSource.next(bucket); ← Remove this
    this.setState({ ← Add this
      bucket
    })
  }

请注意,我们已经从代码中删除了this.bucketSource.next(bucket);行。这是因为我们不再使用bucketSource属性,它是一种BehaviorSubject模式。我们将对下一组函数执行相同的操作。

此外,你现在应该能够看到之前添加的项目,但没有显示出来。

  1. 现在让我们替换BucketService中的addItem方法,以便它可以正确更新状态并显示新的项目在视图中,如我们所期望的那样。为此,我们将使用ComponentStoreupdater方法,并修改我们的addItem方法为一个更新器,如下所示:
  readonly addItem = this.updater((state, fruit: IFruit)   => {
    const bucket = [fruit, ...state.bucket]
    window.localStorage.setItem('bucket',     JSON.stringify(bucket));
    return ({
      bucket
    })
  });

如果你现在添加一个项目,你应该能够在视图中看到它。

  1. 我们现在也可以将BucketService中的removeItem方法替换为updater方法。代码应该如下所示:
  readonly removeItem = this.updater((state, fruit:   IFruit) => {
    const bucket = state.bucket.filter(item =>     item.id !== fruit.id);
    window.localStorage.setItem('bucket',     JSON.stringify(bucket));
    return ({
      bucket
    })
  });

通过这个改变,您应该看到应用程序正在工作。但是我们确实有一个需要解决的问题,那就是EmployeeService也需要更新,使removeItem方法成为updater方法。

  1. 让我们将EmployeeBucketService中的removeItem方法替换为updater方法。修改employee/services/employee-bucket.service.ts文件如下:
import { Injectable } from '@angular/core';
import { IFruit } from 'src/app/interfaces/fruit.interface';
import { BucketService } from 'src/app/services/bucket.service';
...
export class EmployeeBucketService extends BucketService {
  constructor() {
    super();
  }
  readonly removeItem = this.updater((state, _: IFruit)   => {
    alert('Employees can not delete items');
    return state;
  });
}

而且!现在一切应该都很好,您不应该看到任何错误。

  1. 由于我们已经摆脱了BucketService属性bucketSourceBehaviorSubject模式的所有用法,我们可以从BucketService中删除该属性本身。最终代码应该如下所示:
import { Injectable } from '@angular/core';
import { BehaviorSubject ← Remove this, Observable } from 'rxjs';
...
export class BucketService extends ComponentStore<BucketState>  implements IBucketService {
  bucketSource = new BehaviorSubject([]); ← Remove
  readonly bucket$: Observable<IFruit[]> =   this.select((state) => state.bucket);
  constructor() {
    super({
      bucket: []
    })
  }
...
}

恭喜!您已完成该教程。请查看下一节以了解其工作原理。

它是如何工作的...

如前所述,@ngrx/component-store是一个独立的包,可以轻松安装在您的 Angular 应用程序中,而无需使用@ngrx/store@ngrx/effects等。它应该替换 Angular 服务中BehaviorSubject的使用方式,这就是我们在本教程中所做的。我们介绍了如何初始化ComponentStore以及如何使用setState方法设置初始状态,当我们已经有值而无需访问状态时,我们学会了如何创建updater方法,它们可以用于更新状态,因为它们可以访问状态并允许我们甚至为我们自己的用例传递参数。

另请参阅

使用@ngrx/router-store 来以响应式方式处理路由更改

NgRx 很棒,因为它允许您将数据存储在一个集中的位置。然而,监听路由更改仍然是我们目前所涵盖的 NgRx 范围之外的事情。我们确实依赖于ActivatedRoute服务来监听路由更改,当我们想要测试这样的组件时,ActivatedRoute服务就成了一个依赖项。在本教程中,您将安装@ngrx/router-store包,并学习如何使用该包中内置的一些操作来监听路由更改。

准备工作

我们将要处理的项目位于chapter06/start_here/ngrx-router-store中,位于克隆存储库内:

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该会在新的浏览器标签页中打开应用程序,你应该会看到类似这样的东西:

图 6.19 - ngrx-router-store 应用程序运行在 http://localhost:4200

图 6.19 - ngrx-router-store 应用程序运行在 http://localhost:4200

现在应用程序正在运行,请查看下一节的步骤。

如何做…

为了利用 NgRx 甚至对路由更改的强大功能,我们将利用@ngrx/router-store包来监听路由更改。让我们开始吧!

  1. 首先,在项目根目录中运行以下命令安装@ngrx/router-store包:
npm install @ngrx/router-store@12.0.0 --save
  1. 现在,在你的app.module.ts文件中导入StoreRouterConnectingModulerouterReducer,并设置imports,如下所示:
...
import { StoreRouterConnectingModule, routerReducer } from '@ngrx/router-store';
@NgModule({
  declarations: [...],
  imports: [
    BrowserModule,
    AppRoutingModule,
    HttpClientModule,
    StoreModule.forRoot({
      app: appStore.reducer,
  router: routerReducer
    }),
 StoreRouterConnectingModule.forRoot(),
    StoreDevtoolsModule.instrument({
      maxAge: 25, // Retains last 25 states
    }),
    EffectsModule.forRoot([AppEffects])
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

现在刷新应用程序并通过 Redux DevTools 扩展进行检查,你应该还会看到一些额外的名为@ngrx/router-store/*的操作被分发。你还应该看到状态中的router属性具有当前路由的信息,如下截图所示:

图 6.20 - @ngrx/router-store 操作和路由器状态在 NgRx 存储中的反映

图 6.20 - @ngrx/router-store 操作和路由器状态在 NgRx 存储中的反映

  1. 我们现在必须修改我们的 reducer,或者更准确地说,修改AppState接口,以反映我们还有来自@ngrx/router-store包的router属性。为此,请修改store/app.reducer.ts文件,如下所示:
...
import { getUsersSuccess } from './app.actions';
import { RouterReducerState } from '@ngrx/router-store'
export interface AppState {
  users: IUser[];
  router: RouterReducerState<any>;
}
const initialState: AppState = {
  users: null,
  router: null
}
...
  1. 基本上,我们必须摆脱UserDetailComponent类中对ActivatedRoute服务的使用。为了做到这一点,我们首先修改我们的选择器,直接从路由器状态中获取参数。修改app.selectors.ts文件,如下所示:
...
import { getSelectors, RouterReducerState } from '@ngrx/router-store';
export const selectApp = createFeatureSelector<AppState>('app');
export const selectUsers = createSelector(
  selectApp,
  (state: AppState) => state.users
);
...
export const selectRouter = createFeatureSelector<
  AppState,
  RouterReducerState<any>
>('router');
const { selectRouteParam } = getSelectors(selectRouter);
export const selectUserUUID = selectRouteParam('uuid');
export const selectCurrentUser = createSelector(
  selectUserUUID,
  selectUsers,
  (uuid, users: IUser[]) => users ? users.find(user => {
    return user.login.uuid === uuid;
  }) : null
);
export const selectSimilarUsers = createSelector(
  selectUserUUID,
  selectUsers,
  (uuid, users: IUser[]) => users ? users.filter(user =>   {
    return user.login.uuid !== uuid;
  }): null
);

你现在应该在控制台上看到一些错误。那是因为我们改变了selectSimilarUsersselectCurrentUser选择器的签名,但它将在下一步中被修复。

  1. 修改user-detail/user-detail.component.ts文件以正确使用更新后的选择器,如下所示:
...
export class UserDetailComponent implements OnInit, OnDestroy {
  ...
  ngOnInit() {
    ...
    this.route.paramMap.pipe(
      takeWhile(() => !!this.isComponentAlive)
    )
    .subscribe(params => {
      const uuid = params.get('uuid');
      this.user$ = this.store.select(selectCurrentUser)
      this.similarUsers$ = this.store.      select(selectSimilarUsers)
    })
  }
  ...
}

这个更改应该已经解决了控制台上的错误,你应该能够看到应用程序完美地运行,即使我们不再从UserDetailComponent类中传递任何uuid

  1. 通过上一步的更改,我们现在可以安全地从UserDetailComponent类中删除ActivatedRoute服务的使用,代码现在应该是这样的:
...
import { Observable } from 'rxjs/internal/Observable';
import { first } from 'rxjs/operators';
import { Store } from '@ngrx/store';
...
export class UserDetailComponent implements OnInit, OnDestroy {
  ...
  constructor(
    private store: Store<AppState>
) {}
  ngOnInit() {
    this.isComponentAlive = true;
    this.getUsersIfNecessary();
    this.user$ = this.store.select(selectCurrentUser)
    this.similarUsers$ = this.store.    select(selectSimilarUsers)
  }
  ...
}

哇哦!你现在已经完成了这个食谱。查看下一节,了解这是如何运作的。

它是如何工作的...

@ngrx/router-store是一个了不起的包,它通过许多魔法使我们在 NgRx 中的开发变得更加容易。你看到了我们如何通过使用该包中的选择器,完全删除了UserDetailComponent类中的ActivatedRoute服务。基本上,这帮助我们在选择器中正确获取路由参数,并且我们可以在选择器中使用它来获取和过滤出适当的数据。在幕后,该包监听整个 Angular 应用程序中的路由更改,并从路由本身获取数据。然后将相应的信息存储在 NgRx Store 中,以便它保留在 Redux 状态中,并且可以通过该包提供的选择器轻松选择。在我看来,这太棒了!我这么说是因为该包正在做我们否则必须做的所有繁重工作。因此,我们的UserDetailComponent类现在只依赖于Store服务,这使得测试变得更加容易,因为依赖更少。

另请参阅

第七章:第七章:理解 Angular 导航和路由

关于 Angular 最令人惊奇的事情之一是,它是一个完整的生态系统(一个框架),而不是一个库。在这个生态系统中,Angular 路由器是最关键的学习和理解之一。在本章中,您将学习有关 Angular 中路由和导航的一些非常酷的技术。您将学习如何保护您的路由,监听路由更改,并配置路由更改的全局操作。

以下是本章将涵盖的配方:

  • 使用 CLI 创建带有路由的 Angular 应用程序和模块

  • 特性模块和延迟加载路由

  • 使用路由守卫对路由进行授权访问

  • 处理路由参数

  • 在路由更改之间显示全局加载器

  • 预加载路由策略

技术要求

对于本章的配方,请确保您的机器上已安装GitNode.js。您还需要安装@angular/cli包,您可以在终端中使用npm install -g @angular/cli来完成。本章的代码可以在github.com/PacktPublishing/Angular-Cookbook/tree/master/chapter07找到。

使用 CLI 创建带有路由的 Angular 应用程序

如果你问我 7-8 年前我们是如何创建 Web 应用程序项目的,你会惊讶地发现当时有多么困难。幸运的是,软件开发行业的工具和标准已经发展,当涉及到 Angular 时,启动项目变得非常容易。你甚至可以直接配置不同的东西。在这个配方中,您将使用 Angular CLI 创建一个全新的 Angular 项目,并在创建项目时启用路由配置。

准备就绪

我们要处理的项目没有起始文件。所以,你可以直接从克隆的存储库中将chapter07/start_here文件夹打开到 Visual Studio Code 应用程序中。

如何做…

我们将首先使用 Angular CLI 创建应用程序。它将默认启用路由。同样,接下来,我们将创建一些带有组件的特性模块,但它们将具有急切加载的路由。所以,让我们开始吧:

  1. 首先,打开终端,确保你在chapter07/start_here文件夹内。进入后,运行以下命令:
ng new basic-routing-app --routing --style scss

该命令应该为您创建一个新的 Angular 应用程序,并启用路由,并选择 SCSS 作为您的样式选择。

  1. 运行以下命令在浏览器中打开应用程序:
cd basic-routing app
ng serve -o
  1. 现在,通过运行以下命令创建一个顶级组件命名为landing
ng g c landing
  1. app.component.html中删除所有内容,只保留router-outlet,如下所示:
<router-outlet></router-outlet>
  1. 现在,通过将其添加到app-routing.module.ts文件中,将LandingComponent设置为默认路由,如下所示:
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { LandingComponent } from './landing/landing.component';
const routes: Routes = [{
  path: '',
  redirectTo: 'landing',
  pathMatch: 'full'
}, {
  path: 'landing',
  component: LandingComponent
}];
...
  1. 刷新页面,你应该会看到 URL 自动更改为http://localhost:4200/landing,因为应用程序重定向到默认路由。

  2. 用以下代码替换landing.component.html的内容:

<div class="landing">
  <div class="landing__header">
    <div class="landing__header__main">
      Creating an Angular app with routes using CLI
    </div>
    <div class="landing__header__links">
      <div class="landing__header__links__link">
        Home
      </div>
      <div class="landing__header__links__link">
        About
      </div>
    </div>
  </div>
  <div class="landing__body">
    Landing Works
  </div>
</div>
  1. 现在,在landing.component.scss文件中为头部添加一些样式,如下所示:
.landing {
  display: flex;
  flex-direction: column;
  height: 100%;
  &__header {
    height: 60px;
    padding: 0 20px;
    background-color: #333;
    color: white;
    display: flex;
    align-items: center;
    justify-content: flex-end;
    &__main {
      flex: 1;
    }
  }
}
  1. 如下所示,为头部链接添加样式:
.landing {
  ...
  &__header {
    ...
    &__links {
      padding: 0 20px;
      display: flex;
      &__link {
        margin-left: 16px;
        &:hover {
          color: #ececec;
          cursor: pointer;
        }
      }
    }
  }
}
  1. 此外,在&__header选择器之后添加着陆页面主体的样式,如下所示:
.landing {
  ...
  &__header {
   ...
  }
  &__body {
    padding: 30px;
    flex: 1;
    display: flex;
    justify-content: center;
    background-color: #ececec;
  }
}
  1. 最后,为了使一切看起来好看,将以下样式添加到styles.scss文件中:
html, body {
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
}
  1. 现在,通过在项目根目录中运行以下命令,为homeabout路由添加特性模块:
ng g m home
ng g c home
ng g m about
ng g c about
  1. 接下来,在你的app.module.ts文件中导入HomeModuleAboutModule如下所示:
...
import { LandingComponent } from './landing/landing.component';
import { HomeModule } from './home/home.module';
import { AboutModule } from './about/about.module';
@NgModule({
  declarations: [...],
  imports: [
    BrowserModule,
    AppRoutingModule,
    HomeModule,
    AboutModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
  1. 现在,我们可以配置路由。修改app-routing.module.ts文件以添加适当的路由,如下所示:
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AboutComponent } from './about/about.component';
import { HomeComponent } from './home/home.component';
import { LandingComponent } from './landing/landing.component';
const routes: Routes = [{
  path: '',
  redirectTo: 'landing',
  pathMatch: 'full'
}, {
  path: 'landing',
  component: LandingComponent
}, {
  path: 'home',
  component: HomeComponent
}, {
  path: 'about',
  component: AboutComponent
}];
...
  1. 我们可以很快为我们的HomeAbout组件添加样式。将以下 CSS 添加到home.component.scss文件和about.component.scss文件中:
:host {
  display: flex;
  width: 100%;
  height: 100%;
  justify-content: center;
  align-items: center;
  background-color: #ececec;
  font-size: 24px;
}
  1. 现在,我们可以将我们的链接绑定到着陆页面的适当路由上。修改landing.component.html如下所示:
<div class="landing">
  <div class="landing__header">
    <div class="landing__header__links">
      <div class="landing__header__links__link"       routerLink="/home">
        Home
      </div>
      <div class="landing__header__links__link"       routerLink="/about">
        About
      </div>
    </div>
  </div>
  <div class="landing__body">
    Landing Works
  </div>
</div>

太棒了!在短短几分钟内,借助令人惊叹的 Angular CLI 和 Angular 路由器的帮助,我们能够创建一个着陆页面、两个特性模块和特性路由(尽管是急加载的),并且我们也对一些东西进行了样式化。现代网络的奇迹!

现在您已经知道了基本路由是如何实现的,接下来请查看下一节以了解它是如何工作的。

它是如何工作的...

当我们在创建应用程序时使用--routing参数,或者在创建模块时,Angular CLI 会自动创建一个名为<your module>-routing.module.ts的模块文件。该文件基本上包含一个路由模块。在这个示例中,我们只是创建了特性模块而没有路由,以使实现更简单和更快。在下一个示例中,您还将了解有关模块内路由的信息。无论如何,由于我们已经创建了急切加载的特性模块,这意味着所有特性模块的 JavaScript 都会在应用程序加载时加载。您可以检查 Chrome DevTools 中的Network选项卡,并查看main.js文件的内容,因为它包含了所有我们的组件和模块。请参阅以下屏幕截图,其中显示了main.js文件中AboutComponentHomeComponent的代码:

图 7.1 - 包含 AboutComponent 和 HomeComponent 代码的 main.js

图 7.1 - 包含 AboutComponent 和 HomeComponent 代码的 main.js

由于我们已经确定了在应用程序启动时所有示例中的组件都是急切加载的,因此有必要了解这是因为我们在AppModuleimports数组中导入了HomeModuleAboutModule

另请参阅

特性模块和延迟加载路由

在上一个示例中,我们学习了如何创建一个具有急切加载路由的基本路由应用程序。在这个示例中,您将学习如何使用特性模块来延迟加载它们,而不是在应用程序加载时加载它们。对于这个示例,我们将假设我们已经有了路由,并且只需要延迟加载它们。

准备工作

此示例中的项目位于chapter07/start_here/lazy-loading-modules中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这将在新的浏览器选项卡中打开应用程序,您应该看到应用程序如下所示:

图 7.2 - lazy-loading-modules 应用程序运行在 http://localhost:4200

图 7.2 - lazy-loading-modules 应用程序运行在 http://localhost:4200

现在我们的应用程序在本地运行,让我们在下一节中看看这个示例的步骤。

如何做…

图 7.2所示,我们在main.js文件中有所有的组件和模块。因此,main.js文件的大小约为 23.4 KB。我们将修改代码和路由结构以实现懒加载。结果,当我们实际导航到它们时,路由的特定文件将被加载:

  1. 首先,我们必须使我们的目标模块能够被懒加载。为此,我们将不得不为AboutModuleHomeModule分别创建一个<module>-routing.module.ts文件。因此,让我们在abouthome文件夹中都创建一个新文件:

a) 将第一个文件命名为about-routing.module.ts,并向其中添加以下代码:

// about-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AboutComponent } from './about.component';
const routes: Routes = [{
  path: '',
  component: AboutComponent
}];
@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class AboutRoutingModule { }

b) 将第二个文件命名为home-routing.module.ts,并向其中添加以下代码:

// home-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HomeComponent } from './home.component';
const routes: Routes = [{
  path: '',
  component: HomeComponent
}];
@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class HomeRoutingModule { }
  1. 现在,我们将这些路由模块添加到相应的模块中,也就是说,我们将在HomeModule中导入HomeRoutingModule,如下所示:
// home.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HomeComponent } from './home.component';
import { HomeRoutingModule } from './home-routing.module';
@NgModule({
  declarations: [HomeComponent],
  imports: [
    CommonModule,
    HomeRoutingModule
  ]
})
export class HomeModule { }

AboutModule中添加AboutRoutingModule,如下所示:

// about.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AboutComponent } from './about.component';
import { AboutRoutingModule } from './about-routing.module';
@NgModule({
  declarations: [AboutComponent],
  imports: [
    CommonModule,
    AboutRoutingModule
  ]
})
export class AboutModule { }
  1. 我们的模块现在能够被懒加载。我们现在只需要懒加载它们。为了这样做,我们需要修改app-routing.module.ts并更改我们的配置,以便在abouthome路由中使用 ES6 导入,如下所示:
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { LandingComponent } from './landing/landing.component';
const routes: Routes = [{
  path: '',
  redirectTo: 'landing',
  pathMatch: 'full'
}, {
  path: 'landing',
  component: LandingComponent
}, {
  path: 'home',
  loadChildren: () => import('./home/home.module').then   (m => m.HomeModule)
}, {
  path: 'about',
  loadChildren: () => import('./about/about.module').  then(m => m.AboutModule)
}];
@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }
  1. 最后,我们将从AppModuleimports数组中移除AboutModuleHomeModule的导入,以便我们可以直接获得所需的代码拆分。app.module.ts的内容应如下所示:
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { LandingComponent } from './landing/landing.component';
import { HomeModule } from './home/home.module'; ← Remove
import { AboutModule } from './about/about.module'; ← Remove
@NgModule({
  declarations: [
    AppComponent,
    LandingComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    HomeModule, ← Remove
    AboutModule ← Remove
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

刷新应用程序,您会看到main.js文件的捆绑大小已经降至 18.1 KB,之前大约为 23.4 KB。请参阅以下截图:

图 7.3 - 应用程序加载时 main.js 的大小减小

图 7.3 - 应用程序加载时 main.js 的大小减小

但是主页和关于路由呢?懒加载呢?嗯,从标题中点击主页路由,您会看到专门为该路由在网络选项卡中下载的新 JavaScript 文件。这就是懒加载的作用!请参阅以下截图:

图 7.4 - 主页路由被懒加载

图 7.4 - 主页路由被懒加载

太棒了!你刚刚变得懒惰了!开玩笑的。你刚刚学会了在你的 Angular 应用程序中懒加载路由和特性模块的艺术。现在你也可以向你的朋友展示这个。

它是如何工作的…

Angular 使用模块,通常将功能分解为模块。正如我们所知,AppModule 作为 Angular 应用的入口点,Angular 将在构建过程中导入和捆绑在 AppModule 中导入的任何内容,从而生成 main.js 文件。然而,如果我们想要延迟加载我们的路由/功能模块,我们需要避免直接在 AppModule 中导入功能模块,并使用 loadChildren 方法来加载功能模块的路由,以实现按需加载。这就是我们在这个示例中所做的。需要注意的是,路由在 AppRoutingModule 中保持不变。但是,我们必须在我们的功能路由模块中放置 path: '',因为这将合并 AppRoutingModule 中的路由和功能路由模块中的路由,从而成为 AppRoutingModule 中定义的内容。这就是为什么我们的路由仍然是 'about''home'

另请参阅

使用路由守卫授权访问路由

您的 Angular 应用程序中并非所有路由都应该被世界上的每个人访问。在这个示例中,我们将学习如何在 Angular 中创建路由守卫,以防止未经授权的访问路由。

准备工作

这个示例的项目位于 chapter07/start_here/using-route-guards 中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行 npm install 来安装项目的依赖项。

  3. 完成后,运行 ng serve -o

这应该在新的浏览器标签中打开应用程序,您应该看到应用程序如下:

图 7.5 – using-route-guards 应用程序运行在 http://localhost:4200

图 7.5 – using-route-guards 应用程序运行在 http://localhost:4200

现在应用程序在本地运行,让我们在下一节中看到示例的步骤。

如何做…

我们已经设置了一个带有一些路由的应用程序。您可以以员工或管理员身份登录以查看应用程序的待办事项清单。但是,如果您点击标题中的任何两个按钮,您会发现即使没有登录,您也可以导航到管理员和员工部分。这就是我们要防止发生的事情。请注意,在 auth.service.ts 文件中,我们已经有了用户登录的方式,并且我们可以使用 isLoggedIn() 方法来检查用户是否已登录。

  1. 首先,让我们创建一个路由守卫,只有在用户登录时才允许用户转到特定的路由。我们将其命名为AuthGuard。通过在项目根目录中运行以下命令来创建它:
ng g guard guards/Auth

运行命令后,您应该能够看到一些选项,选择我们想要实现的接口。

  1. 选择CanActivate接口并按“Enter”。

  2. 现在,在auth.guard.ts文件中添加以下逻辑来检查用户是否已登录,如果用户未登录,我们将重定向用户到登录页面,即'/auth'路由:

import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { AuthService } from '../services/auth.service';
@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanActivate {
  constructor(private auth: AuthService, private router:   Router) {  }
  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean |     UrlTree> | Promise<boolean | UrlTree> | boolean |     UrlTree {
      const loggedIn = !!this.auth.isLoggedIn();
      if (!loggedIn) {
        this.router.navigate(['/auth']);
        return false;
      }
    return true;
  }
}
  1. 现在,让我们在app-routing.module.ts文件中为 Admin 和 Employee 路由应用AuthGuard,如下所示:
...
import { AuthGuard } from './guards/auth.guard';
const routes: Routes = [{...}, {
  path: 'auth',
  loadChildren: () => import('./auth/auth.module').then   (m => m.AuthModule)
}, {
  path: 'admin',
  loadChildren: () => import('./admin/admin.module').  then(m => m.AdminModule),
  canActivate: [AuthGuard]
}, {
  path: 'employee',
  loadChildren: () => import('./employee/employee.  module').then(m => m.EmployeeModule),
  canActivate: [AuthGuard]
}];
...
export class AppRoutingModule { }

如果您现在注销并尝试点击标题中的“员工部门”或“管理员部门”按钮,您会注意到在登录之前无法转到路由。如果您尝试直接在地址栏中输入路由的 URL 并按“Enter”,情况也是如此。

  1. 现在,我们将尝试创建一个守卫,一个用于员工路由,一个用于管理员路由。依次运行以下命令,并为两个守卫选择CanActivate接口:
ng g guard guards/Employee
ng g guard guards/Admin
  1. 既然我们已经创建了守卫,让我们首先为AdminGuard放置逻辑。我们将尝试查看已登录的用户类型。如果是管理员,则允许导航,否则我们会阻止它。在admin.guard.ts中添加以下代码:
...
import { UserType } from '../constants/user-type';
import { AuthService } from '../services/auth.service';
...
export class AdminGuard implements CanActivate {
  constructor(private auth: AuthService) {}
  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean |     UrlTree> | Promise<boolean | UrlTree> | boolean |     UrlTree {
    return this.auth.loggedInUserType === UserType.Admin;
  }
}
  1. app-routing.module.ts中的 Admin 路由中添加AdminGuard如下:
...
import { AdminGuard } from './guards/admin.guard';
import { AuthGuard } from './guards/auth.guard';
const routes: Routes = [{
  path: '',
 ...
}, {
  path: 'auth',
 ...
}, {
  path: 'admin',
  loadChildren: () => import('./admin/admin.module').  then(m => m.AdminModule),
  canActivate: [AuthGuard, AdminGuard]
}, {
  path: 'employee',
  ...
}];
...

现在尝试注销并以员工身份登录。然后尝试点击标题中的“管理员部门”按钮。您会注意到您现在无法转到清单的管理员部分。这是因为我们已经放置了AdminGuard,而您现在并未以管理员身份登录。以管理员身份登录应该可以正常工作。

  1. 类似地,我们将在employee.guard.ts中添加以下代码:
...
import { UserType } from '../constants/user-type';
import { AuthService } from '../services/auth.service';
@Injectable({
  providedIn: 'root'
})
export class EmployeeGuard implements CanActivate {
  constructor(private auth: AuthService) {}
  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean |     UrlTree> | Promise<boolean | UrlTree> | boolean |     UrlTree {
    return this.auth.loggedInUserType === UserType.    Employee;
  } 
}
  1. 现在,在app-routing.module.ts中的 Employee 路由中添加EmployeeGuard如下:
...
import { EmployeeGuard } from './guards/employee.guard';
const routes: Routes = [
  ...
, {
  path: 'employee',
  loadChildren: () => import('./employee/employee.  module').then(m => m.EmployeeModule),
  canActivate: [AuthGuard, EmployeeGuard]
}];
...

现在,只有适当的路由应该可以通过检查已登录的用户类型来访问。

太棒了!现在在保护路由方面,您是一个授权专家。伴随着强大的力量,也伴随着巨大的责任。明智地使用它。

工作原理…

路由守卫的CanActivate接口是我们的配方的核心,因为它对应于 Angular 中每个路由都可以具有CanActivate属性的守卫数组的事实。当应用守卫时,它应该返回一个布尔值或UrlTree。我们在配方中专注于布尔值的使用。我们可以直接使用 promise 或者使用 Observable 来返回布尔值。这使得守卫即使在远程数据中也非常灵活。无论如何,对于我们的配方,我们通过检查用户是否已登录(对于AuthGuard)以及检查特定路由是否已登录预期类型的用户(AdminGuardEmployeeGuard)来使其易于理解。

另请参阅

使用路由参数

无论是构建使用 Node.js 的 REST API 还是配置 Angular 中的路由,设置路由都是一门绝对的艺术,特别是在处理参数时。在这个配方中,您将创建一些带参数的路由,并学习如何在路由激活后在组件中获取这些参数。

准备工作

这个配方的项目位于chapter07/start_here/working-with-route-params中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该在新的浏览器标签中打开应用程序。一旦页面打开,你应该看到一个用户列表。

  1. 点击第一个用户,你应该看到以下视图:

图 7.6 - 用户详细信息未带来正确的用户

图 7.6 - 用户详细信息未带来正确的用户

现在我们的应用程序在本地运行,让我们在下一节中看看配方的步骤。

如何做…

目前的问题是,我们有一个用于打开用户详细信息的路由,但在UserDetailComponent中我们不知道点击了哪个用户,也就是说,从服务中获取哪个用户。因此,我们将实现路由参数,将用户的 ID(uuid)从主页传递到用户详细信息页面:

  1. 首先,我们必须使我们的用户路由能够接受名为uuid的路由参数。这将是一个必需参数,这意味着没有传递这个参数,路由将无法工作。让我们修改app-routing.module.ts来添加这个必需参数到路由定义中,如下所示:
...
import { UserDetailComponent } from './user-detail/user-detail.component';
const routes: Routes = [
  ...
, {
  path: 'user/:uuid',
  component: UserDetailComponent
}];
...

通过这个改变,在主页上点击用户将不再起作用。如果你尝试,你会看到以下错误,因为uuid是一个必需的参数:

图 7.7 - Angular 抱怨无法匹配请求的路由

图 7.7 - Angular 抱怨无法匹配请求的路由

  1. 错误的修复很容易;我们需要在导航到用户路由时传递uuid。让我们通过修改user-card.component.ts文件来实现这一点:
import { Component, Input, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { IUser } from '../../interfaces/user.interface';
@Component({
  selector: 'app-user-card',
  templateUrl: './user-card.component.html',
  styleUrls: ['./user-card.component.scss']
})
export class UserCardComponent implements OnInit {
  @Input('user') user: IUser;
  constructor(private router: Router) { }
  ngOnInit(): void {
  }
  cardClicked() {
    this.router.navigate(['    /user/${this.user.login.uuid}'])
  }
}

现在我们能够导航到特定用户的路由,并且你也应该能够在地址栏中看到 UUID,如下所示:

图 7.8 - UUID 显示在地址栏中

图 7.8 - UUID 显示在地址栏中

  1. 为了从UserService中获取当前用户,我们需要在UserDetailComponent中获取uuid值。现在,当从UserDetailComponent调用UserServicegetUser方法时,我们发送的是null。为了使用用户的 ID,我们可以通过导入ActivatedRoute服务从路由参数中获取uuid值。更新user-detail.component.ts如下:
...
import { ActivatedRoute } from '@angular/router';
...
export class UserDetailComponent implements OnInit, OnDestroy {
  user: IUser;
  similarUsers: IUser[];
  constructor(
    private userService: UserService,
    private route: ActivatedRoute
  ) {}
  ngOnInit() {
    ...
  }
  ngOnDestroy() {
  }
}
  1. 我们将在UserDetailComponent中创建一个名为getUserAndSimilarUsers的新方法,并将代码从ngOnInit方法移动到新方法中,如下所示:
...
export class UserDetailComponent implements OnInit, OnDestroy {
  ...
  ngOnInit() {
    const userId = null;
    this.getUserAndSimilarUsers(userId);
  }
  getUserAndSimilarUsers(userId) {
    this.userService.getUser(userId)
      .pipe(
        mergeMap((user: IUser) => {
          this.user = user;
          return this.userService.          getSimilarUsers(userId);
        })
      ).subscribe((similarUsers: IUser[]) => {
        this.similarUsers = similarUsers;
      })
  }
  ...
}
  1. 现在我们已经对代码进行了一些重构,让我们尝试使用ActivatedRoute服务从路由参数中访问uuid,并将其传递到我们的getUserAndSimilarUsers方法中,如下所示:
...
import { mergeMap, takeWhile } from 'rxjs/operators';
import { ActivatedRoute } from '@angular/router';
...
export class UserDetailComponent implements OnInit, OnDestroy {
  componentIsAlive = false;
  constructor(private userService: UserService, private   route: ActivatedRoute ) {}
  ngOnInit() {
    this.componentIsAlive = true;
    this.route.paramMap
      .pipe(
        takeWhile (() => this.componentIsAlive)
      )
      .subscribe((params) => {
        const userId = params.get('uuid');
        this.getUserAndSimilarUsers(userId);
      })
  }
  getUserAndSimilarUsers(userId) {...}
  ngOnDestroy() {
   this.componentIsAlive = false;
  }
}

太棒了!通过这个改变,你可以尝试在主页上刷新应用,然后点击任何用户。你应该能够看到当前用户以及加载的相似用户。要了解食谱背后的所有魔法,请参见下一节。

它是如何工作的…

一切都始于我们将路由路径更改为 user/:userId。这使得 userId 成为我们路由的必需参数。拼图的另一部分是在 UserDetailComponent 中检索此参数,然后使用它来获取目标用户,以及类似的用户。为此,我们使用 ActivatedRoute 服务。ActivatedRoute 服务包含了关于当前路由的许多必要信息,因此我们能够通过订阅 paramMap 可观察对象来获取当前路由的 uuid 参数,因此即使在用户页面停留时参数发生变化,我们仍然执行必要的操作。请注意,我们还创建了一个名为 componentIsAlive 的属性。正如您在我们之前的示例中所看到的,我们将它与 takeWhile 操作符一起使用,以便在用户从页面导航离开或组件被销毁时自动取消订阅可观察流。

另请参阅

在路由更改之间显示全局加载程序

构建快速响应的用户界面对于赢得用户至关重要。对于最终用户来说,应用程序变得更加愉快,对于应用程序的所有者/创建者来说,这可能带来很多价值。现代网络的核心体验之一是在后台发生某些事情时显示加载程序。在这个示例中,您将学习如何在您的 Angular 应用程序中创建一个全局用户界面加载程序,每当应用程序中发生路由转换时都会显示。

准备工作

我们将要使用的项目位于克隆存储库中的 chapter07/start_here/routing-global-loader 中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行 npm install 以安装项目的依赖项。

  3. 完成后,运行 ng serve -o

这应该会在新的浏览器标签页中打开应用程序,您应该会看到如下所示:

图 7.9 - routing-global-loader 应用程序正在 http://localhost:4200 上运行

图 7.9 - routing-global-loader 应用程序正在 http://localhost:4200 上运行

现在我们的应用程序在本地运行,让我们在下一节中看一下这个示例的步骤。

如何做…

对于这个示例,我们有一个包含几个路由的应用程序。我们已经创建了LoaderComponent,在路由更改期间我们必须使用它:

  1. 我们将从整个应用程序默认显示LoaderComponent开始。为此,请在app.component.html文件中在具有content类的div之前添加<app-loader>选择器,如下所示:
<div class="toolbar" role="banner" id="toolbar">
  ...
</div>
<app-loader></app-loader>
<div class="content" role="main">
  <div class="page-section">
    <router-outlet></router-outlet>
  </div>
</div>
  1. 现在,我们将在AppComponent类中创建一个属性来有条件地显示加载程序。我们将在路由期间将此属性标记为true,并在路由完成时将其标记为false。在app.component.ts文件中创建属性如下:
...
export class AppComponent {
  isLoadingRoute = false;
  // DO NOT USE THE CODE BELOW IN PRODUCTION
  // IT WILL CAUSE PERFORMANCE ISSUES
  constructor(private auth: AuthService, private router:   Router) {
  }
  get isLoggedIn() {
    return this.auth.isLoggedIn();
  }
  logout() {
    this.auth.logout();
    this.router.navigate(['/auth']);
  }
}
  1. 现在,我们将确保只有在isLoadingRoute属性为true时才显示<app-loader>。为此,请更新app.component.html模板文件,包括以下*ngIf语句:
...
<app-loader *ngIf="isLoadingRoute"></app-loader>
<div class="content" role="main">
  <div class="page-section">
    <router-outlet></router-outlet>
  </div>
</div>
  1. 现在*ngIf语句已经就位,我们需要以某种方式将isLoadingRoute属性设置为true。为了做到这一点,我们将监听路由服务的events属性,并在NavigationStart事件发生时采取行动。修改app.component.ts文件中的代码如下:
import { Component } from '@angular/core';
import { NavigationStart, Router } from '@angular/router';
import { AuthService } from './services/auth.service';
...
export class AppComponent {
  isLoadingRoute = false;
  // DO NOT USE THE CODE BELOW IN PRODUCTION
  // IT WILL CAUSE PERFORMANCE ISSUES
  constructor(private auth: AuthService, private router:   Router) {
    this.router.events.subscribe((event) => {
      if (event instanceof NavigationStart) {
        this.isLoadingRoute = true;
      }
    })
  }
  get isLoggedIn() {...}
  logout() {...}
}

如果您刷新应用程序,您会注意到<app-loader>永远不会消失。它现在一直显示着。这是因为我们没有在任何地方将isLoadingRoute属性标记为false

  1. 要将isLoadingRoute标记为false,我们需要检查三种不同的事件:NavigationEndNavigationErrorNavigationCancel。让我们添加一些逻辑来处理这三个事件,并将属性标记为false
import { Component } from '@angular/core';
import { NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Router } from '@angular/router';
...
export class AppComponent {
  ...
  constructor(private auth: AuthService, private router:   Router) {
    this.router.events.subscribe((event) => {
      if (event instanceof NavigationStart) {
        this.isLoadingRoute = true;
      }
      if (
        event instanceof NavigationEnd ||
        event instanceof NavigationError ||
        event instanceof NavigationCancel
      ) {
        this.isLoadingRoute = false;
      }
    })
  }
  get isLoggedIn() {...}
  logout() {...}
}

然后!我们现在有一个全局加载程序,在不同页面之间的路由导航期间显示。

重要提示

在本地运行应用程序时,您将体验到可能是最佳的互联网条件(特别是如果您没有获取远程数据)。因此,您可能根本看不到加载程序,或者只能看到它一小部分时间。为了能够更长时间地看到它,请打开 Chrome DevTools,转到网络选项卡,模拟缓慢的 3G,刷新应用程序,然后在路由之间导航。

如果路由具有静态数据,那么您只会在首次导航到该路由时看到加载程序。下次导航到相同的路由时,它可能已经被缓存,因此全局加载程序可能不会显示。

恭喜完成了这个示例。现在你可以在 Angular 应用程序中实现一个全局加载器,它将从导航开始到导航结束都会显示。

工作原理…

路由器服务是 Angular 中非常强大的服务。它有很多方法以及我们可以在应用程序中用于不同任务的 Observables。对于这个示例,我们使用了events Observable。通过订阅events Observable,我们可以监听Router服务通过 Observable 发出的所有事件。对于这个示例,我们只对NavigationStartNavigationEndNavigationErrorNavigationCancel事件感兴趣。NavigationStart事件在路由器开始导航时发出。NavigationEnd事件在导航成功结束时发出。NavigationCancel事件在导航由于路由守卫返回false或由于某种原因使用UrlTree而被取消时发出。NavigationError事件在导航期间由于任何原因出现错误时发出。所有这些事件都是Event类型的,我们可以通过检查它是否是目标事件的实例来确定事件的类型,使用instanceof关键字。请注意,由于我们在AppComponent中订阅了Router.events属性,我们不必担心取消订阅,因为应用程序中只有一个订阅,而且AppComponent在应用程序的整个生命周期中都不会被销毁。

另请参阅

预加载路由策略

我们已经熟悉了如何在导航时延迟加载不同的特性模块。尽管有时,您可能希望预加载后续路由,以使下一个路由导航即时进行,甚至可能希望根据应用程序的业务逻辑使用自定义预加载策略。在这个示例中,您将了解PreloadAllModules策略,并将实现一个自定义策略来精选应该预加载哪些模块。

准备工作

我们要处理的项目位于克隆存储库中的chapter07/start_here/route-preloading-strategies中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该在新的浏览器标签中打开应用程序,你应该看到类似以下的内容:

图 7.10 - 在 http://localhost:4200 上运行的 route-preloading-strategies 应用程序

图 7.10 - 在 http://localhost:4200 上运行的 route-preloading-strategies 应用程序

  1. 使用Ctrl + Shift + C在 Windows 上或Cmd + Shift + C在 Mac 上打开 Chrome DevTools。

  2. 转到网络选项卡,并仅筛选 JavaScript 文件。你应该看到类似这样的内容:

图 7.11 - 应用加载时加载的 JavaScript 文件

图 7.11 - 应用加载时加载的 JavaScript 文件

现在我们的应用程序在本地运行,让我们看看下一节

如何做…

请注意图 7.11中我们如何在注销状态下自动加载auth-auth-module.js文件。尽管AuthModule中的路由都配置为惰性加载,但我们仍然可以看看如果我们使用PreloadAllModules策略,然后自定义预加载策略会发生什么:

  1. 我们将首先尝试PreloadAllModules策略。要使用它,让我们修改app-routing.module.ts文件如下:
import { NgModule } from '@angular/core';
import { Routes, RouterModule, PreloadAllModules } from '@angular/router';
const routes: Routes = [...];
@NgModule({
  imports: [RouterModule.forRoot(routes, {
    preloadingStrategy: PreloadAllModules
  })],
  exports: [RouterModule]
})
export class AppRoutingModule { }

如果刷新应用程序,你应该看到不仅auth-auth-module.js文件,还有 Admin 和 Employee 的模块文件,如下所示:

图 7.12 - 使用 PreloadAllModules 策略加载的 JavaScript 文件

图 7.12 - 使用 PreloadAllModules 策略加载的 JavaScript 文件

到目前为止一切顺利。但是如果我们只想预加载 Admin 模块,假设我们的应用主要面向管理员?我们将为此创建一个自定义预加载策略。

  1. 让我们通过在项目中运行以下命令来创建一个名为CustomPreloadStrategy的服务:
ng g s services/custom-preload-strategy
  1. 为了在 Angular 中使用我们的预加载策略服务,我们的服务需要实现@angular/router包中的PreloadingStrategy接口。修改新创建的服务如下:
import { Injectable } from '@angular/core';
import { PreloadingStrategy } from '@angular/router';
@Injectable({
  providedIn: 'root'
})
export class CustomPreloadStrategyService implements PreloadingStrategy {
  constructor() { }
}
  1. 接下来,我们需要实现我们的服务的PreloadingStrategy接口中的preload方法,以使其正常工作。让我们修改CustomPreloadStrategyService以实现preload方法,如下所示:
import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';
@Injectable({
  providedIn: 'root'
})
export class CustomPreloadStrategyService implements PreloadingStrategy {
  constructor() { }
  preload(route: Route, load: () => Observable<any>):   Observable<any> {
    return of(null)
  }
}
  1. 现在,我们的preload方法返回of(null)。相反,为了决定要预加载哪些路由,我们将在我们的路由定义中添加一个对象作为data对象,其中包含一个名为shouldPreload的布尔值。让我们通过修改app-routing.module.ts来快速完成这一点:
...
const routes: Routes = [{...}, {
  path: 'auth',
  loadChildren: () => import('./auth/auth.module').then(m => m.AuthModule),
  data: { shouldPreload: true }
}, {
  path: 'admin',
  loadChildren: () => import('./admin/admin.module').  then(m => m.AdminModule),
  data: { shouldPreload: true }
}, {
  path: 'employee',
  loadChildren: () => import('./employee/employee.  module').then(m => m.EmployeeModule),
  data: { shouldPreload: false }
}];
...
  1. 所有shouldPreload设置为true的路由应该被预加载,如果它们设置为false,那么它们就不应该被预加载。我们将创建两种方法。一种是我们想要预加载路由的情况,另一种是我们不想要预加载路由的情况。让我们修改custom-preload-strategy.service.ts,添加以下方法:
export class CustomPreloadStrategyService implements PreloadingStrategy {
  ...
  loadRoute(route: Route, loadFn: () => Observable<any>):   Observable<any> {
    console.log('Preloading done for route: ${route.    path}')
    return loadFn();
  }
  noPreload(route: Route): Observable<any> {
    console.log('No preloading set for: ${route.path}');
    return of(null);
  }
  ...
}
  1. 太棒了!现在我们必须在preload方法中使用步骤 6中创建的方法。让我们修改方法,使用路由定义中data对象的shouldPreload属性。代码应该如下所示:
...
export class CustomPreloadStrategyService implements PreloadingStrategy {
...
  preload(route: Route, load: () => Observable<any>):   Observable<any> {
    try {
      const { shouldPreload } = route.data;
      return shouldPreload ? this.loadRoute(route, load)       : this.noPreload(route);
    }
    catch (e) {
      console.error(e);
      return this.noPreload(route);
    }
  }
}
  1. 最后一步是使用我们自定义的预加载策略。为了这样做,修改app-routing-module.ts文件如下:
import { NgModule } from '@angular/core';
import { Routes, RouterModule, PreloadAllModules ← Remove } from '@angular/router';
import { CustomPreloadStrategyService } from './services/custom-preload-strategy.service';
const routes: Routes = [...];
@NgModule({
  imports: [RouterModule.forRoot(routes, {
    preloadingStrategy: CustomPreloadStrategyService
  })],
  exports: [RouterModule]
})
export class AppRoutingModule { }

看!如果您现在刷新应用并监视网络选项卡,您会注意到只有 Auth 和 Admin 的 JavaScript 文件被预加载,而 Employee 模块没有预加载,如下所示:

图 7.13-仅使用自定义预加载策略预加载 Auth 和 Admin 模块

图 7.13-仅使用自定义预加载策略预加载 Auth 和 Admin 模块

您还可以查看控制台日志,查看哪些路由已经预加载。您应该看到以下日志:

图 7.14-仅预加载 Auth 和 Admin 模块的日志

图 7.14-仅预加载 Auth 和 Admin 模块的日志

现在您已经完成了这个教程,看看下一节关于这是如何工作的。

它是如何工作的...

Angular 提供了一种很好的方法来为我们的特性模块实现自定义预加载策略。我们可以很容易地决定哪些模块应该预加载,哪些不应该。在这个教程中,我们学习了一种非常简单的方法,通过在路由配置的data对象中添加一个名为shouldPreload的属性来配置预加载。我们创建了自己的自定义预加载策略服务,命名为CustomPreloadStrategyService,它实现了@angular/router包中的PreloadingStrategy接口。这个想法是使用PreloadingStrategy接口中的preload方法,它允许我们决定一个路由是否应该预加载。这是因为 Angular 会使用我们的自定义预加载策略遍历每个路由,并决定哪些路由应该预加载。就是这样。现在我们可以将data对象中的shouldPreload属性分配给我们想要在应用启动时预加载的任何路由。

另请参阅

第八章:第八章:精通 Angular 表单

获取用户输入是几乎任何现代应用程序的一个重要部分。无论是对用户进行身份验证、征求反馈意见,还是填写业务关键表单,知道如何实现和呈现表单给最终用户始终是一个有趣的挑战。在本章中,您将了解 Angular 表单以及如何使用它们创建出色的用户体验。

以下是本章将要涵盖的示例:

  • 创建您的第一个模板驱动 Angular 表单

  • 使用模板驱动表单进行表单验证

  • 测试模板驱动表单

  • 创建您的第一个响应式表单

  • 使用响应式表单进行表单验证

  • 创建一个异步验证器函数

  • 测试响应式表单

  • 使用响应式表单控件进行去抖动

  • 使用ControlValueAccessor编写自定义表单控件

技术要求

对于本章的示例,请确保您的计算机上已安装了GitNodeJS。您还需要安装@angular/cli包,可以在终端中使用npm install -g @angular/cli来安装。本章的代码可以在github.com/PacktPublishing/Angular-Cookbook/tree/master/chapter08找到。

创建您的第一个模板驱动 Angular 表单

让我们在这个示例中开始熟悉 Angular 表单。在这个示例中,您将了解模板驱动表单的基本概念,并将使用模板驱动表单 API 创建一个基本的 Angular 表单。

准备工作

此示例中的项目位于chapter08/start_here/template-driven-forms中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这将在新的浏览器选项卡中打开应用程序,并且您应该看到以下视图:

图 8.1-在 http://localhost:4200 上运行的模板驱动表单应用程序

图 8.1-在 http://localhost:4200 上运行的模板驱动表单应用程序

如何做…

我们已经有一个 Angular 应用程序,其中已经有一个发布日志组件和一堆设置,例如src/app/classes文件夹下的ReleaseLog类。因此,在这个示例中,我们将创建一个模板驱动表单,允许用户选择一个应用程序并提交一个发布版本。让我们开始吧:

  1. 首先,在项目的根目录中打开终端,并创建一个发布表单组件,如下所示:
ng g c components/release-form

该命令应在src/app/components文件夹中创建一个名为ReleaseFormComponent的新组件。

  1. 将新创建的组件添加到VersionControlComponent的模板中,并修改version-control.component.html文件如下:
<div class="version-control">
  <app-release-form></app-release-form>
  <app-release-logs [logs]="releaseLogs"></app-release-  logs>
</div>

接下来,让我们调整一些样式,以便在VersionControlComponent中使用发布表单。

  1. 修改version-control.component.scss文件如下:
:host {
  ...
  min-width: 400px;
  .version-control {
    display: flex;
    justify-content: center;
  }
  app-release-logs,
  app-release-form {
    flex: 1;
  }
  app-release-form {
    margin-right: 20px;
  }
}

ReleaseFormComponent模板中,我们将有两个输入。一个用于选择我们要发布的应用程序,另一个用于我们要发布的版本。

  1. 让我们修改release-form.component.ts文件,将Apps枚举添加为一个本地属性,以便我们稍后可以在模板中使用:
import { Component, OnInit } from '@angular/core';
import { IReleaseLog } from 'src/app/classes/release-log';
import { Apps } from 'src/app/constants/apps';
...
export class ReleaseFormComponent implements OnInit {
  apps = Object.values(Apps);
  newLog: IReleaseLog = {
    app: Apps.CALENDAR,
    version: '0.0.0'
  };
  constructor() { }
  ngOnInit(): void {
  }
}
  1. 现在让我们添加我们表单的模板。修改release-form.component.html文件,并添加以下代码:
<form>
  <div class="form-group">
    <label for="appName">Select App</label>
    <select class="form-control" id="appName" required>
      <option value="">--Choose--</option>
      <option *ngFor="let app of apps"       [value]="app">{{app}}</option>
    </select>
  </div>
  <div class="form-group">
    <label for="versionNumber">Version Number</label>
    <input type="text" class="form-control"     id="versionNumber" aria-describedby="versionHelp"     placeholder="Enter version number">
    <small id="versionHelp" class="form-text     text-muted">Use semantic versioning (x.x.x)</small>
  </div>
  <button type="submit" class="btn btn-primary">  Submit</button>
</form>
  1. 现在我们需要集成模板驱动表单。让我们在app.module.ts文件中添加FormsModule,如下所示:
...
import { ReleaseFormComponent } from './components/release-form/release-form.component';
import { FormsModule } from '@angular/forms';
@NgModule({
  declarations: [...],
  imports: [
    BrowserModule,
    AppRoutingModule,
    FormsModule
  ],
  ...
})
export class AppModule { }
  1. 现在我们可以让我们的表单在模板中工作。让我们修改release-form.component.html文件,为表单创建一个模板变量,命名为#releaseForm。我们还将使用[(ngModel)]绑定来针对newLog属性的适当值:
<form #releaseForm="ngForm">
  <div class="form-group">
    <label for="appName">Select App</label>
    <select name="app" [(ngModel)]="newLog.app"     class="form-control" id="appName" required>
      <option value="">--Choose--</option>
      <option *ngFor="let app of apps"       [value]="app">{{app}}</option>
    </select>
  </div>
  <div class="form-group">
    <label for="versionNumber">Version Number</label>
    <input name="version" [(ngModel)]="newLog.version"     type="text" class="form-control" id="versionNumber"     aria-describedby="versionHelp" placeholder="Enter     version number">
    <small id="versionHelp" class="form-text text-    muted">Use semantic versioning (x.x.x)</small>
  </div>
  <button type="submit" class="btn btn-primary">  Submit</button>
</form>
  1. 创建一个当表单提交时将被调用的方法。修改release-form.component.ts文件,添加一个名为formSubmit的新方法。当调用此方法时,我们将使用 Angular 的@Output发射器发出ReleaseLog的新实例,如下所示:
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { NgForm } from '@angular/forms';
import { IReleaseLog, ReleaseLog } from 'src/app/classes/release-log';
...
export class ReleaseFormComponent implements OnInit {
  @Output() newReleaseLog = new   EventEmitter<ReleaseLog>();
  apps = Object.values(Apps);
  ...
  ngOnInit(): void {
  }
  formSubmit(form: NgForm): void {
    const { app, version } = form.value;
    const newLog: ReleaseLog = new ReleaseLog(app,     version)
    this.newReleaseLog.emit(newLog);
  }
}
  1. 现在更新模板,使用表单提交上的formSubmit方法,并修改release-form.component.html文件如下:
<form  #releaseForm="ngForm" (ngSubmit)="formSubmit(releaseForm)">
  ...
</form>
  1. 现在我们需要修改VersionControlComponent以便对新发布日志进行操作。为了这样做,修改version-control.component.html文件,以便监听来自ReleaseFormComponentnewReleaseLog输出事件,如下所示:
<div class="version-control">
  <app-release-form (newReleaseLog)="addNewReleaseLog   ($event)"></app-release-form>
  <app-release-logs [logs]="releaseLogs"></app-release-  logs>
</div>
  1. 太棒了!让我们在version-control.component.ts文件中创建addNewReleaseLog方法,并将接收到的ReleaseLog添加到releaseLogs数组中。您的代码应如下所示:
...
export class VersionControlComponent implements OnInit {
  releaseLogs: ReleaseLog[] = [];
  ...
  addNewReleaseLog(log: ReleaseLog) {
    this.releaseLogs.unshift(log);
  }
}

太棒了!在几分钟内,我们就能够在 Angular 中创建我们的第一个模板驱动表单。如果现在刷新应用程序并尝试创建一些发布,您应该会看到类似以下内容的东西:

图 8.2 - 模板驱动表单应用程序最终输出

图 8.2 - 模板驱动表单应用程序最终输出

现在您已经了解了如何创建模板驱动表单,让我们看看下一节,了解它是如何工作的。

它是如何工作的…

在 Angular 中使用模板驱动表单的关键在于FormsModulengForm指令,通过使用ngForm指令创建模板变量,并在模板中为输入使用[(ngModel)]双向数据绑定以及name属性。我们首先创建了一个带有一些输入的简单表单。然后,我们添加了FormsModule,这是必须的,用于使用ngForm指令和[(ngModel)]双向数据绑定。一旦我们添加了该模块,我们就可以在ReleaseFormComponent中使用该指令和数据绑定,使用新创建的本地属性命名为newLog。请注意,它可以是ReleaseLog类的实例,但我们将其保留为IReleaseLog类型的对象,因为我们不使用ReleaseLog类的message属性。通过使用[(ngModel)]#releaseForm模板变量,我们可以使用 Angular 的<form>指令的ngSubmit发射器提交表单。请注意,我们将releaseForm变量传递给formSubmit方法,这样可以更容易地测试功能。提交表单时,我们使用表单的值创建一个新的ReleaseLog项目,并使用newReleaseLog输出发射器发射它。请注意,如果为新发布日志提供无效的version,应用程序将抛出错误并且不会创建发布日志。这是因为我们在ReleaseLog类的constructor中验证了版本。最后,当VersionControlComponent捕获到newReleaseLog事件时,它调用addNewReleaseLog方法,将我们新创建的发布日志添加到releaseLogs数组中。由于releaseLogs数组作为@Input()传递给ReleaseLogsComponent,因此它会立即显示出来。

另请参阅

使用模板驱动表单进行表单验证

良好的用户体验是获得更多喜欢使用您的应用程序的用户的关键。而使用表单是用户并不真正喜欢的事情之一。为了确保用户在填写表单上花费最少的时间,并且尽快完成,我们可以实现表单验证,以确保用户尽快输入适当的数据。在这个配方中,我们将看看如何在模板驱动表单中实现表单验证。

准备工作

这个配方的项目位于chapter08/start_here/tdf-form-validation中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行 npm install 来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该在新的浏览器选项卡中打开应用程序,并且您应该看到应用程序如下所示:

图 8.3 - 运行在 http://localhost:4200 上的 TDF 表单验证应用程序

图 8.3 - 运行在 http://localhost:4200 上的 TDF 表单验证应用程序

现在我们已经在本地运行了应用程序,让我们在下一节中看看这个配方涉及的步骤。

如何做…

我们现在有了上一个配方中的应用程序,一个简单的 Angular 应用程序,使用ngFormngModel指令创建一个模板驱动表单。该表单用于创建发布日志。在这个配方中,我们将在用户输入时使这个表单更好地验证输入。让我们开始吧:

  1. 首先,我们将从@angular/forms包中添加一些验证器,这些验证器是响应式表单 API 的一部分。我们将对两个输入应用required验证,并对版本输入应用regex验证。我们需要为我们的两个输入创建模板变量。我们将分别命名它们为nameInputversionInput。修改release-form.component.html文件中的代码,使其如下所示:
<form  #releaseForm="ngForm" (ngSubmit)="formSubmit(releaseForm)">
  <div class="form-group">
    <label for="appName">Select App</label>
    <select #nameInput="ngModel" name="app"     [(ngModel)]="newLog.app" class="form-control"     id="appName" required>
      <option value="">--Choose--</option>
      <option *ngFor="let app of apps"       [value]="app">{{app}}</option>
    </select>
  </div>
  <div class="form-group">
    <label for="versionNumber">Version Number</label>
    <input #versionInput="ngModel" name="version"     [(ngModel)]="newLog.version" type="text"     class="form-control" id="versionNumber" aria-    describedby="versionHelp" placeholder="Enter     version number" required>
    <small id="versionHelp" class="form-text     text-muted">Use semantic versioning (x.x.x)</small>
  </div>
  <button type="submit" class="btn btn-primary">  Submit</button>
</form>
  1. 现在我们可以使用模板变量来应用验证。让我们从名称输入开始。在验证方面,名称输入不应为空,并且应从选择框中选择一个应用程序。当输入无效时,让我们显示一个默认的 Bootstrap 警报。修改release-form.component.html文件中的代码。它应该如下所示:
<form  #releaseForm="ngForm" (ngSubmit)="formSubmit(releaseForm)">
  <div class="form-group">
    <label for="appName">Select App</label>
    <select #nameInput="ngModel" name="app"     [(ngModel)]="newLog.app" class="form-control"     id="appName" required>
      <option value="">--Choose--</option>
      <option *ngFor="let app of apps"       [value]="app">{{app}}</option>
    </select>
    <div [hidden]="nameInput.valid || nameInput.pristine"     class="alert alert-danger">
      Please choose an app
    </div>
  </div>
  <div class="form-group">
    ...
  </div>
  <button type="submit" class="btn btn-primary">Submit   </button>
</form>
  1. 要验证版本名称输入,我们需要应用来自src/app/constants/regexes.ts文件的SEMANTIC_VERSION正则表达式。将常量添加为ReleaseFormComponent类中的本地属性,添加到release-form.component.ts文件中,如下所示:
...
import { Apps } from 'src/app/constants/apps';
import { REGEXES } from 'src/app/constants/regexes';
...
export class ReleaseFormComponent implements OnInit {
  @Output() newReleaseLog = new   EventEmitter<ReleaseLog>();
  apps = Object.values(Apps);
  versionInputRegex = REGEXES.SEMANTIC_VERSION;
  ...
}
  1. 现在,在模板中使用versionInputRegex来应用验证并显示相关错误。修改release-form.component.html文件,使代码如下所示:
<form  #releaseForm="ngForm" (ngSubmit)="formSubmit(releaseForm)">
  <div class="form-group">
    ...
  </div>
  <div class="form-group">
    <label for="versionNumber">Version Number</label>
    <input #versionInput="ngModel"     [pattern]="versionInputRegex" name="version"     [(ngModel)]="newLog.version" type="text"     class="form-control" id="versionNumber" aria-    describedby="versionHelp" placeholder="Enter     version number" required>
    <small id="versionHelp" class="form-text     text-muted">Use semantic versioning (x.x.x)</small>
    <div
      [hidden]="versionInput.value &&       (versionInput.valid || versionInput.pristine)"
      class="alert alert-danger"
    >
      Please write an appropriate version number
    </div>
  </div>
  <button type="submit" class="btn btn-primary">  Submit</button>
</form>
  1. 刷新应用程序,并尝试通过从“选择应用程序”下拉菜单中选择名为--选择--的第一个选项,并清空版本输入字段来使两个输入无效。您应该会看到以下错误:图 8.4 - 使用 ngModel 和验证显示输入错误

图 8.4 - 使用 ngModel 和验证显示输入错误

  1. 接下来,我们将添加一些样式,使我们的输入在验证时更加直观。让我们在release-form.component.scss文件中添加一些样式,如下所示:
:host {
  /* Error messages */
  .alert {
    margin-top: 16px;
  }
  /* Valid form input */
  .ng-valid[required], .ng-valid.required  {
    border-bottom: 3px solid #259f2b;
  }
  /* Invalid form input */
  .ng-invalid:not(form)  {
    border-bottom: 3px solid #c92421;
  }
}
  1. 最后,让我们围绕表单提交进行验证。如果输入值无效,我们将禁用提交按钮。让我们修改release-form.component.html模板如下:
<form #releaseForm="ngForm" (ngSubmit)="formSubmit(releaseForm)">
  <div class="form-group">
    ...
  </div>
  <div class="form-group">
    ...
  </div>
  <button type="submit" [disabled]="releaseForm.invalid"   class="btn btn-primary">Submit</button>
</form>

如果现在刷新应用程序,您会发现只要一个或多个输入无效,提交按钮就会被禁用。

太棒了!您刚学会了如何验证模板驱动表单,并使模板驱动表单的整体用户体验稍微好一些。

它是如何工作的...

本教程的核心组件是ngFormngModel指令。我们可以很容易地确定提交按钮是否应该可点击(未禁用),这取决于表单是否有效,也就是说,如果表单中的所有输入都具有有效值。请注意,我们在<form>元素上使用了使用#releaseForm="ngForm"语法创建的模板变量。这是由于ngForm指令能够导出为模板变量。因此,我们能够在提交按钮的[disabled]绑定中使用releaseForm.invalid属性来有条件地禁用它。我们还根据输入可能无效的条件显示单个输入的错误。在这种情况下,我们显示 Bootstrap 的alert元素(带有 CSS 类alert<div>)。我们还在表单输入上使用 Angular 提供的类ng-validng-invalid,以根据输入值的有效性以某种方式突出显示输入。这个教程有趣的地方在于,我们通过确保应用程序名称的输入包含一个非假值来验证它,其中<select>框的第一个<option>的值为""。更有趣的是,我们还通过在输入上绑定[pattern]到一个正则表达式来验证用户输入版本名称。否则,我们将不得不等待用户提交表单,然后才能进行验证。因此,我们通过在用户输入版本时提供错误信息来提供出色的用户体验。

另请参阅

测试模板驱动表单

为了确保我们为最终用户构建健壮且无错误的表单,最好是对表单进行测试。这样可以使代码更具弹性,更不容易出错。在本教程中,您将学习如何使用单元测试来测试模板驱动表单。

准备工作

本教程的项目位于chapter08/start_here/testing-td-forms中。

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install以安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该会在新的浏览器选项卡中打开应用程序,您应该会看到应用程序如下所示:

图 8.5 - 正在运行的 Testing Template-Driven Forms 应用程序,网址为 http://localhost:4200

图 8.5 - 正在运行的 Testing Template-Driven Forms 应用程序,网址为 http://localhost:4200

现在我们已经在本地运行了应用程序,让我们在下一节中看看这个配方涉及的步骤。

如何做…

我们有来自上一个配方的应用程序,其中包含用于创建发布日志的模板驱动表单。该表单还对输入应用了验证。让我们开始研究如何测试这个表单:

  1. 首先,运行以下命令来运行单元测试:
npm run test

运行命令后,您应该看到打开一个新的 Chrome 窗口来运行单元测试。我们六个测试中的一个测试失败了。您可能会在自动化的 Chrome 窗口中看到类似以下内容:

图 8.6 - 使用 Karma 和 Jasmine 在自动化 Chrome 窗口中运行单元测试

图 8.6 - 使用 Karma 和 Jasmine 在自动化 Chrome 窗口中运行单元测试

  1. ReleaseFormComponent > should create测试失败了,因为我们没有将FormsModule添加到测试中。注意Export of name 'ngForm' not found错误。让我们在release-form.component.spec.ts中的测试模块配置中导入FormsModule,如下所示:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { ReleaseFormComponent } from './release-form.component';
describe('ReleaseFormComponent', () => {
  ...
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ ReleaseFormComponent ],
      imports: [ FormsModule ]
    })
    .compileComponents();
  });
  ...
  it('should create', () => {
    expect(component).toBeTruthy();
  });
});

如果您现在查看测试,您应该看到所有测试都通过了,如下所示:

图 8.7 - 在适当的测试中导入 FormsModule 后,所有测试都通过了

图 8.7 - 在适当的测试中导入 FormsModule 后,所有测试都通过了

为了正确测试表单,我们将添加一些测试,一个用于成功的输入,一个用于每个无效的输入。为此,我们需要访问我们组件中的表单,因为我们正在编写单元测试。

  1. 让我们在release-form.component.ts文件中使用@ViewChild()装饰器来访问我们组件类中的#releaseForm,如下所示:
import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core';
...
@Component({
  selector: 'app-release-form',
  templateUrl: './release-form.component.html',
  styleUrls: ['./release-form.component.scss']
})
export class ReleaseFormComponent implements OnInit {
  @Output() newReleaseLog = new   EventEmitter<ReleaseLog>();
  @ViewChild('releaseForm') releaseForm: NgForm;
  apps = Object.values(Apps);
  versionInputRegex = REGEXES.SEMANTIC_VERSION;
  ...
}
  1. 现在让我们添加一个新的测试。我们将编写一个测试,用于验证当两个输入都具有有效值时的情况。将测试添加到release-form.component.spec.ts文件中,如下所示:
import { ComponentFixture, TestBed, fakeAsync } from '@angular/core/testing';
import { ReleaseFormComponent } from './release-form.component';
describe('ReleaseFormComponent', () => {
  ...
  it('should create', () => {
    expect(component).toBeTruthy();
  });
  it('should submit a new release log with the correct   input values', fakeAsync( () => {
    expect(true).toBeFalsy();
  }));
});
  1. 到目前为止,新的测试失败了。让我们尝试填写表单中的值,提交按钮,并确保我们的@Output发射器命名为newReleaseLogreleaseForm中发射出正确的值。测试的内容应该如下所示:
...
import { ReleaseLog } from 'src/app/classes/release-log';
...
it('should submit a new release log with the correct input values', fakeAsync(async () => {
    const submitButton = fixture.nativeElement.    querySelector('button[type="submit"]');
    const CALENDAR_APP = component.apps[2];
    spyOn(component.newReleaseLog, 'emit');
    await fixture.whenStable(); // wait for Angular     to configure the form
    component.releaseForm.controls[    'version'].setValue('2.2.2');
    component.releaseForm.controls[    'app'].setValue(CALENDAR_APP);
    submitButton.click();
    const expectedReleaseLog = new ReleaseLog(CALENDAR_    APP, '2.2.2');
    expect(component.newReleaseLog.emit)    .toHaveBeenCalledWith(expectedReleaseLog);
  }));

当你保存文件时,你应该看到新的测试通过了预期的值。它应该出现在 Chrome 标签页中如下所示:

图 8.8 - 成功提交表单的新测试通过

图 8.8 - 成功提交表单的新测试通过

  1. 让我们为表单中提供了不正确版本的情况添加一个测试。提交按钮应该被禁用,并且formSubmit方法应该抛出错误。在release-form.component.spec.ts文件中添加一个新的测试,如下所示:
...
describe('ReleaseFormComponent', () => {
  ...
  it('should submit a new release log with the correct   input values', fakeAsync(async () => {
    const submitButton = fixture.nativeElement.    querySelector('button[type="submit"]');
    const CALENDAR_APP = component.apps[2];
    spyOn(component.newReleaseLog, 'emit');
    await fixture.whenStable(); // wait for Angular     to configure the form
    const expectedError = 'Invalid version provided.     Please provide a valid version as     (major.minor.patch)';
    component.releaseForm.controls[    'version'].setValue('x.x.x');
    component.releaseForm.controls[    'app'].setValue(CALENDAR_APP);
    expect(() => component.formSubmit(component.    releaseForm))
      .toThrowError(expectedError);
    fixture.detectChanges();
    expect(submitButton.hasAttribute(    'disabled')).toBe(true);
    expect(component.newReleaseLog.emit)    .not.toHaveBeenCalled();
  }));
});
  1. 让我们添加最后一个测试,确保当我们没有为发布日志选择应用程序时,提交按钮被禁用。在release-form.component.spec.ts文件中添加一个新的测试,如下所示:
...
describe('ReleaseFormComponent', () => {
  ...
  it('should disable the submit button when we   don\'t have an app selected', fakeAsync(async () => {
    const submitButton = fixture.nativeElement.    querySelector('button[type="submit"]');
    spyOn(component.newReleaseLog, 'emit');
    await fixture.whenStable(); // wait for Angular     to configure the form
    component.releaseForm.controls[    'version'].setValue('2.2.2');
    component.releaseForm.controls[    'app'].setValue(null);
    fixture.detectChanges();
    expect(submitButton.hasAttribute(    'disabled')).toBe(true);
    expect(component.newReleaseLog.emit     ).not.toHaveBeenCalled();
  }));
});

如果你查看 Karma 测试窗口,你应该看到所有新的测试都通过了,如下所示:

图 8.9 - 针对该配方的所有测试都通过

图 8.9 - 针对该配方的所有测试都通过

太棒了!现在你已经掌握了一堆测试模板驱动表单的技巧。其中一些技巧可能仍需要一些解释。请查看下一节,了解它是如何工作的。

它是如何工作的…

测试模板驱动表单可能有点挑战,因为它取决于表单的复杂程度,您想要测试的用例以及这些用例的复杂程度。在我们的配方中,我们首先在ReleaseFormComponent的测试文件的导入中包含了FormsModule。这确保了测试知道ngForm指令,并且不会抛出相关错误。对于所有成功输入的测试,我们对ReleaseFormComponent类中定义的newReleaseLog发射器的emit事件进行了监听。这是因为我们知道当输入正确时,用户应该能够点击提交按钮,因此在formSubmit方法内,newReleaseLog发射器的emit方法将被调用。请注意,我们在每个测试中都使用了fixture.whenStable()。这是为了确保 Angular 已经完成了编译,我们的ngForm,命名为#releaseForm,已经准备就绪。对于当版本不正确时应禁用提交按钮的测试,我们依赖于formSubmit抛出错误。这是因为我们知道无效的版本将在创建新的发布日志时导致ReleaseLog类的constructor中出错。这个测试中有一个有趣的地方是我们使用了以下代码:

expect(() => component.formSubmit(component.releaseForm))
      .toThrowError(expectedError);

这里有趣的是,我们需要自己调用 formSubmit 方法,并使用 releaseForm。我们不能只写 expect(component.formSubmit(component.releaseForm)).toThrowError(expectedError);,因为那样会直接调用函数并导致错误。所以,我们需要在这里传递一个匿名函数,Jasmine 将调用这个匿名函数,并期望这个匿名函数抛出一个错误。最后,我们通过在 fixture.nativeElement 上使用 querySelector 来获取按钮,然后使用 submitButton.hasAttribute('disabled') 检查提交按钮上的 disabled 属性,以确保我们的提交按钮是启用还是禁用的。

参见

创建您的第一个响应式表单

在之前的配方中,您已经了解了模板驱动表单,并且现在有信心使用它们构建 Angular 应用程序。现在猜猜?响应式表单甚至更好。许多知名的工程师和企业在 Angular 社区推荐使用响应式表单。原因是在构建复杂表单时,它们的易用性。在这个配方中,您将构建您的第一个响应式表单,并学习其基本用法。

准备工作

这个配方的项目位于 chapter08/start_here/reactive-forms 中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行 npm install 来安装项目的依赖项。

  3. 完成后,运行 ng serve -o

  4. 点击第一个用户的名称,您应该看到以下视图:

图 8.10 – 响应式表单应用程序在 http://localhost:4200 上运行

图 8.10 – 响应式表单应用程序在 http://localhost:4200 上运行

现在我们已经在本地运行了应用程序,让我们在下一节中看看这个配方涉及的步骤。

如何做…

到目前为止,我们有一个具有 ReleaseLogsComponent 的应用程序,它显示了我们创建的一堆发布日志。我们还有 ReleaseFormComponent,它通过表单创建发布日志。现在我们需要使用 Reactive forms API 将当前表单变成一个响应式表单。让我们开始吧:

  1. 首先,我们需要将 ReactiveFormsModule 导入到我们的 AppModule 的导入中。让我们通过修改 app.module.ts 文件来做到这一点:
...
import { ReleaseFormComponent } from './components/release-form/release-form.component';
import { ReactiveFormsModule } from '@angular/forms';
@NgModule({
  declarations: [...],
  imports: [
    BrowserModule,
    AppRoutingModule,
    ReactiveFormsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
  1. 让我们现在创建响应式表单。我们将在ReleaseFormComponent类中创建一个带有所需控件的FormGroup。修改release-form.component.ts文件如下:
...
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { REGEXES } from 'src/app/constants/regexes';
@Component(...)
export class ReleaseFormComponent implements OnInit {
  apps = Object.values(Apps);
  versionInputRegex = REGEXES.SEMANTIC_VERSION;
  releaseForm = new FormGroup({
    app: new FormControl('', [Validators.required]),
    version: new FormControl('', [
      Validators.required,
      Validators.pattern(REGEXES.SEMANTIC_VERSION)
    ]),
  })
  ...
}
  1. 现在我们已经有了名为releaseForm的表单,让我们在模板中使用它来绑定表单。修改release-form.component.html文件如下:
<form [formGroup]="releaseForm">
  ...
</form>
  1. 太棒了!现在我们已经绑定了表单组,我们还可以绑定单个表单控件,这样当我们最终提交表单时,我们可以获取每个单独表单控件的值。进一步修改release-form.component.html文件如下:
<form [formGroup]="releaseForm">
  <div class="form-group">
    ...
    <select formControlName="app" class="form-control"     id="appName" required>
      ...
    </select>
  </div>
  <div class="form-group">
    ...
    <input formControlName="version" type="text"     class="form-control" id="versionNumber" aria-    describedby="versionHelp" placeholder="Enter     version number">
    <small id="versionHelp" class="form-text     text-muted">Use semantic versioning (x.x.x)</small>
  </div>
  ...
</form>
  1. 让我们决定当我们提交这个表单时会发生什么。我们将在模板中调用一个名为formSubmit的方法,并在表单提交时传递releaseForm。修改release-form.component.html文件如下:
<form [formGroup]="releaseForm" (ngSubmit)="formSubmit(releaseForm)">
  ...
</form>
  1. formSubmit方法目前还不存在。让我们现在在ReleaseFormComponent类中创建它。我们还将在控制台上记录该值,并使用@Output发射器发射该值。修改release-form.component.ts文件如下:
import { Component, OnInit, Output, EventEmitter } from '@angular/core';
...
import { ReleaseLog } from 'src/app/classes/release-log';
...
@Component(...)
export class ReleaseFormComponent implements OnInit {
  @Output() newReleaseLog = new   EventEmitter<ReleaseLog>();
  apps = Object.values(Apps);
  ...
  formSubmit(form: FormGroup): void {
    const { app, version } = form.value;
    console.log({app, version});
    const newLog: ReleaseLog = new ReleaseLog(app,     version)
    this.newReleaseLog.emit(newLog);
  }
}

如果您现在刷新应用程序,填写完表单,然后点击提交,您应该在控制台上看到如下日志:

图 8.11 - 显示使用响应式表单提交的值的日志

图 8.11 - 显示使用响应式表单提交的值的日志

  1. 由于我们通过newReleaseLog输出发射器发射了新创建的发布日志的值,我们可以在version-control.component.html文件中监听此事件,并相应地添加新日志。让我们修改文件如下:
<div class="version-control">
  <app-release-form (newReleaseLog)="addNewReleaseLog   ($event)"></app-release-form>
  <app-release-logs [logs]="releaseLogs">  </app-release-logs>
</div>
  1. 刷新应用程序,您应该看到新的发布日志被添加到发布日志视图中。您还应该在控制台上看到日志,如下面的截图所示:

图 8.12 - 在表单提交时添加到日志视图的新日志

图 8.12 - 在表单提交时添加到日志视图的新日志

太棒了!现在你知道如何使用响应式表单 API 创建基本的响应式表单了。请参考下一节,了解它是如何工作的。

它是如何工作的…

该食谱始于我们的 Angular 应用程序中有一个基本的 HTML 表单,没有与之绑定的 Angular 魔法。我们首先在 AppModule 中导入了 ReactiveFormsModule。如果您正在使用所选编辑器的 Angular 语言服务,当您导入 ReactiveFormsModule 到应用程序中并且没有将其与响应式表单绑定时,您可能会看到一个错误,换句话说,没有与 FormGroup 绑定。好吧,这就是我们做的。我们使用 FormGroup 构造函数创建了一个响应式表单,并使用 FormControl 构造函数创建了相关的表单控件。然后,我们监听了 <form> 元素上的 ngSubmit 事件,以提取 releaseForm 的值。完成后,我们使用 @Ouput() 命名为 newReleaseLog 发射了这个值。请注意,我们还定义了此发射器将发射的值的类型为 IReleaseLog;定义这些是一个好习惯。这个发射器是必需的,因为 ReleaseLogsComponent 是组件层次结构中 ReleaseFormComponent 的兄弟组件。因此,我们通过父组件 VersionControlComponent 进行通信。最后,我们在 VersionControlComponent 模板中监听 newReleaseLog 事件的发射,并通过 addNewReleaseLog 方法向 releaseLogs 数组添加新日志。并且这个 releaseLogs 数组被传递给 ReleaseLogsComponent,它会显示所有添加的日志。

另请参阅

使用响应式表单进行表单验证

在上一篇食谱中,您学会了如何创建一个响应式表单。现在,我们将学习如何测试它们。在这个食谱中,您将学习一些测试响应式表单的基本原则。我们将使用上一篇食谱中的相同示例(发布日志应用程序),并实现多个测试用例。

准备工作

我们将要使用的项目位于克隆存储库中的 chapter08/start_here/validating-reactive-forms 中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行 npm install 来安装项目的依赖项。

  3. 完成后,运行 ng serve -o

这应该会在新的浏览器标签中打开应用程序,您应该会看到它如下所示:

图 8.13 – 在 http://localhost:4200 上运行的验证响应式表单应用程序

图 8.13 – 在 http://localhost:4200 上运行的验证响应式表单应用程序

现在我们已经在本地运行了应用程序,让我们在下一节中看看这个配方涉及的步骤。

如何做...

对于这个配方,我们使用的是已经实现了响应式表单的发布日志应用程序,尽管到目前为止我们还没有任何输入验证。如果你只是选择一个应用程序并提交表单,你会在控制台上看到以下错误:

图 8.14 - 在没有表单验证的情况下提交响应式表单应用程序时出错

图 8.14 - 在没有表单验证的情况下提交响应式表单应用程序时出错

我们将加入一些表单验证来增强用户体验,并确保表单不能使用无效输入提交。让我们开始:

  1. 首先,我们将从@angular/forms包中添加一些验证,这些验证是响应式表单 API 的一部分。我们将在两个输入上应用required验证器,并在version表单控件上应用pattern验证器。更新release-form.component.ts文件如下:
import { Component, OnInit, Output, EventEmitter } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
...
import { REGEXES } from 'src/app/constants/regexes';
@Component({...})
export class ReleaseFormComponent implements OnInit {
  ...
  versionInputRegex = REGEXES.SEMANTIC_VERSION;
  releaseForm = new FormGroup({
    app: new FormControl('', Validators.required),
    version: new FormControl('', [
      Validators.required,
      Validators.pattern(this.versionInputRegex)
    ]),
  })
  ...
}
  1. 现在我们将在视图中添加提示,以在选择无效输入时向用户显示错误。修改release-form.component.html文件如下:
<form [formGroup]="releaseForm" (ngSubmit)="formSubmit(releaseForm)">
  <div class="form-group">
    <label for="appName">Select App</label>
    <select formControlName="app" class="form-control"     id="appName">
      ...
    </select>
    <div
      [hidden]="releaseForm.get('app').valid ||       releaseForm.get('app').pristine"
      class="alert alert-danger">
      Please choose an app
    </div>
  </div>
  <div class="form-group">
    ...
    <small id="versionHelp" class="form-text     text-muted">Use semantic versioning (x.x.x)</small>
    <div [hidden]="releaseForm.get('version').valid ||     releaseForm.get('version').pristine"
      class="alert alert-danger">
      Please write an appropriate version number
    </div>
  </div>
  <button type="submit" class="btn btn-primary">Submit   </button>
</form>
  1. 我们还将添加一些样式来以更好的 UI 显示错误。将以下样式添加到release-form.component.scss文件中:
:host {
  /* Error messages */
  .alert {
    margin-top: 16px;
  }
  /* Valid form input */
  .ng-valid:not(form),
  .ng-valid.required {
    border-bottom: 3px solid #259f2b;
  }
  /* Invalid form input */
  .ng-invalid:not(form) {
    border-bottom: 3px solid #c92421;
  }
}

刷新应用程序,当输入值错误时,你应该看到带有红色边框的输入。一旦输入或选择无效输入,错误将如下所示:

图 8.15 - 显示无效输入值的红色边框

图 8.15 - 显示无效输入值的红色边框

  1. 最后,让我们围绕表单提交进行验证。如果输入无效,我们将禁用提交按钮。让我们修改release-form.component.html模板如下:
<form [formGroup]="releaseForm" (ngSubmit)="formSubmit(releaseForm)">
  <div class="form-group">
    ...
  </div>
  <div class="form-group">
    ...
  </div>
  <button type="submit" [disabled]="releaseForm.invalid"   class="btn btn-primary">Submit</button>
</form>

如果现在刷新应用程序,你会看到只要一个或多个输入无效,提交按钮就会被禁用。

这就结束了这个配方。让我们看看下一节,看看它是如何工作的。

它是如何工作的...

我们通过添加验证器开始了这个教程,Angular 已经提供了一堆验证器,包括Validators.emailValidators.patternValidators.required。我们在教程中分别为应用程序名称和版本的输入使用了required验证器和pattern验证器。之后,为了显示无效输入的提示/错误,我们添加了一些条件样式,以在输入上显示底部边框。我们还添加了一些<div>元素,带有class="alert alert-danger",这些基本上是 Bootstrap 警报,用于显示表单控件的无效值的错误。请注意,我们使用以下模式来隐藏错误元素:

[hidden]="releaseForm.get(CONTROL_NAME).valid || releaseForm.get(CONTROL_NAME).pristine"

我们使用.pristine条件来确保一旦用户选择了正确的输入并修改了输入,我们再次隐藏错误,以便在用户输入或进行其他选择时不显示错误。最后,我们确保即使表单控件的值无效,表单也无法提交。我们使用[disabled]="releaseForm.invalid"来禁用提交按钮。

另见

创建一个异步验证器函数

在 Angular 中,表单验证非常简单,原因在于 Angular 提供了超级棒的验证器。这些验证器是同步的,意味着一旦您更改输入,验证器就会启动并立即提供有关值有效性的信息。但有时,您可能会依赖于后端 API 的一些验证。这些情况需要一种称为异步验证器的东西。在本教程中,您将创建您的第一个异步验证器。

准备工作

我们将要使用的项目位于克隆存储库中的chapter08/start_here/asynchronous-validator中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install以安装项目的依赖项。

  3. 完成后,运行ng serve -o

这将在新的浏览器选项卡中打开应用程序,您应该看到类似以下内容的内容:

图 8.16 - 异步验证器应用程序在 http://localhost:4200 上运行

图 8.16 - 异步验证器应用程序在 http://localhost:4200 上运行

现在我们的应用程序正在运行,让我们在下一节中看看这个配方涉及的步骤。

如何做到...

我们已经在发布日志应用程序中设置了一些内容。我们在src/assets文件夹中有一个data.json文件,其中包含发布日志的每个目标应用程序的版本。我们将创建一个异步验证器,以确保每个应用程序的新版本都比data.json文件中指定的版本大。让我们开始:

  1. 首先,我们将为该配方创建异步验证器函数。让我们在version.service.ts文件的VersionService类中创建一个名为versionValidator的方法,如下所示:
...
import { compareVersion } from 'src/app/utils';
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { Observable, of } from 'rxjs';
@Injectable({...})
export class VersionService {
  ...
  versionValidator(appNameControl: AbstractControl):   AsyncValidatorFn {
    // code here
  }
  ...
}
  1. 现在我们将定义验证器函数的内容。让我们修改versionValidator方法如下:
versionValidator(appNameControl: AbstractControl): AsyncValidatorFn {
  return (control: AbstractControl):   Observable<ValidationErrors> => {
  // if we don't have an app selected, do not validate
  if (!appNameControl.value) {
    return of(null);
  }
  return this.getVersionLog().pipe(
    map(vLog => {
      const newVersion = control.value;
      const previousVersion = vLog[appNameControl.value];
      // check if the new version is greater than          previous version
      return compareVersion(newVersion, previousVersion)       === 1 ? null : {
        newVersionRequired: previousVersion
      };
    }))
  }
}
  1. 现在我们已经有了验证器函数,让我们将其添加到版本号的表单控件中。修改release-form.component.ts文件如下:
import { Component, OnInit, Output, EventEmitter } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { IReleaseLog, ReleaseLog } from 'src/app/classes/release-log';
import { Apps } from 'src/app/constants/apps';
import { REGEXES } from 'src/app/constants/regexes';
import { VersionService } from 'src/app/core/services/version.service';
@Component({...})
export class ReleaseFormComponent implements OnInit {
  ...
  constructor(private versionService: VersionService) { }
  ngOnInit(): void {
    this.releaseForm.get('version')    .setAsyncValidators(
      this.versionService.versionValidator(
        this.releaseForm.get('app')
      )
    )
  }
  ...
}
  1. 现在我们将使用验证器来增强表单的用户体验,修改release-form.component.html文件。为了方便使用,让我们使用*ngIf指令将内容包装在<ng-container>元素中,并在模板中创建一个变量用于版本表单控件,如下所示:
<form [formGroup]="releaseForm" (ngSubmit)="formSubmit(releaseForm)">
  <ng-container *ngIf="releaseForm.get('version')   as versionControl">
    <div class="form-group">
      ...
    </div>
    <div class="form-group">
      ...
    </div>
    <button type="submit" [disabled]="releaseForm.    invalid" class="btn btn-primary">Submit</button>
  </ng-container>
</form>
  1. 现在让我们添加错误消息。我们将使用我们的自定义错误newVersionRequired,从验证器函数中显示错误,当指定的版本不比先前的版本更新时。修改release-form.component.html文件如下:
<form [formGroup]="releaseForm" (ngSubmit)="formSubmit(releaseForm)">
  <ng-container *ngIf="releaseForm.get('version')   as versionControl">
    <div class="form-group">
      ...
    </div>
    <div class="form-group">
      <label for="versionNumber">Version Number</label>
      <input formControlName="version" type="text"       class="form-control" id="versionNumber"       aria-describedby="versionHelp" placeholder="Enter       version number">
      ...
      <div *ngIf="(versionControl.      getError('newVersionRequired') &&       !versionControl.pristine)"
        class="alert alert-danger">
        The version number should be greater         than the last version '{{versionControl.        errors['newVersionRequired']}}'
      </div>
    </div>
    <button [disabled]="releaseForm.invalid"     class="btn btn-primary">Submit</button>
  </ng-container>
</form>

尝试选择一个应用程序并添加一个较低的版本号,现在您应该看到以下错误:

图 8.17 - 提供较低版本号时显示的错误

图 8.17 - 提供较低版本号时显示的错误

  1. 目前的一个问题是,我们能够在异步验证进行时提交表单。这是因为 Angular 默认情况下会将错误标记为null,直到验证完成。为了解决这个问题,我们可以在模板中显示一个加载消息,而不是提交按钮。修改release-form.component.html文件如下:
<form [formGroup]="releaseForm" (ngSubmit)="formSubmit(releaseForm)">
  <ng-container *ngIf="releaseForm.get('version')   as versionControl">
    <div class="form-group">
      ...
    </div>
    <div class="form-group">
      ...
    </div>
    <button *ngIf="versionControl.status     !== 'PENDING'; else loader" type="submit"     [disabled]="releaseForm.invalid" class="btn      btn-primary">Submit</button>
  </ng-container>
  <ng-template #loader>
    Please wait...
  </ng-template>
</form>

如果您刷新应用程序,选择一个应用程序,并输入一个有效的版本号,您应该看到以下请稍候...消息:

图 8.18 - 异步验证进行时的加载消息

图 8.18 - 异步验证进行时的加载消息

  1. 我们仍然有一个问题,即用户可以快速输入并按Enter提交表单。为了防止这种情况发生,让我们在release-form.component.ts文件的formSubmit方法中添加一个检查,如下所示:
  formSubmit(form: FormGroup): void {
    if (form.get('version').status === 'PENDING') {
      return;
    }
    const { app, version } = form.value;
    ...
  }
  1. 最后,我们还有另一个问题要处理。如果我们选择了一个有效的版本号并更改了应用程序,尽管逻辑上是错误的,我们仍然可以提交带有输入版本号的表单。为了处理这个问题,我们应该在'app'表单控件的值发生变化时更新'version'表单控件的验证。为此,请按照以下方式修改release-form.component.ts文件:
import { Component, OnInit, Output, EventEmitter, OnDestroy } from '@angular/core';
...
import { takeWhile } from 'rxjs/operators';
...
@Component({...})
export class ReleaseFormComponent implements OnInit, OnDestroy {
  @Output() newReleaseLog = new   EventEmitter<IReleaseLog>();
  isComponentAlive = false;
  apps = Object.values(Apps);
  ...
  ngOnInit(): void {
    this.isComponentAlive = true;
    this.releaseForm.get     ('version').setAsyncValidators(...)
    this.releaseForm.get('app').valueChanges
      .pipe(takeWhile(() => this.isComponentAlive))
      .subscribe(() => {
        this.releaseForm.get         ('version').updateValueAndValidity();
      })
  }
  ngOnDestroy() {
    this.isComponentAlive = false;
  }
  ...
}

很棒!现在你知道如何在 Angular 中为响应式表单创建异步验证器函数了。既然你已经完成了这个示例,请参考下一节,看看它是如何工作的。

它是如何工作的...

Angular 提供了一种非常简单的方法来创建异步验证器函数,它们也非常方便。在这个示例中,我们首先创建了名为versionValidator的验证器函数。请注意,我们为验证器函数命名了一个名为appNameControl的参数。这是因为我们想要获取正在验证版本号的应用程序名称。还要注意,我们将返回类型设置为AsyncValidatorFn,这是 Angular 所要求的。验证器函数应该返回一个AsyncValidatorFn,这意味着它将返回一个函数(让我们称之为内部函数),该函数接收一个AbstractControl并返回一个ValidatorErrorsObservable。在内部函数中,我们使用VersionServicegetVersionLog()方法,使用HttpClient服务获取data.json文件。一旦我们从data.json中获取了特定应用程序的版本,我们就将表单中输入的版本与data.json中的值进行比较,以验证输入。请注意,我们并不只是返回一个ValidationErrors对象,其中newVersionRequired属性设置为true,而是实际上将其设置为previousVersion,以便稍后向用户显示。

创建验证器函数后,我们通过在ReleaseFormComponent类中使用FormControl.setAsyncValidators()方法将其附加到版本名称的表单控件上。然后我们在模板中使用名为newVersionRequired的验证错误来显示错误消息,以及来自data.json文件的版本。

我们还需要处理这样一种情况,即在验证进行中,表单控件在验证完成之前是有效的。这使我们能够在版本名称的验证正在进行时提交表单。我们通过检查FormControl.status的值是否为'PENDING'来处理这个问题,在这种情况下,我们隐藏提交按钮,并在此期间显示请等待…消息。请注意,我们还在ReleaseFormComponent类的formSubmit方法中添加了一些逻辑,以检查版本号的FormControl.status是否为'PENDING',在这种情况下,我们只需执行return;

食谱中的另一个有趣之处是,如果我们添加了一个有效的版本号并更改了应用程序,我们仍然可以提交表单。我们通过向'app'表单控件的.valueChanges添加订阅来处理这个问题,因此每当这种情况发生时,我们使用.updateValueAndValidity()方法在'version'表单控件上触发另一个验证。

参见

测试响应式表单

为了确保我们为最终用户构建健壮且无错误的表单,围绕您的表单编写测试是一个非常好的主意。这使得代码更具弹性,更不容易出错。在这个食谱中,您将学习如何使用单元测试测试您的模板驱动表单。

做好准备

此处的项目位于chapter08/start_here/testing-reactive-forms

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该在新的浏览器标签中打开应用程序,您应该看到应用程序如下:

图 8.19 - 在 http://localhost:4200 上运行的测试响应式表单应用程序

图 8.19 - 在 http://localhost:4200 上运行的测试响应式表单应用程序

现在我们的应用程序在本地运行,让我们在下一节中看看这个食谱涉及的步骤。

如何做…

我们有一个使用一些验证实现的响应式表单的 Release Logs 应用程序。在这个食谱中,我们将为表单实现一些测试。让我们开始吧:

  1. 首先,在单独的终端窗口中运行以下命令来运行单元测试:
yarn test

运行命令后,你应该看到一个新的 Chrome 窗口实例被打开,运行测试如下:

![图 8.20 - 单元测试与 Karma 和 Jasmine 在自动化 Chrome 窗口中运行

图 8.20 - 单元测试与 Karma 和 Jasmine 在自动化 Chrome 窗口中运行

图 8.20 - 单元测试与 Karma 和 Jasmine 在自动化 Chrome 窗口中运行

  1. 让我们为所有输入都有有效值的情况添加第一个测试。在这种情况下,我们应该提交表单,并通过newReleaseLog输出的发射器发出表单的值。修改release-form.component.spec.ts文件如下:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReleaseLog } from 'src/app/classes/release-log';
...
describe('ReleaseFormComponent', () => {
  ...
  it('should submit a new release log with the correct   input values', (() => {
    const app = component.apps[2];
    const version = '2.2.2';
    const expectedReleaseLog = new ReleaseLog(app,     version);
    spyOn(component.newReleaseLog, 'emit');
    component.releaseForm.setValue({ app, version });
    component.formSubmit(component.releaseForm);
    expect(component.newReleaseLog.emit)    .toHaveBeenCalledWith(expectedReleaseLog);
  }));
});

如果你现在查看测试,你应该看到新的测试通过如下:

图 8.21 - 成功输入的测试用例通过

![图 8.21 - 成功输入的测试用例通过

  1. 让我们为表单中提供了不正确版本的情况添加一个测试。提交按钮应该被禁用,并且formSubmit方法应该抛出错误。在你的release-form.component.spec.ts文件中添加一个新的测试,如下所示:
...
describe('ReleaseFormComponent', () => {
  ...
  it('should throw an error for a new release log with   the incorrect version values', (() => {
    const submitButton = fixture.nativeElement.    querySelector('button[type="submit"]');
    const app = component.apps[2];
    const version = 'x.x.x';
    spyOn(component.newReleaseLog, 'emit');
    const expectedError = 'Invalid version provided.     Please provide a valid version as (major.minor.    patch)';
    component.releaseForm.setValue({ app, version });
    expect(() => component.formSubmit(component.    releaseForm))
      .toThrowError(expectedError);
    expect(submitButton.hasAttribute(    'disabled')).toBe(true);
    expect(component.newReleaseLog.emit     ).not.toHaveBeenCalled();
  }));
});
  1. 让我们添加我们的最终测试,确保当我们没有为发布日志选择应用程序时,提交按钮被禁用。在release-form.component.spec.ts文件中添加一个新的测试,如下所示:
...
describe('ReleaseFormComponent', () => {
  ...
  it('should disable the submit button when we   don\'t have an app selected', (() => {
    const submitButton = fixture.nativeElement.    querySelector('button[type="submit"]');
    spyOn(component.newReleaseLog, 'emit');
    const app = '';
    const version = '2.2.2';
    component.releaseForm.setValue({ app, version });
    submitButton.click();
    fixture.detectChanges();
    expect(submitButton.hasAttribute(    'disabled')).toBe(true);
    expect(component.newReleaseLog.emit     ).not.toHaveBeenCalled();
  }));
});

如果你查看 Karma 测试窗口,你应该看到所有新的测试都通过了如下:

图 8.22 - 所有测试通过了食谱

![图 8.22 - 所有测试用例通过了食谱

太棒了!现在你知道如何为响应式表单编写一些基本的测试了。请参考下一节,了解它是如何工作的。

它是如何工作的...

测试响应式表单甚至不需要在 Angular 10 中将ReactiveFormsModule导入测试模块。对于我们食谱中的所有测试,我们都对ReleaseFormComponent类中定义的newReleaseLog发射器的emit事件进行了监听。这是因为我们知道当输入正确时,用户应该能够单击提交按钮,因此在formSubmit方法内,将调用newReleaseLog发射器的emit方法。对于涵盖'version'表单控件有效性的测试,我们依赖于formSubmit抛出错误。这是因为我们知道无效的版本将在创建新的发布日志时导致ReleaseLog类的constructor中出错。在这个测试中有一个有趣的地方是我们使用了以下代码:

expect(() => component.formSubmit(component.releaseForm))
      .toThrowError(expectedError);

有趣的是,我们需要自己调用formSubmit方法来调用releaseForm。我们不能只写expect(component.formSubmit(component.releaseForm)).toThrowError(expectedError);,因为那样会直接调用函数并导致错误。所以我们需要在这里传递一个匿名函数,Jasmine 会调用这个匿名函数,并期望这个匿名函数抛出一个错误。最后,我们通过在fixture.nativeElement上使用querySelector来获取按钮,然后使用submitButton.hasAttribute('disabled')来检查提交按钮上的disabled属性,确保我们的提交按钮是启用还是禁用。

另请参阅

使用反弹与响应式表单控件

如果您正在构建一个中到大型规模的 Angular 应用程序,并使用响应式表单,那么您肯定会遇到一种情况,您可能希望在响应式表单上使用反弹。这可能是出于性能原因,或者为了节省 HTTP 调用。因此,在这个示例中,您将学习如何在响应式表单控件上使用反弹。

准备工作

我们要处理的项目位于克隆存储库中的chapter08/start_here/using-debounce-with-rfc中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install以安装项目的依赖项。

  3. 完成后,运行ng serve -o

这将在新的浏览器选项卡中打开应用程序,并且您应该看到如下所示:

图 8.23 - 使用反弹与响应式表单控件应用程序正在 http://localhost:4200 上运行

图 8.23 - 使用反弹与响应式表单控件应用程序正在 http://localhost:4200 上运行

现在,您会注意到每输入一个字符,我们就会向 API 发送一个新的 HTTP 请求,如下所示:

图 8.24 - 在输入时发送的多个 HTTP 调用

图 8.24 - 在输入时发送的多个 HTTP 调用

现在我们的应用程序在本地运行,让我们在下一节中看看这个示例涉及的步骤。

如何做…

对于这个示例,我们使用一个使用 RandomUser.me API 获取用户的应用程序。如图 8.24所示,我们在输入变化时发送新的 HTTP 调用。让我们开始避免这样做的示例:

  1. 将防抖功能添加到表单中非常容易。让我们在home.component.ts文件中使用debounceTime操作符,如下所示:
...
import { debounceTime, takeWhile } from 'rxjs/operators';
@Component({...})
export class HomeComponent implements OnInit, OnDestroy {
  searchDebounceTime = 300;
  ...
  ngOnInit() {
    ...
    this.searchUsers();
    this.searchForm.get('username').valueChanges
      .pipe(
        debounceTime(this.searchDebounceTime),
        takeWhile(() => !!this.componentAlive)
      )
      .subscribe(() => {
        this.searchUsers();
      })
  }
}

嗯,有趣的是,就任务而言,这就是本节的全部内容。但我确实希望能给您带来更多。因此,我们将编写一些有趣的测试。

  1. 现在我们将添加一个测试,以确保在searchDebounceTime过去之前不会调用我们的searchUsers方法。在home.component.spec.ts文件中添加以下测试:
import { HttpClientModule } from '@angular/common/http';
import { waitForAsync, ComponentFixture, discardPeriodicTasks, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { HomeComponent } from './home.component';
describe('HomeComponent', () => {
  ...
  it('should not send an http request before the   debounceTime of 300ms', fakeAsync(async () => {
    spyOn(component, 'searchUsers');
    component.searchForm.get(    'username').setValue('iri');
    tick(component.searchDebounceTime - 10);     // less than desired debounce time
    expect(component.searchUsers     ).not.toHaveBeenCalled();
    discardPeriodicTasks();
  }));
});
  1. 现在我们将为searchDebounceTime过去并且应该已调用searchUsers()方法的情况添加一个测试。在home.component.spec.ts文件中添加以下新测试:
...
describe('HomeComponent', () => {
  ...
  it('should send an http request after the debounceTime   of 300ms', fakeAsync(async () => {
    spyOn(component, 'searchUsers');
    component.searchForm.get(    'username').setValue('iri');
    tick(component.searchDebounceTime + 10); // more     than desired debounce time
    expect(component.searchUsers     ).toHaveBeenCalled();
    discardPeriodicTasks();
  }));
});

如果刷新 Karma 测试 Chrome 窗口,您将看到所有测试都通过了,如下所示:

图 8.25 - 本节所有测试都通过

图 8.25 - 本节所有测试都通过

  1. 现在,运行npm start命令再次启动应用程序。然后,在输入到搜索框时监视网络调用。您会看到debounceTime操作符在您停止输入 300 毫秒后只调用 1 次,如下截图所示:

图 8.26 - 在 300 毫秒防抖后仅发送一个网络调用

图 8.26 - 在 300 毫秒防抖后仅发送一个网络调用

太棒了!现在,您知道如何在响应式表单控件中使用防抖,以及如何编写测试来检查防抖是否正常工作。这就结束了本节。让我们参考下一节,看看它是如何工作的。

工作原理…

本节的主要任务非常简单。我们只是从rxjs包中使用了debounceTime操作符,并将其与我们的响应式表单控件的.valueChanges Observable 一起使用。由于我们在.subscribe()方法之前在.pipe()操作符中使用它,所以每当我们改变输入的值,无论是输入值还是按下退格键,它都会根据searchDebounceTime属性等待300ms,然后调用searchUsers()方法。

我们还在这个食谱中编写了一些测试。请注意,我们对searchUsers()方法进行了间谍,因为每当我们更改'username'表单控件的值时,它就应该被调用。我们将测试函数包装在fakeAsync方法中,这样我们就可以控制测试中用例的异步行为。然后我们使用FormControl.setValue()方法设置表单控件的值,这应该在经过searchDebounceTime的时间后触发作为.subscribe()方法参数提供的方法。然后我们使用tick()方法和searchDebounceTime的值,这样就模拟了时间的异步流逝。然后我们编写我们的expect()块来检查searchUsers()方法是否应该被调用。最后,在测试结束时,我们使用discardPeriodicTasks()方法。我们使用这个方法是为了避免出现Error: 1 periodic timer(s) still in the queue.错误,以及我们的测试工作。

另请参阅

使用 ControlValueAccessor 编写自定义表单控件

Angular 表单很棒。虽然它们支持默认的 HTML 标签,如 input、textarea 等,但有时,您可能希望定义自己的组件,以从用户那里获取值。如果这些输入的变量是您已经在使用的 Angular 表单的一部分,那就太好了。

在这个食谱中,您将学习如何使用 ControlValueAccessor API 创建自己的自定义表单控件,这样您就可以在模板驱动表单和响应式表单中使用表单控件。

准备工作

这个食谱的项目位于chapter08/start_here/custom-form-control中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install以安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该会在新的浏览器标签中打开应用程序,您应该会看到以下视图:

图 8.27 - 自定义表单控件应用程序在 http://localhost:4200 上运行

图 8.27 - 自定义表单控件应用程序在 http://localhost:4200 上运行

现在我们的应用程序在本地运行,让我们在下一节中看看这个食谱涉及的步骤。

如何做…

我们有一个简单的 Angular 应用。它有两个输入和一个提交按钮。输入用于评论,要求用户为这个虚构物品的评分和任何评论提供价值。我们将使用 ControlValueAccessor API 将评分输入转换为自定义表单控件。让我们开始吧:

  1. 让我们为我们的自定义表单控件创建一个组件。在项目根目录中打开终端并运行以下命令:
ng g c components/rating
  1. 现在我们将为评分组件创建星星 UI。修改rating.component.html文件如下:
<div class="rating">
  <div
    class="rating__star"
    [ngClass]="{'rating__star--active': (
      (!isMouseOver && value  >= star) ||
      (isMouseOver && hoveredRating  >= star)
    )}"
    (mouseenter)="onRatingMouseEnter(star)"
    (mouseleave)="onRatingMouseLeave()"
    (click)="selectRating(star)"
    *ngFor="let star of [1, 2, 3, 4, 5]; let i = index;">
    <i class="fa fa-star"></i>
  </div>
</div>
  1. rating.component.scss文件中为评分组件添加样式如下:
.rating {
  display: flex;
  margin-bottom: 10px;
  &__star {
    cursor: pointer;
    color: grey;
    padding: 0 6px;
    &:first-child {
      padding-left: 0;
    }
    &:last-child {
      padding-right: 0;
    }
    &--active {
      color: orange;
    }
  }
}
  1. 我们还需要修改RatingComponent类来引入必要的方法和属性。让我们修改rating.component.ts文件如下:
...
export class RatingComponent implements OnInit {
  value = 2;
  hoveredRating = 2;
  isMouseOver = false;

  ...
  onRatingMouseEnter(rating: number) {
    this.hoveredRating = rating;
    this.isMouseOver = true;
  }
  onRatingMouseLeave() {
    this.hoveredRating = null;
    this.isMouseOver = false;
  }
  selectRating(rating: number) {
    this.value = rating;
  }
}
  1. 现在我们需要在home.component.html文件中使用这个评分组件而不是已有的输入。修改文件如下:
<div class="home">
  <div class="review-container">
    ...
    <form class="input-container" [formGroup]=    "reviewForm" (ngSubmit)="submitReview(reviewForm)">
      <div class="mb-3">
        <label for="ratingInput" class="form-        label">Rating</label>
        <app-rating formControlName="rating">        </app-rating>
      </div>
      <div class="mb-3">
        ...
      </div>
      <button id="submitBtn" [disabled]="reviewForm.      invalid" class="btn btn-dark" type="submit">      Submit</button>
    </form>
  </div>
</div>

如果现在刷新应用并悬停在星星上,你会看到颜色随着悬停而改变。选定的评分也会被突出显示如下:

图 8.28 - 悬停在星星上的评分组件

图 8.28 - 悬停在星星上的评分组件

  1. 现在让我们为我们的评分组件实现ControlValueAccessor接口。它需要实现一些方法,我们将从onChange()onTouched()方法开始。修改rating.component.ts文件如下:
import { Component, OnInit } from '@angular/core';
import { ControlValueAccessor } from '@angular/forms';
@Component({...})
export class RatingComponent implements OnInit, ControlValueAccessor {
  ...
  constructor() { }
  onChange: any = () => { };
  onTouched: any = () => { };
  ngOnInit(): void {
  }
  ...
  registerOnChange(fn: any){
    this.onChange = fn;
  }
  registerOnTouched(fn: any) {
    this.onTouched = fn;
  }
}
  1. 我们现在将添加必要的方法来在需要时禁用输入并设置表单控件的值,换句话说,setDisabledState()writeValue()方法。我们还将在RatingComponent类中添加disabledvalue属性如下:
import { Component, Input, OnInit } from '@angular/core';
import { ControlValueAccessor } from '@angular/forms';
@Component({...})
export class RatingComponent implements OnInit, ControlValueAccessor {
  ...
  isMouseOver = false;
  @Input() disabled = false;
  constructor() { }
  ...
  setDisabledState(isDisabled: boolean): void {
    this.disabled = isDisabled;
  }
  writeValue(value: number) {
    this.value = value;
  }
}
  1. 需要使用disabled属性来防止在其值为true时进行任何 UI 更改。value变量的值也不应该被更新。修改rating.component.ts文件如下:
...
@Component({...})
export class RatingComponent implements OnInit, ControlValueAccessor {
  ...
  isMouseOver = false;
  @Input() disabled = true;
  ...

  onRatingMouseEnter(rating: number) {
    if (this.disabled) return;
    this.hoveredRating = rating;
    this.isMouseOver = true;
  }
  ...
  selectRating(rating: number) {
    if (this.disabled) return;
    this.value = rating;
  }
  ...
}
  1. 让我们确保将value变量的值发送到ControlValueAccessor,因为这是我们以后要访问的内容。同时,让我们将disabled属性设置回false。修改RatingComponent类中的selectRating方法如下:
...
@Component({...})
export class RatingComponent implements OnInit, ControlValueAccessor {
  ...
  @Input() disabled = false;
  constructor() { }
  ...
  selectRating(rating: number) {
    if (this.disabled) return;
    this.value = rating;
    this.onChange(rating);
  }
  ...
}
  1. 我们需要告诉 Angular,我们的RatingComponent类有一个值访问器,否则在<app-rating>元素上使用formControlName属性会抛出错误。让我们向RatingComponent类的装饰器添加一个NG_VALUE_ACCESSOR提供者,如下所示:
import { Component, forwardRef, Input, OnInit } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
  selector: 'app-rating',
  templateUrl: './rating.component.html',
  styleUrls: ['./rating.component.scss'],
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => RatingComponent),
    multi: true
  }]
})
export class RatingComponent implements OnInit, ControlValueAccessor {
  ...
}

如果现在刷新应用程序,选择一个评分,然后点击提交按钮,你应该看到以下值被记录:

图 8.29-使用自定义表单控件记录的表单值

图 8.29-使用自定义表单控件记录的表单值

看吧!你刚刚学会了如何使用ControlValueAccessor创建自定义表单控件。请参考下一节以了解它是如何工作的。

它是如何工作的...

我们通过创建一个组件来开始这个配方,我们可以用它来为我们必须提交的评论提供评分。我们首先添加了评分组件的模板和样式。请注意,我们在每个星元素上都使用了[ngClass]指令,以有条件地添加rating__star--active类。现在让我们讨论每个条件:

  • (isMouseOver && hoveredRating >= star): 这个条件依赖于isMouseOverhoveredRating变量。isMouseOver变量在我们悬停在任何星星上时立即变为true,当我们离开星星时又变回false。这意味着只有在我们悬停在星星上时它才为truehoveredRating告诉我们我们当前悬停在哪颗星星上,并且被赋予星星的值,换句话说,一个从15的值。因此,只有当我们悬停时,且悬停星星的评分大于当前星星的值时,这个条件才为真。因此,如果我们悬停在第四颗星星上,所有值从14的星星都会被高亮显示,因为它们会有rating__star--active类有条件地分配给它们。

  • (!isMouseOver && value >= star): 这个条件依赖于我们之前讨论过的isMouseOver变量和value变量。value变量保存了所选评分的值,在我们点击星星时更新。因此,当我们没有鼠标悬停并且value变量的值大于当前星星时,应用这个条件。当value变量被赋予一个较大的值,并且尝试悬停在一个值较小的星星上时,所有值大于悬停星星的星星都不会被高亮显示,这是特别有益的。

然后我们在每个星星上使用了三个事件:mouseentermouseleaveclick,然后分别使用我们的onRatingMouseEnteronRatingMouseLeaveselectRating方法。所有这些都是为了确保整个 UI 流畅,并具有良好的用户体验。然后我们为我们的评分组件实现了ControlValueAccessor接口。当我们这样做时,我们需要定义onChangeonTouched方法为空方法,我们如下所示:

onChange: any = () => { };
onTouched: any = () => { };

然后我们使用ControlValueAccessor中的registerOnChangeregisterOnTouched方法将我们的方法分配如下:

registerOnChange(fn: any){
  this.onChange = fn;
}
registerOnTouched(fn: any) {
  this.onTouched = fn;
}

我们注册了这些函数,因为每当我们在组件中进行更改并希望让ControlValueAccessor知道值已更改时,我们需要自己调用onChange方法。我们在selectRating方法中这样做,以确保当我们选择评分时,我们将表单控件的值设置为所选评分的值:

selectRating(rating: number) {
  if (this.disabled) return;
  this.value = rating;
  this.onChange(rating);
}

另一种情况是当我们需要知道表单控件的值是从组件外部更改的。在这种情况下,我们需要将更新后的值分配给value变量。我们在ControlValueAccessor接口的writeValue方法中这样做:

writeValue(value: number) {
  this.value = value;
}

如果我们不希望用户为评分提供值怎么办?换句话说,我们希望评分表单控件被禁用。为此,我们做了两件事。首先,我们将disabled属性用作@Input(),这样我们可以在需要时从父组件传递和控制它。其次,我们使用了ControlValueAccessor接口的setDisabledState方法,因此每当表单控件的disabled状态发生变化时,除了@Input()之外,我们自己设置disabled属性。

最后,我们希望 Angular 知道这个RatingComponent类具有值访问器。这样我们就可以使用响应式表单 API,特别是使用<app-rating>选择器的formControlName属性,并将其用作表单控件。为此,我们使用NG_VALUE_ACCESSOR注入令牌将我们的RatingComponent类作为提供者提供给其@Component定义装饰器,如下所示:

@Component({
  selector: 'app-rating',
  templateUrl: './rating.component.html',
  styleUrls: ['./rating.component.scss'],
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => RatingComponent),
    multi: true
  }]
})
export class RatingComponent implements OnInit, ControlValueAccessor {}

请注意,我们在其中使用forwardRef()方法的useExisting属性提供了我们的RatingComponent类。我们需要提供multi: true,因为 Angular 本身使用NG_VALUE_ACCESSOR注入令牌注册一些值访问器,还可能有第三方表单控件。

一旦我们设置好了一切,我们可以在home.component.html文件中如下使用formControlName来使用我们的评分组件:

<app-rating formControlName="rating"></app-rating>

参见

第九章:第九章:Angular 和 Angular CDK

Angular 拥有令人惊叹的工具和库生态系统,无论是 Angular Material、Angular 命令行界面(Angular CLI)还是备受喜爱的 Angular 组件开发工具包(Angular CDK)。我称之为“备受喜爱”,因为如果你要在 Angular 应用中实现自定义交互和行为,而不必依赖整套库,Angular CDK 将成为你的好朋友。在本章中,您将了解 Angular 和 Angular CDK 是多么惊人的组合。您将了解 CDK 内置的一些很棒的组件,并将使用一些 CDK 应用程序编程接口(API)来创建令人惊叹和优化的内容。

以下是本章我们将要涵盖的示例:

  • 使用虚拟滚动处理大型列表

  • 列表的键盘导航

  • 使用覆盖 API 创建尖尖的小弹出窗口

  • 使用 CDK 剪贴板与系统剪贴板一起工作

  • 使用 CDK 拖放功能将项目从一个列表移动到另一个列表

  • 使用 CDK Stepper API 创建多步游戏

  • 使用 CDK 文本字段 API 调整文本输入大小

技术要求

对于本章的示例,请确保您的计算机上已安装 Git 和 Node.js。您还需要安装@angular/cli包,可以在终端中使用npm install -g @angular/cli来安装。本章的代码可以在github.com/PacktPublishing/Angular-Cookbook/tree/master/chapter09找到。

使用虚拟滚动处理大型列表

在您的应用程序中可能会出现某些情况,您可能需要显示大量的项目。这可能来自您的后端 API 或浏览器的本地存储。在任何情况下,一次渲染大量项目会导致性能问题,因为文档对象模型(DOM)会受到影响,还因为 JS 线程被阻塞,页面变得无响应。在这个示例中,我们将渲染一个包含 10,000 个用户的列表,并将使用 Angular CDK 的虚拟滚动功能来提高渲染性能。

准备工作

我们将要处理的项目位于克隆存储库中的chapter09/start_here/using-cdk-virtual-scroll中。请按照以下步骤进行:

  1. 在 Visual Studio Code(VS Code)中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该在新的浏览器标签中打开应用程序,并且应该是这样的:

图 9.1 - 使用 cdk-virtual-scroll 应用程序在 http://localhost:4200 上运行

图 9.1 - 使用 cdk-virtual-scroll 应用程序在 http://localhost:4200 上运行

现在我们的应用程序在本地运行,让我们在下一节中看看食谱的步骤。

如何做…

我们有一个非常简单的 Angular 应用,但有大量数据。现在,它会显示一个加载器(按钮)大约 3 秒钟,然后应该显示数据。然而,您会注意到在 3 秒后,加载器仍然显示,按钮无响应,我们看到一个空白屏幕,如下所示:

图 9.2 - 应用在渲染列表项时卡住空白屏幕

图 9.2 - 应用在渲染列表项时卡住空白屏幕

事实上,我们整个应用程序变得无响应。如果您滚动或者甚至悬停在项目上,您会发现列表项上的悬停动画不够流畅,有点延迟。让我们看看使用 Angular CDK 虚拟滚动来提高渲染性能的步骤,如下所示:

  1. 首先,打开一个新的终端窗口/标签,并确保您在ch8/start_here/using-cdk-virtual-scroll文件夹中。进入后,运行以下命令安装 Angular CDK:
npm install --save @angular/cdk@12.0.0
  1. 您将不得不重新启动您的 Angular 服务器,因此重新运行ng serve命令。

  2. @angular/cdk包中的ScrollingModule类添加到您的app.module.ts文件中,如下所示:

...
import { LoaderComponent } from './components/loader/loader.component';
import { ScrollingModule } from '@angular/cdk/scrolling';
@NgModule({
  declarations: [...],
  imports: [
    ...
    HttpClientModule,
    ScrollingModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
  1. 我们现在必须实现虚拟滚动,修改the-amazing-list-item.component.html文件,使用*cdkVirtualFor指令而不是*ngFor指令,并将容器<div>元素更改为<cdi-virtual-scroll-viewport>元素,如下所示:
<h4 class="heading">Our trusted customers</h4>
<cdk-virtual-scroll-viewport
  class="list list-group"
  [itemSize]="110">
  <div
    class="list__item list-group-item"
    *cdkVirtualFor="let item of listItems">
    <div class="list__item__primary">
      ...
    </div>
    <div class="list__item__secondary">
      ...
    </div>
  </div>
</cdk-virtual-scroll-viewport>

砰!通过几个步骤,并使用 Angular CDK 虚拟滚动,我们能够解决 Angular 应用中的一个重大渲染问题。现在您知道基本路由是如何实现的,请参阅下一节以了解其工作原理。

它是如何工作的…

Angular CDK 提供了滚动 API,其中包括*cdkVirtualFor指令和<cdk-virtual-scroll-viewport>元素。必须将<cdk-virtual-scroll-viewport>包装在具有*cdkVirtualFor指令的元素周围。请注意,cdk-virtual-scroll-viewport元素上有一个名为[itemSize]的属性,其值设置为"110"。原因是每个列表项的高度大约为 110 像素,如下截图所示:

图 9.3 - 每个列表项的高度大约为 110 像素

图 9.3 - 每个列表项的高度大约为 110 像素

但是它如何提高渲染性能呢?很高兴你问!在这个示例的原始代码中,当我们加载了 10,000 个用户时,它会为每个用户创建一个带有class="list__item list-group-item"属性的单独的<div>元素,从而一次创建 10,000 个 DOM 元素。有了虚拟滚动,CDK 只会创建一些<div>元素,呈现它们,并在我们滚动项目时只是替换这些少数<div>元素的内容。

对于我们的示例,它创建了确切的九个<div>元素,如下截图所示:

图 9.4 - 由于虚拟滚动,仅显示了一些在 DOM 上呈现的元素

图 9.4 - 由于虚拟滚动,仅显示了一些在 DOM 上呈现的

元素

由于 DOM 上只呈现了一些元素,我们不再有性能问题,悬停动画现在也非常流畅。

提示

在您自己的应用程序中实现虚拟滚动时,请确保为<cdk-virtual-scroll viewport>元素设置特定的高度,并将[itemSize]属性设置为预期的列表项高度(以像素为单位),否则列表将不会显示。

另请参阅

列表的键盘导航

无障碍性是构建具有良好用户体验的应用程序最重要的方面之一。应用程序不仅应该快速和高性能,还应该具有可访问性。虽然在考虑无障碍性时有很多事情要考虑,但在这个食谱中,我们将通过为项目提供键盘导航来使列表和列表项更具可访问性。使用 Angular CDK,这非常简单。我们将使用 Angular 中的ListKeyManager服务来为目标应用程序中的用户列表实现键盘导航。

做好准备。

这个食谱的项目位于chapter09/start_here/using-list-key-manager。请按照以下步骤进行:

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该会在新的浏览器选项卡中打开应用程序,如下所示:

图 9.5 - 在 http://localhost:4200 上运行的 using-list-key-manager 应用程序

图 9.5 - 在 http://localhost:4200 上运行的 using-list-key-manager 应用程序

现在我们的应用程序在本地运行,让我们在下一节中看看食谱的步骤。

操作步骤:

我们的应用程序已经具有了一些 Angular CDK 的优点,即它已经从上一个食谱中实现了虚拟滚动。现在我们将开始对应用程序进行更改,以实现键盘导航,如下所示:

  1. 首先,我们需要为列表中的每个项目创建一个新组件,因为我们需要它们能够与ListKeyManager类一起工作。通过在项目中运行以下命令来创建一个组件:
ng g c components/the-amazing-list-item
  1. 现在,我们将把the-amazing-list-component.html文件中的代码移动到the-amazing-list-item.component.html文件中,用于项目的标记。the-amazing-list-item.component.html文件中的代码应该如下所示:
  <div class="list__item__primary">
    <div class="list__item__primary__info">
      {{ item.name }}
    </div>
    <div class="list__item__primary__info">
      {{ item.phone }}
    </div>
  </div>
  <div class="list__item__secondary">
    <div class="list__item__secondary__info">
      <img src="{{ item.picture }}" />
    </div>
    <div class="list__item__secondary__info">
      {{ item.email }}
    </div>
  </div>
  1. 让我们也更新相应的组件,以包括模板中使用的item属性。我们将把它作为@Input()项添加到TheAmazingListItemComponent类中。更新the-amazing-list-item.component.ts文件如下:
import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core';
import { AppUserCard } from 'src/interfaces/app-user-card.interface';
@Component({
  selector: 'app-the-amazing-list-item',
  templateUrl: './the-amazing-list-item.component.html',
  styleUrls: ['./the-amazing-list-item.component.scss'],
  encapsulation: ViewEncapsulation.None
})
export class TheAmazingListItemComponent implements OnInit {
  @Input() item: Partial<AppUserCard>;
  constructor() { }
  ngOnInit(): void {
  }
}
  1. 让我们也添加样式。我们将从the-amazing-list.component.scss文件中复制样式,并粘贴到the-amazing-list-item.component.scss文件中,如下所示:
.list__item {
  transition: all ease 1s;
  cursor: pointer;
  &:hover, &:focus {
    background-color: #ececec; transform: scale(1.02);
  }
  &__primary,
  &__secondary {
    display: flex;
    justify-content: space-between;
    align-items: center;
    &__info { font-size: small; }
  }
  &__primary {
    &__info {
      &:nth-child(1) { font-weight: bold; font-size:       larger; }
    }
  }
  img { border-radius: 50%; width: 60px; height: 60px; }
}
  1. 更新the-amazing-list.component.scss文件,只包含列表的样式,如下所示:
.heading {
  text-align: center;
  margin-bottom: 10px;
}
.list {
  box-shadow: rgba(0, 0, 0, 0.24) 0px 3px 8px;
  height: 500px;
  overflow: scroll;
  min-width: 400px;
  max-width: 960px;
  width: 100%;
}
  1. 现在,更新the-amazing-list.component.html文件,使用<app-the-amazing-list-item>组件,并将[item]属性传递给它,如下所示:
<h4 class="heading">Our trusted customers</h4>
<cdk-virtual-scroll-viewport
  class="list list-group"
  [itemSize]="110">
  <app-the-amazing-list-item
    class="list__item list-group-item"
    *cdkVirtualFor="let item of listItems"
    [item]="item">
  </app-the-amazing-list-item>
</cdk-virtual-scroll-viewport>
  1. 用户界面UI)现在几乎完成了。我们现在将实现FocusableOption接口和一些辅助功能到我们的TheAmazingListItemComponent类,如下所示:
import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core';
import { AppUserCard } from 'src/interfaces/app-user-card.interface';
import { FocusableOption } from '@angular/cdk/a11y';
@Component({
  selector: 'app-the-amazing-list-item',
  templateUrl: './the-amazing-list-item.component.html',
  styleUrls: ['./the-amazing-list-item.component.scss'],
  encapsulation: ViewEncapsulation.None,
  host: {
    tabindex: '-1',
    role: 'list-item',
  },
})
export class TheAmazingListItemComponent implements OnInit, FocusableOption {
  @Input() item: Partial<AppUserCard>;
  constructor() { }
  focus() { }
  ngOnInit(): void {
  }
}
  1. 现在,我们需要实现focus()方法中发生的事情。我们将使用ElementRef服务来获取nativeElement,并将focus()设置为它,如下所示:
import { Component, ElementRef, Input, OnInit, ViewEncapsulation } from '@angular/core';
import { AppUserCard } from 'src/interfaces/app-user-card.interface';
import { FocusableOption } from '@angular/cdk/a11y';
@Component({...})
export class TheAmazingListItemComponent implements OnInit, FocusableOption {
  @Input() item: Partial<AppUserCard>;
  constructor(private el: ElementRef) { }
  focus() {
    this.el.nativeElement.focus();
  }
  ...
}
  1. 现在我们需要在我们的TheAmazingListComponent类中实现FocusKeyManager类。我们将不得不在组件中查询我们的列表项,以创建FocusKeyManager类的实例。更新the-amazing-list.component.ts文件,如下所示:
import { FocusKeyManager } from '@angular/cdk/a11y';
import { AfterViewInit, Component, Input, OnInit, QueryList, ViewChildren } from '@angular/core';
import { AppUserCard } from 'src/interfaces/app-user-card.interface';
import { TheAmazingListItemComponent } from '../the-amazing-list-item/the-amazing-list-item.component';
@Component({
  ...
  styleUrls: ['./the-amazing-list.component.scss'],
  host: { role: 'list' }
})
export class TheAmazingListComponent implements OnInit, AfterViewInit {
  @Input() listItems: Partial<AppUserCard>[] = [];
  @ViewChildren(TheAmazingListItemComponent)   listItemsElements: QueryList   <TheAmazingListItemComponent>;
  private listKeyManager:   FocusKeyManager<TheAmazingListItemComponent>;
  constructor() { }
  ...
  ngAfterViewInit() {
    this.listKeyManager = new FocusKeyManager(
      this.listItemsElements
    );
  }
}
  1. 最后,我们需要监听键盘事件。为此,您可以使用keydown事件或window:keydown事件。为了简化示例,我们将使用window:keydown事件,如下所示:
import { FocusKeyManager } from '@angular/cdk/a11y';
import { AfterViewInit, Component, HostListener, Input, OnInit, QueryList, ViewChildren } from '@angular/core';
...
@Component({...})
export class TheAmazingListComponent implements OnInit, AfterViewInit {
  ...
  @HostListener('window:keydown', ['$event'])
  onKeydown(event) {
    this.listKeyManager.onKeydown(event);
  }
  constructor() { }
  ...
}

太棒了!您刚刚学会了如何使用 Angular CDK 实现键盘导航。请查看下一节以了解其工作原理。

它是如何工作的...

Angular CDK 提供了ListKeyManager类,允许您实现键盘导航。我们可以使用ListKeyManager类的一堆技术,对于这个特定的示例,我们选择了FocusKeyManager类。为了使其适用于项目列表,我们需要做以下事情:

  1. 为列表中的每个项目创建一个组件。

  2. 在列表组件中使用ViewChildren()QueryList查询所有列表项组件。

  3. 在列表组件中创建一个FocusKeyManager实例,提供列表项组件的类型。

  4. 为列表组件添加键盘监听器,并将事件传递给FocusKeyManager类的实例。

当我们在TheAmazingListComponent类中定义listKeyManager属性时,我们还通过将其指定为FocusKeyManager<TheAmazingListItemComponent>来定义其类型。这样更容易理解我们的FocusKeyManager类应该与TheAmazingListItemComponent元素数组一起工作。因此,在ngAfterViewInit()方法中,我们指定this.listKeyManager = new FocusKeyManager(this.listItemsElements);,这提供了一个查询到的TheAmazingListItemComponent元素列表。

最后,当我们监听 window:keydown 事件时,我们将在处理程序中接收到的 keydown 事件提供给我们的 FocusKeyManager 类的实例作为 this.listKeyManager.onKeydown(event);。这告诉我们的 FocusKeyManager 实例哪个键被按下以及它必须做什么。

请注意,我们的 TheAmazingListItemComponent 类实现了 FocusableOption 接口,并且它还有 focus() 方法,当我们按下键盘的向下箭头或向上箭头键时,FocusKeyManager 类在幕后使用它。

另请参阅

使用 Overlay API 创建尖尖的小弹出窗口

这是本书中的高级食谱之一,特别是对于那些已经使用 Angular 一段时间的人来说。在这个食谱中,我们不仅将使用 CDK Overlay API 创建一些弹出窗口,还将使它们变得尖尖,就像工具提示一样,这就是乐趣所在。

准备工作

此食谱的项目位于 chapter09/start_here/pointy-little-popovers。请按照以下步骤进行:

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行 npm install 来安装项目的依赖项。

  3. 完成后,运行 ng serve -o

这应该在新的浏览器选项卡中打开应用程序,如下所示:

图 9.6 - pointy-little-popovers 应用程序在 http://localhost:4200 上运行

图 9.6 - pointy-little-popovers 应用程序在 http://localhost:4200 上运行

现在我们已经在本地运行了应用程序,让我们在下一节中看一下食谱的步骤。

如何做…

我们的应用程序有一个用户列表,我们可以在页面上滚动查看。我们将为每个项目添加一个弹出菜单,以便显示带有一些操作的下拉菜单。我们已经安装了 @angular/cdk 包,所以我们不需要担心。让我们按照以下食谱开始:

  1. 首先,我们需要安装 @angular/cdk,因为我们需要将 OverlayModule 类导入到我们的 AppModule 类中,以便我们可以使用 Overlay API。更新 app.module.ts 文件,如下所示:
...
import { TheAmazingListItemComponent } from './components/the-amazing-list-item/the-amazing-list-item.component';
import { OverlayModule } from '@angular/cdk/overlay';

@NgModule({
  declarations: [...],
  imports: [
    ...
    ScrollingModule,
    OverlayModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
  1. 我们首先添加 Overlay 的默认样式,以便在显示覆盖层时,它能正确定位。打开 src/styles.scss 文件,并按照以下要点进行更新:

gist.github.com/AhsanAyaz/b039814e898b3ebe471b13880c7b4270

  1. 现在,我们将创建变量来保存覆盖层触发器(用于打开覆盖层的位置起点)和实际相对位置的设置。打开the-amazing-list.component.ts文件并进行更新,如下所示:
import { FocusKeyManager } from '@angular/cdk/a11y';
import { CdkOverlayOrigin } from '@angular/cdk/overlay';
...
@Component({...})
export class TheAmazingListComponent implements OnInit, AfterViewInit {
  @Input() listItems: Partial<AppUserCard>[] = [];
  @ViewChildren(TheAmazingListItemComponent)   listItemsElements: QueryList   <TheAmazingListItemComponent>;
  popoverMenuTrigger: CdkOverlayOrigin;
  menuPositions = [
    { offsetY: 4, originX: 'end', originY: 'bottom',     overlayX: 'end', overlayY: 'top' },
    { offsetY: -4, originX: 'end', originY: 'top',     overlayX: 'end', overlayY: 'bottom' },
  ];
  private listKeyManager: FocusKeyManager   <TheAmazingListItemComponent>;
  ...
}
  1. 现在,打开the-amazing-list.component.html文件,并将cdkOverlayOrigin指令添加到<app-the-amazing-list-item>选择器中,以便我们可以将每个列表项作为弹出菜单的起点,如下所示:
<h4 class="heading">Our trusted customers</h4>
<cdk-virtual-scroll-viewport
  class="list list-group"
  [itemSize]="110">
  <app-the-amazing-list-item
    cdkOverlayOrigin #itemTrigger="cdkOverlayOrigin"
    class="list__item list-group-item"
    *cdkVirtualFor="let item of listItems"
    [item]="item">
  </app-the-amazing-list-item>
</cdk-virtual-scroll-viewport>
  1. 我们需要以某种方式将模板中的#itemTrigger变量传递到TheAmazingListComponent类中的popoverMenuTrigger属性上。为此,在the-amazing-list.component.ts文件中创建一个名为openMenu()的方法,如下所示:
...
@Component({...})
export class TheAmazingListComponent implements OnInit, AfterViewInit {
  ...
  ngOnInit(): void {
  }
  openMenu($event, itemTrigger) {
    if ($event) {
      $event.stopImmediatePropagation();
    }
    this.popoverMenuTrigger = itemTrigger;
  }
  ...
}
  1. 我们还需要一个属性来显示/隐藏弹出菜单。让我们在openMenu()方法中创建它,并将其设置为true。更新the-amazing-list.component.ts文件,如下所示:
...
@Component({...})
export class TheAmazingListComponent implements OnInit, AfterViewInit {
  ...
  popoverMenuTrigger: CdkOverlayOrigin;
  menuShown = false;
  ...
  openMenu($event, itemTrigger) {
    if ($event) {
      $event.stopImmediatePropagation();
    }
    this.popoverMenuTrigger = itemTrigger;
    this.menuShown = true;
  }
  ...
}
  1. 现在,我们将创建一个实际的覆盖层。为此,我们将创建一个带有cdkConnectedOverlay指令的<ng-template>元素。修改您的the-amazing-list.component.html文件,如下所示:
<h4 class="heading">Our trusted customers</h4>
<cdk-virtual-scroll-viewport>
  ...
</cdk-virtual-scroll-viewport>
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="popoverMenuTrigger"
  [cdkConnectedOverlayOpen]="menuShown"   [cdkConnectedOverlayHasBackdrop]="true"
  (backdropClick)="menuShown = false"
  [cdkConnectedOverlayPositions]="menuPositions"
  cdkConnectedOverlayPanelClass="menu-popover"
  >
  <div class="menu-popover__list">
    <div class="menu-popover__list__item">
      Duplicate
    </div>
    <div class="menu-popover__list__item">
      Edit
    </div>
    <div class="menu-popover__list__item">
      Delete
    </div>
  </div>
</ng-template>
  1. 我们需要在单击列表项时将每个列表项上的#itemTrigger变量传递给openMenu()方法。更新文件,如下所示:
<h4 class="heading">Our trusted customers</h4>
<cdk-virtual-scroll-viewport
  class="list list-group"
  [itemSize]="110">
  <app-the-amazing-list-item
    class="list__item list-group-item"
    *cdkVirtualFor="let item of listItems"
    (click)="openMenu($event, itemTrigger)"
    cdkOverlayOrigin #itemTrigger="cdkOverlayOrigin"
    [item]="item">
  </app-the-amazing-list-item>
</cdk-virtual-scroll-viewport>
<ng-template>
  ...
</ng-template>
  1. 如果现在刷新应用程序并单击任何列表项,您应该看到显示一个下拉菜单,如下所示:图 9.7 - 每个列表项的工作下拉菜单

图 9.7 - 每个列表项的工作下拉菜单

  1. 现在,我们需要实现一个部分,其中我们显示一个带有下拉菜单的尖小箭头,以便我们可以将下拉菜单与列表项相关联。首先,在src/styles.scss文件的.popover-menu类中添加以下样式:
...
.menu-popover {
  min-width: 150px;
  height: auto;
  border: 1px solid white;
  border-radius: 8px;
  &::before {
    top: -10px;
    border-width: 0px 10px 10px 10px;
    border-color: transparent transparent white     transparent;
    position: absolute;
    content: '';
    right: 5%;
    border-style: solid;
  }
  &__list {...}
}

现在,您应该能够在下拉菜单的右上方看到一个尖箭头,但是如果您尝试点击屏幕上的最后一个项目,您会发现下拉菜单向上打开,但仍然显示在顶部的指针,如下所示:

图 9.8 - 指向错误列表项的下拉箭头

图 9.8 - 指向错误列表项的下拉箭头

  1. 为了指向弹出菜单/下拉菜单的实际起点,我们需要实现一个应用自定义类的自定义指令。让我们首先创建一个指令,如下所示:
ng g directive directives/popover-positional-class
  1. 根据以下要点更新popover-positional-class.directive.ts生成的文件中的代码:

gist.github.com/AhsanAyaz/f28893e90b71cc03812287016192d294

  1. 现在,打开the-amazing-list.component.html文件,将我们的指令应用到cdkConnectedOverlay指令上。更新文件中的<ng-template>元素如下:
...
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="popoverMenuTrigger"
  [cdkConnectedOverlayOpen]="menuShown"   [cdkConnectedOverlayHasBackdrop]="true"
  (backdropClick)="menuShown = false"   [cdkConnectedOverlayPositions]="menuPositions"
  appPopoverPositionalClass targetSelector=  ".menu-popover" inverseClass="menu-popover--up"
  [originY]="menuPopoverOrigin.originY"   (positionChange)="popoverPositionChanged($event,   menuPopoverOrigin)"
  cdkConnectedOverlayPanelClass="menu-popover"
  >
  <div class="menu-popover__list">
    ...
  </div>
</ng-template>
  1. 现在,我们需要在the-amazing-list.component.ts文件中创建一个menuPopoverOrigin属性和一个popoverPositionChanged()方法。更新如下:
...
import { AfterViewInit, ChangeDetectorRef, Component, HostListener, Input, OnInit, QueryList, ViewChildren } from '@angular/core';
...
@Component({...})
export class TheAmazingListComponent implements OnInit, AfterViewInit {
  ...
  menuPositions = [...];
  menuPopoverOrigin = {
    originY: null
  }
  ...
  constructor(private cdRef: ChangeDetectorRef) { }
  popoverPositionChanged($event, popover) {
    if (popover.originY !== $event.connectionPair.    originY) {
      popover.originY = $event.connectionPair.originY;
    }
    this.cdRef.detectChanges();
  }
  ...
}
  1. 最后,让我们使用这个反转类来反转弹出指针。更新src/styles.scss文件以添加以下样式:
...
.menu-popover {
  ...
  &::before {...}
  &--up {
    transform: translateY(-20px);
    &::before {
      top: unset !important;
      transform: rotate(180deg);
      bottom: -10px;
    }
  }
  &__list {...}
}

现在,刷新页面并点击每个列表项,你会看到箭头指向正确的方向。查看下面的截图,查看由于弹出框显示在项目上方,箭头指向最后一个项目的下方:

图 9.9 - 下拉箭头指向正确的列表项(向下指)

图 9.9 - 下拉箭头指向正确的列表项(向下指)

太棒了!现在你知道如何使用 Angular CDK 来处理叠加层,创建自定义弹出/下拉菜单。此外,你现在知道如何快速实现菜单上的尖箭头,使用自定义指令。查看下一节,了解它是如何工作的。

它是如何工作的...

使用 Angular CDK Overlay API 实现叠加层包括一些要处理的部分。首先,我们必须在AppModule的 imports 中导入OverlayModule类。然后,为了创建一个叠加层,我们需要有一个叠加层和一个叠加触发器。在这个示例中,因为我们使用叠加层为每个列表项创建一个弹出菜单,我们在<app-the-amazing-list-item>元素上使用cdkOverlayOrigin指令。注意,<app-the-amazing-list-item>元素是通过*ngFor指令渲染的。因此,为了知道点击了哪个项目或者准确地说我们需要为哪个项目显示弹出框,我们在每个列表项元素上创建一个#itemTrigger模板变量,并且你会注意到我们还将(click)事件绑定到列表项上,调用openMenu()方法,并将这个itemTrigger模板变量传递给它。

现在,如果你注意到the-amazing-list.component.ts文件中的openMenu()方法,它看起来像这样:

openMenu($event, itemTrigger) {
    if ($event) {
      $event.stopImmediatePropagation();
    }
    this.popoverMenuTrigger = itemTrigger;
    this.menuShown = true;
  }

请注意,我们将itemTrigger属性分配给我们类的popoverMenuTrigger属性。这是因为这个popoverMenuTrigger属性与我们模板中的实际覆盖层绑定。您还可以看到我们将menuShown属性设置为true,这是因为它将决定覆盖层是应该显示还是隐藏。

现在,让我们看一下实际覆盖层的代码,如下所示:

<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="popoverMenuTrigger"
  [cdkConnectedOverlayOpen]="menuShown"   [cdkConnectedOverlayHasBackdrop]="true"
  (backdropClick)="menuShown = false"   [cdkConnectedOverlayPositions]="menuPositions"
  appPopoverPositionalClass targetSelector=".menu-popover"   inverseClass="menu-popover--up"
  [originY]="menuPopoverOrigin.originY"   (positionChange)="popoverPositionChanged($event, menuPopoverOrigin)"
  cdkConnectedOverlayPanelClass="menu-popover"
  >
  ...
</ng-template>

让我们逐个讨论cdkConnectedOverlay指令的每个属性:

  • cdkConnectedOverlay属性:这是实际的覆盖层指令,使<ng-template>元素成为 Angular CDK 覆盖层。

  • [cdkConnectedOverlayOrigin]属性:这告诉覆盖层 API 这个覆盖层的起点是什么。这是为了帮助 CDK 决定打开时覆盖层的位置。

  • [cdkConnectedOverlayOpen]属性:这决定了覆盖层是否应该显示或隐藏。

  • [cdkConnectedOverlayHasBackdrop]属性:这决定了覆盖层是否应该有背景或者没有背景,也就是说,如果有背景,用户在打开时就不能点击覆盖层以外的任何东西。

  • (backdropClick)属性:这是当我们点击背景时的事件处理程序。在这种情况下,我们将menuShown属性设置为false,这会隐藏/关闭覆盖层。

  • [cdkConnectedOverlayPositions]属性:这为覆盖层 API 提供了定位配置。它是一个首选位置的数组,定义了覆盖层是否应该显示在起点下方,起点上方,左侧,右侧,离起点多远等等。

  • [cdkConnectedOverlayPanelClass]属性:要应用于生成的覆盖层的层叠样式表CSS)类。这用于样式设置。

所有属性设置正确后,我们可以在点击列表项时看到覆盖层的工作。 “但是,阿赫桑,尖箭头呢?” 好吧,等一下!我们也会讨论它们。

因此,Angular CDK 覆盖层 API 已经涵盖了许多内容,包括根据可用空间确定覆盖层的位置,由于我们想要显示尖箭头,我们将不得不分析覆盖层是在项目上方还是在项目下方。默认情况下,我们在src/styles.scss文件中设置了以下样式以在弹出框下方显示尖箭头:

.menu-popover {
  ...
  &::before {
    top: -10px;
    border-width: 0px 10px 10px 10px;
    border-color: transparent transparent white  transparent;
    position: absolute;
    content: '';
    right: 5%;
    border-style: solid;
  }
  &--up {...}
  &__list {...}
}

然后,我们有--up修饰符类,如下所示,以在弹出框上方显示覆盖层:

.menu-popover {
  ...
  &::before {...}
  &--up {
    transform: translateY(-20px);
    &::before {
      top: unset !important;
      transform: rotate(180deg);
      bottom: -10px;
    }
  }
  &__list {...}
}

请注意在前面的代码片段中,我们将箭头旋转到180deg以倒转其指针。

现在,让我们谈谈这个--up修饰符类是如何以及何时应用的。我们创建了一个名为appPopoverPositionalClass的自定义指令。这个指令也适用于我们为覆盖创建的<ng-template>元素,也就是说,这个指令与cdkConnectedOverlay指令一起应用,并期望以下输入属性:

  • appPopoverPositionalClass属性:实际的指令选择器。

  • targetSelector属性:由 Angular CDK 覆盖 API 生成的元素的查询选择器。理想情况下,这应该与我们在cdkConnectedOverlayPanelClass中使用的相同。

  • inverseClass属性:当覆盖的垂直位置(originY)发生变化时(即从"top""bottom",反之亦然)应用的类。

  • originY属性:覆盖此刻的originY位置。该值要么是"top",要么是"bottom",取决于覆盖的位置。

我们在 CDK 覆盖<ng-template>元素上有一个(positionChange)监听器,一旦覆盖位置发生变化,就会触发popoverPositionChanged()方法。请注意,在popoverPositionChanged()方法内,一旦获得新的位置,我们会更新popover.originY属性,该属性正在更新menuPopoverOrigin.originY,然后我们还将menuPopoverOrigin.originY作为[originY]属性传递给我们的appPopoverPositionalClass指令。因为我们将其传递给指令,所以指令知道覆盖位置在任何特定时间是"top"还是"bottom"。为什么?因为我们在指令中使用ngOnChanges生命周期钩子来监听originY属性/输入,一旦我们获得originY的不同值,我们要么根据originY属性的值向覆盖元素添加inverseClass的值作为 CSS 类,要么根据originY属性的值将其删除。此外,根据应用的 CSS 类,决定了覆盖的气泡箭头的方向。

另请参阅

使用 CDK 剪贴板与系统剪贴板一起工作

您可能随着时间访问了数百个网站,您可能已经在其中一些网站上看到了一个名为“点击复制”的功能。当您需要复制长文本或链接时,通常会使用此功能,您会发现仅需点击即可复制,而无需选择然后按键盘快捷键会更方便。在本教程中,我们将学习如何使用 Angular CDK 剪贴板 API 将文本复制到剪贴板。

准备工作

本教程的项目位于chapter09/start_here/using-cdk-clipboard-api。请按照以下步骤进行:

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行npm install以安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该会在新的浏览器选项卡中打开应用程序,如下所示:

图 9.10 - 使用-cdk-clipboard-api 在 http://localhost:4200 上运行

图 9.10 - 使用-cdk-clipboard-api 在 http://localhost:4200 上运行

现在我们的应用程序在本地运行,让我们在下一节中看看本教程的步骤。

如何做…

我们现在有一个应用程序,其中有一些不起作用的选项,即我们应该能够复制链接、文本区域中的文本和图像。为此,我们将使用 CDK 剪贴板 API。让我们开始吧。

  1. 首先,我们需要将ClipboardModule类导入到我们的AppModule类的imports数组中。修改app.module.ts文件,如下所示:
...
import { ClipboardModule } from '@angular/cdk/clipboard';
@NgModule({
  declarations: [...],
  imports: [
    BrowserModule,
    AppRoutingModule,
    ClipboardModule
  ],
  ...
})
export class AppModule { }
  1. 现在,我们将click-to-copy功能应用于链接。为此,我们将在app.component.html文件中的链接输入上使用cdkCopyToClipboard指令,并将其应用如下:
...
<div class="content" role="main">
  <div class="content__container">
    <div class="content__container__copy-from">
      <h3>Copy From</h3>
      <div class="mb-3 content__container__copy-from__      input-group">
        <input
          #linkInput
          [cdkCopyToClipboard]="linkInput.value"
          (click)="copyContent($event, contentTypes.          Link)"
          class="form-control"
          type="text" readonly="true"
          value="...">
        <div class="hint">...</div>
      </div>
      ...
  </div>
</div>

如果您现在点击链接输入,然后尝试将其粘贴到任何地方(应用内或应用外),您应该看到链接的值。

  1. 现在,我们将对文本输入(即<textarea>)做类似的事情。再次更新模板,如下所示:
...
<div class="content" role="main">
  <div class="content__container">
    <div class="content__container__copy-from">
      <h3>Copy From</h3>
      ...
      <div class="mb-3 content__container__copy-from__      input-group">
        <textarea
          #textInput
          class="form-control"
          rows="5">{{loremIpsumText}}</textarea>
        <button
          [cdkCopyToClipboard]="textInput.value"
          (click)="copyContent($event, contentTypes.          Text)"
          class="btn btn-dark">
          {{ contentCopied === contentTypes.Text ?           'Text copied' : 'Copy text to clipboard'}}
        </button>
      </div>
      ...
  </div>
</div>
  1. 最后,我们将对图像做一些不同的事情。由于 CDK 剪贴板 API 只能处理字符串,我们将下载图像,将其转换为 blob,并复制 blob 统一资源定位符(URL)。让我们首先更新模板的逻辑,如下所示:
...
<div class="content" role="main">
  <div class="content__container">
    <div class="content__container__copy-from">
      <h3>Copy From</h3>
      ...
      <div class="mb-3 content__container__copy-from__      input-group">
        <img src="assets/landscape.jpg">
        <button
          (click)="copyImageUrl(imageUrl);           copyContent($event, contentTypes.Image)"
          class="btn btn-dark">
            ...
        </button>
      </div>
    </div>
    ...
  </div>
</div>
  1. 现在,让我们实现copyImageUrl()方法来获取图像,将其转换为 blob,并将 URL 复制到剪贴板。更新app.component.ts文件,如下所示:
import { Clipboard } from '@angular/cdk/clipboard';
import { Component, HostListener, OnInit } from '@angular/core';
...
@Component({...})
export class AppComponent implements OnInit {
  ...
  constructor(private clipboard: Clipboard) {
    this.resetCopiedHash();
  }
  async copyImageUrl(srcImageUrl) {
    const data = await fetch(srcImageUrl);
    const blob = await data.blob();
    this.clipboard.copy(URL.createObjectURL(blob));
  }
  ...
}

太棒了!有了这个改变,你可以尝试刷新应用程序。现在,你应该能够通过点击输入链接和按钮分别复制链接、文本和图片。要了解这个教程背后的所有魔力,请参阅下一节。

它是如何工作的...

在这个教程中,我们从 CDK 剪贴板 API 中使用了两个主要的东西——一个是cdkCopyToClipboard指令,另一个是Clipboard服务。cdkCopyToClipboard指令将一个点击处理程序绑定到应用了该指令的元素上。它既作为指令的选择器,又作为指令的@Input()项,以便它知道在点击元素时要复制到剪贴板的值是什么。在我们的教程中,对于链接输入,请注意我们使用了[cdkCopyToClipboard]="linkInput.value"。这将一个点击处理程序绑定到<input>元素,并绑定了linkInput模板变量的value属性,该属性指向要复制的实际链接的输入值。当我们点击输入时,它使用linkInput.value绑定来访问输入的值,对于<text-area>输入也是一样。唯一的区别是cdkCopyToClipboard指令没有绑定到<text-area>元素本身。原因是我们希望将点击处理程序绑定到文本区域下面的按钮上。因此,在复制文本的按钮上,我们有[cdkCopyToClipboard]="textInput.value"绑定。

对于图片,我们做了一些不同的事情。我们使用了@angular/cdk/clipboard包中的Clipboard服务来手动复制 blob URL。我们创建了一个名为copyImageUrl()的方法,当点击复制图片的按钮时调用该方法。我们将imageUrl属性传递给这个方法,然后下载图片,将其读取为 blob,并生成 blob URL,最后使用Clipboard服务的copy()方法将其复制到剪贴板。

另请参阅

使用 CDK 拖放将项目从一个列表移动到另一个列表

你是否曾经使用过 Trello 板应用,或者其他允许你将列表项从一个列表拖放到另一个列表的应用?好吧,你可以很容易地使用 Angular CDK 来做到这一点,在这个教程中,你将学习关于 Angular CDK 拖放 API,以将项目从一个列表移动到另一个列表。你还将学习如何重新排序列表。

准备工作

我们要处理的项目位于克隆存储库中的 chapter09/start_here/using-cdk-drag-drop 中。请按照以下步骤进行:

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行 npm install 安装项目的依赖项。

  3. 完成后,运行 ng serve -o

这应该在新的浏览器标签中打开应用程序,并且应该看起来像这样:

图 9.11 - 使用 cdk 拖放的应用程序在 http://localhost:4200 上运行

图 9.11 - 使用 cdk 拖放的应用程序在 http://localhost:4200 上运行

现在我们的应用程序在本地运行,让我们在下一节中看一下配方的步骤。

如何做…

对于这个配方,我们有一个有趣的应用程序,其中有一些文件夹和文件。我们将为文件实现拖放功能,以便将文件拖到其他文件夹中,这应该会立即更新文件夹的文件计数,并且我们还应该能够在新文件夹中看到文件。让我们开始吧。

  1. 首先,我们需要将 DragDropModule 类导入到 AppModule 类的 imports 数组中。修改 app.module.ts 文件如下:
...
import {DragDropModule} from '@angular/cdk/drag-drop';
@NgModule({
  declarations: [...],
  imports: [
    BrowserModule,
    AppRoutingModule,
    FontAwesomeModule,
    DragDropModule
  ],
  ...
})
export class AppModule { }
  1. 现在,我们将对每个文件应用 cdkDrag 指令,并将对每个文件夹应用 cdkDropList 指令。更新 folders-list.component.html 文件如下:
<div class="folders">
  ...
  <div class="folders__list">
    <app-folder
      cdkDropList
      ...
      [folder]="folder"
    >
    </app-folder>
  </div>
  <div class="folders__selected-folder-files"   *ngIf="selectedFolder">
    <div>
      <app-file
        cdkDrag
        *ngFor="let file of selectedFolder.files"
        [file]="file"
      ></app-file>
    </div>
  </div>
</div>
  1. 我们还将通过在文件的容器元素上添加 cdkDropList 指令来启用文件夹内文件的重新排序,如下所示:
<div class="folders">
  ...
  <div class="folders__selected-folder-files"   *ngIf="selectedFolder">
    <div cdkDropList>
      <app-file ...></app-file>
    </div>
  </div>
</div>
  1. 现在,我们将通过在每个 <app-file> 元素上指定 [cdkDragData] 属性以及在每个 <app-folder> 元素上指定 [cdkDropListData] 属性,并且还在文件容器上指定该属性来定义拖放交互的起源。再次更新模板如下:
<div class="folders">
  ...
  <div class="folders__list">
    <app-folder
      cdkDropList
      [cdkDropListData]="folder.files"
      ...
    >
    </app-folder>
  </div>
  <div class="folders__selected-folder-files"   *ngIf="selectedFolder">
    <div
      cdkDropList
      [cdkDropListData]="selectedFolder.files"
    >
      <app-file
        cdkDrag
        [cdkDragData]="file"
        ...
      ></app-file>
    </div>
  </div>
</div>
  1. 现在我们需要实现文件被拖放时发生的情况。为此,我们将使用 (cdkDropListDropped) 事件处理程序。更新模板如下:
<div class="folders">
  ...
  <div class="folders__list">
    <app-folder
      cdkDropList
      [cdkDropListData]="folder.files"
      (cdkDropListDropped)="onFileDrop($event)"
      ...
    >
    </app-folder>
  </div>
  <div class="folders__selected-folder-files"   *ngIf="selectedFolder">
    <div
      cdkDropList
      [cdkDropListData]="selectedFolder.files"
      (cdkDropListDropped)="onFileDrop($event)"
    >
      ...
    </div>
  </div>
</div>
  1. 最后,我们需要实现 onFileDrop 方法。更新 folders-list.component.ts 文件如下:
...
import {
  CdkDragDrop, moveItemInArray, transferArrayItem,
} from '@angular/cdk/drag-drop';
@Component({...})
export class FoldersListComponent implements OnInit {
  ...
  onFileDrop(event: CdkDragDrop<string[]>) {
    if (event.previousContainer === event.container) {
      moveItemInArray(
        event.container.data, event.previousIndex,
        event.currentIndex
      );
    } else {
      transferArrayItem(
        event.previousContainer.data, event.container.        data,
        event.previousIndex, event.currentIndex
      );
    }
  }
}

如果现在刷新应用程序并尝试将文件拖到文件夹中,您应该会看到类似于这样的东西:

图 9.12 - 将文件拖放到另一个文件夹中

图 9.12 - 将文件拖放到另一个文件夹

丑陋,不是吗?这是因为我们必须在接下来的步骤中修复拖放预览。

  1. 为了处理拖放预览,我们需要将它们封装到一个带有cdkDropListGroup指令的元素中。更新folders-list.component.html文件,并将该指令应用于具有"folders"类的元素,如下所示:
<div class="folders" cdkDropListGroup>
...
</div>
  1. 为了应用自定义拖动预览,我们使用一个带有*cdkDragPreview指令的自定义元素。更新folders-list.component.html文件如下:
<div class="folders" cdkDropListGroup>
  ...
  <div class="folders__selected-folder-files"   *ngIf="selectedFolder">
    <div
      cdkDropList
      ...
    >
      <app-file
        cdkDrag
        ...
      >
        <fa-icon
          class="file-drag-preview"
          *cdkDragPreview
          [icon]="file.icon"
        ></fa-icon>
      </app-file>
    </div>
  </div>
</div>
  1. 我们还需要一些拖放预览的样式。更新folders-list.component.scss文件如下:
$folder-bg: #f5f5f5;
$file-preview-transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
.folders {...}
.file-drag-preview {
  padding: 10px 20px;
  background: transparent;
  font-size: 32px;
}
.file-drop-placeholder {
  min-height: 60px;
  transition: $file-preview-transition;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 32px;
}
  1. 让我们还添加一些样式,以确保在对文件夹内的项目重新排序时,其他列表项可以平稳移动。更新src/styles.scss文件如下:
...
* {
  user-select: none;
}
/* Animate items as they're being sorted. */
.cdk-drop-list-dragging .cdk-drag {
  transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
/* Animate an item that has been dropped. */
.cdk-drag-animating {
  transition: transform 300ms cubic-bezier(0, 0, 0.2, 1);
}
  1. 现在,我们也需要创建一个拖放预览模板。为此,我们在preview元素上使用*cdkDragPlaceholder指令。更新folders-list.component.html文件如下:
<div class="folders" cdkDropListGroup>
  ...
  <div class="folders__selected-folder-files" *ngIf="selectedFolder">
    <div cdkDropList ...>
      <app-file cdkDrag ...>
        <fa-icon class="file-drag-preview"
          *cdkDragPreview ... ></fa-icon>
        <div class="file-drop-placeholder"         *cdkDragPlaceholder>
          <fa-icon [icon]="upArrow"></fa-icon>
        </div>
      </app-file>
    </div>
  </div>
</div>
  1. 最后,让我们使用@fortawesome包中的faArrowAltCircleUp图标创建一个upArrow属性。更新folders-list.component.ts文件如下:
import { Component, OnInit } from '@angular/core';
import { APP_DATA } from '../constants/data';
import { IFolder } from '../interfaces';
import { faArrowAltCircleUp } from '@fortawesome/free-regular-svg-icons';
import {
  CdkDragDrop,
  moveItemInArray,
  transferArrayItem,
} from '@angular/cdk/drag-drop';
import { FileIconService } from '../core/services/file-icon.service';
@Component({...})
export class FoldersListComponent implements OnInit {
  folders = APP_DATA;
  selectedFolder: IFolder = null;
  upArrow = faArrowAltCircleUp;
  constructor(private fileIconService: FileIconService)   {...}
  ...
}

砰!现在我们整个拖放流程都有了无缝的用户体验(UX)。喜欢吗?确保在 Twitter 上分享一个快照,并在@muhd_ahsanayaz上标记我。

现在我们已经完成了这个示例,让我们在下一节中看看它是如何工作的。

它是如何工作的...

在这个示例中有一些有趣的指令,我们将逐一介绍它们。首先,作为优秀的 Angular 开发人员,我们将DragDropModule类导入到我们的AppModuleimports数组中,以确保我们不会出现错误。然后,我们开始使文件可拖动。我们通过将cdkDrag指令应用于每个文件元素并将*ngFor指令应用于它来实现这一点。这告诉 Angular CDK 这个元素将被拖动,因此 Angular CDK 会将不同的处理程序绑定到每个要拖动的元素上。

重要提示

Angular 组件默认不是块元素。因此,当将cdkDrag指令应用于 Angular 组件(例如<app-file>组件)时,可能会限制从 CDK 应用动画时拖动元素。为了解决这个问题,我们需要为我们的组件元素设置display: block;。请注意,我们正在为.folders__selected-folder-files__file类在folders-list.component.scss文件(第 25 行)中应用所需的样式。

在配置拖动元素之后,我们使用cdkDropList指令将每个容器 DOM 元素指定为我们应该放置文件的位置。在我们的配方中,这是屏幕上看到的每个文件夹,我们还可以重新排列文件夹内的文件。因此,我们将cdkDropList指令应用于当前显示文件的包装元素,以及对folders数组进行*ngFor循环的每个<app-folder>项。

然后,我们通过为每个可拖动的文件指定[cdkDragData]="file"来指定我们正在拖动的data。这有助于我们在稍后的过程中识别它,当我们将其放置在当前文件夹内或其他文件夹内时。我们还通过在我们应用了cdkDropList指令的元素上指定[cdkDropListData]="ARRAY"语句来指定此拖动的项目将在放置在特定列表上时添加到哪个数组中。当 Angular CDK 结合cdkDragDatacdkDropListData属性的信息时,它可以轻松地识别项目是否在同一列表内被拖动然后放置,或者在另一个列表内被放置。

为了处理当我们放置被拖动的文件时发生的情况,我们在具有cdkDropList指令的元素上使用来自 Angular CDK 的(cdkDropListDropped)方法。我们获取从 CDK 发出的$event并将其传递给我们的onFileDrop()方法。很棒的是,在onFileDrop()方法中,我们使用来自 Angular CDK 的moveItemInArray()transferArrayItem()辅助方法,使用非常简单的逻辑来比较容器。也就是说,Angular CDK 为我们提供了足够的信息,让我们可以非常轻松地完成整个功能。

在配方的最后,我们通过在自定义模板上使用*cdkDragPreview指令来定制拖动预览的外观,告诉 Angular CDK 不要立即渲染它,而是在开始拖动文件时用鼠标显示它。对于我们的配方,我们只显示文件的图标作为拖动预览。最后,我们还使用*cdkDragPlaceholder指令自定义了放置预览(或拖动占位符),它显示一个透明的矩形,带有一个向上的箭头图标,以反映项目在放置时将被添加的位置。当然,我们还必须为拖动预览和放置预览添加一些自定义样式。

另请参阅

使用 CDK Stepper API 创建多步游戏

如果你尝试在互联网上找到 CDK Stepper 的示例,你会发现有很多围绕使用 CDK Stepper API 创建多步表单的文章,但由于它本质上是一个步进器,它可以用于各种用例。在这个示例中,我们将使用 Angular CDK Stepper API 构建一个猜测游戏,用户将猜测掷骰子的输出是什么。

做好准备

我们要处理的项目位于克隆存储库内的chapter09/start_here/using-cdk-stepper中。请按照以下步骤进行:

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这将在新的浏览器标签中打开应用程序,你应该会看到类似这样的东西:

图 9.13 – 在 http://localhost:4200 上运行的 using-cdk-stepper 应用程序

图 9.13 – 在 http://localhost:4200 上运行的 using-cdk-stepper 应用程序

现在,让我们在下一节中看看如何使用 CDK Stepper API 创建多步游戏。

如何做…

我们手头上有一个非常简单但有趣的应用程序,其中已经构建了一些组件,包括骰子组件、值猜测组件和排行榜组件。我们将使用 Stepper API 将这个游戏创建为一个多步游戏。请按照以下步骤进行:

  1. 首先,打开一个新的终端窗口/标签,并确保你在ch8/start_here/using-cdk-stepper文件夹内。进入后,运行以下命令安装 Angular CDK:
npm install --save @angular/cdk@12.0.0
  1. 你需要重新启动你的 Angular 服务器,所以重新运行ng serve命令。

  2. 现在,在你的app.module.ts文件中从@angular/cdk包中导入CdkStepperModule类,如下所示:

...
import { LeaderBoardComponent } from './components/leader-board/leader-board.component';
import { CdkStepperModule } from '@angular/cdk/stepper';
...
@NgModule({
  declarations: [...],
  imports: [BrowserModule, AppRoutingModule,   ReactiveFormsModule, CdkStepperModule],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}
  1. 现在让我们来创建我们的步进器组件。在项目文件夹中运行以下命令:
ng g c components/game-stepper
  1. 为了使我们的组件成为CdkStepper,我们需要使用CdkStepper令牌提供它,并且还必须从CdkStepper扩展我们的组件类。我们可以移除constructorOnInit实现和ngOnInit方法。修改game-stepper.component.ts文件,如下所示:
import { Component } from '@angular/core';
import { CdkStepper } from '@angular/cdk/stepper';
@Component({
  selector: 'app-game-stepper',
  templateUrl: './game-stepper.component.html',
  styleUrls: ['./game-stepper.component.scss'],
  providers: [{ provide: CdkStepper, useExisting:   GameStepperComponent }],
})
export class GameStepperComponent extends CdkStepper {
}

请注意,我们已经移除了对ngOnInitOnInit生命周期的使用,因为我们不希望这些用于此组件。

  1. 让我们为我们的<game-stepper>组件添加模板。我们将首先添加将显示步骤标签的标题。更新您的game-stepper.component.html文件如下:
<section class="game-stepper">
  <header>
    <h3>
      <ng-container
        *ngIf="selected.stepLabel; else showLabelText"
        [ngTemplateOutlet]="        selected.stepLabel.template"
      >
      </ng-container>
      <ng-template #showLabelText>
        {{ selected.label }}
      </ng-template>
    </h3>
  </header>
</section>
  1. 现在,我们将添加模板来显示所选步骤的主要内容 - 这很简单。我们需要添加一个带有[ngTemplateOutlet]属性的 div,我们将在其中显示内容。更新game-stepper.component.html文件如下:
<section class="game-stepper">
  <header>
    ...
  </header>
  <section class="game-stepper__content">
    <div [ngTemplateOutlet]="selected ? selected.content     : null"></div>
  </section>
  ...
</section>
  1. 最后,我们将添加一个包含导航按钮的页脚元素,用于我们的步进器 - 也就是说,我们应该能够使用这些导航按钮跳转到下一个和上一个步骤。进一步更新game-stepper.component.html文件如下:
<section class="game-stepper">
  ...
  <section class="game-stepper__content">
    <div [ngTemplateOutlet]="selected ? selected.content     : null"></div>
  </section>
  <footer class="game-stepper__navigation">
    <button
      class="game-stepper__navigation__button btn       btn-primary"
      cdkStepperPrevious
      [style.visibility]="steps.get(selectedIndex - 1) ?       'visible' : 'hidden'"
    >
      &larr;
    </button>
    <button
      class="game-stepper__navigation__button btn       btn-primary"
      cdkStepperNext
      [style.visibility]="steps.get(selectedIndex + 1) ?       'visible' : 'hidden'"
    >
      &rarr;
    </button>
  </footer>
</section>
  1. 让我们为我们的game-stepper组件添加一些样式。修改game-stepper.component.scss文件如下:
.game-stepper {
  display: flex;
  flex-direction: column;
  align-items: center;
  &__navigation {
    width: 100%;
    display: flex;
    align-items: center;
    justify-content: space-between;
    > button {
      margin: 0 8px;
    }
  }

  &__content {
    min-height: 350px;
    display: flex;
    justify-content: center;
    align-items: center;
    flex-direction: column;
  }
  header,
  footer {
    margin: 10px auto;
  }
}
  1. 现在,我们将在game.component.html文件中用<app-game-stepper>组件包装整个模板。更新文件如下:
<app-game-stepper>
  <form (ngSubmit)="submitName()" [formGroup]="nameForm">
    ...
  </form>
  <app-value-guesser></app-value-guesser>
  <app-dice></app-dice>
  <app-leader-board></app-leader-board>
</app-game-stepper>
  1. 现在,我们将修改我们的game.component.html文件,将内部模板分解为步骤。为此,我们将使用<cdk-step>元素来包装每个步骤的内容。更新文件如下:
<app-game-stepper>
  <cdk-step>
    <form (ngSubmit)="submitName()"     [formGroup]="nameForm">
      ...
    </form>
  </cdk-step>
  <cdk-step>
    <app-value-guesser></app-value-guesser>
    <app-dice></app-dice>
  </cdk-step>
  <cdk-step>
    <app-leader-board></app-leader-board>
  </cdk-step>
</app-game-stepper>
  1. 现在,我们将为每个步骤添加一个标签,以显示所选步骤的主要内容 - 这很简单。我们需要在每个<cdk-step>元素内添加一个<ng-template>元素。更新game.component.html文件如下:
<app-game-stepper>
  <cdk-step>
    <ng-template cdkStepLabel>Enter your     name</ng-template>
    <form (ngSubmit)="submitName()"     [formGroup]="nameForm">
      ...
    </form>
  </cdk-step>
  <cdk-step>
    <ng-template cdkStepLabel>Guess what the value     will be when the die is rolled</ng-template>
    <app-value-guesser></app-value-guesser>
    <app-dice></app-dice>
  </cdk-step>
  <cdk-step>
    <ng-template cdkStepLabel> Results</ng-template>
    <app-leader-board></app-leader-board>
  </cdk-step>
</app-game-stepper>

如果刷新应用程序,您应该看到第一步作为可见步骤,以及底部导航按钮如下:

图 9.14 - 使用 CDKStepper 的第一步和导航按钮

图 9.14 - 使用 CDKStepper 的第一步和导航按钮

  1. 现在,我们需要确保只有在第一步输入姓名后才能前进到第二步。对game.component.html文件进行以下更改:
<app-game-stepper [linear]="true">
  <cdk-step [completed]="!!nameForm.get('name').value">
    <ng-template cdkStepLabel> Enter your     name</ng-template>
    <form (ngSubmit)="submitName()"     [formGroup]="nameForm">
      <div class="mb-3" *ngIf="nameForm.get('name')       as nameControl">
        ...
      </div>
      <button ← REMOVE THIS
        type="submit"
        [disabled]="!nameForm.valid"
        class="btn btn-primary"
      >
        Submit
      </button>
  </form>
  </cdk-step>
  ...
</app-game-stepper>
  1. 我们还需要在第一步上禁用下一步按钮,直到我们为玩家姓名输入一个值。为此,请更新game-stepper.component.html文件 - 具体来说,具有cdkStepperNext属性的元素如下:
<section class="game-stepper">
  ...
  <footer class="game-stepper__navigation">
    ...
    <button
      class="game-stepper__navigation__button btn       btn-primary"
      cdkStepperNext
      [disabled]="!selected.completed"
      [style.visibility]="steps.get(selectedIndex + 1) ?       'visible' : 'hidden'"
    >
      &rarr;
    </button>
  </footer>
</section>
  1. 处理用户提供姓名并按下Enter键导致表单提交的情况时,我们可以使用GameComponent类中的@ViewChild()来处理移动到下一步。修改game.component.ts文件如下,并尝试输入姓名然后按Enter键:
import { CdkStepper } from '@angular/cdk/stepper';
import { Component, OnInit, ViewChild } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
@Component({...})
export class GameComponent implements OnInit {
  @ViewChild(CdkStepper) stepper: CdkStepper;
  nameForm = new FormGroup({
    name: new FormControl('', Validators.required),
  });
 ...
  submitName() {
    this.stepper.next();
  }
}
  1. 现在,让我们编写猜数字的流程。更新game.component.ts文件如下:
...
import { DiceComponent } from '../components/dice/dice.component';
import { ValueGuesserComponent } from '../components/value-guesser/value-guesser.component';
import { IDiceSide } from '../interfaces/dice.interface';
@Component({...})
export class GameComponent implements OnInit {
  @ViewChild(CdkStepper) stepper: CdkStepper;
  @ViewChild(DiceComponent) diceComponent: DiceComponent;
  @ViewChild(ValueGuesserComponent)
  valueGuesserComponent: ValueGuesserComponent;
  guessedValue = null;
  isCorrectGuess = null;
  ...
  submitName() {...}
  rollTheDice(guessedValue) {
    this.isCorrectGuess = null;
    this.guessedValue = guessedValue;
    this.diceComponent.rollDice();
  }
  showResult(diceSide: IDiceSide) {
    this.isCorrectGuess = this.guessedValue === diceSide.value;
  }
}
  1. 现在我们已经有了函数,让我们更新模板以侦听来自<app-value-guesser><app-dice>组件的事件监听器,并相应地采取行动。我们还将添加.alert元素以在猜对或猜错时显示消息。更新game.component.html文件如下:
<app-game-stepper [linear]="true">
  <cdk-step [completed]="!!nameForm.get('name').value">
    ...
  </cdk-step>
  <cdk-step [completed]="isCorrectGuess !== null">
    <ng-template cdkStepLabel
      >Guess what the value will be when the die is       rolled</ng-template
    >
    <app-value-guesser (valueGuessed)="rollTheDice     ($event)"></app-value-guesser>
    <app-dice (diceRolled)="showResult($event)">    </app-dice>
    <ng-container [ngSwitch]="isCorrectGuess">
      <div class="alert alert-success"       *ngSwitchCase="true">
        You rock {{ nameForm.get('name').value }}!         You got 50 points
      </div>
      <div class="alert alert-danger"       *ngSwitchCase="false">
        Oops! Try again!
      </div>
    </ng-container>
  </cdk-step>
  <cdk-step>...</cdk-step>
</app-game-stepper>
  1. 最后,我们需要填充排行榜。更新game.component.ts文件以使用LeaderboardService类,如下所示:
...
import { LeaderboardService } from '../core/services/leaderboard.service';
import { IDiceSide } from '../interfaces/dice.interface';
import { IScore } from '../interfaces/score.interface';
@Component({...})
export class GameComponent implements OnInit {
  ...
  scores: IScore[] = [];
  constructor(private leaderboardService:   LeaderboardService) {}
  ngOnInit(): void {
    this.scores = this.leaderboardService.getScores();
  }
  ...
  showResult(diceSide: IDiceSide) {
    this.isCorrectGuess = this.guessedValue ===     diceSide.value;
    if (!this.isCorrectGuess) {
      return;
    }
    this.scores = this.leaderboardService.setScores({
      name: this.nameForm.get('name').value,
      score: 50,
    });
  }
}
  1. 现在,更新game.component.html文件以将分数作为属性传递给<app-leader-board>组件,如下所示:
<app-game-stepper [linear]="true">
  <cdk-step [completed]="!!nameForm.get('name').value">
    ...
  </cdk-step>
  <cdk-step [completed]="isCorrectGuess !== null">
    ...
  </cdk-step>
  <cdk-step>
    <ng-template cdkStepLabel>Results</ng-template>
    <app-leader-board [scores]="scores"></app-leader-    board>
  </cdk-step>
</app-game-stepper>

如果现在刷新应用并玩游戏,你应该能够看到排行榜,如下所示:

图 9.15 - 在第 3 步中在排行榜中显示结果

图 9.15 - 在第 3 步中在排行榜中显示结果

哎呀!这是一个很长的配方!嗯,完美需要时间和专注。随时可以自己玩这个游戏,甚至和朋友一起玩,如果你改进了它,也请告诉我。

现在你已经完成了这个配方,看看下一节它是如何工作的。

它是如何工作的…

这个配方中有很多组成部分,但它们非常简单。首先,我们将CdkStepperModule类导入到我们的AppModule类的imports数组中。然后,我们创建一个扩展CdkStepper类的组件。扩展CdkStepper类的原因是为了能够创建这个GameStepperComponent组件,以便我们可以创建一个可重用的模板,其中包含一些样式,甚至一些自定义功能。

要开始使用GameStepperComponent组件,我们在game.component.html文件中将整个模板包装在<app-game-stepper>元素中。由于该组件扩展了CdkStepper API,因此我们可以在这里使用CdkStepper组件的所有功能。对于每个步骤,我们使用 CDK 中的<cdk-step>元素并将步骤的模板包装在其中。请注意,在game-stepper.component.html文件中,我们对步骤的标签和实际内容都使用了[ngTemplateOutlet]属性。这反映了CdkStepper API 的强大之处。它根据我们为每个步骤提供的值/模板自动生成step.label属性和content属性。由于我们在每个<cdk-step>元素内提供了一个<ng-template cdkStepLabel>,CDK 会自动生成一个step.stepLabel.template,然后我们在game-stepper.component.html文件中使用它,如上所述。如果我们没有提供它,它将根据我们的代码使用step.label属性。

对于底部导航按钮,您会注意到我们使用带有cdkStepperPreviouscdkStepperNext指令的<button>元素分别用于前进到上一步和下一步。我们还根据条件显示/隐藏下一个和上一个按钮,以检查是否有步骤可供前进。我们使用[style.visibility]绑定来隐藏导航按钮,就像您在代码中看到的那样。

CdkStepper API 的一个有趣之处在于,我们可以告诉用户是否应该能够前进到下一步和后退,而不管当前步骤的状态如何,或者用户是否应该先在当前步骤中做一些事情才能进入下一步。我们通过在<app-game-stepper>元素上使用[linear]属性并将其值设置为true来实现这一点。这告诉CdkStepper API 在当前步骤的completed属性为true之前不要使用cdkStepperNext按钮进入下一步。虽然只提供[linear]="true"就足以处理功能,但我们通过在cdkStepperNext按钮上使用[disabled]="!selected.completed"来禁用下一步按钮来改善用户体验,因为如果点击按钮不会做任何事情,禁用按钮更有意义。

此外,我们需要决定何时认为一步骤已经完成。对于第一步,很明显,我们应该在输入中输入名称才能认为步骤已完成,换句话说,在 nameForm FormGroup 中的 'name' 属性的 FormControl 应该有一个值。对于第二步,当用户猜测一个数字后,无论猜测是否正确,我们都会标记该步骤已完成,并让用户进入下一步(排行榜),如果用户愿意的话。大致就是这样。

另请参阅

使用 CDK TextField API 调整文本输入大小

文本输入是我们日常计算机使用的重要部分。无论是填写表单、在谷歌上搜索内容,还是找到您喜欢的 YouTube 视频,我们都与文本输入进行交互,当我们必须在单个文本输入中写入大量内容时,确实需要良好的用户体验。在这个教程中,您将学习如何使用 CDK TextField API 根据输入值自动调整 <textarea> 输入的大小。

准备工作

此教程的项目位于 chapter09/start_here/resizable-text-inputs-using-cdk。请按照以下步骤进行:

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行 npm install 来安装项目的依赖项。

  3. 完成后,运行 ng serve -o

这应该在新的浏览器标签中打开应用程序,您应该能够看到应用程序。尝试输入一长段文本,您将看到文本区域显示如下:

图 9.16 – resizable-text-inputs-using-cdk 应用正在 http://localhost:4200 上运行

图 9.16 – resizable-text-inputs-using-cdk 应用正在 http://localhost:4200 上运行

现在我们的应用程序在本地运行,让我们在下一节中看看这个教程的步骤。

如何做…

图 9.16 中,您会注意到我们无法看到输入的整个内容,这在大多数情况下都有点烦人,因为您无法在按下 操作 按钮之前真正审查它。让我们按照以下步骤使用 CDK TextField API:

  1. 首先,打开一个新的终端窗口/标签,并确保您在 chapter09/start_here/resizable-text-inputs-using-cdk 文件夹内。进入后,运行以下命令安装 Angular CDK:
npm install --save @angular/cdk@12.0.0
  1. 您需要重新启动 Angular 服务器,因此重新运行 ng serve 命令。

  2. 现在,我们需要将 TextFieldModule 类导入到 AppModule 类的 imports 数组中。修改 app.module.ts 文件如下:

...
import { TextFieldModule } from '@angular/cdk/text-field';
@NgModule({
  declarations: [...],
  imports: [
    BrowserModule,
    AppRoutingModule,
    TextFieldModule
  ],
  ...
})
export class AppModule { }
  1. 现在,我们将把 cdkTextareaAutosize 指令应用到我们的元素上,以便它可以根据内容自动调整大小。更新 write-message.component.html 文件如下:
<div class="write-box-container">
  <div class="write-box">
    <textarea
      cdkTextareaAutosize
      placeholder="Enter your message here"
      class="chat-input"
      [(ngModel)]="chatInput"
      rows="1"
      (keyup.enter)="sendMessage()"
    ></textarea>
  </div>
  <div class="send-button">
    ...
  </div>
</div>

如果您现在在文本输入中输入一些长短语,您应该会看到它被正确地调整大小,如下所示:

图 9.17 – 根据内容调整文本区域的大小

图 9.17 – 根据内容调整文本区域的大小

虽然这很棒,但您可能会注意到一旦消息被发送(即添加到消息列表中),元素的大小并没有重置为其初始状态。

  1. 为了将元素的大小重置为初始大小,我们将使用 CdkTextareaAutosize 指令的 reset()方法。为此,我们将获取该指令作为 ViewChild,然后触发 reset()方法。修改 write-message.component.ts 文件如下:
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import {
  ...
  EventEmitter,
  ViewChild,
} from '@angular/core';
...
@Component({...})
export class WriteMessageComponent implements OnInit {
  @Output() public onMessageSent = new   EventEmitter<any>();
  @ViewChild(CdkTextareaAutosize) newMessageInput:   CdkTextareaAutosize;
  public chatInput = '';
  ...
  /**
   * @author Ahsan Ayaz
   * Creates a new message and emits to parent component
   */
  sendMessage() {
    if (this.chatInput.trim().length) {
      ...
      this.chatInput = '';
      this.newMessageInput.reset();
    }
  }
}

太棒了!通过这个改变,当您刷新页面,输入一个非常长的句子并按下Enter键时,您会看到元素的大小被重置,如下所示:

图 9.18 – 在创建新消息时重置大小

图 9.18 – 在创建新消息时重置大小

现在您已经完成了这个教程,接下来请查看下一节以了解它是如何工作的。

它是如何工作的…

在这个教程中,我们使用了 CDK 剪贴板 API 中的两个主要功能——一个是cdkCopyToClipboard指令,另一个是Clipboard服务。cdkCopyToClipboard指令将点击处理程序绑定到应用该指令的元素上。它既可以作为指令的selector,也可以作为指令的@Input()项,以便在单击元素时知道要复制到剪贴板的值是什么。在我们的教程中,对于链接输入,请注意我们使用了[cdkCopyToClipboard]="linkInput.value"。这将点击处理程序绑定到<input>元素,并绑定到指向输入值的linkInput模板变量的value属性,即要复制的实际链接。当我们点击输入时,它使用linkInput.value绑定来访问输入的值,对于<text-area>输入也是一样。唯一的区别是cdkCopyToClipboard指令没有绑定到<text-area>元素本身。原因是我们希望将点击处理程序绑定到文本区域下面的按钮上。因此,在复制文本的按钮上,我们有[cdkCopyToClipboard]="textInput.value"绑定。

对于图片,我们做了一些不同的事情。我们使用了@angular/cdk/clipboard包中的Clipboard服务来手动复制 blob URL。我们创建了一个名为copyImageUrl()的方法,当点击复制图片的按钮时调用该方法。我们将imageUrl属性传递给这个方法,然后下载图片,将其读取为 blob,并生成 blob URL,然后使用Clipboard服务的copy()方法将其复制到剪贴板。

另请参阅

第十章:第十章:使用 Jest 在 Angular 中编写单元测试

"它在我的机器上运行……"这句话不会随着时间的推移而失去它的美丽。对许多工程师来说,这是一个护身符,对 QA 人员来说则是一个噩梦。但老实说,有什么比为应用程序的健壮性编写测试更好的方式呢?当涉及编写单元测试时,我个人最喜欢的是 Jest。因为它非常快速、轻量级,并且具有易于编写测试的简单 API。更重要的是,它比 Angular 默认提供的 Karma 和 Jasmine 设置更快。在本章中,您将学习如何配置 Angular 与 Jest,以便并行运行这些测试。您将学习如何使用 Jest 测试组件、服务和管道。您还将学习如何为这些测试模拟依赖项。

在本章中,我们将涵盖以下内容:

  • 在 Angular 中使用 Jest 设置单元测试

  • 为 Jest 提供全局模拟

  • 使用存根来模拟服务

  • 在单元测试中对注入的服务使用间谍

  • 使用ng-mocks包模拟子组件和指令

  • 使用 Angular CDK 组件测试更简单

  • 使用 Observables 对组件进行单元测试

  • 单元测试 Angular 管道

技术要求

在本章的食谱中,请确保您的计算机上安装了GitNodeJS。您还需要安装@angular/cli包,可以使用终端命令npm install -g @angular/cli来安装。本章的代码可以在github.com/PacktPublishing/Angular-Cookbook/tree/master/chapter10找到。

在 Angular 中使用 Jest 设置单元测试

默认情况下,新的 Angular 项目包含了很多好东西,包括配置和工具,可以使用 Karma 和 Jasmine 来运行单元测试。虽然使用 Karma 相对方便,但许多开发人员发现,在大型项目中,如果涉及大量测试,整个测试过程会变得非常缓慢。这主要是因为无法并行运行测试。在本章中,我们将为 Angular 应用程序设置 Jest 进行单元测试。此外,我们还将把现有的测试从 Karma 语法迁移到 Jest 语法。

准备工作

我们将要处理的项目位于chapter10/start_here/setting-up-jest中,该文件夹位于克隆的存储库内。首先,执行以下步骤:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install以安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该会在新的浏览器标签中打开应用程序。您应该会看到类似以下截图的内容:

图 10.1 - 在 http://localhost:4200 上运行的 setting-up-jest 应用程序

图 10.1 - 在 http://localhost:4200 上运行的 setting-up-jest 应用程序

接下来,尝试运行测试并监视整个过程运行的时间。从终端运行ng test命令;几秒钟后,一个新的 Chrome 窗口应该会打开,如下所示:

图 10.2 - 使用 Karma 和 Jasmine 的测试结果

图 10.2 - 使用 Karma 和 Jasmine 的测试结果

看着前面的截图,你可能会说“Pfffttt Ahsan,它说'在 0.126 秒内完成!'你还想要什么?”嗯,那个时间只涵盖了 Karma 在 Chrome 窗口创建后在浏览器中运行测试所花费的时间。它没有计算实际启动过程、启动 Chrome 窗口,然后加载测试所花费的时间。在我的机器上,整个过程大约需要15 秒。这就是为什么我们要用 Jest 替换它。现在您了解了问题,在下一节中,让我们看一下食谱的步骤。

如何做...

在这里,我们有一个 Angular 应用程序,其中有一个非常简单的Counter组件。它显示计数器的值,并有三个操作按钮:一个用于增加计数器的值,一个用于减少值,一个用于重置值。此外,还有一些使用 Karma 和 Jasmine 编写的测试,如果运行ng test命令,所有测试都会通过。我们将首先设置 Jest。执行以下步骤:

  1. 首先,打开一个新的终端窗口/标签,并确保您在chapter10/start_here/setting-up-jest文件夹内。进入后,运行以下命令以安装使用 Jest 所需的软件包:
npm install --save-dev jest jest-preset-angular @types/jest
  1. 现在我们可以卸载 Karma 和不需要的依赖项。现在在您的终端中运行以下命令:
npm uninstall karma karma-chrome-launcher karma-jasmine-html-reporter @types/jasmine @types/jasminewd2 jasmine-core jasmine-spec-reporter karma-coverage-istanbul-reporter karma-jasmine
  1. 我们还需要摆脱一些我们不需要的额外文件。从项目中删除karma.conf.js文件和src/test.ts文件。

  2. 现在按照以下方式更新angular.json文件中的测试配置:

{
  ...
  "projects": {
    "setting-up-jest": {
      "...
      "prefix": "app",
      "architect": {
        "build": {...},
        "serve": {...},
        "extract-i18n": {...},
        "test": {
          "builder": "@angular-builders/jest:run",
          "options": {
            "tsConfig": "<rootDir>/src/tsconfig.test.            json",
            "collectCoverage": false,
            "forceExit": true
          }
        },
        "lint": {...},
        "e2e": {...}
      }
    }
  },
  "defaultProject": "setting-up-jest"
}
  1. 我们现在将创建一个文件来为我们的项目配置 Jest。在项目的根文件夹中创建一个名为jestSetup.ts的文件,并粘贴以下内容:
import 'jest-preset-angular /setup-jest';
  1. 现在,让我们修改tsconfig.spec.json以使用 Jest 而不是 Jasmine。修改后,整个文件应如下所示:
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/spec",
    "types": ["jest", "node"],
    "esModuleInterop": true,
    "emitDecoratorMetadata": true
  },
  "files": ["src/polyfills.ts"],
  "include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
}
  1. 我们现在将修改package.json以添加将运行 Jest 测试的npm脚本:
{
  "name": "setting-up-jest",
  "version": "0.0.0",
  "scripts": {
    ...
    "build": "ng build",
    "test": "jest",
    "test:coverage": "jest --coverage",
    ...
  },
  "private": true,
  "dependencies": {...},
  "devDependencies": {...},
}
  1. 最后,让我们通过在package.json文件中添加 Jest 配置来完成我们 Jest 测试的整个配置,如下所示:
{
  ...
  "dependencies": {...},
  "devDependencies": {...},
  "jest": {
    "preset": "jest-preset-angular",
    "setupFilesAfterEnv": [
      "<rootDir>/jestSetup.ts"
    ],
    "testPathIgnorePatterns": [
      "<rootDir>/node_modules/",
      "<rootDir>/dist/"
    ],
    "globals": {
      "ts-jest": {
        "tsconfig": "<rootDir>/tsconfig.spec.json",
        "stringifyContentPathRegex": "\\.html$"
      }
    }
  }
}
  1. 现在我们已经设置好了一切,只需运行test命令,如下所示:
npm run test

测试完成后,您应该能够看到以下输出:

图 10.3 - 使用 Jest 进行测试的结果

图 10.3 - 使用 Jest 进行测试的结果

砰!您会注意到使用 Jest 运行测试的整个过程大约需要 6 秒。第一次运行时可能需要更多时间,但随后的运行应该更快。现在您知道如何配置 Angular 应用程序以使用 Jest 进行单元测试,请参考下一节以了解更多资源。

另请参阅

为 Jest 提供全局模拟

在上一个食谱中,我们学习了如何为 Angular 单元测试设置 Jest。可能会有一些情况,您希望使用浏览器 API,这些 API 可能不是实际 Angular 代码的一部分;例如,使用localStoragealert()。在这种情况下,我们需要为我们希望从中返回模拟值的函数提供一些全局模拟。这样我们就可以进行涉及它们的测试。在这个食谱中,您将学习如何为 Jest 提供全局模拟。

准备工作

此食谱的项目位于chapter10/start_here/providing-global-mocks-for-jest。执行以下步骤:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install以安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该在新的浏览器标签中打开应用程序。应用程序应如下所示:

图 10.4 - 在 http://localhost:4200 上运行提供全局模拟的 jest 应用程序

图 10.4 - 在 http://localhost:4200 上运行提供全局模拟的 jest 应用程序

现在我们已经在本地运行了应用程序,在接下来的部分中,让我们按照食谱的步骤进行。

如何做...

我们在这个示例中使用的应用程序使用了两个全局 API:window.localStoragewindow.alert()。请注意,当应用程序启动时,我们从 localStorage 中获取计数器的值,然后在增加、减少和重置时,我们将其存储在 localStorage 中。当计数器的值大于 MAX_VALUE 或小于 MIN_VALUE 时,我们使用 alert() 方法显示警报。让我们通过编写一些很酷的单元测试来开始这个示例:

  1. 首先,我们将编写我们的测试用例,以便在计数器值超过 MAX_VALUEMIN_VALUE 时显示警报。修改 counter.component.spec.ts 文件如下:
...
describe('CounterComponent', () => {
  ...
  it('should show an alert when the counter value goes   above the MAX_VALUE', () => {
    spyOn(window, 'alert');
    component.counter = component.MAX_VALUE;
    component.increment();
    expect(window.alert).toHaveBeenCalledWith('Value too     high');
    expect(component.counter).toBe(component.MAX_VALUE);
  });
  it('should show an alert when the counter value goes   above the MAX_VALUE', () => {
    spyOn(window, 'alert');
    component.counter = component.MIN_VALUE;
    component.decrement();
    expect(window.alert).toHaveBeenCalledWith('Value too     low');
    expect(component.counter).toBe(component.MIN_VALUE);
  });
});

在这里,您可以看到测试通过了。但是,如果我们想要检查 localStorage 中的值是否被正确保存和检索呢?

  1. 我们将创建一个新的测试,以确保调用 localStorage.getItem() 方法来从 localStorage API 中检索最后保存的值。将以下测试添加到 counter.component.spec.ts 文件中:
...
describe('CounterComponent', () => {
  ...
  it.only('should call the localStorage.getItem method on   component init', () => {
    spyOn(localStorage, 'getItem');
    component.ngOnInit();
    expect(localStorage.getItem).toBeCalled();
  });
});

请注意,我们在这个测试用例中使用了 it.only。这是为了确保我们只运行这个测试(目前)。如果您运行测试,您应该能够看到类似以下截图的内容:

图 10.5 – 正在覆盖 localStorage API 的测试失败了

图 10.5 – 正在覆盖 localStorage API 的测试失败了

请注意 Matcher error: received value must be a mock or a spy function 消息。这就是我们接下来要做的事情,也就是提供一个模拟。

  1. 在项目的根目录中创建一个名为 jest-global-mocks.ts 的文件。然后,添加以下代码以模拟 localStorage API:
const createLocalStorageMock = () => {
  let storage = {};
  return {
    getItem: (key) => {
      return storage[key] ? storage[key] : null;
    },
    setItem: (key, value) => {
      storage[key] = value;
    },
  };
};
Object.defineProperty(window, 'localStorage', {
  value: createLocalStorageMock(),
});
  1. 现在将此文件导入到 jestSetup.ts 文件中,如下所示:
import 'jest-preset-angular';
import './jest-global-mocks';

现在,如果您重新运行测试,它们应该通过。

  1. 让我们添加另一个测试,以确保我们在组件初始化时从 localStorage 中检索到最后保存的值。修改 counter.component.spec.ts 文件如下:
...
describe('CounterComponent', () => {
  ...
  it('should call the localStorage.getItem method on   component init', () => {
    spyOn(localStorage, 'getItem');
    component.ngOnInit();
    expect(localStorage.getItem).toBeCalled();
  });
  it('should retrieve the last saved value from   localStorage on component init', () => {
    localStorage.setItem('counterValue', '12');
    component.ngOnInit();
    expect(component.counter).toBe(12);
  });
});
  1. 最后,让我们确保每当触发 increment()decrement()reset() 方法时,我们都将计数器的值保存到 localStorage 中。更新 counter.component.spec.ts 如下:
...
describe('CounterComponent', () => {
  ...
  it('should save the new counterValue to localStorage   on increment, decrement and reset', () => {
    spyOn(localStorage, 'setItem');
    component.counter = 0;
    component.increment();
    expect(localStorage.setItem).    toHaveBeenCalledWith('counterValue', '1');
    component.counter = 20;
    component.decrement();
    expect(localStorage.setItem).    toHaveBeenCalledWith('counterValue', '19');
    component.reset();
    expect(localStorage.setItem).    toHaveBeenCalledWith('counterValue', '0');
  });
});

太棒了!您刚刚学会了如何为 Jest 提供全局模拟以进行测试。请参考下一节以了解其工作原理。

工作原理...

Jest 提供了一种定义要为每个测试加载的文件路径列表的方法。如果打开package.json文件并查看jest属性,您可以查看setupFilesAfterEnv属性,它接受一个文件路径数组。我们已经在那里为jestSetup.ts文件定义了路径。定义全局模拟的一种方法是创建一个新文件,然后将其导入jestSetup.ts。这是因为它无论如何都会在测试环境中被调用。这就是我们在这个示例中所做的。

请注意,我们在window对象中使用Object.defineProperty方法为localStorage对象提供了一个模拟实现。对于 JSDOM 中未实现的任何 API,情况都是一样的。同样,您可以为测试中使用的每个 API 提供全局模拟。请注意,在value属性中,我们使用了createLocalStorageMock()方法。实质上,这是定义模拟的一种方式。我们创建了createLocalStorageMock()方法,在其中我们有一个名为storage的私有/封装对象,模拟了localStorage对象。我们还在其中定义了getItem()setItem()方法,以便我们可以向此存储设置值并从中获取值。请注意,我们没有在原始localStorageAPI 中拥有的removeItem()clear()方法的实现。我们不必这样做,因为我们在测试中没有使用这些方法。

在“应该在组件初始化时调用 localStorage.getItem 方法”测试中,我们只是对localStorage对象的getItem()方法进行了间谍监视,自己调用了ngOnInit()方法,然后期望它已被调用。非常简单。

在“应该在组件初始化时从 localStorage 中检索最后保存的值”测试中,我们使用setItem()方法将计数器值保存在localStorage对象中,值为'12'。实质上,调用setItem()方法会调用我们的模拟实现方法,而不是实际的localStorageAPI 的setItem()方法。请注意,这里我们getItem()方法进行间谍监视;这是因为后来,我们希望组件的counter属性的值为12

重要说明

每当我们对一个方法进行间谍操作时,请记住实际函数中的任何语句都不会再被执行。这就是为什么我们在前面的测试中不对getItem()方法进行间谍操作。如果我们这样做,模拟实现中的getItem()方法将不会返回任何内容。因此,我们对计数器属性的预期值将不会是12

简而言之,如果您必须依赖于函数实现的结果,或者函数内部执行的语句,就不要对该函数进行间谍操作,并相应地编写您的测试。

PS:我总是在调试和苦苦思索一段时间后才艰难地学会这一点。开个玩笑!

最后的测试很简单。在'should save the new counterValue to localStorage on increment, decrement and reset'测试中,我们只是对setItem()方法进行了间谍操作,因为我们不关心它的实现。然后,我们手动多次设置计数器属性的值,然后分别运行increment()decrement()reset()方法。此外,我们期望setItem()方法已被调用,并使用正确的参数将值保存到存储中。请注意,我们在保存后不检查存储的值。正如我之前提到的,由于我们已经对setItem()方法进行了间谍操作,它的内部语句不会触发,值也不会被保存;因此,我们无法在保存后检索保存的值。

另请参阅

使用存根(mock)来模拟服务

几乎没有一个 Angular 应用程序不会在其中创建一个Service。就整体业务逻辑而言,服务在与 API 交互时承载了大量的业务逻辑,特别是在涉及到与 API 交互时。在这个食谱中,您将学习如何使用存根(mock)来模拟服务。

准备工作

该食谱的项目位于chapter10/start_here/mocking-services-using-stubs。执行以下步骤:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该会在新的浏览器标签页中打开应用程序。您应该会看到类似以下截图的内容:

图 10.6 - 在 http://localhost:4200 上运行的 mocking-services-using-stubs 应用程序

图 10.6 - 使用存根模拟服务的应用程序在 http://localhost:4200 上运行

现在我们已经在本地运行了应用程序,在下一节,让我们来看看食谱的步骤。

如何做...

我们有与上一个食谱相同的应用程序;但是,我们已经将保存和检索数据的逻辑从localStorage移动到了我们创建的CounterService中。现在所有的测试都通过了。但是,如果我们想要隐藏/封装计数器值存储的逻辑怎么办?也许我们想要为此发送后端 API 调用。为了做到这一点,更有意义的是对服务的方法进行监视。让我们按照食谱为我们的服务提供一个模拟存根:

  1. 首先,在src文件夹内创建一个名为__mocks__的文件夹。在其中,创建另一个名为services的文件夹。然后,在这个文件夹内再次创建counter.service.mock.ts文件,并包含以下内容:
const CounterServiceMock = {
  storageKey: 'counterValue',
  getFromStorage: jest.fn(),
  saveToStorage: jest.fn(),
};
export default CounterServiceMock;
  1. 现在在counter.component.spec.ts中提供模拟服务而不是实际服务,如下所示:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CounterService } from 'src/app/core/services/counter.service';
import CounterServiceMock from 'src/__mocks__/services/counter.service.mock';
...
describe('CounterComponent', () => {
  ...
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [CounterComponent],
      providers: [
        {
          provide: CounterService,
          useValue: CounterServiceMock,
        },
      ],
    }).compileComponents();
  });
  ...
});

通过上述更改,您应该看到以下错误,指出localStorage.setItem没有被调用。这是因为我们现在正在对我们服务的模拟存根上的方法进行监视:

图 10.7 - localStorage.setItem 没有被调用,因为方法被监视了

图 10.7 - localStorage.setItem 没有被调用,因为方法被监视了

  1. 现在,我们不再期望调用localStorage对象的方法,而是期望在我们的测试中调用我们服务的方法。更新counter.component.spec.ts文件如下:
...
describe('CounterComponent', () => {
  ...
  it('should call the CounterService.getFromStorage   method on component init', () => {
    component.ngOnInit();
    expect(CounterServiceMock.getFromStorage).    toBeCalled();
  });
  it('should retrieve the last saved value from   CounterService on component init', () => {
    CounterServiceMock.getFromStorage.    mockReturnValue(12);
    component.ngOnInit();
    expect(component.counter).toBe(12);
  });
  it('should save the new counterValue via CounterService   on increment, decrement and reset', () => {
    component.counter = 0;
    component.increment();
    expect(CounterServiceMock.saveToStorage).    toHaveBeenCalledWith(1);
    component.counter = 20;
    component.decrement();
    expect(CounterServiceMock.saveToStorage).    toHaveBeenCalledWith(19);
    component.reset();
    expect(CounterServiceMock.saveToStorage).    toHaveBeenCalledWith(0);
  });
});

太棒了!现在你知道如何模拟服务来测试具有服务依赖关系的组件。请参考下一节,了解它是如何工作的。

它是如何工作的...

为 Angular 服务提供存根已经非常简单。这要归功于 Angular 的开箱即用的方法和来自@angular/core包的工具,特别是@angular/core/testing。首先,我们为我们的CounterService创建存根,并对CounterService中的每个方法使用jest.fn()

使用jest.fn()返回一个新的未使用的模拟函数,Jest 会自动对其进行监视。可选地,我们还可以将模拟实现方法作为参数传递给jest.fn。查看官方文档中关于jest.fn()的以下示例:

const mockFn = jest.fn();
mockFn();
expect(mockFn).toHaveBeenCalled(); // test passes
// With a mock implementation:
const returnsTrue = jest.fn(() => true);
console.log(returnsTrue()); // true;
expect(returnsTrue()).toBe(true); // test passes

一旦我们创建了存根,我们将其传递给TestBed配置中的提供者数组,针对CounterService - 但useValue属性设置为CounterServiceMock。这告诉 Angular 使用我们的存根作为CounterService

然后,在我们期望组件初始化时调用CounterService.getFromStorage()的测试中,我们使用以下语句:

expect(CounterServiceMock.getFromStorage).toBeCalled();

请注意,在前面的代码中,我们能够直接在CounterServiceMock.getFromStorage上使用expect()。虽然这在 Karma 和 Jasmine 中是不可能的,但在 Jest 中是可能的,因为我们对每个基础方法使用了jest.fn()

然后,对于我们想要检查getFromStorage()方法是否被调用并返回保存的值的测试,我们首先使用CounterServiceMock.getFromStorage.mockReturnValue(12);语句。这确保了当调用getFromStorage()方法时,它会返回值12。然后,我们只需在测试中运行ngOnInit()方法,并期望我们组件的 counter 属性现在已经设置为12。这实际上意味着发生了以下事情:

  1. ngOnInit()调用getFromStorage()方法。

  2. getFromStorage()返回先前保存的值(在我们的情况下是12,但实际上,这将从localStorage中获取)。

  3. 组件的counter属性设置为检索到的值,这里是12

现在,对于最终的测试,我们只期望CounterServicesaveToStorage方法在每种必要情况下都被调用。为此,我们使用以下类型的expect()语句:

expect(CounterServiceMock.saveToStorage).toHaveBeenCalledWith(1);

大致就是这样。单元测试很有趣,不是吗?现在您已经了解了所有的工作原理,请参考下一节,了解一些有用的资源,以便进行进一步阅读。

另请参阅

在单元测试中使用对注入服务的间谍

虽然你可以在单元测试中使用 Jest 为你的服务提供存根,但有时为每个新服务创建一个模拟可能会感觉有些多余。假设如果服务的使用仅限于一个测试文件,那么仅仅在实际注入的服务上使用间谍可能更有意义。在这个示例中,这正是我们要做的。

做好准备

这个配方的项目位于chapter10/start_here/using-spies-on-injected-service

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行npm run test

这将在控制台上使用 Jest 运行单元测试。你应该会看到类似以下输出:

图 10.8 – 'using-spies-on-injected-service'项目的单元测试失败

图 10.8 – 'using-spies-on-injected-service'项目的单元测试失败

现在我们已经在本地运行了测试,在下一节中,让我们通过配方的步骤。

如何做到...

我们在CounterComponent代码中的测试是不完整的。这是因为我们缺少expect()块和对CounterService方法进行监听的代码。让我们开始使用实际的CounterService来完成编写测试的配方,如下所示:

  1. 首先,我们需要在测试中获取实际注入的服务的实例。因此,我们将创建一个变量,并在beforeEach()方法中获取注入的服务。更新counter.component.spec.ts文件如下:
...
describe('CounterComponent', () => {
  let component: CounterComponent;
  let fixture: ComponentFixture<CounterComponent>;
  let counterService: CounterService;
  beforeEach(async () => {...});
  beforeEach(() => {
    fixture = TestBed.createComponent(CounterComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
    counterService = TestBed.inject(CounterService);
  });
  ...
});
  1. 现在,我们将为服务编写我们的第一个expect()块。对于测试中说的'应该在组件初始化时调用 localStorage.getItem 方法',添加以下spyOn()expect()块:
...
describe('CounterComponent', () => {
  ...
  it('should call the localStorage.getItem method on   component init', () => {
    spyOn(counterService, 'getFromStorage');
    component.ngOnInit();
    expect(counterService.getFromStorage).    toHaveBeenCalled();
  });
  ...
});

如果再次运行npm run test,你应该仍然会看到一个测试失败,但其余测试通过。

  1. 现在,让我们修复失败的测试。即'应该在组件初始化时从 localStorage 中检索到上次保存的值'。在这种情况下,我们需要监听CounterServicegetFromStorage()方法,以返回预期值12。为此,请更新测试文件,如下所示:
...
describe('CounterComponent', () => {
  ...
  it('should retrieve the last saved value from   localStorage on component init', () => {
    spyOn(counterService, 'getFromStorage').and.    returnValue(12);
    component.ngOnInit();
    expect(component.counter).toBe(12);
  });
  ...
});
  1. 最后,让我们修复我们的最后一个测试,我们期望increment()decrement()reset()方法调用CounterServicesaveToStorage()方法。更新测试如下:
...
describe('CounterComponent', () => {
  ...
  it('should save the new counterValue to localStorage   on increment, decrement and reset', () => {
    spyOn(counterService, 'saveToStorage');
    component.counter = 0;
    component.increment();
    expect(counterService.saveToStorage).    toHaveBeenCalledWith(1);
    component.counter = 20;
    component.decrement();
    expect(counterService.saveToStorage).    toHaveBeenCalledWith(19);
    component.reset();
    expect(counterService.saveToStorage).    toHaveBeenCalledWith(0);
  });
});

太棒了!通过这个改变,你应该看到所有 12 个测试都通过了。让我们看看下一节,以了解它是如何工作的。

它是如何工作的...

这个配方包含了本章先前配方中的许多知识。然而,关键亮点是TestBed.inject()方法。基本上,这个神奇的方法会将提供的服务实例CounterService传递给我们。这是与CounterComponent实例绑定的服务实例。由于我们可以访问与组件实例使用的相同服务实例,我们可以直接对其进行监视,并期望它被调用,甚至可以模拟返回的值。

另请参阅

使用 ng-mocks 包模拟子组件和指令

单元测试主要围绕着对组件进行孤立测试。但是,如果您的组件完全依赖于另一个组件或指令才能正常工作呢?在这种情况下,通常会为组件提供一个模拟实现,但这是很多工作。然而,使用ng-mocks包就非常简单。在这个配方中,我们将学习如何使用ng-mocks来进行一个高级示例,即父组件依赖于子组件才能正常工作。

准备就绪

我们将要处理的项目位于chapter10/start_here/mocking-components-with-ng-mocks中,这是在克隆存储库内部的。执行以下步骤:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该会在新的浏览器标签中打开应用程序。您应该会看到类似以下截图的内容:

图 10.9 - 运行在 http://localhost:4200 上的 mocking-components-with-ng-mocks 应用程序

图 10.9 - 运行在 http://localhost:4200 上的 mocking-components-with-ng-mocks 应用程序

现在我们的应用程序在本地运行,接下来让我们在下一节中按照配方的步骤进行操作。

如何做...

如果运行yarn test命令或npm run test命令,您会发现并非所有测试都通过了。此外,控制台上会出现一堆错误,如下所示:

图 10.10 - 单元测试期间出现未知元素错误

图 10.10 - 单元测试期间出现未知元素错误

让我们按照配方确保我们的测试通过ng-mocks包正确无误地运行:

  1. 首先,让我们在项目中安装ng-mocks包。为此,请在终端中从项目根目录运行以下命令:
npm install ng-mocks --save
# or
yarn add ng-mocks
  1. 现在,我们将尝试修复AppComponent的测试。为了只基于字符串正则表达式运行特定的测试,我们可以使用jest命令的-t参数。运行以下命令,只运行AppComponent的测试:
npm run test -- -t 'AppComponent'
#or
yarn test -- -t 'AppComponent'

现在你可以看到我们只运行AppComponent的测试,它们失败如下:

图 10.11 – 错误 – 'app-version-control'不是已知元素

图 10.11 – 错误 – 'app-version-control'不是已知元素

  1. 为了解决图 10.11中显示的错误,我们将VersionControlComponent导入app.component.spec.ts文件中的TestBed定义。这样我们的测试环境也会知道缺少的VersionControlComponent。为此,请按照以下方式修改提到的文件:
...
import { VersionControlComponent } from './components/version-control/version-control.component';
...
describe('AppComponent', () => {
  beforeEach(waitForAsync(() => {
    TestBed.configureTestingModule({
      imports: [RouterTestingModule],
      declarations: [AppComponent,       VersionControlComponent],
    }).compileComponents();
  }));
  ...
});

重新运行AppComponent的测试,你会看到一些更新的错误。惊喜!这就是依赖关系的影响。我们将在它是如何工作的...部分详细讨论细节。然而,为了解决这个问题,让我们按照下面的步骤进行。

  1. 我们不需要直接提供VersionControlComponent,而是需要模拟它,因为我们对AppComponent的测试并不真正关心它。为此,请按照以下方式更新app.component.spec.ts文件:
...
import { MockComponent } from 'ng-mocks';
...
describe('AppComponent', () => {
  beforeEach(waitForAsync(() => {
    TestBed.configureTestingModule({
      imports: [RouterTestingModule],
      declarations: [AppComponent,       MockComponent(VersionControlComponent)],
    }).compileComponents();
  }));
  ...
});

问题解决了!再次运行测试,只针对AppComponent,你应该看到它们都通过了,如下所示:

图 10.12 – 通过所有的 AppComponent 测试

图 10.12 – 通过所有的 AppComponent 测试

  1. 现在,让我们来谈谈VersionControlComponent的测试。这取决于ReleaseFormComponent以及ReleaseLogsComponent。这次让我们像专业人士一样模拟它们,使用MockBuilderMockRender方法,这样我们就可以在测试过程中摆脱错误。更新后,version-control.component.spec.ts文件应如下所示:
import { MockBuilder, MockedComponentFixture, MockRender } from 'ng-mocks';
import { ReleaseFormComponent } from '../release-form/release-form.component';
import { ReleaseLogsComponent } from '../release-logs/release-logs.component';
import { VersionControlComponent } from './version-control.component';
describe('VersionControlComponent', () => {
  let component: VersionControlComponent;
  let fixture: MockedComponentFixture   <VersionControlComponent>;
  beforeEach(() => {
    return MockBuilder(VersionControlComponent)
      .mock(ReleaseFormComponent)
      .mock(ReleaseLogsComponent);
  });
  beforeEach(() => {
    fixture = MockRender(VersionControlComponent);
    component = fixture.point.componentInstance;
  });
  it('should create', () => {...});
});

现在运行npm run test,你应该看到所有的测试都通过了。在接下来的步骤中,让我们实际编写一些有趣的测试。

  1. VersionControlComponentReleaseLogsComponent作为子组件使用。此外,它通过[logs]属性将releaseLogs属性作为@Input()提供给ReleaseLogsComponent。我们实际上可以检查输入的值是否设置正确。为此,请按照以下方式更新version-control.component.spec.ts文件:
import {
  MockBuilder,
  MockedComponentFixture,
  MockRender,
  ngMocks,
} from 'ng-mocks';
import { Apps } from 'src/app/constants/apps';
...
describe('VersionControlComponent', () => {
  ...
  it('should set the [logs] @Input for the   ReleaseLogsComponent', () => {
    const releaseLogsComponent = ngMocks.    find<ReleaseLogsComponent>(
      'app-release-logs'
    ).componentInstance;
    const logsStub = [{ app: Apps.DRIVE, version:     '2.2.2', message: '' }];
    component.releaseLogs = [...logsStub];
    fixture.detectChanges();
    expect(releaseLogsComponent.logs.length).toBe(1);
    expect(releaseLogsComponent.logs).toEqual([...logsStub]);
  });
});
  1. 现在我们将确保当我们通过ReleaseFormComponent创建了一个新的日志时,我们通过将其添加到VersionControlComponent中的releaseLogs数组中来显示这个新的日志。然后,我们还将其作为@Input logs传递给ReleaseLogsComponent。将以下测试添加到version-control.component.spec.ts文件中:
...
describe('VersionControlComponent', () => {
  ...
  it('should add the new log when it is created via   ReleaseFormComponent', () => {
    const releaseFormsComponent = ngMocks.    find<ReleaseFormComponent>('app-release-form').    componentInstance;
    const releaseLogsComponent = ngMocks.    find<ReleaseLogsComponent>('app-release-logs').    componentInstance;
    const newLogStub = { app: Apps.DRIVE, version:    '2.2.2', message: '' };
    component.releaseLogs = []; // no logs initially
    releaseFormsComponent.newReleaseLog.emit(newLogStub);     // add a new log
    fixture.detectChanges(); // detect changes
    expect(component.releaseLogs).toEqual([newLogStub]);     // VersionControlComponent logs
    expect(releaseLogsComponent.logs).    toEqual([newLogStub]); // ReleaseLogsComponent logs
  });
});

哇!我们通过使用ng-mocks包实现了一些有趣的测试。每次我使用它时,我都非常喜欢它。现在我们已经完成了这个配方,在下一节,让我们来看看它是如何工作的。

它是如何工作的...

在这个配方中,我们涵盖了一些有趣的事情。首先,为了避免控制台报告未知组件的错误,我们使用了ng-mocks包中的MockComponent方法,将我们依赖的组件声明为模拟组件。这绝对是我们通过ng-mocks包实现的最简单的事情。然而,我们确实进入了一个高级的情况,我承认这是一种非常规的方法;那就是在父组件中测试子组件的@Input@Output发射器,以测试整个流程。这就是我们为VersionControlComponent的测试所做的。

请注意,我们完全从version-control.component.spec.ts文件中移除了对@angular/core/testing包的使用。这是因为我们不再使用TestBed来创建测试环境。相反,我们使用ng-mocks包中的MockBuilder方法来构建VersionControlComponent的测试环境。然后,我们使用.mock()方法来模拟我们稍后在测试中要使用的每个子组件。.mock()方法不仅用于模拟组件,还可以用于模拟服务、指令、管道等。请参考下一节以获取更多阅读资源。

然后,在'should add the new log when it is created via ReleaseFormComponent'测试中,注意我们使用的ngMocks.find()方法,用于找到相关组件并获取其实例。它的使用方式与我们在TestBed中所做的相对类似,如下所示:

fixture.debugElement.query(
  By.css('app-release-form')
).componentInstance

然而,使用ngMocks.find()更合适,因为它对类型有更好的支持。一旦我们掌握了ReleaseFormComponent的实例,我们就使用名为newReleaseLog@Output来使用.emit()方法创建新日志。然后,我们快速进行fixture.detectChanges()以触发 Angular 变更检测。我们还检查VersionControl.releaseLogs数组,以确定我们的新发布日志是否已添加到数组中。之后,我们还检查ReleaseLogsComponent.logs属性,以确保子组件已通过@Input更新了logs数组。

重要说明

请注意,我们不在VersionControlComponent.addNewReleaseLog方法上使用间谍。这是因为如果我们这样做,该函数将成为 Jest 间谍函数。因此,它将失去其内部功能。反过来,它将永远不会将新日志添加到releaseLogs数组中,我们的测试也不会通过。你可以试试看。

另请参阅

使用 Angular CDK 组件挽具更轻松的组件测试

在为组件编写测试时,可能会出现您实际上希望与 DOM 元素进行交互的情况。现在,这可以通过使用fixture.debugElement.query方法找到使用选择器的元素,然后在其上触发事件来实现。但是,这意味着为不同平台维护它,了解所有选择器的标识符,然后在测试中公开所有这些。如果我们谈论的是一个 Angular 库,情况会更糟。每个与我的库交互的开发人员都不需要知道所有元素选择器才能编写测试。只有库的作者应该知道这么多以尊重封装。幸运的是,我们有来自 Angular CDK 团队的组件挽具,它们是与 IVY 编译器一起在 Angular 9 发布的。他们以身作则,为 Angular 材料组件提供了组件挽具。在这个教程中,您将学习如何创建自己的组件挽具。

准备就绪

我们将要使用的项目位于克隆存储库内的chapter10/start_here/tests-using-cdk-harness中。执行以下步骤:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这将在新的浏览器标签页中打开应用程序。你应该看到类似于以下截图的内容:

图 10.13 - 在 http://localhost:4200 上运行的 tests-using-cdk-harness 应用程序

图 10.13 - 在 http://localhost:4200 上运行的 tests-using-cdk-harness 应用程序

现在应用程序正在运行,让我们继续下一节按照配方进行操作。

如何做...

我们有一个我们喜爱的 Angular 版本控制应用程序,允许我们创建发布日志。我们已经编写了测试,包括与 DOM 元素交互以验证一些用例的测试。让我们按照配方改用组件 harness,并发现在实际测试中使用它变得多么容易:

  1. 首先,打开一个新的终端窗口/标签,并确保你在chapter10/start_here/tests-using-cdk-harness文件夹内。进入后,运行以下命令安装 Angular CDK:
npm install --save @angular/cdk@12.0.0
  1. 你需要重新启动你的 Angular 服务器。因此,重新运行ng serve命令。

  2. 首先,我们将为ReleaseFormComponent创建一个组件 harness。让我们在release-form文件夹内创建一个新文件,并将其命名为release-form.component.harness.ts。然后,在其中添加以下代码:

import { ComponentHarness } from '@angular/cdk/testing';
export class ReleaseFormComponentHarness extends ComponentHarness {
  static hostSelector = 'app-release-form';
  protected getSubmitButton = this.  locatorFor('button[type=submit]');
  protected getAppNameInput = this.  locatorFor(`#appName`);
  protected getAppVersionInput = this.  locatorFor(`#versionNumber`);
  protected getVersionErrorEl = async () => {
    const alerts = await this.locatorForAll('.alert.    alert-danger')();
    return alerts[1];
  };
}
  1. 现在我们需要为我们的VersionControlComponent测试设置 harness 环境。为此,我们将使用 Angular CDK 中的HarnessLoaderTestbedHarnessEnvironment。按照以下方式更新version-control.component.spec.ts文件:
...
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
describe('VersionControlComponent', () => {
  let component: VersionControlComponent;
  let fixture: ComponentFixture<VersionControlComponent>;
  let harnessLoader: HarnessLoader;
  ...
  beforeEach(() => {
    fixture = TestBed.    createComponent(VersionControlComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
    harnessLoader = TestbedHarnessEnvironment.    loader(fixture);
  });
  ...
});
  1. 现在,让我们在我们的ReleaseFormComponentHarness类中编写一些方法来获取相关信息。我们将在后续步骤中使用这些方法。按照以下方式更新release-form.component.harness.ts文件:
...
export class ReleaseFormComponentHarness extends ComponentHarness {
  ...
  async getSelectedAppName() {
    const appSelectInput = await this.getAppNameInput();
    return appSelectInput.getProperty('value');
  }
  async clickSubmit() {
    const submitBtn = await this.getSubmitButton();
    return await submitBtn.click();
  }
  async setNewAppVersion(version: string) {
    const versionInput = await this.getAppVersionInput();
    return await versionInput.sendKeys(version);
  }
  async isVersionErrorShown() {
    const versionErrorEl = await this.    getVersionErrorEl();
    const versionErrorText = await versionErrorEl.text();
    return (
      versionErrorText.trim() === 'Please write an       appropriate version number'
    );
  }
}
  1. 接下来,我们将使用组件 harness 来进行我们的第一个测试,命名为“'应该选择第一个应用程序以进行新的发布日志'”。按照以下方式更新version-control.component.spec.ts文件:
...
import { ReleaseFormComponentHarness } from '../release-form/release-form.component.harness';
describe('VersionControlComponent', () => {
  ...
  it('should have the first app selected for the new   release log', async () => {
    const rfHarness = await harnessLoader.getHarness(
      ReleaseFormComponentHarness
    );
    const appSelect = await rfHarness.    getSelectedAppName();
    expect(appSelect).toBe(Apps.DRIVE);
  });
  ...
});

现在如果你运行npm run test,你应该看到所有的测试都通过了,这意味着我们使用组件 harness 进行的第一个测试成功了。哇呼!

  1. 现在,我们将开始进行第二个测试,即“'应该在输入错误的版本号时显示错误'”。按照以下方式更新version-control.component.spec.ts文件中的测试:
...
describe('VersionControlComponent', () => {
  ...
  it('should show error on wrong version number input',   async () => {
    const rfHarness = await harnessLoader.getHarness(
      ReleaseFormComponentHarness
    );
    await rfHarness.setNewAppVersion('abcd');
    const isErrorshown = await rfHarness.    isVersionErrorShown();
    expect(isErrorshown).toBeTruthy();
  });
  ...
});

砰!请注意,我们刚刚将此测试的代码行数从九个语句减少到了只有四个语句。这不是很神奇吗?老实说,我认为这很棒,而且更加清晰。

  1. 对于最终的测试,我们还需要为ReleaseLogsComponent创建一个组件测试工具。让我们快速创建它。在release-logs文件夹中添加一个名为release-logs.component.harness.ts的新文件,并添加以下代码:
import { ComponentHarness } from '@angular/cdk/testing';
export class ReleaseLogsComponentHarness extends ComponentHarness {
  static hostSelector = 'app-release-logs';
  protected getLogsElements = this.locatorForAll   ('.logs__item');
  async getLogsLength() {
    const logsElements = await this.getLogsElements();
    return logsElements.length;
  }
  async getLatestLog() {
    const logsElements = await this.getLogsElements();
    return await logsElements[0].text();
  }
  async validateLatestLog(version, app) {
    const latestLogText = await this.getLatestLog();
    return (
      latestLogText.trim() === `Version ${version}       released for app ${app}`
    );
  }
}
  1. 最后,让我们修改version-control.component.spec.ts文件中的最终测试如下:
...
import { ReleaseFormComponentHarness } from '../release-form/release-form.component.harness';
import { ReleaseLogsComponentHarness } from '../release-logs/release-logs.component.harness';
describe('VersionControlComponent', () => {
  ...
  it('should show the new log in the list after adding   submitting a new log', async () => {
    const rfHarness = await harnessLoader.getHarness(
      ReleaseFormComponentHarness
    );
    const rLogsHarness = await harnessLoader.getHarness(
      ReleaseLogsComponentHarness
    );
    let logsLength = await rLogsHarness.getLogsLength();
    expect(logsLength).toBe(0); // no logs initially
    const APP = Apps.DRIVE;
    const VERSION = '2.3.6';
    await rfHarness.setNewAppVersion(VERSION);
    await rfHarness.clickSubmit();
    logsLength = await rLogsHarness.getLogsLength();
    expect(logsLength).toBe(1);
    const isNewLogAdded = await rLogsHarness.    validateLatestLog(VERSION, APP);
    expect(isNewLogAdded).toBe(true);
  });
});

哇!使用 Angular CDK 组件测试工具进行了一些令人惊叹的测试。如果现在运行测试,你应该能看到所有的测试都通过了。现在你已经完成了这个教程,请参考下一节来了解它是如何工作的。

它是如何工作的...

好了!这是一个很酷的教程,我自己很喜欢。这个教程的关键因素是@angular/cdk/testing包。如果你之前使用 Protractor 进行过e2e测试,这与 Protractor 中的Pages概念类似。首先,我们为ReleaseLogsComponentReleaseFormComponent分别创建了一个组件测试工具。

请注意,我们从@angular/cdk/testing导入了ComponentHarness类来为两个组件测试工具。然后,我们从ComponentHarness类扩展了我们的自定义类ReleaseFormComponentHarnessReleaseLogsComponentHarness。基本上,这是编写组件测试工具的正确方式。你注意到了叫做hostSelector的静态属性吗?我们需要为我们创建的每个组件测试工具类添加这个属性。而且这个值总是目标元素/组件的选择器。这确保了当我们将这个测试工具加载到测试环境中时,环境能够在 DOM 中找到宿主元素,也就是我们正在创建组件测试工具的元素。在我们的组件测试工具类中,我们使用this.locatorFor()方法来查找宿主组件中的元素。locateFor()方法接受一个参数,即要查找的元素的css 选择器,并返回一个AsyncFactoryFn。这意味着返回的值是一个我们可以在以后使用的函数,用来获取所需的元素。

ReleaseFormComponentHarness类中,我们使用protected方法getSubmitButtongetAppNameInputgetAppVersionInput分别找到提交按钮、应用程序名称输入和版本号输入,这些方法都是AsyncFactoryFn类型,如前所述。我们将这些方法设置为protected,因为我们不希望编写单元测试的人访问或关心 DOM 元素的信息。这样做可以让每个人更轻松地编写测试,而不用担心访问 DOM 的内部实现。

请注意,getVersionErrorEl()方法略有不同。它实际上不是AsyncFactoryFn类型。相反,它是一个常规的async函数,首先调用locatorForAll方法获取所有具有alert类和alert-danger类的元素,这些元素是错误消息。然后,它选择第二个警报元素,用于应用程序版本号输入。

这里需要提到的一件重要的事情是,当我们调用locatorFor()方法或locatorForAll()方法时,我们会得到一个带有TestElement项的Promise,或者一个TestElement项列表的Promise。每个TestElement项都有一堆方便的方法,比如.click().sendKeys().focus().blur().getProperty().text()等等。这些方法是我们感兴趣的,因为我们在幕后使用它们与 DOM 元素进行交互。

现在,让我们谈谈如何配置测试环境。在version-control.component.spec.ts文件中,我们设置环境使用ReleaseLogsComponentReleaseFormComponent的组件挽具。这里的关键元素是TestbedHarnessEnvironment元素。我们使用TestbedHarnessEnvironment类的.loader()方法,通过提供我们的fixture作为参数。请注意,fixture 是我们在测试环境中使用TestBed.createComponent(VersionControlComponent)语句获得的。因为我们将这个 fixture 提供给TestbedHarnessEnvironment.loader()方法,我们得到了一个HarnessLoader语句的元素,现在可以为其他组件加载组件挽具,即ReleaseLogsComponentReleaseFormComponent

请注意,在测试中,我们使用 harnessLoader.getHarness() 方法,通过提供 harness 类作为参数。这使得测试环境能够找到与 harness 类的 hostSelector 属性相关联的 DOM 元素。此外,我们还可以获得组件 harness 的实例,以便在测试中进一步使用。

另请参阅

  • 使用组件 harness 在 DOM 中查找组件 (https://material.angular.io/cdk/test-harnesses/overview#finding-elements-in-the-components-dom)

  • 组件 harness 作者的 API (https://material.angular.io/cdk/test-harnesses/overview#api-for-component-harness-authors)

使用 Observables 进行组件的单元测试

如果您正在构建 Angular 应用程序,很可能会在应用程序中的某个时候使用 Observables。例如,您可能会从第三方 API 获取数据,或者仅仅是管理状态。在任何情况下,测试具有 Observables 的应用程序会变得稍微困难。在本食谱中,我们将学习如何使用 Observables 进行单元测试。

准备就绪

此食谱的项目位于 chapter10/start_here/unit-testing-observables。执行以下步骤:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行 npm install 来安装项目的依赖项。

  3. 完成后,运行 ng serve -o

这将在新的浏览器选项卡中打开应用程序。您应该看到类似以下截图的内容:

图 10.14 – 在 http://localhost:4200 上运行的 unit-testing-observables 应用程序

图 10.14 – 在 http://localhost:4200 上运行的 unit-testing-observables 应用程序

现在我们已经在本地运行了应用程序,在下一节中,让我们来看一下食谱的步骤。

如何做…

我们将首先编写测试用例,这在技术上涉及使用 Observables。基本上,我们必须使用 Observables 模拟方法,并且必须使用 Angular 提供的 fakeAsynctick() 方法来达到编写具有 Observables 的良好单元测试的目标。让我们开始吧:

  1. 首先,我们将编写一个测试,看看当我们在包含 Observable 的函数中使用 expect() 语句时会发生什么。通过在 users.component.spec.ts 文件中添加一个测试,检查在组件初始化时是否从服务器获取用户:
import { HttpClientModule } from '@angular/common/http';
import {
  ComponentFixture,
  fakeAsync,
  TestBed,
  tick,
} from '@angular/core/testing';
...
describe('UsersComponent', () => {
  ...
  it('should get users back from the API component init',   fakeAsync(() => {
    component.ngOnInit();
    tick(500);
    expect(component.users.length).toBeGreaterThan(0);
  }));
});

现在,一旦你运行npm run test命令,你会看到测试失败并显示以下消息:

图 10.15 - 错误 - 无法在伪异步测试中进行 XHR 请求

图 10.15 - 错误 - 无法在伪异步测试中进行 XHR 请求

这意味着我们不能在fakeAsync测试中进行真实的 HTTP 调用,这就是在调用ngOnInit()方法后发生的情况。

  1. 正确的测试方法是模拟UserService。幸运的是,我们已经在项目中做过这个,因为我们有UserServiceMock类。我们需要将它提供为TestBedUserServiceuseClass属性,并稍微更新我们的测试。让我们修改users.component.spec.ts文件,如下所示:
...
import {
  DUMMY_USERS,
  UserServiceMock,
} from 'src/__mocks__/services/user.service.mock';
...
describe('UsersComponent', () => {
  ...
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [UsersComponent, UserCardComponent],
      providers: [
        {
          provide: UserService,
          useClass: UserServiceMock,
        },
      ],
      imports: [HttpClientModule, ReactiveFormsModule,       RouterTestingModule],
    }).compileComponents();
  });
  ...
  it('should get users back from the API component init',   fakeAsync(() => {
    component.ngOnInit();
    tick(500);
    expect(component.users.length).toBe(2);
    expect(component.users).toEqual(DUMMY_USERS);
  }));
});

现在,如果你再次运行测试,它们应该通过。我们稍后会在它是如何工作...部分详细介绍这一点。

  1. 让我们为一个想要搜索用户的场景添加另一个测试。我们将设置username表单控件的值,并使用UserService或更准确地说是UserServiceMock来搜索用户。然后,我们期望结果是合适的。在users.component.spec.ts文件中添加一个测试,如下所示:
...
describe('UsersComponent', () => {
  ...
  it('should get the searched users from the API upon   searching', fakeAsync(() => {
    component.searchForm.get('username').    setValue('hall');
    // the second record in our DUMMY_USERS array has     the name Mrs Indie Hall
    const expectedUsersList = [DUMMY_USERS[1]];
    component.searchUsers();
    tick(500);
    expect(component.users.length).toBe(1);
    expect(component.users).toEqual(expectedUsersList);
  }));
});
  1. 现在我们将为UserDetailComponent编写一个测试。我们需要测试当组件初始化时,UserDetailComponent能否从服务器获取到适当的用户,并且我们也能获取到相似的用户。在user-detail.component.spec.ts文件中添加一个测试,如下所示:
...
import {..., fakeAsync, tick, } from '@angular/core/testing';
...
import { UserServiceMock } from 'src/__mocks__/services/user.service.mock';
describe('UserDetailComponent', () => {
  ...
  beforeEach(
    waitForAsync(() => {
      TestBed.configureTestingModule({
        declarations: [...],
        imports: [HttpClientModule, RouterTestingModule],
        providers: [
          {
            provide: UserService,
            useClass: UserServiceMock,
          },
        ],
      }).compileComponents();
    })
  );
  ...
  it('should get the user based on routeParams on page   load', fakeAsync(() => {
    component.ngOnInit();
    tick(500);
    expect(component.user).toBeTruthy();
  }));
});

新的测试目前应该是失败的。我们将在接下来的步骤中修复它。

  1. 为了调试,我们可以在ngOnInit()方法中订阅route.paramMap Observable 并快速添加一个console.log()来打印我们从params中获取的内容。修改user-detail.component.ts文件,然后再次运行测试:
...
@Component({...})
export class UserDetailComponent implements OnInit, OnDestroy {
  ...
  ngOnInit() {
    this.isComponentAlive = true;
    this.route.paramMap
      .pipe(
        takeWhile(() => !!this.isComponentAlive),
        flatMap((params) => {
          this.user = null;
          console.log('params', params);
          ...
          return this.userService.getUser(userId).          pipe(...);
        })
      )
      .subscribe((similarUsers: IUser[]) => {...});
  }
  ...
}

现在当你运行测试时,你会看到错误,如下所示:

图 10.16 - 错误 - 空参数和缺少 uuid

图 10.16 - 错误 - 空参数和缺少 uuid

  1. 正如你在图 10.16中所看到的,我们在Params对象中没有uuid。这是因为这不是一个真实用户的真实路由过程。因此,我们需要模拟UserDetailComponent中使用的ActivatedRoute服务以获得期望的结果。让我们在__mocks__文件夹内创建一个名为activated-route.mock.ts的新文件,并将以下代码添加到其中:
import { convertToParamMap, ParamMap, Params } from '@angular/router';
import { ReplaySubject } from 'rxjs';
/**
 * An ActivateRoute test double with a `paramMap`  observable.
 * Use the `setParamMap()` method to add the next  `paramMap` value.
 */
export class ActivatedRouteMock {
  // Use a ReplaySubject to share previous values with   subscribers
  // and pump new values into the `paramMap` observable
  private subject = new ReplaySubject<ParamMap>();
  constructor(initialParams?: Params) {
    this.setParamMap(initialParams);
  }
  /** The mock paramMap observable */
  readonly paramMap = this.subject.asObservable();
  /** Set the paramMap observables's next value */
  setParamMap(params?: Params) {
    this.subject.next(convertToParamMap(params));
  }
}
  1. 现在我们将在UserDetailComponent的测试中使用这个模拟。更新user-detail.component.spec.ts文件,如下所示:
...
import { ActivatedRouteMock } from 'src/__mocks__/activated-route.mock';
import {
  DUMMY_USERS,
  UserServiceMock,
} from 'src/__mocks__/services/user.service.mock';
...
describe('UserDetailComponent', () => {
  ...
  let activatedRoute;
  beforeEach(
    waitForAsync(() => {
      TestBed.configureTestingModule({
        ...
        providers: [
          {...},
          {
            provide: ActivatedRoute,
            useValue: new ActivatedRouteMock(),
          },
        ],
      }).compileComponents();
    })
  );
  beforeEach(() => {
    ...
    fixture.detectChanges();
    activatedRoute = TestBed.inject(ActivatedRoute);
  });
  ...
});
  1. 现在我们已经将模拟注入到测试环境中,让我们修改我们的测试以从DUMMY_USERS数组中获取第二个用户。更新测试文件如下:
...
describe('UserDetailComponent', () => {
  ...
  it('should get the user based on routeParams on page   load', fakeAsync(() => {
    component.ngOnInit();
    activatedRoute.setParamMap({ uuid: DUMMY_USERS[1].    login.uuid });
    tick(500);
    expect(component.user).toEqual(DUMMY_USERS[1]);
  }));
});
  1. 现在我们将编写一个测试,当加载UserDetailComponent时,允许我们获取相似的用户。请记住,根据我们当前的业务逻辑,相似的用户是除了页面上保存在user属性中的当前用户之外的所有用户。让我们在user-detail.component.spec.ts文件中添加测试,如下所示:
...
describe('UserDetailComponent', () => {
  ...
  it('should get similar user based on routeParams uuid   on page load', fakeAsync(() => {
    component.ngOnInit();
    activatedRoute.setParamMap({ uuid: DUMMY_USERS[1].    login.uuid }); // the second user's uuid
    const expectedSimilarUsers = [DUMMY_USERS[0]]; //     the first user
    tick(500);
    expect(component.similarUsers).    toEqual(expectedSimilarUsers);
  }));
});

如果你运行测试,你应该看到它们都通过,如下所示:

图 10.17 - 所有的测试都通过了模拟的 Observables

图 10.17 - 所有的测试都通过了模拟的 Observables

太棒了!现在你知道如何在编写组件的单元测试时使用 Observables 了。虽然在 Angular 中测试 Observables 还有很多要学习的,但这个教程的目的是保持一切简单和甜美。

现在你已经完成了这个教程,请参考下一节以了解它是如何工作的。

它是如何工作的...

我们通过使用'@angular/core/testing'包中的fakeAsync()tick()方法来开始我们的教程。请注意,我们使用fakeAsync()方法包装我们测试的回调方法。在fakeAsync()方法中包装的方法是在一个叫做fakeAsync区域中执行的。这与实际的 Angular 应用程序运行在ngZone内的方式相反。

重要提示

为了使用fakeAsync区域,我们需要在测试环境中导入zone.js/dist/zone-testing库。当你创建一个 Angular 项目时,通常会在src/test.ts文件中进行这个操作。然而,由于我们迁移到了 Jest,我们删除了那个文件。

“好的。那么,它是如何工作的,阿赫桑?”好吧,我很高兴你问。在为 Jest 设置时,我们使用jest-preset-angular包。这个包最终需要为fakeAsync测试导入所有必要的文件,如下所示:

图 10.18 - jest-preset-angular 包导入所需的 zone.js 文件

图 10.18 - jest-preset-angular 包导入所需的 zone.js 文件

基本上,tick()方法在这个虚拟的fakeAsync区域中模拟时间的流逝,直到所有的异步任务都完成。它接受一个毫秒参数,反映了经过了多少毫秒或虚拟时钟前进了多少。在我们的情况下,我们使用500毫秒作为tick()方法的值。

请注意,我们为UsersComponent的测试模拟了UserService。特别是对于'should get users back from the API component init',我们在测试中调用了component.ngOnInit()方法,然后调用了tick()方法。同时,ngOnInit()方法调用了searchUsers()方法,该方法调用了UserServiceMock.searchUsers()方法,因为我们在测试环境中为UserService提供了useClass属性。最后,它返回了我们在user.service.mock.ts文件中定义的DUMMY_USERS数组的值。对于UsersComponent的另一个测试,'should get the searched users from the API upon searching',也是非常相似的。

关于UserDetailComponent的测试,我们做了一些不同的事情,也就是,我们还必须模拟activatedRoute服务。为什么?那是因为UserDetailComponent是一个可以使用uuid导航的页面,并且因为它的路径在app-routing.module.ts文件中被定义为'/users/:uuid'。因此,我们需要在我们的测试中填充这个uuid参数,以便与DUMMY_USERS数组一起使用。为此,我们在__mocks__文件夹中使用ActivatedRouteMock类。请注意,它有一个setParamMap()方法。这允许我们在测试中指定uuid参数。然后,当实际代码订阅this.route.paramMap可观察对象时,我们设置的uuid参数就可以在那里找到。

对于'should get the user based on routeParams on page load'测试,我们将DUMMY_USERS数组中的第二个用户的uuid设置为uuid路由参数的值。然后,我们使用tick()方法,之后我们期望user属性的值是DUMMY_USERS数组中的第二个用户。文件中的另一个测试也是非常相似和不言自明的。有关单元测试场景的更多有用链接,请参考下一节。

另外

单元测试 Angular 管道

在我个人看来,管道是 Angular 应用程序中最容易测试的组件。为什么?嗯,这是因为它们(应该)是根据相同的输入集返回相同结果的纯函数。在这个食谱中,我们将为 Angular 应用程序中的一个非常简单的管道编写一些测试。

准备工作

我们要处理的项目位于chapter10/start_here/unit-testing-pipes中,这是在克隆的存储库中。执行以下步骤:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这将在新的浏览器选项卡中打开应用程序。您应该看到类似以下截图的内容:

图 10.19 - 运行在 http://localhost:4200 上的 unit-testing-pipes 应用程序

图 10.19 - 运行在 http://localhost:4200 上的 unit-testing-pipes 应用程序

既然我们已经在本地运行了应用程序,在下一节中,让我们按照食谱的步骤进行。

如何做...

在这里,我们有一个简单的食谱,需要两个输入 - 数字和最大因子值。根据这些输入,我们显示一个乘法表。根据我们的业务逻辑,我们已经有了工作正常的MultTablePipe。现在我们将编写一些单元测试来验证我们的输入和预期输出,如下所示:

  1. 让我们为MultTablePipe编写我们的第一个测试。我们将确保当digit输入的值无效时,它返回一个空数组。更新mult-table.pipe.spec.ts文件,如下所示:
...
describe('MultTablePipe', () => {
  ...
  it('should return an empty array if the value of digit   is not valid', () => {
    const digit = 0;
    const limit = 10;
    const outputArray = pipe.transform(null, digit,     limit);
    expect(outputArray).toEqual([]);
  });
});
  1. 让我们编写另一个测试,验证limit输入,以便在无效时也返回一个空数组:
...
describe('MultTablePipe', () => {
  ...
  it('should return an empty array if the value of limit   is not valid', () => {
    const digit = 10;
    const limit = 0;
    const outputArray = pipe.transform(null, digit,     limit);
    expect(outputArray).toEqual([]);
  });
});
  1. 现在我们将编写一个测试,验证管道的转换方法的输出,在digitlimit输入都有效时。在这种情况下,我们应该得到包含乘法表的数组。编写另一个测试如下:
...
describe('MultTablePipe', () => {
  ...
  it('should return the correct multiplication table when   both digit and limit inputs are valid', () => {
    const digit = 10;
    const limit = 2;
    const expectedArray = ['10 * 1 = 10', '10 * 2 = 20'];
    const outputArray = pipe.transform(null, digit,     limit);
    expect(outputArray).toEqual(expectedArray);
  });
});
  1. 现在,在应用程序中,我们有可能为“限制”输入提供小数位数。例如,我们可以在输入中将2.5写为最大因子。为了处理这个问题,我们在MultTablePipe中使用“Math.floor()”将其向下舍入到较低的数字。让我们编写一个测试来确保这个功能有效:
...
describe('MultTablePipe', () => {
  ...
  it('should round of the limit if it is provided in   decimals', () => {
    const digit = 10;
    const limit = 3.5;
    const expectedArray = ['10 * 1 = 10', '10 * 2 = 20',     '10 * 3 = 30']; // rounded off to 3 factors instead     of 3.5
    const outputArray = pipe.transform(null, digit,     limit);
    expect(outputArray).toEqual(expectedArray);
  });
});

易如反掌!为 Angular 管道编写测试是如此直接,以至于我喜欢它。我们可以称之为纯函数的力量。现在您已经完成了这个步骤,请参考下一节以获取更多信息链接。

另请参阅

第十一章:第十一章:使用 Cypress 在 Angular 中进行 E2E 测试

一个应用程序有几个端到端(E2E)测试,肯定比一个没有测试的应用程序更可靠,在当今世界,随着新兴企业和复杂应用程序的出现,编写端到端测试以捕获整个应用程序流程变得至关重要。Cypress 是当今用于 Web 应用程序的 E2E 测试的最佳工具之一。在本章中,您将学习如何使用 Cypress 在 Angular 应用程序中测试您的 E2E 流程。以下是本章中要涵盖的内容:

  • 编写您的第一个 Cypress 测试

  • 验证文档对象模型(DOM)元素是否在视图上可见

  • 测试表单输入和提交

  • 等待 XMLHttpRequest(XHR)完成

  • 使用 Cypress 捆绑包

  • 使用 Cypress fixtures 提供模拟数据。

技术要求

在本章的配方中,请确保您的计算机上已安装了 Git 和 Node.js。您还需要安装 @angular/cli 包,您可以在终端中使用 npm install -g @angular/cli 来完成。本章的代码可以在 github.com/PacktPublishing/Angular-Cookbook/tree/master/chapter11 找到。

编写您的第一个 Cypress 测试

如果您已经在编写 E2E 测试,您可能已经使用 Protractor 进行了这项工作。不过,使用 Cypress 是完全不同的体验。在这个配方中,您将使用现有的 Angular 应用程序设置 Cypress,并将使用 Cypress 编写您的第一个 E2E 测试。

准备工作

我们要处理的项目位于克隆存储库中的 chapter11/start_here/angular-cypress-starter 中:

  1. 在 Visual Studio Code 中打开项目(VS Code)。

  2. 打开终端并运行 npm install 来安装项目的依赖项。

现在我们已经在本地打开了项目,让我们在下一节中看看这个配方的步骤。

如何做…

我们要处理的应用程序是一个简单的计数器应用程序。它有最小和最大值,以及一些按钮,可以增加、减少和重置计数器的值。我们将首先为我们的应用程序配置 Cypress,然后开始编写测试:

  1. 首先,打开一个新的终端窗口/标签,并确保你在chapter11/start_here/angular-cypress-starter文件夹内。进入后,运行以下命令在我们的项目中安装Cypressconcurrently
npm install -d cypress concurrently
  1. 现在,打开你的package.json文件,并在scripts对象内添加以下脚本,如下所示:
{
  "name": "angular-cypress-starter",
  "version": "0.0.0",
  "scripts": {
    ... 
    "e2e": "ng e2e",
    "start:cypress": "cypress open",
  "cypress:test": "concurrently 'npm run start' 'npm run   start:cypress'"
  },
  ...
}
  1. 让我们运行cypress:test命令,同时启动http://localhost:4200的 Angular 服务器,并开始 Cypress 测试,如下所示:
npm run cypress:test

你还应该看到 Cypress 默认创建了一个名为cypress的文件夹,并在其中创建了一些示例测试。Cypress 还创建了一个cypress.json文件来提供一些配置。我们不会删除这些默认测试,而是在下一步中忽略它们。

  1. 通过修改cypress.json文件来忽略默认/示例测试,如下所示:
{
  "baseUrl": "http://localhost:4200",
  "ignoreTestFiles": "**/examples/*",
  "viewportHeight": 760,
  "viewportWidth": 1080
}
  1. 如果你现在再看 Cypress 窗口,你会发现我们没有任何集成测试,如下所示:图 11.1 - 没有集成测试可执行

图 11.1 - 没有集成测试可执行

  1. 让我们现在创建我们的第一个测试。我们只需检查我们应用程序的浏览器标题是否为编写您的第一个 Cypress 测试。在cypress/integration文件夹内创建一个名为app.spec.js的新文件,并粘贴以下代码:
/// <reference types="cypress" />
context('App', () => {
  beforeEach(() => {
    cy.visit('/');
  });
  it('should have the title "Writing your first Cypress   test "', () => {
    // https://on.cypress.io/title
    cy.title().should('eq', 'Writing your first Cypress     test');
  });
});
  1. 如果你再次看 Cypress 窗口,你会看到一个名为app.spec.js的新文件列出,如下所示:图 11.2 - 显示的新 app.spec.js 测试文件

图 11.2 - 显示的新 app.spec.js 测试文件

  1. 点击图 11.2中显示的窗口中的app.spec.js文件,你应该看到文件中编写的 Cypress 测试通过了。

砰!在几个步骤内,我们已经为我们的 Angular 应用程序设置了 Cypress,并编写了我们的第一个测试。你应该看到 Cypress 窗口,如下所示:

图 11.3 - 我们的第一个 Cypress 测试通过

图 11.3 - 我们的第一个 Cypress 测试通过

简单吧!对吧?现在你知道如何为 Angular 应用程序配置 Cypress 了,看看下一节来了解它是如何工作的。

它是如何工作的…

Cypress 可以与任何框架和 Web 开发项目集成。有趣的是,Cypress 在幕后使用 Mocha 作为测试运行器。Cypress 的工具会监视代码更改,这样你就不必一次又一次地重新编译测试。Cypress 还会在被测试的应用程序周围添加一个外壳,以捕获日志并在测试期间访问 DOM 元素,并提供一些用于调试测试的功能。

在我们的 app.spec.js 文件的顶部,我们使用 context() 方法来定义测试套件,基本上是定义即将在内部编写的测试的上下文。然后,我们使用 beforeEach() 方法来指定每个测试执行前应该发生什么。由于每个测试都从零数据开始,我们首先必须确保 Cypress 导航到我们应用程序的 http://localhost:4200 统一资源定位符 (URL)。我们之所以只指定 cy.visit('/') 并且它仍然有效,是因为我们已经在 cypress.json 文件中指定了 baseUrl 属性。因此,在我们的测试中只需提供相对 URL。

最后,我们使用 it() 方法来指定我们第一个测试的标题,然后我们使用 cy.title() 方法,这是一个方便的辅助工具,来获取当前正在呈现的超文本标记语言 (HTML)页面的标题的文本值。我们使用 'eq' 运算符来将其值与 '编写你的第一个 Cypress 测试' 字符串进行比较,一切正常!

另请参阅

验证 DOM 元素在视图上是否可见

在上一个示例中,我们学习了如何在 Angular 应用程序中安装和配置 Cypress。在您的应用程序中可能有不同的情况,您想要查看 DOM 上的元素是否可见。在这个示例中,我们将编写一些测试来确定 DOM 上是否有任何元素可见。

准备工作

此示例的项目位于 chapter11/start_here/cypress-dom-element-visibility

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行 npm install 来安装项目的依赖项。

  3. 完成后,运行 npm run cypress:test

这应该在https://localhost:4200上运行应用程序,并应该打开 Cypress 窗口,如下所示:

图 11.4–Cypress 测试运行 cypress-dom-element-visibility 应用程序

图 11.4–Cypress 测试运行 cypress-dom-element-visibility 应用程序

现在我们已经在本地运行了应用程序和 Cypress 测试,让我们在下一节中看到食谱的步骤。

如何做…

我们有与上一个食谱相同的旧计数器应用程序。但是,有些事情已经改变。现在我们在顶部有一个按钮,可以切换计数器组件(CounterComponent)的可见性。此外,我们必须悬停在计数器卡上才能看到增加减少重置操作按钮。让我们开始编写一些测试来检查计数器组件(CounterComponent)的可见性和操作:

  1. 让我们编写一个测试,检查当我们点击切换计数器可见性按钮以显示它时,计数器组件(CounterComponent)的可见性。我们将通过断言具有.counter__heading.counter类的元素的可见性来检查它。更新cypress/integration/app.spec.js文件,如下所示:
...
context('App', () => {
  ...
  it('should show the counter component when the "Toggle   Counter Visibility" button is clicked', () => {
    cy.get('.counter__heading').should('have.length', 0);
    cy.get('.counter').should('have.length', 0);
    cy.contains('Toggle Counter Visibility').click();
    cy.get('.counter__heading').should('be.visible');
    cy.get('.counter').should('be.visible');
  });
});
  1. 现在,我们将编写一个测试,检查当我们悬停在counter组件上时,我们的操作按钮(增加减少重置)是否显示出来。更新app.spec.js文件,如下所示:
...
context('App', () => {
  ...
  it('should show the action buttons on hovering the   counter card', () => {
    cy.contains('Toggle Counter Visibility').click();
    cy.get('.counter').trigger('mouseover');
    cy.get('.counter__actions__action').    should('have.length', 3);
    cy.contains('Increment').should('be.visible');
    cy.contains('Decrement').should('be.visible');
    cy.contains('Reset').should('be.visible');
  });
});

如果您现在查看 Cypress 窗口,您应该看到测试失败,如下所示:

图 11.5–悬停时无法获取操作按钮

图 11.5–悬停时无法获取操作按钮

测试失败的原因是 Cypress 目前不提供层叠样式表CSS)悬停效果。为了解决这个问题,我们将在下一步中安装一个包。

  1. 停止运行 Cypress 和 Angular 应用程序,然后安装cypress-real-events包,如下所示:
npm install --save-dev cypress-real-events
  1. 现在,打开cypress/support/index.js文件并更新如下:
...
// Import commands.js using ES2015 syntax:
import './commands';
import 'cypress-real-events/support';
...
  1. 现在,更新app.spec.js文件,使用包中的.realHover()方法在.counter元素上,如下所示:
/// <reference types="cypress" />
/// <reference types="cypress-real-events" />
context('App', () => {
  ...
  it('should show the action buttons on hovering the   counter card', () => {
    cy.contains('Toggle Counter Visibility').click();
    cy.get('.counter').realHover();
    cy.get('.counter__actions__action').    should('have.length', 3);
    ...
  });
});
  1. 现在,再次运行cypress:test命令,使用npm run cypress:test。一旦应用程序运行并且 Cypress 窗口打开,您应该看到所有测试都通过了,如下所示:

图 11.6–使用 cypress-real-events 包后所有测试都通过

图 11.6 - 使用 cypress-real-events 包后所有测试都通过

太棒了!您刚刚学会了如何在不同场景下检查 DOM 元素的可见性。当然,这些不是唯一可用的标识和与 DOM 元素交互的选项。现在您已经完成了这个配方,请查看下一节以了解它是如何工作的。

它是如何工作的…

在配方的开头,在我们的第一个测试中,我们使用.should('have.length', 0)断言。当我们使用'have.length'断言时,Cypress 会检查使用cy.get()方法找到的 DOM 元素的length属性。我们使用的另一个断言是.should('be.visible'),它检查元素在 DOM 上是否可见。只要元素在屏幕上可见,这个断言就会通过,也就是说,父元素中没有隐藏的元素。

在后面的测试中,我们尝试悬停在具有'.counter'选择器的元素上,使用cy.get('.counter').trigger('mouseover');。这导致我们的测试失败。为什么?因为 Cypress 中的所有悬停解决方法最终都会触发 JavaScript 事件,而不会影响 CSS 伪选择器,而且由于我们的操作按钮(使用'.counter__actions__action'选择器)显示在具有'.counter'选择器的元素的:hover(CSS)上,我们的测试失败,因为在测试中我们的操作按钮实际上没有显示。为了解决这个问题,我们使用cypress-real-events包,它具有.realHover()方法,可以影响伪选择器,并最终显示我们的操作按钮。

另请参阅

测试表单输入和提交

如果您正在构建 Web 应用程序,很有可能您的应用程序中至少会有一个表单,当涉及到表单时,我们需要确保我们有正确的用户体验UX)和正确的业务逻辑。有什么比编写 E2E 测试来确保一切都按预期工作更好的方法呢?在这个配方中,我们将使用 Cypress 测试登录表单。

做好准备

此配方的项目位于chapter11/start_here/cy-testing-forms中:

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行npm run cypress:test

这将打开一个新的 Cypress 窗口。点击app.spec.ts文件,你应该看到测试,如下所示:

图 11.7 - Cypress 测试正在运行 cy-testing-forms 应用程序

图 11.7 - Cypress 测试正在运行 cy-testing-forms 应用程序

现在我们已经运行了 Cypress 测试,让我们在下一节看看这个步骤的详细过程。

如何做…

我们必须确保当表单成功提交时,我们会看到一个成功提示。如果任何输入值无效,我们还需要确保我们看到相关的错误。让我们开始吧:

  1. 让我们在cypress/integration文件夹中创建一个名为login.spec.js的新文件。

  2. 首先,我们要确保除非我们有有效的表单输入,否则我们的表单不能被提交。为了做到这一点,让我们确保当没有输入值或无效值时,提交按钮被禁用。打开login.spec.js文件并添加一个测试,如下所示:

/// <reference types="cypress" />
context('Login', () => {
  beforeEach(() => {
    cy.visit('/');
  });
  it('should have the button disabled if the form inputs   are not valid', () => {
    // https://on.cypress.io/title
    // No input values
    cy.contains('Submit').should('be.disabled');
    cy.get('#passwordInput').type('password123');
    cy.contains('Submit').should('be.disabled');
    cy.get('#emailInput').type('ahsanayaz@gmail.com');
    cy.get('#passwordInput').clear();
    cy.contains('Submit').should('be.disabled');
  });
});

现在,在 Cypress 窗口中打开login.spec.js文件,你应该看到测试都通过了,如下所示:

图 11.8 - 检查当输入无效时提交按钮是否被禁用

图 11.8 - 检查当输入无效时提交按钮是否被禁用

  1. 让我们添加另一个测试,验证当输入正确的值时,我们会看到一个成功提示。在login.spec.js文件中添加另一个测试,如下所示:
...
context('Login', () => {
  ...
  it('should submit the form with the correct values and   show the success alert', () => {
    cy.get('#emailInput')
      .type('ahsan.ayaz@domain.com')
      .get('#passwordInput')
      .type('password123');
    cy.contains('Submit').click();
    cy.get('.alert.alert-success').should('be.visible');
  });
});
  1. 现在我们将添加另一个测试,以确保成功提示在点击关闭按钮时隐藏。由于我们在成功登录时使用相同的逻辑/代码,我们将创建一个函数来重用它。让我们修改login.spec.js文件,如下所示:
...
context('Login', () => {
  ...
  it('should submit the form with the correct values and   show the success alert', () => {
    successfulLogin();
    cy.get('.alert.alert-success').should('be.visible');
  });
  it('should hide the success alert on clicking close   button', () => {
    successfulLogin();
    cy.get('.alert.alert-success').find('.btn-close').    click();
    cy.get('.alert.alert-success').should((domList) => {
      expect(domList.length).to.equal(0);
    });
  });
});
function successfulLogin() {
  cy.get('#emailInput')
    .type('ahsan.ayaz@domain.com')
    .get('#passwordInput')
    .type('password123');
  cy.contains('Submit').click();
}
  1. 成功提示在输入更改时也应该隐藏。为了检查这一点,让我们添加另一个测试,如下所示:
...
context('Login', () => {
  ...
  it('should hide the success alert on changing the   input', () => {
    successfulLogin();
    cy.get('#emailInput').clear().    type('mohsin.ayaz@domain.com');
    cy.get('.alert.alert-success').should((domList) => {
      expect(domList.length).to.equal(0);
    });
  });
});
  1. 最后,让我们编写一个测试,确保我们在输入无效时显示错误消息。在logic.spec.js文件中添加另一个测试,如下所示:
...
context('Login', () => {
 ...
  it('should show the (required) input errors on invalid   inputs', () => {
    ['#emailHelp', '#passwordHelp'].map((selector) => {
      cy.get(selector).should((domList) =>       expect(domList.length).to.equal(0));
    });
    cy.get('#emailInput').type(    'mohsin.ayaz@domain.com').clear().blur();
    cy.get('#emailHelp').should('be.visible');
    cy.get('#passwordInput').type(    'password123').clear().blur();
    cy.get('#passwordHelp').should('be.visible');
  });
});

如果你现在查看测试窗口,你应该看到所有的测试都通过了,如下所示:

图 11.9 - 登录页面的所有测试都通过了

图 11.9 - 登录页面的所有测试都通过了

太棒了!现在你知道如何使用 Cypress 来测试一些有趣的用例和断言。查看下一节以了解它是如何工作的。

工作原理…

由于我们应用程序的逻辑规定提交按钮在电子邮件和密码输入都有有效值之前应该被禁用,我们在测试中检查按钮是否被禁用。我们通过在提交按钮上使用'be.disabled'断言来实现这一点,如下所示:

cy.contains('Submit').should('be.disabled');

然后我们在cy.get()选择器上使用.type()方法链来依次输入两个输入,并在任何输入无效值或根本没有输入时检查按钮是否被禁用。

执行成功的登录,我们执行以下代码:

cy.get('#emailInput')
    .type('ahsan.ayaz@domain.com')
    .get('#passwordInput')
    .type('password123');
  cy.contains('Submit').click();

注意,我们获取每个输入并在其中输入有效值,然后在提交按钮上调用.click()方法。然后,我们使用'.alert.alert-success'选择器和should('be.visible')断言来检查成功提示是否存在。

在我们想要检查成功提示在单击警报上的关闭按钮或任何输入更改时是否已被解除时,我们不能只使用should('not.be.visible')断言。这是因为在这种情况下,Cypress 会期望警报在 DOM 中,但只是不可见,而在我们的情况下(在我们的 Angular 应用程序中),元素甚至不存在在 DOM 中,因此 Cypress 无法获取它。因此,我们使用以下代码来检查成功提示甚至不存在:

cy.get('.alert.alert-success').should((domList) => {
    expect(domList.length).to.equal(0);
});

最后一个有趣的事情是当我们想要检查每个输入的错误消息是否在我们在任一输入中输入内容并清除输入时显示。在这种情况下,我们使用以下代码:

cy.get('#emailInput').type('mohsin.ayaz@domain.com').clear().blur();
cy.get('#emailHelp').should('be.visible');
cy.get('#passwordInput').type('password123').clear().blur();
cy.get('#passwordHelp').should('be.visible');

我们使用.blur()方法的原因是因为当 Cypress 只清除输入时,Angular 的变化检测不会立即发生,这导致错误消息不会立即显示在视图上。由于 Angular 的变化检测对浏览器事件进行了 monkey-patching,我们在两个输入上触发.blur()事件来触发变化检测机制。结果,我们的错误消息会正确显示。

另请参阅

等待 XHR 完成

测试用户界面(UI)转换是 E2E 测试的本质。虽然测试立即预测结果的重要性很高,但实际上可能存在结果有依赖性的情况。例如,如果用户填写了登录表单,我们只有在从后端服务器成功收到响应后才能显示成功的提示,因此我们无法立即测试成功提示是否显示。在这个配方中,您将学习如何等待特定的 XHR 调用完成后再执行断言。

准备工作

此处的配方项目位于chapter11/start_here/waiting-for-xhr

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行npm run cypress:test

这将打开一个新的 Cypress 窗口。点击user.spec.ts文件,您应该会看到测试,如下所示:

图 11.10 - Cypress 测试正在运行等待 XHR 应用程序

图 11.10 - Cypress 测试正在运行等待 XHR 应用程序

现在我们已经让 Cypress 测试运行起来了,让我们在下一节中看看这个配方的步骤。

如何做…

现在所有的测试都很好,即使我们涉及 XHR 调用来获取数据。那么,这个配方到底是关于什么的呢?嗯,Cypress 在 4,000 毫秒(4 秒)的时间内尝试断言,直到断言通过。如果我们的 XHR 花费超过 4,000 毫秒呢?让我们在这个配方中试一试:

  1. 首先,我们需要模拟期望结果在 4,000 毫秒后发生的情况。我们将使用rxjs中的debounceTime操作符,延迟为 5,000 毫秒。让我们将其应用于users.component.ts文件中searchForm属性的valueChanges Observable,如下所示:
...
import { debounceTime, takeWhile } from 'rxjs/operators';
@Component({...})
export class UsersComponent implements OnInit {
  ...
  ngOnInit() {
    ...
    this.searchForm
      .get('username')
      .valueChanges.pipe(
        takeWhile(() => !!this.componentAlive),
        debounceTime(5000)
      )
      .subscribe(() => {
        this.searchUsers();
      });
  }
  ...
}

如果现在检查 Cypress 测试,您应该会看到一个测试失败,如下所示:

图 11.11 - 测试搜索特定用户失败

图 11.11 - 测试搜索特定用户失败

  1. 现在我们可以尝试修复这个问题,这样无论 XHR 花费多长时间,我们都会等待它完成后再进行断言。让我们拦截 XHR 调用并为其创建一个别名,以便稍后使用它来等待 XHR 调用。更新users.spec.js文件,如下所示:
...
context('Users', () => {
  ...
  it('should get the users list on searching', () => {
    cy.intercept('https://api.randomuser.me/*')    .as('searchUsers');
    cy.get('#searchInput').type('irin');
    cy.get('app-user-card').should((domList) => {
      expect(domList.length).equal(1);
    });
  });
});
  1. 现在,让我们使用别名在断言之前等待 XHR 调用完成。更新users.spec.js文件,如下所示:
...
context('Users', () => {
  ...
  it('should get the users list on searching', () => {
    cy.intercept('https://api.randomuser.me/*')    .as('searchUsers');
    cy.get('#searchInput').type('irin');
    cy.wait('@searchUsers');
    cy.get('app-user-card').should((domList) => {
      expect(domList.length).equal(1);
    });
  });
});

如果现在检查user.spec.js的 Cypress 测试,你应该看到它们都通过了,如下所示:

图 11.12 – 测试等待 XHR 调用完成后进行断言

图 11.12 – 测试等待 XHR 调用完成后进行断言

太棒了!现在你知道如何使用 Cypress 实现包括等待特定 XHR 调用完成在断言之前的 E2E 测试。要了解配方背后的所有魔力,请参阅下一节。

工作原理…

在这个配方中,我们使用了一种叫做变量别名的东西。我们首先使用cy.intercept()方法,这样 Cypress 就可以监听网络调用。请注意,我们在参数中使用通配符作为 URL,使用https://api.randomuser.me/*,然后我们使用.as('searchUsers')语句为这个拦截设置一个别名。

然后,我们使用cy.wait('@searchUsers');语句,使用searchUsers别名告诉 Cypress 它必须等待直到别名的拦截发生——也就是说,直到网络调用被发出,无论需要多长时间。这使我们的测试通过,即使在实际获取网络调用之前,常规的 4,000 毫秒 Cypress 超时已经过去。神奇,不是吗?

嗯,希望你喜欢这个配方——查看下一节以查看进一步阅读的链接。

另请参阅

使用 Cypress 捆绑包

Cypress 提供了一堆捆绑工具和包,我们可以在测试中使用它们来简化事情,不是因为使用 Cypress 编写测试本来就很难,而是因为这些库已经被许多开发人员使用,所以他们对它们很熟悉。在这个配方中,我们将看看捆绑的jQuery、Lodash 和 Minimatch库,以测试一些我们的用例。

准备工作

我们要处理的项目位于chapter11/start_here/using-cypress-bundled-packages,在克隆的存储库中:

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行npm run cypress:test

这应该打开一个新的 Cypress 窗口。点击users.spec.ts文件,你应该看到测试,如下所示:

图 11.13 - 使用 Cypress 捆绑包运行的测试

图 11.13 - 使用 Cypress 捆绑包运行的测试

现在我们有了 Cypress 测试运行,让我们在下一节中看看这个示例的步骤。

如何做...

对于这个示例,我们有users列表和一个从应用程序编程接口API)端点获取一些用户的搜索应用。我们将对 DOM 进行一些条件断言,验证 API 的响应,并且还会断言 URL 的变化。让我们开始吧:

  1. 首先,我们将尝试使用捆绑的jQuery库以及 Cypress。我们可以使用Cypress.$来访问它。让我们添加另一个测试并记录一些 DOM 元素。更新users.spec.js文件,如下所示:
...
context('Users', () => {
  ...
  it('should have the search button disabled when there   is no input', () => {
    const submitButton = Cypress.$('#userSearchSubmit');
    console.log(submitButton);
  });
});

如果你现在看测试,特别是控制台,你应该会看到以下日志:

图 11.14 - 使用 jQuery 通过 Cypress.$记录的搜索按钮

图 11.14 - 使用 jQuery 通过 Cypress.$记录的搜索按钮

  1. 现在,让我们尝试记录在 HTTP 调用之后看到的用户卡。添加另一个查询和登录到相同的测试中,如下所示:
...
context('Users', () => {
  ...
  it('should have the search button disabled when there   is no input', () => {
    const submitButton = Cypress.$('#userSearchSubmit');
    console.log(submitButton);
  const appUserCards = Cypress.$('app-user-card');
  console.log(appUserCards);
  });
});

如果你再次在 Cypress 窗口的测试和日志中看到,你会发现Cypress.$('app-user-card')查询不会返回任何 DOM 元素。这是因为当运行查询时,HTTP 调用尚未完成。那么,我们应该等待 HTTP 调用完成吗?让我们试试看。

  1. 让我们添加一个cy.wait(5000)来等待 5 秒,期间 HTTP 调用应该已经完成,并且让我们使用cy.wrap()方法进行断言,检查当搜索输入没有提供值时搜索按钮是否被禁用。更新测试,如下所示:
...
context('Users', () => {
  ...
  it('should have the search button disabled when there   is no input', () => {
    const submitButton = Cypress.$('#userSearchSubmit');
    cy.wrap(submitButton).should('have.attr',     'disabled');
    cy.get('#searchInput').type('irin');
    cy.wait(5000);
    const appUserCards = Cypress.$('app-user-card');
    console.log(appUserCards);
    cy.wrap(submitButton).should('not.have.attr',     'disabled');
  });
});

如果你看到 Cypress 测试和控制台,你会发现我们仍然没有得到<app-user-card>元素的 DOM 元素:

图 11.15 - 即使使用 cy.wait(5000)也找不到使用 Cypress.$的用户卡

图 11.15 - 即使使用 cy.wait(5000)也找不到使用 Cypress.$的用户卡

我们将在它是如何工作的...部分讨论为什么会发生这种情况。现在,了解你应该只对从页面加载时就存在于 DOM 中的元素使用Cypress.$

  1. 让我们通过删除cy.wait()方法和控制台日志来清理我们的测试。然后它应该看起来像这样:
...
context('Users', () => {
  ...
  it('should have the search button disabled when there   is no input', () => {
    const submitButton = Cypress.$('#userSearchSubmit');
    cy.wrap(submitButton).should('have.attr', 'disabled');
    cy.get('#searchInput').type('irin');
    cy.wrap(submitButton).should('not.have.attr',     'disabled');
  });
});
  1. 现在我们将添加一个测试来验证,对于相同的种子字符串,我们从随机用户 API 中获取相同的用户。我们已经有了包含预期结果的API_USERS.js文件。让我们在下一个测试中使用捆绑的lodash库来断言返回用户的名字、姓氏和电子邮件的匹配值,如下所示:
...
import API_USERS from '../constants/API_USERS';
context('Users', () => {
  ...
  it('should return the same users as the seed data   every time', async () => {
    const { _ } = Cypress;
    const response = await cy.request(
      'https://api.randomuser.me/?      results=10&seed=packt'
    );
    const propsToCompare = ['name.first', 'name.last',     'email'];
    const results = _.get(response, 'body.results');
    _.each(results, (user, index) => {
      const apiUser = API_USERS[index];
      _.each(propsToCompare, (prop) => {
        const userPropVal = _.get(user, prop);
        const apiUserPropVal = _.get(apiUser, prop);
        return expect(userPropVal).        to.equal(apiUserPropVal);
      });
    });
  });
});

如果你现在在 Cypress 中看到测试,它应该通过,如下所示:

图 11.16 – 使用 lodash 通过 Cypress 进行测试通过

图 11.16 – 使用 lodash 通过 Cypress 进行测试通过

  1. 现在我们将使用 Cypress 捆绑的moment.js包。让我们断言用户卡片正确显示格式化的日期,使用moment.js。在users.spec.js文件中编写另一个测试,如下所示:
...
context('Users', () => {
  ...
  it('should show the formatted date of birth on the   user card', () => {
    const { _, moment } = Cypress;
    const apiUserDate = _.get(API_USERS[0], 'dob.date');
    const apiUserDateFormatted = moment(apiUserDate).    format(
      'dddd, MMMM D, YYYY'
    );
    cy.get('app-user-card')
      .eq(0)
      .find('#userCardDOB')
      .should((el) => {
       expect(el.text().trim()).       to.equal(apiUserDateFormatted);
      });
  });
});
  1. 接下来我们将探索的包是minimatch包。当我们点击用户卡片时,它会打开用户详细信息。由于我们将时间戳作为查询参数附加到 URL 上,我们无法将 URL 作为精确匹配与我们的断言进行比较。让我们使用minimatch包来使用模式进行断言。添加一个新的测试,如下所示:
...
context('Users', () => {
  ...
  it('should go to the user details page with the user   uuid', () => {
    const { minimatch } = Cypress;
    cy.get('app-user-card').eq(0).click();
    const expectedURL = `http://localhost:4200/    users/${API_USERS[0].login.uuid}`;
    cy.url().should((url) => {
      const urlMatches = minimatch(url,       `${expectedURL}*`);
      expect(urlMatches).to.equal(true);
    });
  });
});

哇!现在我们使用 Cypress 捆绑的包都通过了所有的测试。既然我们已经完成了这个方法,让我们在下一节看看它是如何工作的。

它是如何工作的…

Cypress 将jQuery与其捆绑在一起,我们通过Cypress.$属性使用它。这使我们能够执行jQuery函数允许我们执行的一切。它使用cy.visit()方法自动检查视图中的哪个页面,然后使用提供的选择器查询文档。

重要提示

Cypress.$只能从 DOM 上立即可用的文档元素中获取。这对于在 Cypress 测试窗口中使用 Chrome DevTools 调试 DOM 非常有用。然而,重要的是要理解它对 Angular 变化检测没有任何上下文。此外,你不能查询任何在页面上一开始就不可见的元素,就像我们在遵循该方法时所经历的那样——它不会等待 XHR 调用使元素可见。

Cypress 还捆绑了lodash并通过Cypress._对象公开它。在本教程中,我们使用_.get()方法从user对象中获取嵌套属性。_.get()方法接受两个参数:对象和反映属性路径的字符串,例如,我们使用_.get(response, 'body.results');,它实质上返回response.body.results的值。我们还使用_.each()方法在本教程中迭代数组。请注意,我们可以在 Cypress 测试中使用任何lodash方法,而不仅仅是上述方法。

我们还使用了 Cypress 通过Cypress.minimatch对象公开的minimatch包。minimatch包非常适合与字符串匹配和测试 glob 模式。我们用它来测试导航到用户详细信息页面后的 URL。

最后,我们还使用了 Cypress 通过Cypress.moment对象公开的moment.js包。我们用它来确保每个用户的出生日期在视图上显示为预期格式。非常简单。

另请参阅

使用 Cypress fixtures 提供模拟数据

在编写端到端测试时,fixtures 在确保测试不会出现问题方面发挥了重要作用。考虑到您的测试依赖于从 API 服务器获取数据,或者您的测试包括快照测试,其中包括从内容交付网络(CDN)或第三方 API 获取图像。尽管它们在技术上是测试成功运行所必需的,但重要的是服务器数据和图像不是从原始来源获取的,因此我们可以为它们创建 fixtures。在本教程中,我们将为用户数据以及要在 UI 上显示的图像创建 fixtures。

准备工作

我们将要使用的项目位于克隆存储库中的chapter11/start_here/using-cypress-fixtures中:

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行npm install以安装项目的依赖项。

  3. 完成后,运行npm run cypress:test

这将打开一个新的 Cypress 窗口。点击users.spec.ts文件,你应该会看到测试,如下所示:

图 11.17 - 使用 Cypress fixtures 测试在 Cypress 中运行

图 11.17 - 使用 Cypress fixtures 测试在 Cypress 中运行

现在我们已经让 Cypress 测试运行了,让我们在下一节中看看这个示例的步骤。

如何做…

我们有与上一个示例中相同的 Angular 应用程序。但是,我们现在将使用 Cypress fixtures 来提供我们的数据和图像 fixture。让我们开始吧:

  1. 我们首先为我们对randomuser.me API 的 HTTP 调用创建一个 fixture。在cypress/fixtures文件夹下创建一个名为users.json的新文件。然后,将代码从chapter11/final/using-cypress-fixtures/cypress/fixtures/users.json文件复制并粘贴到新创建的文件中。它应该看起来像这样:
{
  "fixture_version": "1",
  "results": [
    {
      "gender": "male",
      "name": { "title": "Mr", "first": "Irineu",       "last": "da Rocha" },
      ...
    },
    ...
    {
      "gender": "male",
      "name": { "title": "Mr", "first": "Justin",       "last": "Grewal" },
      ...
    }
  ]
}
  1. 现在,让我们在users.spec.js文件中使用 fixture。我们将在beforeEach()生命周期钩子中使用它,因为我们希望在文件中的所有测试中使用 fixture。这意味着我们还将删除文件中现有的cy.intercept()方法的使用。更新users.spec.js文件,如下所示:
...
context('Users', () => {
  beforeEach(() => {
    cy.fixture('users.json')
      .then((response) => {
        cy.intercept('GET', 'https://api.randomuser.        me/*', response).as(
          'searchUsers'
        );
      })
      .visit('/users');
  });
  ...
  it('should get the users list on searching', () => {
    cy.intercept('
https://api.randomuser.me/*').as('searchUsers'); ← // REMOVE THIS
    cy.get('#searchInput').type('irin');
    cy.wait('@searchUsers');
    ...
  });
   ...
});

现在我们需要从项目中删除constants/API_USERS.js文件,因为我们现在有了 fixture。

  1. 我们将创建一个新变量,其中我们将存储users数组的值,并将其用于替代API_USERS数组。让我们进一步修改users.spec.js文件,如下所示:
...
import API_USERS from '../constants/API_USERS'; ← // REMOVE THIS
context('Users', () => {
  let API_USERS;
  beforeEach(() => {
    cy.fixture('users.json')
      .then((response) => {
        API_USERS = response.results;
        cy.intercept('GET', 'https://api.randomuser.        me/*', response).as(
          'searchUsers'
        );
      })
      .visit('/users');
    });
  });
  ...
});

您会注意到,所有的测试都仍然通过了。您现在可以安全地从项目中删除constants/API_USERS.js文件。此外,您可以在 Cypress Tests窗口中查看网络调用,以验证我们使用的是 fixture 而不是实际的 API 响应,如下所示:

图 11.18 - Cypress 测试使用 users.json fixture 作为 XHR 响应

图 11.18 - Cypress 测试使用 users.json fixture 作为 XHR 响应

  1. 现在,让我们尝试模拟我们的图像,从磁盘加载它们,而不是从randomuser.me API。为此,我们已经将图像存储在fixtures/images文件夹中。我们只需要根据特定用户的 URL 来使用它们。为此,请修改users.spec.js文件,如下所示:
...
context('Users', () => {
  let API_USERS;
  beforeEach(() => {
    cy.fixture('users.json')
      .then((response) => {
        API_USERS = response.results;
        ...
        API_USERS.forEach((user) => {
          const url = user.picture.large;
          const imageName = url.substr(url.          lastIndexOf('/') + 1);
          cy.intercept(url, { fixture:           `images/${imageName}` });
        });
      .visit('/users');
  });
  ...
});

如果您现在查看测试,所有测试都应该仍然通过,如下所示:

图 11.19 - 使用图像 fixture 后所有测试都通过了

图 11.19 - 使用图像 fixture 后所有测试都通过了

看着测试,你可能会想:“这一切看起来和以前一样,阿赫桑。我怎么知道我们在模拟图像?”好问题。我们已经有一种方法来测试这个。

  1. cypress/fixtures/images文件夹中,我们有一个名为9.jpg的文件,另一个测试文件名为9_test.jpg。让我们将9.jpg文件的名称修改为9_original.jpg,将9_test.jpg文件的名称修改为9.jpg。如果你现在看到测试,你应该会看到使用替换文件的最后一个测试的结果不同,如下所示:

图 11.20 - 使用 fixture 中的图像进行 Cypress 测试

图 11.20 - 使用 fixture 中的图像进行 Cypress 测试

太棒了!现在你知道如何在 Cypress E2E 测试中使用 fixtures 了。现在你已经完成了这个教程,看看下一节关于它是如何工作的。

它是如何工作的...

我们使用cy.fixture()方法在 Cypress 测试中使用 fixtures,这允许我们使用文件中的数据。在这个教程中,我们使用 fixtures 来获取用户数据和图像的 HTTP 调用。但是它是如何工作的呢?实质上,fixture方法有四个重载,如下所示:

cy.fixture(filePath)
cy.fixture(filePath, encoding)
cy.fixture(filePath, options)
cy.fixture(filePath, encoding, options)

filePath参数接受一个字符串作为相对于Fixture文件夹的文件路径,默认为cypress/fixture路径,尽管我们可以通过在cypress.json配置文件中定义fixturesFolder属性来提供不同的Fixture文件夹。请注意,对于 HTTP 调用,我们使用cy.fixture('users.json')语句,它实质上指向cypress/fixture/users.json文件。

首先,我们在cy.visit()方法之前使用cy.fixture('users.json')方法,以确保我们在启动应用程序时触发的即时 XHR 调用使用 fixture。如果你改变代码,你会发现它不会按预期工作。然后我们使用.then()方法来获取users.json文件中的数据。一旦我们得到数据(response对象),我们使用cy.intercept()方法使用 Minimatch glob 模式拦截 HTTP 调用以获取用户数据,并且我们将 fixture 中的response对象作为 HTTP 调用的响应。因此,所有对与'api.randomuser.me/*' glob 匹配的端点的调用都使用我们的 fixture,即users.json文件。

在这个示例中,我们还做了一件有趣的事情,那就是模拟图片,以避免从原始来源获取它们。当你使用第三方 API 并且每次调用 API 都要付费时,这非常方便。我们已经将夹具图片存储在 cypress/fixture/images 文件夹中。因此,我们循环遍历 API_USERS 数组中的每个用户,并提取文件名(imageName 变量)。然后,我们拦截每个用于获取图片的 HTTP 调用,并在我们的测试中使用夹具图片代替原始资源。

另请参阅

第十二章:第十二章:Angular 性能优化

性能始终是您为最终用户构建的任何产品中关注的问题。这是增加某人第一次使用您的应用程序成为客户的机会的关键因素。现在,除非我们确定了改进的潜在可能性和实现这一点的方法,否则我们无法真正提高应用程序的性能。在本章中,您将学习一些在改进 Angular 应用程序时要部署的方法。您将学习如何使用多种技术来分析、优化和改进您的 Angular 应用程序的性能。以下是本章中要涵盖的内容:

  • 使用OnPush变更检测来修剪组件子树

  • 从组件中分离变更检测器

  • 使用runOutsideAngular在 Angular 外部运行async事件

  • *ngFor中使用trackBy来处理列表

  • 将重型计算移至纯管道

  • 使用 Web Workers 进行重型计算

  • 使用性能预算进行审计

  • 使用webpack-bundle-analyzer分析捆绑包

技术要求

对于本章中的食谱,请确保您的计算机上已安装了GitNode.js。您还需要安装@angular/cli包,可以在终端中使用npm install -g @angular/cli来安装。本章的代码可以在以下链接找到:github.com/PacktPublishing/Angular-Cookbook/tree/master/chapter12

使用 OnPush 变更检测来修剪组件子树

在当今现代 Web 应用程序的世界中,性能是出色的用户体验UX)和最终业务转化的关键因素之一。在本章的第一个食谱中,我们将讨论您可以在组件中进行的基本优化,即使用OnPush变更检测策略。

准备工作

我们将要处理的项目位于Chapter12/start_here/using-onpush-change-detection中,位于克隆存储库内:

  1. Visual Studio Code (VS Code)中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 运行ng serve -o命令启动 Angular 应用程序并在浏览器上提供服务。您应该看到以下应用程序:

图 12.1 – 使用 OnPush 变更检测运行的应用程序,位于 http://localhost:4200

图 12.1 – 应用程序使用 OnPush 变更检测在 http://localhost:4200 上运行

现在我们已经在浏览器上提供了项目,让我们在下一节中看到食谱的步骤。

如何做…

我们正在处理的应用程序存在一些性能问题,特别是UserCardComponent类。这是因为它使用idUsingFactorial()方法来生成要显示在卡片上的唯一 ID。我们将尝试体验和理解这会导致的性能问题。我们将尝试使用OnPush变更检测策略来解决这个问题。让我们开始吧:

  1. 首先,尝试在搜索框中输入名为Elfie Siegert的用户。您会注意到应用程序立即挂起,并且需要几秒钟才能显示用户。您还会注意到在输入时,甚至看不到在搜索框中输入的字母。

让我们向代码添加一些逻辑。我们将检查页面加载时 Angular 调用idUsingFactorial()方法的次数。

  1. 修改app/core/components/user-card/user-card.component.ts文件,更新如下:
...
@Component({...})
export class UserCardComponent implements OnInit {
  ...
  constructor(private router: Router) {}
  ngOnInit(): void {
    if (!window['appLogs']) {
      window['appLogs'] = {};
    }
    if (!window['appLogs'][this.user.email]) {
      window['appLogs'][this.user.email] = 0;
    }
  }
  ...
  idUsingFactorial(num, length = 1) {
    window['appLogs'][this.user.email]++;
    if (num === 1) {...} else {...}
  }
}
  1. 现在,刷新应用程序并打开 Chrome DevTools,在控制台选项卡中,输入appLogs并按Enter。您应该会看到一个对象,如下所示:图 12.2 – 反映对 idUsingFactorial()方法调用次数的日志

图 12.2 – 反映对 idUsingFactorial()方法调用次数的日志

  1. 现在,在搜索框中再次输入名称Elfie Siegert。然后,在控制台选项卡中再次输入appLogs并按Enter以再次查看对象。您会看到它有一些增加的数字。如果在输入名称时没有打错字,您应该会看到类似于这样的内容:图 12.3 – 输入名称 Elfie Siegert 后的日志

图 12.3 – 输入名称 Elfie Siegert 后的日志

注意调用idUsingFactorial()方法时的计数,例如justin.grewal@example.com。现在,它从40增加到300,仅需按几下按键。

现在让我们使用OnPush变更检测策略。这将避免 Angular 变更检测机制在每个浏览器事件上运行,这目前会导致性能问题。

  1. 打开user-card.component.ts文件并进行更新,如下所示:
import {
  ChangeDetectionStrategy,
  Component,
  ...
} from '@angular/core';
...
@Component({
  selector: 'app-user-card',
  templateUrl: './user-card.component.html',
  styleUrls: ['./user-card.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent implements OnInit {
  ...
}
  1. 现在,再试着在搜索框中输入Elfie Siegert这个名字。你会注意到,现在你可以在搜索框中看到输入的字母,而且应用程序不会卡住那么多。另外,如果你在控制台选项卡中查看appLogs对象,你应该会看到类似下面的内容:

图 12.4 - 使用 OnPush 策略输入 Elfie Siegert 名称后的日志

图 12.4 - 使用 OnPush 策略输入 Elfie Siegert 名称后的日志

请注意,即使刷新应用程序并输入Elfie Siegert这个名字后,对idUsingFactorial()方法的调用次数也大大减少了。例如,对于justin.grewal@example.com电子邮件地址,我们只有20次调用,而不是图 12.2中显示的初始40次调用,以及图 12.3中显示的300次调用。

太棒了!通过使用OnPush策略,我们能够在一个步骤中改善UserCardComponent的整体性能。现在你知道如何使用这个策略了,接下来看下一节来了解它是如何工作的。

它是如何工作的...

Angular 默认使用默认的变更检测策略 - 或者从@angular/core包中的ChangeDetectionStrategy.Default枚举来说,技术上来说是这样。由于 Angular 不知道我们创建的每个组件,它使用默认策略来避免遇到任何意外。但是作为开发人员,如果我们知道一个组件除非它的@Input()变量之一发生变化,否则不会改变,我们可以 - 而且应该 - 为该组件使用OnPush变更检测策略。为什么?因为它告诉 Angular 在组件的@Input()变量发生变化之前不要运行变更检测。这个策略对于呈现组件(有时被称为组件)来说是绝对胜利的,它们只是使用@Input()变量/属性来显示数据,并在交互中触发@Output()事件。这些呈现组件通常不包含任何业务逻辑,比如重型计算,使用服务进行超文本传输协议HTTP)调用等。因此,对于这些组件来说,我们更容易使用OnPush策略,因为它们只会在父组件的@Input()属性发生变化时显示不同的数据。

由于我们现在在 UserCardComponent 上使用了 OnPush 策略,它只在我们替换整个数组时触发变更检测。这发生在300ms 的去抖之后(users.component.ts 文件中的第 28 行),因此只有在用户停止输入时才会执行。因此,在优化之前,默认的变更检测是在每次按键时触发的浏览器事件,现在不会触发。

重要提示

现在您已经知道 OnPush 策略仅在一个或多个 @Input() 绑定发生变化时触发 Angular 变更检测机制,这意味着如果我们在组件 (UserCardComponent) 中更改属性,它不会在视图中反映出来,因为在这种情况下变更检测机制不会运行,因为该属性不是一个 @Input() 绑定。您必须标记组件为脏,以便 Angular 可以检查组件并运行变更检测。您将使用 ChangeDetectorRef 服务来实现这一点,具体来说,使用 .markForCheck() 方法。

另请参阅

从组件中分离变更检测器

在上一个示例中,我们学习了如何在组件中使用 OnPush 策略,以避免 Angular 变更检测运行,除非其中一个 @Input() 绑定发生了变化。然而,还有另一种方法可以告诉 Angular 完全不运行变更检测。当您希望完全控制何时运行变更检测时,这将非常方便。在本示例中,您将学习如何完全分离 Angular 组件的变更检测器,以获得性能改进。

准备工作

此示例的项目位于 Chapter12/start_here/detaching-change-detecto

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行 npm install 来安装项目的依赖项。

  3. 运行 ng serve -o 命令来启动 Angular 应用程序并在浏览器上提供服务。您应该看到应用程序如下:

图 12.5 – 应用程序 detaching-change-detector 在 http://localhost:4200 运行

图 12.5 – 应用程序 detaching-change-detector 在 http://localhost:4200 运行

现在我们在浏览器上提供了项目,让我们在下一节中看一下本教程的步骤。

如何做…

我们有相同的用户列表应用程序,但有所不同。现在,我们有UserSearchInputComponent组件,其中包含搜索输入框。这是我们输入用户名以在用户列表中搜索的地方。另一方面,我们有UserCardListComponent组件,其中包含用户列表。我们将首先体验性能问题,然后巧妙地分离变更检测器以获得性能改进。让我们开始吧:

  1. 在浏览器中刷新应用程序,然后只需点击搜索输入框内部,然后再点击搜索输入框外部,首先触发输入框上的focus事件,然后触发blur事件。重复这两次,然后在 Chrome Dev Tools 中的控制台中,检查appLogs对象的值。您应该会看到类似于这样的内容:![图 12.6 - 在搜索输入框上执行三次焦点和模糊后的日志

(图 12.6_B15150.jpg)

图 12.6 - 在搜索输入框上执行三次焦点和模糊后的日志

UserCardComponent类中的idUsingFactorial()方法已经被调用了大约 100 次,仅在我们迄今为止执行的步骤中。

  1. 现在,尝试快速在搜索框中输入elfie用户的名称进行搜索。

您会注意到应用程序立即挂起,需要几秒钟才能显示用户。您还会注意到,当您输入字母时,甚至看不到它们在搜索框中被输入。如果您已正确执行步骤 1步骤 2,您应该会看到一个appLogs对象,如下所示:

![图 12.7 - 在输入搜索框中输入 elfie 后的日志

(图 12.7_B15150.jpg)

图 12.7 - 在输入搜索框中输入 elfie 后的日志

您可以在上述截图中看到,justin.grewal@example.com用户的idUsingFactorial()方法现在已经被调用了大约 220 次。

  1. 为了提高性能,我们将在本教程中使用ChangeDetectorRef服务,从UsersComponent组件中完全分离变更检测器,这是我们用户页面的顶级组件。更新users.component.ts文件,如下所示:
import { ChangeDetectorRef, Component, OnInit} from '@angular/core';
...
@Component({...})
export class UsersComponent implements OnInit {
  users: IUser[];
  constructor(
    private userService: UserService,
  private cdRef: ChangeDetectorRef
  ) {}
  ngOnInit() {
    this.cdRef.detach();
    this.searchUsers();
  }
}

如果现在刷新应用程序,您会看到...实际上,您什么都看不到,这没关系 - 我们还有更多的步骤要遵循。

  1. 现在,由于我们只想在搜索用户时运行变更检测 - 也就是当UsersComponent类中的users数组发生变化时,我们可以使用ChangeDetectorRef实例的detectChanges()方法。再次更新users.component.ts文件,如下所示:
...
@Component({...})
export class UsersComponent implements OnInit {
  ...
  searchUsers(searchQuery = '') {
    this.userService.searchUsers(
searchQuery).subscribe((users) => {
      this.users = users;
  this.cdRef.detectChanges();
    });
  }
  ...
}
  1. 现在,再试着执行一遍动作 - 也就是刷新页面,聚焦输入框,失去焦点,再次聚焦,再次失去焦点,再次聚焦,再次失去焦点,然后在搜索输入框中输入elfie。一旦你按照这些步骤操作,你应该会看到appLogs对象,如下所示:

图 12.8 - 在执行测试步骤并使用 ChangeDetectorRef.detach()后的日志

图 12.8 - 在执行测试步骤并使用 ChangeDetectorRef.detach()后的日志

从上面的截图中可以看到,即使在执行步骤 1步骤 2中提到的所有操作之后,我们的变更检测运行周期非常低。

太棒了!你刚学会了如何使用ChangeDetectorRef服务分离 Angular 变更检测器。现在你已经完成了这个教程,看看下一节来了解它是如何工作的。

它是如何工作的...

ChangeDetectorRef服务提供了一系列重要的方法来完全控制变化检测。在这个示例中,我们在UsersComponent类的ngOnInit()方法中使用.detach()方法来从这个组件中分离出 Angular 变化检测机制。结果,UsersComponent类以及其子类都不会触发任何变化检测。这是因为每个 Angular 组件都有一个变化检测树,其中每个组件都是一个节点。当我们从变化检测树中分离一个组件时,该组件(作为树节点)以及其子组件(或节点)也会被分离。通过这样做,我们最终使UsersComponent类不会发生任何变化检测。因此,当我们刷新页面时,即使我们从应用程序编程接口API)获取了用户并将它们分配给UsersComponent类中的users属性,也不会渲染任何内容。由于我们需要在视图上显示用户,这需要触发 Angular 变化检测机制,我们在将用户数据分配给users属性后,立即使用ChangeDetectorRef实例的.detectChanges()方法。结果,Angular 运行了变化检测机制,我们在视图上看到了用户卡片。

这意味着在整个Users页面(即/users路由)上,只有在UsersComponent类初始化后,当我们调用searchUsers()方法,从 API 获取数据并将结果分配给users属性时,Angular 变化检测机制才会触发,从而创建一个高度受控的变化检测周期,从而在整体上获得更好的性能。

参见

在 Angular 之外运行异步事件的 runOutsideAngular

Angular 在一些事物上运行其变更检测机制,包括但不限于所有浏览器事件,如keyupkeydown等。它还在setTimeoutsetInterval和 Ajax HTTP 调用上运行变更检测。如果我们需要避免在这些事件中运行变更检测,我们需要告诉 Angular 不要在这些事件上触发变更检测 - 例如,如果您在 Angular 组件中使用setTimeout()方法,每次调用其回调方法时都会触发 Angular 变更检测。在这个食谱中,您将学习如何使用runOutsideAngular()方法在ngZone服务之外执行代码块。

准备就绪

这个食谱的项目位于Chapter12/start_here/run-outside-angula中:

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 运行ng serve -o命令启动 Angular 应用程序并在浏览器上提供服务。您应该看到应用程序,如下所示:

图 12.9 - 在 http://localhost:4200 上运行的 App run-outside-angular

图 12.9 - 在 http://localhost:4200 上运行的 App run-outside-angular

现在我们的应用程序正在运行,让我们在下一节中看一下食谱的步骤。

如何做…

我们有一个显示手表的应用程序。但是,目前应用程序中的变更检测并不理想,我们有很大的改进空间。我们将尝试使用ngZone中的runOutsideAngular方法来消除任何不必要的变更检测。让我们开始吧:

  1. 时钟值不断更新。因此,我们对每个更新周期运行变更检测。打开 Chrome DevTools 并切换到控制台选项卡。键入appLogs并按Enter,以查看hoursminutessecondsmilliseconds组件的变更检测运行次数。应该看起来像这样:图 12.10 - 反映变更检测运行次数的 appLogs 对象

图 12.10 - 反映变更检测运行次数的 appLogs 对象

  1. 为了衡量性能,我们需要在固定时间段内查看数字。让我们添加一些代码,在应用程序启动后的 4 秒内关闭时钟的间隔计时器。修改watch-box.component.ts文件,如下所示:
...
@Component({...})
export class WatchBoxComponent implements OnInit {
  ...
  ngOnInit(): void {
    this.intervalTimer = setInterval(() => {
      this.timer();
    }, 1);
    setTimeout(() => {
      clearInterval(this.intervalTimer);
    }, 4000);
  }
  ...
}
  1. 刷新应用程序并等待 4 秒钟以停止时钟。然后,在控制台选项卡中多次输入appLogs,按Enter,并查看结果。时钟停止,但动画仍在运行。您应该看到watch键的变更检测仍在增加,如下所示:图 12.11 - 对手表组件的变更检测仍在运行

图 12.11 - 对手表组件的变更检测仍在运行

  1. 让我们在手表内部的动画运行 4 秒后停止。更新watch.component.ts文件如下:
...
@Component({...})
export class WatchComponent implements OnInit {
  ...
  ngOnInit(): void {
    this.intervalTimer = setInterval(() => {
      this.animate();
    }, 30);
    setTimeout(() => {
      clearInterval(this.intervalTimer);
    }, 4000);
  }
  ...
}

刷新应用程序并等待动画停止。查看 Chrome DevTools 中的appLogs对象,您应该看到watch键的变更检测停止,如下所示:

图 12.12 - 停止动画间隔后变更检测停止

图 12.12 - 停止动画间隔后变更检测停止

  1. 我们希望动画运行,但不会导致额外的变更检测运行。这是因为我们希望使我们的应用程序更加高效。所以,让我们暂停时钟。为此,请更新watch-box.component.ts文件如下:
...
@Component({...})
export class WatchBoxComponent implements OnInit {
  ...
  ngOnInit(): void {
    // this.intervalTimer = setInterval(() => {
    //   this.timer();
    // }, 1);
    // setTimeout(() => {
    //   clearInterval(this.intervalTimer);
    // }, 4000);
  }
}

由于我们现在已经停止了时钟,因此appLogswatch键的值现在仅基于这 4 秒的动画。您现在应该看到watch键的值在250260之间。

  1. 让我们通过在ngZone服务外部运行间隔来避免对动画进行变更检测。我们将使用runOutsideAngular()方法来实现这一点。更新watch.component.ts文件如下:
import {
  ...
  ViewChild,
  NgZone,
} from '@angular/core';
@Component({...})
export class WatchComponent implements OnInit {
  ...
  constructor(private zone: NgZone) {
   ...
  }
  ngOnInit(): void {
    this.zone.runOutsideAngular(() => {
      this.intervalTimer = setInterval(() => {
        this.animate();
      }, 30);
      setTimeout(() => {
        clearInterval(this.intervalTimer);
      }, 2500);
    });
  }
  ...
}

刷新应用程序并等待大约 5 秒钟。如果现在检查appLogs对象,您应该看到每个属性的变更检测运行总数减少,如下所示:

图 12.13 - 在 WatchComponent 中使用 runOutsideAngular()后的 appLogs 对象

图 12.13 - 在 WatchComponent 中使用 runOutsideAngular()后的 appLogs 对象

耶耶!注意appLogs对象中watch键的值已经从大约250减少到4。这意味着我们的动画现在根本不会影响变更检测。

  1. WatchComponent类的动画中删除对clearInterval()的使用。结果,动画应该继续运行。修改watch.component.ts文件如下:
...
@Component({...})
export class WatchComponent implements OnInit {
  ...
  ngOnInit(): void {
    ...
    this.ngZone.runOutsideAngular(() => {
      this.intervalTimer = setInterval(() => {
        this.animate();
      }, 30);
      setTimeout(() => { // ← Remove this block
        clearInterval(this.intervalTimer);
      }, 4000);
    });
  }
  ...
}
  1. 最后,从WatchBoxComponent类中删除对clearInterval()的使用以运行时钟。更新watch-box.component.ts文件如下:
import { Component, OnInit } from '@angular/core';
@Component({
  selector: 'app-watch-box',
  templateUrl: './watch-box.component.html',
  styleUrls: ['./watch-box.component.scss'],
})
export class WatchBoxComponent implements OnInit {
  name = '';
  time = {
    hours: 0,
    minutes: 0,
    seconds: 0,
    milliseconds: 0,
  };
  intervalTimer;
  constructor() {}
  ngOnInit(): void {
    this.intervalTimer = setInterval(() => {
      this.timer();
    }, 1);
    setTimeout(() => { // ← Remove this
      clearInterval(this.intervalTimer);
    }, 4000);
  }
  ...
}

刷新应用程序并在几秒钟后多次检查appLogs对象的值。你应该看到类似于这样的内容:

图 12.14 - 使用 runOutsideAngular()进行性能优化后的 appLogs 对象

图 12.14 - 使用 runOutsideAngular()进行性能优化后的 appLogs 对象

看着前面的截图,你可能会说:“阿赫桑!这是什么?我们对于观察键的变化检测运行次数仍然很大。这到底有多高效?”很高兴你问了。我会在“它是如何工作的…”部分告诉你为什么

  1. 最后一步,停止 Angular 服务器,并运行以下命令以在生产模式下启动服务器:
ng serve --prod
  1. 再次转到localhost:4200。等待几秒钟,然后多次检查控制台选项卡中的appLogs对象。你应该看到如下对象:

图 12.15 - 使用生产构建的 appLogs 对象

图 12.15 - 使用生产构建的 appLogs 对象

砰!如果你看前面的截图,你会发现watch键的变化检测运行次数总是比milliseconds键多一个周期。这意味着WatchComponent类几乎只在我们更新@Input() milliseconds绑定的值时重新渲染。

现在你已经完成了这个示例,看看下一节来了解它是如何工作的。

它是如何工作…

在这个示例中,我们首先查看了appLogs对象,其中包含一些键值对。每个键值对的值表示 Angular 为特定组件运行变化检测的次数。hoursmillisecondsminutesseconds键分别表示时钟上显示的每个值的WatchTimeComponent实例。watch键表示WatchComponent实例。

在配方的开头,我们看到watch键的值比milliseconds键的值大两倍以上。我们为什么要关心milliseconds键呢?因为在我们的应用程序中,@Input()属性绑定milliseconds是最频繁变化的——也就是说,它每 1 毫秒(ms)就会变化一次。第二频繁变化的值是WatchComponent类中的xCoordinateyCoordinates属性,它们每 30 毫秒变化一次。xCoordinateyCoordinate的值并没有直接绑定到模板(超文本标记语言(HTML))上,因为它们会改变stopWatch视图子组件的层叠样式表(CSS)变量。这是在animate()方法内部发生的:

el.style.setProperty('--x', `${this.xCoordinate}px`);
el.style.setProperty('--y', `${this.yCoordinate}px`);

因此,改变这些值实际上不应该触发变化检测。我们首先通过在WatchBoxComponent类中使用clearInterval()方法来限制时钟窗口,以便时钟在 4 秒内停止,我们可以评估数字。在图 12.11中,我们看到即使时钟停止后,变化检测机制仍然会为WatchComponent类触发。随着时间的推移,这会增加appLogs对象中watch键的计数。然后我们在WatchComponent类中使用clearInterval()来停止动画。这也在 4 秒后停止动画。在图 12.12中,我们看到watch键的计数在动画停止后停止增加。

然后我们尝试只基于动画来查看变化检测的计数。在步骤 6中,我们停止了时钟。因此,我们只会得到appLogs对象中watch键的基于动画的计数,这个值在 250 和 260 之间。

然后我们在代码中引入了神奇的runOutsideAngular()方法。这个方法是NgZone服务的一部分。NgZone服务打包在@angular/core包中。runOutsideAngular()方法接受一个方法作为参数。这个方法在 Angular 区域之外执行。这意味着在runOutsideAngular()方法内部使用的setTimeout()setInterval()方法不会触发 Angular 变化检测周期。在图 12.13中,您可以看到使用runOutsideAngular()方法后,计数下降到 4。

然后,我们从WatchBoxComponentWatchComponent类中删除了clearInterval()的使用-也就是说,像我们在开始时那样再次运行时钟和动画。在图 12.14中,我们看到watch键的计数几乎是milliseconds键的两倍。现在,为什么会是两倍呢?这是因为在开发模式下,Angular 运行变更检测机制两次。因此,在步骤 9步骤 10中,我们以生产模式运行应用程序,在图 12.15中,我们看到watch键的值仅比milliseconds键的值大 1,这意味着动画不再触发我们应用程序的任何变更检测。很棒,不是吗?如果您发现这个示例有用,请在我的社交媒体上告诉我。

现在您已经了解了它的工作原理,请参阅下一节以获取更多信息。

另请参阅

使用*ngFor 为列表添加 trackBy

列表是我们今天构建的大多数应用程序的重要部分。如果您正在构建一个 Angular 应用程序,您很有可能会在某个时候使用*ngFor指令。我们知道*ngFor允许我们循环遍历数组或对象,为每个项目生成 HTML。然而,对于大型列表,使用它可能会导致性能问题,特别是当*ngFor的源完全改变时。在这个示例中,我们将学习如何使用*ngFor指令和trackBy函数来提高列表的性能。让我们开始吧。

准备工作

此示例的项目位于Chapter12/start_here/using-ngfor-trackb:

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行npm install以安装项目的依赖项。

  3. 运行ng serve -o命令启动 Angular 应用程序并在浏览器上提供服务。您应该看到应用程序如下:

图 12.16-应用程序使用-ngfor-trackby 在 http://localhost:4200 上运行

图 12.16-应用程序使用-ngfor-trackby 在 http://localhost:4200 上运行

现在我们的应用程序正在运行,让我们在下一节中看看这个示例的步骤。

如何做…

我们有一个应用程序,在视图上显示了 1,000 个用户的列表。由于我们没有使用虚拟滚动和标准的*ngFor列表,目前我们面临一些性能问题。请注意,当您刷新应用程序时,即使加载程序隐藏了,您会在列表出现之前看到一个空白的白色框大约 2-3 秒钟。让我们开始重现性能问题并修复它们的步骤。

  1. 首先,打开 Chrome DevTools 并查看控制台选项卡。您应该看到ListItemComponent initiated消息被记录了 1,000 次。每当创建/初始化列表项组件时,都会记录此消息。

  2. 现在,通过使用交叉按钮删除第一项。您现在应该再次看到大约 999 次相同的消息被记录,如下截图所示。这意味着我们为剩下的 999 个项目重新创建了列表项组件:图 12.17–删除项目后再次显示日志

图 12.17–删除项目后再次显示日志

  1. 现在,刷新应用程序并点击第一个列表项。您应该再次看到ListItemComponent initiated日志,如下截图所示。这意味着我们在项目更新时重新创建所有列表项。您会注意到在用户界面UI)中对第一项名称的更新在大约 2-3 秒内反映出来:图 12.18–更新项目后再次显示日志

图 12.18–更新项目后再次显示日志

  1. 现在,让我们通过使用trackBy函数来解决性能问题。打开the-amazing-list.component.ts文件并进行更新,如下所示:
...
@Component({...})
export class TheAmazingListComponent implements OnInit {
  ...
  ngOnInit(): void {}
  trackByFn(_, user: AppUserCard) {
    return user.email;
  }
}
  1. 现在,更新the-amazing-list.component.html文件,使用我们刚刚创建的trackByFn()方法,如下所示:
<h4 class="heading">Our trusted customers</h4>
<div class="list list-group">
  <app-list-item
    *ngFor="let item of listItems; trackBy: trackByFn"
    [item]="item"
    (itemClicked)="itemClicked.emit(item)"
    (itemDeleted)="itemDeleted.emit(item)"
  >
  </app-list-item>
</div>
  1. 现在,刷新应用程序,并点击第一个列表项进行更新。您会注意到项目立即更新,我们不再记录ListItemComponent initiated消息,如下截图所示:图 12.19–使用 trackBy 函数更新项目后没有更多日志

图 12.19–使用 trackBy 函数更新项目后没有更多日志

  1. 现在也删除一个项目,您会看到在这种情况下我们不再记录ListItemComponent initiated消息。

太棒了!您现在知道如何使用*ngFor指令的trackBy函数来优化 Angular 中列表的性能。要了解该配方背后的所有魔力,请参阅下一节。

它是如何工作的…

*ngFor指令默认假定对象本身是其唯一标识,这意味着如果您只更改了*ngFor指令中使用的对象的属性,则不会重新呈现该对象的模板。但是,如果您提供一个新对象(内存中的不同引用),特定项目的内容将重新呈现。这实际上是我们在这个配方中为了重现性能问题内容所做的。在data.service.ts文件中,我们有updateUser()方法的以下代码:

updateUser(updatedUser: AppUserCard) {
    this.users = this.users.map((user) => {
      if (user.email === updatedUser.email) {
        return {
      ...updatedUser,
   };
      }
      // this tells angular that every object has now       a different reference
      return { ...user };
    });
  }

请注意,我们使用对象扩展运算符({ … })为数组中的每个项目返回一个新对象。这告诉*ngFor指令在TheAmazingListComponent类的listItems数组中的每个项目上重新呈现 UI。假设您向服务器发送查询以查找或过滤用户。服务器可能返回一个包含 100 个用户的响应。在这 100 个用户中,大约有 90 个已经在视图上呈现,只有 10 个不同。然而,由于以下潜在原因(但不限于此),Angular 将重新呈现所有列表项的 UI:

  • 用户的排序/放置可能已经改变。

  • 用户的长度可能已经改变。

现在,我们希望避免使用对象引用作为每个列表项的唯一标识符。对于我们的用例,我们知道每个用户的电子邮件是唯一的,因此我们使用trackBy函数告诉 Angular 使用用户的电子邮件作为唯一标识符。现在,即使我们在updateUser()方法中为每个用户返回一个新对象(如前所示),Angular 也不会重新呈现所有列表项。这是因为新对象(用户)具有相同的电子邮件,Angular 使用它来跟踪它们。很酷,对吧?

现在您已经了解了该配方的工作原理,请查看下一节以查看进一步阅读的链接。

另请参阅

将重计算移动到纯管道

在 Angular 中,我们有一种特殊的编写组件的方式。由于 Angular 的观点很强烈,我们已经从社区和 Angular 团队那里得到了很多关于编写组件时要考虑的指南,例如,直接从组件中进行 HTTP 调用被认为是一个不太好的做法。同样,如果组件中有大量计算,这也被认为是一个不好的做法。当视图依赖于使用计算不断转换数据的转换版本时,使用 Angular 管道是有意义的。在这个示例中,您将学习如何使用 Angular 纯管道来避免组件内的大量计算。

准备工作

我们要处理的项目位于Chapter12/start_here/using-pure-pipes,在克隆的存储库中:

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 运行ng serve -o命令启动 Angular 应用程序并在浏览器上提供服务。您应该看到应用程序如下:

图 12.20 – 在 http://localhost:4200 上运行 using-pure-pipes 应用程序

图 12.20 – 在 http://localhost:4200 上运行 using-pure-pipes 应用程序

现在我们在浏览器上提供了项目,让我们在下一节中看看这个示例的步骤。

如何做…

我们正在处理的应用程序存在一些性能问题,特别是UserCardComponent类,因为它使用idUsingFactorial()方法来生成要显示在卡片上的唯一 ID。如果您尝试在搜索框中输入'irin',您会注意到应用程序会暂停一段时间。我们无法立即看到在搜索框中输入的字母,并且在结果显示之前需要一段时间。我们将通过将idUsingFactorial()方法中的计算移动到 Angular(纯)管道中来解决这些问题。让我们开始:

  1. 让我们创建一个 Angular 管道。我们将把为这个管道生成唯一 ID 的计算移到后面的代码中。在项目根目录中,在终端中运行以下命令:
ng g pipe core/pipes/unique-id
  1. 现在,从user-card.component.ts文件中复制createUniqueId()方法的代码,并粘贴到unique-id.pipe.ts文件中。我们还将稍微修改代码,所以现在应该是这样的:
...
@Pipe({...})
export class UniqueIdPipe implements PipeTransform {
  characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef   ghijklmnopqrstuvwxyz0123456789';
  createUniqueId(length) {
    var result = '';
    const charactersLength = this.characters.length;
    for (let i = 0; i < length; i++) {
      result += this.characters.charAt(
        Math.floor(Math.random() * charactersLength)
      );
    }
    return result;
  }
  ...
  transform(index: unknown, ...args: unknown[]): unknown {
    return null;
  }
}
  1. 现在,还要从user-card.component.ts文件中复制idUsingFactorial()方法到unique-id.pipe.ts文件,并更新文件,如下所示:
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
  name: 'uniqueId',
})
export class UniqueIdPipe implements PipeTransform {
  ...
  idUsingFactorial(num, length = 1) {
    if (num === 1) {
      return this.createUniqueId(length);
    } else {
      const fact = length * (num - 1);
      return this.idUsingFactorial(num - 1, fact);
    }
  }
  transform(index: number): string {
    return this.idUsingFactorial(index);
  }
}
  1. 现在,更新user-card.component.html文件,使用uniqueId管道而不是组件的方法。代码应该如下所示:
<div class="user-card">
  <div class="card" *ngIf="user" (click)="cardClicked()">
    <img [src]="user.picture.large" class="card-img-top"     alt="..." />
    <div class="card-body">
      <h5 class="card-title">{{ user.name.first }}      {{ user.name.last }}</h5>
      <p class="card-text">{{ user.email }}</p>
      <p class="card-text unique-id" title="{{ index |       uniqueId }}">
        {{ index | uniqueId }}
      </p>
      <a href="tel: {{ user.phone }}" class="btn       btn-primary">{{
        user.phone
      }}</a>
    </div>
  </div>
</div>
  1. 现在,刷新应用程序并在搜索框中输入名称Elfie Siegert。注意到 UI 没有被阻塞。我们能够立即看到我们输入的字母,搜索结果也更快。

砰!现在你知道了如何通过将繁重的计算移动到纯 Angular 管道来优化性能,接下来看看下一节,了解这是如何工作的。

它是如何工作的…

正如我们所知,Angular 默认在应用程序中触发的每个浏览器事件上运行变更检测,而且由于我们在组件模板(UI)中使用了idUsingFactorial()方法,这个函数会在每次 Angular 运行变更检测机制时运行,导致更多的计算和性能问题。如果我们使用 getter 而不是方法,情况也是如此。在这里,我们使用方法是因为每个唯一的 ID 都依赖于索引,当调用它时,我们需要在方法中传递索引。

我们可以从最初的实现中退一步,思考这个方法实际上是做什么。它接受一个输入,进行一些计算,并根据输入返回一个值——这是数据转换的经典例子,也是你会使用纯函数的例子。幸运的是,Angular 纯管道是纯函数,除非输入发生变化,它们不会触发变更检测。

在这个示例中,我们将计算移动到一个新创建的 Angular 管道中。管道的transform()方法接收我们应用管道的值,即users数组中每个用户卡的索引。然后管道使用idUsingFactorial()方法,最终使用createUniqueId()方法来计算一个随机的唯一 ID。当我们开始在搜索框中输入时,索引的值不会改变。这导致在我们输入到搜索框中时不会触发变更检测,从而优化性能并解除 UI 线程的阻塞。

另请参阅

使用 Web Workers 进行繁重的计算

如果您的 Angular 应用程序在执行操作期间进行了大量计算,那么它很有可能会阻塞 UI 线程。这将导致 UI 渲染出现延迟,因为它阻塞了主 JavaScript 线程。Web workers 允许我们在后台线程中运行大量计算,从而释放 UI 线程,因为它不会被阻塞。在本教程中,我们将使用一个应用程序,在UserService类中进行大量计算。它为每个用户卡创建一个唯一 ID,并将其保存到localStorage中。但是,在这样做之前,它会循环几千次,这会导致我们的应用程序暂时挂起。在本教程中,我们将把大量计算从组件移动到 web worker,并在 web worker 不可用的情况下添加一个回退。

准备工作

我们将要处理的项目位于克隆存储库中的Chapter12/start_here/using-web-workers中:

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 运行ng serve -o命令启动 Angular 应用程序并在浏览器上提供服务。您应该看到应用程序如下:

图 12.21 - 应用程序 using-web-workers 在 http://localhost:4200 上运行

图 12.21 - 应用程序 using-web-workers 在 http://localhost:4200 上运行

现在我们的应用程序正在运行,让我们在下一节中看看本教程的步骤。

操作步骤如下…

一旦您打开应用程序,您会注意到在用户卡片被渲染之前需要一些时间。这表明 UI 线程被阻塞,直到计算完成。罪魁祸首是UserService类中的saveUserUniqueIdsToStorage()方法。这在保存到localStorage之前会生成几千个唯一 ID。让我们开始本教程,以改善应用程序的性能。我们将首先实现 web worker:

  1. 我们将首先创建一个 web worker。在项目根目录中运行以下命令:
ng generate web-worker core/workers/idGenerator
  1. 现在,将UserService类中的saveUserUniqueIdsToStorage()方法中的for循环复制到新创建的id-generator.worker.ts文件中。代码应该如下所示:
/// <reference lib="webworker" />
import createUniqueId from '../constants/create-unique-id';
addEventListener('message', ({ data }) => {
  console.log('message received IN worker', data);
  const { index, email } = data;
  let uniqueId;
  for (let i = 0, len = (index + 1) * 100000; i < len;   ++i) {
    uniqueId = createUniqueId(50);
  }
  postMessage({ uniqueId, email });
});
  1. 现在我们已经创建了 worker 文件,让我们创建一个 worker 的单个实例,以便在接下来的步骤中使用它。在constants文件夹中创建一个新文件。命名为get-unique-id-worker.ts,并在文件中添加以下代码:
let UNIQUE_ID_WORKER: Worker = null;
const getUniqueIdWorker = (): Worker => {
  if (typeof Worker !== 'undefined' && UNIQUE_ID_WORKER   === null) {
    UNIQUE_ID_WORKER = new Worker('../workers/    id-generator.worker', {
      type: 'module',
    });
  }
  return UNIQUE_ID_WORKER;
};
export default getUniqueIdWorker;
  1. 现在,我们将在user.service.ts文件中使用 worker。更新它如下:
...
import getUniqueIdWorker from '../constants/get-unique-id-worker';
@Injectable({...})
export class UserService {
  ...
  worker: Worker = getUniqueIdWorker();
  constructor(private http: HttpClient) {
  this.worker.onmessage = ({ data: { uniqueId, email }   }) => {
      console.log('received message from worker',       uniqueId, email);
      const user = this.usersCache.find((user) => user.      email === email);
      localStorage.setItem(
        `ng_user__${user.email}`,
        JSON.stringify({
          ...user,
          uniqueId,
        })
      );
    };
  }
  ...
}
  1. 我们将再次更新文件以修改saveUserUniqueIdsToStorage()方法。如果环境中有 Web 工作者可用,我们将使用工作者而不是使用现有的代码。按照以下方式更新user.service.ts文件:
...
@Injectable({...})
export class UserService {
  ...
  saveUserUniqueIdsToStorage(user: IUser, index) {
    let uniqueId;
    const worker: Worker = getUniqueIdWorker();
    if (worker !== null) {
      worker.postMessage({ index, email: user.email });
    } else {
      // fallback
      for(let i = 0, len = (index + 1) * 100000; i<len;       ++i) {
        uniqueId = createUniqueId(50);
      }
      localStorage.setItem(...);
    }
  }
  ...
}
  1. 刷新应用程序,注意用户卡片渲染需要多长时间。它们应该比以前出现得快得多。此外,你应该能够看到以下日志,反映了应用程序与 Web 工作者之间的通信:

图 12.22 - 显示应用程序与 Web 工作者之间消息的日志

图 12.22 - 显示应用程序与 Web 工作者之间消息的日志

哇呜!Web 工作者的力量!现在你知道如何在 Angular 应用程序中使用 Web 工作者将繁重的计算移动到它们那里了。既然你已经完成了这个教程,那就看看下一节它是如何工作的吧。

它是如何工作的...

正如我们在教程描述中讨论的那样,Web 工作者允许我们在与主 JavaScript(或 UI 线程)分开的线程中运行和执行代码。在教程开始时,每当我们刷新应用程序或搜索用户时,它都会阻塞 UI 线程。直到为每张卡生成一个唯一的 ID 为止。我们通过使用 Angular 的命令行界面CLI)创建一个 Web 工作者来开始这个教程。这将创建一个id-generator.worker.ts文件,其中包含一些样板代码,用于接收来自 UI 线程的消息并作为响应发送消息回去。CLI 命令还通过添加webWorkerTsConfig属性来更新angular.json文件。webWorkerTsConfig属性的值是tsconfig.worker.json文件的路径,CLI 命令还创建了这个tsconfig.worker.json文件。如果你打开tsconfig.worker.json文件,你应该会看到以下代码:

/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/worker",
    "lib": [
      "es2018",
      "webworker"
    ],
    "types": []
  },
  "include": [
    "src/**/*.worker.ts"
  ]
}

创建完 Web Worker 文件后,我们创建另一个名为uniqueIdWorker.ts的文件。该文件将getUniqueIdWorker()方法作为默认导出。当我们调用此方法时,如果尚未生成 Worker 实例,它将生成一个新的Worker实例。该方法使用id-generator.worker.ts文件来生成 Worker。我们还在 Worker 文件中使用addEventListener()方法来监听从 UI 线程(即UserService类)发送的消息。我们接收到的消息中包含用户卡的index和用户的email。然后我们使用for循环来生成一个唯一 ID(uniqueId变量),循环结束后,我们使用postMessage()方法将uniqueId变量和email发送回 UI 线程。

现在,在UserService类中,我们监听来自 Worker 的消息。在constructor()方法中,我们通过检查getUniqueIdWorker()方法的值(应该是非空值)来检查环境中是否可用 Web Workers。然后,我们使用worker.onmessage属性来分配一个方法。这是为了监听来自 Worker 的消息。由于我们已经知道我们从 Worker 那里得到了uniqueId变量和email,我们使用email来从usersCache变量中获取相应的用户。然后,我们将用户数据与uniqueId变量存储到localStorage中,针对用户的email

最后,我们更新saveUserUniqueIdsToStorage()方法以使用 Worker 实例(如果可用)。请注意,我们使用worker.postMessage()方法来传递用户的indexemail。还要注意,我们在没有启用 Web Workers 的情况下,使用先前的代码作为备用。

另请参阅

使用性能预算进行审核

在当今世界,大多数人口都有相当好的互联网连接,可以使用日常应用程序,无论是移动应用程序还是 Web 应用程序,令人着迷的是我们作为企业向最终用户发送了多少数据。现在向用户发送的 JavaScript 数量呈不断增长的趋势,如果你正在开发 Web 应用程序,你可能希望使用性能预算来确保捆绑包大小不超过一定限制。对于 Angular 应用程序,设置预算大小非常简单。在本教程中,您将学习如何使用 Angular CLI 为您的 Angular 应用程序设置预算。

准备工作

本教程的项目位于Chapter12/start_here/angular-performance-budget中:

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行npm install以安装项目的依赖项。

  3. 运行ng build --configuration production命令以在生产模式下构建 Angular 应用程序。注意控制台上的输出。它应该是这样的:

图 12.23 - 以生产模式构建输出,没有性能预算

图 12.23 - 以生产模式构建输出,没有性能预算

请注意,main.*.js文件的捆绑包大小目前约为 260 千字节(KB)。现在我们已经构建了应用程序,让我们在下一节中看看教程的步骤。

如何操作…

目前,我们的应用程序在捆绑包大小方面非常小。然而,随着即将到来的业务需求,这可能会变成一个庞大的应用程序。为了本教程的目的,我们将故意增加捆绑包大小,然后使用性能预算来阻止 Angular CLI 在捆绑包大小超过预算时构建应用程序。让我们开始教程:

  1. 打开app.component.ts文件并进行更新,如下所示:
...
import * as moment from '../lib/moment';
import * as THREE from 'three';
@Component({...})
export class AppComponent {
  ...
  constructor(private auth: AuthService, private router:   Router) {
    const scene = new THREE.Scene();
    console.log(moment().format('MMM Do YYYY'));
  }
  ...
}
  1. 现在,使用ng build --configuration production命令再次为生产构建应用程序。您会看到main.*.js文件的捆绑包大小现在为 1.12 兆字节(MB)。与原始的 268.05 KB 相比,这是一个巨大的增加,如下截图所示:图 12.24 - main.*.js 的捆绑包大小增加到 1.11 MB

图 12.24 - main.*.js 的捆绑包大小增加到 1.11 MB

假设我们的业务要求我们不要将主捆绑包大小超过 1.0 MB。为此,我们可以配置我们的 Angular 应用程序,如果达到阈值,就抛出错误。

  1. 刷新应用程序,打开angular.json文件并进行更新。我们要定位的属性是projects.angular-performance-budgets.architect.build.configurations.production.budgets。文件应该如下所示:
...
{
  "budgets": [
    {
      "type": "initial",
      "maximumWarning": "800kb",
      "maximumError": "1mb"
    },
    {
      "type": "anyComponentStyle",
      "maximumWarning": "6kb",
      "maximumError": "10kb"
    }
  ]
}
...
  1. 现在我们已经制定了预算,让我们再次使用ng build --configuration production命令构建应用程序。构建应该会失败,并且您应该在控制台上看到警告和错误,如下所示:图 12.25 – Angular CLI 根据性能预算抛出错误和警告

图 12.25 – Angular CLI 根据性能预算抛出错误和警告

  1. 通过在app.component.ts文件中不导入整个库,并使用date-fns包代替moment.js来改进我们的应用程序。运行以下命令安装date-fns包:
npm install --save date-fns
  1. 现在,按照以下步骤更新app.component.ts文件:
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from './services/auth.service';
import { format } from 'date-fns';
import { Scene } from 'three';
@Component({...})
export class AppComponent {
  ...
  constructor(private auth: AuthService, private router:   Router) {
    console.log(format(new Date(), 'LLL do yyyy'));
    const scene = new Scene();
  }
  ...
}
  1. 再次运行ng build --configuration production命令。您应该会看到捆绑包大小减小,如下所示:

图 12.26 – 使用 date-fns 和优化导入后减小的捆绑包大小

图 12.26 – 使用 date-fns 和优化导入后减小的捆绑包大小

砰!!你刚学会了如何使用 Angular CLI 来定义性能预算。这些预算可以根据您的配置来发出警告和错误。请注意,预算可以根据不断变化的业务需求进行修改。然而,作为工程师,我们必须谨慎地设置性能预算,以免将 JavaScript 超出一定限制发送给最终用户。

另请参阅

使用 webpack-bundle-analyzer 分析捆绑包

在上一个示例中,我们看到了为我们的 Angular 应用程序配置预算,这很有用,因为您可以知道整体捆绑包大小是否超过了某个阈值,尽管您不知道代码的每个部分实际上对最终捆绑包的贡献有多大。这就是我们所谓的分析捆绑包,在本示例中,您将学习如何使用webpack-bundle-analyzer来审计捆绑包大小和导致它们的因素。

准备就绪

我们要处理的项目位于克隆存储库中的Chapter12/start_here/using-webpack-bundle-analyzer中:

  1. 在 VS Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 运行ng serve -o命令来启动 Angular 应用程序并在浏览器上提供服务。您应该看到应用程序如下所示:图 12.27 – 使用 webpack-bundle-analyzer 运行的应用程序位于 http://localhost:4200

图 12.27 – 使用 webpack-bundle-analyzer 运行的应用程序位于 http://localhost:4200

  1. 现在,使用ng build --configuration production命令构建 Angular 应用程序的生产模式。您应该看到以下输出:

图 12.28 – 主捆绑包,大小为 1.11 MB

图 12.28 – 主捆绑包,大小为 1.11 MB

现在我们已经构建了应用程序,让我们看看下一节中的步骤。

如何做…

正如您可能已经注意到的,我们有一个大小为 1.12 MB 的主捆绑包。这是因为我们在app.component.ts文件中使用了Three.js库和moment.js库,它们被导入到主捆绑包中。让我们开始分析捆绑包大小的因素:

  1. 我们首先安装webpack-bundle-analyzer包。在项目根目录中运行以下命令:
npm install --save-dev webpack-bundle-analyzer
  1. 现在,在package.json文件中创建一个脚本。我们将在接下来的步骤中使用这个脚本来分析我们的最终捆绑包。更新package.json文件如下:
{
  ...
  "scripts": {
    "ng": "ng",
    "start": "ng serve",
    "build": "ng build",
    "test": "ng test",
    "lint": "ng lint",
    "e2e": "ng e2e",
    "analyze-bundle": "webpack-bundle-analyzer     dist/using-webpack-bundle-analyzer/stats.json"
  },
  "private": true,
  "dependencies": {... },
  "devDependencies": {...}
}
  1. 现在,再次构建生产捆绑包,但使用参数生成一个stats.json文件。从项目根目录运行以下命令:
ng build --configuration production --stats-json
  1. 现在,运行analyze-bundle脚本来使用webpack-bundle-analyzer包。从项目根目录运行以下命令:
npm run analyze-bundle

这将启动一个带有捆绑包分析的服务器。您应该看到默认浏览器中打开了一个新标签页,它应该是这样的:

图 12.29 – 使用 webpack-bundle-analyzer 进行捆绑包分析

图 12.29 – 使用 webpack-bundle-analyzer 进行捆绑包分析

  1. 注意,lib文件夹占据了捆绑包大小的很大一部分——确切地说是 648.29 KB,你可以通过在lib框上悬停鼠标来检查。让我们尝试优化捆绑包大小。让我们安装date-fns包,这样我们就可以使用它而不是moment.js。从项目根目录运行以下命令:
npm install --save date-fns
  1. 现在,更新app.component.ts文件,使用date-fns包的format()方法,而不是使用moment().format()方法。我们还将只从Three.js包中导入Scene类,而不是导入整个库。代码应该如下所示:
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from './services/auth.service';
import { format } from 'date-fns';
import { Scene } from 'three';
@Component({...})
export class AppComponent {
  ...
  constructor(private auth: AuthService, private router:   Router) {
    const scene = new Scene();
    console.log(format(new Date(), 'LLL do yyyy'));
  }
  ...
}
  1. 运行ng build --configuration production --stats-json命令,然后运行npm run analyze-bundle

一旦webpack-bundle-analyzer运行,您应该会看到分析结果,如下面的屏幕截图所示。请注意,我们不再有moment.js文件或lib块,整体捆绑大小已从 1.15 MB 减少到 831.44 KB:

图 12.30-在使用 date-fns 而不是 moment.js 之后进行捆绑分析

图 12.30-在使用 date-fns 而不是 moment.js 之后进行捆绑分析

哇呜!!!您现在知道如何使用webpack-bundle-analyzer包来审计 Angular 应用程序中的捆绑大小。这是改善整体性能的好方法,因为您可以识别导致捆绑大小增加的块,然后优化捆绑。

另请参阅

第十三章:第十三章:使用 Angular 构建 PWAs

PWAs 或渐进式 Web 应用程序本质上是 Web 应用程序。尽管它们使用现代浏览器支持的增强功能和体验构建,但如果在不支持现代功能/增强功能的浏览器中运行 PWA,则用户仍然可以获得 Web 应用程序的核心体验。在本章中,您将学习如何将 Angular 应用程序构建为 PWA。您将学习一些技术,使您的应用程序具有可安装、功能强大、快速和可靠的特性。以下是本章中要涵盖的内容:

  • 使用 Angular CLI 将现有的 Angular 应用程序转换为 PWA

  • 修改您的 PWA 的主题颜色

  • 在您的 PWA 中使用深色模式

  • 为您的 PWA 提供自定义可安装体验

  • 使用 Angular 服务工作者预缓存请求

  • 为您的 PWA 创建应用程序外壳

技术要求

在本章的示例中,请确保您的计算机上已安装 Git 和 Node.js。您还需要安装@angular/cli包,可以在终端中使用npm install -g @angular/cli来安装。您还需要全局安装http-server包。您可以在终端中运行npm install -g http-server来安装它。本章的代码可以在github.com/PacktPublishing/Angular-Cookbook/tree/master/chapter13找到。

使用 Angular CLI 将现有的 Angular 应用程序转换为 PWA

PWA 涉及一些有趣的组件,其中两个是服务工作者和 Web 清单文件。服务工作者有助于缓存静态资源和缓存请求,而 Web 清单文件包含有关应用程序图标、应用程序的主题颜色等信息。在本示例中,我们将把现有的 Angular 应用程序转换为 PWA。这些原则也适用于从头开始创建的新 Angular 应用程序。为了示例,我们将转换一个现有的 Angular 应用程序。我们将看到我们的 Angular Web 应用程序中发生了什么变化,以及@angular/pwa包如何将其转换为 PWA。还有它如何帮助缓存静态资源。

准备工作

我们将要处理的项目位于克隆存储库中的chapter13/start_here/angular-pwa-app中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng build --configuration production

  4. 现在运行http-server dist/angular-pwa-app -p 4200

这应该以生产模式在http://localhost:4200上运行应用程序,并且应该如下所示:

图 13.1 - angular-pwa-app 在 http://localhost:4200 上运行

图 13.1 - angular-pwa-app 在 http://localhost:4200 上运行

现在我们已经在本地运行了应用程序,让我们在下一节中看到食谱的步骤。

如何做到

我们正在使用的应用程序是一个简单的计数器应用程序。它有一个最小值和最大值,以及一些按钮,可以增加、减少和重置计数器的值。该应用程序将计数器的值保存在localStorage中,但它还不是 PWA。让我们将其转换为 PWA:

  1. 首先,让我们看看我们的应用程序是否根本可以离线工作,因为这是 PWA 的特征之一。为应用程序打开 Chrome DevTools。转到网络选项卡,并将限速更改为离线,如下所示:图 13.2 - 将网络限速更改为离线以查看离线体验

图 13.2 - 将网络限速更改为离线以查看离线体验

  1. 现在通过退出终端中的进程停止http服务器。完成后,刷新应用程序的页面。您应该看到应用程序不再工作,如下图所示:图 13.3 - 应用程序在离线状态下无法工作

图 13.3 - 应用程序在离线状态下无法工作

  1. 要将此应用程序转换为 PWA,请打开一个新的终端窗口/选项卡,并确保您在chapter13/start_here/angular-pwa-app文件夹内。进入后,运行以下命令:
ng add @angular/pwa

当命令完成时,您应该看到一堆文件被创建和更新。

  1. 现在再次构建应用程序,运行ng build --configuration production。完成后,使用http-server dist/angular-pwa-app -p 4200命令进行服务。

  2. 现在确保您已经通过切换到网络选项卡并将无限制设置为选择选项来关闭限速,如图 13.4所示。还要注意禁用缓存选项已关闭:图 13.4 - 关闭网络限速

图 13.4 - 关闭网络限速

  1. 现在刷新应用程序一次。您应该看到应用程序正在工作,并且网络日志显示从服务器加载了 JavaScript 文件等资产,如图 13.5所示:图 13.5 - 从源下载的资产(Angular 服务器)

图 13.5 - 从源(Angular 服务器)下载的资产

  1. 现在再次刷新应用程序,你会看到相同的资产现在是使用服务工作线程从缓存中下载的,如图 13.6所示:图 13.6 - 使用服务工作线程从缓存中下载的资产

图 13.6 - 使用服务工作线程从缓存中下载的资产

  1. 现在是我们一直在等待的时刻。将网络限制改回离线以进入离线模式,然后刷新应用程序。你应该仍然看到应用程序在离线模式下工作,因为服务工作线程,如图 13.7所示:图 13.7 - 使用服务工作线程作为 PWA 离线工作的 Angular 应用程序

图 13.7 - 使用服务工作线程作为 PWA 离线工作的 Angular 应用程序

  1. 而且,你现在实际上可以在你的机器上安装这个 PWA。由于我使用的是 MacBook,它被安装为 Mac 应用程序。如果你使用的是 Chrome,安装选项应该在地址栏附近,如图 13.8所示:

图 13.8 - 从 Chrome 安装 Angular PWA

图 13.8 - 从 Chrome 安装 Angular PWA

砰!只需使用@angular/pwa包,我们就将现有的 Angular 应用程序转换为 PWA,而且没有进行任何配置。我们现在能够离线运行我们的应用程序,并且可以在我们的设备上安装它作为 PWA。看看图 13.9,看看应用程序的外观 - 就像在 macOS X 上的本机应用程序一样:

图 13.9 - 我们的 Angular PWA 在 macOS X 上作为本机应用程序的外观

图 13.9 - 我们的 Angular PWA 在 macOS X 上作为本机应用程序的外观

很酷,对吧?现在你知道如何使用 Angular CLI 构建 PWA 了,看看下一节,了解它是如何工作的。

它是如何工作的

Angular 核心团队和社区在@angular/pwa包以及通常的ng add命令方面做得非常出色,这使我们能够使用 Angular 原理图向我们的应用程序添加不同的包。在这个示例中,当我们运行ng add @angular/pwa时,它使用原理图生成应用程序图标以及 Web 应用程序清单。如果你查看更改的文件,你可以看到新文件,如图 13.10所示:

图 13.10 - Web 清单文件和应用图标文件

图 13.10 - Web 清单文件和应用图标文件

manifest.webmanifest文件是一个包含 JSON 对象的文件。这个对象定义了 PWA 的清单并包含一些信息。这些信息包括应用的名称、简称、主题颜色以及不同设备的不同图标的配置。想象一下这个 PWA 安装在你的安卓手机上。你肯定需要一个图标在你的主屏幕上,点击图标打开应用。这个文件包含了关于根据不同设备尺寸使用哪个图标的信息。

我们还会看到ngsw-config.json文件,其中包含了服务工作者的配置。在幕后,当ng add命令运行原理时,它也会在我们的项目中安装@angular/service-worker包。如果你打开app.module.ts文件,你会看到注册我们服务工作者的代码如下:

...
import { ServiceWorkerModule } from '@angular/service-worker';
...
@NgModule({
  declarations: [AppComponent, CounterComponent],
  imports: [
    ...
    ServiceWorkerModule.register('ngsw-worker.js', {
      enabled: environment.production,
      // Register the ServiceWorker as soon as the app is       stable
      // or after 30 seconds (whichever comes first).
      registrationStrategy: 'registerWhenStable:30000',
    }),
  ],
  ...
})
export class AppModule {}

该代码注册了一个名为ngsw-worker.js的新服务工作者文件。这个文件使用ngsw-config.json文件中的配置来决定缓存哪些资源以及使用哪些策略。

现在你知道这个配方是如何工作的了,看下一节以获取更多信息。

另请参阅

修改 PWA 的主题颜色

在上一个配方中,我们学习了如何将一个 Angular 应用转换为 PWA。当我们这样做时,@angular/pwa包会创建带有默认主题颜色的 Web 应用清单文件,如图 13.9所示。然而,几乎每个 Web 应用都有自己的品牌和风格。如果你想根据自己的品牌主题化 PWA 的标题栏,这就是你需要的配方。我们将学习如何修改 Web 应用清单文件来自定义 PWA 的主题颜色。

准备工作

这个配方的项目位于chapter13/start_here/pwa-custom-theme-color

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng build --configuration production

  4. 现在运行http-server dist/pwa-custom-theme-color -p 5300来提供服务。

  5. 打开localhost:5300来查看应用程序。

  6. 最后,按照图 13.8中所示安装 PWA。

如果你打开 PWA,它应该如下所示:

图 13.11 - PWA 自定义主题颜色应用

图 13.11 - PWA 自定义主题颜色应用

现在我们的应用程序正在运行,让我们在下一节中看看食谱的步骤。

如何做

正如图 13.11中所示,应用程序的标题栏与应用程序的原生标题栏(或工具栏)颜色有些不同。由于这种差异,应用程序看起来有点奇怪。我们将修改 Web 应用程序清单以更新主题颜色。让我们开始吧:

  1. 在你的编辑器中打开src/manifest.webmanifest文件,并按照以下方式更改主题颜色:
{
  "name": "pwa-custom-theme-color",
  "short_name": "pwa-custom-theme-color",
  "theme_color": "#8711fc",
  "background_color": "#fafafa",
  "display": "standalone",
  "scope": "./",
  "start_url": "./",
  "icons": [...]
}
  1. 我们的index.html文件中也设置了theme-color。默认情况下,它优先于 Web 应用程序清单文件。因此,我们需要更新它。打开index.html文件,并按照以下方式更新它:
<!DOCTYPE html>
<html lang="en">
  <head>
    ...
    <link rel="manifest" href="manifest.webmanifest" />
  <meta name="theme-color" content="#8711fc" />
  </head>
  <body>
    ...
  </body>
</html>
  1. 现在,使用ng build --configuration production命令再次构建应用程序。然后使用http-server进行服务,如下所示:
http-server dist/pwa-custom-theme-color -p 5300
  1. 再次打开 PWA 应用程序,并按照图 13.12中所示卸载它。确保在提示时勾选“也清除 Chrome 中的数据(...)”的复选框:图 13.12 – 卸载 pwa-custom-theme-color 应用程序

图 13.12 – 卸载 pwa-custom-theme-color 应用程序

  1. 现在在新的 Chrome 标签页中打开 Angular 应用程序,网址为http://localhost:5300,并按照图 13.8中所示再次安装该应用程序作为 PWA。

  2. PWA 应该已经打开了。如果没有,请从你的应用程序中打开它,你应该会看到更新后的主题颜色,就像图 13.13中所示:

图 13.13 – 带有更新主题颜色的 PWA 应用程序

图 13.13 – 带有更新主题颜色的 PWA 应用程序

太棒了!你刚刚学会了如何为 Angular PWA 更新主题颜色。完成了这个食谱后,查看下一节以获取更多阅读材料。

另请参阅

在你的 PWA 中使用深色模式

在现代设备和应用程序时代,最终用户的偏好也有所发展。随着屏幕和设备的使用增加,健康成为了一个主要关注点。我们知道现在几乎所有屏幕设备都支持深色模式。考虑到这一事实,如果你正在构建一个 Web 应用程序,你可能希望为其提供深色模式支持。如果它是一个以原生应用程序形式呈现的 PWA,那责任就更大了。在这个食谱中,你将学习如何为你的 Angular PWA 提供深色模式。

准备工作

这个食谱的项目位于chapter13/start_here/pwa-dark-mode中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行 npm install 来安装项目的依赖项。

  3. 完成后,运行 ng build --configuration production

  4. 现在运行 http-server dist/pwa-dark-mode -p 6100 进行服务。

  5. 最后,按照 图 13.8 所示安装 PWA

  6. 现在确保您的计算机上启用了暗色主题。如果您正在运行 macOS X,您可以打开 设置 | 通用 并选择 暗色 外观,如 图 13.14 所示:图 13.14 – 在 macOS X 中更改系统外观为暗模式

图 13.14 – 在 macOS X 中更改系统外观为暗模式

  1. 完成后,以原生应用程序的形式打开 PWA,您应该会看到它如 图 13.15 所示:

图 13.15 – PWA 自定义主题颜色应用程序在系统暗模式下的外观

图 13.15 – PWA 自定义主题颜色应用程序在系统暗模式下的外观

现在我们已经将 PWA 作为原生应用程序运行,并将暗模式应用于系统,让我们在下一节中看到食谱的步骤。

如何操作

正如您所见,目前 Angular 应用程序不支持暗模式。我们将从以开发模式运行应用程序开始,并为暗模式添加不同的颜色。让我们开始吧:

  1. 以开发模式运行应用程序,运行命令 ng serve -o --port 9291

这应该会在新的浏览器选项卡中为应用程序提供服务,网址为 http://localhost:4200

  1. 现在,打开 styles.scss 文件以使用 prefers-color-scheme 媒体查询。我们将为全局 CSS 变量使用不同的值,以创建暗模式的不同视图。按照以下方式更新文件:
/* You can add global styles to this file, and also import other style files */
:root {...}
html,
body {...}
@media (prefers-color-scheme: dark) {
  :root {
    --main-bg: #333;
    --text-color: #fff;
    --card-bg: #000;
    --primary-btn-color: #fff;
    --primary-btn-text-color: #333;
  }
}

如果您在浏览器选项卡中再次刷新应用程序,您将看到基于 prefers-color-scheme 媒体查询的不同暗模式视图,如 图 13.16 所示:

图 13.16 – 使用 prefers-color-scheme 媒体查询的暗模式视图

图 13.16 – 使用 prefers-color-scheme 媒体查询的暗模式视图

重要提示

有可能您已经在 localhost:4200 上运行了 PWA;这就是为什么在 步骤 1 中我们将目标端口设为 9291。如果甚至那个端口也被使用过,请确保清除应用程序缓存,然后刷新。

  1. 让我们使用 Chrome DevTools 模拟深色和浅色模式,因为它提供了一个非常好的方法来做到这一点。打开 Chrome DevTools,然后打开“命令”菜单。在 macOS 上,键是Cmd + Shift + P。在 Windows 上,它是Ctrl + Shift + P。然后输入Render,并选择“显示渲染”选项,如图 13.17 所示:图 13.17 - 使用“显示渲染”选项打开渲染视图

图 13.17 - 使用“显示渲染”选项打开渲染视图

  1. 现在,在“渲染”选项卡中,切换prefers-color-scheme仿真为浅色和深色模式,如图 13.18 所示:图 13.18 - 模拟 prefers-color-scheme 模式

图 13.18 - 模拟 prefers-color-scheme 模式

  1. 现在我们已经测试了两种模式。我们可以创建生产版本并重新安装 PWA。运行ng build --configuration production命令以在生产模式下构建应用程序。

  2. 现在通过打开现有的 PWA 并从“更多”菜单中选择“卸载”选项来卸载它,如图 13.12 所示。在提示时确保勾选“同时清除 Chrome 中的数据(...)”的复选框。

  3. 运行以下命令在浏览器上提供构建的应用程序,然后导航到http://localhost:6100

http-server dist/pwa-dark-mode -p 6100
  1. 等待几秒钟,直到地址栏中出现“安装”按钮。然后安装 PWA,类似于图 13.8。

  2. 现在,当你运行 PWA 时,如果你的系统外观设置为深色模式,你应该看到深色模式视图,如图 13.19 所示:

图 13.19 - 我们的 PWA 支持开箱即用的深色模式

图 13.19 - 我们的 PWA 支持开箱即用的深色模式

太棒了!如果你将系统外观从深色模式切换到浅色模式,或者反之亦然,你应该看到 PWA 反映出适当的颜色。现在你知道如何在你的 PWA 中支持深色模式了,看看下一节,看看更多阅读的链接。

另请参阅

在你的 PWA 中提供自定义可安装体验

我们知道 PWA 是可安装的。这意味着它们可以像本机应用程序一样安装在您的设备上。然而,当您首次在浏览器中打开应用时,它完全取决于浏览器如何显示安装选项。这因浏览器而异。而且它也可能不太及时或清晰可见。而且,您可能希望在应用程序启动之后的某个时刻显示安装提示,这对一些用户来说是很烦人的。幸运的是,我们有一种方法为我们的 PWA 提供自定义的安装选项对话框/提示。这就是我们将在本节中学习的内容。

准备工作

本配方的项目位于chapter13/start_here/pwa-custom-install-prompt中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng build --configuration production

  4. 现在运行http-server dist/pwa-custom-install-prompt -p 7200来提供服务。

  5. 导航到http://localhost:7200。等待一会儿,您应该会看到安装提示,如图 13.20所示:

图 13.20 - pwa-custom-install-prompt 在 http://localhost:7200 上运行

图 13.20 - pwa-custom-install-prompt 在 http://localhost:7200 上运行

现在我们的应用程序正在运行,让我们在下一节中看看这个配方的步骤。

如何做

我们有一个名为 Dice Guesser 的应用程序,您可以在其中掷骰子并猜测结果。对于本节,我们将阻止默认的安装提示,并仅在用户猜对时显示它。让我们开始吧:

  1. 首先,创建一个服务,将在接下来的步骤中显示我们的自定义可安装提示。在项目根目录中,运行以下命令:
ng g service core/services/installable-prompt
  1. 现在打开创建的文件installable-prompt.service.ts,并按以下方式更新代码:
import { Injectable } from '@angular/core';
@Injectable({
  providedIn: 'root',
})
export class InstallablePromptService {
  installablePrompt;
  constructor() {
    this.init();
  }
  init() {
    window.addEventListener(
      'beforeinstallprompt',
      this.handleInstallPrompt.bind(this)
    );
  }
  handleInstallPrompt(e) {
    e.preventDefault();
    // Stash the event so it can be triggered later.
    this.installablePrompt = e;
    console.log('installable prompt event fired');
    window.removeEventListener('beforeinstallprompt',     this.handleInstallPrompt);
  }
}
  1. 现在,让我们构建我们将向用户显示的自定义对话框/提示。我们将使用@angular/material包中已经安装在项目中的Material对话框。打开app.module.ts文件,并按以下方式更新它:
...
import { MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
@NgModule({
  declarations: [... ],
  imports: [
    ...
    BrowserAnimationsModule,
    MatDialogModule,
    MatButtonModule,
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}
  1. 让我们为Material对话框创建一个组件。在项目根目录中,运行以下命令:
ng g component core/components/installable-prompt
  1. 现在我们将在InstallablePromptService中使用这个组件。打开installable-prompt.service.ts文件,并按以下方式更新代码:
...
import { MatDialog } from '@angular/material/dialog';
import { InstallablePromptComponent } from '../components/installable-prompt/installable-prompt.component';
@Injectable({...})
export class InstallablePromptService {
  installablePrompt;
  constructor(private dialog: MatDialog) {...}
...
  async showPrompt() {
    if (!this.installablePrompt) {
      return;
    }
    const dialogRef = this.dialog.    open(InstallablePromptComponent, {
      width: '300px',
    });
  }
}
  1. 我们还需要根据我们自定义可安装提示的选择来显示浏览器的提示。例如,如果用户点击按钮,这意味着他们想将应用程序安装为 PWA。在这种情况下,我们将显示浏览器的提示。按照以下方式进一步更新installable-prompt.service.ts文件:
...
export class InstallablePromptService {
  ...
  async showPrompt() {

    …
    const dialogRef = this.dialog.    open(InstallablePromptComponent, {
      width: '300px',
    });
    dialogRef.afterClosed().subscribe(async (result) => {
      if (!result) {
        this.installablePrompt = null;
        return;
      }
      this.installablePrompt.prompt();
      const { outcome } = await this.installablePrompt.      userChoice;
      console.log(`User response to the install prompt:       ${outcome}`);
      this.installablePrompt = null;
    });
  }
}
  1. 现在我们已经为浏览器的提示设置了主要代码。让我们来处理我们自定义可安装提示的模板。打开installable-prompt.component.html文件,并用以下代码替换模板:
<h1 mat-dialog-title>Add to Home</h1>
<div mat-dialog-content>
  <p>Enjoying the game? Would you like to install the app   on your device?</p>
</div>
<div mat-dialog-actions>
  <button mat-button [mat-dialog-close]="false">No   Thanks</button>
  <button mat-button [mat-dialog-close]="true" cdkFocusInitial>Sure</button>
</div>
  1. 最后,每当用户猜对时,让我们显示这个提示。打开game.component.ts文件,并按照以下方式更新它:
...
import { InstallablePromptService } from '../core/services/installable-prompt.service';
...
@Component({...})
export class GameComponent implements OnInit {
  ...
  constructor(
    private leaderboardService: LeaderboardService,
    private instPrompt: InstallablePromptService
  ) {}
  ...
  showResult(diceSide: IDiceSide) {
    ...
    this.scores = this.leaderboardService.setScores({
      name: this.nameForm.get('name').value,
      score: 50,
    });
    this.instPrompt.showPrompt();
  }
}
  1. 现在让我们测试应用程序。使用以下命令在生产模式下构建应用程序,并使用http-server包在端口7200上提供服务:
ng build --configuration production
http-server dist/pwa-custom-install-prompt -p 7200
  1. 在我们测试之前,您可能想要清除应用程序的缓存并注销服务工作者。您可以通过打开 Chrome DevTools 并导航到应用程序选项卡来执行此操作。然后点击图 13.21所示的清除站点数据按钮。确保选中注销服务工作者选项:图 13.21 - 清除站点数据,包括服务工作者

图 13.21 - 清除站点数据,包括服务工作者

  1. 现在玩游戏,直到您猜对一个答案。一旦您猜对,您将看到自定义可安装提示,如图 13.22所示。点击确定按钮,您应该会看到浏览器的提示:

图 13.22 - 我们 PWA 的自定义可安装提示

图 13.22 - 我们 PWA 的自定义可安装提示

太棒了!现在您可以通过安装和卸载几次 PWA 应用程序并尝试用户选择安装或不安装应用程序的所有组合来玩转应用程序。这都是有趣的游戏。现在您知道如何为 Angular PWA 实现自定义安装提示,接下来请查看下一节以了解其工作原理。

它是如何工作的

这个示例的核心是beforeinstallprompt事件。这是一个标准的浏览器事件,在最新版本的 Chrome、Firefox、Safari、Opera、UC 浏览器(Android 版本)和 Samsung Internet 中都得到支持,也就是几乎所有主要浏览器。该事件有一个prompt()方法,在设备上显示浏览器的默认提示。在这个示例中,我们创建了InstallablePromptService并将事件存储在其local属性中。这样我们可以在用户猜对正确的值时随需使用它。请注意,一旦我们收到beforeinstallprompt事件,就会从window对象中移除事件侦听器,这样我们只保存一次事件。这是在应用程序启动时。如果用户选择不安装应用程序,我们在同一会话中不会再次显示提示。但是,如果用户刷新应用程序,他们仍然会在第一次猜对时获得一次提示。我们可以进一步将这个状态保存在localStorage中,以避免在页面刷新后再次显示提示,但这不是这个示例的一部分。

对于自定义安装提示,我们使用@angular/material包中的MatDialog服务。该服务有一个open()方法,接受两个参数:要显示为 Material 对话框的组件和MatDialogConfig。在这个示例中,我们创建了InstallablePromptComponent,它使用了来自@angular/material/dialog包的一些带指令的 HTML 元素。请注意,在按钮上,我们在installable-prompt.component.html文件中使用了属性[mat-dialog-close]。值分别设置为truefalse,用于确定不,谢谢按钮。这些属性帮助我们将相应的值从此模态发送到InstallablePromptService。请注意在installable-prompt.service.ts文件中使用了dialogRef.afterClosed().subscribe()。这是值被传递回去的地方。如果值为true,那么我们使用事件,也就是this.installablePrompt属性的.prompt()方法来显示浏览器的提示。请注意,在使用后我们将installablePrompt属性的值设置为null。这样我们在同一会话中不会再次显示提示,直到用户刷新页面。

现在您了解了所有的工作原理,可以查看下一节以获取进一步阅读的链接。

另请参阅

使用 Angular 服务工作者预缓存请求

在我们之前的示例中添加了服务工作者,我们已经看到它们已经缓存了资产,并在离线模式下使用服务工作者提供它们。但是网络请求呢?如果用户离线并立即刷新应用程序,网络请求将失败,因为它们没有与服务工作者一起缓存。这导致了破碎的离线用户体验。在这个示例中,我们将配置服务工作者来预缓存网络请求,以便应用程序在离线模式下也能流畅运行。

准备工作

我们要处理的项目位于克隆存储库中的chapter13/start_here/precaching-requests中:

  1. 在 Visual Studio Code 中打开项目。

  2. 完成后,运行ng build --configuration production

  3. 现在运行http-server dist/precaching-requests -p 8300来提供服务。

  4. 导航到http://localhost:8300。刷新应用程序一次。然后按照图 13.2所示切换到离线模式。如果您转到网络选项卡并使用查询results过滤请求,您应该看到请求失败,如图 13.23所示:

图 13.23 - 由于未缓存网络请求而导致的离线体验破碎

图 13.23 - 由于未缓存网络请求而导致的离线体验破碎

现在我们看到网络请求失败了,让我们在下一节中看看修复这个问题的步骤。

如何做

对于这个示例,我们有用户列表和搜索应用程序,从 API 端点获取一些用户。正如您在图 13.23中所看到的,如果我们进入离线模式,fetch调用以及对服务工作者的请求也会失败。这是因为服务工作者尚未配置为缓存数据请求。让我们开始修复这个问题的示例:

  1. 为了缓存网络请求,打开ngsw-config.json文件并进行如下更新:
{
  "$schema": "./node_modules/@angular/service-worker/  config/schema.json",
  "index": "/index.html",
  "assetGroups": [...],
  "dataGroups": [
    {
      "name": "api_randomuser.me",
      "urls": ["https://api.randomuser.me/?results*"],
      "cacheConfig": {
        "strategy": "freshness",
        "maxSize": 100,
        "maxAge": "2d"
      }
    }
  ]
};
  1. 现在让我们测试一下应用程序。使用以下命令以生产模式构建应用程序,并使用http-server包在端口8300上提供服务:
ng build --configuration production
http-server dist/precaching-requests -p 8300
  1. 现在导航到 http://localhost:8300. 确保此时没有使用网络限速。也就是说,你没有处于离线模式。

  2. 使用 Chrome DevTools 清除应用程序数据,如图 13.21所示。完成后,刷新应用程序页面。

  3. 在 Chrome DevTools 中,转到网络选项卡,并切换到离线模式,如图 13.2所示。现在使用查询results过滤网络请求。即使处于离线状态,您也应该看到结果。网络调用是由 service worker 提供的,如图 13.24所示:

图 13.24 – 使用 service worker 离线工作的网络调用

图 13.24 – 使用 service worker 离线工作的网络调用

哇!即使现在点击一个卡片,您仍然应该看到应用程序无缝运行,因为所有页面都使用相同的 API 调用,因此由 service worker 提供。通过这样,您刚刚学会了如何在 Angular 应用程序中配置 service worker 以缓存网络/数据请求。即使离线,您也可以安装 PWA 并使用它。很棒,对吧?

现在我们已经完成了这个教程,让我们在下一节中看看它是如何工作的。

工作原理

这个教程的核心是ngsw-config.json文件。当使用@angular/service-worker包生成 service worker 文件时,该文件将被@angular/pwa原理图使用时,该文件已经包含一个 JSON 对象。该 JSON 包含一个名为assetGroups的属性,基本上根据提供的配置来配置资产的缓存。对于这个教程,我们希望缓存网络请求以及资产。因此,我们在 JSON 对象中添加了新属性dataGroups。让我们看看配置:

"dataGroups": [
    {
      "name": "api_randomuser.me",
      "urls": ["https://api.randomuser.me/?results*"],
      "cacheConfig": {
        "strategy": "freshness",
        "maxSize": 100,
        "maxAge": "2d"
      }
    }
  ]

如您所见,dataGroups 是一个数组。我们可以将不同的配置对象作为其元素提供。每个配置都有一个name,一个urls数组,以及定义缓存策略的cacheConfig。对于我们的配置,我们使用了 API URL 的通配符,也就是说,我们使用了urls: ["https://api.randomuser.me/?results*"]。对于cacheConfig,我们使用了"freshness"策略,这意味着应用程序将始终首先从其原始位置获取数据。如果网络不可用,那么它将使用来自服务工作器缓存的响应。另一种策略是"performance",它首先查找服务工作器以获取缓存的响应。如果缓存中没有特定 URL(或 URL)的内容,那么它将从实际原始位置获取数据。maxSize属性定义了可以为相同模式(或一组 URL)缓存多少个请求。maxAge属性定义了缓存数据在服务工作器缓存中存活多长时间。

现在您知道这个示例是如何工作的,请参阅下一节以获取进一步阅读的链接。

另请参阅

为您的 PWA 创建一个应用外壳

在构建 Web 应用程序的快速用户体验时,最大的挑战之一是最小化关键渲染路径。这包括加载目标页面的最关键资源,解析和执行 JavaScript 等。通过应用外壳,我们有能力在构建时而不是运行时渲染页面或应用的一部分。这意味着用户最初将看到预渲染的内容,直到 JavaScript 和 Angular 开始运行。这意味着浏览器不必为了第一个有意义的绘制而工作和等待一段时间。在这个示例中,您将为 Angular PWA 创建一个应用外壳。

准备就绪

我们要处理的项目位于克隆存储库内的chapter13/start_here/pwa-app-shell中:

  1. 在 Visual Studio Code 中打开项目。

  2. 打开终端并运行npm install来安装项目的依赖项。

  3. 完成后,运行ng serve -o

这应该打开一个选项卡,并在http://localhost:4200上运行应用程序,如图 13.25所示:

图 13.25 - 在 http://localhost:4200 上运行的 pwa-app-shell

图 13.25 - pwa-app-shell 运行在 http://localhost:4200

现在我们将禁用 JavaScript 以模拟解析 JavaScript 需要很长时间。或者,模拟尚未放置 App Shell。打开 Chrome DevTools 并打开命令面板。在 macOS X 上的快捷键是Cmd + Shift + P,在 Windows 上是Ctrl + Shift + P。输入Disable JavaScript,选择该选项,然后按Enter。您应该看到以下消息:

图 13.26 - 应用程序中没有 App Shell

图 13.26 - 应用程序中没有 App Shell

现在我们已经检查了 App Shell 的缺失,让我们在下一节中看到该配方的步骤。

如何操作

我们有一个从 API 获取一些用户的 Angular 应用程序。我们将为此应用程序创建一个 App Shell,以便作为 PWA 更快地提供第一个有意义的绘制。让我们开始吧:

  1. 首先,通过从项目根目录运行以下命令为应用程序创建 App Shell:
ng generate app-shell
  1. 更新app.module.ts以导出组件,以便我们可以使用它们在 App Shell 中呈现Users页面。代码应如下所示:
...
@NgModule({
  declarations: [...],
  imports: [... ],
  providers: [],
  exports: [
    UsersComponent,
    UserCardComponent,
    UserDetailComponent,
    AppFooterComponent,
    LoaderComponent,
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}
  1. 现在打开app-shell.component.html文件,并使用<app-users>元素,以便在 App Shell 中呈现整个UsersComponent。代码应如下所示:
<app-users></app-users>
  1. 现在我们已经为 App Shell 编写了代码。让我们创建它。运行以下命令以在开发模式下生成 App Shell:
ng run pwa-app-shell:app-shell
  1. 一旦在步骤 4中生成了 App Shell,请运行以下命令使用http-server包来提供它:
http-server dist/pwa-app-shell/browser -p 4200
  1. 确保应用程序的 JavaScript 仍然关闭。如果没有,请打开 Chrome DevTools,按下 macOS X 上的Cmd + Shift + P以打开命令面板(Windows 上的Ctrl + Shift + P)。然后输入Disable Javascript,按Enter选择如图 13.27所示的选项:图 13.27 - 使用 Chrome DevTools 禁用 JavaScript

图 13.27 - 使用 Chrome DevTools 禁用 JavaScript

  1. 在禁用 JavaScript 的情况下刷新应用程序。现在,尽管 JavaScript 被禁用,您应该看到应用程序仍然显示了预渲染的用户页面,如图 13.28所示。哇哦!图 13.28 - App Shell 显示了预渲染的用户页面

图 13.28 - App Shell 显示了预渲染的用户页面

  1. 要验证我们是否在构建时预渲染了用户页面,请检查<project-root>/dist/pwa-app-shell/browser.index.html中生成的代码。您应该在<body>标签内看到整个渲染的页面,如图 13.29所示:图 13.29 - 包含预渲染用户页面的 index.html 文件

图 13.29 - 包含预渲染用户页面的 index.html 文件

  1. 通过运行以下命令创建带有 App Shell 的生产构建,并在端口1020上提供服务:
ng run pwa-app-shell:app-shell:production
http-server dist/pwa-app-shell/browser -p 1020
  1. 在浏览器中导航到http://localhost:1020,并按照图 13.8所示安装应用程序作为 PWA。完成后,运行 PWA,它应该如下所示:

图 13.30 - 安装后作为本机应用程序运行的 pwa-app-shell

图 13.30 - 安装后作为本机应用程序运行的 pwa-app-shell

太棒了!现在你知道如何为你的 Angular PWA 创建一个 App Shell。现在您已经完成了这个食谱,请查看下一节关于它是如何工作的。

它是如何工作的

该食谱始于为我们的应用程序禁用 JavaScript。这意味着当应用程序运行时,我们只显示静态的 HTML 和 CSS,因为没有 JavaScript 执行。我们看到一个关于不支持 JavaScript 的消息,如图 13.26所示。

然后我们运行ng generate app-shell命令。这个 Angular CLI 命令为我们做了以下几件事情:

  • 创建一个名为AppShellComponent的新组件,并生成其相关文件。

  • 在项目中安装了@angular/platform-server包。

  • 更新app.module.ts文件以使用BrowserModule.withServerTransition()方法,这样我们就可以为服务器端渲染提供appId属性。

  • 添加了一些新文件,即main.server.tsapp.server.module.ts,以启用服务器端渲染(确切地说是我们的 App Shell 的构建时渲染)。

  • 最重要的是,它更新了angular.json文件,添加了一堆用于服务器端渲染的原理图,以及用于生成app-shell的原理图。

在这个食谱中,我们从AppModule中导出组件,这样我们就可以在应用外壳中使用它们。这是因为应用外壳不是AppModule的一部分。相反,它是在app.server.module.ts文件中新创建的AppServerModule的一部分。正如您所看到的,在这个文件中,我们已经导入了AppModule。尽管如此,除非我们从AppModule中导出它们,否则我们无法使用这些组件。在导出组件之后,我们更新了app-shell.component.html(应用外壳模板),以使用<app-users>选择器,这反映了UsersComponent类。这就是整个用户页面。

我们通过运行ng run pwa-app-shell:app-shell命令来验证应用外壳。这个命令会生成一个带有应用外壳(非最小化代码)的开发模式下的 Angular 构建。请注意,在通常的构建中,我们会在dist文件夹内生成pwa-app-shell文件夹。在这个文件夹内,我们会有index.html。然而,在这种情况下,我们在pwa-app-shell文件夹内创建了两个文件夹,即browser文件夹和server文件夹。我们的index.html位于browser文件夹内。如图 13.29所示,我们在index.html文件的<body>标签内有整个用户页面的代码。这段代码是在构建时预渲染的。这意味着 Angular 打开应用程序,进行网络调用,然后在构建时将 UI 预渲染为应用外壳。因此,一旦应用程序打开,内容就会被预渲染。

要生成带有应用外壳的生产构建,我们运行ng run pwa-app-shell:app-shell:production命令。这将生成带有应用外壳的生产 Angular 构建,并进行了最小化处理。最后,我们安装 PWA 进行测试。

现在您知道了这个食谱是如何工作的,请查看下一节以获取进一步阅读的链接。

参见

posted @ 2024-05-18 12:03  绝不原创的飞龙  阅读(27)  评论(0编辑  收藏  举报