Angular-秘籍第二版-全-

Angular 秘籍第二版(全)

原文:zh.annas-archive.org/md5/69fbe45134859c45b2aa58e42abe465f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Angular 是世界上最受欢迎的框架之一,不仅用于构建网络应用,甚至还可以用于构建移动和桌面应用。由 Google 支持并由 Google 使用,这个框架被数百万个应用使用。尽管该框架非常适合任何规模的应用,但企业尤其偏爱 Angular,因为它具有明确的性质和一致的生态系统,该生态系统包括创建现代和可生产网络应用所需的所有工具。

虽然学习 JavaScript、HTML 和 CSS 等核心技术对于成为一名网络开发者来说是绝对必要的,但当涉及到框架时,学习框架本身的核心概念也同样重要。当我们处理 Angular 时,通过了解并使用 Angular 生态系统中的正确工具,我们可以对我们的网络应用做很多事情。这正是本书的用武之地。

本书是为中级和高级 Angular 开发者编写的,旨在通过易于遵循、可以玩转和实践自己变体的食谱来磨练他们的 Angular 开发技能。你不仅可以从食谱本身学习,还可以从与食谱相关的实际真实项目中学习。因此,这些食谱和项目中有很多隐藏的宝藏等你去发现。

开心编码!

本书面向对象

本书面向寻求解决 Angular 企业开发中常见问题的中级 Angular 网络开发者。使用 Angular 技术的移动开发者也会发现本书很有用。理解本书涵盖的主题需要具备 JavaScript 和 TypeScript 的工作经验。

本书涵盖内容

第一章赢得组件通信,解释了实现 Angular 组件间通信的不同技术。涵盖了@Input()@Output()装饰器、服务以及生命周期钩子。本章还涵盖了 Angular Signals,它在 Angular v17 中变得稳定。

第二章与 Angular 指令和内置控制流一起工作,介绍了 Angular 指令以及使用属性指令、结构指令和指令组合 API 的一些食谱。

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

第四章理解 Angular 动画,包含了实现多状态动画、交错动画、关键帧动画以及在不同路由间切换的 Angular 应用的动画的食谱。

第五章Angular 和 RxJS – 强强联合,涵盖了关于执行顺序和并行 HTTP 调用的 RxJS 食谱。它还包括关于使用combineLatestflatMapdebounceTimeswitchMap运算符的食谱,以及关于使用 RxJS 流的技巧和窍门。

第六章使用 NgRx 进行响应式状态管理,包含关于著名 NgRX 库及其核心概念的食谱。它涵盖了 NgRx 动作、reducer、selectors 和 effects 等核心概念,并探讨了使用@ngrx/store-devtools@component/store等包。

第七章理解 Angular 导航和路由,探讨了关于懒加载路由、路由守卫、路由预加载策略以及与 Angular 路由一起使用的有趣技术的食谱。

第八章精通 Angular 表单,涵盖了模板驱动表单、响应式表单、表单验证、测试表单、表单数组和创建自己的表单控件。

第九章Angular 和 Angular CDK,包含许多有趣的 Angular CDK 食谱,包括虚拟滚动、键盘导航、overlay API、CDK 菜单、CDK 拖放、CDK 步骤 API 和 CDK Listbox API。

第十章使用 Jest 在 Angular 中编写单元测试,涵盖了使用 Jest 进行单元测试的食谱,探索 Jest 中的全局模拟,模拟服务/子组件/管道,使用 Angular CDK 组件工具包,以及单元测试 Observables。

第十一章使用 Cypress 在 Angular 中进行端到端测试,提供了在 Angular 应用程序中使用 Cypress 进行端到端测试的食谱。它涵盖了验证表单、等待 XHR 调用、模拟 HTTP 调用响应、使用 Cypress 捆绑包以及使用 Cypress 中的固定值。

第十二章Angular 的性能优化,包含了一些使用OnPush变更检测策略、懒加载功能路由、从组件中分离变更检测器、使用 Angular 的 web workers、使用纯管道、向 Angular 应用程序添加性能预算以及使用webpack-bundle分析器来提高 Angular 应用程序性能的技巧。

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

为了充分利用这本书

这本书的食谱是用 Angular v17 构建的,Angular 为其发布遵循语义版本控制。由于 Angular 不断改进,为了稳定起见,Angular 团队为更新提供了可预测的发布周期。发布频率如下:

  • 每半年一个大版本发布。

  • 每个大版本发布 1 到 3 个小版本。

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

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

本书中涵盖的软件 操作系统要求
Angular v17 Windows, macOS, or Linux
TypeScript 5.x+
Nx v17

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

读完本书后,请确保发推文告诉我您对本书的反馈。此外,您可以根据自己的喜好修改本书提供的代码,将其上传到您的 GitHub 仓库并分享。我会确保转发它:)

下载示例代码文件

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

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

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/gbp/9781803233444

使用的约定

本书使用了多种文本约定。

文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 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 {...}
} 

粗体: 表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“您会注意到我们看不到输入内容的全部内容——在最理想的情况下,这也是有点令人烦恼的,因为您在按下操作按钮之前无法真正地审查它。”

重要提示

应该看起来像这样。

提示

应该看起来像这样。

联系我们

我们欢迎读者的反馈。

一般反馈: 如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误表: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将非常感激您向我们报告。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

一旦您阅读了《Angular Cookbook - 第二版》,我们非常乐意听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。

按照以下简单步骤获取优惠:

  1. 扫描下面的二维码或访问以下链接

https://packt.link/free-ebook/9781803233444

  1. 提交您的购买证明

  2. 就这些!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件

第一章:赢得组件通信

在本章中,您将掌握 Angular 中的组件通信。您将学习不同的技术来建立组件间的通信,并了解在哪种情况下哪种技术是合适的。您还将学习关于自 Angular v17 起稳定的新的信号 API 的内容。

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

  • 使用组件 @Input@Output 属性进行组件通信

  • 使用服务进行组件通信

  • 使用设置器来拦截输入属性的变化

  • 使用 ngOnChanges 来拦截输入属性的变化

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

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

  • 独立组件和通过路由参数传递数据

  • 使用信号进行组件通信

技术要求

对于本章的食谱,请确保您的设置已按照 'Angular-Cookbook-2E' GitHub 仓库中的 'Technical Requirements' 完成。有关设置详情,请访问:github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/docs/technical-requirements.md。本章的起始代码位于 github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/start/apps/chapter01

使用组件 @Input 和 @Output 属性进行组件通信

您将从包含父组件和两个子组件的应用开始。然后,您将使用 Angular @Input@Output 装饰器通过属性和 EventEmitter(s) 建立它们之间的通信。通信流程如图 图 1.1 所示。

图 1.1:使用 @Input() 和 @Output() 属性的通信流程

准备工作

我们将要工作的应用位于克隆的仓库中的 start/apps/chapter01/cc-inputs-outputs

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令来启动项目:

    npm run serve cc-inputs-outputs 
    

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

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

如何做到这一点...

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

  1. 我们将把 notificationsCount 变量从 NotificationsManagerComponent 移动到 AppComponent。为此,在 app.component.ts 中创建一个 notificationsCount 属性,如下所示:

    export class AppComponent {
      **notificationsCount =** **0****;**
    } 
    
  2. 接下来,将 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;
      }
    } 
    
  3. 更新 notifications-manager.component.html 以使用 count 而不是 notificationsCount

    <div class="notif-manager">
    <div class="notif-manager__count">
        Notifications Count: {{**count**}}
      </div>
      ...
    </div> 
    
  4. 接下来,将 notificationsCount 属性从 app.component.html 传递到 <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**;
    } 
    
  5. 现在,在 notifications-button.component.ts 中也创建一个名为 count@Input()

    import { Component, OnInit, **Input** } from '@angular/core';
    ...
    export class NotificationsButtonComponent implements OnInit {
      @**Input****() count =** **0****;**
      ...
    } 
    
  6. app.component.html 中也将 notificationsCount 传递给 <app-notifications-button>

    <!-- 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>
    ... 
    
  7. 使用 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 改变计数,通知铃声图标上的计数不会改变。

  8. 为了传达从 NotificationsManagerComponentNotificationsButtonComponent 的变化,我们现在将使用 Angular 的 @Output() 属性。在 notifications-manager.component.ts 中使用来自 '@angular/core'@Output@EventEmitter

    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****)**;
      }
    } 
    
  9. 然后,在 app.component.html 中监听来自 NotificationsManagerComponent 的先前发出的事件,并相应地更新 notificationsCount 属性:

    <div class="content" role="main">
    <app-notifications-manager
     **(****countChanged****)=****"updateNotificationsCount($event)**"
     [count]="notificationsCount">
     </app-notifications- manager>
    </div> 
    
  10. 由于我们之前已监听 countChanged 事件并调用 updateNotificationsCount 方法,我们需要在 app.component.ts 中创建此方法并相应地更新 notificationsCount 属性的值:

    export class AppComponent {
      notificationsCount = 10;
      **updateNotificationsCount****(****count:** **number****) {**
    **this****.****notificationsCount** **= count;**
    **}**
    } 
    

它是如何工作的…

为了使用 @Input()@Output() 属性在组件之间进行通信,数据流始终是 子组件 父组件通过输出事件发射器(@Output()), 父组件到子组件通过输入绑定(@Input())。一般来说,当两个兄弟组件需要通信时,其中一个必须使用输出发射器将值传递给父组件,然后父组件可以将新的(更新的)值 作为输入 提供给其他子组件。因此,NotificationsManagerComponent 发出 countChanged 事件。AppComponent(作为父组件)监听该事件并更新 notificationsCount 的值,由于 notificationsCount 被作为 @Input() count 传递给 NotificationsButtonComponent,这会自动更新 NotificationsButtonComponent 中的 count 属性。图 1.3 展示了整个流程:

图 1.3:组件如何通过输入和输出进行通信

参考内容

使用服务进行组件通信

在这个菜谱中,你将从包含父组件和子组件的应用程序开始。然后,你将使用 Angular 服务在它们之间建立通信。我们将使用 BehaviorSubjectObservable 流来在组件和服务之间进行通信。

准备工作

该菜谱的项目位于 start/apps/chapter01/cc-services:

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

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve cc-services 
    

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

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

如何做到这一点…

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

  1. 从终端确保你位于工作区的根目录,并运行以下命令创建一个名为 NotificationsService 的新服务:

    cd start && nx g s services/Notifications --project cc-services 
    
  2. notifications.service.ts 中创建一个名为 count$BehaviorSubject,并使用 0 初始化它(BehaviorSubject 需要一个初始值):

    import { Injectable } from '@angular/core';
    **import** **{** **BehaviorSubject** **}** **from****'rxjs'****;**
    @Injectable({
      providedIn: 'root'
    })
    export class NotificationsService {
      **count$ =** **new****BehaviorSubject****(****0****);**
    setCount(value: number) {
        this.count$.next(value);
      }
    } 
    
  3. notifications-manager.component.ts 中的 notificationsCount 属性重命名为 notificationsCount$,并将其分配给服务的 count$ 属性,如下所示:

    import { Component, **inject** } from '@angular/core';
    **import** **{** **NotificationsService** **}** **from****'../services/notifications.service'****;**
    ...
    export class NotificationsManagerComponent implements OnInit {
      **notificationService =** **inject****(****NotificationsService****);**
    **notificationsCount$ =** **this****.****notificationService****.****count$****;**
    ...
    } 
    
  4. 修改与 NotificationsManagerComponent 相关的函数,以更新 Behavior Subject 的值,如下所示:

    ...
    export class NotificationsManagerComponent implements OnInit {
      ...
      addNotification() {
        **const** **currentValue =**
    **this****.****notificationsCount$****.****getValue****();**
    **this****.****notificationService****.****setCount****(currentValue +** **1****);**
      }
      removeNotification() {
        **const** **currentValue =**
    **this****.****notificationsCount$****.****getValue****();**
    **if** **(currentValue ===** **0****) {**
    **return****;**
    **}**
    **this****.****notificationService****.****setCount****(currentValue -** **1****);**
      }
      resetCount() {
        **this****.****notificationService****.****setCount****(****0****);**
      }
    } 
    
  5. notifications-manager.component.html 中使用 async 管道,利用 notificationsCount$ 可观察对象来显示其值:

    <div class="notif-manager">
    <div class="notif-manager__count">
        Notifications Count: {{**notificationsCount$ | async**}}
      </div>
      ...
    </div> 
    
  6. 现在,类似地,在 notifications-button.component.ts 中注入 NotificationsService,在 NotificationsButtonComponent 中创建一个名为 notificationsCount$ 的可观察对象,并将其分配给服务的 count$ 可观察对象:

    import { Component**, inject** } from '@angular/core';
    **import** **{** **NotificationsService** **}** **from****'../services/notifications.service'****;**
    ...
    export class NotificationsButtonComponent {
      **notificationsCount$ =** **inject****(****NotificationsService****).****count$****;**
    } 
    
  7. notifications-button.component.html 中使用 async 管道,利用 notificationsCount$ 可观察对象:

    <div class="bell">
    <i class="material-icons">notifications</i>
    <div class="bell__count">
    <div class="bell__count__digits">
          {{**notificationsCount$ | async**}}
        </div>
    </div>
    </div> 
    

    如果你现在刷新应用程序,你应该能够在通知管理组件和通知按钮组件中看到值 0

  8. count BehaviorSubject 的初始值更改为 10 并查看它是否在两个组件中反映:

    ...
    export class NotificationsService {
      private count: BehaviorSubject<number> = new
    BehaviorSubject<number>(**10**);
      ...
    } 
    

它是如何工作的…

BehaviorSubject 是一种特殊的 Observable 类型,它需要一个初始值并且可以被多个订阅者使用。在这个菜谱中,我们创建一个 BehaviorSubject 来存储通知计数器的值。

一旦我们创建了名为 count$BehaviorSubject,我们使用(相对较新的)inject 函数在我们的组件中注入 NotificationsService,并将服务的 count$ 属性分配给组件的一个属性。这允许我们在 NotificationsButtonComponentNotificationsManagerComponent 中使用 BehaviorSubject

然后,我们在上述两个函数的模板中使用 notificationsCount$ 属性,以便能够渲染计数值。注意,我们在模板中使用了 async 管道。这有助于 Angular 在组件渲染时让模板订阅 BehaviorSubject,并在组件销毁时自动取消订阅。

要更新 BehaviorSubject 的值,我们通过提供要设置的新值来使用其 next 方法。一旦 count$ 的值被更新,组件就会因为 RxJS 和 Angular 的变更检测而重新渲染新值。

图 1.5:使用 Angular 服务进行组件通信的方式

相关内容

使用设置器来拦截输入属性更改

在这个菜谱中,你将学习如何拦截从父组件传递的 @Input 的更改,以及如何在此事件上执行某些操作。我们将拦截从 VersionControlComponent 父组件传递到 VcLogsComponent 子组件的 vName 输入。我们将使用 设置器vName 的值更改时生成日志,并在子组件中显示这些日志。

准备工作

本菜谱的项目位于 start/apps/chapter01/cc-setters

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

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve cc-setters 
    

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

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

如何做到这一点...

  1. 我们首先在 VcLogsComponent 中创建一个 logs 数组,如下所示,以存储我们稍后将在模板中显示的所有日志:

    export class VcLogsComponent implements OnInit {
      @Input() vName;
      **logs****:** **string****[] = [];**
    } 
    
  2. 让我们修改 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.7:带有日志容器的 cc-setters 应用程序

  3. 现在,我们将vc-logs.component.ts中的@Input()转换为使用gettersetter,以便我们可以拦截输入变化。为此,我们还将创建一个名为_vName的内部属性。代码应如下所示:

    import { Component, **Input** } from '@angular/core'
    ...
    export class VcLogsComponent implements OnInit {
      **@Input****()**
    **get****vName****() {**
    **return****this****.****_vName****;**
    **}**
    **set****vName****(****name:** **string****) {**
    **this****.****_vName** **= name;**
    **}**
    logs: string[] = [];
      **_vName!:** **string****;**
    ...
    } 
    
  4. 修改 setter 以创建一些日志。每当vName的值发生变化时,我们将向logs数组中推送一个新的日志。第一次,我们将推送一条日志,内容为'initial version is x.x.x'

    export class VcLogsComponent implements OnInit {
      ...
      set vName(name: string) {
        **if** **(!****this****.****_vName****) {**
    **this****.****logs****.****push****(****`initial version is** **${name.trim()}****`****)**
    **}**
    this._vName = name;
      }
    ...
    } 
    
  5. 现在,每次我们更改版本名称时,都需要显示不同的消息,内容为'version changed to x.x.x'图 1.8显示了最终输出。为了进行必要的更改,让我们修改vNamesetter 如下:

    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.8:使用 setter 的最终输出

工作原理...

JavaScript 有getters作为返回动态计算值的函数。它也有setters作为在目标属性变化时执行一些逻辑的函数。Angular 使用 TypeScript,它是 JavaScript 的超集,Angular 的@Input()属性也可以使用getterssetters,因为它们基本上是提供类中的属性。

图 1.9:解释 cc-setters 应用中数据流的图表

对于这个菜谱,我们为名为vName的输入使用 getter 和 setter,所以每当输入发生变化时,我们使用 setter 函数将新版本推送到日志列表。然后我们在模板中使用logs数组来渲染视图上的日志列表。

总是使用私有变量/属性与使用 getters 和 setters 的属性一起使用是一个好主意。这样我们就可以在我们的组件中修改私有属性,而模板只通过 getter 访问公共属性。

参见

使用 ngOnChanges 拦截输入属性变化

在这个菜谱中,您将学习如何使用ngOnChanges通过SimpleChanges API 拦截变化。我们将监听从VersionControlComponent父组件传递到VcLogsComponent子组件的vName输入。

准备工作

我们将要工作的应用位于克隆的仓库中的start/apps/chapter01/cc-ng-on-changes

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve cc-ng-on-changes 
    

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

    图 1.10:运行在 http://localhost:4200 上的 cc-ng-on-changes 应用

如何做到这一点...

  1. 我们首先在VcLogsComponent中创建一个logs数组,如下所示,以存储我们稍后将在模板中使用的所有日志:

    export class VcLogsComponent implements OnInit {
      @Input() vName;
      **logs****:** **string****[] = [];**
    ...
    } 
    
  2. 让我们创建显示日志的 HTML。让我们在vc-logs.component.html中使用以下代码添加logs容器和日志项:

    <h5>Latest Version = {{vName}}</h5>
    **<****div****class****=****"logs"****>**
    **<****div****class****=****"logs__item"** *******ngFor****=****"let log of logs"****>**
    **{{log}}**
    **</****div****>**
    **</****div****>** 
    

    以下截图显示了带有logs容器的应用:

    图 1.11:带有日志容器的 cc-ng-on-changes 应用

  3. 现在,让我们在VcLogsComponent中实现ngOnChanges,如下所示,在vc-logs.component.ts文件中使用简单的更改:

    import { Component, Input, **OnChanges****,** **SimpleChanges** } from '@angular/core';
    ...
    export class VcLogsComponent **implements****OnChanges** {
      @Input() vName;
      logs: string[] = [];
      **ngOnChanges****(****changes: SimpleChanges****) {**
    **}**
    } 
    
  4. 现在我们可以为vName输入的初始版本添加一个日志,表示“初始版本是 x.x.x”。我们通过使用isFirstChange方法检查它是否是初始值来完成此操作,如下所示:

    ...
    export class VcLogsComponent implements OnChanges {
      ...
      ngOnChanges(changes: SimpleChanges) {
        **const** **{ currentValue } = changes[****'vName'****];**
    **if** **(changes[****'vName'****].****isFirstChange****()) {**
    **this****.****logs****.****push****(****`initial version is** **${currentValue.trim()}****`****);**
    **}**
      }
    } 
    
  5. 让我们处理在初始值分配之后更新版本的情况。为此,我们将添加另一个日志,表示“版本已更改为 x.x.x”,使用else条件,如下所示:

    ...
    export class VcLogsComponent implements OnInit, OnChanges {
      ...
      ngOnChanges(changes: SimpleChanges) {
        const { currentValue } = changes['vName'];
        if (changes['vName'].isFirstChange()) {
          this.logs.push(`initial version is ${currentValue.trim()}`);
        } **else** **{**
    **this****.****logs****.****push****(****`version changed to** **${currentValue.trim()}****`****)**
    **}**
      }
    } 
    

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

    图 1.12:使用ngOnChanges的最终输出

它是如何工作的…

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

图 1.13:ngOnChanges如何将新版本推送到日志数组

参见

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

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

准备工作

我们将要工作的应用程序位于克隆的仓库中的 start/apps/chapter01/cc-template-vars 目录内:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve cc-template-vars 
    

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

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

点击顶部的按钮以查看相应的控制台日志。这表明我们已经将点击处理程序绑定到了按钮上。

如何操作…

  1. 我们将在 app.component.html 文件中的 <app-gallery> 组件上创建一个名为 #gallery 的模板变量,如下所示:

    ...
    <div class="content" role="main">
      ...
      <app-gallery **#****gallery**></app-gallery>
    </div> 
    
  2. 修改 app.component.ts 中的 addNewPictureremoveFirstPicture 方法,使其接受一个名为 gallery 的参数。这样我们就可以在点击按钮时,从 app.component.html 将模板变量 #gallery 传递给它们。代码如下:

    import { Component } from '@angular/core';
    **import** **{** **GalleryComponent** **}** **from****'./components/gallery/gallery.component'**;
    ...
    export class AppComponent {
      ...
      addNewPicture(**gallery: GalleryComponent**) {
        console.log('added new picture'**, gallery**);
      }
      removeFirstPicture(**gallery: GalleryComponent**) {
        console.log('removed first picture'**, gallery**);
      }
    } 
    
  3. 现在,让我们将 #gallery 模板变量从 app.component.html 传递给两个按钮的点击处理程序,如下所示:

    ...
    <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.15:点击添加图片按钮时的控制台日志

  4. 现在我们可以实现添加新图片的代码。为此,我们将访问 GalleryComponent 类的 generateImage 方法,并将一个新项目作为第一个元素添加到 pictures 数组中。代码如下:

    ...
    export class AppComponent {
      ...
      addNewPicture(gallery: GalleryComponent) {
        **gallery.****pictures****.****unshift****(gallery.****generateImage****());**
      }
      ...
    } 
    
  5. 为了从数组中移除第一个项目,我们将在 GalleryComponent 类中的 pictures 数组上使用 JavaScript 数组类的 shift 方法。代码如下:

    ...
    export class AppComponent {
       ...
      removeFirstPicture(gallery: GalleryComponent) {
        **gallery.****pictures****.****shift****();**
      }
    } 
    

工作原理…

模板引用变量 通常是指向模板中 DOM 元素的引用。它也可以是指向 Angular 中的组件或指令(来源:angular.io/guide/template-reference-variables)。

在这个示例中,我们通过在 <app-gallery> 标签上创建一个引用(变量)来在 app.component.html 中引用我们的画廊组件。在这个例子中,这个标签是一个 Angular 组件。在模板中用变量引用它之后,我们将这个引用(模板变量)作为函数参数传递给组件中的函数。

我们随后通过传递的模板变量来访问 GalleryComponent 的属性和方法。您可以看到,我们能够直接从 AppComponent 中添加和移除位于 GalleryComponent 中的 pictures 数组中的项目——即,我们是从父组件(App)中访问 GalleryComponent 的属性和方法。

参见

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

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

准备工作

我们将要工作的项目位于克隆的仓库中的 chapter01/start_here/cc-view-child

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目:

    npm run serve cc-view-child to serve the project. 
    

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

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

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

如何实现…

  1. 我们将从将 GalleryComponent 导入到我们的 app.component.ts 文件开始,这样我们就可以为它创建一个 ViewChild

    import { Component**,** **ViewChild** } from '@angular/core';
    **import** **{** **GalleryComponent** **}** **from****'****./components/gallery/gallery.component'****;**
    ...
    export class AppComponent {
      **@ViewChild****(****GalleryComponent****) gallery!:** **GalleryComponent****;**
     **...**
    } 
    
  2. 为了处理添加新图片,我们将在 AppComponent 中的 addNewPicture 方法中使用 gallery ViewChild。我们将使用 GalleryComponentgenerateImage 方法将新图片添加到数组的顶部,如下所示:

    ...
    export class AppComponent {
      @ViewChild(GalleryComponent) gallery!: GalleryComponent;
      addNewPicture() {    console.log('added new picture');
        **this****.****gallery****.****pictures****.****unshift****(**
    **this****.****gallery****.****generateImage****()**
    **);**
      }
      ...
    } 
    
  3. 为了处理移除图片,我们将在 AppComponent 类中的 removeFirstPicture 方法中添加逻辑。我们将使用 Array.prototype.shift 方法在 pictures 数组上移除第一个元素,如下所示:

    ...
    export class AppComponent {
    ...
      removeFirstPicture() {
        **this****.****gallery****.****pictures****.****shift****();**
      }
    } 
    

它是如何工作的…

ViewChild() 是 Angular 提供的一个装饰器,用于访问在父组件模板中使用的子组件。它为 Angular 的变更检测器配置了一个 视图查询。变更检测器试图找到与查询匹配的第一个元素,并将其分配给与 ViewChild() 装饰器关联的属性。在我们的教程中,我们通过提供 GalleryComponent 作为查询参数创建了一个 ViewChild,即 ViewChild(GalleryComponent)。这允许 Angular 变更检测器在 app.component.html 模板中找到 <app-gallery> 元素,然后将其分配给 AppComponent 类中的 gallery 属性。定义 gallery 属性的类型为 GalleryComponent 非常重要,这样我们就可以在组件中轻松地使用它,并利用 TypeScript 的所有魔法。

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

参见

独立组件和通过路由参数传递数据

在本教程中,我们将学习如何使用 独立组件 并如何通过路由参数传递一些数据到其他组件。请注意,这不仅仅限于独立组件,也可以用常规组件实现。应用程序的起始代码为我们提供了一个用户列表视图。我们的任务是使用路由参数实现 details 视图。

准备工作

我们将要工作的项目位于克隆的仓库中的 start/apps/cc-standalone-components 目录下:

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令来运行项目:

    npm run serve cc-standalone-components 
    

    您应该能够看到应用程序如下所示:

    图 1.17:显示应用程序 cc-standalone-components 的用户列表

如何操作…

  1. 创建 UserDetails 组件/页面,稍后我们将在这里查看单个用户的详细信息。从项目根目录运行以下命令来创建它:

    cd start && nx g c user-details --standalone --directory apps/chapter01/cc-standalone-components/src/app/user-details 
    

    如果被问及,请选择 @nx/angular:componentschematics 并选择“按提供”操作。

  2. 我们现在将创建一个路由用于 UserDetailsComponent。按照以下方式更新 app.routes.ts 文件:

    ...
    export const appRoutes: Route[] = [
      {...},
      **{**
    **path****:** **':uuid'****,**
    **loadComponent****:** **() =>**
    **import****(****'****./user-details/user-details.component'****)**
    **.****then****(****(****m****) =>** **m.****UserDetailsComponent**
    **),**
    **},**
    ]; 
    
  3. 现在在 users.component.ts 文件中将 RouterModule 作为导入添加到 UsersComponent 中,如下所示:

    ...
    import { RouterModule } from '@angular/router';
    @Component({
      ...
      imports: [CommonModule, RouterModule],
      ...
    })
    export class UsersComponent{} 
    
  4. users.component.html 文件中为用户列表中的每个用户项添加 routerLink 以导航到用户详情页面,如下所示:

    <ul>
      @for (user of users; track user.uuid) {
        <li **routerLink****=****"/{{ user.uuid }}"****>**
          ...
        </li>
      }
    </ul> 
    
  5. user-details.component.ts 文件中 UserDetailsComponent 类的导入中添加 RouterModule

    ...
    import { RouterModule } from '@angular/router';
    @Component({
      ...
      imports: [CommonModule, RouterModule],
      ...
    })
    export class UserDetailsComponent {} 
    
  6. 进一步更新 UserDetailsComponent 以创建一个用于保持当前显示用户数据的 可观察对象

    import { Component, **inject** } from '@angular/core';
    import { CommonModule } from '@angular/common';
    import { **ActivatedRoute****,** RouterModule } from '@angular/router';
    **import** **{** **Observable** **}** **from****'rxjs'****;**
    **import** **{** **User** **}** **from****'../data'****;**
    @Component({...})
    export class UserDetailsComponent {
      **route =** **inject****(****ActivatedRoute****);**
    **user$!:** **Observable****<****User** **|** **undefined****>;**
    } 
    
  7. 现在创建一个 constructor 函数,从路由参数中获取 uuid,并获取和设置当前显示的用户数据,如下所示:

    ...
    import { filter, map, Observable } from 'rxjs';
    import { User, USERS } from '../data';
    @Component({...})
    export class UserDetailsComponent {
      ...
      constructor() {
        this.user$ = this.route.paramMap.pipe(
          filter((params) => !!params.get('uuid')),
          map((params) => {
            const uuid = params.get('uuid');
            return USERS.find((user) => user.uuid === uuid);
          })
        );
      }
    } 
    
  8. 最后,让我们更新 user-details.component.html 文件中 UserDetailsComponent 的模板,以如下方式显示用户:

    <ng-container *ngIf="user$ | async as user">
    <div class="flex gap-4 items-center">
    <a routerLink="/" class="hover:text-slate-500">
    <span class="material-symbols-outlined"> arrow_back
          </span>
    </a>
    <article routerLink="/{{ user.uuid }}">
    <img src="img/{{ user.picture.thumbnail }}" />
    <h4>{{ user.name.first }} {{ user.name.last }}</h4>
    </article>
    </div>
    </ng-container> 
    

    图 1.18:用户详细信息分页显示当前选定的用户信息

它是如何工作的…

应用程序的起始模板包含一个配置为在主页路由(/)上显示的 UsersComponent。我们首先使用 Nx CLI 命令 nx g c user-details --standalone --directory apps/chapter01/cc-standalone-components/src/app/user-details 创建 UserDetailsComponent。注意,这使用 --standalone 来让 Angular 知道我们需要一个独立组件。我们还使用 --directory apps/chapter01/cc-standalone-components/src/app/user-details;因为我们正在使用 Nx 仓库,我们需要指定我们创建组件的确切目录。

然后,我们在 app.routes.ts 文件中添加 UserDetailsComponent 的路由。注意,我们使用 ':uuid' 作为此路由的路径。这将导致一个示例路由 http://localhost:4200/abc123 显示组件,而 uuid 的值变为 abc123 作为路由参数。然后我们在 UserDetailsComponentUsersComponent 类的装饰器元数据中导入 RouterModule。如果你之前使用过 Angular,你可能认为这通常是在 NgModule 中导入的。嗯,你是对的。但是,由于这些是独立组件,它们需要有自己的导入处理,因为它们本身不是任何 NgModule 的一部分。

然后,我们在主页路由(在 UsersComponent 模板)上的每个用户项添加 routerLink 以导航到用户的详细信息页面,并将用户的 ID 作为 uuid 参数传递。接下来的步骤是从 ActivatedRoute 服务中检索 uuid 参数,并使用 uuid(用户的 ID)获取所需用户。你会注意到我们在 USERS 数组上执行 find 方法,通过 uuid 查找所需用户。

最后,我们修改 user-details.component.html 文件以更新模板,显示视图上所需用户。简单易行!

参见

使用信号进行组件通信

信号 是 Angular 生态系统中的一个强大补充。它们也比常规的 Angular 类属性更高效,因为当你改变信号值时,Angular 只会通知订阅了该信号的组件来运行变更检测。这可以提高你应用程序的性能,特别是如果有许多组件订阅了该信号。在这个菜谱中,我们将使用 Angular 信号来实现一些有趣的结果。我们将显示完成任务的数目与总任务数。当所有任务都完成时,我们还将显示一条消息。让我们开始吧!

准备工作

我们将要工作的应用程序位于克隆的仓库中的 start/apps/chapter01/ng-cc-signals 目录内。执行以下步骤开始:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令来运行项目:

    npm run serve ng-cc-signals 
    

    这应该会在浏览器中打开一个新标签页,你应该在 http://localhost:4200 看到以下内容:

    图 1.19:ng-cc-signals 应用在 http://localhost:4200 上运行

现在我们已经在本地运行了应用程序,让我们在下一节中逐步介绍菜谱。

如何操作

我们正在工作的应用程序是一个基本的 任务管理器 应用程序。然而,我们有以下两个有趣的要求:

  • 我们应该能够在 任务管理器 标题下 动态地 看到完成任务的计数与总任务数。这意味着任何对任务的更改都应该自动更新它。

  • 当所有任务都完成时,我们应该向用户显示一条消息以祝贺他们。

让我们开始吧:

  1. 首先,我们将为任务创建一个 type,并为过滤器创建一个 enum,因为 —— TypeScript 赢家。在 src/app 文件夹内创建一个新文件,命名为 task.model.ts,并将以下代码添加到其中:

    ...
    **export****type****Task** **= {**
    **completed****:** **boolean****;**
    **title****:** **string****;**
    **}**
    **export****enum****TasksFilter** **{**
    **All****,**
    **Active****,**
    **Completed**
    **}** 
    
  2. 现在,我们将在 app.component.ts 文件中为任务数组创建第一个信号。更新如下:

    import { CommonModule } from '@angular/common';
    import { Component, **signal** } from '@angular/core';
    import { RouterModule } from '@angular/router';
    **import** **{** **Task** **}** **from****'./task.model'****;**
    ...
    export class AppComponent {
      tasks = **signal<****Task****[]>(**[
        { title: 'Buy milk', completed: false },
        { title: 'Read a book', completed: true },
      ]**)**;
    } 
    

    由于属性更改为信号,app.component.html 应该会开始抱怨。

  3. 更新 app.component.html 文件以使用信号及其获取函数如下:

    <!-- Task List -->
    <ul>
          @for (task of **tasks()**; track $index) {
            <li class="mb-2 flex gap-4 items-center cursor
    -pointer hover:opacity-70">
    <input type="checkbox" [checked]=
     "task.completed" />
    <span [ngClass]="{'line-through': task.completed}" >{{ task.title }}</span>
    </li>
          }
        </ul> 
    
  4. 我们将实现切换任务为完成或未完成的功能。更新 app.component.ts 如下:

    ...
    export class AppComponent {
      tasks = signal<Task[]>([...]);
    
      **toggleTask****(****task: Task****) {**
    **const** **updatedTasks =** **this****.****tasks****().****map****(****taskItem** **=>**
    **taskItem.****title** **=== task.****title** **? {...taskItem,**
    **completed****: !taskItem.****completed****} : taskItem**
    **);**
    **this****.****tasks****.****set****(updatedTasks);**
    **}**
    } 
    
  5. 现在更新模板以将每个项的点击处理程序绑定到切换其 完成 状态。更新 app.component.html 如下:

    <!-- Task List -->
    <ul>
          @for (task of tasks(); track $index) {
            <li **(****click****)=****"toggleTask(task)"** class="mb-2 flex
    gap-4 items-center cursor-pointer
    hover:opacity-70">
    <input type="checkbox" [checked]=
     "task.completed" />
    <span [ngClass]="{'line-through':
    task.completed}">{{ task.title }}</span>
    </li>
          }
        </ul> 
    

    你应该能够通过点击任务项来标记任务为 完成 或未完成。

  6. 我们现在将创建一个 computed 属性来跟踪完成任务的数目。更新 app.component.ts 文件如下:

    ...
    import { Component, **computed,** signal } from '@angular/core';
    ...
    export class AppComponent {
      tasks = signal<Task[]>([...]);
    
      **finishedTasksCount =** **computed****(****() =>** **{**
    **return****this****.****tasks****().****filter****(****task** **=>**
    **task.****completed****).****length****;**
    **})**
    toggleTask(task: Task) {...}
    } 
    
  7. 更新模板以显示完成任务的计数。我们将更新 app.component.html 文件如下:

    <main class="content" role="main">
    <div class="mx-auto p-4">
    <div class="flex items-center justify-between mb-4">
    <h1 class="text-2xl">Task Manager</h1>
    <span>
            (**{{finishedTasksCount()}}** / **{{tasks().length}}**)
          </span>
    </div>
        ...
      </div>
    </main> 
    

    你应该能够看到如图所示的完成任务的计数和任务计数:

    图 1.20:显示完成任务的计数

  8. 我们现在将添加添加新任务的功能。按照以下方式更新 app.component.ts 文件:

    ...
    export class AppComponent {
      ...
      finishedTasksCount = computed(() => {...})
    
      **addTask****(****titleInput: HTMLInputElement****) {**
    **if** **(titleInput.****value****) {**
    **const** **newTask = {** 
    **title****: titleInput.****value****,** 
    **completed****:** **false**
    **};**
    **this****.****tasks****.****set****([...****this****.****tasks****(), newTask]);**
    **}**
    **titleInput.****value** **=** **''****;**
    **}**
    toggleTask(task: Task) {...}
    } 
    
  9. 现在,更新 app.component.html 以将 addTask 方法绑定到输入和 Add 按钮上,如下所示:

    <!-- Task Input -->
    <div class="mb-4">
    <input **#****titleInput** **(****keydown.enter****)=**
    **"addTask(titleInput)"** class="p-2 border rounded
    mr-2" placeholder="New task..." />
    <button **(****click****)=****"addTask(titleInput)"**>Add</button>
    </div> 
    

    你应该能够创建如图所示的新任务:

    图片

    ![图 1.21:在应用程序中创建新任务]

  10. 我们现在将添加通过项目完成状态过滤项的可能性。让我们在 app.component.ts 文件中添加一个新的 signal 和一个 computed 属性,如下所示:

    ...
    import { Task**,** **TasksFilter** } from './task.model';
    
    ...
    export class AppComponent {
      tasks = signal<Task[]>([...]);
      **filter =** **signal****(****TasksFilter****.****All****);**
    **filters =** **TasksFilter****;**
    **filteredTasks =** **computed****(****() =>** **{**
    **switch****(****this****.****filter****()) {**
    **case****TasksFilter****.****All****:**
    **return****this****.****tasks****();**
    **case****TasksFilter****.****Active****:**
    **return****this****.****tasks****().****filter****(****taskItem** **=>** **{**
    **return** **!taskItem.****completed****;**
    **});**
    **case****TasksFilter****.****Completed****:**
    **return****this****.****tasks****().****filter****(****taskItem** **=>** **{**
    **return** **taskItem.****completed****;**
    **});**
    **}**
    **})**
    **changeFilter****(****filter: TasksFilter****) {**
    **this****.****filter****.****set****(filter);**
    **}**
      ...
    } 
    
  11. 现在我们可以使用 changeFilter 方法和在模板中的 filteredTasks 计算信号来过滤任务。按照以下方式更新 app.component.html 文件:

    <!-- Task List -->
    <ul>
          @for (task of **filteredTasks()**; track $index) {
            <li (click)="toggleTask(task)" class="mb-2 flex gap-
    4 items-center cursor-pointer hover:opacity-70">
    <input type="checkbox" [checked]=
     "task.completed" />
    <span [ngClass]="{'line-through':
    task.completed}">{{ task.title }}</span>
    </li>
          }
        </ul>
    <!-- Filters -->
    <div class="mt-4">
    <button **(****click****)=****"changeFilter(filters.All)"**
    **[****ngClass****]=****"{'!bg-purple-500 text-white': filter()**
    **=== filters.All}"** class="p-2 rounded mr-2">
            All</button>
    <button **(****click****)=****"changeFilter(filters.Active)"**
    **[****ngClass****]=****"{'!bg-purple-500 text-white': filter()**
    **=== filters.Active}"** class="p-2 rounded mr-2">
            Active</button>
    <button **(****click****)=****"changeFilter(filters.Completed)"**
    **[****ngClass****]=****"{'!bg-purple-500 text-white': filter()**
    **=== filters.Completed}"** class="p-2 rounded">
            Completed</button>
    </div> 
    

    如果你现在查看应用程序,你可以通过所有活动完成来过滤任务,如图所示:

    图片

    图 1.22:通过 Active 应用中的过滤任务

  12. 最后,我们将实现 snack bar。我们希望在用户完成所有活动任务时显示它。让我们首先更新 app.component.ts 文件以导入 SnackbarComponent 并创建一个 effect,如下所示:

    ...
    import { Component, computed, signal, effect, ViewChild } from '@angular/core';
    ...
    import { SnackbarComponent } from './components/snackbar/snackbar.component';
    
    @Component({
      ...,
      imports: [CommonModule, RouterModule, SnackbarComponent],
    })
    export class AppComponent {
      @ViewChild(SnackbarComponent) snackbar!: SnackbarComponent;
      ...
      completedEffectRef = effect(() => {
        const tasks = this.tasks();
        if (this.finishedTasksCount() === tasks.length && tasks.length > 0) {
          this.snackbar.show();
        }
      })
    
      ...
    } 
    
  13. 现在,我们可以更新模板以在 UI 中添加 snackbar 组件。让我们按照以下方式更新 app.component.html 文件:

    <main>
      ...
    </main>
    **<****app-snackbar****>**
    **Congratulations! You completed all tasks.**
    **</****app-snackbar****>** 
    

    如果你现在将所有任务标记为完成,你应该会看到如图所示的 snackbar:

    图片

    ![图 1.23:使用效果显示所有任务完成时的 snackbar]

哇!仅仅通过使用信号,我们就可以在 Angular 中创建一个完全功能(小型)的任务管理器应用程序。现在你知道如何在 Angular 中使用信号了,请看下一节了解菜谱是如何工作的。

工作原理

Angular 核心团队发布了一个关于信号的请求评论RFC),我非常兴奋。我已经说过这个了吗?我想是的!但是它太棒了,我不得不再次提到。信号最大的好处是它们与 Angular 的变更检测协同工作的方式。而不是 Angular 的变更检测在应用程序中寻找变化,信号可以在发生变化时通知 Angular 变更检测。

在这个菜谱中,我们首先创建了一个任务项的 type 和一个过滤器的 enum。然后我们使用 @angular/core 包中的 signal 函数创建了一个 WritableSignal,类型为 Task。这是因为 signal 函数返回一个 WritableSignal

在撰写本书时,signal 函数是从 @angular/core 包导出的,信号仍然处于开发者预览阶段。这可能会随着未来的版本而改变。

在 Angular 中使用信号时,获取或渲染信号值的方式是使用其 getter 函数,这实际上就是将信号作为一个函数来调用。在菜谱中,你可以看到我们提到了 this.tasks() 几次。我们正在获取任务信号的值,它是一个类型为 Task 的数组。

注意,我们也在模板(app.component.html)中以相同的方式使用这个信号来通过{{tasks().length}}渲染总任务数。这就是如何获取信号值的方法。然而,要设置信号,我们需要在信号本身上使用set方法。让我们观察filter信号和changeFilter方法如下:

filter = signal(TasksFilter.All); 
...
changeFilter(filter: TasksFilter) {
  this.filter.set(filter);
} 

注意,为了更新过滤信号值,我们使用语句this.filter.set(filter)。我们本可以避免创建changeFilter方法,并在app.component.html(模板)中使用类似filter.set(filters.All)等的语句,但我发现这种方法在 TypeScript 和 HTML 文件中都要干净得多,也更容易阅读。

除了信号之外,我们还使用了计算属性。这些属性依赖于一个或多个信号,如果这些信号中的任何一个发生变化,它们将自动更新。这很强大,可以在不写很多代码的情况下为应用程序提供响应性。如果你将鼠标悬停在app.component.ts中的finishedTasksCount(计算)属性上,你会注意到它显示:

(property) AppComponent.finishedTasksCount: Signal<number> 

这意味着计算属性不是可写的信号,因为它们是自动计算的,我们不会手动更改它们。

最后,我们实现了一个效果。一个效果是一段代码——技术上是一个函数,如果函数代码块中使用的任何信号发生变化,它将自动触发。这可以用于诸如发起 API 调用、保存到本地存储、报告分析事件和记录日志等操作。如果你的代码中存在需要根据信号触发的副作用,你可以使用效果来实现。

现在你已经了解了食谱的工作原理,请查看下一节以获取更多阅读内容。

参见

在 Discord 上了解更多

要加入这本书的 Discord 社区——在那里你可以分享反馈、向作者提问,并了解新版本发布——请扫描下面的二维码:

packt.link/AngularCookbook2e

二维码

第二章:使用 Angular 指令和内置控制流一起工作

在本章中,你将深入了解 Angular 指令,并通过使用一个在搜索时突出显示文本的指令的实际示例来学习。你还将编写你的第一个结构化指令,并了解ViewContainerTemplateRef服务如何协同工作以从文档对象模型DOM)中添加/删除元素,就像在*ngIf的情况下一样。你还将创建一些非常酷的属性指令,它们执行不同的任务。最后,你将学习如何使用指令组合 API将多个指令应用于同一元素。

在本章中,我们将要涵盖以下食谱:

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

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

  • 创建一个允许你垂直滚动到元素的指令

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

  • 如何将多个结构化指令应用于同一元素

  • 使用指令组合 API 将多个指令应用于同一元素

技术要求

对于本章中的食谱,请确保您的设置已按照“Angular-Cookbook-2E”GitHub 仓库中的“技术要求”完成。有关设置详细信息,请访问:github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/docs/technical-requirements.md。本章的起始代码位于github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/start/apps/chapter02

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

在这个食谱中,你将使用一个名为highlight的 Angular 属性指令。使用这个指令,你将能够在段落中搜索单词和短语,并在进行搜索时突出显示它们。当进行搜索时,整个段落的容器背景也会改变。例如,使用以下代码:

<p class="text-content max-w-2xl m-auto" appHighlight
  [highlightText]="'de'">
  <!--text here --> 

结果将如图图 2.1所示:

图片

图 2.1:使用高亮指令的结果

准备工作

我们将要工作的应用位于克隆的仓库中的start/apps/chapter02/ng-attribute-directive目录下:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-attribute-directive 
    

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

    图片

    图 2.2:运行在 http://localhost:4200 上的 ng-attribute-directive 应用

如何做到这一点...

应用程序有一个搜索输入框和一段文本。我们希望能够在输入框中输入搜索查询,以便我们可以在段落中突出显示并找到所有匹配的实例。以下是实现此目的的步骤:

  1. 我们将在app.component.ts文件中创建一个名为searchText的属性,我们将将其用作搜索文本输入的model

    ...
    export class AppComponent {
      searchText = '';
    } 
    
  2. 然后,我们在模板中使用searchText属性,即在app.component.html文件中,将搜索输入作为ngModel,如下所示:

    ...
    <div class="content" role="main">
      ...
         <input [(ngModel)]="searchText" type="text"
    placeholder="Quick Search..." class="pr-4 !pl-10
    py-2">
    </div> 
    
  3. 你会注意到ngModel还没有工作。这是因为我们在应用程序中缺少FormsModule。让我们将其导入到app.component.ts文件中,如下所示:

    ...
    import { FormsModule } from '@angular/forms';
     @Component({
      selector: 'app-root',
      templateUrl: './app.component.html',
      standalone: true,
      styleUrls: ['./app.component.scss'],
      imports: [CommonModule, RouterModule, FormsModule],
    })
    export class AppComponent {
      searchText = '';
    } 
    
  4. 现在,我们将通过使用工作区根目录下的以下命令创建一个名为highlight的属性指令:

    cd start && nx g directive highlight --directory apps/chapter02/ng-attribute-directive/src/app --standalone 
    

    如果被要求,请选择@nx/angular:directive schematics,并选择“按提供”操作。前面的命令生成一个具有appHighlight选择器的独立指令。请参阅它是如何工作的…部分了解为什么会发生这种情况,以及独立 API 的简要说明。

  5. 现在我们已经有了指令,我们将为指令创建两个输入,从AppComponent(从app.component.html)传递——一个用于搜索文本,另一个用于高亮颜色。在highlight.directive.ts文件中的代码应该如下所示:

    import { Directive**,** **Input** } from '@angular/core';
    @Directive({
      selector: '[appHighlight]',
      standalone: true
    })
    export class HighlightDirective {
      **@****Input****() highlightText =** **''****;**
    **@****Input****() highlightColor =** **'yellow'****;**
    } 
    
  6. 让我们在app.component.html中使用appHighlight指令,并将searchText模型从那里传递到appHighlight指令,如下所示:

    <div class="content" role="main">
      ...
      <p class="text-content" **appHighlight**
    **[****highlightText****]=****"searchText"**>
        ...
      </p>
    </div> 
    
  7. 现在,我们将使用ngOnChanges监听searchText输入的变化。请参阅第一章赢得组件通信中的使用 ngOnChanges 拦截输入属性更改配方,了解如何监听输入变化。现在,我们将在输入变化时仅进行console.log。让我们更新highlight.directive.ts如下:

    import { Directive, Input, OnChanges, SimpleChanges } from '@angular/core';
    ...
    export class HighlightDirective implements OnChanges {
      @Input() highlightText = '';
      @Input() highlightColor = 'yellow';
      ngOnChanges(changes: SimpleChanges) {
        if (changes['highlightText']?.firstChange) {
          return;
        }
        const { currentValue } = changes['highlightText'];
        console.log({ currentValue });
      }
    } 
    

    如果你输入搜索输入并查看控制台日志,你会在每次更改值时看到新的值被记录。

  8. 现在,我们将编写高亮显示搜索文本的逻辑。我们首先导入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**) { }
      ...
    } 
    
  9. 现在,我们将用自定义的<span>标签替换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>`****)**
    **}**
    } 
    

    TIP

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

  10. 为了保持原始文本不变,让我们创建一个名为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>`)
        }
      }
    } 
    
  11. 现在,我们将编写一些逻辑,在我们移除搜索查询(当搜索文本为空时)时将一切重置回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****;**
    **}**
      }
    } 
    

它是如何工作的…

我们创建了一个名为highlightappHighlight)的属性指令,它接受两个输入:highlightTexthighlightColor。该指令通过 Angular 的ngOnChanges生命周期钩子监听highlightText输入的变化。SimpleChanges对象中的每个属性都是一个包含以下属性的SimpleChange对象:

  • previousValue: 任何类型

  • currentValue: 任何类型

  • firstChange: 布尔值

  • isFirstChange(): 布尔值

首先,我们确保通过使用ElementRef服务获取附加元素来保存目标元素的原始内容。我们使用应用于元素的.nativeElement.innerHTML属性来获取它。我们将初始值保存到指令的originalHTML属性中。

每当输入发生变化时,我们通过将段落中搜索词的所有实例替换为额外的 HTML 元素(一个<span>元素)来分配originalHTML的替换版本。我们还为此<span>元素添加背景颜色。应用的背景颜色来自highlightColor输入。您可以修改它以使用不同的颜色突出显示。尝试一下,使这个例子成为您自己的。

参见

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

在这个菜谱中,您将创建一个属性指令来计算文章的阅读时间,就像 Medium (medium.com),这是一个分享文章和博客文章的平台。这个菜谱的代码高度受我 GitHub 上现有仓库的启发,您可以在以下链接中查看:github.com/AhsanAyaz/ngx-read-time

准备工作

我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter02/ng-read-time-directive

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-read-time-directive 
    

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

    图片

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

如何做到这一点…

目前,我们在app.component.html文件中有一个段落,我们需要计算其read-time(分钟数)。让我们开始吧:

  1. 首先,我们将创建一个名为read-time的属性指令。为此,从项目根目录运行以下命令,并在被询问时选择@nx/angular:directive schematics

    cd start && nx g directive read-time --directory apps/chapter02/ng-read-time-directive/src/app/directives --standalone=false 
    

    如果被询问,选择@nx/angular:directive schematics并选择“按提供”操作。

    注意,我们在命令中使用 --standalone = false。这是因为我们有一个基于 NgModule 的应用程序,而 AppComponent 不是一个独立组件。

  2. 上述命令创建了一个具有类名 ReadTimeDirective 的指令,并且选择器为 appReadTime。我们将在 app.component.html 文件中应用此指令到具有 id 设置为 mainContentdiv 元素,如下所示:

    ...
    <div class="content" role="main" id="mainContent"
    **appReadTime**>
    ...
    </div> 
    
  3. 现在,我们将为我们的 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() { }
    } 
    
  4. 现在,我们可以继续获取文本以计算阅读时间。为此,我们将使用 ElementRef 服务来检索元素的 textContent 属性。我们将提取 textContent 属性并将其分配给一个名为 text 的局部变量,在 ngOnInit 生命周期钩子中,如下所示:

    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****;**
    **}**
    } 
    
  5. 现在我们已经将文本变量填充了元素的整个文本内容,我们可以计算阅读此文本所需的时间。为此,我们将创建一个名为 calculateReadTime 的方法,并将 text 属性传递给它,如下所示:

    ...
    export class ReadTimeDirective implements OnInit {
      ...
      ngOnInit() {
        const text = this.el.nativeElement.textContent;
        **const** **time =** **this****.****calculateReadTime****(text);**
    **console****.****log****({** **readTime****: time });**
      }
      **calculateReadTime****(****text: string****) {**
    **const** **wordsCount = text.****split****(****/\s+/g****).****length****;**
    **const** **minutes = wordsCount /** **this****.****configuration****.**
    **wordsPerMinute;**
    **return****Math****.****ceil****(minutes);**
    **}**
    } 
    

    如果你现在查看控制台,你应该会看到一个包含 readTime 属性的对象被记录下来。readTime 的值是分钟数:

    图 2.4:控制台日志显示分钟数

  6. 我们现在得到了分钟数,但当前它不是一个用户可读的格式,因为它只是一个数字。我们需要以一种对最终用户可理解的方式显示它。为此,我们将进行一些小的计算,并创建一个适当的字符串来在 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****({** **readTime****: timeStr });**
      }
    ...
      **createTimeString****(****timeInMinutes: number****) {**
    **if** **(timeInMinutes <** **1****) {**
    **return****'< 1 minute'****;**
    **}** **else****if** **(timeInMinutes ===** **1****) {**
    **return****'1 minute'****;**
    **}** **else** **{**
    **return****`****${timeInMinutes}** **minutes`****;**
    **}**
    **}**
    } 
    

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

  7. 现在,让我们在 read-time.directive.ts 文件中添加一个 @Output(),以便我们可以在父组件中获取阅读时间并在 UI 上显示它。让我们添加如下所示:

    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) { }
    ...
    } 
    
  8. 让我们使用 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);**
      }
    ...
    } 
    
  9. 由于我们使用 readTimeCalculated 输出发出 read-time 值,我们必须在 app.component.html 文件中监听此输出事件的触发,并将其分配给 AppComponent 类的一个属性,以便我们可以在视图中显示它。但在那之前,我们将在 app.component.ts 文件中创建一个局部属性来存储输出事件的值,并且我们还将创建一个在输出事件被触发时调用的方法。代码如下所示:

    ...
    export class AppComponent {
      **readTime****!: string;**
    **onReadTimeCalculated****(****readTimeStr: string****) {**
    **this****.****readTime** **= readTimeStr;**
    **}**
    } 
    
  10. 现在,我们可以在 app.component.html 文件中监听输出事件,然后当 readTimeCalculated 输出事件被触发时,我们可以调用 onReadTimeCalculated 方法:

    ...
    <div class="content" role="main" id="mainContent" appReadTime
     **(****readTimeCalculated****)=** **"onReadTimeCalculated($event)"****>**
    ...
    </div> 
    
  11. 现在,我们终于可以在app.component.html文件中显示阅读时间,如下所示:

    <div class="content" role="main" id="mainContent" appReadTime
      (readTimeCalculated)="onReadTimeCalculated($event)">
    **<****h4****class****=****"text-3xl"****>****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> 
    

    如果你现在访问http://localhost:4200,你应该能在应用中看到阅读时间,如下面的图片所示:

    图片

    图 2.5:在应用中显示的阅读时间

它是如何工作的…

appReadTime指令是这个菜谱的核心。在创建指令时,我们将其创建为一个非独立指令,因为应用本身是使用 NgModule 而不是独立的AppComponent启动的。我们在指令内部使用ElementRef服务来获取指令附加到的原生元素,然后提取其文本内容。剩下的唯一事情就是进行计算。我们首先使用/\s+/g 正则表达式regex)将整个文本内容拆分成单词,从而计算文本内容中的总单词数。然后,我们将单词数除以配置中的wordsPerMinute值来计算阅读整个文本需要多少分钟。最后,我们使用createTimeString方法以更好的方式使其可读。简单易行,轻松愉快

参见

创建一个允许你垂直滚动到元素的指令

你能想象一下能够瞬间跳到你能看到的地方吗?那将太棒了!不是吗?但如果我们想让我们的应用能够做到这一点呢?在这个菜谱中,你将创建一个用户可以点击以跳转到 Angular 应用中特定会话的指令。

准备工作

我们将要工作的应用位于克隆的仓库中的start/apps/chapter02/ng-scroll-to-directive

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-scroll-to-directive 
    

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

    图片

    图 2.6:ng-scroll-to-directive 应用在 http://localhost:4200 上运行

如何实现…

  1. 首先,我们将创建一个scroll-to指令,这样我们就可以通过平滑滚动到不同的部分来增强我们的应用。我们将在工作区根目录中使用以下命令来完成此操作:

    cd start && nx g directive scroll-to --directory apps/chapter02/ng-scroll-to-directive/src/app/directives 
    

    如果被询问,请选择@nx/angular:component schematics并选择“按提供”操作。

  2. 现在,我们需要使指令能够接受一个@Input(),它将包含我们目标部分的CSS 查询选择器,在元素的click事件发生时,我们将滚动到该部分。让我们将输入添加到我们的scroll-to.directive.ts文件中:

    import { Directive, Input } from '@angular/core';
    @Directive({
      selector: '[appScrollTo]'
    })
    export class ScrollToDirective {
      @Input() target = '';
    } 
    
  3. 现在,我们将把appScrollTo指令应用到app.component.html文件中的链接以及相应的目标上。我们将用target属性替换href属性。代码应该看起来像这样:

    ...
    <main class="content" role="main">
    <div class="page-links">
    <h4 class="page-links__heading">
          Links
        </h4>
    <a class="page-links__link" **appScrollTotarget****=**
    **"#resources"**>Resources</a>
    <a class="page-links__link" **appScrollTotarget****=**
    **"#nextSteps"****>**Next Steps</a>
    <a class="page-links__link" **appScrollTotarget****=**
    **"#moreContent"**>More Content</a>
    <a class="page-links__link" **appScrollTotarget****=**
    **"#furtherContent"**>Further Content</a>
    <a class="page-links__link" **appScrollTotarget****=**
    **"#moreToRead"**>More To Read</a>
    </div>
    </main>
      ...
    <a **appScrollTo****target****=****"#toolbar"** class="to-top-button w-12
    h-12 text-white flex items-center justify-center">
    <span class="material-symbols-outlined text-3xl text-
    white"> expand_less </span>
    </a> 
    
  4. 现在,我们将实现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****);**
    **}**
      ...
    } 
    
  5. 我们现在将实现滚动到特定目标的逻辑。我们将使用document.querySelector方法,使用target变量的值来获取元素,然后使用Element.scrollIntoView Web API 来滚动到目标元素。通过这个更改,你应该在点击相应的链接时看到页面滚动到目标元素:

    ...
    export class ScrollToDirective {
      @Input() target = '';
      @HostListener('click')
      onClick() {
        **const** **targetElement =**
    **document****.****querySelector****(****this****.****target****);**
    **if** **(!targetElement) {**
    **throw****new****Error****(****'`target' is required.`****);**
    **}**
    **targetElement.****scrollIntoView****();**
      }
      ...
    } 
    
  6. 好的——我们让滚动功能正常工作了。“但是,Ahsan,有什么新的吗?这难道不就是我们在之前使用 href 实现时已经做过的吗?” 你是对的。但是,我们将使滚动更加平滑。我们将scrollIntoViewOptions作为参数传递给scrollIntoView方法,并使用{behavior: "smooth"}值来在滚动时使用动画。代码应该看起来像这样:

    ...
    export class ScrollToDirective {
      @Input() target = '';
      @HostListener('click')
      onClick() {
        const targetElement = document.querySelector
          (this.target);
        targetElement.scrollIntoView(**{****behavior****:** **'smooth'****});**
      }
    } 
    

它是如何工作的...

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

参见

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

在这个菜谱中,你将编写你的第一个自定义结构指令,命名为showFor(或者带有前缀的*appShowFor)。结构指令是一种可以添加或从 DOM 中删除元素的指令。因此,使用这个指令,如果提供的布尔值为真,我们将把特定的元素添加到 DOM 中,并在指定的时间(以毫秒表示的数字)后将其删除。

准备工作

我们将要工作的应用程序位于克隆仓库的start/apps/chapter02/ng-show-for-directive目录下:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-show-for-directive 
    

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

    图 2.7:在 http://localhost:4200 上运行的 ng-show-for-directive 应用

如何操作...

  1. 首先,我们将在工作区根目录中使用以下命令创建一个指令:

    cd start && nx g directive show-for --directory apps/chapter02/ng-show-for-directive/src/app/directives --standalone=false 
    

    如果被问及,请选择 @nx/angular:component schematics 并选择“按提供”操作。

  2. 现在,我们不再需要在具有类名 "dialog" 的元素上的 app.component.html 文件中使用 *ngIf 指令,我们可以使用我们的 *appShowFor 指令:

    ...
    <main class="content" role="main">
    <button (click)="toggleDialog()">Toggle Dialog</button>
    <div class="dialog" *******appShowFor****=****"showDialog"**>
    <div class="dialog__heading">...</div>
    <div class="dialog__body">...</div>
    </div>
    </main> 
    
  3. 现在我们已经设置了条件,我们需要在指令的 TypeScript 文件内创建两个 @Input 属性,一个是 boolean 属性,另一个是 number。我们将使用一个 setter 来拦截布尔值的更改,并暂时将值记录到控制台:

    import { Directive**,** **Input** } from '@angular/core';
    @Directive({
      selector: '[appShowFor]',
    })
    export class ShowForDirective {
      **@****Input****() duration =** **1500****;**
    **@****Input****()** **set****appShowFor****(****value: boolean****) {**
    **console****.****log****({** **showForValue****: value });**
    **}**
    } 
    
  4. 如果你现在点击 切换对话框 按钮,你应该会看到值的变化并在控制台上反映出来,如下所示:

图 2.8:显示 appShowFor 指令值变化的控制台日志

  1. 现在,我们正在向实际实现显示和隐藏内容的方向迈进,根据值分别为 falsetrue。为此,我们首先需要在 if-not.directive.ts 文件的构造函数中注入 TemplateRef 服务和 ViewContainerRef 服务。让我们添加这些,如下所示:

    import { Directive, Input**,** **TemplateRef****,** **ViewContainerRef** } from '@angular/core';
    @Directive({
      selector: '[appShowFor]'
    })
    export class ShowForDirective{
      @Input() duration = 1500;
      @Input() set appShowFor(value: boolean) {
         console.log({ showForValue: value });
       }
       **constructor****(**
    **private templateRef: TemplateRef<any>,**
    **private viewContainerRef: ViewContainerRef**
    **) {}**
    } 
    
  2. 现在,让我们显示这个元素。我们将创建一个 show 方法,并在 appShowFor 属性的值变为 true 时调用它。代码应该如下所示:

    ...
    export class ShowForDirective {
      @Input() duration = 1500;
      @Input() set appShowFor(value: boolean) {
        console.log({ showForValue: value });
        **if** **(value) {**
    **this****.****show****();**
    **}**
      }
      **show****() {**
    **this****.****viewContainerRef****.****createEmbeddedView****(**
    **this****.****templateRef**
    **);**
    **}**
    constructor(...) {}
    } 
    

    如果你现在点击 切换对话框 按钮,你应该能够看到如下所示的对话框:

    图 2.9:使用 show 方法显示对话框

  3. 让我们实现隐藏对话框的逻辑。我们将使用一个带有 EventEmitter@Output() 属性来完成这个任务,因为我们希望更新由父组件传递的 appShowFor 的值,而不是在指令内部更新它。如下修改代码:

    import { ... , **EventEmitter**} from '@angular/core';
    ...
    export class ShowForDirective {
      @Input() duration = 1500;
      @Input() set appShowFor(value: boolean) {
        ...
      }
      **@****Output****() elementHidden =** **new****EventEmitter****();**
    show() {...}
      **hide****() {**
    **this****.****viewContainerRef****.****clear****();**
    **}**
    constructor(...) {}
    } 
    
  4. 现在我们已经有了 hide 方法,让我们在指令的 duration 属性中保存的持续时间之后调用它。这样对话框就会在那时隐藏。如下修改 show 方法的代码:

    show() {
      this.viewContainerRef.createEmbeddedView(
       this.templateRef
      );
      **setTimeout****(****() =>** **{**
    **this****.****elementHidden****.****emit****();**
    **},** **this****.****duration****);**
     } 
    

    通过这个更改,你会发现当对话框显示后点击 切换对话框 按钮时没有任何反应,也就是说它永远不会被隐藏。为此,我们需要监听我们刚刚创建的 elementHidden 事件发射器。

  5. 让我们让 app.component.html 监听 elementHidden 事件监听器,如下更改 showDialog 属性的值:

    <div class="dialog" *appShowFor="showDialog"
     **(****elementHidden****)=****"toggleDialog()"**>
    <div class="dialog__heading">
                I am a Dialog
        </div>
    <div class="dialog__body">
          And this is some random content
        </div>
    </div> 
    

    通过这个更改,你会发现它仍然不起作用。是的!因为我们需要在将 showDialog 作为 appShowFor 属性传递的值设置为 false 时调用 hide 方法。

  6. appShowFor的值变为false时,在ShowForDirective(在appShowFor属性的set方法)中调用hide方法,如下所示:

    @Input() set appShowFor(value: boolean) {
        console.log({ showForValue: value });
        if (value) {
          this.show();
        } **else** **{**
    **this****.****hide****();**
    **}**
      } 
    

    问题是……这仍然不会工作,因为 Angular 中的结构化指令不能发出值。即使它能,父元素也无法监听到它。以下 Stack Overflow 问题讨论了原因,并链接到 Angular 仓库中的一个开源 GitHub 问题:stackoverflow.com/q/44235638

  7. 为了让我们的结构化指令正常工作,我们需要摆脱它附带的所有语法糖。让我们修改app.component.html,以不同的(扩展的)方式使用该指令,如下所示:

    <main class="content" role="main">
    <button (click)="toggleDialog()">Toggle Dialog</button>
    **<****ng-template** **[****appShowFor****]=****"showDialog"**
    **(****elementHidden****)=****"toggleDialog()"****>**
    <div class="dialog">
    <div class="dialog__heading">
            I am a Dialog
          </div>
    <div class="dialog__body">
            And this is some random content
          </div>
    </div>
    **</****ng-template****>**
    </main> 
    

    对话框现在应该隐藏了。太好了!但是等等。快速连续点击切换对话框按钮多次。你会看到应用程序变得疯狂。这是因为我们最终注册了太多的setTimeout函数。

  8. 如果我们手动隐藏对话框,让我们清除setTimeout。按照以下方式更新ShowForDirective类的代码:

    ...
    export class ShowForDirective {
      ...
      **timer!:** **ReturnType****<****typeof****setTimeout****>;**
    show() {
        this.viewContainerRef.createEmbeddedView(
          this.templateRef
        );
        **this****.****timer** **=** setTimeout(() => {
          this.elementHidden.emit();
        }, this.duration);
      }
      hide() {
        **clearTimeout****(****this****.****timer****);**
    this.viewContainerRef.clear();
      }
      constructor(...) {}
    } 
    

太棒了!你会注意到,即使你快速连续点击切换对话框按钮多次,应用程序的行为也是正确的。

它是如何工作的……

Angular 中的结构化指令有多个特殊之处。首先,它们允许你操作 DOM 元素——也就是说,不仅仅是显示和隐藏,还可以根据你的需求完全添加和删除 DOM 中的元素。此外,它们有*前缀,这绑定到 Angular 在幕后所做的所有魔法。例如,Angular 自动提供TemplateRefViewContainer以供此指令使用。例如,*ngIf*ngFor都是结构化指令,它们在幕后与包含你绑定的指令内容的<ng-template>指令一起工作。然后,它们在ng-template的作用域内为你创建所需的变量/属性。在这个菜谱中,我们做的是同样的。我们使用TemplateRef服务来访问 Angular 为我们幕后创建的<ng-template>指令,其中包含我们的appShowFor指令应用到的宿主元素。我们使用ViewContainerRef服务通过createEmbeddedView方法将TemplateRef添加到 DOM 中。

appShowFor 属性的值变为 true 时,我们执行此操作。请注意,我们正在使用 setter 拦截 appShowFor 属性。我们在 第一章组件通信的胜利 中学习了这一点。然后我们使用 setTimeout 自动通知父组件,传递给 appShowFor 属性的值需要更改为 false。我们使用名为 elementHidden@Output() 发射器来完成此操作。请注意,我们不应该在指令内部将其设置为 false。父组件应该这样做,并且它将自动反映在指令中。我们的指令应该对此变化做出反应,并从 ViewContainer 中隐藏(或删除)TemplateRef。你可以在 hide 方法中看到我们使用 this.viewContainerRef.clear(); 语句来完成此操作。从这个食谱中可以学到的一个重要事情是,如果我们使用语法糖,即 *appShowFor,在 app.component.html 中,我们无法监听 elementHidden 事件发射器。这是因为这是 Angular 的一个怪癖——GitHub 上有一个关于此的开放问题(请参阅 参考以下内容 部分)。为了使其工作,我们删除了语法糖,并在 步骤 11 中使用 <ng-template> 来包装对话框的 HTML,从而扩展了语法。请注意,我们只是使用 [appShowFor] 来传递 showDialog 变量,而不是 *appShowFor="showDialog"。我们还在 <ng-template> 元素本身上监听 elementHidden 事件。

参考以下内容

如何将多个结构性指令应用于同一元素

在某些情况下,你可能想在同一个宿主或相同元素上使用多个结构性指令——例如,*ngIf*ngFor 的组合——但这不是 Angular 默认支持的功能。原因是很难确定哪个指令比另一个指令有优先级,即使有系统,我认为应用程序会变得过于复杂且难以管理。在本食谱中,我们将展示当桶中没有项目时,使用 *ngIf 条件性地显示消息。由于我们打算条件性地显示它并应用 for 循环到元素上,这是一个使用本食谱的完美例子。

准备工作

我们将要工作的应用程序位于克隆的仓库中的 start/apps/chapter02/ng-multi-struc-directives 目录内:

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-multi-struc-directives 
    

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

    图片

    图 2.10:ng-multi-struc-directives 应用在 http://localhost:4200 上运行

现在我们已经启动了应用程序,让我们在下一节中查看这个菜谱的步骤。

如何做…

  1. 我们将首先创建一个模板,用于显示当桶中没有项目时显示的消息。我们将为此修改 app.component.html 文件,如下所示:

    <div class="fruits">
        ...
        **<****ng-template** **#****bucketEmptyMessage****>**
    **<****div****class****=****"fruits__no-items-msg"****>**
    **No items in bucket. Add some fruits!**
    **</****div****>**
    **</****ng-template****>**
    </div> 
    
  2. 现在我们将尝试将 *ngIf 条件应用于渲染水果的元素。让我们在同一文件中修改代码,如下所示:

    ...
    <div class="fruits">
    <div
     class="fruits__item"
          *ngFor="let item of bucket"
     *******ngIf****=****"bucket.length > 0; else bucketEmptyMessage"** >...</div>
    <ng-template #bucketEmptyMessage>...</ng-template>
    </div> 
    

    一旦您保存上述代码,您将看到应用程序崩溃,显示我们无法在单个元素上使用多个模板绑定。这意味着我们无法在单个元素上使用多个结构指令:

    图片

    图 2.11:Angular 语言服务解释我们不能在同一个元素上使用两个结构指令

  3. 我们可以通过将一个结构指令移动到 <ng-container> 包装器中来解决这个问题,它不会在 DOM 中创建任何额外的 HTML 元素。让我们按照以下方式修改代码:

    <div class="fruits">
    **<****ng-container** *******ngIf****=****"bucket.length > 0; else**
    **bucketEmptyMessage"****>**
    <div class="fruits__item" *ngFor="let item of bucket">
          ...
        </div>
    **</****ng-container****>**
    <ng-template #bucketEmptyMessage>...</ng-template>
    </div> 
    

    通过上述更改,您应该能够在桶中没有项目时看到消息,如下所示:

    图片

    图 2.12:使用 *ngIf 和 *ngFor 一起的最终结果

它是如何工作的…

由于我们无法在同一个元素上使用两个结构指令(比如说一个按钮),我们总是可以使用另一个 HTML 元素作为包装器(父元素)来使用其中一个结构指令,另一个结构指令用于目标元素(在我们的例子中是按钮)。然而,这会在 DOM 中添加另一个元素,可能会根据您的实现导致元素层次结构或其他布局行为问题。然而,<ng-container> 是 Angular 中的一个神奇元素,它不会添加到 DOM 中。相反,它只是包装了您应用于它的逻辑/条件,这使得它非常适合我们在这种情况下使用。

参见

使用指令组合 API 将多个指令应用于同一元素

在这个菜谱中,您将使用 指令组合 API 来创建多个组件,并直接将指令应用于它们以提高可重用性,而不是必须将指令应用于每个组件或创建组件模板内的额外元素以应用指令。

准备工作

我们将要工作的应用位于克隆的仓库中的start/apps/chapter02/ng-directive-comp-api目录下:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-directive-comp-api 
    

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

    图 2.13:ng-directive-comp-api 应用在 http://localhost:4200 上运行

如何做到这一点…

  1. 首先,我们将为我们的应用创建几个组件。我们将创建一个用于填充按钮的指令,一个用于轮廓按钮的指令,以及一个用于带提示的按钮的指令。从工作区内的start文件夹运行以下命令:

    nx g directive button-filled --directory apps/chapter02/ng-directive-comp-api/src/app/directives --standalone=false
    nx g directive button-outlined --directory apps/chapter02/ng-directive-comp-api/src/app/directives --standalone=false
    nx g directive button-with-tooltip --directory apps/chapter02/ng-directive-comp-api/src/app/directives --standalone=false 
    

    如果被要求,选择@nx/angular:component schematics并选择“按提供”操作。

    注意,我们创建的所有指令都是非独立指令。这是因为应用是用NgModule启动的,而AppComponent不是一个独立组件。因此,我们需要将这些指令导入到app.module.ts中,以便这个菜谱能够工作。

  2. 让我们将ButtonDirective变成一个独立指令,这意味着它不会成为任何NgModule的一部分。更新button.directive.ts文件如下:

    ...
    @Directive({
      selector: '[appButton]',
      **standalone****:** **true****,**
    })
    export class ButtonDirective {
      ...
    } 
    
  3. 让我们同样从app.module.ts文件中移除它,因为它现在是一个standalone指令。更新app.module.ts文件如下:

    ...
    **import** **{** **ButtonDirective** **}** **from****'****./directives/button.directive'****;** **// <-- remove the import**
    ...
    @NgModule({
      declarations: [
        ...,
        **ButtonDirective****,** **// <-- remove this**
        ...
      ],
      ...
    })
    export class AppModule {} 
    

    你会注意到,所有的按钮都没有了所需的样式,如下所示:

    图 2.14:按钮指令的样式已消失

  4. 让我们更新ButtonFilledDirective以使用ButtonDirective,使用指令组合 API。更新button-filled.directive.ts文件如下:

    import { Directive**,** **HostBinding** } from '@angular/core';
    **import** **{** **ButtonDirective** **}** **from****'./button.directive'****;**
    @Directive({
      selector: '[appButtonFilled]',
      **hostDirectives****: [**
    **{**
    **directive****:** **ButtonDirective****,**
    **inputs****: [****'color'****],**
    **},**
    **],**
    })
    export class ButtonFilledDirective {
      **@****HostBinding****(****'attr.fill'****)**
    **fill =** **'filled'****;**
    } 
    
  5. 我们可以在app.component.html文件中使用appButtonFilled指令,如下所示:

    ...
    <main class="content" role="main">
    <ul class="flex flex-col">
    <li class="flex gap-4 items-center border-b justify-
          between border-slate-300 py-3">...</li>
    <li class="flex gap-4 items-center border-b justify-
          between border-slate-300 py-3">
    <h4 class="text-lg">Filled Button:</h4>
    **<****button****appButtonFilled****color****=****"****yellow"****>****Click**
    **Me****</****button****>**
    </li>
    <li class="flex gap-4 items-center border-b justify-
          between border-slate-300 py-3">...</li>
    <li class="flex gap-4 items-center border-b justify-
          between border-slate-300 py-3">...</li>
    </ul>
    </main> 
    

注意,我们已经从元素中移除了fill属性。

  1. 让我们同样更新ButtonOutlined指令。我们将修改button-outlined.directive.ts如下:

    import { Directive**,** **HostBinding** } from '@angular/core';
    **import** **{** **ButtonDirective** **}** **from****'./button.directive'****;**
    @Directive({
      selector: '[appButtonOutlined]',
      **hostDirectives****: [**
    **{**
    **directive****:** **ButtonDirective****,**
    **inputs****: [****'color'****],**
    **},**
    **],**
    })
    export class ButtonOutlinedDirective {
      **@****HostBinding****(****'attr.fill'****)**
    **fill =** **'outlined'****;**
    } 
    
  2. 让我们同样修改ButtonWithTooltipDirective类。我们将更新button-with-tooltip.directive.ts如下:

    import { Directive } from '@angular/core';
    **import** **{** **ButtonDirective** **}** **from****'./button.directive'****;**
    **import** **{** **TooltipDirective** **}** **from****'./tooltip.directive'****;**
    @Directive({
      selector: '[appButtonWithTooltip]',
      **hostDirectives****: [**
    **{**
    **directive****:** **ButtonDirective****,**
    **inputs****: [****'color'****,** **'fill'****],**
    **},**
    **{**
    **directive****:** **TooltipDirective****,**
    **inputs****: [****'appTooltip: tooltip'****],**
    **},**
    **],**
    })
    export class ButtonWithTooltipDirective {} 
    

    你会注意到应用开始抛出错误,指出TooltipDirective不是一个独立组件。这是真的。我们需要对TooltipDirective也做与ButtonDirective步骤 2步骤 3中相同的事情。完成这些后,继续下一步。

  3. 现在,更新app.component.html文件以使用appButtonOutlinedappButtonTooltip指令,如下所示:

    ...
    <main class="content" role="main">
    <ul class="flex flex-col">
    <li class="flex gap-4 items-center border-b justify-
          between border-slate-300 py-3">...</li>
    <li class="flex gap-4 items-center border-b justify-
          between border-slate-300 py-3">...</li>
    <li class="flex gap-4 items-center border-b justify-
          between border-slate-300 py-3">
    <h4 class="text-lg">Outlined Button:</h4>
    **<****button****appButtonOutlined****>****Click Me****</****button****>**
    </li>
    <li class="flex gap-4 items-center border-b justify-
          between border-slate-300 py-3">
    <h4 class="text-lg">Button with Tooltip:</h4>
    <div class="flex flex-col gap-4">
    **<****button****appButtonWithTooltip****tooltip****=****"code with**
    **ahsan"****fill****=****"****outlined"****color****=****"blue"****>**
    **Click Me**
    **</****button****>**
    **<****button****appButtonWithTooltip****tooltip****=****"code with**
    **ahsan"****fill****=****"filled"****color****=****"blue"****>**
    **Click Me**
    **</****button****>**
    </div>
    </li>
    </ul>
    </main> 
    

    如果你正确地遵循了所有步骤,你应该能够看到以下最终结果:

    图 2.15:包含应用不同指令的按钮的最终结果

它是如何工作的…

指令组合 API 是在 Angular v15 中引入的,并且一直是 Angular 社区最请求的功能之一。在这个菜谱中,我们尝试创建一些组件,将指令直接绑定到组件的 TypeScript 类中,而不是在模板中。这消除了在组件内部创建包装元素以应用指令或映射组件的输入到指令的输入的需求。这也允许多个指令绑定到同一个组件——即使它们可能有相同名称的输入,我们也可以将它们别名为不同的名称。

我们应用程序中指令的流程如下:

  • AppComponent 使用了 ButtonFilledDirectiveButtonOutlinedDirectiveButtonWithTooltipDirective 指令。为此,这些指令需要是非独立的,因为应用程序是用 NgModule 引导的

  • ButtonFilledDirectiveButtonOutlinedDirectiveButtonWithTooltipDirective 指令使用指令组合 API 来使用 ButtonDirectiveTooltipDirective。这些指令需要是独立指令,以便用作 ‘hostDirectives

使用指令组合 API 的关键是使用 standalone: true 标志构建你的基础指令。这意味着你的指令不属于任何 NgModule,可以直接导入到它们被使用的任何组件的导入数组中。这就是为什么我们在步骤 2、3 和 7 中将 ButtonDirectiveTooltipDirective 都设置为独立。然后,我们在 ButtonFilledDirectiveButtonOutlinedDirectiveButtonWithTooltipDirective 中使用这些指令,以便能够重用逻辑,而无需创建任何包装组件或额外的 HTML。我们通过在指令元数据中使用 hostDirectives 属性来实现。请注意,我们向此属性传递一个对象数组,每个对象可以包含 directive 属性,该属性接受要应用的 directive 的类。我们还可以为主绑定提供 输入输出。正如你在 ButtonWithTooltipDirective 中看到的,我们还把 TooltipDirectiveappTooltip 输入别名设置为 ButtonWithTooltipDirectivetooltip 输入。需要注意的是,如果你不想映射任何输入或输出,只想在 hostDirectives 中绑定一个指令,你可以简单地提供一个要应用的指令类的数组,如下所示:

hostDirectives: [
  ButtonDirective,
  TooltipDirective
], 

参见

在 Discord 上了解更多

要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问以及了解新书发布——请扫描下面的二维码:

packt.link/AngularCookbook2e

二维码

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

本章全部关于 Angular 中 依赖注入DI)的魔法。在这里,你将了解 Angular 中 DI 概念的详细信息。DI 是 Angular 用于将不同依赖项注入到组件、指令和服务的进程。你将通过几个示例进行操作,使用服务和提供者来获得一些实际经验,这些经验可以在你以后的 Angular 项目中利用。

在本章中,我们将介绍以下食谱:

  • 使用 Angular DI 令牌

  • 可选依赖项

  • 使用 providedIn 创建单例服务

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

  • 对同一 DI 令牌提供替代类

  • 使用值提供者进行动态配置

技术要求

对于本章的食谱,请确保你的设置已按照 'Angular-Cookbook-2E' GitHub 仓库中的 '技术要求' 完成。有关设置详细信息,请访问:github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/docs/technical-requirements.md。本章的起始代码位于 github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/start/apps/chapter03

使用 Angular DI 令牌

在这个食谱中,你将学习如何创建基本的 DI 令牌。我们将为常规 TypeScript 类创建它,以便使用 DI 作为 Angular 服务。在我们的应用程序中有一个名为 Jokes 的类,它通过手动创建该类的新实例在 AppComponent 中使用。这使得我们的代码紧密耦合且难以测试,因为 AppComponent 类直接使用 Jokes 类。

换句话说,当运行 App 组件的测试时,我们现在依赖于 Jokes 类,如果该类中发生任何变化,我们的测试将失败。由于 Angular 专注于 DI服务,我们将使用 DI 令牌来使用 Jokes 类作为 Angular 服务。我们将使用 InjectionToken 方法创建 DI 令牌,然后使用 @Inject 装饰器来使我们能够在服务中使用该类。

准备工作

我们将要工作的应用位于克隆的仓库中的 start/apps/chapter03/ng-di-token

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-di-token 
    

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

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

现在我们已经运行了应用,我们可以继续进行食谱的步骤。

如何操作...

我们目前拥有的应用程序向一个从 TypeScript 类 Jokes 中检索到的随机用户显示问候消息。我们通过在 AppComponent 类中使用语句 jokes = new Jokes(); 创建 Jokes 类的实例。然而,Angular 有一种内置的方式使用类作为服务通过依赖注入(DI)。所以,我们不会将其作为类使用,而是将其作为 Angular 服务使用 DI。我们将首先为我们的 Jokes 类创建一个 InjectionToken,然后将其注入到 AppComponent 类中。按照以下步骤进行操作:

  1. 我们将在 jokes.class.ts 文件中创建一个 InjectionToken。我们将命名令牌为 'Jokes',使用一个新的 InjectionToken 实例。最后,我们将从这个文件中导出这个令牌:

    import { InjectionToken } from '@angular/core';
    export const JOKES = new InjectionToken('Jokes', {
      providedIn: 'root',
      factory: () => new Jokes(),
    });
    class Jokes {...}
    export default Jokes; 
    
  2. 现在,我们将使用 @angular/core 包中的 inject 方法和 jokes.class.ts 文件中的 JOKES 令牌来使用该类,如下所示:

    import { Component, **inject**, OnInit } from '@angular/core';
    **import** **{** **JOKES** **}** **from****'./classes/jokes.class'****;**
    import { IJoke } from './interfaces/joke.interface';
    @Component({...})
    export class AppComponent implements OnInit {
      joke!: IJoke;
      **jokes =** **inject****(****JOKES****);**
      ...
    } 
    

就这样。你应该看到应用程序与之前一样工作。唯一的区别是,我们不是手动实例化 Jokes 类的实例,而是依赖于注入令牌来实例化它。这不仅带来了无需创建实例的便利,而且如果 Jokes 类通过 Angular DI 使用其他类作为依赖项,并且其中任何一个缺失,我们将会得到适当的错误来修复问题。因此,我们有一个更健壮的服务和组件架构,这确保在应用程序运行/构建之前满足依赖项。现在我们知道了配方,让我们更详细地看看它是如何工作的。

它是如何工作的…

Angular 不识别常规 TypeScript 类作为可注入项。然而,我们可以创建自己的注入令牌,并使用 @angular/core 包中的 inject 方法在需要的地方注入相关的类和值。Angular 在幕后识别这些令牌并找到它们的对应定义,这通常是以 factory 函数的形式。请注意,我们在令牌定义中使用 providedIn: 'root'。这意味着在整个应用程序中只有一个类的实例。

相关内容

可选依赖

当你在 Angular 应用程序中使用或配置一个可能存在或不存在或尚未提供的依赖项时,Angular 中的可选依赖项非常强大。在这个配方中,我们将学习如何使用 @Optional 装饰器在组件和服务中配置可选依赖项。我们将与 LoggerService 一起工作,确保如果组件尚未提供 LoggerService,它们不会崩溃。

准备工作

我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter03/ng-optional-dependencies目录下:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令来启动项目:

    npm run serve ng-optional-dependencies 
    

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

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

现在我们已经运行了应用程序,我们可以继续进行下一步骤。

如何做到这一点…

我们将有一个包含LoggerService的应用程序,该服务通过providedIn: 'root'作为其可注入配置提供。我们将看到当我们没有在任何地方提供此服务时会发生什么。然后,我们将使用@Optional装饰器识别和修复问题。按照以下步骤操作:

  1. 首先,让我们运行应用程序,输入一个新的版本号,0.0.1,然后点击提交按钮。

    这将导致日志通过LoggerService保存到localStorage中。打开Chrome 开发者工具,导航到应用程序,选择本地存储,然后点击http://localhost:4200。你会看到带有日志值的键vc_logs_ng_od,如下所示:

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

  2. 让我们在logger.service.ts文件中尝试移除为LoggerService提供的@Injectable装饰器中的配置。更改应如下所示:

    import { Injectable } from '@angular/core'; 
    **// <-- remove the above import**
    import { Logger } from '../interfaces/logger';
    @Injectable(**{****//<-- remove this object**
    **providedIn: 'root'**
    **})**
    export class LoggerService implements Logger {
      ...
    } 
    

    这将导致 Angular 无法识别它,并在控制台抛出错误,如下所示:

    图 3.4:一个反映 Angular 无法识别 LoggerService 错误的错误

  3. 我们现在可以使用@Optional装饰器将依赖项标记为可选。让我们从@angular/core包中导入它,并在vc-logs.component.ts文件的VcLogsComponent构造函数中使用装饰器,如下所示:

    import { Component, OnInit, Input, OnChanges, SimpleChanges**,** **Optional** } from '@angular/core';
    ...
    export class VcLogsComponent implements OnInit {
      ...
      **constructor****(****@Optional****()** **private** **logger: LoggerService****) {**
    **this****.****logs** **=** **this****.****logger****?.****retrieveLogs****() || [];**
    **}**
      ...
    } 
    

    太好了!现在,如果你刷新应用程序并查看控制台,应该会有不同的错误。太棒了,有进展!

    图 3.5 显示,我们有一个新的错误,因为我们正在尝试在ngOnChanges方法内部调用this.logger.log()语句。

    图 3.5:一个详细说明this.logger现在是基本为空的错误

  4. 为了解决这个问题,我们可以选择完全不记录任何日志,或者如果未提供LoggerService,则回退到console.*方法。回退到console.*方法的代码如下:

    ...
    export class VcLogsComponent implements OnInit {
      ...
      constructor(@Optional() private loggerService: 
    LoggerService) {
        this.logs = this.logger?.retrieveLogs() || [];
      }
      **get****log****() {**
    **return****this****.****logger****?.****log****.****bind****(****this****.****logger****) ||**
    **console****.****log****;**
    **}**
      ... 
    
  5. 让我们也更新ngOnChanges块以使用此日志(获取器)函数:

    ...
    export class VcLogsComponent implements OnInit {
      ...
      constructor(@Optional() private logger: LoggerService) { }
      get log() {}
      ngOnChanges(changes: SimpleChanges) {
        const currValue = changes['vName'].currentValue;
        let message;
        if (changes['vName'].isFirstChange()) {
          message = `initial version is ${currValue.trim()}`;
          if (!this.logs.length) {
            **this****.****log****(message);**
    this.logs.push(message);
          }
        } else {
            message = `version changed to ${currValue.trim()}`;
            **this****.****log****(message);**
    this.logs.push(message);
        }
      }
      ... 
    
  6. 现在,如果你更新版本并点击提交,你应该会在控制台上看到日志,如下所示:

    图 3.6:当未提供 LoggerService 时,日志作为回退在控制台上的打印

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

工作原理

@Optional装饰器是@angular/core包中的一个特殊装饰器,它允许你将一个依赖项标记为可选。在幕后,当在具有依赖项的类的构造函数方法中使用时,如果依赖项不存在或未提供给应用程序,Angular 将提供值为null。由于我们从LoggerService类的@Injectable()装饰器中移除了配置对象,它不会在 Angular 中提供用于 DI。因此,我们的@Optional()装饰器在注入时将其设置为null,不会导致 Angular 抛出图 3.4中显示的NullInjectorError。在步骤 4中,我们在组件的类VcLogsComponent中创建了一个log获取器函数,这样我们就可以在服务提供时使用LoggerServicelog方法;否则使用console.log。然后,在接下来的步骤中,我们只需使用我们创建的log方法。如果你回到logger.service.ts文件并将服务作为providedIn: 'root'再次提供,你现在将看不到任何控制台日志,并且会看到现在应用程序正在使用服务,即使用localStorageLoggerService

参考以下内容

使用providedIn创建单例服务

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

准备工作

我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter03/ng-singleton-service内:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-singleton-service 
    

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

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

现在我们已经启动了应用程序,我们可以继续进行下一步的步骤。

如何操作

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

  1. 我们将使用providedIn: 'root'NotificationService来告诉 Angular 它只应在根模块中提供,并且在整个应用中只有一个实例。所以,让我们去notifications.service.ts文件,并在@Injectable装饰器参数中传递providedIn: 'root',如下所示:

    import { Injectable } from '@angular/core';
    import { BehaviorSubject, Observable } from 'rxjs';
    @Injectable(**{**
    **providedIn****:** **'root'**
    **}**)
    export class NotificationsService {
      ...
    } 
    

    太好了!现在,即使你刷新并尝试添加或删除通知,你仍然会看到标题中的计数没有变化。“但是为什么这样,Ahsan?” 好吧,我很高兴你问了。这是因为我们仍然在AppModuleHomeModule类中提供了这个服务。

  2. 首先,让我们从app.module.ts中的providers数组中移除NotificationsService,如下面的代码块中突出显示:

    ...
    import { NotificationsButtonComponent } from './components/notifications-button/notifications-button.component';
    **import** **{** **NotificationsService** **}** **from** './services/notifications.service'**;**
    // <-- Remove the import above
    @NgModule({
      declarations: [... ],
      imports: [...],
      providers: [
        **NotificationsService** **// <-- Remove this**
      ],
      bootstrap: [AppComponent]
    })
    export class AppModule { } 
    
  3. 现在,我们将从home.module.ts中移除NotificationsService,如下面的代码块中突出显示:

    ...
    **import** **{** **NotificationsService** **}** **from****'../services/notifications.service'****;** 
    **// <-- Remove the import above**
    @NgModule({
      declarations: [...],
      imports: [...],
      providers: [
        **NotificationsService****// <-- Remove this**
      ]
    })
    export class HomeModule { } 
    

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

  4. 让我们把NotificationsService放回home.module.ts文件中:

    ...
    **import** **{** **NotificationsService** **}** **from****'../services/notifications.service'****;**
    @NgModule({
      declarations: [HomeComponent, NotificationsManagerComponent],
      imports: [CommonModule, HomeRoutingModule],
      providers: [**NotificationsService**],
    })
    export class HomeModule {} 
    

    哗啦!我们在控制台或编译时间都没有任何错误。然而,我们有一个问题,那就是标题中的计数没有更新。那么,我们如何提醒开发者他们犯了这样的错误呢?

  5. 为了提醒开发者关于潜在的重复提供者,我们将在我们的NotificationsService中使用来自@angular/core包的@SkipSelf装饰器,并抛出一个错误来通知并修改NotificationsService,如下所示:

    import { Injectable, **SkipSelf** } from '@angular/core';
    import { BehaviorSubject, Observable } from 'rxjs';
    @Injectable({
      providedIn: 'root',
    })
    export class NotificationsService {
      ...
      **constructor****(****@SkipSelf****() existingService:**
    **NotificationsService****) {**
    **if** **(existingService) {**
    **throw****Error****(**
    **'The service has already been provided in the**
    **app.**
    **Avoid providing it again in child  modules'**
    **);**
    **}**
    **}**
      ...
    } 
    

    在完成前一步后,你会注意到我们有一个问题,那就是我们未能向我们的应用提供NotificationsService。你应该在控制台中看到以下内容:

    图 3.8:一个详细说明 NotificationsService 无法注入到 NotificationsService 的错误

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

  6. 好吧——现在,我们将在notifications.service.ts中使用@Optional()装饰器,它位于构造函数中的依赖项旁边,与@SkipSelf装饰器一起。代码应该如下所示:

    import { Injectable**,** **Optional**, SkipSelf } from '@angular/core';
    ...
    export class NotificationsService {
      ...
      constructor(**@Optional****()** @SkipSelf() existingService:
    NotificationsService) {
        if (existingService) {
          throw Error ('The service has already been provided in
    the app. Avoid providing it again in child
    modules');
        }
      }
      ...
    } 
    

    我们现在已经解决了NotificationsService -> NotificationsService依赖问题。你应该在控制台中看到NotificationsService被多次提供的正确错误,如下所示:

    图 3.9:一个详细说明 NotificationsService 已经在应用中提供的错误

  7. 现在,我们将安全地从home.module.ts文件中的providers数组中移除提供的NotificationsService,正如步骤 3中所示,并检查应用是否正常工作。

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

它是如何工作的

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

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

参见

使用 forRoot() 创建单例服务

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

准备工作

我们将要工作的应用位于 start/apps/chapter03/ng-singleton-service-forroot,在克隆的仓库内:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令来运行项目:

    npm run serve ng-singleton-service-forroot 
    

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

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

现在我们已经运行了应用,在下一节中,我们可以继续进行菜谱的步骤。

如何操作

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

  1. 首先,我们要确保服务有自己的模块。在许多 Angular 应用中,你可能会看到 CoreModule,其中提供了服务(假设我们没有使用 providedIn: 'root' 语法的原因)。为了开始,我们将使用以下命令从项目根目录创建一个名为 ServicesModule 的模块:

    cd start && nx g m services --project ng-singleton-service-forroot 
    
  2. 让我们在 services.module.ts 文件中的 ServicesModule 类内创建一个静态方法 forRoot()。我们将命名该方法为 forRoot,并返回一个包含 NotificationsService(在 providers 数组中提供)的 ModuleWithProviders 对象,如下所示:

    import { **ModuleWithProviders****,** NgModule } from '@angular/core';
    import { CommonModule } from '@angular/common';
    **import** **{** **NotificationsService** **}** **from****'./notifications.service'****;**
    @NgModule({
      declarations: [],
      imports: [CommonModule],
    })
    export class ServicesModule {
      **static****forRoot****():** **ModuleWithProviders****<****ServicesModule****> {**
    **return** **{**
    **ngModule****:** **ServicesModule****,**
    **providers****: [****NotificationsService****],**
    **};**
    **}**
    } 
    
  3. 现在,我们将从app.module.ts文件的providers数组中移除NotificationsService,并在app.module.ts文件中包含ServicesModule。特别是,我们将使用forRoot()方法在imports数组中添加ServicesModule,如下面的代码块所示。

    这是因为它将ServicesModule及其提供者注入到AppModule中,例如,提供NotificationsService,如下所示:

    ...
    **import** **{** **NotificationsService** **}** **from** **'./services/notifications.service'****;**
    **// <-- Remove the import above**
    **import** **{** **ServicesModule** **}** **from****'./services/services.module'****;**
    @NgModule({
      declarations: [...],
      imports: [
        ...,
        **ServicesModule****.****forRoot****()**
      ],
      providers: [
        **NotificationsService**// <-- **Remove this**
      ],
      bootstrap: [AppComponent]
    })
    export class AppModule { } 
    

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

  4. 我们将从home.module.ts文件的providers数组中移除NotificationsService,如下所示:

    ...
    **import** **{** **NotificationsService** **}** **from****'../services/notifications.service'****;** 
    **//  <-- Remove the above import**
    **import** **{** **ServicesModule** **}** **from****'../services/services.module'****;**
    @NgModule({
      declarations: [HomeComponent,
        NotificationsManagerComponent],
      imports: [CommonModule, HomeRoutingModule,
        **ServicesModule**],
      providers: [
        **NotificationsService****// <-- Remove this**
      ],
    })
    export class HomeModule {} 
    

干得好。现在我们已经完成了这个食谱,在下一节中,让我们讨论它是如何工作的。

工作原理

ModuleWithProviders充当NgModule的包装器,将其与providers数组捆绑在一起。它用于配置NgModule及其提供者,确保当模块在其他地方导入时,它也带来了其提供者。在我们的ServicesModule中,我们创建了一个返回ModuleWithProvidersforRoot方法。它包括我们的NotificationsService,这使得我们可以在整个应用中拥有这个服务的单个实例,避免了在ServicesModuleproviders数组中提供NotificationsService并将其导入到各个模块时通常出现的多个实例。因此,为了确保单个实例,ServicesModule应该使用ModuleWithProviders方法导入,而不是标准方式。这就是为什么在使用ModuleWithProviders方法时,我们不按常规方式导入ServicesModule,如下所示:

@NgModule({
  ...
  imports: [..., **ServicesModule**],
}) 

相反,我们使用forRoot方法导入它,这确保了NotificationService在整个应用中只被提供一次,如下所示:

@NgModule({
...
  imports: [..., **ServicesModule****.****forRoot****()**],
}) 

现在你已经了解了这个食谱的工作原理,请查看下一节以获取一些有用的链接。

参见

针对相同的 DI 令牌提供备用类

在这个食谱中,你将学习如何使用别名类提供者向应用提供两个不同的服务。这在复杂的应用程序中非常有用,其中你需要为某些组件/模块缩小服务的/类的实现,即针对相同的 DI 令牌提供不同的类以实现多态行为。此外,别名在组件/服务单元测试中使用,以模拟依赖服务的实际实现,这样我们就不依赖于它了。

准备工作

我们将要工作的应用位于 start/apps/chapter03/ng-aliased-class-providers,在克隆的仓库内:

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-aliased-class-providers 
    

    这应该在新的浏览器标签页中打开应用,您应该看到如图 3.11 所示的应用。

  3. 点击 登录为管理员 按钮。您应该看到以下截图类似的内容:

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

现在我们已经运行了应用,让我们进入下一节,按照菜谱的步骤进行操作。

如何做到这一点

我们有一个名为 BucketComponent 的独立组件,它被用于管理员和员工组件中。BucketComponent 在幕后使用 BucketService 来添加/删除桶中的项目。对于员工,我们将通过提供一个 aliased 类提供者和名为 EmployeeBucketServiceBucketService 替换来限制删除项目的权限。这样我们就可以覆盖删除项目功能。按照以下步骤开始:

  1. 我们将首先在 employee 文件夹内创建 EmployeeBucketService。从工作区根目录运行以下命令:

    cd start && nx g service employee/employee-bucket --project ng-aliased-class-providers 
    
  2. 接下来,我们将从 BucketService 扩展 EmployeeBucketService,以便我们能够获得 BucketService 类的所有优点。让我们按照以下方式修改代码:

    import { Injectable } from '@angular/core';
    **import** **{** **BucketService** **}** **from****'../bucket/bucket.service'****;**
    ...
    export class EmployeeBucketService**extends****BucketService** {
      constructor() {
        **super****();**
      }
    } 
    
  3. 现在,我们将重写 removeItem 方法以显示一个简单的 alert,说明员工不能从桶中删除项目。您的代码应如下所示:

    ...
    export class EmployeeBucketService extends BucketService {
      constructor() {...}
      **override****removeItem****() {**
    **alert****(****'Employees can not delete items'****);**
    **}**
    } 
    
  4. 作为最后一步,我们需要将 aliased 类提供者提供给 employee.component.ts 文件,如下所示:

    ...
    **import** **{** **BucketService** **}** **from****'../bucket/bucket.service'****;**
    **import** **{** **EmployeeBucketService** **}** **from****'****./employee-bucket.service'****;**
    @Component({
      ...
      **providers****: [{**
    **provide****:** **BucketService****,**
    **useClass****:** **EmployeeBucketService****,**
    **}],**
    })
    export class EmployeeComponent {} 
    

如果您现在以员工身份登录应用并尝试删除项目,您将看到一个弹出窗口,上面写着“员工不能删除项目”。

它是如何工作的

当我们将服务注入到组件中时,Angular 会尝试在我们提供的依赖项的组件/模块中找到该组件,然后通过移动组件和模块的层次结构来查找。我们的 BucketService'root' 中提供,使用 providedIn: 'root' 语法。因此,它位于层次结构的顶部。然而,由于在这个菜谱中,我们在 EmployeeComponent 类中对 DI 令牌 BucketService 使用了一个 aliased 类提供者,当 Angular 为 EmployeeComponent 查找 BucketService 时,它会快速找到 EmployeeComponent 中的 EmployeeBucketService 对应的令牌并停止搜索——即,它不会到达'root'以获取实际的 BucketService。这正是我们想要的。

参见

使用值提供者的动态配置

在这个菜谱中,你将学习如何在 Angular 中使用值提供者来为你的应用提供常量和配置值。我们将从上一个菜谱中的相同示例开始,该示例涉及EmployeeComponentAdminComponent使用BucketComponent来管理一个水果桶。我们将通过使用值提供者的配置来限制EmployeeComponent删除桶中项目的权限。因此,员工甚至看不到删除按钮。

准备工作

我们将要工作的应用位于克隆的仓库中的start/apps/chapter03/ng-value-providers目录下:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令来运行项目:

    npm run serve ng-value-providers 
    

    这应该会在新浏览器标签页中打开应用,你应该会看到如图 3.12 所示的界面。

  3. 点击登录为管理员按钮。你应该会看到如下截图:

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

现在你看到应用正在运行,让我们看看下一步要遵循的菜谱。

如何操作

我们有一个名为BucketComponent的独立组件,它被用于管理员和员工组件中。BucketComponent在幕后使用BucketService来添加/删除桶中的项目。对于员工,我们将通过提供值提供者来限制删除项目的权限。这样我们就可以覆盖删除项目的功能。让我们从以下步骤开始:

  1. 首先,我们将在项目根目录下创建一个新的文件,命名为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注入这个值时,提供者的值才能被解析。

  2. 让我们在app.module.ts文件中添加提供者,如下所示:

    ...
    **import** **{** **AppConfig****,** **APP_CONFIG** **}** **from****'./app-config'****;**
    @NgModule({
      declarations: [AppComponent],
      imports: [...],
      **providers****: [{**
    **provide****:** **APP_CONFIG****,**
    **useValue****:** **AppConfig****,**
    **}],**
    bootstrap: [AppComponent],
    })
    export class AppModule {} 
    

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

  3. 我们将使用inject方法将其注入到BucketComponent类中,在bucket/bucket.component.ts文件中,如下所示:

    import { Component, i**nject,** OnInit } from '@angular/core';
    ...
    **import** **{** **APP_CONFIG** **}** **from****'../app-config'****;**
    ...
    export class BucketComponent implements OnInit {
      bucketService = inject(BucketService);
      **appConfig =** **inject****(****APP_CONFIG****);**
      ...
    } 
    

    太好了!常量已经注入。现在,如果你刷新应用,你不应该收到任何错误。下一步是使用BucketComponent中的configcanDeleteItems属性来显示/隐藏delete按钮。

  4. 现在,我们将在bucket/bucket.component.html文件中添加一个*ngIf指令,仅在appConfig.canDeleteItems的值为true时显示delete按钮。更新具有fruites__item__delete-icon类的元素,如下所示:

    ...
    <div *******ngIf****=****"appConfig.canDeleteItems"**
     class="fruites__item__delete-icon"
      (click)="deleteFromBucket(item)">
    <div class="material-symbols-outlined">delete</div>
    </div>
    ... 
    

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

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

  5. 现在,让我们创建一个员工配置对象。我们将在employee文件夹内创建一个employee.config.ts文件,并将以下代码添加到其中:

    import { IAppConfig } from '../app-config';
    export const EmployeeConfig: IAppConfig = {
      canDeleteItems: false,
    }; 
    
  6. 现在,我们将这个EmployeeConfig常量提供给EmployeeComponent,用于相同的APP_CONFIG注入令牌。employee.component.ts文件中的代码应如下所示:

    ...
    **import** **{** **APP_CONFIG** **}** **from****'../app-config'****;**
    **import** **{** **EmployeeConfig** **}** **from****'./employee.config'****;**
    @Component({
      ...
      **providers****: [{**
    **provide****:** **APP_CONFIG****,**
    **useValue****:** **EmployeeConfig****,**
    **}],**
    })
    export class EmployeeComponent {} 
    

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

工作原理

当我们将令牌注入到组件中时,Angular 会尝试在注入位置找到令牌的解析值,然后通过移动组件和模块的层次结构向上查找。我们在EmployeeComponent类中针对APP_CONFIG令牌提供了EmployeeConfig对象。当 Angular 尝试解析BucketComponent的令牌值时,它会在EmployeeComponent内部找到EmployeeConfig,而不是在AppModule中作为AppConfig提供的值。因此,Angular 会立即停止,不会到达AppModule。这真是太神奇了,因为我们现在可以拥有全局配置,并覆盖嵌套模块/组件内的配置。

参见

在 Discord 上了解更多

要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:

packt.link/AngularCookbook2e

第四章:理解 Angular 动画

在本章中,你将学习如何在 Angular 中处理动画。你将了解多状态动画、交错动画和关键帧动画,以及如何在 Angular 应用程序中实现切换路由的动画以及如何有条件地禁用动画。

以下是我们将在本章中涵盖的菜谱:

  • 创建你的第一个两种状态的 Angular 动画

  • 与多状态动画一起工作

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

  • 使用交错动画在 Angular 中动画化列表

  • Angular 中的顺序动画与并行动画

  • Angular 中的路由动画

  • 有条件地禁用 Angular 动画

技术要求

对于本章的菜谱,确保你的设置已按照'Angular-Cookbook-2E' GitHub 仓库中的'技术要求'完成。有关设置详细信息,请访问:github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/docs/technical-requirements.md。本章的起始代码位于github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/start/apps/chapter04

创建你的第一个两种状态的 Angular 动画

在这个菜谱中,你将创建一个基本的两种状态 Angular 动画,它具有淡入淡出效果。我们将从一个已经内置了 UI 的 Angular 应用程序开始。然后,我们将使用 Angular 动画在应用程序中启用动画,并逐步创建我们的第一个动画。

准备工作

我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter04/ng-basic-animation

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目:

    npm run serve ng-basic-animation 
    

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

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

现在我们已经运行了应用程序,我们将继续到菜谱的步骤。

如何做到这一点...

我们有一个完全没有配置 Angular 动画的应用程序。我们将使用 Angular 动画为卡片创建淡入效果。让我们继续以下步骤:

  1. 首先,我们将从@angular/platform-browser/animations包中导入provideAnimations函数到我们的src/app/app.config.ts文件中,这样我们就可以在应用程序中使用动画了。我们将在providers数组中使用它,如下所示:

    ...
    **import** **{ provideAnimations }** **from****'@angular/platform-browser/animations'****;** 
    export const appConfig: ApplicationConfig = {
      providers: [
        provideRouter(appRoutes, withEnabledBlockingInitialNavigation())**,**
    **provideAnimations****()**
      ],
    }; 
    
  2. 现在修改app.component.ts文件,添加以下动画:

    ...
    **import** **{ trigger, transition, style, animate }** **from****'@angular/animations'****;**
    @Component({
      ...
      imports: [CommonModule, FbCardComponent,
        TwitterCardComponent],
      **animations****: [**
    **trigger****(****'fadeInOut'****, [**
    **transition****(****':enter'****, [**
    **style****({** **opacity****:** **0****,** **scale****:** **0.85** **}),**
    **animate****(****'200ms 100ms'****,** **style****({** **opacity****:** **1****,**
    **scale****:** **1** **})),**
    **]),**
    **transition****(****':leave'****, [** 
    **style****({** **opacity****:** **1****,** **scale****:** **1** **}),** 
    **animate****(****'100ms'****,** **style****({** **opacity****:** **0****,** **scale****:** **0.85** **})),**
    **]),**
    **]),**
    **],** 
    })
    ... 
    
  3. 最后,在app.component.html文件中为两个卡片添加fadeInOut动画,如下所示:

     <!-- Toolbar -->
    <div class="toolbar" role="banner">...</div>
    <main class="content" role="main">
    <div class="type-picker mb-8">...</div>
    <ng-container [ngSwitch]="selectedCardType">
    <app-fb-card **[@****fadeInOut****]**
    *ngSwitchCase="'facebook'"></app-fb-card>
    <app-twitter-card **[@****fadeInOut****]**
     *ngSwitchCase="'twitter'"></app-twitter-card>
    </ng-container>
    </main>} 
    

太好了!你现在已经为卡片实现了基本的淡入 <=> 淡出动画。简单,但很漂亮!参考下一节了解菜谱的工作原理。

它是如何工作的...

Angular 提供了自己的动画 API,允许您对 CSS 过渡支持的任何属性进行动画处理。好处是您可以根据所需条件动态配置它们。如果我们要在 CSS 中创建相同的行为,我们必须执行以下操作:

  1. 我们需要在 CSS 中创建以下关键帧:

    @keyframes fadeIn {
      0% { opacity: 0; transform: scale(0.85); }
      100% { opacity: 1; transform: scale(1); }
    }
    @keyframes fadeOut {
      0% { opacity: 1; transform: scale(1); }
      100% { opacity: 0; transform: scale(0.85); }
    } 
    

    创建应用这些动画的 CSS 类:

    /* For elements that are entering */
    .fade-in {
      animation: fadeIn 200ms 100ms forwards;
    }
    
    /* For elements that are leaving */
    .fade-out {
      animation: fadeOut 100ms forwards;
    } 
    
  2. 然后,我们必须在每个元素上添加和删除 CSS 类,因为它们在 DOM 中 创建移除 时。然而,Angular 使用内置的 :enter:leave 状态来处理此过程,这些状态分别在项目被添加到或从 DOM 中移除时触发。

即使有上述步骤,当处理此类动画时,仍可能出现更多挑战。多亏了 Angular 动画,我们可以更快地实现这些功能。

我们首先使用 trigger 函数注册名为 fadeInOut 的动画。然后我们使用 transition 函数注册 :enter:leave 过渡。最后,我们使用 styleanimate 函数定义了这些过渡的样式和动画。请注意,我们在 :enter 过渡中使用 '200ms 100ms..'200ms 是过渡的持续时间,而 100ms 是延迟。我们添加这个延迟,以便在我们可以移动到下一个要显示的卡的 :enter 过渡之前,等待之前显示的卡的 :leave 过渡完成。让我们深入了解我们使用的每个函数:

  1. trigger 函数:trigger 函数用于在 Angular 中定义动画触发器。第一个参数是触发器的名称,它将在模板中使用以将动画绑定到特定元素。第二个参数是状态和过渡定义的数组。例如,trigger('fadeInOut', [...]) 注册了一个名为 'fadeInOut' 的动画触发器。

  2. :enter:leave 过渡::entervoid => * 状态转换的别名。它表示一个元素被添加到 DOM 中的状态。:leave* => void 状态转换的别名。它表示一个元素被从 DOM 中移除的状态。这些别名对于元素进入或离开视图时常见的动画非常有用,例如淡入和淡出动画。

  3. transition 函数:transition 函数用于定义过渡将发生的状态。它接受两个参数:第一个是一个字符串,定义了状态更改表达式;第二个是一个数组,当过渡被触发时将运行动画步骤。例如,transition(':enter', [...]) 定义了当元素进入视图时将执行的动画步骤。

  4. stylestyle函数用于定义在动画中将使用的 CSS 样式集。它接受一个对象,其中键是 CSS 属性,值是这些属性的期望值。例如,style({ opacity: 0, scale: 0.85 })将透明度设置为0并将元素缩小到原始大小的 85%。

  5. animateanimate函数用于定义样式之间的转换的计时和缓动。第一个参数是一个字符串,定义了持续时间、延迟和缓动曲线。例如,200ms 100ms意味着动画将持续 200 毫秒,并在延迟 100 毫秒后开始。第二个参数是动画将过渡到的样式或一组样式。例如,animate('200ms 100ms', style({ opacity: 1, scale: 1 }))将在等待 100 毫秒后,在 300 毫秒内将元素过渡到全透明度和原始大小。

参见

多状态动画的制作

在这个食谱中,我们将处理包含多个状态的 Angular 动画。这意味着我们将为特定项目处理超过两个状态。我们也将使用相同的 Facebook 和 Twitter 卡片示例来完成这个食谱。

我们将为两张卡片配置以下状态:

  • 卡片出现在屏幕上的状态。

  • 用户悬停在卡片上时的状态。

  • 用户将鼠标从卡片移开时的状态。

准备工作

我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter04/ng-multi-state-animations目录内:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目:

    npm run serve ng-multi-state-animations 
    

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

    图片

    图 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**} from '@angular/core';
    ...
    @Component({...})
    export class FbCardComponent {
      **cardState****:** **'active'** **|** **'hovered'** **=** **'active'****;**
    **@****HostListener****(****'mouseenter'****)**
    **onMouseEnter****() {**
    **this****.****cardState** **=** **'hovered'****;**
    **}**
    **@****HostListener****(****'mouseleave'****)**
    **onMouseLeave****() {**
    **this****.****cardState** **=** **'active'****;**
    **}**
    } 
    
  2. 现在,我们将在 components/twitter-card/twitter-card-component.ts 文件中为 TwitterCardComponent 做同样的事情。代码应如下所示:

    import { Component, **HostListener**} from '@angular/core';
    ...
    @Component({...})
    export class TwitterCardComponent {
      **cardState****:** **'active'** **|** **'hovered'** **=** **'active'****;**
    **@****HostListener****(****'mouseenter'****)** 
    **onMouseEnter****() {** 
    **this****.****cardState** **=** **'hovered'****;** 
    **}** 
    **@****HostListener****(****'mouseleave'****)** 
    **onMouseLeave****() {** 
    **this****.****cardState** **=** **'active'****;** 
    **}**
    } 
    

    到目前为止,应该没有视觉变化,因为我们只是更新了 cardState 变量以拥有悬停和活动状态。我们还没有为动画定义过渡。

  3. 现在,我们将定义当用户的鼠标进入卡片时我们的状态,即 mouseenter 事件。这个状态被称为 hovered,在 animation.ts 文件中应如下所示:

    ...
    export const cardAnimation = trigger('cardAnimation', [
      state('active', style({
        color: 'rgb(51, 51, 51)',
        backgroundColor: 'white'
      })),
      **state****(****'hovered'****,** **style****({**
    **transform****:** **'scale3d(1.05, 1.05, 1.05)'****,**
    **backgroundColor****:** **'#333'****,**
    **color****:** **'white'**
    **})),**
    transition('void => active', [...]),
    ]) 
    

    如果你现在刷新应用,点击 Facebook 或 Twitter 按钮,并将鼠标悬停在卡片上,你会看到卡片的 UI 发生变化。这是因为我们将状态更改为 hovered。然而,在样式更改之间还没有动画效果。让我们在下一步添加动画。

  4. 我们现在将在 animations.ts 文件中添加 active => hovered 过渡,这样我们就可以从 active 状态平滑地导航到 hovered 状态:

    ...
    export const cardAnimation = trigger('cardAnimation', [
      state('active', style(...)),
      state('hovered', style(...)),
      transition('void => active', [...]),
      **transition****(****'active => hovered'****, [**
    **animate****(****'0.3s 0s ease-out'****,** **style****({**
    **transform****:** **'scale3d(1.05, 1.05, 1.05)'****,**
    **backgroundColor****:** **'#333'****,**
    **color****:** **'white'**
    **}))**
    **]),**
    ]) 
    

    如果你刷新应用,现在你应该会看到 mouseenter 事件上的平滑过渡。

  5. 最后,我们将添加最终的过渡,hovered => active,这样当用户离开卡片时,我们可以通过平滑动画恢复到活动状态。代码应如下所示:

    ...
    export const cardAnimation = trigger('cardAnimation', [
      state('active', style(...)),
      state('hovered', style(...)),
      transition('void => active', [...]),
      transition('active => hovered', [...]),
      **transition****(****'hovered => active'****, [**
    **animate****(****'0.3s 0s ease-out'****,** **style****({**
    **transform****:** **'scale3d(1, 1, 1)'****,**
    **color****:** **'rgb(51, 51, 51)'****,**
    **backgroundColor****:** **'white'**
    **}))**
    **]),**
    ]) 
    

哇!你现在知道如何使用 Angular 动画 在单个元素上实现不同的状态和不同的动画。

它是如何工作的…

Angular 使用触发器来理解动画处于哪种状态。一个示例语法如下:

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

expression 可以是一个有效的 JavaScript 表达式,并计算为状态的名称。在我们的例子中,我们将其绑定到 cardState 属性,它包含 activehovered。因此,我们最终为我们的卡片得到三个过渡:

  • void => active(当元素被添加到 DOM 中并渲染时)

  • active => hovered(当卡片上的 mouseenter 事件触发时)

  • hovered => active(当卡片上的 mouseleave 事件触发时)

参见

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

由于你已经从之前的菜谱中了解了 Angular 动画,你可能正在想,“这很简单。”好吧,现在是时候提升你的动画技能了。在这个菜谱中,你将使用 keyframes 创建一个复杂的 Angular 动画,以开始编写一些高级动画。

准备工作

我们将要工作的应用位于克隆的仓库中的start/apps/chapter04/ng-animations-keyframes

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令来启动项目:

    npm run serve ng-animations-keyframes 
    

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

    图片

    图 4.3:ng-animations-keyframes 应用在 http://localhost:4200 上运行

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

如何做到这一点...

我们现在有一个应用,它有一个单一的过渡,即void => active,当元素进入 DOM 时触发。目前,动画非常简单。我们将使用keyframes函数来构建一个复杂动画:

  1. 让我们从向animations.ts文件添加@angular/animations中的keyframes函数开始,如下所示:

    import {
      ...,
      keyframes
    } from '@angular/animations';
    ... 
    
  2. 现在,我们将把void => transition的单样式动画转换为使用关键帧,如下所示:

    ...
    export const cardAnimation = trigger('cardAnimation', [
      ...,
      transition('void => *', [
        style({ // ← Remove this style
    transform: 'translateX(-200px)',
          opacity: 0
        }),
        animate('0.2s ease', **keyframes****([**
    **style****({**
    **transform****:** **'translateX(-200px)'****,**
    **offset****:** **0**
    **}),**
    **style****({**
    **transform****:** **'translateX(0)'****,**
    **offset****:** **1**
    **})**
    **]))**
      ]),
    ]) 
    

    注意,之前我们不得不定义初始样式和animate函数。现在我们可以在按时间顺序的keyframes函数内部定义相同的样式。如果你现在刷新应用并尝试,你仍然会看到之前的相同动画。但现在我们使用的是keyframes

  3. 最后,让我们开始添加一些复杂的动画。让我们通过在styletransform属性中添加scale3doffset: 0来以缩小的卡片开始动画。我们还将增加动画时间为1.5s

    ...
    export const cardAnimation = trigger('cardAnimation', [
      transition('void => active', [
        animate('**1.5s** ease', keyframes([
          style({
            transform: 'translateX(-200px)
    **scale3d(0.4,0.4,0.4)**',
            offset: 0
          }),
          style({...})
        ]))
      ]),
    ]) 
    

    你现在应该看到卡片动画从一个小的卡片开始,它从左侧滑行并移动到右侧,逐渐增大。

  4. 现在我们将实现一个类似“之字形”的动画来代替卡片出现的滑动动画。让我们向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:卡片向右弹跳然后撞到左侧墙壁

  5. 作为最后一步,我们在卡片返回原始位置之前将其顺时针旋转。为此,我们将使用offset: 0.75,结合rotate函数和一些额外的角度。代码应该如下所示:

    ...
    export const cardAnimation = trigger('cardAnimation', [
      transition('void => *', [
        animate('1.5s 0s ease', keyframes([
          style({...}),
          style({...}),
          style({...}),
          **style****({**
    **transform****:** **'translateX(-100px) rotate(135deg)**
    **translateY(0) scale3d(0.6, 0.6, 0.6)'****,**
    **offset****:** **0.75**
    **}),**
    style({...})
        ]))
      ]),
    ]) 
    

太棒了!你现在知道如何使用keyframes函数在 Angular 中实现复杂动画。你将在下一节中看到它是如何工作的。

它是如何工作的...

对于 Angular 中的复杂动画,@angular/animations包中的keyframes函数是提供动画整个旅程中不同时间偏移的绝佳方式。我们可以使用style函数来定义偏移量,它返回一个类型为AnimationStyleMetadata的对象。style函数接受标记作为输入,这些标记是一个键值对,其中键是字符串类型,值可以是字符串或数字。本质上,一个标记代表一个 CSS 属性。这允许我们传递offset属性,如菜谱中所示,其值介于01之间,反映了动画从0%100%的时间。因此,我们可以为不同的偏移量定义不同的样式来创建高级动画。

参见

使用交错动画在 Angular 中动画化列表

无论你今天构建什么类型的 Web 应用程序,你很可能会在其中实现某种类型的列表。为了使这些列表更加出色,为什么不给它们实现优雅的动画呢?在这个菜谱中,你将学习如何使用交错动画在 Angular 中动画化列表。

准备工作

我们将要工作的应用程序位于克隆的仓库start/apps/chapter04/ng-animating-lists中:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以提供项目:

    npm run serve ng-animating-lists 
    

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

    图片

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

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

如何做…

我们现在有一个应用程序,其中包含一个桶项目列表。我们需要使用交错动画来动画化这个列表。我们将一步步完成这个操作。我很兴奋——你呢?

太棒了。我们将按照以下步骤进行菜谱:

  1. 首先,让我们在src/app/app.config.ts文件中使用 Angular 的provideAnimations函数提供 Angular 动画,如下所示:

    ...
    **import** **{ provideAnimations }** **from****'@angular/platform-browser/animations'****;**
    import { appRoutes } from './app.routes';
    export const appConfig: ApplicationConfig = {
      providers: [
        provideRouter(appRoutes,
          withEnabledBlockingInitialNavigation()),
        **provideAnimations****()**
      ], 
    
  2. 现在,在项目的app文件夹中创建一个名为animations.ts的文件,并将以下代码添加到注册一个名为listItemAnimation的基本列表项动画中:

    import { trigger, style, animate, transition } from '@angular/animations';
    export const ANIMATIONS = {
      LIST_ITEM_ANIMATION: trigger('listItemAnimation', [
        transition(':enter', [
          style({ opacity: 0 }),
          animate('0.5s ease', style({ opacity: 1 })),
        ]),
        transition(':leave', [
          style({ opacity: 1 }),
          animate('0.5s ease', style({ opacity: 0 })),
        ]),
      ]),
    }; 
    
  3. 现在,我们将动画添加到app/bucket/bucket.component.ts文件中的BucketComponent,如下所示:

    ...
    **import** **{** **ANIMATIONS** **}** **from****'../../../constants/animations'****;**
    @Component({
      ...
      **animations****: [****ANIMATIONS****.****LIST_ITEM_ANIMATION****]**
    }) 
    

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

  4. 让我们在bucket.component.html文件中将动画添加到html元素,带有fruits__item类,如下所示:

    <div class="fruits__item" *ngFor="let item of bucket"
     **@****listItemAnimation**>
      ...
    </div> 
    

    如果你现在刷新应用程序并向桶列表中添加一个项目,你应该看到它以淡入效果出现。如果你删除一个项目,你应该看到它以动画消失。

  5. 我们现在将修改LIST_ITEM_ANIMATION以使用stagger函数。这是因为交错动画应用于列表,而不是列表项。首先,我们需要从@angular/animations中导入stagger函数。然后我们需要从触发器数组中删除所有内容,然后创建一个如下所示的列表通配符转换:

    import {
      ...,
      **stagger,**
    } from '@angular/animations';
    export const ANIMATIONS = {
      LIST_ITEM_ANIMATION: trigger('listItemAnimation', [
        **transition****(****'* <=> *'****, [**
    **// we'll add more code here**
    **]),**
      ]),
    }; 
    
  6. 现在,我们将添加一个查询,用于当列表中添加新项目时的情况。这里我们将使用交错动画。代码应该如下所示:

    import { trigger, style, animate, transition, stagger, **query** } from '@angular/animations';
    export const ANIMATIONS = {
      LIST_ITEM_ANIMATION: trigger('listItemAnimation', [
        transition('* <=> *', [
          **query****(**
    **':enter'****,**
    **[**
    **style****({** **opacity****:** **0** **}),**
    **stagger****(****100****, [**
    **animate****(****'0.5s ease'****,** **style****({** **opacity****:** **1** **}))**
    **]),**
    **],**
    **{** **optional****:** **true** **}**
    **),**
        ]),
      ]),
    }; 
    
  7. 现在我们将添加一个查询,用于当项目离开列表时的情况。代码应该如下所示:

    export const ANIMATIONS = {
      LIST_ITEM_ANIMATION: trigger('listItemAnimation', [
        transition('* <=> *', [
          query(':enter', [...],
            { optional: true }
          ),
          **query****(**
    **':leave'****,**
    **[**
    **style****({** **opacity****:** **1** **}),**
    **animate****(****'0.5s ease'****,** **style****({** **opacity****:** **0** **}))**
    **],**
    **{** **optional****:** **true** **}**
    **),**
        ]),
      ]),
    }; 
    
  8. 现在我们可以将动画应用到列表本身。按照以下方式更新bucket.component.html,将动画放置在具有fruits类的div上:

     ...
      <div class="fruits" *ngIf="$bucket | async as bucket"
    **[@****listItemAnimation****]=****"bucket.length"**>
        ...
    </div>
    ... 
    

    注意,我们将[@ listAnimationlistItemAnimation]属性绑定到bucket.length。这将确保动画在桶的长度改变时触发,即当向桶中添加或从桶中删除项目时。这是由于('* <=> *')转换。

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

它是如何工作的…

交错动画仅在query函数内部工作,并且应用于列表(包含项目)而不是项目本身。为了搜索或查询项目,我们首先使用query函数。然后我们使用stagger函数来定义在动画开始之前我们想要多少毫秒的交错。我们还在stagger函数中使用动画来定义查询中找到的每个元素的动画。请注意,我们在:enter查询和:leave查询中都使用了{ optional: true }。我们这样做是因为如果没有项目要动画化,无论是应用程序启动时还是所有项目都被删除时,Angular 都会抛出一个错误,因为它找不到可以动画化的内容。

参见

Angular 中的顺序与并行动画

在这个菜谱中,你将学习如何在 Angular 中按顺序运行动画与并行运行动画。这在我们需要在开始下一个动画之前完成一个动画,或者同时运行动画时非常有用。

准备工作

我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter04/ng-seq-parallel-animations目录下:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-seq-parallel-animations 
    

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

    图 4.6:ng-seq-parallel-animations 应用程序在 http://localhost:4200 上运行

    图 4.6:ng-seq-parallel-animations 应用程序在 http://localhost:4200 上运行

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

如何操作...

我们有一个应用程序,显示我们在前面的菜谱中使用的两个社交卡片。一个用于 Facebook,一个用于 Twitter。

为了同时按顺序和并行运行两张卡片上的动画,我们将使用query函数来按顺序配置动画。然后我们将使用group函数来并行运行它们。让我们开始吧:

  1. 首先,让我们在src/app/app.config.ts文件中使用 Angular 的provideAnimations函数提供 Angular 动画,如下所示:

    ...
    **import** **{ provideAnimations }** **from****'@angular/platform-browser/animations'****;**
    export const appConfig: ApplicationConfig = {
      providers: [
        provideRouter(appRoutes,
          withEnabledBlockingInitialNavigation())**,**
    **provideAnimations****()**
      ],
    }; 
    
  2. 我们将创建一个简单的包装转换来处理卡片进入和离开 DOM。之后,我们将处理当当前卡片离开视图时如何一起触发它们。在app文件夹中创建一个名为animations.ts的新文件。将以下代码添加到其中:

    import { trigger, style, transition, animate, query, group, keyframes } from '@angular/animations';
    const duration = '1.5s';
    export const cardAnimation = trigger('cardAnimation', [
      transition('* <=> *', [
        // more code here later
      ]),
    ]); 
    
  3. 现在,让我们为卡片离开视图时添加一个查询。在transition数组内部,按照以下方式添加以下query

    export const cardAnimation = trigger('cardAnimation', [
      transition('* <=> *', [
         **query****(** **':leave'****, [**
    **style****({** **transform****:** **'translateX(0)'****,** **opacity****:** **1** **}),**
    **animate****(** **`****${duration}** **ease`****,** **style****({**
    **transform****:** **'translateX(100%)'****,**
    **})**
    **),**
    **animate****(** **`****${duration}** **ease`****,** **style****({**
    **opacity****:** **0****,**
    **})**
    **),**
    **],**
    **{** **optional****:** **true** **}**
    **)**
      ]),
    ]); 
    
  4. 我们将在app.component.ts文件中导入动画并将其添加到animations数组中,如下所示:

    ...
    **import** **{ cardAnimation }** **from****'./animation'****;**
    ...
    @Component({
      ...
      **animations****: [cardAnimation],**
      ...
    })
    export class AppComponent {...} 
    
  5. 现在,我们将更新app.component.html文件以使用具有card-container类的元素的动画。按照以下方式更新文件:

    ...
    <main>
      …
      <div class="card-container relative h-[600px] w-full 
    overflow-hidden py-4" 
    **[@****cardAnimation****]=****"selectedCardType"**>
        ...
      </div>
    </main> 
    

    你应该可以通过点击 Facebook 和 Twitter 按钮看到动画了。也就是说,卡片从屏幕右侧的位置滑动到当前位置。然而,它看起来并不漂亮。

  6. 让我们为下一张卡片进入视图时添加另一个查询。我们首先确保卡片在开始进入 DOM 时是不可见的。按照以下方式替换animations.ts文件中的动画:

    ...
    export const cardAnimation = trigger('cardAnimation', [
      transition('* <=> *', [
        **query****(****':enter'****, [**
    **style****({** **opacity****:** **0** **}),**
    **]),**
    query( ':leave', [
          ...
        ]),
    ]); 
    
  7. 现在为要进入屏幕的卡片添加第二个query。我们将确保它从左侧滑入并缓慢变得可见。按照以下方式更新animations.ts文件:

    ...
    export const cardAnimation = trigger('cardAnimation', [
      transition('* <=> *', [    query(':enter', [...]),
        query(':leave', [...]),
        **query****(** **':enter'****, [**
    **style****({**
    **transform****:** **'translateX(-100%)'****,**
    **opacity****:** **0****,**
    **}),**
    **animate****(** **`****${duration}** **ease`****,** **style****({**
    **transform****:** **'translateX(0)'****,**
    **opacity****:** **1****,**
    **})**
    **),**
    **],**
    **{** **optional****:** **true** **}**
    **),**
      ]),
    ]); 
    

    你会注意到动画现在正在工作。然而,它们真的很慢。也就是说,在当前卡片离开屏幕后,下一张卡片需要很长时间才能出现。这是因为它们都是按顺序运行的。

  8. 我们可以将第二个和第三个查询包裹在group函数中,以并行运行它们。按照以下方式更新animations.ts文件中的代码:

    import { ..., **group** } from '@angular/animations';
    export const cardAnimation = trigger('cardAnimation', [
      transition('* <=> *', [
        query(':enter', [...]),
        **group****([**
    query( ':leave', [...], { optional: true }),
          query(':enter', [...], { optional: true }),
        **]),**
      ]),
    ]); 
    

然后,砰!你现在可以看到动画正在并行运行,并且在执行:enter转换之前不会等待:leave转换完成。

它是如何工作的...

在 Angular 中,动画默认按顺序运行。如果一个转换有多个步骤,即styleanimate用法,动画将按顺序运行。group函数使我们能够并行运行动画。对于这个菜谱,我们希望:enter:leave转换同时运行,所以我们把它们组合起来并行运行。

参见

Angular 中的路由动画

在这个菜谱中,你将学习如何在 Angular 中实现路由动画。你将学习如何通过将过渡状态名称作为数据属性传递给路由来配置路由动画。你还将学习如何使用RouterOutlet API 获取过渡名称并将其应用于要执行的动画。我们将实现一些 3D 过渡,这将很有趣!

准备中

我们将要工作的应用位于克隆的仓库中的start/apps/chapter04/ng-route-animations

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目:

    npm run serve ng-route-animations 
    

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

    图 4.7:ng-route-animations 应用在 http://localhost:4200 上运行

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

如何做到这一点…

我们现在有一个简单的应用,包含两个懒加载的路由。这些路由是针对主页关于页面的,我们现在将开始配置应用的动画:

  1. 首先,让我们在src/app/app.config.ts文件中使用 Angular 的provideAnimations函数提供 Angular 动画,如下所示:

    ...
    **import** **{ provideAnimations }** **from****'@angular/platform-browser/animations'****;** 
    export const appConfig: ApplicationConfig = {
      providers: [
        provideRouter(appRoutes,
          withEnabledBlockingInitialNavigation())**,**
    **provideAnimations****()**
      ],
    }; 
    
  2. 我们现在将在app文件夹内创建一个名为animations.ts的新文件。让我们将以下代码放入animations.ts文件中,以注册一个基本的触发器来处理从路由到其他所有路由的动画:

    import {trigger,  transition } from '@angular/animations';
    export const ROUTE_ANIMATION = trigger('routeAnimation', [
      transition('* <=> *', [
        // states and transitions to be added here
      ])
    ]); 
    
  3. 我们现在将为动画注册一些查询和基本状态。让我们按照以下方式在transition函数的数组中添加以下项:

    import { trigger, transition, **style**, **query** } from '@angular/animations';
    **const** **optional = {** **optional****:** **true** **};**
    export const ROUTE_ANIMATION = trigger('routeAnimation', [
      transition('* <=> *', [
        **style****({** **position****:** **'relative'****,**
    **perspective****:** **'1000px'** **}),**
    **query****(**
    **':enter, :leave'****,**
    **[****style****({** **position****:** **'absolute'****,** **width****:** **'100%'** **})],**
    **optional**
    **),**
      ]),
    ]); 
    

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

  4. 我们可以使用每个路由的唯一标识符来提供过渡状态。有许多方法可以做到这一点,但最简单的方法是在app.routes.ts文件中使用路由配置中的data属性来提供它们,如下所示:

    export const appRoutes: Route[] = [
      ...,
      {
        path: 'home',
       **data****: {** **transitionState****:** **'HomePage'** **},**
    loadComponent: () =>
    import('./home/home.component').then(
            (m) => m.HomeComponent),
      },
      {
        path: 'about',
        **data****: {** **transitionState****:** **'****AboutPage'** **},**
    loadComponent: () =>
    import('./about/about.component').then(
            (m) => m.AboutComponent),
      },
    ]; 
    
  5. 现在,我们需要在app.component.html文件中提供这个transitionState属性,从当前路由到路由动画触发器。为此,在app.component.ts文件中创建一个@ViewChild属性。这个ViewChild将针对app.component.html模板中的<router-outlet>元素。这样我们就可以获取当前路由的data和提供的transitionState值。app.component.ts文件中的代码应该如下所示:

    import { CommonModule } from '@angular/common';
    import { Component, **ViewChild**} from '@angular/core';
    import { RouterModule, **RouterOutlet**} from '@angular/router';
    export class AppComponent {
      **@****ViewChild****(****RouterOutlet****) routerOutlet!:** **RouterOutlet****;**
    } 
    
  6. 我们还将从animations.ts文件中导入ROUTE_ANIMATIONapp.component.ts中,如下所示:

    ...
    **import** **{** **ROUTE_ANIMATION** **}** **from****'./animations'****;**
    @Component({
      selector: "app-root",
      templateUrl: "./app.component.html",
      styleUrls: ["./app.component.scss"],
      **animations****: [**
    **ROUTE_ANIMATION**
    **]**
    }) 
    

    我们现在将创建一个名为getRouteAnimationState的方法,该方法将获取当前路由的数据和transitionState值,并返回它。这个函数将在app.component.html中使用。按照以下方式修改你的app.component.ts代码:

    ...
    @Component({
    ...
    })
    export class AppComponent {
      @ViewChild(RouterOutlet) routerOutlet!: RouterOutlet;
      **getRouteAnimationState****() {**
    **return** **(**
    **this****.****routerOutlet** **&&**
    **this****.****routerOutlet****.****activatedRouteData** **&&**
    **this****.****routerOutlet****.****activatedRouteData****[****'transitionState'****]**
    **);**
    **}**
    } 
    
  7. 最后,让我们在app.component.html中使用getRouteAnimationState方法和@routeAnimation触发器,以便我们可以看到动画的播放效果:

    ...
    <div class="content" role="main">
    <div class="router-container"
     **[@****routeAnimation****]=****"getRouteAnimationState()"**>
    <router-outlet></router-outlet>
    </div>
    </div> 
    
  8. 现在我们已经设置好了一切,让我们最终确定动画。我们将为路由离开视图添加一个查询。更新animations.ts文件如下:

    import { trigger, style, transition, query, **animate, keyframes** } from '@angular/animations';
    ...
    export const ROUTE_ANIMATION = trigger('routeAnimation', [
      transition('* <=> *', [
        style({ position: 'relative', perspective: '1000px' }),
        query( ':enter, :leave', [...] ,optional),
        **query****(** **':leave'****, [**
    **animate****(** **'1s ease-in'****,** **keyframes****([**
    **style****({** **opacity****:** **1****,** **offset****:** **0****,**
    **transform****:** **'rotateY(0) translateX(0)**
    **translateZ(0)'****,**
    **}),**
    **style****({** **offset****:** **0.25****,** **transform****:**
    **'rotateY(45deg) translateX(25%)**
    **translateZ(100px) translateY(5%)'****,**
    **}),**
    **style****({** **offset****:** **0.5****,** **transform****:**
    **'rotateY(90deg) translateX(75%)**
    **translateZ(400px) translateY(10%)'****,**
    **}),**
    **style****({** **offset****:** **0.75****,** **transform****:**
    **'rotateY(135deg) translateX(75%)**
    **translateZ(800px) translateY(15%)'****,**
    **}),**
    **style****({** **opacity****:** **0****,** **offset****:** **1****,** **transform****:**
    **'rotateY(180deg) translateX(0)**
    **translateZ(1200px) translateY(25%)'****,**
    **}),**
    **])**
    **),**
    **],**
    **optional**
    **),**
      ]),
    ]); 
    

    如果你在不同路由之间导航,你会注意到离开路由在进入路由之后以动画形式退出。让我们也为进入路由添加动画。

  9. 我们将为进入视图的路由添加动画。更新animations.ts如下:

    ...
    export const ROUTE_ANIMATION = trigger('routeAnimation', [
      transition('* <=> *', [
        style({ position: 'relative', perspective: '1000px' }),
        query(':enter, :leave', ...),
        query(':leave', ...),
        **query****(** **':enter'****, [**
    **animate****(** **'****1s ease-out'****,** **keyframes****([**
    **style****({** **opacity****:** **0****,** **offset****:** **0****,** **transform****:**
    **'rotateY(180deg) translateX(25%)** **translateZ(1200px)'****,**
    **}),**
    **style****({** **offset****:** **0.25****,** **transform****:**
    **'rotateY(225deg) translateX(-25%) translateZ(1200px)'****,**
    **}),**
    **style****({** **offset****:** **0.5****,** **transform****:**
    **'rotateY(270deg) translateX(-50%) translateZ(400px)'****,**
    **}),**
    **style****({** **offset****:** **0.75****,** **transform****:**
    **'rotateY(315deg) translateX(-50%) translateZ(25px)'****,**
    **}),**
    **style****({** **opacity****:** **1****,** **offset****:** **1****,** **transform****:**
    **'rotateY(360deg) translateX(0) translateZ(0)'****,**
    **}),**
    **])**
    **),**
    **],**
    **optional**
    **),**
      ]),
    ]); 
    

    如果你查看导航时的动画路由,你会注意到进入路由立即出现,然后我们看到离开路由的动画,之后我们看到进入路由的动画。让我们将进入和离开动画组合在一起,以并行运行它们。

  10. 更新animations.ts如下,以并行运行进入和离开路由的动画:

    import {..., **group** } from '@angular/animations';
    export const ROUTE_ANIMATION = trigger('routeAnimation', [
      transition('* <=> *', [
        style({ position: 'relative', perspective: '1000px' }),
        query( ':enter, :leave', ...),
        **group****([**
    query( ':leave', [...], optional ),
          query( ':enter', [...], optional),
        **]),**
      ]),
    ]); 
    

哇!刷新应用,看看魔法。现在,当你从主页导航到关于页面,反之亦然时,你应该会看到进入和离开路由的 3D 动画。在 Angular 中使用关键帧和动画,你可以做到的事情没有极限。

它是如何工作的…

animations.ts文件中,我们首先定义了一个名为routeAnimation的动画触发器。然后我们确保默认情况下,触发器分配的 HTML 元素具有position: 'relative'样式:

transition('* <=> *', [
    **style****({**
**position****:** **'relative'**
**}),**
    ...
]) 

然后,我们按照所述,使用:enter:leave将样式position: 'absolute'应用到子元素上:

 query(':enter, :leave', [
      style({
        **position****:** **'absolute'****,**
width: '100%'
      })
    ], {optional: true}), 

这样确保这些元素,即要加载的路由,具有position: 'absolute'样式和全宽使用width: '100%',以便它们可以相互叠加。你可以通过注释其中任何一个样式来随意尝试,看看会发生什么(尽管这样做有风险!)。

然后,我们定义了我们的路由转换,作为两个动画的组合,第一个是query :leave,第二个是query :enter。对于离开视图的路由,我们通过动画将opacity设置为0,而对于进入视图的路由,我们也通过动画将opacity设置为1。请注意,Angular 动画的动画是按顺序运行的:

 query(':leave', [
      ...
    ], {optional: true}),
    query(':enter', [
      ...
    ], {optional: true}), 

你会注意到在我们的代码中,我们正在使用 keyframes 函数进行动画。对于离开路由,"keyframes 函数" 从 opacity 1 开始,最初没有任何变换。然后它结束于 opacity 0,但变换设置为 'rotateY(180deg) translateX(0) translateZ(1200px) translateY(25%)'。这与进入路由相反。

最后,我们使用 group 函数将离开和进入动画一起包裹起来,这样它们可以并行运行而不是按顺序运行。这使得进入路由在离开路由消失时进入。

参见

有条件地禁用 Angular 动画

在这个菜谱中,你将学习如何在 Angular 中有条件地禁用动画。这在各种情况下都很有用,例如在特定设备上禁用动画。

小贴士:使用 ngx-device-detector 来识别你的 Angular App 是否在手机、平板电脑等设备上运行(一个不再是秘密的秘密……我创建了它!)

不再是秘密的推广,在这个菜谱中,我们将禁用应用程序中员工的动画,考虑到我们目前只对管理员推出动画。

准备工作

我们将要工作的 App 位于克隆的仓库中的 start/apps/chapter04/ng-disable-animations 目录内:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令来运行项目:

    npm run serve ng-disable-animations 
    

    这应该在新的浏览器标签页中打开 App。以管理员身份登录,添加一些 bucket 项目,你应该会看到以下内容:

    图 4.8:ng-disable-animations App 在 http://localhost:4200 上运行

现在我们已经让 App 运行起来,我们将继续进行下一步骤。

如何做到这一点…

我们有一个已经配置了一些 Angular 动画的 App。你会注意到管理员和员工页面都启用了动画。我们将使用一个 config 来禁用员工页面的动画。接下来,我们按照以下步骤继续操作:

  1. 首先,我们将在 src/app/app.config.ts 文件中为我们的 IEmployeeConfig 接口和 EMPLOYEE_CONFIG 变量添加一个名为 disableAnimations 的新属性,如下所示:

    ...
    export interface IEmployeeConfig {
      canDeleteItems: boolean;
    **disableAnimations****: boolean;**
    }
    ...
    export const employeeConfig: IEmployeeConfig = {
      canDeleteItems: true,
      **disableAnimations****:** **false**
    };
    ... 
    

    如果你保存文件,TypeScript 将在控制台中开始抛出错误,因为我们还需要在 employee.config.ts 文件中添加相同的 disableAnimations 属性。

  2. 按照以下方式更新 src/app/employee/employee.config.ts 文件:

    import { IEmployeeConfig } from '../app.config';
    
    export const employeeConfig: IEmployeeConfig = {
      canDeleteItems: false,
      **disableAnimations****:** **true**
    }; 
    
  3. 最后,在 bucket.component.ts 中添加一个 HostBinding 来根据配置禁用 bucket 组件中的动画。更新 bucket/bucket.component.ts 文件如下:

    import { CommonModule } from '@angular/common';
    import { Component, inject, OnInit, **HostBinding** } from '@angular/core';
    ...
    @Component({...})
    export class BucketComponent implements OnInit {
      ...
      fruits: string[] = Object.values(Fruit);
      **@****HostBinding****(****'@.disabled'****)**
    **animationsDisabled =** **this****.****appConfig****.****disableAnimations****;**
    ngOnInit(): void {...}
            ...
    } 
    

太好了!如果你现在刷新应用程序并查看 Admin 页面,你会看到动画正在工作。如果你转到 Employee 页面,你会看到那里的动画被禁用了。魔法!查看下一节以了解配方是如何工作的。

它是如何工作的...

Angular 提供了一种使用 [@.disabled] 绑定来禁用动画的方法。你可以在模板的任何位置放置一个表达式,该表达式评估一个布尔值。在这种情况下,所有在其嵌套 HTML 树中应用的子动画都将被禁用。我们有一个应用程序级别的配置,该配置通过 EmployeeConfig 对象在 employee 组件中被覆盖。因此,我们首先在 IAppConfig 接口中创建了一个 disableAnimations 属性。此接口由 app-config.ts 文件中的 AppConfig 变量和 employee.config.ts 文件中的 employeeConfig 变量使用。正如你所见,我们将 disabledAnimations 的值设置为 false,用于 app.config.ts 中定义的配置,以及 true,用于 employee.config.ts 中定义的配置。然后,我们在 BucketComponent 类中使用 @HostBinding() 装饰器,通过将其值分配给提供的配置的 disabledAnimations 属性。由于 AdminComponent 类通过 EMPLOYEE_CONFIG 标记获取 app.config.ts 中定义的配置,而 EmployeeComponent 类通过 EMPLOYEE_CONFIG 标记获取 employee.config.ts 中定义的配置,因此这些组件的动画分别被启用和禁用。

参考以下内容

在 Discord 上了解更多

要加入此书的 Discord 社区——在那里你可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:

packt.link/AngularCookbook2e

二维码

第五章:Angular 和 RxJS – 强强联合

AngularRxJS 构成了一个令人惊叹的强大组合。通过结合这些技术,你可以在 Angular 应用程序中以响应式的方式处理数据,处理流,并在 Angular 应用程序中实现复杂的企业逻辑。这正是本章将要介绍的内容。

本章我们将介绍以下食谱:

  • 在 Angular 中使用 RxJS 进行顺序和并行 HTTP 请求

  • 监听多个可观察流

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

  • 使用 Angular 的 async 管道自动取消订阅流

  • 使用 map 操作符转换数据

  • 使用 switchMapdebounceTime 操作符与自动完成功能以获得更好的性能

  • 创建自定义 RxJS 操作符

  • 使用 RxJS 重试失败的 HTTP 请求

技术要求

对于本章的食谱,确保你的设置已按照 'Angular-Cookbook-2E' GitHub 仓库中的 'Technical Requirements'(技术要求)完成。有关设置详情,请访问:github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/docs/technical-requirements.md。本章的起始代码位于 github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/start/apps/chapter05

在 Angular 中使用 RxJS 进行顺序和并行 HTTP 请求

在这个食谱中,你将学习如何使用不同的 RxJS 操作符在 Angular 应用程序中进行顺序和并行 HTTP 请求。我们将使用著名的星球大战 API(swapi)获取一些数据以在 UI 上显示。

准备工作

我们将要工作的应用程序位于克隆的仓库中的 start/apps/chapter05/rx-seq-parallel-http

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目:

    npm run serve rx-seq-parallel-http 
    

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

    图 5.1:运行在 http://localhost:4200 的 rx-seq-parallel-http 应用程序

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

如何做到这一点…

我们有一个使用 Star Wars API (swapi) 从星球大战中获取人物及其参与的电影的 Angular 应用程序。所有这些操作都通过大量的 HTTP 请求完成,而我们的代码到目前为止完全是垃圾。这是因为我们首先显示加载器,但在我们检索所有数据之前就将其隐藏了。此外,如果你不断刷新页面,你会看到电影序列每次都会改变。因此,我们看到了 UI 跳动很多。我们希望的方法是先获取人物,然后获取所有电影,然后隐藏加载器。我们将使用 RxJS 实现这种方法。让我们开始吧:

  1. 首先,我们将避免使用setTimeout函数,而是依赖于在1500ms内获取到人员数据。我们更愿意将此操作移至subscribe块内部,并且也会适当地处理错误。按照以下方式更新app.component.ts中的fetchData方法:

    fetchData() {
        this.loadingData = true;
        this.swapi.fetchPerson('1').subscribe({
          next: (person) => {
              this.person = person;
              this.person.filmObjects = [];
              this.person.films.forEach((filmUrl) => {
                this.swapi.fetchFilm(filmUrl).subscribe({
                  next: (film) => {
                    this.person.filmObjects.push(film);
                    this.loadingData = false;
                  },
                  error: (err) => {
                    console.error('Error while fetching film',
                      err);
                  },
                });
              });
          },
          error: (err) => {
            console.error('Error while fetching person', err);
          }
        });
      } 
    

    这存在一个潜在问题。那就是,一旦检索到第一部电影,加载器就会隐藏,因为我们把this.loadingData设置为false

  2. 现在,我们将使用pipe方法添加mergeMap操作符,以便以后能够链式调用。目前,我们只需将添加filmObjects数组到this.person对象的代码移至mergeMap回调中。现在按照以下方式更新fetchData方法:

    ...
    **import** **{ mergeMap,** **of** **}** **from****'****rxjs'****;**
    ...
      fetchData() {
        this.loadingData = true;
        this.swapi
          .fetchPerson('1')
          .pipe(
            **mergeMap****((person) => {**
    **const** **personObj = {**
    **...person,**
    **filmObjects****: [],**
    **};**
    **return** **of****(personObj);**
    **})**
    **)**
          .subscribe({
            next: (person) => {
              this.person = person;
              this.person.films.forEach((filmUrl) => {
                this.swapi.fetchFilm(filmUrl).subscribe({
                  next: (film) => {
                    this.person.filmObjects.push(film);
                    this.loadingData = false;
                  },
                  error: (err) => {
                    console.error('Error while fetching film', err);
                  },
                });
              });
            },
            error: (err) => {
              console.error('Error while fetching person', err);
            },
          });
      } 
    

    注意到 UI 变得稍微好一些。加载器仍然会在从服务器检索到任何一部电影后立即隐藏。然而,我们希望在所有电影都检索完毕后隐藏加载器。此外,电影的顺序仍然不可预测。

  3. 现在,我们将使用forkJoin函数并行地对电影进行 API 调用,并等待合并后的响应。我们这样做而不是使用of操作符,因为of操作符只是从mergeMap函数传递电影 URL。按照以下方式更新fetchData方法,并更新顶部的导入:

    import { **forkJoin,** mergeMap, of **(//<-- remove of)** } from 'rxjs';
    ...
      fetchData() {
        this.loadingData = true;
        this.swapi
          .fetchPerson('1')
          .pipe(
            mergeMap((person) => {
              const personObj = {
                ...person,
                filmObjects: [],
              };
              this.person = personObj;
              **return** **forkJoin****(**
    **this****.person.films.****map****((filmUrl) =>**
    **this****.swapi****.****fetchFilm****(filmUrl))**
    **);**
            }),
            catchError((err) => {
              console.error('Error while fetching films', err);
              alert('Could not get films. Please try again.');
              return of([]);
            })
          )
          .subscribe({
            next: (films) => {
              this.person.filmObjects = films;
              this.loadingData = false;
            },
            error: (err) => {
              console.error('Error while fetching person', err);
            },
          });
      } 
    

哇哦!现在如果你刷新应用,你会注意到两件事。首先,加载器只有在所有数据都检索完毕后才会停止。其次,电影的顺序总是相同(并且正确)。

现在你已经完成了食谱,让我们继续到下一部分,了解这一切是如何工作的。

它是如何工作的...

mergeMap 操作符允许我们通过从其回调中返回一个 可观察对象 来链式连接可观察对象。您可以将它想象成我们链式调用 Promise.then,但这是针对可观察对象的。一个流行的替代方案是 switchMap 操作符,它的工作方式类似于 mergeMap 操作符,但在第一次调用/执行完成之前被调用两次或更多次时,也会取消之前的调用/执行。我们首先移除了 setTimeout 函数(将这些情况放入代码中通常没有意义,因为结果在时间上并不总是可预测的),并将获取人物信息的逻辑移动到获取人物信息的 subscribe 块中。我们还使用了 of 操作符从 mergeMap 函数的回调中返回 personObject 对象。mergeMap 函数用于将可观察对象链式连接起来,在我们的上下文中,它可以链式等待一个 HTTP 调用完成,以便我们可以执行其他的调用。在 步骤 3 中,我们打算并行执行所有人物的电影的多个 HTTP 调用。我们使用 forkJoin 操作符来完成这项工作,它接受一个可观察对象的数组。在这种情况下,这些可观察对象是针对每部电影的 HTTP 调用。forkJoin 还使得等待所有并行调用完成并触发 subscribe 块的回调成为可能。forkJoin 还做的一件事是,它以与可观察对象相同的顺序提供响应的数组形式给我们。这使得响应可预测,并且我们总是在 UI 上显示相同的数据。

参见

监听多个可观察流

在这个菜谱中,我们将使用 combineLatest 操作符一次性监听多个可观察流。使用此操作符将导致输出为一个数组,合并所有流。当您希望从所有流中获取最新的输出并合并到一个订阅中时,这种方法是合适的。

准备中

我们将要工作的应用程序位于克隆的仓库中的 start/apps/chapter05/rx-multiple-streams 目录下:

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve rx-multiple-streams 
    

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

    图片 B18469_05_02

    图 5.2:在 http://localhost:4200 上运行的 rx-multiple-streams 应用程序

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

如何做到这一点...

对于这个食谱,我们有一个显示盒子的应用。这个盒子有一个大小(宽度和高度)、边框半径、背景颜色和文本颜色。它还有四个使用Reactive Forms API 来修改所有这些因素的输入。目前,即使输入发生变化,我们也必须手动点击按钮来应用更改。如果我们能够订阅输入的变化并立即更新盒子,而不需要用户点击按钮,那会怎么样?这正是我们要在这里做的:

  1. 我们将首先创建一个名为listenToInputChanges的方法。我们将创建一个我们想要工作的控件数组。更新home.component.ts的代码,如下所示:

    ...
    export class HomeComponent implements OnInit {
      ...
      ngOnInit() {
        this.applyChanges();
      }
      **listenToInputChanges****() {**
    **const****controls****:** **AbstractControl****[] = [**
    **this****.****boxForm****.****controls****.****size****,**
    **this****.****boxForm****.****controls****.****borderRadius****,**
    **this****.****boxForm****.****controls****.****textColor****,**
    **this****.****boxForm****.****controls****.****backgroundColor****,**
    **];**
    **}**
      ...
    } 
    
  2. 现在,我们将遍历控件,给它们赋予初始值,这样当 Observable 流被订阅时,它们就有值可以工作了。进一步更新listenToInputChanges方法,如下所示:

    **import** **{ startWith }** **from****'rxjs'****;**
    ...
    export class HomeComponent implements OnInit {
    ...
    listenToInputChanges() {
        const controls: AbstractControl[] = [...];
        controls.map((control) =>
          control.valueChanges.pipe(**startWith****(control.value)**)
        );
      }
    } 
    
  3. 现在,我们将用名为boxStyles$Observable替换boxStyles属性。然后,我们将每个表单控制的valueChanges流包裹在combineLatest操作符中,以将它们连接起来。最后,我们将连接流的输出分配给boxStyles$Observable。更新home.component.ts文件,如下所示:

    ...
    import { **Observable**, **combineLatest**, startWith} from 'rxjs';
    ...
    export class HomeComponent implements OnInit, OnDestroy {
      ...
      **boxStyles$!:** **Observable****<****BoxStyles****>;**
            ...
      listenToInputChanges() {
        **this****.****boxStyles$** **=** **combineLatest****(**
          controls.map((control) =>
            control.valueChanges.pipe(startWith(control.value))
          );
        **);**
    **}**
      ...
    } 
    
  4. 现在我们将在组合流上使用map操作符和pipe来将其映射到BoxStyle类型值。更新home/home.component.ts文件中的listenToInputChanges方法,如下所示:

    import { combineLatest, **map**, Observable, startWith } from 'rxjs';
    export class HomeComponent implements OnInit {
      listenToInputChanges() {
        const controls: AbstractControl[] = [...];
        this.boxStyles$ = combineLatest(...)**.****pipe****(**
    **map****(****(****[size, borderRadius, textColor,**
    **backgroundColor]****) =>** **{**
    **return** **{** **width****:** **`****${size}****px`****,** **height****:** **`****${size}****px`****,**
    **backgroundColor****: backgroundColor,**
    **color****: textColor,**
    **borderRadius****:** **`****${borderRadius}****px`****,**
    **};**
    **})**
    **);**
      }
    } 
    
  5. 我们需要从home.component.ts文件中移除setBoxStylesapplyChanges方法以及applyChanges方法的用法。更新文件,如下所示:

    export class HomeComponent implements OnInit {
      ...
      ngOnInit() {
        **this****.****listenToInputChanges****();** **//← Add this call**
        ...
        **this****.****applyChanges****();** **//← Remove this call**
    **}**
    **...**
    **setBoxStyles****(****...****) {...}** **//← Remove this method**
    **applyChanges****() {...}** **//← Remove this method**
      ...
    } 
    
  6. 我们还需要从模板中移除applyChanges方法的用法。从home.component.html文件中的<form>元素移除(ngSubmit)处理器,使其看起来像这样:

    <div class="home" [formGroup]="boxForm"
     **(****ngSubmit****)=****"applyChanges()"****<!--← Remove this-->**
      ...
    </div> 
    
  7. 我们还需要从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> 
    
  8. 现在我们可以使用boxStyles$Observable 了,让我们在模板中使用它,即home.component.html文件,而不是boxStyles属性:

     ...
      **<****div****class****=****"row"** *******ngIf****=****"boxStyles$ | async as**
    **boxStyles"****>**
    **<****div****class****=****"box"** **[****ngStyle****]=****"boxStyles"****>**
    **<****div****class****=****"box__text"****>**
    **Hello World!**
    **</****div****>**
    **</****div****>**
    **</****div****>**
      ... 
    

哇!如果你刷新应用,你应该能看到带有默认样式的盒子出现。如果你更改了任何选项,你也会看到相应的变化。

恭喜你完成了这个食谱。你现在已经是使用combineLatest操作符处理多个流的专家了。查看下一节以了解它是如何工作的。

它是如何工作的...

Reactive Forms 的美丽之处在于,它们比常规的 ngModel 绑定或模板驱动的表单提供了更多的灵活性。对于每个表单控件,我们可以订阅其 valueChanges 可观察对象,每当输入改变时,它都会接收到一个新的值。因此,我们不需要依赖于 提交 按钮的点击,而是直接订阅每个 表单控件valueChanges 属性。在常规场景中,这会导致四个不同的流对应四个输入,这意味着我们需要处理四个订阅并确保取消订阅它们。这就是 combineLatest 操作符发挥作用的地方。我们使用了 combineLatest 操作符将这四个流合并为一个,这意味着我们只需要在组件销毁时取消订阅一个流。但是,嘿!记得如果我们使用 async 管道,我们就不需要这样做吗?这正是我们做的。我们从 home.component.ts 文件中移除了订阅,并使用 pipe 方法与 map 操作符。map 操作符根据我们的需求转换数据,然后将转换后的数据返回设置到 boxStyles$ 可观察对象。最后,我们在模板中使用 async 管道订阅 boxStyles$ 可观察对象,并将其值作为 [ngStyle] 分配给我们的盒子元素。由于 valueChanges 是一个 Subject 而不是一个 ReplaySubject,我们还通过 startWithvalueChanges 管道化,以提供一个初始值。如果我们不使用 startWith,盒子将不会显示,除非所有输入至少手动更改一次值。试试看!

参见

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

流式处理很有趣,它们很棒。当你完成这一章时,你会对 RxJS 和流有更多的了解。一个现实是,当不小心使用流时,会遇到一些未预见的问题。使用流时犯的最大错误之一是在不再需要它们时没有取消订阅,在这个菜谱中,你将学习如何取消订阅流以避免 Angular 应用中的内存泄漏。

准备工作

我们将要工作的应用程序位于克隆的仓库中的 start/apps/chapter05/rx-unsubscribing-streams

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve rx-unsubscribing-streams 
    

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

    图片

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

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

如何做到这一点…

我们目前有一个有两个路由的应用——即主页关于。这是为了向你展示未处理的订阅可能会在应用中引起内存泄漏。默认路由是主页,在HomeComponent类中,我们使用interval操作符函数处理一个输出数据的流:

  1. 点击开始流按钮,你应该会看到流正在发出值。

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

    你看到什么奇怪的吗?没有?一切看起来都正常,对吧?嗯,并不完全是这样。

  3. 为了查看我们是否有未处理的订阅,让我们在home.component.ts文件中的startStream方法内放置console.log——具体来说,在subscribe函数的块内,如下所示:

    ...
    export class HomeComponent implements OnInit {
      ...
      startStream() {
        const streamSource = interval(1500);
        this.subscription = streamSource.subscribe((input) => {
          this.outputStreamData.push(input);
          **console****.****log****({ input });**
        });
      }
      stopStream() {...}
    } 
    

    如果你现在按照步骤 1 中提到的步骤操作,你将在控制台上看到以下输出,如图图 5.4所示:

    ![img/B18469_05_04.png]

    图 5.4:在关于页面上间隔发出值

    想要更多乐趣吗?尝试多次执行步骤 1,甚至一次都不刷新页面。你将看到的将是混乱!

  4. 因此,为了解决这个问题,我们将使用最简单的方法——即在用户离开路由时取消订阅流。让我们为它实现ngOnDestroy生命周期方法,如下所示:

    import { Component, **OnDestroy** } from '@angular/core';
    ...
    @Component({...})
    export class HomeComponent implements **OnDestroy** {
      ...
      startStream() {...}
      **ngOnDestroy****() {**
    **this****.****stopStream****();**
    **}**
    stopStream() {...}
    } 
    

太好了!如果你再次按照步骤 1 的说明操作,你会发现一旦你离开主页页面,控制台上就没有进一步的日志了,而且我们的应用现在没有未处理的流导致内存泄漏。阅读下一节以了解它是如何工作的。

它是如何工作的…

当我们创建一个Observable/stream并订阅它时,RxJS 会自动将我们提供的subscribe函数块作为处理程序添加到Observable。所以,每当Observable发出值时,我们的方法都应该被调用。有趣的部分是,Angular 不会在组件卸载或你离开路由时自动销毁那个订阅/处理程序。这是因为可观察的核心是RxJS,而不是 Angular;因此,这不是 Angular 的责任来处理它。

Angular 提供了一些生命周期方法,我们使用了OnDestroy (ngOnDestroy)方法。因此,我们使用了ngOnDestroy方法来调用stopStream方法,以便在用户离开页面时立即销毁订阅。这是可能的,因为当我们离开一个路由时,Angular 会销毁该路由,因此我们可以执行我们的stopStream方法。

还有更多…

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

startStream() {
    const streamSource = interval(1500);
    **const** **secondStreamSource =** **interval****(****3000****);**
**const** **fastestStreamSource =** **interval****(****500****);**
    streamSource.subscribe((input) => {
      this.outputStreamData.push(input);
      **console****.****log****(****'first stream output'****, input);**
    });
    **secondStreamSource.****subscribe****(****input** **=>** **{**
**this****.****outputStreamData****.****push****(input);**
**console****.****log****(****'second stream output'****, input)**
**});**
**fastestStreamSource.****subscribe****(****input** **=>** **{**
**this****.****outputStreamData****.****push****(input);**
**console****.****log****(****'fastest stream output'****, input)**
**});**
  }
  stopStream() {
    **// remove code from here**
  } 

注意,我们不再将 streamSource 中的 订阅 保存到 this.subscription 中,并且也从 stopStream 方法中移除了代码。这样做的原因是我们没有为每个订阅设置单独的属性/变量。相反,我们将有一个单独的变量来处理。让我们看看以下步骤来开始操作:

  1. 首先,我们在 HomeComponent 类中创建一个名为 isStreamActive 的属性:

    import { Component, OnDestroy } from '@angular/core';
      ...
    export class HomeComponent implements OnDestroy {
      isStreamActive = true;
      ...
    } 
    
  2. 现在,我们将从 rxjs/operators 中导入 takeWhile 操作符,如下所示:

    import { Component, OnInit, OnDestroy } from '@angular/core';
    ...
    import { interval, Subscription, **takeWhile** } from 'rxjs'; 
    
  3. 我们现在将使用 takeWhile 操作符与每个流一起使用,使它们仅在 isStreamActive 属性设置为 true 时工作。由于 takeWhile 接受一个 predicate 方法,它应该看起来像这样:

    startStream() {
        ...
        streamSource
          **.****pipe****(****takeWhile****(****() =>****this****.****isStreamActive****))**
          .subscribe(input => {...});
        secondStreamSource
          **.****pipe****(****takeWhile****(****() =>****this****.****isStreamActive****))**
          .subscribe(input => {...});
        fastestStreamSource
          **.****pipe****(****takeWhile****(****() =>****this****.****isStreamActive****))**
          .subscribe(input => {...});
      } 
    

    如果你现在点击 开始流 按钮在 主页 上,你仍然看不到任何输出或日志,因为 isStreamActive 属性仍然是 未定义 的。

  4. 要使流工作,我们在 startStream 方法中将 isStreamActive 属性设置为 true。代码应该看起来像这样:

     ngOnDestroy() {
        this.stopStream();
      }
      startStream() {
      **isStreamActive =** **true****;**
    const streamSource = interval(1500);
        const secondStreamSource = interval(3000);
        const fastestStreamSource = interval(500);
        ...
      } 
    

    在这一步之后,如果你现在尝试开始流并离开页面,你仍然会看到流的问题——也就是说,它们没有被取消订阅。

  5. 要一次性取消所有流的订阅,我们在 stopStream 方法中将 isStreamActive 的值设置为 false,如下所示:

     stopStream() {
        **this****.****isStreamActive** **=** **false****;**
      } 
    
  6. 最后,更新模板以根据 isStreamActive 属性而不是 subscription 来处理哪个按钮被禁用。按照以下方式更新 home.component.html 文件:

     <div class="home">
    <div class="buttons-container">
    <button [disabled]="**isStreamActive**" class="btn btn-
    primary" (click)="startStream()">Start
              Stream</button>
    <button [disabled]="**!isStreamActive**" class="btn
    btn-dark" (click)="stopStream()">Stop
              Stream</button>
    </div>
          ...
      </div> 
    

然后,当你在流正在发出值时离开路由,流将立即停止。哇!

参见

使用 Angular 的异步管道自动取消订阅流

如你在前面的食谱中所学,取消订阅你订阅的流是至关重要的。如果我们有一种更简单的方法在组件销毁时取消订阅它们——也就是说,让 Angular 以某种方式处理它——会怎样?在这个食谱中,你将学习如何使用 Angular 的async管道与可观察对象直接绑定流中的数据到 Angular 模板,而不是需要在*.component.ts文件中进行订阅。

准备工作

我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter05/ng-async-pipe

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目:

    npm run serve ng-async-pipe 
    

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

    图 5.5:运行在 http://localhost:4200 上的 ng-async-pipe 应用程序

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

如何做到这一点...

我们目前拥有的应用程序有三个流/可观察对象在不同的间隔观察值。我们依赖于isStreamActive属性来保持订阅活跃或当属性设置为false时停止它。我们将删除takeWhile的使用,并设法让一切工作得和现在一样。

  1. 首先,在HomeComponent类中添加一个名为streamOutput$的类型为Observable的属性。按照以下方式更新home.component.ts文件中的代码:

    ...
    import { interval, **Observable**, takeWhile } from 'rxjs';
    ...
    export class HomeComponent implements OnDestroy {
      ...
      isStreamActive!: boolean;
      **streamsOutput$!:** **Observable****<****number****>;**
    constructor() { }
      ...
    } 
    
  2. 我们现在将所有流合并以输出单个输出——即outputStreamData数组。我们将从startStream方法中删除所有现有的pipesubscribe函数,因此代码现在应该看起来像这样:

    ...
    import { interval, **merge**, **scan**, Observable, takeWhile } from 'rxjs';
    ...
    export class HomeComponent implements OnDestroy {
      ...
      startStream() {
        ...
        const fastestStreamSource = interval(500);
        **this****.****streamsOutput$** **=** **merge****(**
    **streamSource,**
    **secondStreamSource,**
    **fastestStreamSource**
    **).****pipe****(**
    **scan****(****(****acc, next****) =>** **{**
    **return** **[...acc, next];**
    **}, []** **as****number****[])**
    **);**
      }
      ...
    } 
    
  3. 由于我们希望在点击停止流按钮时停止流,我们将在流中使用takeWhile操作符来与流一起工作,只有在点击开始流按钮时才发出值,并在点击停止流按钮时停止。按照以下方式更新home.component.ts中的startStream方法:

    startStream() {
        ...
        this.streamsOutput$ = merge(...).pipe(
          **takeWhile****(****() =>****this****.****isStreamActive****),**
    scan((acc, next) => {
            return [...acc, next];
          }, [] as number[])
        **)**
      } 
    
  4. 删除ngOnDestroy方法,因为当我们将离开组件(转到另一个路由)时,我们的流将自动取消订阅。这是因为我们正在使用async管道,Angular 本身在使用async管道时会为我们处理订阅和取消订阅。此外,我们应该删除implements OnDestroy语句和OnDestroy导入。

  5. 最后,修改home.component.html中的模板,以使用streamOutput$可观察对象和async管道来循环输出数组:

     <div class="output-stream">
    <div class="input-stream__item" *ngFor="let item of
    **streamsOutput$ | async**">
            {{item}}
          </div>
    </div> 
    
  6. 为了验证在组件销毁时订阅确实被销毁,让我们在startStream方法中的tap操作符内添加console.log,如下所示:

    import { ..., takeWhile, **tap** } from 'rxjs';
    startStream() {
        ...
        this.streamsOutput$ = merge(...).pipe(
          takeWhile(...),
          scan(...)**,**
    **tap****(****(****output****) =>****console****.****log****(****'output'****, output))**
        )
      } 
    

哈哈!随着这个更改,你可以尝试刷新应用;离开主页路由,你会看到一旦你离开主页,控制台日志就会停止。此外,你还可以开始和停止流以在控制台看到输出。你对刚刚通过移除所有额外代码所得到的结果感到满意吗?我当然满意。在下一节中,我们将看到这一切是如何工作的。

它是如何工作的…

Angular 的async管道会在组件销毁时自动销毁/取消订阅,这为我们提供了一个很好的机会在可能的地方使用它。在菜谱中,我们基本上使用merge操作符组合了所有流。有趣的部分是,对于streamsOutput$属性,我们想要一个输出数组的可观察对象,我们可以遍历它。然而,合并流只会将它们组合起来,并发出任何流发出的最新值。因此,我们添加了一个带有scan操作符的pipe函数,以获取组合流的最新输出并将其添加到之前发出的所有输出数组中。这有点像 JavaScript 数组中的reduce函数。

有趣的事实——流在未被订阅的情况下不会发出任何值。“但是 Ahsan,我们没有订阅流,我们只是合并并映射了数据。订阅在哪里?”很高兴你问了。Angular 的async管道会自动订阅流本身,这会触发console.log,这是我们使用tap函数在步骤 6中添加的。

重要提示

async管道有一个限制,就是你不能在组件销毁之前停止订阅。对于想要有条件地订阅和取消订阅的情况,你可能需要选择像takeWhile/takeUntil这样的操作符,或者当组件销毁时自己使用常规的unsubscribe函数。

参见

使用 map 操作符转换数据

在 Web 应用中制作 API/HTTP 调用时,通常服务器不会以易于直接渲染到 UI 的形式返回数据。我们通常需要将服务器接收到的数据进行某种转换,以便将其映射到我们的 UI 可以处理的内容。在这个菜谱中,你将学习如何使用map操作符来转换 HTTP 调用的响应。

准备工作

我们将要工作的应用位于克隆的仓库中的start/apps/chapter05/rx-map-operator目录内:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve rx-map-operator 
    

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

    图 5.6:运行在 http://localhost:4200 的 rx-map-operator 应用

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

如何操作…

我们的应用模板(app.component.html)已经设置好了。同样,我们的app.component.ts文件和所需的appData数据结构也已经设置。

  1. 我们将首先在swapi.service.ts文件中创建一个方法来获取数据。我们希望只有一个函数能够从不同的 API 调用中获取数据,将其合并,并返回。按照以下方式更新文件:

    ...
    import { delay, forkJoin, **Observable** } from 'rxjs';
    import { IFilm, **IPerson** } from './interfaces';
    ...
    export class SwapiService {
      ...
      **fetchData****(****personId****:** **string****):** **Observable****<{****person****:**
    **IPerson****}> {**
    **}**
    fetchPerson(id: string) {...}
      fetchPersonFilms(films: string[]) {...}
    } 
    

    你将看到 TypeScript 对我们很生气。不用担心,我们会在适当的时候让它高兴起来

  2. 让我们在fetchData函数中添加以下代码,首先获取人物,然后获取该人物的影片:

    ...
    export class SwapiService {
      ...
      fetchData(personId: string): Observable<{person: IPerson}> {
        let personInfo: IPerson;
        **return****this****.****fetchPerson****(personId)**
    **.****pipe****(**
    **mergeMap****(****(****person****) =>** **{**
    **personInfo = person;**
    **return****this****.****fetchPersonFilms****(person.****films****);**
    **})**
    **)**
    **}**
      ...
    } 
    

    现在我们可以在收到影片后决定要做什么。

  3. 我们将遍历films HTTP 调用返回的响应,并将其添加到personInfo对象中。按照以下方式更新swapi.service.ts文件:

    ...
    import { delay, forkJoin, **map**, mergeMap, Observable } from 'rxjs';
    ...
    export class SwapiService {
     ...
      fetchData(personId: string): Observable<{ person: IPerson }> {
        let personInfo: IPerson;
        return this.fetchPerson(personId).pipe(
          mergeMap((person) => {
            personInfo = person;
            return this.fetchPersonFilms(person.films);
          })**,**
    **map****(****(****films: IFilm[]****) =>** **{**
    **personInfo.****filmObjects** **= films;**
    **return** **{**
    **person****: personInfo,**
    **};**
    **})**
        );
      }
      ...
    } 
    
  4. 最后,让我们在app.component.ts文件中使用SwapiServicefetchData方法。按照以下方式更新文件中的fetchData方法,并确保从文件中删除未使用的依赖项:

    ...
    export class AppComponent implements OnInit {
      ...
      fetchData() {
        this.loadingData = true;
        **this****.****swapi****.****fetchData****(****'1'****).****subscribe****(****(****response****) =>** **{**
    **this****.****appData** **= response;**
    **this****.****loadingData** **=** **false****;**
    **});**
      }
    } 
    

    是的!如果你现在刷新应用,你会注意到数据正在视图中显示:

    图 5.7:显示从 swapi 接收到的数据的 UI

现在你已经完成了配方,请查看下一节了解它是如何工作的。

它是如何工作的…

map运算符是所有时间中最常用的 RxJS 运算符之一。特别是在 Angular 中,当我们进行 HTTP 调用时。在这个配方中,我们的目标是尽可能少地在app.component.ts文件中做工作。这是因为作为社区采纳的实践之一,组件应该从服务请求数据,服务应该以这种方式提供数据,以便它可以绑定到 UI 变量。Angular 文档也鼓励将组件的代码保持尽可能小。通常,将代码分布到不同的层,即组件、服务、管道等,也是一个好主意。这是为了能够轻松地扩展应用程序,有更好的测试可能性,并且能够轻松地用完全不同的事物替换层。因此,我们在SwapiService类中创建了fetchData方法,使用fetchPersonfetchPersonFilms方法首先进行 HTTP 调用,然后我们使用了map运算符将数据转换成组件/UI 期望的确切数据结构。

参见

使用 switchMap 和 debounceTime 运算符以及自动完成功能以获得更好的性能

对于许多应用程序,我们具有用户键入时搜索内容等特性。这对于用户体验(UX)来说非常好,因为用户不需要按按钮就可以进行搜索。然而,如果我们每次按键都向服务器发送 HTTP 调用,这将导致发送大量的 HTTP 调用,我们无法知道哪个 HTTP 调用会首先完成;因此,我们无法确定是否会在视图中显示正确的数据。在本食谱中,您将学习如何使用switchMap操作符取消最后一个订阅并创建一个新的订阅。这将导致取消之前的 HTTP 调用,并保留一个 HTTP 调用——最后一个。我们将使用debounceTime操作符等待输入空闲后再尝试进行调用。

准备工作

我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter05/rx-switchmap-operator目录内:

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目:

    npm run serve rx-switchmap-operato 
    

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

    图片

    图 5.8:在 http://localhost:4200 上运行的 rx-switchmap-operator 应用程序

现在我们已经在本地上运行了应用程序,打开Chrome DevTools并转到网络标签页。在搜索输入中键入wolf,您会看到向 API 服务器发送了四个调用,如下所示:

图片

图 5.9:为每次输入更改发送单独的 HTTP 调用

如何做到这一点...

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

  1. 首先,在users/users.component.ts文件中从rxjs/operators导入switchMap操作符,如下所示:

    ...
    import { mergeMap, startWith, takeWhile, **switchMap** } from 'rxjs/operators'; 
    
  2. 我们现在将修改对username表单控制的订阅——具体来说,是使用switchMap操作符来调用this.userService.searchUsers(query)方法的valueChanges可观察对象。这返回一个包含 HTTP 调用结果的Observable。代码应该看起来像这样:

    ngOnInit() {
        ...
        this.searchForm.controls['username'].valueChanges
          .pipe(
            startWith(''),
            takeWhile(() => this.componentAlive),
            **switchMap**((query) =>
    this.userService.searchUsers(query))
          )
          .subscribe((users) => {...});
      } 
    

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

    图片

    图 5.10:switchMap 取消之前的 HTTP 调用

    好吧,看起来不错,但backend/api端点仍然接收那些调用。

  3. 现在我们将使用debounceTime操作符等待搜索输入空闲后再开始执行调用。按照以下方式更新users.component.ts文件:

    ...
    import { **debounceTime, ..**.} from 'rxjs/operators';
    ...
    export class UsersComponent implements OnInit {
      ...
      ngOnInit() {
        ...
        this.searchForm.controls['username'].valueChanges
          .pipe(
            startWith(''),
            **debounceTime****(****500****),**
    takeWhile(() => this.componentAlive),
            switchMap((query) =>
    this.userService.searchUsers(query))
          )
          .subscribe((users) => {...});
      }
    } 
    

    图 5.11显示,即使在搜索输入中键入四个字母之后,也只向服务器发送了一个调用:

    图片

    图 5.11:等待输入空闲的 debounceTime

哇!我们现在只有一个调用会成功,处理数据,并最终显示在视图中;请看下一节了解它是如何工作的。

它是如何工作的...

switchMap操作符取消之前的(内部)订阅,并订阅一个新的可观察对象。在我们的例子中,父级可观察对象(输入元素的valueChanges发射器)发出一个值,switchMap操作符取消正在进行的上一个操作。这就是为什么它会取消我们例子中之前发送的所有 HTTP 调用,并仅订阅最后一个。然而,调用仍然到达 API 端点。如果这是我们自己的服务器,我们可能仍然会收到 API 调用,所以我们使用debounceTime操作符在表单控件上等待输入空闲(500 毫秒),然后我们才发送第一个调用。

参见

创建自定义 RxJS 操作符

通过遵循本章中的其他食谱,我必须问你是否已经成为 RxJS 的粉丝了?你成为了吗?好吧,我是。在这个食谱中,你将提升你的 RxJS 技能。你将创建自己的自定义 RxJS 操作符,它可以直接连接到任何可观察流并在控制台上记录值。我们将称之为logWithLabel操作符。

准备工作

我们将要工作的应用位于克隆的仓库中的start/apps/chapter05/rx-custom-operator

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令来提供项目服务:

    npm run serve rx-custom-operator 
    

    这应该在新的浏览器标签页中打开应用。如果你在打开 DevTools 的同时点击开始流按钮,你应该看到以下内容:

    图 5.12:在 http://localhost.4200 上运行的 rx-custom-operator 应用

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

如何做到这一点...

我们将创建一个名为logWithLabel的自定义 RxJS 操作符,它将在控制台上带有标签记录可观察流中的值。

  1. app文件夹内创建一个新文件,并将其命名为log-with-label.ts。然后在文件中添加以下代码:

    import { Observable } from 'rxjs/internal/Observable';
    import { tap } from 'rxjs/operators';
    const logWithLabel = <T>(
      label: string
    ): ((source$: Observable<T>) => Observable<T>) => {
      return (source$) => source$.pipe(tap((value) =>
    console.log(label, value)));
    };
    export default logWithLabel; 
    
  2. 现在我们可以从home/home.component.ts文件中的log-with-label.ts文件导入logWithLabel操作符,如下所示:

    ...
    **import** **logWithLabel** **from****'../log-with-label'****;**
    @Component({...})
    export class HomeComponent {
      ...
      startStream() {
        ...
        this.streamsOutput$ = merge(...).pipe(
          takeWhile(...),
          scan(...),
          **logWithLabel****(****'stream-output'****)**
        );
      }
      ...
    } 
    

    就这样!如果你刷新应用并点击开始流按钮,你可以使用logWithLabel操作符查看输出,如下所示:

    图 5.13:使用 logWithLabel 自定义 RxJS 操作符记录的日志

请参阅下一节了解它是如何工作的。

它是如何工作的...

一个自定义 RxJS 操作符是一个函数,它应该接受一个可观察源流并返回某物。那个某物通常是可观察的。在这个菜谱中,我们希望深入到流中,每次流发出值时在控制台记录一些内容。我们还希望为这个流的日志添加一个自定义标签。这就是我们最终创建自定义操作符作为工厂函数的原因,它可以接受label作为输入,即当我们调用logWithLabel函数(让我们称它为函数 A)时,它返回一个函数(让我们称它为函数 B)。返回的函数(B)是 RxJS 在我们使用pipe函数中的logWithLabel方法时与可观察流一起调用的。在函数 B内部,我们使用 RxJS 的tap操作符来拦截源可观察流并在控制台使用提供的label记录值。

参见

使用 RxJS 重试失败的 HTTP 请求

在这个菜谱中,你将学习如何使用 RxJS 操作符智能地重试 HTTP 请求。我们将使用一种称为指数退避的技术。这意味着我们将重试 HTTP 请求,但每次后续调用都比前一次尝试的延迟更长,并在尝试几次最大次数后停止。听起来很激动人心吗?让我们开始吧。

准备工作

我们将要与之合作的应用程序位于克隆的仓库中的start/apps/chapter05/rx-retry-http-calls

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以使用后端服务器提供项目:

    npm run serve rx-retry-http-calls with-server 
    

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

    图 5.14:在 http://localhost.4200 上运行的 rx-retry-http-calls

让我们跳到下一节中的菜谱步骤。

如何做到这一点...

我们将创建一个名为backoff的自定义 RxJS 操作符,它将使用指数退避策略为我们重试 HTTP 请求。

  1. app文件夹内创建一个新文件,并将其命名为retry-backoff.ts。然后在文件中添加以下代码:

    import { of, pipe, throwError } from 'rxjs';
    import { retry } from 'rxjs/operators';
    export function retryBackoff(maxTries: number, delay: number) {
      return pipe(
        retry({
          delay: (error, retryCount) => {
            return retryCount > maxTries ? throwError(() =>
              error) : of(retryCount);
          },
        })
      );
    } 
    
  2. 现在,让我们在app.component.ts中使用这个操作符来重试 HTTP 请求。按照以下方式更新文件:

    ...
    **import** **{ retryBackoff }** **from****'./retry-backoff'****;**
    ...
    export class AppComponent implements OnInit {
      ...
      ngOnInit(): void {
        this.isMakingHttpCall = true;
        this.http
          .get('http://localhost:3333/api/bad-request')
          .pipe(
            **retryBackoff****(****3****,** **300****),**
    catchError(...)
          )
          .subscribe(...);
      }
    } 
    

    如果你刷新应用程序,你会注意到现在我们正在重试 HTTP 请求。但所有的重试都是立即完成的(注意瀑布列),如图 5.15 所示。我们不想这样。我们希望每次尝试都以递增的延迟完成。

    图 5.15:立即多次重试 HTTP 请求

  3. retry-backoff.ts 文件更新为使用 timer 操作符和一些计算来添加延迟,如下所示:

    import { of, pipe, throwError, **timer** } from 'rxjs';
    import { **map, mergeMap**, retry } from 'rxjs/operators';
    export function retryBackoff(maxTries: number, delay: number) {
      return pipe(
        retry({
          delay: (error, retryCount) => {
            return (
              retryCount > maxTries ? throwError(() => error) :
                of(retryCount)
            )**.****pipe****(**
    **map****(****(****count****) =>** **count * count),**
    **mergeMap****(****(****countSq****) =>****timer****(countSq * delay))**
    **);**
          },
        })
      );
    } 
    

    就这样!如果您刷新应用程序,您会看到每次 HTTP 调用的后续重试的延迟都比前一次增加。注意最后一个 HTTP 调用在 Waterfall 列中的位置有多远(它在 图 5.16 的右边缘):

    图 5.16:使用指数退避重试 HTTP 调用

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

它是如何工作的…

retry 操作符有两个重载(在撰写本书时)。其中一个接受 number 参数,RxJS 将仅重试观察者指定次数(直到抛出异常)。另一个重载是它接受一个配置对象。在配置对象中,我们使用 delay 函数来处理我们的逻辑。delay 函数接收来自 RxJS 的 errorretryCount,我们使用它们来抛出错误,如果我们已经尝试了最大次数,或者传递 retryCount。我们从 retryBackoff 函数的参数中获取最大尝试次数。最后,我们使 mapmergeMap 操作符与 delay 一起工作。使用 map 操作符,我们取 retryCount 变量值的平方。然后,在 mergeMap 操作符中,我们将平方值与传递给 retryBackoff 函数的延迟相乘。结果,每次后续请求的延迟等于 ((retryCount * retryCount) * delay)。请注意,我们使用 timer 函数让 RxJS 在再次重试 HTTP 调用之前等待。

另请参阅

在 Discord 上了解更多信息

要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问以及了解新版本——请扫描下面的二维码:

AngularCookbook2e

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

Angular 和 reactive programming 是最佳拍档,以响应式的方式处理应用程序的状态是你可以为你的应用程序做的最好的事情之一。NgRx(代表 Angular Reactive Extensions)是一个提供一组库作为 Angular 的响应式扩展的框架。在本章中,你将学习如何使用 NgRx 生态系统以响应式的方式管理你的应用程序状态,你还将了解 NgRx 生态系统将帮助你的一些酷功能。

这里是我们将在本章中涵盖的食谱:

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

  • 使用 NgRx Store Devtools 调试状态变化

  • 使用 NgRx 选择器在组件中选择和渲染状态

  • 使用 NgRx 效果从 API 调用中获取数据

  • 使用 NgRx 组件存储来管理组件的状态

技术要求

对于本章的食谱,请确保你的设置已按照 'Angular-Cookbook-2E' GitHub 仓库中的 'Technical Requirements' 完成设置。有关设置详细信息,请访问:github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/docs/technical-requirements.md。本章的入门代码位于 github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/start/apps/chapter06

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

在这个食谱中,你将通过设置你的第一个 NgRx 存储,逐步了解 NgRx 的基础知识。你还将创建一些动作,以及一个还原器,为了看到还原器中的变化,我们将添加适当的控制台日志。

准备工作

我们将要工作的应用程序位于克隆的仓库中的 start/apps/chapter06/ngrx-actions-reducer 目录内:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目:

    npm run serve ngrx-actions-reducer 
    

    这应该在新的浏览器标签页中打开应用程序。如果你添加了一些项目,你应该会看到以下内容:

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

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

如何做…

我们有一个包含一个桶的单页 Angular 应用程序。你可以向你的桶中添加水果,并从桶中移除项目。我们已经在工作区中安装了 @ngrx/store 包,所以你不需要安装它。然而,当你独立工作(或在你的一些自己的项目中)时,你将首先添加 NgRx 并运行以下命令:

npm install @ngrx/store 

更新 app.config.ts 文件以提供 NgRx 存储,如下所示:

...
import { provideStore } from '@ngrx/store';
...
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideStore } from '@ngrx/store';
export const appConfig: ApplicationConfig = {
  providers: [
    ...,
    provideAnimations(),
    **provideStore****({}),**
  ],
}; 

注意,我们已经将一个空对象 {} 传递给 provideStore() 方法;我们将从现在开始更改它。

现在,我们将创建一些动作。在app文件夹内创建一个名为store的文件夹。然后,在store文件夹内创建一个名为bucket.actions.ts的文件,最后,将以下代码添加到新创建的文件中:

import { createActionGroup, props } from '@ngrx/store';
import { IFruit } from '../interfaces/fruit.interface';
export const BucketActions = createActionGroup({
  source: 'Bucket',
  events: {
    'Add Fruit': props<{ fruit: IFruit }>(),
    'Remove Fruit': props<{ fruitId: number }>(),
  },
}); 

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

store文件夹内创建一个新文件,命名为bucket.reducer.ts,并将以下代码添加到其中以定义必要的导入和初始状态:

import { createReducer, on } from '@ngrx/store';
import { IFruit } from '../interfaces/fruit.interface';
import { BucketActions } from './bucket.actions';
export const initialState: ReadonlyArray<IFruit> = []; 

现在,我们将定义 reducer。将以下代码添加到bucket.reducer.ts文件中:

...
**export****const** **bucketReducer =** **createReducer****(**
**initialState,**
**on****(****BucketActions****.****addFruit****,** **(****_state, { fruit }****) =>** **{**
**console****.****log****({ fruit });**
**return** **[fruit, ..._state];**
**}),**
**on****(****BucketActions****.****removeFruit****,** **(****_state, { fruitId }****) =>** **{**
**console****.****log****({ fruitId });**
**return** **_state.****filter****(****(****fr****) =>** **fr.****id** **=== fruitId);**
**})**
**);** 

接下来,我们将更新app.config.ts文件以使用我们刚刚创建的 reducer。按照以下方式更新文件:

...
**import** **{ bucketReducer }** **from****'./app/store/bucket.reducer'****;**
bootstrapApplication(AppComponent, {
  providers: [
    ...,
    provideStore(**{**
**bucket****: bucketReducer,**
**}**),
  ],
}).catch((err) => console.error(err)); 

现在,我们将使用我们创建的动作来查看 reducer 函数的控制台日志。按照以下方式更新bucket.component.ts文件,首先使用 NgRx 存储库如下:

...
**import** **{** **StoreModule****,** **Store** **}** **from****'@ngrx/store'****;**
@Component({
  ...,
  imports: [CommonModule, FormsModule, **StoreModule**],
})
export class BucketComponent implements OnInit {
  ...
  fruits: string[] = Object.values(Fruit);
  **store =** **inject****(****Store****);**
  ...
} 

让我们派发动作到存储库,以便我们在桶中添加水果和移除水果。我们还将稍微修改一下代码以避免重复。按照以下方式更新bucket.component.ts文件:

...
**import** **{** **BucketActions** **}** **from****'../store/bucket.actions'****;**
...
export class BucketComponent implements OnInit {
  ...
    addSelectedFruitToBucket() {
    **const****newFruit****:** **IFruit** **= {**
**id****:** **Date****.****now****(),**
**name****:** **this****.****selectedFruit****,**
**};**
**this****.****store****.****dispatch****(**
**BucketActions****.****addFruit****({**
**fruit****: newFruit,**
**})**
**);**
this.bucketService.addItem(**newFruit**);
  }
  deleteFromBucket(fruit: IFruit) {
    **this****.****store****.****dispatch****(**
**BucketActions****.****removeFruit****({**
**fruitId****: fruit.****id****,**
**})**
**);**
this.bucketService.removeItem(fruit);
  }
} 

现在如果您向桶中添加或移除项目,您将在控制台看到日志,如下所示在图 6.2中,这意味着我们的操作和 reducer 正在工作:

图片

图 6.2:显示从桶中添加和移除项目动作的日志

这样就完成了这个食谱的所有内容!您现在能够将 NgRx 存储库集成到 Angular 应用程序中,创建 NgRx 动作,并派发这些动作。您还能够创建一个 reducer,定义其状态,并监听动作以对其做出反应。

参见

使用 NgRx Store Devtools 调试状态变化

在这个食谱中,您将学习如何使用@ngrx/store-devtools来调试应用程序的状态、派发的动作以及动作派发时状态的变化。我们将使用我们熟悉的现有应用程序来了解这个过程。

准备中

我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter06/ngrx-devtools

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ngrx-devtools 
    

    这应该在新的浏览器标签页中打开应用程序。如果您添加了一些项目,您应该会看到以下内容:

    图片

    图 6.3:使用运行在 http://localhost:4200 的 ngrx-devtools 应用

现在我们已经设置了应用程序,让我们在下一节中查看食谱的步骤。

如何做到这一点…

我们有一个已经集成了@ngrx/store包的 Angular 应用程序。我们还有一个设置好的 reducer 和一些动作,这些动作会在你添加或移除项目时立即在控制台记录。我们已经在工作区中安装了@ngrx/store-devtools包,所以你不需要安装它。然而,当你独立工作(或在你的一些自己的项目中)时,你会从添加 NgRx 存储devtools并运行以下命令开始:

npm install @ngrx/store-devtools 
  1. 首先,更新您的app.config.ts文件以包含StoreDevtoolsModule.instrument条目,如下所示:

    ...
    **import** **{ provideStoreDevtools }** **from****'@ngrx/store-devtools'****;**
    import { bucketReducer } from './app/store/bucket.reducer';
    bootstrapApplication(AppComponent, {
      providers: [
        ...,
        provideStore({
          bucket: bucketReducer,
        }),
        **provideStoreDevtools****({**
    **maxAge****:** **50****,**
    **}),**
      ],
    }).catch((err) => console.error(err)); 
    
  2. 现在从github.com/zalmoxisus/redux-devtools-extension/下载Redux DevTools扩展程序到您的浏览器中并安装它。在这本书中,我会持续使用 Chrome 浏览器。

  3. 打开Chrome DevTools。应该有一个名为Redux的新标签页。点击它并刷新页面。你会看到如下内容:

    图 6.4:Redux DevTools 显示最初派发的 Redux 动作

  4. 移除所有水果,往桶里加两个樱桃和一个香蕉,然后从桶里移除香蕉。你应该会看到所有相关的动作和状态如下所示:

    图 6.5:Redux DevTools 显示桶动作和状态

太好了!你刚刚学会了如何使用 Redux DevTools 扩展来查看你的 NgRx 状态和正在派发的动作。

它是如何工作的...

重要的是要理解 NgRx 是 Angular 和 RxJS 的结合,从而实现了包括 Redux 在内的不同模式的实现。通过使用 Store Devtools 包和 Redux DevTools 扩展,我们能够轻松地调试应用程序,这有助于我们找到潜在的 bug,预测状态变化,并在@ngrx/store包的幕后发生的事情上更加透明。

还有更多...

你还可以看到动作在应用程序状态中引起的差异,即当我们向桶中添加一个项目并从桶中移除一个项目时。分别参见图 6.6图 6.7

图 6.6:Redux Devtools 中的添加水果动作

注意图 6.6中桶项目周围的绿色背景。这表示状态中的添加。你可以在以下图片中看到ReemoveFruit动作:

图 6.7:Redux Devtools 中的移除水果动作

现在注意红色背景和Diff的删除线。这表示从状态中移除。

参见

使用 NgRx 选择器在组件中选择和渲染状态

在之前的菜谱中,我们创建了一些动作,一个单独的 reducer,并且我们集成了 devtools 来观察状态变化。然而,我们的 bucket 应用仍然使用BucketService中的某些变量来渲染数据。在这个菜谱中,我们将全力以赴使用 NgRx。我们将从状态中渲染 bucket 项目,因为我们已经将它们保存在 NgRx 存储中。

准备工作

我们将要工作的应用位于克隆的仓库中的start/apps/chapter06/ngrx-selectors

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目:

    npm run serve ngrx-selectors 
    

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

    图 6.8:使用运行在 http://localhost:4200 的 ngrx-selectors 应用

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

如何操作…

在这个菜谱中,我们只需要处理 NgRx 选择器。存储、动作和 reducer 已经设置好了。简单易懂!让我们开始吧!

我们首先将在主页上显示我们的 bucket 中的水果,为此,我们必须创建我们的第一个 NgRx 选择器:

  1. store文件夹内创建一个新文件。命名为bucket.selectors.ts并将以下代码添加到其中:

    import { createFeatureSelector } from '@ngrx/store';
    import { IFruit } from '../interfaces/fruit.interface';
    export const selectBucket =
    createFeatureSelector<ReadonlyArray<IFruit>>('bucket'); 
    

    现在我们已经有了选择器,让我们在BucketComponent类中使用它。

  2. 按照以下方式修改bucket.component.ts文件以重新分配$bucket可观察对象:

    ...
    **import** **{ selectBucket }** **from****'../store/bucket.selectors'****;**
          ...
    export class BucketComponent implements OnInit {
      ...
      store = inject(Store);
      **$bucket****:** **Observable****<****IFruit****[]> =** **this****.****store****.****select****(**
    **selectBucket);**
    ngOnInit(): void {
        this.bucketService.loadItems();
      }
      ...
    } 
    

    刷新应用并尝试向 bucket 中添加和移除项目。你可以看到我们仍然得到了渲染的 bucket 项目。这次,它来自 NgRx 存储。

  3. 现在我们可以移除从BucketService管理状态的额外代码。按照以下方式更新bucket.component.ts文件中的BucketComponent类:

    //  remove the `implements onInit` below
    export class BucketComponent implements OnInit {
      **bucketService =** **inject****(****BucketService****);****// ← remove**
    selectedFruit: Fruit = '' as Fruit;
      ...
      **ngOnInit****():** **void** **{****// ← remove this method**
    **this****.****bucketService****.****loadItems****();**
    **}**
    addSelectedFruitToBucket() {
        const newFruit: IFruit = {...};
        this.store.dispatch(...);
        **this****.****bucketService****.****addItem****(newFruit);****// ← remove**
      }
      deleteFromBucket(fruit: IFruit) {
        this.store.dispatch(...);
        **this****.****bucketService****.****removeItem****(fruit);****// ← remove**
      }
    } 
    

    一旦你移除了代码,确保也从文件中移除未使用的依赖(导入)。尝试运行应用,你会发现它仍然可以工作。太好了!

  4. 现在我们可以更新bucket.service.ts文件,使其不再将 bucket 项目保存在BehaviorSubject中。按照以下方式更新文件:

    ...
    import { Injectable } from '@angular/core';
    import { IFruit } from '../interfaces/fruit.interface';
    import { IBucketService } from '../interfaces/bucket-service';
    @Injectable({
      providedIn: 'root',
    })
    export class BucketService implements IBucketService {
      **storeKey =** **'bucket_ngrx-selectors'****;**
    **loadItems****() {**
    **return****JSON****.****parse****(****window****.****localStorage****.****getItem****(**
    **this****.****storeKey****) ||** **'[]'****);**
    **}**
    **saveItems****(****items****:** **IFruit****[]****) {**
    **window****.****localStorage****.****setItem****(**
    **this****.****storeKey****,** **JSON****.****stringify****(items));**
    **r}**
    } 
    

    注意我们已经移除了addItemremoveItem函数,并添加了saveItems方法。

  5. 你会看到 TypeScript 很生气。这是因为BucketService不再实现IBucketService接口了。更新bucket-service.ts文件以更新接口如下:

    import { IFruit } from './fruit.interface';
    export interface IBucketService {
      loadItems(): void;
      **saveItems****(****fruit****:** **IFruit****[]):** **void****;**
    } 
    

就这样!你已经完成了菜谱。你会注意到,一旦我们刷新应用,我们就失去了 bucket 项目。但别担心——我们将在下一个菜谱中把它们带回来。

它是如何工作的…

在这个菜谱中,我们已经设置了动作和选择器。然而,我们只是将动作分发给存储以添加/移除项目。组件仍然使用 BucketService 和从它生成的 Observable 来管理状态并渲染项目。由于我们只想在整个过程中使用 NgRx,我们在应用中引入了一个选择器来选择桶中的项目。

注意我们使用了来自 @ngrx/store 包的 createFeatureSelector 方法。这允许我们从应用中选择一个功能。例如,你的应用可以有配置文件、用户、事件和设置等功能。理想情况下,我们创建功能选择器来提取目标功能的唯一数据,而不是整个 NgRx 状态对象。

然后,我们使用我们刚刚创建的选择器 selectBucket() 替换 BucketComponent 类中的 $bucket 属性。注意,选择器返回一个 Readonly 数组。这确保了我们不会修改实际数据,并且它是只读的。最后,我们从 BucketService 类中移除不必要的函数,并从 BucketComponent 类中移除对这些函数的使用。

如果你现在从整体上审视这个应用,我们可以使用 NgRx 来向桶中添加和移除项目,并且可以使用选择器在 UI 中渲染这些项目。然而,一旦你刷新应用,所有内容都会消失,因为数据不是持久的;尽管我们有使用 LocalStorage 获取和保存项目的函数。处理这种情况的理想方式是使用 NgRx 效果。然而,你将在下一个菜谱中学习它们。你可以挑战自己再次回到这个菜谱,实现一个将更新后的桶列表保存到存储中的效果。

参考以下内容

使用 NgRx 效果从 API 调用中获取数据

在这个菜谱中,你将学习如何使用 @ngrx/effects 包中的 NgRx 效果。我们有一个已经安装了 @ngrx/store@ngrx/store-devtools 的应用。我们能够向桶中添加和移除项目。然而,在这个菜谱中,我们将使用服务器来接收、存储、添加和移除桶中的项目,即数据将同时存在于 NgRx 存储和后端。

准备工作

我们将要工作的应用位于克隆的仓库中的 start/apps/chapter06/ngrx-effects 目录内:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以使用后端应用提供项目服务:

    npm run serve ngrx-effects with-server 
    

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

    图 6.9:在 http://localhost:4200 上运行的 ngrx-effects 应用

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

如何实现...

我们有一个包含 bucket 的单页 Angular 应用。你可以向 bucket 中添加水果,并从 bucket 中移除项目。我们已经在工作区中安装了 @ngrx/store@ngrx/store-devtools@ngrx/effects 包,所以你不需要安装它们。然而,当你在一个独立的应用(或你的一些自己的项目中)工作时,你可以通过运行以下命令来添加 NgRx effects:

npm install --save @ngrx/effects 

现在,让我们按照以下步骤进行:

  1. 我们将更新 bucket.actions.ts 文件,添加一些包括 API 调用的动作。按照以下方式更新文件:

    import { createActionGroup, **props**, **emptyProps** } from '@ngrx/store';
    import { IFruit } from '../interfaces/fruit.interface';
    export const BucketActions = createActionGroup({
      source: 'Bucket',
      events: {
        **'Get Bucket'****:** **emptyProps****(),**
    **'****Get Bucket Success'****: props<{** **bucket****:** **IFruit****[] }>(),**
    **'Get Bucket Failure'****: props<{** **error****:** **string** **}>(),**
    'Add Fruit': props<{ fruit: IFruit }>(),
        **'****Add Fruit Success'****: props<{** **fruit****:** **IFruit** **}>(),**
    **'Add Fruit Failure'****: props<{** **error****:** **string** **}>(),**
    'Remove Fruit': props<{ fruitId: number }>(),
        **'****Remove Fruit Success'****: props<{** **fruitId****:** **number** **}>(),**
    **'Remove Fruit Failure'****: props<{** **error****:** **string** **}>(),**
      },
    }); 
    
  2. store 文件夹中创建一个名为 bucket.effects.ts 的文件,并将以下代码添加到其中:

    import { Injectable } from '@angular/core';
    import { Actions, ofType, createEffect } from '@ngrx/effects';
    import { of } from 'rxjs';
    import { catchError, exhaustMap, map } from 'rxjs/operators';
    import { BucketService } from '../bucket/bucket.service';
    import { BucketActions } from './bucket.actions';
    @Injectable()
    export class BucketEffects {
      getBucket$ = createEffect(() =>
    this.actions$.pipe(
          ofType(BucketActions.getBucket),
          exhaustMap(() =>
    this.bucketService.getBucket().pipe(
              map(({ bucket }) => BucketActions
     .getBucketSuccess({ bucket })),
              catchError((error) => of(BucketActions
     .getBucketFailure({ error })))
            )
          )
        )
      );
      constructor(
     private actions$: Actions,
     private bucketService: BucketService
     ) {}
    } 
    
  3. 现在,我们可以在 app.config.ts 文件中通过 BucketEffects 类提供效果,如下所示:

    ...
    **import** **{ provideEffects }** **from****'@ngrx/effects'****;**
    ...
    import { BucketEffects } from './app/store/bucket.effects';
    ...
    bootstrapApplication(AppComponent, {
      providers: [
        ...,
        provideHttpClient(),
        **provideEffects****([****BucketEffects****]),**
      ],
    }).catch((err) => console.error(err)); 
    
  4. 我们现在将更新 bucket.reducer.ts 文件,在 HTTP 调用成功事件中添加或移除项目,并在应用启动时设置从服务器检索的 bucket 项目。按照以下方式更新文件:

    ...
    export const initialState: ReadonlyArray<IFruit> = [];
    export const bucketReducer = createReducer(
      initialState,
      **on****(****BucketActions****.****getBucketSuccess****,**
    **(****_state, { bucket }****) =>** **{**
    **return** **bucket;**
    **}),**
    on(BucketActions.**addFruitSuccess**, (_state, { fruit }) => {
        console.log({ fruit });
        return [fruit, ..._state];
      }),
      on(BucketActions.**removeFruitSuccess**, (_state, { fruitId }) => {
        console.log({ fruitId });
        return _state.filter((fr) => fr.id !== fruitId);
      })
    ); 
    
  5. 现在,当组件挂载时,我们将从 bucket.componen.ts 中分发 getBucket 动作。按照以下方式更新 bucket.component.ts

    import { CommonModule } from '@angular/common';
    import { Component, inject, **OnInit** } from '@angular/core';
    ...
    @Component({...})
    export class BucketComponent**implements****OnInit** {
      ...
      **ngOnInit****() {**
    **this****.****store****.****dispatch****(****BucketActions****.****getBucket****());**
    **}**
      ...
    } 
    

    现在我们已经链接了一切,刷新应用,你将看到来自服务器的 bucket 项目。你可以在 Redux Devtools 中看到以下动作:

    图 6.10:使用 NgRx effects 从服务器获取 bucket

    你也应该能够看到由于图 6.9 中的 [Bucket] Get Bucket 动作而发生的网络调用(见图 6.10)。

    图 6.11:@ngrx/effects 初始化网络调用

    如果你尝试添加或移除项目,你会看到它们不起作用。这是因为我们在第 4 步中更改了我们的 reducer,使其在出现 addFruitSuccessremoveFruitSuccess 事件时采取行动。

  6. 现在我们将在 bucket.effects.ts 中添加 addFruit 的效果。按照以下方式更新文件:

    ...
    export class BucketEffects {
      ...
      **addItem$ =** **createEffect****(****() =>**
    **this****.****actions$****.****pipe****(**
    **ofType****(****BucketActions****.****addFruit****),**
    **exhaustMap****(****(****action****) =>**
    **this****.****bucketService****.****addItem****(action.****fruit****).****pipe****(**
    **map****(****(****{ fruit }****) =>****BucketActions**
    **.****addFruitSuccess****({ fruit })),**
    **catchError****(****(****error****) =>****of****(**
    **BucketActions****.****addFruitFailure****({ error })))**
    **)**
    **)**
    **)**
    **);**
      ...
    } 
    
  7. 让我们在 bucket.effects.ts 中也添加 removeFruit 的效果。按照以下方式更新文件:

    ...
    export class BucketEffects {
      ...
      **removeItem$ =** **createEffect****(****() =>**
    **this****.****actions$****.****pipe****(**
    **ofType****(****BucketActions****.****removeFruit****),**
    **exhaustMap****(****(****action****) =>**
    **this****.****bucketService****.****removeItem****(**
    **action.****fruitId****).****pipe****(**
    **map****(****() =>**
    **BucketActions****.****removeFruitSuccess****({** **fruitId****:**
    **action.****fruitId** **})**
    **),**
    **catchError****(****(****error****) =>****of****(**
    **BucketActions****.****removeFruitFailure****({ error })))**
    **)**
    **)**
    **)**
    **);**
      ...
    } 
    

    如果你现在刷新应用,添加一个苹果,然后移除它,你应该能在 Redux Devtools 中看到状态,如图所示:

    图 6.12:NgRx effects 获取 bucket 并添加和移除项目

    注意图 6.11 中的状态显示了 4 个项目,这在 UI 中显示。同时,注意动作分发的顺序。在这种情况下,每个 API/HTTP 动作都会导致以下 Success 动作。

太棒了!你现在知道如何在 Angular 应用中使用 NgRx effects 了。查看下一节以了解 NgRx effects 是如何工作的。

它是如何工作的…

为了使 NgRx 效果正常工作,我们需要安装 @ngrx/effects 包,创建一个效果,并在 main.ts 文件中的效果数组(根效果)中注册它。NgRx 效果本质上是一种监听动作、执行某些操作(我们称这种操作为副作用),然后返回一个动作的东西。当从任何组件或甚至从另一个效果向存储发送动作时,注册的效果会执行你想要它执行的任务,并应该返回另一个动作。对于 HTTP 调用,我们通常有三个动作——即主要动作,以及随后的成功和失败动作。理想情况下,在成功动作(也许在失败动作上也是如此),你可能会想要更新一些你的状态变量。在 步骤 4 中,在 BucketEffects 类内部,你会注意到我们注入了 NgRx 的 Action 服务和 BucketService。然后我们使用 createEffect 函数创建效果。效果本身使用 ofType 操作符监听特定事件的 Action 流。然后我们使用 exhaustMap 操作符执行 HTTP 调用,它返回一个可观察对象。最后,我们使用 map 操作符返回成功动作,并使用 catchError 操作符返回失败动作。

参见

使用 NgRx Component Store 管理组件的状态

在这个食谱中,你将学习如何使用 NgRx Component Store 以及如何用它来代替基于推送的 Subject/BehaviorSubject 模式和用于维护组件状态的服务。我们还将看到如何使用 Component Store 促进跨组件通信。

记住 @ngrx/component-store 是一个独立的库,并且与 Redux 或 @ngrx/store 等不相关。

准备工作

我们将要工作的应用位于克隆的仓库中的 start/apps/chapter06/ngrx-component-store

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以使用后端应用提供项目:

    npm run serve ngrx-component-store 
    

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

    图 6.13:在 http://localhost:4200 上运行的 ngrx-component-store 应用

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

如何做到这一点…

我们有我们最喜欢的桶应用,我们已经在很多食谱中使用过。桶的当前状态存储在组件本身中。这限制了状态仅限于组件内部,我们无法在顶部标题中使用桶的长度。一种方法可能是使用 BehaviorSubject。但我们会更愿意使用 NgRx Component Store。我们已经在单仓库中安装了 @ngrx/component-store 包。但如果你正在开发一个新项目,你将按照以下方式安装包:

npm install @ngrx/component-store 

我们将在 BucketService 中构建我们的组件存储。

  1. 让我们使其与 ComponentStore 兼容。为了做到这一点,我们将为桶状态创建一个接口,从 ComponentStore 扩展 BucketService,并通过调用 super 方法初始化服务。按照以下方式更新 bucket.service.ts 文件:

    ...
    **import** **{** **ComponentStore** **}** **from****'@ngrx/component-store'****;**
    **import** **{** **IFruit** **}** **from****'../interfaces/fruit.interface'****;**
    **export****interface****BucketState** **{**
    **bucket****:** **IFruit****[];**
    **}**
    ...
    export class BucketService **extends****ComponentStore****<**
    **BucketState****>** {
      storeKey = 'bucket_ngrx-component-store';
      **constructor****() {**
    **super****({** **bucket****: [] });**
    **}**
    loadItems() {...}
      saveItems(items: IFruit[]) {...}
    } 
    

    直到我们实际上显示 ComponentStore 中的数据,这一切都没有意义。让我们现在着手解决这个问题。

  2. 让我们在 BucketService 中创建一个 bucket$ 可观察对象,如下所示:

    ...
    **import** **{** **Observable** **}** **from****'rxjs/internal/Observable'****;**
    import { IFruit } from '../interfaces/fruit.interface';
    ...
    export class BucketService extends ComponentStore<BucketState> {
      storeKey = 'bucket_ngrx-component-store';
      **readonly****bucket$****:** **Observable****<****IFruit****[]> =** **this****.****select****(**
    **(****state****) =>** **state.****bucket****);**
      ...
    } 
    
  3. 首先,让我们确保我们可以使用 localStorage 中的值初始化组件存储。我们将按照以下方式更新 bucket.service.ts 文件中的 constructor 方法,以使用 loadItems 方法:

     constructor() {
        super({ bucket: [] });
        **this****.****setState****({**
    **bucket****:** **this****.****loadItems****(),**
    **});**
      } 
    
  4. 现在我们将添加将水果项目添加到和从桶中移除的方法:

    export class BucketService extends ComponentStore<BucketState> {
      ...
      **readonly** **addItem =** **this****.****updater****(****(****state****:** **BucketState****,**
    **fruit****:** **IFruit****) =>** **{**
    **const** **bucketUpdated = [fruit, ...state.****bucket****];**
    **this****.****saveItems****(bucketUpdated);**
    **return** **{**
    **bucket****: bucketUpdated,**
    **};**
    **});**
    **readonly** **removeItem =** **this****.****updater****(****(****state****:** **BucketState****,**
    **fruitId****:** **number****) =>** **{**
    **const** **bucketUpdated = state.****bucket****.****filter****(****(****fr****) =>**
    **fr.****id** **!== fruitId);**
    **this****.****saveItems****(bucketUpdated);**
    **return** **{**
    **bucket****: bucketUpdated,**
    **};**
    **});**
      ...
    } 
    
  5. 现在我们已经设置了组件存储,让我们在 bucket.component.ts 文件中使用它,如下所示:

    ...
    **import** **{** **BucketService** **}** **from****'****./bucket.service'****;**
    @Component({...})
    export class BucketComponent {
      ...
      **bucket****:** **IFruit****[] = [];** **//← remove**
    **store =** **inject****(****BucketService****);** **//← add**
    **bucket$ =** **this****.****store****.****bucket$****;** **//← add**
    addSelectedFruitToBucket() {
        const newFruit: IFruit = {
          id: Date.now(),
          name: this.selectedFruit,
        };
        **this****.****store****.****addItem****(newFruit);**
      }
      deleteFromBucket(fruit: IFruit) {
        **this****.****store****.****removeItem****(fruit.****id****);**
      }
    } 
    
  6. 我们现在可以在模板中使用 bucket$ 可观察对象。按照以下方式更新 bucket.component.html 文件:

     <div class="fruits" *ngIf="**bucket$ | async as bucket**"
        [@listItemAnimation]="bucket.length">
    <ng-container *ngIf="bucket.length > 0; else
          bucketEmptyMessage">
        ...
        </ng-container>
    <ng-template #bucketEmptyMessage>...</ng-template>
    </div> 
    

    经过这次更改,你应该能看到应用正在工作。如果你现在尝试添加或删除项目,你应该能够看到它们在应用中有所反映。现在所有这些操作都是通过组件存储来完成的。

  7. 我们接下来需要做的是在应用页眉中使用桶的长度。让我们在 bucket.service.ts 文件中创建一个新的状态选择器,如下所示:

    ...
    export class BucketService extends ComponentStore<BucketState> {
      storeKey = 'bucket_ngrx-component-store';
      readonly bucket$: Observable<IFruit[]> =
        this.select((state) => state.bucket);
      **readonly****bucketLength$****:** **Observable****<****number****> =**
    **this****.****select****(**
    **(****state****) =>** **state.****bucket****.****length**
    **);**
      ...
    } 
    
  8. 现在我们可以使用组件存储在应用页眉中。首先,让我们按照以下方式在 app.component.ts 中导入存储:

    ...
    **import** **{** **BucketService** **}** **from****'./bucket/bucket.service'****;**
    ...
    export class AppComponent {
      **store =** **inject****(****BucketService****);**
    **bucketLength$ =** **this****.****store****.****bucketLength$****;**
    } 
    
  9. 我们现在可以在 app.component.html 中使用 bucketLength$ 可观察对象,如下所示:

    <span class="mr-4">{{**bucketLength$ | async**}} items</span> 
    

    哇!如果你从桶中添加和删除项目,你应该能够在页眉中看到桶的长度:

    图片

    图 6.14:通过组件存储在页眉中显示桶的长度

恭喜!你已经完成了食谱。查看下一节以了解它是如何工作的。

它是如何工作的…

如前所述,@ngrx/component-store 是一个独立的包,可以轻松地安装到你的 Angular 应用中,而无需使用 @ngrx/store@ngrx/effects 等等。它旨在取代 Angular 服务中 BehaviorSubject 的使用,这正是我们在本食谱中做的。我们介绍了如何初始化 ComponentStore,以及如何使用 setState 方法设置初始状态,因为我们已经有了值而没有访问状态,我们还学习了如何创建更新器方法,这些方法可以用来更新状态,因为它们可以访问状态,并允许我们为我们的用例传递参数。

参见

在 Discord 上了解更多

要加入这本书的 Discord 社区——在那里你可以分享反馈,向作者提问,并了解新版本——请扫描下面的二维码:

packt.link/AngularCookbook2e

二维码

第七章:理解 Angular 导航和路由

Angular 最令人惊奇的事情之一是它是一个完整的生态系统(一个框架),而不仅仅是一个库。在这个生态系统中,Angular 路由是学习和理解的最关键的模块之一。在本章中,你将学习一些关于 Angular 路由和导航的非常酷的技术。你将了解如何保护你的路由,监听路由变化,并在路由变化时配置全局操作。

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

  • 在 Angular (独立) 应用中创建路由

  • Angular 中的懒加载路由

  • 预加载路由策略

  • 使用路由守卫授权访问路由

  • 使用路由参数进行操作

  • 在路由变化之间显示全局加载器

技术要求

对于本章的食谱,请确保你的设置已按照 'Angular-Cookbook-2E' GitHub 仓库中的 '技术要求' 完成。有关设置详情,请访问:github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/docs/technical-requirements.md。本章的起始代码位于 github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/start/apps/chapter07

在 Angular (独立) 应用中创建路由

如果你问我我们过去 7-8 年是如何创建 Web 应用项目的话,你会惊讶地了解到那是多么困难。幸运的是,软件开发行业中的工具和标准已经发展,对于 Angular 来说,开始一个项目变得超级简单。使用 Angular 独立应用,应用的引导过程和路由配置都要小得多。在本食谱中,你将要在一个新的 Angular 应用程序中实现一些路由。

准备工作

我们将要工作的应用位于克隆的仓库中的 start/apps/chapter07/ng-basic-routing 目录下:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-basic-routing 
    

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

    图 7.1:ng-basic-routes 应用在 http://localhost:4200 上运行

如何操作…

我们将在应用中配置路由,添加一些路由,并在页眉中添加这些路由的链接。让我们开始吧:

  1. 首先,在 app 文件夹内创建一个名为 app.routes.ts 的文件,并将以下代码添加到其中:

    import { Route } from '@angular/router';
    export const appRoutes: Route[] = []; 
    
  2. 现在更新 src 文件夹中的 app.config.ts 文件,并按照以下更改进行:

    import { ApplicationConfig } from '@angular/core';
    **import** **{**
    **provideRouter,**
    **withEnabledBlockingInitialNavigation,**
    **}** **from****'@angular/router'****;**
    **import** **{ appRoutes }** **from****'./app.routes'****;**
    export const appConfig: ApplicationConfig = {
      providers: [**provideRouter****(appRoutes,** **withEnabledBlockingInitialNavigation****())**],
    }; 
    
  3. 现在,让我们创建一些组件(作为页面)。我们将创建一个主页和一个about页。在工作区的start文件夹中,从终端运行以下npx命令。如果需要,你可以使用@nx/angular:component脚本来选择“按提供”操作:

    nx g c home --directory apps/chapter07/ng-basic-routing/src/app/home --standalone
    nx g c about --directory apps/chapter07/ng-basic-routing/src/app/about --standalone 
    

    你现在应该在项目的app文件夹中看到创建了两个新文件夹。

  4. 我们现在将配置路由。更新我们在步骤 1中创建的app.routes.ts文件,如下所示:

    import { Route } from '@angular/router';
    **import** **{** **AboutComponent** **}** **from****'./about/about.component'****;**
    **import** **{** **HomeComponent** **}** **from****'./home/home.component'****;**
    export const appRoutes: Route[] = [**{**
    **path****:** **''****,**
    **pathMatch****:** **'full'****,**
    **redirectTo****:** **'home'**
    **}, {**
    **path****:** **'home'****,**
    **component****:** **HomeComponent**
    **}, {**
    **path****:** **'about'****,**
    **component****:** **AboutComponent**
    **}**
    ]; 
    
  5. 剩下的就是在视图中使用模板中的<router-outlet>来连接路由。更新app.component.html文件如下:

    <!-- Toolbar -->
    ...
    <main class="content" role="main">
    **<****router-outlet****></****router-outlet****>**
    </main> 
    

    但是等等!这会导致应用崩溃。这是因为AppComponent还没有理解路由。

  6. 更新app.component.ts文件,在其中添加RouterModule,如下所示:

    ...
    **import** **{** **RouterModule** **}** **from****'@angular/router'****;**
    @Component({
      ...,
      imports: [CommonModule, **RouterModule**],
    })
    export class AppComponent {} 
    

    哇!现在你应该能够看到home组件了,如下所示:

    图片

    图 7.2:ng-basic-routes 应用中的 home 组件

    如果你查看 URL,它默认为http://localhost:4200/home,即使你尝试访问https://localhost:4200

  7. 最后,我们将在页眉中添加路由的链接。更新app.component.html文件如下:

    <!-- Toolbar -->
    <div class="toolbar" role="banner">
      ...
      <div class="spacer"></div>
    **<****div****class****=****"route-links"****>**
    **<****div**
    **class****=****"route-links__route-link"**
    **routerLink****=****"/home"**
    **routerLinkActive****=****"route-links__route-link--active"**
    **>**
    **Home**
    **</****div****>**
    **<****div**
    **class****=****"route-links__route-link"**
    **routerLink****=****"/about"**
    **routerLinkActive****=****"route-links__route-link--active"**
    **>**
    **About**
    **</****div****>**
    **</****div****>**
      ...
    <main class="content" role="main">...</main> 
    

    现在,如果你点击顶部的关于链接,你应该能够看到about路由,如下所示:

    图片

    图 7.3:ng-basic-routes 应用中的 about 组件

太棒了!在几分钟内,借助 Angular 路由和 NX CLI,我们能够创建一个主页和一个about页,并且还配置了路由。现代网络的奇迹!

现在你已经知道了基本路由是如何实现的,下一节将帮助你理解它是如何工作的。

它是如何工作的…

当我们创建一个不带--routing标志的 Angular 应用时,我们不会得到app.routes.ts文件,也不会在main.ts文件中添加配置。在这个例子中,我们使用了一个已经设置了此标志的应用。如果你要开始一个新的项目,你可以在创建应用时直接使用--routing标志,以便从应用一开始就设置好路由。然后我们使用nx g c <component name> –standalone命令创建了一些独立组件。如果你使用 Angular CLI 而不是 NX 单仓库,你只需在上一个命令中将nx替换为ng,其余的都相同。使用npx允许我们使用nx包,而无需在我们的系统中全局安装它。由于我们有一个独立的应用,即AppComponent是一个独立组件(注意app.component.ts文件中的standalone: true),我们必须将其导入到RouterModule中,以便能够使用<router-outlet>和链接上的routeLink属性。由于我们没有AppModule(因为应用是一个独立应用),我们通过在main.ts文件中使用provideRouter函数并添加路由来配置路由。

参见

Angular 中的懒加载路由

在前面的食谱中,我们学习了如何创建一个带有急速加载路由的基本路由应用。在这个食谱中,你将学习如何使用功能模块来懒加载它们,而不是在应用加载时加载它们。对于这个食谱,我们假设我们已经有路由在位,我们只需要懒加载它们。

准备工作

我们将要工作的应用位于克隆的仓库中的start/apps/chapter07/ng-lazy-routing

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-lazy-routing 
    

    这应该在新的浏览器标签中打开应用。如果你打开网络标签,你应该会看到以下内容:

    图片

    图 7.4:ng-lazy-routing 应用在 http://localhost:4200 上运行

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

如何实现...

图 7.4所示,我们所有的组件和模块都在main.js文件中。因此,main.js文件的大小约为 11.9 KB(这可能会根据 Angular 如何进一步优化框架而改变)。我们将修改代码和路由结构以实现懒加载。因此,当我们导航到这些路由时,路由的文件将会被加载。在独立组件时代之前,这一步是困难的。即使今天,如果你使用NgModules与 Angular 应用一起工作,你也会发现设置路由需要更多的代码。但既然我们的应用只有独立组件,看看这变得有多简单:

  1. app.routes.ts更新为使用loadComponent方法代替路由中的component属性,如下所示:

    import { Route } from '@angular/router';
    export const appRoutes: Route[] = [{
      path: '',
      pathMatch: 'full',
      redirectTo: 'home'
    }**, {**
    **path****:** **'home'****,**
    **loadComponent****:** **() =>****import****(****'./home/home.component'****).****then****(****m** **=>** **m.****HomeComponent****)**
    **}, {**
    **path****:** **'about'****,**
    **loadComponent****:** **() =>****import****(****'./about/about.component'****).****then****(****m** **=>** **m.****AboutComponent****)**
    **}**]; 
    

    刷新应用后,你会看到main.js文件的包大小降至 8.3 KB,之前大约是 11.9 KB。请看以下截图:

    图片

    图 7.5:应用加载时 main.js 文件大小的减少

    但关于HomeAbout路由呢?还有懒加载呢?好吧,点击页眉中的About路由,你会在网络标签中看到一个针对该路由的新 JavaScript 文件正在下载。这就是懒加载的作用!请看以下截图:

    图片

    图 7.6:关于路由的懒加载

太棒了!你刚刚学会了如何在 Angular 应用中懒加载路由和功能组件的艺术。你现在可以向你的朋友炫耀这项技能了。

它是如何工作的...

Angular 使用模块和组件,通常,功能被拆分为 NgModules,或者使用独立组件,组件本身是懒加载的。我们将从独立组件的角度来探讨。正如我们所知,AppComponent 作为独立 Angular 应用的入口点,Angular 将在构建过程中导入并捆绑在 AppComponent 中导入的任何内容,从而生成 main.js 文件。然而,如果我们想懒加载我们的路由/功能组件,我们需要避免在 AppComponent 中直接导入功能组件,甚至不在路由中导入它们。相反,我们可以使用 loadChildren 方法来懒加载其他模块,或者使用 loadComponent 方法来懒加载其他独立组件。这正是我们在本食谱中做的事情。需要注意的是,在 app.routes.ts 文件中,路由保持不变。

参考也

预加载路由策略

我们已经熟悉了如何在导航期间懒加载不同的功能模块。不过,有时你可能希望预加载后续路由以使下一个路由导航瞬间完成,或者甚至可能希望根据应用程序的业务逻辑使用自定义预加载策略。在本食谱中,你将了解 PreloadAllModules 策略,并实现一个自定义策略来选择哪些模块应该被预加载。

准备工作

我们将要工作的应用位于克隆的仓库中的 start/apps/chapter07/ng-route-preload-strat

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目:

    npm run serve ng-route-preload-strat 
    

    这应该在新的浏览器标签页中打开应用。如果你以管理员身份登录,你应该看到以下内容:

    图片 B18469_07_07.png

    图 7.7:在 http://localhost:4200 上运行的 ng-route-preload-strat 应用

  3. 通过按 Windows 上的 Ctrl + Shift + C 或 Mac 上的 Cmd + Shift + C 来打开 Chrome 开发者工具。

  4. 导航到 网络 选项卡,并仅过滤 JavaScript 文件。你应该看到如下内容!图片 B18469_07_08.png

    图 7.8:应用加载时加载的 JavaScript 文件

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

如何操作…

注意在 图 7.8 中的第一个网络调用。这是包含 admin 组件和 bucket 组件的 JavaScript 包。尽管 app-routes.ts 中的所有路由都配置为懒加载,但我们仍然可以查看如果我们使用 PreloadAllModules 策略然后自定义预加载策略会发生什么:

  1. 我们将首先尝试使用PreloadAllModules策略。为了使用它,让我们按照以下方式修改app/app.config.ts文件:

    import { ApplicationConfig } from '@angular/core';
    import {
      **PreloadAllModules****,**
    **provideRouter****,**
    **withEnabledBlockingInitialNavigation****,**
    **withPreloading****,**
    **}** from '@angular/router';
    import { appRoutes } from './app.routes';
    import { provideAnimations } from '@angular/platform-browser/animations';
    export const appConfig: ApplicationConfig = {
      providers: [
        provideRouter(
          appRoutes,
          withEnabledBlockingInitialNavigation(),
          **withPreloading****(****PreloadAllModules****)**
        ),
        provideAnimations(),
      ],
    }; 
    

    如果你刷新应用,你应该看到所有组件的 JavaScript 包正在下载,如下所示:

    图片

    图 7.9:使用 PreloadAllModules 策略预先加载所有组件

    这已经提供了巨大的性能优势,因为最小的app shell首先被加载,然后所有其他包并行预加载。这意味着当用户现在导航到页面时,他们不需要下载路由的包并立即导航(即,导航将更快)。但如果我们只想预加载admin模块,假设我们的应用主要是为管理员设计的呢?我们将为那个创建一个自定义预加载策略。

  2. 让我们从start文件夹中运行以下命令来创建一个名为CustomPreloadStrategy的服务:

    nx g service app-preload-strategy --project ng-route-preload-strat 
    
  3. 为了使用我们的预加载策略服务与 Angular 一起工作,我们的服务需要实现@angular/router包中的PreloadingStrategy接口。按照以下方式修改新创建的服务:

    import { Injectable } from '@angular/core';
    **import** **{** **PreloadingStrategy** **}** **from****'@angular/router'****;**
    @Injectable({
      providedIn: 'root'
    })
    export class CustomPreloadStrategyService**implements****PreloadingStrategy** {
    } 
    
  4. 接下来,我们需要实现PreloadingStrategy接口中的preload方法,以便我们的服务能够正常工作。让我们修改CustomPreloadStrategyService以实现preload方法,如下所示:

    import { Injectable } from '@angular/core';
    import { PreloadingStrategy**,** **Route** } from '@angular/router';
    **import** **{** **Observable****,** **of** **}** **from****'rxjs'****;**
    @Injectable({...})
    export class CustomPreloadStrategyService implements PreloadingStrategy {
      **preload****(****route****:** **Route****,** **load****:** **() =>****Observable****<****any****>):**
    **Observable****<****any****> {**
    **return****of****(****null****)**
    **}**
    **logAndLoad****(****route****:** **Route****,** **load****: () =>** **Observable****<****any****>****) {**
    **console****.****log****(****`Preloading route:** **${route.path}****`****);**
    **return****load****();**
    **}**
    } 
    
  5. 目前,我们的preload方法返回of(null)。相反,为了决定要预加载哪些路由,我们将在我们的路由定义中添加一个对象作为data对象,该对象具有布尔值,用于预加载admin路由和employee路由。让我们按照以下方式修改app-routes.ts

    ...
    export const appRoutes: Route[] = [
      { path: '', ...},
      { path: 'auth', ...},
      { path: 'admin', ..., **data****: {** **loadForAdmin****:** **true** **}** },
      {
        path: 'admin-campaign',
        ...,
        **data****: {** **loadForAdmin****:** **true** **}**
      },
      {
        path: 'employee',
        ...,
       **data****: {** **loadForEmployee****:** **true** **},**
      },
      {
        path: 'employee-campaign',
        ...,
        **data****: {** **loadForEmployee****:** **true** **},**
      },
    ]; 
    
  6. 现在让我们在AppPreloadStrategyService类的preload方法中添加逻辑,以处理在步骤 5中添加的属性。我们将首先注入AuthService,并在preload方法内部创建一些变量。更新app-preload-strategy.service.ts文件如下:

    ...
    **import** **{** **AuthService** **}** **from****'./auth/auth.service'****;**
    **import** **{** **UserType** **}** **from****'./constants/user-type'****;**
    ...
    export class AppPreloadStrategyService implements PreloadingStrategy {
      **auth =** **inject****(****AuthService****);**
    preload(route: Route, load: () => Observable<any>): Observable<any> {
        **const** **isLoggedIn =** **this****.****auth****.****isLoggedIn****();**
    **if** **(!isLoggedIn) {**
    **return****of****(****null****)**
    **}**
    **const** **isAdmin =** **this****.****auth****.****loggedInUserType** **===**
    **UserType****.****Admin****;**
    return of(null)
      }
      logAndLoad(route: Route, load: () => Observable<any>) {...}
    } 
    
  7. 让我们进一步修改preload方法,以便与loadForAdminloadForEmployee路由数据属性一起使用如下:

    ...
    export class CustomPreloadStrategyService implements PreloadingStrategy {
    ...
      preload(route: Route, load: () => Observable<any>):
      Observable<any> {
        ...
        const isAdmin = this.auth.loggedInUserType ===
      UserType.Admin;
        **if** **(**
    **(isAdmin && route.****data****?.[****'loadForAdmin'****]) ||**
    **(!isAdmin && route.****data****?.[****'loadForEmployee'****])**
    **) {**
    **return****this****.****logAndLoad****(route, load);**
    **}**
    return of(null)
      }
    } 
    
  8. 最后一步是使用我们的自定义预加载策略。为了做到这一点,我们需要按照以下方式修改app/app.config.ts文件:

    import { ApplicationConfig } from '@angular/core';
    import {
      provideRouter,
      withEnabledBlockingInitialNavigation,
      withPreloading,
    } from '@angular/router';
    import { appRoutes } from './app.routes';
    **import** **{** **AppPreloadStrategyService** **}** **from****'./app-preload-strategy.service'****;**
    import { provideAnimations } from '@angular/platform-browser/animations';
    export const appConfig: ApplicationConfig = {
      providers: [
        provideRouter(
          appRoutes,
          withEnabledBlockingInitialNavigation(),
          withPreloading(AppPreloadStrategyService)
        ),
        provideAnimations(),
      ],
    }; 
    

    哇!如果你现在刷新应用并监控网络选项卡,你会注意到当你以admin身份登录时,你可以在控制台日志中看到admin-campaign路由已被预加载,如图图 7.10所示:

    图片

    图 7.10:使用自定义预加载策略在到达 admin 路由时预加载 admin-campaign 路由

    你也可以查看网络选项卡来查看正在下载的包:

    图片

    图 7.11:为 admin-campaign 路由预先加载 JavaScript 包

尝试注销并作为员工登录。你会看到employee-campaign路由正在预加载。不仅如此,如果你刷新employee-campaign路由,你还会看到employee路由也在预加载。对于admin-campaignadmin路由也是如此。

现在你已经完成了配方,请查看下一节了解它是如何工作的。

它是如何工作的…

Angular 提供了一种很好的方式来实现我们自己的自定义预加载策略,用于我们的功能模块。我们可以轻松地决定哪些模块应该预加载,哪些不应该。在配方中,我们学习了一种非常简单的方法来配置预加载,即通过在路由配置的data对象中添加名为loadForAdminloadForEmployee的属性。我们创建了自己的自定义预加载策略服务,名为AppPreloadStrategyService,该服务实现了来自@angular/router包的PreloadingStrategy接口。PreloadingStrategy类提供了一个名为preload的方法,该方法具有以下签名:

preload(route: Route, load: () => Observable<any>) => Observable<any> 

命名为load的参数是 Angular 路由中的一个方法,它允许我们通过调用它来加载组件/模块。因此,它提供了一个可观察对象。由于preload方法必须返回一个可观察对象,所以当我们不想预加载一个路由时,我们返回of(null);当我们想加载与路由关联的组件时,我们返回load(它返回一个可观察对象)。一般来说,目标是拥有一个自定义预加载策略,该策略使用preload方法来决定是否应该预加载路由。这是因为 Angular 会遍历每个路由,使用我们的自定义预加载策略,并让我们决定哪些路由应该预加载。就是这样。我们可以看到登录用户是否是admin并且路由的数据中是否有loadForAdmin属性,或者登录用户是否是员工并且路由的数据中是否有loadForEmployee属性。然后我们预加载该路由。否则,我们不预加载。当用户未登录时,没有预加载。

相关内容

使用路由守卫授权访问路由

并非你的 Angular 应用中的所有路由都应该对世界上所有人可访问。在本配方中,我们将学习如何在 Angular 中创建路由守卫以防止未经授权访问路由。

准备工作

我们将要工作的应用位于克隆的仓库中的start/apps/chapter07/ng-route-guards目录内:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目:

    npm run serve ng-route-guards 
    

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

    图 7.12:ng-route-guards 应用在 http://localhost:4200 上运行

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

如何实现...

我们已经有一个应用程序,其中已经设置了一些路由。你可以以员工或管理员的身份登录,以访问应用程序的待办事项列表。然而,如果你点击页眉中的任意一个按钮,你会看到即使没有登录,你也可以导航到管理员员工部分。这正是我们想要防止发生的事情。注意在auth.service.ts文件中,我们已经有了一种让用户登录的方法,我们可以使用isLoggedIn方法来检查用户是否已登录:

  1. 首先,让我们创建一个路由守卫,它将只允许用户在登录的情况下访问特定的路由。我们将它命名为AuthGuard。让我们在auth文件夹内创建一个新文件,并将其命名为auth.guards.ts。然后添加以下代码:

    import { inject } from "@angular/core";
    import { CanActivateFn, Router } from "@angular/router";
    import { AuthService } from "./auth.service";
    export const canActivateAdminOrEmployee: CanActivateFn =
      () => {
      const router = inject(Router);
      const authService = inject(AuthService);
      const isLoggedIn = authService.isLoggedIn();
      if (!isLoggedIn) {
        router.navigate(['/auth']);
        return false;
      }
      return true;
    }; 
    
  2. 现在我们可以将这个守卫添加到路由中。按照以下方式更新app.routes.ts

     import { Route } from '@angular/router';
    import { canActivateAdminOrEmployee } from './auth/auth.guards';
    export const appRoutes: Route[] = [
      {...},
      { path: 'auth', ... },
      {
        path: 'admin',
        ...,
        **canActivate****: [canActivateAdminOrEmployee]**
      },
      {
        path: 'employee',
        ...,
        **canActivate****: [canActivateAdminOrEmployee]**
      }
    ]; 
    

    如果你现在尝试在不登录的情况下访问管理员或员工页面,你会看到由于路由守卫的存在,你将无法再这样做。

  3. 让我们确保如果我们已经登录,我们无法访问auth页面。在auth.guards.ts文件中添加一个函数守卫,如下所示:

    ...
    **import** **{** **UserType** **}** **from****'../constants/user-type'****;**
    **export****const****canActivateLogin****:** **CanActivateFn** **=** **() =>** **{**
    **const** **router =** **inject****(****Router****);**
    **const** **authService =** **inject****(****AuthService****);**
    **const** **isLoggedIn = authService.****isLoggedIn****();**
    **if** **(router.****url** **===** **'/'** **&& isLoggedIn) {**
    **const** **isAdmin = authService.****loggedInUserType** **===**
    **UserType****.****Admin****;**
    **router.****navigate****([****`/****${isAdmin ?** **'admin'** **:**
    **'employee'****}****`****])**
    **return****false**
    **}**
    **return** **!authService.****isLoggedIn****()**
    **}**
    export const canActivateAdminOrEmployee: CanActivateFn = () => {...}; 
    

    如果你登录,然后点击浏览器的后退按钮,你会看到你也不能回到/auth页面。即使你打开一个新标签页并访问http://localhost:4200,你也会看到它根据登录用户的类型带你到正确的页面。

太好了!现在,在路由守卫方面,你已经成为了一名授权专家。权力越大,责任越大。明智地使用它。

它是如何工作的…

从 Angular v14.2 开始,Angular 切换到了功能路由守卫。这使得与之前的版本相比,配置路由变得更加容易。有许多路由守卫,例如CanActivateCanDeactivateCanActivateChildren。在main.ts中使用的provideRouter函数使我们能够提供带有功能路由守卫的路由。一个路由可以将守卫作为数组与守卫属性名称相对。你可以在步骤 2中看到我们如何为/employee/admin路由提供了我们的canActivateAdminOrEmployee守卫。功能守卫应该返回一个布尔值或一个UrlTree,一个布尔值或UrlTreepromise,或者一个布尔值或UrlTree的可观察对象。在我们的菜谱中,我们专注于布尔值的用法。在canActivateAdminOrEmployee守卫中,我们检查用户是否已登录。如果是这样,我们允许路由被激活(即,我们允许路由发生),或者我们导航到/auth路由。在canActivateLogin守卫中,我们做了一些更复杂的事情。由于有人可能会到达主页(路由是/,它重定向到/auth),我们必须首先检查用户是否已登录,如果是这样,那么这是哪种类型的用户。基于评估,我们将用户路由到/employee/admin路由。

参见

使用路由参数进行工作

不论是使用 Node.js 构建 REST API 还是配置 Angular 的路由,设置路由是一项绝对的艺术,尤其是在处理参数时。在这个菜谱中,你将创建一些带有参数的路由,并学习如何在路由激活后如何在组件中获取这些参数。

准备工作

我们将要工作的应用位于克隆的仓库中的start/apps/chapter07/ng-route-params目录下:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目:

    npm run serve ng-route-guards 
    

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

    图片

    图 7.13:在 localhost:4200 上运行的 ng-route-params 应用

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

如何做到这一点...

目前的问题是,我们有一个用于打开用户详情的路由,但我们看不到任何内容,即我们有一个空白页面。这是因为这个路由没有任何关于要显示哪个用户的想法。如果有一种方法可以从用户列表页面传递点击的用户信息到用户详情页面不是很好吗?这正是我们将在本菜谱中要做的:

  1. 首先,我们必须使我们的用户路由能够接受 route 参数。这将是一个 required 参数,这意味着没有传递此参数,路由将无法工作。让我们修改 app.routes.ts 以将此必需参数添加到路由定义中,如下所示:

    import { Route } from '@angular/router';
    export const appRoutes: Route[] = [
      { path: '', ...},
      { path: 'users', ...},
      {
        path: 'users/**:uuid**',
        loadComponent: () => ...,
      },
    ]; 
    
  2. 我们现在将更新 src/app/components/users-list/users-list.component.html 文件,以更改链接以使用 uuid,如下所示:

    <ul>
    <li
     routerLink="/users/**{{user.uuid}}**"
        *ngFor="let user of users">
        ...
      </li>
    </ul> 
    

    我们现在能够使用 uuid 导航到特定用户的路由,您也应该能在地址栏中看到它,如下所示:

    图 7.14:地址栏中显示的 UUID

  3. 要根据 uuidUserService 获取用户,我们需要在 UserDetailsComponent 中从路由参数中获取 uuid 值。让我们按照以下方式更新 user-details.component.ts

    ...
    import { **ActivatedRoute****,** RouterModule } from '@angular/router';
    import { **filter, map,** Observable} from 'rxjs';
    ...
    @Component({...})
    export class UserDetailsComponent {
      **route =** **inject****(****ActivatedRoute****);**
      ...
      **constructor****() {**
    **this****.****user$** **=** **this****.****route****.****paramMap****.****pipe****(**
    **map****(****(****params****) =>** **params.****get****(****'uuid'****)),**
    **filter****(****(****uuid****) =>** **!!uuid),**
    **map****(****(****uuid****) =>** **{**
    **return****this****.****userService****.****getById****(uuid** **as****string****) ||**
    **null****;**
    **})**
    **);**
    **}**
    } 
    

    现在,您应该能够从 /users 页面看到我们点击的用户。唯一剩下的事情是显示相似用户。

  4. 而不是创建另一个订阅,我们可以使用 RxJS 的 tap 操作符来利用我们在上一步中创建的订阅。进一步更新 user-details.component.ts 文件,如下所示:

    ...
    import { filter, map, Observable**,** **of****, tap**} from 'rxjs';
    ...
    @Component({...})
    export class UserDetailsComponent {
      ...
      constructor() {
        this.user$ = this.route.paramMap.pipe(
          map((params) => params.get('uuid')),
          filter((uuid) => !!uuid),
          **tap****(****(****uuid****) =>** **{**
    **this****.****similarUsers$** **=**
    **of****(****this****.****userService****.****getSimilar****(uuid** **as****string****))**
    **}),**
    map((uuid) => {...})
        );
      }
    } 
    

    然后,砰!您应该能够看到被点击的用户以及相似用户,如下所示:

    图 7.15:地址栏中显示的 UUID

太好了!通过这次更改,您可以在主页上刷新应用,然后点击任何用户。您应该能看到当前用户以及相似用户正在被加载。要了解配方背后的所有魔法,请参阅下一节。

它是如何工作的……

所有这一切都始于我们将路由的路径更改为 user/:userId。这使得 userId 成为我们的路由参数。拼图的另一部分是在 UserDetailsComponent 中检索此参数,然后使用它来获取目标用户以及相似用户。为此,我们使用 ActivatedRoute 服务。ActivatedRoute 服务包含有关当前路由的大量必要信息,因此我们能够通过订阅 paramMap 可观察对象来获取当前路由的 uuid 参数,所以即使参数在用户页面上发生变化,我们仍然执行必要的操作。ActivatedRoute 还有一个 queryParamMap 可观察对象,但它适用于查询参数而不是路由参数。如果您不想订阅 paramMap 可观察对象,只想在 ngOnInit 或构造函数中一次出现值,您还可以使用 ActivatedRoute 对象上的 snapshot 属性,如下所示:

this.route.snapshot.paramMap.get('uuid'); 

注意,使用paramMap可观察对象时,我们还使用了tap操作符来执行一些额外操作。即将相似用户的值分配给所需的可观察对象。由于这两个可观察对象都仅在模板中使用async管道,因此我们不需要自己取消订阅,因为 Angular 会处理这个问题。此外,如果你将新的可观察对象重新分配给由async管道使用的属性,它将自动订阅新的可观察对象并取消之前的订阅。

注意,你也可以直接将路由定义中解析的值作为@Input()属性提供给组件,使用 v16 中引入的bindToComponentInputswithComponentInputBinding属性,分别用于 Angular 模块和独立组件的 Angular Router。有关相关链接,包括解释如何使用withComponentInputBinding的文章,请参阅下一节。

相关内容

在路由转换之间显示全局加载器

构建快速且响应迅速的用户界面是赢得用户的关键。应用程序对最终用户来说变得更加有趣,这可以为应用程序的所有者/创建者带来很多价值。现代网络的核心体验之一是在后台发生某些操作时显示加载器。在这个食谱中,你将学习如何在 Angular 应用程序中创建一个全局用户界面加载器,以便在应用程序中发生路由转换时显示。

准备工作

我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter07/ng-global-loader目录内:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-global-loader 
    

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

    图片

    图 7.16:ng-global-loader 应用程序在 http://localhost:4200 上运行

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

如何操作…

如果你尝试登录或登出,你会注意到路由变化需要一段时间。我们在 start/apps/chapter07/ng-global-loader/src/app/auth/auth.guard.ts 文件中的 canActivateLogin 路由守卫中手动模拟了路由变化的延迟,该文件使用 RxJS 的 delay 操作符在一个可观察链中,在检查用户是否登录以及用户的类型之前。目的是模拟需要基于 HTTP 调用来路由的使用场景。在这个食谱中,我们已经创建了 LoaderComponent,我们必须在路由变化期间使用它:

  1. 我们首先在 app.component.ts 中添加一个新的属性 isRouting。我们将其初始化为 true。我们还在 AppComponent 类的 imports 中导入 LoaderComponent 类,这样我们就可以在模板中使用它。按照以下方式更新文件:

    ...
    @Component({
      ...
      imports: [RouterModule, CommonModule**,** **LoaderComponent**],
      providers: [...],
    })
    export class AppComponent {
      ...
      router = inject(Router);
      **isRouting =** **true****;**
      ...
    } 
    
  2. 现在,我们将在 app.component.html 中添加加载器以有条件地显示它,并将整个应用内容包裹在另一个变量中。按照以下方式更新文件:

    <div *ngIf="isRouting; else app" class="loader-container
      fixed w-full h-full flex items-center justify-center ">
    <app-loader></app-loader>
    </div>
    <ng-template #app>
    <div class="toolbar" role="banner">...</div>
    <main class="content" role="main">...</main>
    </ng-template> 
    

    你现在应该能够在每个路由上,在屏幕上持续看到加载器。然而,我们希望它与 Angular 路由一起工作。

  3. 现在,我们将更新 app.component.ts 文件以监听路由服务的 events 属性,并在 NavigationStart 事件上采取行动。按照以下方式修改 app.component.ts 文件中的代码:

    import { **NavigationStart****,** Router, RouterModule } from '@angular/router';
    ...
    @Component({...})
    export class AppComponent {
      **constructor****() {**
    **this****.****router****.****events****.****subscribe****(****(****event****) =>** **{**
    **if** **(event** **instanceof****NavigationStart****) {**
    **this****.****isRouting** **=** **true****;**
    **}**
    **})**
    **}**
    get isLoggedIn() {...}
    } 
    

    如果你刷新应用,你会注意到 <app-loader> 从未消失。这是因为我们没有在任何地方将 isRouting 属性标记为 false

  4. 要将 isRouting 标记为 false,我们需要检查三个不同的事件:NavigationEndNavigationErrorNavigationCancel。让我们添加一些额外的逻辑来处理这三个事件并将属性标记为 false

    ...
    import { **NavigationCancel****,** **NavigationEnd****,** **NavigationError****,** NavigationStart, Router, RouterModule } from '@angular/router';
    ...
    @Component({...})
    export class AppComponent {
      ...
      isRouting = **false**;
      constructor() {
        this.router.events.subscribe((event) => {
          if (event instanceof NavigationStart) {
            this.isRouting = true;
          }**else****if** **(**
    **event** **instanceof****NavigationEnd** **||**
    **event** **instanceof****NavigationError** **||**
    **event** **instanceof****NavigationCancel**
    **) {**
    **this****.****isRouting** **=** **false****;**
    **}**
        })
      }
      ...
    } 
    

    现在我们有一个在页面间导航时显示的全局加载器。

恭喜你完成了食谱。现在你可以在 Angular 应用中实现一个全局加载器,它将从导航开始显示到导航结束。

它是如何工作的…

路由服务是 Angular 中一个非常强大的服务。它拥有许多方法以及我们可以用于我们应用中不同任务的观察者。对于这个菜谱,我们使用了events观察者。通过订阅events观察者,我们可以监听Router服务通过观察者发出的所有事件。这个用法中最常见的案例之一(尤其是作为一个单页应用程序(SPA))是使用 Google Analytics 或 Mixpanel 跟踪页面访问。对于这个菜谱,我们只对NavigationStartNavigationEndNavigationErrorNavigationCancel事件感兴趣。当路由开始导航时,会发出NavigationStart事件。当导航成功结束时,会发出NavigationEnd事件。当由于路由守卫返回false或由于某些原因使用UrlTree进行重定向而取消导航时,会发出NavigationCancel事件。当在导航过程中由于任何原因出现错误时,会发出NavigationError事件。所有这些事件都是Event类型,我们可以通过检查它是否是目标事件的实例来识别事件类型,使用instanceof关键字。请注意,由于我们在AppComponent中订阅了Router.events属性,所以我们不必担心取消订阅,因为应用中只有一个订阅,且AppComponent在整个应用生命周期中不会被销毁。

参见

在 Discord 上了解更多

要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:

packt.link/AngularCookbook2e

第八章:精通 Angular 表单

获取用户输入是几乎所有现代应用不可或缺的一部分。无论是用户认证、请求反馈还是填写关键业务表单,了解如何实现和向最终用户展示表单总是一个有趣的挑战。在本章中,你将了解Angular 表单以及如何使用它们创建出色的用户体验。

在本章中,我们将要涵盖的食谱如下:

  • 创建带有验证的第一个模板驱动表单

  • 创建带有表单验证的第一个响应式表单

  • 在 Angular 中测试表单

  • 使用异步验证函数进行服务器端验证

  • 使用响应式FormArray实现复杂表单

  • 使用ControlValueAccessor编写自己的自定义表单控件

技术要求

对于本章的食谱,请确保你的设置按照'Angular-Cookbook-2E' GitHub 仓库中的'技术要求'完成。有关设置详情,请访问:github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/docs/technical-requirements.md。本章的起始代码位于github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/start/apps/chapter08

创建带有验证的第一个模板驱动表单

让我们从本食谱开始熟悉 Angular 表单。在这个食谱中,你将了解模板驱动表单的基本概念,并创建一个基本的 Angular 表单,使用模板驱动表单 API。

准备工作

我们将要工作的应用位于克隆的仓库中的start/apps/chapter08/ng-tdf

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-tdf 
    

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

    图 8.1:运行在 http://localhost:4200 的 ng-tdf 应用

如何做到这一点...

我们有一个已经包含了许多组件和一些 UI 设置的 Angular 应用。我们需要实现添加新版本到日志的功能。我们将使用模板驱动表单来允许用户选择一个应用并提交一个发布版本。让我们开始吧:

  1. 首先,我们将在项目的src/app/components/version-control/version-control.component.ts文件中添加FormsModule,如下所示:

    ...
    import { FormsModule } from '@angular/forms';
    ...
    @Component({
      ...,
      imports: [CommonModule, FormsModule, VcLogsComponent]
    })
    export class VersionControlComponent  {...} 
    
  2. 现在,我们将在组件的 HTML 文件中的表单输入上使用NgFormNgModel模板变量来使用模板驱动表单。按照以下方式更新version-control.component.html文件:

    <form #versionForm="ngForm">
    <div class="form-group mb-4">
    <label for="versionNumber">Version Number</label>
    <input [(ngModel)]="versionInput" name="version"
    type="text" class="form-control my-2 text-center"
    id="versionNumber" aria-describedby="versionHelp"
    placeholder="Enter version number">
        ...
      </div>
      ...
    </form> 
    
  3. 让我们创建一个当表单提交时会被触发的机制。按照以下方式更新version-control.component.ts文件:

    ...
    import { FormsModule, **NgForm** } from '@angular/forms';
    ...
    export class VersionControlComponent  {
      ...
      **formSubmit****(****form****:** **NgForm****) {**
    **this****.versionName = form.controls[****'version'****].value;**
    **}**
    } 
    
  4. 现在我们已经有了表单提交处理程序,让我们给模板添加事件监听器。更新version-control.component.html文件,如下所示:

    <form #versionForm="ngForm"
      (ngSubmit)="formSubmit(versionForm)">
      ...
    </form>
    <app-vc-logs [vName]="versionName"></app-vc-logs> 
    

    添加这个之后,你应该能够添加新的版本。然而,你可以为输入使用任何值,甚至不提供任何内容,它也会作为一个新版本添加。请参见以下截图以获取示例:

    图片

    图 8.2:没有表单验证的 ng-tdf 应用

  5. 我们将向前添加一些表单验证。首先,我们要确保只有当表单有有效输入时,我们才更改versionName属性的值。更新version-control.component.ts文件中的formSubmit方法,如下所示:

    export class VersionControlComponent  {
      ...
      formSubmit(form: NgForm) {
        if (!form.valid) {
          return;
        }
        this.versionName = form.controls['version'].value;
      }
    } 
    
  6. 我们现在将在模板文件中添加一些验证。我们将强制输入,并确保提供的版本遵循语义版本控制。更新version-control.component.html文件,如下所示:

    <form #versionForm="ngForm" (ngSubmit)="formSubmit(versionForm)">
    <div class="form-group mb-4">
    <label for="versionNumber">Version Number</label>
    <input [(ngModel)]="versionInput" pattern="(
    [0-9]+)\.([0-9]+)\.([0-9]+)" required name="version"
    type="text" class="form-control my-2 text-center"
    id="versionNumber" aria-describedby="versionHelp"
    placeholder="Enter version number">
        ...
      </div>
    <button type="submit" class="btn btn-primary">Submit</button>
    </form> 
    

    如果你现在尝试提交一个无效值的表单,你会看到提供的版本不会添加到日志中,但如果提供一个有效的版本,如2.1.0,它将被添加到日志中。然而,从用户体验的角度来看,这并不好,因为它没有告诉我们哪里出了问题。

  7. 让我们根据我们已有的表单验证显示一些错误消息。更新version-control.component.html文件,如下所示:

    <form #versionForm="ngForm" (ngSubmit)="formSubmit(versionForm)">
    <div class="form-group mb-4">
    <label for="versionNumber">Version Number</label>
    <input [(ngModel)]="versionInput" pattern="(
          [0-9]+)\.([0-9]+)\.([0-9]+)" required name="version"
      type="text" class="form-control my-2 text-center"
      id="versionNumber" aria-describedby="versionHelp"
      placeholder="Enter version number">
    <small id="versionHelp" class="form-text text-muted
          block mt-6">Use semantic versioning (x.x.x)</small>
    <small class="error block" *ngIf="versionForm.submitted
          && versionForm.controls['version'].errors?
          .['required']">
          Version number is required
        </small>
    <small class="error block"*ngIf="versionForm.submitted
          && versionForm.controls['version']
          .errors?.['pattern']">
          Version number does not match the pattern (x.x.x)
        </small>
    </div>
    <button type="submit" class="btn btn-
    primary">Submit</button>
    </form>
    <app-vc-logs [vName]="versionName"></app-vc-logs> 
    

    如果你现在提交一个没有任何输入的表单,你应该会看到以下错误:

    图片

    图 8.3:具有表单验证的 ng-tdf 应用

    如果你尝试给它错误的值,你会看到不同的错误。

    太好了!在几分钟内,我们就能够使用表单验证在 Angular 中创建我们的第一个模板驱动表单。如果你现在刷新应用并添加一些版本,你应该会看到以下内容:

    图片

    图 8.4:ng-tdf 应用最终输出

现在你已经知道了模板驱动表单是如何创建的,让我们看看下一节来了解它是如何工作的。

它是如何工作的…

在 Angular 中使用模板驱动表单的关键在于FormsModulengForm指令和ngModel绑定。这几乎总是包括模板变量以及模板中输入的name属性。我们首先在VersionControlComponent类中添加了FormsModule,这是使用ngForm指令和[(ngModel)]双向数据绑定所必需的。然后,我们将[(ngModel)]添加到版本输入中。我们还添加了ngForm属性到form元素上,同样创建了一个#versionForm变量,这样我们就可以用它来检查整个表单是否有效。然后,我们在form元素上添加了ngSubmit处理器,以便在表单提交时触发一个方法。我们还在VersionControlComponent类中添加了相应的formSubmit方法。请注意,我们将versionForm变量传递给formSubmit方法,这使得我们更容易测试其功能。提交表单时,我们在formSubmit方法中使用表单来获取输入值并创建一个新的version-log条目。请注意,我们通过使用form.controls['version']来访问控件/输入。关键版本直接对应于模板中输入元素的name属性值。ngForm会自动为我们创建一个FormControl实例,使用输入的name属性值,因此我们可以这样访问它。此外,请注意,如果您为新版本日志提供了无效的版本,应用程序将根据空输入或无效的版本格式显示相应的错误。这是因为我们在<input />元素上设置了required属性以及pattern属性。

参见

创建带有验证的第一个响应式表单

在前面的菜谱中,您已经了解了模板驱动表单,并且现在对使用它们构建 Angular 应用程序充满信心。现在,猜猜看?响应式表单甚至更好。Angular 社区中的许多知名工程师和企业推荐仅使用响应式表单来构建 Angular 应用程序。原因是,当涉及到复杂表单时,响应式表单使得管理变得容易,即您可以将表单的逻辑与模板解耦,并动态添加验证器等。在本菜谱中,您将构建您的第一个响应式表单,并学习其基本用法。

准备工作

我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter08/ng-reactive-forms目录下:

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令来运行项目:

    npm run serve ng-reactive-forms 
    

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

    图 8.5:运行在 http://localhost:4200 上的 ng-reactive-forms 应用

现在我们已经在本地运行了应用,让我们在下一节中查看这个食谱所涉及的步骤。

如何做到这一点...

到目前为止,我们有一个包含VcLogsComponent的应用,它显示了我们创建的一组版本日志。我们还有一个VersionControlComponent,它有一个表单,通过这个表单将创建发布日志。我们现在必须将我们的当前表单改为响应式表单,使用响应式表单 API。让我们开始吧:

  1. 首先,我们需要将ReactiveFormsModule导入到我们的AppModule的导入中。让我们通过修改app/components/version-control/version-control.component.ts文件来实现,如下所示:

    ...
    import { ReactiveFormsModule } from '@angular/forms';
    ...
    @Component({
      ...,
      imports: [CommonModule, VcLogsComponent,
      ReactiveFormsModule]
    })
    export class VersionControlComponent  {...} 
    
  2. 我们将很快创建响应式表单。首先,在version-control.component.ts文件中导入所需的依赖项,如下所示:

    import { CommonModule } from '@angular/common';
    import { Component } from '@angular/core';
    **import** **{**
    **FormControl****,**
    **FormGroup****,**
    **ReactiveFormsModule****,**
    **Validators****,**
    **}** **from****'@angular/forms'****;**
    ... 
    
  3. 现在,我们将在VersionControlComponent类中创建一个名为version的控件FormGroupFormGroup允许我们在表单中将多个控件组合在一起。按照以下方式修改version-control.component.ts文件:

    @Component({...})
    export class VersionControlComponent {
      versionInput = '';
      versionName = '0.0.0';
      **versionPattern =** **'([0-9]+)\.([0-9]+)\.([0-9]+)'****;**
    **versionForm =** **new****FormGroup****({**
    **version****:** **new** **FormControl****(****''****, [**
    **Validators****.required,**
    **Validators****.****pattern****(****this****.****versionPattern),**
    **]),**
    **});**
    } 
    
  4. 现在我们已经设置了响应式表单,让我们在模板中将versionForm绑定到<form>元素。按照以下方式更新version-control.component.html文件:

    <form **[****formGroup****]=****"versionForm"**>
      ...
    </form> 
    
  5. 太好了!现在我们已经绑定了表单组,我们还可以绑定version表单控件。进一步修改version-control.component.html文件,如下所示:

    <form [formGroup]="versionForm">
    <div class="form-group mb-4">
    <label for="versionNumber">Version Number</label>
    <input **formControlName****=****"version"** name="version"
      type="text" class="form-control my-2 text-center"
      id="versionNumber" aria-describedby="versionHelp"
      placeholder="Enter version number">
        ...
      </div>
      ...
    </form>
    ... 
    
  6. 让我们决定当提交这个表单时会发生什么。在模板中,我们将调用名为formSubmit的方法,并在表单提交时将versionForm传递给它。按照以下方式修改version-control.component.html文件:

    <form
      [formGroup]="versionForm"
     **(****ngSubmit****)=****"formSubmit(versionForm)"**>
      ...
    </form> 
    
  7. formSubmit方法还不存在。现在让我们在VersionControlComponent类中创建它。按照以下方式修改version-control.component.ts文件:

    ...
    @Component({...})
    export class VersionControlComponent {
      ...
      **formSubmit****(****form****:** **FormGroup****) {**
    **if** **(!form.****valid****) {**
    **return****;**
    **}**
    **this****.****versionName** **= form.****controls****[****'version'****].****value****;**
    **}**
    } 
    

    如果你现在尝试提交一个无效的值,你会看到提供的版本不会添加到日志中,但如果你提供一个有效的版本,如2.1.0,它将被添加到日志中。然而,从用户体验的角度来看,这并不好,因为它没有告诉我们哪里出了问题。

  8. 让我们根据我们已有的表单验证显示一些错误消息。按照以下方式更新version-control.component.html文件:

    <form [formGroup]="versionForm" (ngSubmit)="formSubmit(versionForm)">
    <div class="form-group mb-4">
    <label for="versionNumber">Version Number</label>
        ...
        <small class="error block" *******ngIf****=****"versionForm.dirty &&**
    **versionForm.controls.version.errors?.['required']"**>
          Version number is required
        </small>
    <small class="error block" *******ngIf****=****"versionForm.dirty &&**
    **versionForm.controls.version.errors?.['pattern']"**>
          Version number does not match the pattern (x.x.x)
        </small>
    </div>
    <button type="submit" class="btn btn-
    primary">Submit</button>
    </form>
    <app-vc-logs [vName]="versionName"></app-vc-logs> 
    

    如果你现在在表单中输入一些内容然后清除输入,你应该会看到一个错误,如下所示:

    图 8.6:具有表单验证的 ng-reactive-forms 应用

    如果你尝试给它错误的值,你会看到一个不同的错误。

太好了!在几分钟内,我们就能在 Angular 中创建带有表单验证的响应式表单。参见下一节了解它是如何工作的。

如何工作...

要在 Angular 中使用响应式表单,对于独立组件,我们需要在组件中导入ReactiveFormsModule,如果你使用模块,则在NgModule中导入。在配方中,我们正是这样做的,即在VersionControlComponent类的imports数组中导入ReactiveFormsModule。之后,我们创建一个FormGroup,这是一个响应式表单,它将一个对象作为构造函数的参数。传递给构造函数的对象可以具有进一步的FormControl,嵌套表单组,以及FormArray等,因为它们都继承自AbstractControl类,如图8.6所示。

图片

图 8.7:基类 AbstractControl 正在被继承

在我们的配方中,我们为version提供了一个键名version。在我们的配方中,FormControl构造函数方法接受两个参数;第一个是默认值,第二个是Validators数组。请注意,我们正在使用以下验证器:

Validators.required
Validators.pattern 

此外,请注意,我们用于验证语义版本的模式是([0-9]+)\.([0-9]+)\.([0-9]+)。然后我们在模板中的<form>元素上使用FormGroup(即versionForm变量),使用[formGroup]绑定。然后我们使用formControlName属性在模板中绑定version FormControl。我们使用<form>元素上的(ngSubmit)监听器处理表单提交。在VersionControlComponent类中,我们有formSubmit方法来处理表单提交。这也是我们在模板中绑定事件监听器的地方。当此方法被调用时,我们检查表单是否有效。

每个FormGroupFormArrayFormControl的验证发生在changeblursubmit事件上。由于它们继承自AbstractControl类,它们可以通过AbstractControl上的updateOn属性进行配置。在我们的情况下,验证默认发生在change上。如果表单无效,我们简单地不做任何事情。否则,我们设置versionName属性的值,这将在日志列表中添加一条新记录。

最后,我们展示了基于模板表单验证的错误,使用version表单控件上的errors属性。当对表单进行任何初始更改后,表单控件上的errors对象将required属性设置为true

当用户向输入框添加值时,required属性被移除,并触发pattern验证,如果模式([0-9]+)\.([0-9]+)\.([0-9]+)与输入值不匹配,我们将在表单控件的errors对象中添加pattern属性。注意,在步骤 8中,我们使用errors对象中的这些属性来显示相关错误。

参见

在 Angular 中测试表单

为了确保我们为最终用户构建健壮且无错误的表单,拥有与您的表单相关的测试是一个非常好的主意。这使得代码更具弹性,更不容易出错。在这个菜谱中,您将学习如何使用单元测试测试您的模板驱动表单。

准备工作

我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter08/ng-testing-forms目录内:

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目及其后端服务器:

    npm run serve ng-testing-forms with-server 
    

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

    图片

    图 8.8:ng-testing-forms 应用程序在 http://localhost:4200 上运行

现在我们已经在本地上运行了应用程序,让我们在下一节中查看这个菜谱涉及的步骤。

如何做到这一点...

我们有一个包含三个不同组件的应用程序。第一个有一个模板驱动表单,第二个有一个带有验证的响应式表单,第三个有一个带有同步和异步验证的响应式表单。这三个组件的实现都是相同的,除了第三个组件的验证会在表单变脏时立即启动,而不是等待提交。表单也对输入应用了验证。让我们开始探讨如何测试这个表单:

  1. 首先,运行以下命令来运行单元测试:

    npm run test ng-testing-forms 
    

    命令运行后,您应该在控制台上看到所有测试通过,如下所示:

    图片

    图 8.9:使用 ng-testing-forms 运行的单元测试

  2. 让我们编写模板驱动表单的测试。这很简单。我们将更新version-control-tdf.component.spec.ts文件,如下所示:

    ...
    describe('VersionControlTdfComponent', () => {
      ...
      it('should create', () => {...});
      **it****(****'should submit the form with valid version'****,** **() =>** **{**
    **component.****versionForm****.****controls****[****'version'****]**
    **.****setValue****(****'2.2.4'****);**
    **fixture.****debugElement****.****nativeElement****.****querySelector****(****'button'****).****click****();**
    **expect****(component.****versionName****).****toBe****(****'2.2.4'****);**
    **});** 
    

    如果您现在查看测试,您应该看到所有测试都通过,如下所示:

    图片

    图 8.10:模板驱动表单的第一个测试通过

  3. 让我们在文件中添加另一个测试。这个测试将检查我们是否能够看到必填输入的错误消息。让我们更新version-control-tdf.component.spec.ts文件,如下所示:

    it('should show required error', () => {
        component.versionForm.controls['version']
          .setValue('2.2.4');
        fixture.detectChanges();
        component.versionForm.controls['version'].setValue('');
        fixture.detectChanges();
        fixture.debugElement.nativeElement
     .querySelector('button').click();
        fixture.detectChanges();
        expect(component.versionName).toBe('0.0.0');
        expect(
          fixture.debugElement.nativeElement
            .querySelector('.error')
            .textContent.trim()
        ).toBe('Version number is required');
      }); 
    
  4. 让我们再添加一个测试。这个测试将检查我们是否能够看到版本模式有效时的消息。将以下测试添加到同一文件中:

    it('should show pattern error', () => {
        component.versionForm.controls['version']
          .setValue('2.2.4');
        fixture.detectChanges();
        component.versionForm.controls['version']
          .setValue('invalid input');
        fixture.detectChanges();
        fixture.debugElement.nativeElement
     .querySelector('button').click();
        fixture.detectChanges();
        expect(component.versionName).toBe('0.0.0');
        expect(
          fixture.debugElement.nativeElement
            .querySelector('.error')
            .textContent.trim()
        ).toBe('Version number does not match the pattern (x.x.x)');
      }); 
    

    太好了!你应该看到所有测试仍然通过。有趣的事实是,对于只包含同步验证器的响应式表单(Reactive form)示例,测试也是相同的。

  5. 将我们从步骤 2步骤 4添加的所有三个测试复制到文件version-control-rf.component.spec.ts中。

  6. 现在是棘手的部分,即处理async验证器。我们不能简单地复制粘贴这个测试,并期望在表单中提供错误版本时测试通过。在这种情况下,我们需要做一些额外的工作。首先,将version-control-tdf.component.spec.ts文件中的相同测试复制到version-control-rf-async.component.spec.ts文件中。

    你会注意到现在有两个测试失败了,如下所示:

    图 8.11:具有异步验证器的表单测试失败,使用服务

  7. 我们将模拟 FormValidationService 进行我们的测试。按照以下方式更新 version-control-rf-async.component.spec.ts 文件:

    import { provideHttpClient } from '@angular/common/http';
    import { ComponentFixture, TestBed } from '@angular/core/testing';
    **import** **{** **of** **}** **from****'rxjs/internal/observable/of'****;**
    **import** **{** **FormValidationService** **}** **from****'../../form-validation.service'****;**
    import { VersionControlRfAsyncComponent } from './version-control-rf-async.component';
    **const****FormValidationServiceMock** **= {**
    **versionValidator****() {**
    **return****() =>****of****(****null****);**
    **},**
    **};** 
    
  8. 让我们将我们的模拟服务提供给同一文件中的 TestBed 进行测试,如下所示:

    ...
    describe('VersionControlRfAsyncComponent', () => {
     ...
      beforeEach(async () => {
        await TestBed.configureTestingModule({
          imports: [VersionControlRfAsyncComponent],
          providers: [
            provideHttpClient(),
            **{**
    **provide****:** **FormValidationService****,**
    **useValue****:** **FormValidationServiceMock****,**
    **},**
          ],
        }).compileComponents();
        ...
      });
    }); 
    

    通过上述更改,测试 '应该提交带有有效版本的表单''应该显示必需错误' 应该通过。太好了!然而,最后一个测试失败了。我们将修复它。

  9. 让我们更新最终的失败测试。我们将更新我们的 FormValidationServiceMock 对象,使其在输入无效时返回一个 pattern 错误。按照以下方式更新文件:

    ...
    **import** **{** **AbstractControl** **}** **from****'@angular/forms'****;**
    import { of } from 'rxjs/internal/observable/of';
    import { FormValidationService } from '../../form-validation.service';
    import { VersionControlRfAsyncComponent } from './version-control-rf-async.component';
    const FormValidationServiceMock = {
      versionValidator(**control****:** **AbstractControl**) {
        return () => {
          **if** **(control.****value** **===** **'invalid input'****) {**
    **return****of****({** **pattern****:** **true** **});**
    **}**
    return of(null);
        };
      }}; 
    

    哇!现在所有的测试都应该通过了,如下所示:

    图 8.12:配方中所有测试通过

太棒了!你现在已经了解了一堆测试你的 Angular 表单的技术。然而,其中一些技术可能还需要一些解释。参见下一节了解这一切是如何工作的。

它是如何工作的…

测试 Angular 表单可能有点挑战性,因为它取决于表单的复杂程度,你想要测试哪些用例,以及这些用例的复杂程度。对于需要处理多个依赖项的情况也是如此。在我们的配方中,我们从模板驱动的表单开始,对我们来说测试它们很容易,因为组件有一个 ViewChild() 类型为 NgForm。这使得我们编写第一个测试来设置 version 表单控件的值变得容易。然后,我们点击 提交 按钮,并期望组件的 versionName 属性值是我们表单中输入的那个。简单得很!

对于第二次测试,我们首先将表单的值设置为 2.2.4,然后通过将值设置为空字符串来清空表单。然后,我们提交表单并期望组件的 versionName 属性保持不变,仍然是 0.0.0。然而,我们还检查的是,在表单内部,我们可以看到显示 '版本号是必需的' 错误。请注意,我们使用 fixture.debugElement.nativeElement.querySelector 方法获取所需的错误元素,然后检查其 textContent 值。

为了检查模式,我们与第二次测试相同,但不是清空输入,而是将 '``invalid input' 作为新值提供给表单控件。由模板驱动的表单选择它并显示错误 '版本号不匹配模式(x.x.x)',这是我们测试中期望的。

与我们之前在 Reactive Forms 示例中所使用的三个测试完全相同。这次的不同之处在于,组件中的 versionForm 属性不是 NgForm,而是 FormGroup。令人惊讶的是,Angular 在 NgFormFormGroup 中都提供了相同的 API(即相同的方法)来设置和检索值。这是因为它在幕后创建了一个顶层的 FormGroup,从而使我们的测试保持一致。

对于使用 async 验证器的测试,我们做了些特别的事情。除了从其他示例中复制粘贴测试之外,我们还需要模拟 FormValidationService。这是因为这个示例有一个来自 FormValidationService 类的异步验证函数,并且模拟单元的所有依赖项是一个通用的最佳实践,这样我们就可以纯粹地测试它。

我们首先创建一个名为 FormValidationServiceMock 的存根(我们将在 第十章 中学习更多关于存根的知识,使用 Jest 在 Angular 中编写单元测试)。然后,我们将这个存根提供给 TestBed,针对 FormValidationService 类。最后,我们确保存根中的 versionValidator 方法在输入无效时返回正确的错误。就这样。在测试本身没有太多变化的情况下,我们能够通过微小的调整测试所有三种类型的表单。我希望你在本菜谱中学到了很多技术。

参见

使用异步验证函数进行服务器端验证

在 Angular 中,表单验证非常直接,原因在于 Angular 提供的超级棒的验证器。这些验证器是同步的,这意味着只要你改变输入,验证器就会立即启动并提供有关值有效性的信息。然而,有时你可能依赖于来自后端 API 的某些验证,或者需要执行一些异步逻辑来验证表单值。这些情况将需要称为异步验证器的东西。在这个菜谱中,你将创建你的第一个异步验证器。

准备工作

我们将要工作的应用位于克隆的仓库中的 start/apps/chapter08/ng-rf-async-validator

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目及其后端服务器:

    npm run serve ng-rf-async-validator with-server 
    

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

    图片

    图 8.13:异步验证器应用在 http://localhost:4200 上运行

现在我们已经运行了应用,让我们在下一节中查看本菜谱所涉及的步骤。

如何做到这一点...

我们已经有一个后端,它有一个可以接受版本作为查询参数的端点,返回一个boolean,表示提供的版本是否根据模式(x.x.x)是有效的版本,并且比之前提供的版本新。我们将创建一个async验证器来确保新版本有一个有效的版本。让我们开始:

  1. 首先,我们将修改version.service.ts文件中的VersionService类,如下所示,以添加一个通过http://localhost:3333/api/version/validate端点验证表单输入的方法:

    ...
    export class VersionService {
      ...
      **validateVersion****(****version****:** **string****):** **Observable****<{** **error****:**
    **string** **}> {**
    **return****this****.****http****.****get****<{** **error****:** **string** **}>(**
    **`****${****this****.apiBaseUrl}****/validate?val=****${version}****`**
    **);**
    **}**
    submitVersion(version: string): Observable<{ success:
      boolean }> {...);
      }
    } 
    
  2. 现在,我们将在VersionService类中创建一个异步验证函数AsyncValidatorFn)。这是我们稍后将在应用程序中的表单上绑定的事情。让我们更新version.service.ts文件,如下所示:

    import { HttpClient } from '@angular/common/http';
    import { Injectable, inject } from '@angular/core';
    import { Observable } from 'rxjs/internal/Observable';
    **import** **{** **AbstractControl****,** **AsyncValidatorFn****,** **ValidationErrors** **}** **from****'@angular/forms'****;**
    **import** **{ timer, switchMap, map }** **from****'rxjs'****;**
    ...
    export class VersionService {
      ...
    
    **versionValidator****():** **AsyncValidatorFn** **{**
    **return** **(****control****:** **AbstractControl****):** **Observable****<**
    **ValidationErrors** **|** **null** **> => {**
    **return****timer****(****500****).****pipe****(**
    **switchMap****(****() =>****this****.****validateVersion****(**
    **control.****value****)),**
    **map****(****(****{ error }****) =>** **{**
    **const****errors****:** **ValidationErrors** **= {};**
    **if** **(error ===** **null****) {**
    **return****null****;**
    **}**
    **errors[error] =** **true****;**
    **return** **errors;**
    **})**
    **);**
    **};**
    **}**
      ...
    } 
    
  3. 我们将使用我们刚刚在VersionControlComponent类中创建的versionValidator方法,与versionForm一起使用。为此,让我们修改version-control.component.ts文件,如下所示:

    import { Component, **OnInit**, **inject** } from '@angular/core';
    ...
    export class VersionControlComponent **implements****OnInit** {
      ...
      **ngOnInit****() {**
    **this****.****versionForm****.****controls****.****version****.****setAsyncValidators****(**
    **this****.****versionService****.****versionValidator****()**
    **);**
    **}**
    formSubmit(form: FormGroup) {...}
    } 
    

    如果你现在尝试提交一个等于或低于上次提交版本的版本,你将收到一个错误,如下所示:

    图片

    图 8.14:提供较低或相等的版本号时显示的错误

太棒了!所以,你现在知道如何在 Angular 中创建异步验证函数来进行响应式表单验证。既然你已经完成了这个菜谱,请参考下一节,看看它是如何工作的。

它是如何工作的...

Angular 提供了一个轻松创建async验证函数的方法,而且它们也很方便。当我们需要执行可能耗时的验证且不想阻塞主线程,或者我们依赖于后端服务进行验证时,我们会使用async验证函数。例如,在这个菜谱中,我们首先在一个名为VersionService的新服务中创建了一个名为versionValidator的验证器方法。请注意,我们在该方法中使用了一些 RxJS 运算符,包括timerswitchMapmap。我们使用timer运算符来去抖动并等待用户停止输入500毫秒。然后,我们使用switchMap运算符与validateVersion方法结合,向后端发起 HTTP 调用,验证版本。switchMap在这里的好处是它会取消现有的 HTTP 调用(如果有的话)。然后,我们使用map运算符从 HTTP 调用中移除错误。如果没有错误,我们从map函数的回调中返回一个空对象。如果有错误,我们在errors对象中将[error]设置为true,如步骤 4所示。

创建验证函数后,我们通过在VersionControlComponent类中使用FormControl.setAsyncValidators方法将其附加到版本名称的表单控件上。然后,我们在模板中使用名为patternoldVersion的验证错误来显示相关的错误消息。

参见

使用响应式 FormArray 实现复杂表单

这无疑是第一版 Angular Cookbook 读者最常请求的食谱之一。在这个食谱中,我们将与响应式表单一起工作,特别是响应式表单中的 FormArray 类。我们将实现一个复杂的表单,该表单可以提交一系列项目。用户将能够添加他们想要的任意数量的项目,并且可以删除他们不需要的项目。

准备工作

我们将要工作的应用位于克隆的仓库中的 start/apps/chapter08/ng-form-arrays 目录内:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-form-arrays 
    

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

    图 8.15:运行在 http://localhost:4200 的响应式 FormArray 应用

现在我们已经在本地运行了应用,让我们在下一节中查看这个食谱所涉及的步骤。

如何操作...

我们有一个已经实现了响应式表单的应用。然而,到目前为止我们只能添加一个项目。我们将使用响应式 FormArray 允许用户提交多个项目,并且我们将使用响应式 FormBuilder,这是 @angular/forms 包中的一个很好的小 API,它使得在 Angular 中创建和维护响应式表单变得容易。让我们开始吧:

  1. 首先,让我们使用响应式 FormBuilderportfolio-form.component.ts 文件中向现有的响应式表单添加一个 FormArray,如下所示:

    ...
    import { FormBuilder, ReactiveFormsModule, Validators, **FormGroup**, **FormControl** } from '@angular/forms';
    ...
    export class PortfolioFormComponent {
      fb = inject(FormBuilder);
      portfolioForm = this.fb.group({
        name: ['', Validators.required],
        bio: [''],
        **projects****:** **this****.****fb****.****array****<**
    **FormGroup****<{**
    **label****:** **FormControl****<****string** **|** **null****>;**
    **url****:** **FormControl****<****string** **|** **null****>;**
    **}>**
    **>([]),**
      })
      **get****projectsFormArr****() {**
    **return****this****.****portfolioForm****.****controls****.****projects****;**
    **}**
      ...
    } 
    
  2. 现在我们有了 projects FormArray,让我们在模板中使用它,以便我们可以根据表单数组显示项目的表单输入。让我们修改 portfolio-form.component.html 文件,如下所示:

    <div class="flex gap-4 items-center flex-col md:flex-row">
    <form **[****formGroup****]=****"portfolioForm"** class="flex-1 w-full
        md:w-auto">
    <section>...</section>
        ...
        <section **formArrayName****=****"projects"**>
    <label class="text-sm">Projects</label>
    <fieldset class="flex gap-4 mb-4 justify-between" 
            ***ngFor****=****"let projectControl of**
    **projectsFormArr.controls; trackBy: trackByFn"**
    **[****formGroup****]=****"projectControl"****>**
    <input **formControlName****=****"label"** **type="text"**
      class="form-control" placeholder="Enter label">
    <input **formControlName****=****"ur****l" type="url**"
      class="form-control" placeholder="Enter URL">
    </fieldset>
    </section>
    </form>
      ...
    </div> 
    

    你会注意到应用右侧的对象现在有一个 projects 数组。然而,我们目前看不到任何关于项目的输入。这是因为表单数组是空的,如下面的图所示:

    图 8.16:运行在 http://localhost:4200 的响应式 FormArray 应用

  3. 当应用启动时,我们将默认向表单数组中添加一个表单组。为此,修改 portfolio-form.component.ts 文件,如下所示:

    import { Component, **OnInit**, inject } from '@angular/core';
    ...
    export class PortfolioFormComponent**implements****OnInit** {
      ...
      **ngOnInit****():** **void** **{**
    **this****.****addNewProject****();**
    **}**
    **addNewProject****() {**
    **this****.****projectsFormArr****.****push****(**
    **this****.****fb****.****group****({**
    **label****: [****''****,** **Validators****.****required****],**
    **url****: [****''****,** **Validators****.****required****]**
    **})**
    **)**
    **}**
    **...**
    } 
    

    如果你现在查看应用,你应该会看到一组项目的输入,如下面的图所示:

    图 8.17:默认情况下的一组项目输入

  4. 让我们添加一个按钮,让用户能够添加更多项目。我们将修改 portfolio-form.component.html 文件,如下所示:

    <section formArrayName="projects">
    <label class="text-sm">Projects</label>
    <fieldset [formGroup]="projectControl" class="flex
            gap-4 mb-4 justify-between" *ngFor="let
            projectControl of projectsFormArr.controls;
     **let isLast = last;** trackBy: trackByFn">
            ...
            <input formControlName="url" type="url" class="form-
              control" placeholder="Enter URL">
    **<****button****type****=****"button"** **[****style.visibility****]=****"isLast**
    **? 'visible' : 'hidden'"**
    **(****click****)=****"addNewProject()"****>****+****</****button****>**
    </fieldset>
    </section> 
    

    通过上面的代码,你应该会在点击 + 按钮时看到新的项目输入出现。

  5. 让我们为这个菜谱添加最后一个特性,即显示用于移除项目输入行的按钮。我们首先在 portfolio-form.component.ts 文件中添加一个方法,该方法接收要移除的行的索引作为参数:

    ...
    export class PortfolioFormComponent implements OnInit {
      ...
      addNewProject() {...}
      **removeProject****(****index****:** **number****) {**
    **this****.****projectsFormArr****.****removeAt****(index);**
    **}**
      ...
    } 
    
  6. 现在,我们将更新模板以添加 Remove 按钮。更新 portfolio-form.component.html 文件,如下所示:

    <section formArrayName="projects">
    <label class="text-sm">Projects</label>
    <fieldset [formGroup]="projectControl" class="flex
            gap-4 mb-4 justify-between" *ngFor="let
            projectControl of projectsFormArr.controls; let
            isLast = last; **let i = index;** trackBy: trackByFn">
    <input formControlName="label" type="text"
      class="form-control" placeholder="Enter label">
    <input formControlName="url" type="url" class="form-
              control" placeholder="Enter URL">
    **<****button****type****=****"button"** **[****hidden****]=****"****isLast"**
    **(****click****)=****"removeProject(i)"****>****-****</****button****>**
    <button type="button" [style.visibility]="isLast ?
              'visible' : 'hidden'" [hidden]="!isLast"
      (click)="addNewProject()">+</button>
    </fieldset>
    </section> 
    

    如果你现在查看应用程序,你应该能够根据需要添加或移除项目到表单中,如下面的图所示:

    图片

    图 8.18:带有表单数组的最终结果

太棒了!你现在已经知道如何使用响应式 FormArray。参考下一节了解它是如何工作的。

它是如何工作的...

Angular 的 FormArray 是 Angular 响应式表单中内置的一个神奇工具。它将每个子 FormControlFormGroup 的所有值聚合到一个数组中。FormArray 的美妙之处在于,如果任何一个子 FormControls 无效,整个数组都会变为无效。在这个菜谱中,我们首先在 PortfolioFormComponent 类中使用 ReactiveFormsModule 中的 FormBuilder。我们用空 FormArray 初始化它,然后在组件挂载时(使用 ngOnInit 方法)向其中推送一个 FormGroup。这样我们就可以看到一组项目表单输入。请注意,我们使用 addNewProject 方法来做这件事。还请注意,我们使用了一个名为 projectsFormArrgetter 函数,这样我们就可以轻松访问 FormArray,以便在需要时遍历它、向其中添加表单组以及从其中移除所需的 FormGroups。然后我们遍历 projectsFormArr.controls 数组,以便通过 HTML 模板使用 *ngFor 指令显示输入。

然后,我们修改模板以添加 + 按钮,这样我们就可以向表单数组中添加更多项目。注意 *ngFor 中的语句有 let isLast = last*ngFor 指令自动提供第一个和最后一个变量,因此我们可以在模板中使用它们。这个语句为我们创建了一个名为 isLast 的变量,并将 *ngFor 指令提供的 last 变量的值(一个布尔值)赋给它。这样我们就可以只在最后一行显示 + 按钮,而不是在每一行都显示。

最后,我们将 remove 按钮添加到除了最后一行之外的所有行,这样我们就可以从 FormArray 中移除相应的 FormGroup。请注意,在这里我们还在模板中的 *ngFor 指令中添加了 let i = index。这是因为我们的 TypeScript 文件中的 removeProject 方法在调用时期望传入 FormArrayFormGroup 的索引。因此,我们能够从 *ngFor 指令中获取每个 FormGroup 的索引,并将其传递给 removeProject 方法。我们在每个 remove 按钮上使用 [hidden]="isLast" 以使其仅在最后一行隐藏。这是因为我们在最后一行显示了 + 按钮,并且我们需要至少有一行空行用于项目。

参见

使用 ControlValueAccessor 编写自定义表单控件

Angular 表单很棒。虽然它们支持默认的 HTML 标签如inputtextarea等,但有时你可能想定义自己的组件,这些组件可以从用户那里获取值。如果你的组件能够无缝地与 Angular 表单一起工作,即使用formControlNamengModel等,那会怎么样?那会很酷,对吧?

在这个菜谱中,你将学习如何使用ControlValueAccessor API 创建一个具有自定义表单控件的组件,允许你使用你的组件与模板驱动表单和响应式表单一起使用。

准备中

我们将要工作的应用位于克隆的仓库start/apps/chapter08/ng-form-cva中:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-form-cva 
    

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

    图片

    图 8.19:运行在 http://localhost:4200 上的自定义表单控件应用

现在我们已经在本地运行了应用,让我们在下一节中查看这个菜谱涉及的步骤。

如何操作...

我们有一个简单的 Angular 应用。它有两个输入和一个提交按钮。输入用于评论,并要求用户为这个假想项目的评分提供一个值,以及用户想要提供的任何评论。我们将使用ControlValueAccessor API 将评分输入转换为自定义FormControl。让我们开始吧:

  1. 让我们为我们的自定义FormControl创建一个组件。在项目根目录中打开终端,并运行以下命令,当被询问时选择@nx/angular:component schematics和“按提供”:

    cd start && nx g c rating --directory apps/chapter08/ng-form-cva/src/app/components/rating 
    
  2. 我们现在将为rating组件创建star UI。按照以下方式修改rating.component.html文件:

    <div class="rating">
    <div class="rating__star" [ngClass]="{
          'rating__star--active':
            (!isMouseOver && value >= star) ||
            (isMouseOver && hoveredRating >= star),
            '!cursor-default': disabled
        }" (mouseenter)="onRatingMouseEnter(star)"
      (mouseleave)="onRatingMouseLeave()"
             (click)="selectRating(star)"
        *ngFor="let star of [1, 2, 3, 4, 5]; let i = index">
    <span class="material-symbols-outlined">
          star
        </span>
    </div>
    </div> 
    
  3. rating组件的样式添加到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;
        }
      }
    } 
    
  4. 我们还需要修改RatingComponent类,以引入必要的方法和属性。按照以下方式修改rating.component.ts文件:

    /* eslint-disable @typescript-eslint/no-empty-function */
    import { CommonModule } from '@angular/common';
    import { Component } from '@angular/core';
    @Component({
      ...
      standalone: true,
      imports: [CommonModule]
    })
    export class RatingComponent {
      value = 2;
      hoveredRating = 2;
      isMouseOver = false;
      onRatingMouseEnter(rating: number) {
        this.hoveredRating = rating;
        this.isMouseOver = true;
      }
      onRatingMouseLeave() {
        this.hoveredRating = 0;
        this.isMouseOver = false;
      }
      selectRating(rating: number) {
        this.value = rating;
      }
    } 
    
  5. 由于RatingComponent是一个standalone组件,我们需要在HomeComponent类中导入RatingComponent类。更新home.component.ts文件,如下所示:

    ...
    **import** **{** **RatingComponent** **}** **from****'../components/rating/rating.component'****;**
    @Component({
      selector: 'app-home',
      templateUrl: './home.component.html',
      styleUrls: ['./home.component.scss'],
      standalone: true,
      imports: [CommonModule, ReactiveFormsModule,
      **RatingComponent**]
    })
    export class HomeComponent {
      ...
    } 
    
  6. 现在,我们需要使用这个rating组件来替换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.20:带有悬停星星的评分组件

  7. 现在我们来实现ControlValueAccessor接口,用于我们的rating组件。它需要实现几个方法。按照以下方式修改rating.component.ts文件:

    **/* eslint-disable @typescript-eslint/no-empty-function */**
    import { CommonModule } from '@angular/common';
    import { Component } from '@angular/core';
    **import** **{** **ControlValueAccessor** **}** **from****'@angular/forms'****;**
    @Component({...})
    export class RatingComponent**implements****ControlValueAccessor** {
      ...
      isMouseOver = false;
      **onChange****:** **any** **=** **() =>** **{ };**
    **onTouched****:** **any** **=** **() =>** **{ };**
      ...
      **registerOnChange****(****fn****:** **any****){**
    **this****.****onChange** **= fn;**
    **}**
    **registerOnTouched****(****fn****:** **any****) {**
    **this****.****onTouched** **= fn;**
    **}**
    } 
    
  8. 我们现在将添加必要的函数来在需要时禁用输入,并设置表单控件的值——换句话说,就是setDisabledStatewriteValue方法。我们还将向RatingComponent类添加disabled属性,如下所示:

    ...
    import { Component, **Input** } from '@angular/core';
    ...
    export class RatingComponent implements ControlValueAccessor {
      ...
      isMouseOver = false;
      **disabled =** **false****;**
      ...
      **setDisabledState****(****isDisabled****:** **boolean****):** **void** **{**
    **this****.****disabled** **= isDisabled;**
    **}**
    **writeValue****(****value****:** **number****) {**
    **this****.****value** **= value;**
    **}**
    } 
    
  9. 我们需要使用disabled属性来防止当它为true时进行任何 UI 更改。value变量的值也不应该被更新。修改rating.component.ts文件以实现这一点,如下所示:

    ...
    @Component({...})
    export class RatingComponent implements OnInit, ControlValueAccessor {
      ...
      isMouseOver = false;
      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;
      }
      ...
    } 
    

    通过上述更改,你会注意到由于disabled属性被设置为true,现在悬停在星星上不会做任何事情。

  10. 确保我们将value变量的值发送到ControlValueAccessor,因为这是我们稍后想要访问的内容。同时,让我们将disabled属性重置为false。按照以下方式更新RatingComponent类中的selectRating方法:

    ...
    export class RatingComponent implements ControlValueAccessor {
      ...
      @Input() disabled = **false**;
      constructor() { }
      ...
      selectRating(rating: number) {
        if (this.disabled) return;
        this.value = rating;
        this.onTouched();
        **this****.****onChange****(rating);**
      }
      ...
    } 
    
  11. 我们需要告诉 Angular 我们的RatingComponent类有一个value访问器;否则,在<app-rating>元素上使用formControlName属性将引发错误。让我们在RatingComponent类的装饰器中添加一个NG_VALUE_ACCESSOR提供者,如下所示:

    import { Component, **forwardRef**, Input, OnInit } from '@angular/core';
    import { ControlValueAccessor**,** **NG_VALUE_ACCESSOR** } from '@angular/forms';
    @Component({
      ...
      imports: [CommonModule],
      **providers****: [{**
    **provide****:** **NG_VALUE_ACCESSOR****,**
    **useExisting****:** **forwardRef****(****() =>****RatingComponent****),**
    **multi****:** **true**
    **}]**
    })
    ... 
    

    如果你现在刷新应用程序,选择一个评分,并点击提交按钮,你应该会看到如下所示的值被记录:

    图片

    图 8.21:使用自定义表单控件记录的表单值

哇!你刚刚学会了如何使用ControlValueAccessor创建自定义表单控件。参考下一节以了解它是如何工作的。

它是如何工作的…

我们从创建一个可以用来为我们提交的评论提供评分的组件开始。我们首先添加了rating组件的模板和样式。请注意,我们正在使用[ngClass]指令在每个star元素上条件性地添加rating__star--active类。现在让我们讨论每个条件:

  • (isMouseOver && hoveredRating >= star): 这个条件依赖于isMouseOverhoveredRating变量。isMouseOver变量在我们鼠标悬停在任意星星上时变为true,当我们从星星移开时变为false。这意味着它只有在悬停在星星上时才是truehoveredRating告诉我们我们在任何时刻悬停在哪个星星上,并分配给星星的值——换句话说,一个从15的值。因此,这个条件只有在鼠标悬停时才为true,并且悬停的星星的评分大于当前星星的值。所以,如果我们悬停在第四颗星星上,所有从值14的星星都会被突出显示,因为它们将条件性地分配rating__star--active类。

  • (!isMouseOver && value >= star): 这个条件依赖于我们之前讨论的isMouseOver变量和value变量。value变量包含所选评分的值,当我们在星星上点击时更新。因此,当我们在不进行鼠标悬停的情况下,并且value变量的值大于当前星星时,这个条件被应用。当你将更大的值分配给value变量并尝试悬停在具有较小值的星星上时,这尤其有益,在这种情况下,所有值大于悬停星星的星星都不会被突出显示。

然后,我们为每个星星使用了三个事件,mouseentermouseleaveclick,然后分别使用我们的onRatingMouseEnteronRatingMouseLeaveselectRating方法处理这些事件。所有这些设计都是为了确保整个 UI 流畅并提供良好的用户体验。然后我们为我们的rating组件实现了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;**
} 

如果我们不想让用户为评分提供值呢?换句话说,我们希望评分表单控件被禁用。为此,我们使用ControlValueAccessor接口中的setDisabledState方法,这样每当表单控件的disabled状态发生变化时,我们就设置disabled属性以反映这些更改。

最后,我们希望 Angular 知道这个RatingComponent类有一个值访问器。这样我们就可以使用响应式表单 API——具体来说,使用<app-rating>选择器的formControlName属性——并将其用作表单控件。为此,我们将我们的RatingComponent类作为提供者提供给其@Component定义装饰器,使用NG_VALUE_ACCESSOR注入令牌,如下所示:

@Component({
  ...
  **providers****: [{**
**provide****:** **NG_VALUE_ACCESSOR****,**
**useExisting****:** **forwardRef****(****() =>****RatingComponent****),**
**multi****:** **true**
 **}]**
}) 

注意,我们正在使用useExisting属性,并通过forwardRef方法提供我们的RatingComponent类。我们需要提供multi: true,因为 Angular 本身使用NG_VALUE_ACCESSOR注入令牌注册了一些值访问器,并且可能还有第三方表单控件。

一切设置完成后,我们在 home.component.html 文件中的 rating 组件上使用 formControlName,如下所示:

<app-rating **formControlName****=****"rating"**></app-rating> 

参见

在 Discord 上了解更多

要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新书发布——请扫描下面的二维码:

packt.link/AngularCookbook2e

第九章:Angular 和 Angular CDK

Angular 拥有一个令人惊叹的工具和库生态系统,包括 Angular Material、Angular 命令行界面Angular CLI)以及备受喜爱的 Angular 组件开发工具包Angular CDK)。我称之为“备受喜爱”,因为如果你想在 Angular 应用中实现自己的自定义交互和行为,而不必依赖于整个库集,Angular CDK 将成为你的最佳拍档。在本章中,你将了解 Angular 和 Angular CDK 的惊人组合。你将了解 CDK 中内置的一些巧妙组件,并使用一些 CDK API 创建令人惊叹且优化的内容。

在本章中,我们将涵盖以下食谱:

  • 使用虚拟滚动处理大量列表

  • 列表的键盘导航

  • 使用 Overlay API 的尖角小弹出框

  • Angular CDK 的输入强制转换实用工具

  • 使用 CDK 拖放 API 将项目从一个列表移动到另一个列表

  • 使用 CDK 步进器 API 创建多步游戏

  • 使用 CDK 列表框指令进行可访问的列表框交互

  • 使用 Angular CDK 菜单 API 处理嵌套菜单

技术要求

对于本章的食谱,请确保你的设置已按照 'Angular-Cookbook-2E' GitHub 仓库中的 '技术要求' 完成。有关设置详情,请访问:github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/docs/technical-requirements.md。本章的起始代码位于 github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/start/apps/chapter09

使用虚拟滚动处理大量列表

在你的应用中可能存在某些场景,你可能需要显示大量项目。这可能来自你的后端 API 或浏览器的本地存储。在任一情况下,一次性渲染大量项目会导致性能问题,因为 文档对象模型DOM)会挣扎,也因为 JS 线程被阻塞,页面变得无响应。在本食谱中,我们将渲染一个包含 270,000 个用户的列表,并使用 Angular CDK 的 虚拟滚动 功能来提高渲染性能。

准备工作

我们将要工作的应用位于克隆的仓库中的 start/apps/chapter09/ng-cdk-virtual-scroll

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-cdk-virtual-scroll 
    

    这应该会在新浏览器标签页中打开应用。当你点击 获取数据 按钮时,你应该看到以下内容:

    img/B18469_09_01.png

    图 9.1:运行在 http://localhost:4200 的 ng-cdk-virtual-scroll 应用

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

如何做到这一点...

我们有一个相当简单的 Angular 应用,但数据量很大。目前,它显示一个加载器(按钮),大约持续几秒钟,然后应该显示数据。然而,你会注意到在 Tap me 按钮消失后,加载器仍然显示,按钮无响应,我们看到一个空白屏幕,如下所示:

图片

图 9.2:渲染列表项时应用卡在空白屏幕

实际上,我们的整个应用变得无响应。如果你滚动通过项目,或者甚至悬停在项目上,你会看到列表项上的悬停动画不流畅,有点卡顿。让我们看看使用 Angular CDK 虚拟滚动来提高渲染性能的步骤,如下所示:

  1. 我们已经在工作区中安装了 Angular CDK。然而,如果你需要安装它,你将需要在项目的根目录中运行以下命令:

    cd start && npm install --save @angular/cdk 
    
  2. 如果你重新安装了包,可能需要重新启动你的 Angular 服务器,所以再次运行 npm run serve ng-cdk-virtual-scroll 命令。

  3. ScrollingModule 类从 @angular/cdk 包导入到 users-list.component.ts 文件中,如下所示:

    ...
    **import** **{** **ScrollingModule** **}** **from****'@angular/cdk/scrolling'****;**
    @Component({
      ...,
      imports: [CommonModule, **ScrollingModule**]
    })
    export class UsersListComponent {
      @Input() listItems: AppUserCard[] = [];
    } 
    

    我们现在必须实现虚拟滚动,修改 users-list-item.component.html 文件以使用 *cdkVirtualFor 指令而不是 *ngFor 指令,并将 <li> 元素包裹在 <cdk-virtual-scroll-viewport> 元素内,如下所示:

    <h4 class="heading">Our trusted customers</h4>
    <ul>
    <cdk-virtual-scroll-viewport class="list list-group p-2"
      [itemSize]="120">
    <li class="list__item list-group-item my-2 rounded-md p -4"
          *cdkVirtualFor="let item of listItems">
          ...
        </li>
    </cdk-virtual-scroll-viewport>
    </ul> 
    

    注意,我们将 CSS 类 "list list-group p-2"<ul> 元素移动到了 <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] 的属性,其值设置为 "120"。这是因为每个列表项的高度大约为 120 像素,如下面的截图所示:

图片

图 9.3:每个列表项的高度大约为 120 像素

但它是如何提高渲染性能的呢?很高兴你问了!在这个菜谱的原始代码中,当我们加载 270,000 个用户时,它会为每个用户创建一个具有 class="list__item list-group-item" 属性的单独 <li> 元素,从而一次性创建了 270,000 个 DOM 元素。有了虚拟滚动,CDK 只创建几个 <li> 元素,渲染它们,然后在我们滚动通过项目时,只替换这些几个 <li> 元素的 内容。

对于我们的示例,它创建了 6 个 <li> 元素,如下面的截图所示:

图片

图 9.4:由于虚拟滚动,仅显示 DOM 上渲染的几个

元素

由于我们只渲染了少量元素在 DOM 上,我们不再有性能问题,并且悬停动画现在看起来也非常平滑。

在您自己的应用程序中实现虚拟滚动时,请确保为 <cdk-virtual-scroll viewport> 元素设置一个特定的高度,并将 [itemSize] 属性设置为像素中预期的列表项高度,否则列表将不会显示。

参见

列表的键盘导航

可访问性是构建具有良好用户体验的应用程序最重要的方面之一。应用程序不仅应该快速且性能出色,还应该易于访问。虽然涉及到可访问性有很多事情需要考虑,但在本食谱中,我们将通过提供键盘导航来使列表和列表项更加易于访问。使用 Angular CDK,这非常简单。我们将使用 Angular CDK 中的 ListKeyManager 服务来实现目标应用程序中用户列表的键盘导航。

准备工作

我们将要工作的应用程序位于克隆的仓库中的 start/apps/chapter09/ng-cdk-lkm 内:

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-cdk-lkm 
    

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

    图 B18469_09_05.png

    图 9.5:在 http://localhost:4200 上运行的 ng-cdk-lkm 应用

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

如何实现

我们有一个已经拥有一些 Angular CDK 优点(即,它已经实现了从先前食谱中的虚拟滚动)的应用程序。我们现在将开始修改应用程序以实现键盘导航,如下所示:

  1. 首先,我们将实现 FocusableOption 接口和一些可访问性因素到我们的 UsersListItemComponent 类中,如下所示:

    import { Component, HostBinding, Input, ViewEncapsulation } from '@angular/core';
    ...
    **import** **{** **FocusableOption** **}** **from****'@angular/cdk/a11y'****;**
    @Component({
      ...,
      styleUrls: ['./users-list-item.component.scss'],
      encapsulation: ViewEncapsulation.None,
    })
    export class UsersListItemComponent **implements** **FocusableOption** {
      @Input() item!: Partial<AppUserCard>;
      @HostBinding('tabIndex')
      tabIndex = -1;
      @HostBinding('role')
      role = 'list-item';
      **focus****() {}**
    } 
    
  2. 我们现在需要实现 focus 方法中发生的事情。我们将使用 ElementRef 服务来获取 nativeElement 并将焦点设置在 nativeElement 上,如下所示:

    import { Component, **ElementRef****,** HostBinding, Input, ViewEncapsulation, inject } from '@angular/core';
    ...
    export class UsersListItemComponent implements FocusableOption {
      ...
      role = 'list-item';
      **el =** **inject****(****ElementRef****);**
    focus() {
        **this****.el.nativeElement.****focus****();**
      }
    } 
    
  3. 我们现在将添加一些列表本身的可访问性。按照以下方式更新文件 users-list.component.ts

    import { Component, **HostBinding**, Input } from '@angular/core';
    ...
    export class UsersListComponent {
      **@HostBinding****(****'role'****)**
    **role =** **'list'****;**
    @Input() listItems: AppUserCard[] = [];
    } 
    
  4. 我们现在需要在我们的 UsersListComponent 类中实现 FocusKeyManager 类。我们将在组件中查询我们的列表项以创建 FocusKeyManager 类的实例。按照以下方式更新 users-list.component.ts 文件:

    import { Component, HostBinding, Input, QueryList, ViewChildren } from '@angular/core';
    ...
    **import** **{** **FocusKeyManager** **}** **from****'@angular/cdk/a11y'****;**
    ...
    export class UsersListComponent {
      @HostBinding('role')
      role = 'list';
      @Input() listItems: AppUserCard[] = [];
      @ViewChildren(UsersListItemComponent)
      listItemsElements!: QueryList<UsersListItemComponent>;
      **private** **listKeyManager!:** **FocusKeyManager****<****UsersListItemComponent****>;**
    } 
    
  5. 我们现在将在同一文件中的 AfterViewInit 钩子中初始化 FocusKeyManager 实例。按照以下方式更新 users-list.component.ts 文件:

    import { **AfterViewInit****,** Component, HostBinding, Input, QueryList, ViewChildren } from '@angular/core';
          ...
    export class UsersListComponent **implements** **AfterViewInit** {
      ...
      **ngAfterViewInit****() {**
    **this****.listKeyManager =** **new** **FocusKeyManager****(**
    **this****.listItemsElements**
    **);**
    **}**
    } 
    
  6. 最后,我们需要监听键盘事件。为此,你可以使用keydown事件或window:keydown事件。为了简化配方,我们将使用window:keydown事件,因为按下任何键都会将事件冒泡到window对象。按照以下方式更新文件:

    import { AfterViewInit, Component, HostBinding, **HostListener****,** Input, QueryList, ViewChildren } from '@angular/core';
    ...
    export class UsersListComponent implements AfterViewInit {
      ...
      **@HostListener****(****'window:keydown'****, [****'$event'****])**
    **onKeydown****(****event****:** **KeyboardEvent****) {**
    **this****.listKeyManager.****onKeydown****(event);**
    **}**
      ...
    } 
    

    如果你进入应用并按下向下箭头键,你应该看到列表中第一个项被聚焦,你可以使用箭头键导航到下一个和上一个项并将它们聚焦。

  7. 为了让配方更有趣,让我们也实现一个事件监听器,用于当用户在特定项上按下Enter键时。我们只需显示一个带有项详细信息的警告框。让我们在users-list-item.component.ts文件中使用HostListener装饰器,如下所示:

    import { Component, ElementRef, HostBinding, **HostListener****,** Input, ViewEncapsulation, inject } from '@angular/core';
    ...
    export class UsersListItemComponent implements FocusableOption {
      ...
      role = 'list-item';
      **@HostListener****(****'keyup'****, [****'****$event'****])**
    **onEnter****(****ev****:** **KeyboardEvent****) {**
    **if** **(ev.code ===** **'Enter'****) {**
    **alert****(****'Selected item is: '** **+**
     **JSON****.****stringify****(****this****.item));**
    **}**
    **}**
      ...
    } 
    

    通过上述更改,你应该能够看到所选项,如下面的图所示:

    图片

    图 9.6:所选项 JSON 在警告框中显示

太棒了!你已经学会了如何使用 Angular CDK 实现键盘导航。参见下一节了解它是如何工作的。

它是如何工作的…

Angular CDK 提供了ListKeyManager类,它允许你实现键盘导航。我们可以使用ListKeyManager类的一组技术,并且在这个配方中,我们选择了FocusKeyManager类。为了使其适用于项目列表,我们需要做以下几件事情:

  1. 我们确保列表中的每一项都有一个组件。

  2. 在列表组件中使用了ViewChildrenQueryList来查询所有列表项组件。ViewChildren是一个属性装饰器,它使用QueryList从 DOM 中检索所有的UsersListItemComponent元素。如果列表中的项被添加、删除或更新,QueryList会自动更新。

  3. 然后,我们在列表组件中创建了一个FocusKeyManager类的实例,这样我们就可以初始化列表项组件元素。

  4. 在列表组件中添加了一个键盘监听器,并将事件传递给FocusKeyManager类的实例。

当我们在UsersListComponent类中定义listKeyManager属性时,我们还通过指定它为FocusKeyManager<UsersListItemComponent>来定义其类型。这使得更容易理解我们的FocusKeyManager类应该与UsersListItemComponent元素数组一起工作。因此,在ngAfterViewInit方法中,我们指定this.listKeyManager = new FocusKeyManager(this.listItemsElements);,这提供了一个查询到的UsersListItemComponent元素列表。

最后,当我们监听window:keydown事件时,我们在处理程序中接收到的keydown事件,并将其提供给我们的FocusKeyManager类实例作为this.listKeyManager.onKeydown(event);。这告诉我们的FocusKeyManager实例哪个键被按下以及它必须做什么。

注意,我们的 UsersListItemComponent 类实现了 FocusableOption 接口,并且它还有一个 focus 方法,当按下键盘上的向下或向上箭头键时,FocusKeyManager 类会在幕后使用这个方法。

最后,我们还使用了两个带有 HostBinding 装饰器的属性。让我们逐一分析它们:

@HostBinding('tabIndex') and tabIndex = -1; 

这里使用 HostBinding 装饰器将一个类属性(tabIndex)绑定到一个宿主元素的属性(tabIndex)。这实际上将组件宿主元素的 tabIndex 设置为 -1,使其可以通过编程方式聚焦,但不能通过顺序键盘导航访问。

@HostBinding('role') and role = 'list-item'; 

同样,上面的代码将类的角色属性绑定到组件宿主元素的 role 属性。它将 ARIA 角色列表项分配给宿主元素,为辅助技术提供有关如何解释此元素的提示。

现在你已经了解了食谱的工作原理,请查看下一节中的相关链接。

参考信息

使用 Overlay API 的尖锐小弹出框

这本书中的高级食谱之一,特别是对于那些已经使用 Angular 工作了相当一段时间的人来说。在这个食谱中,我们不仅将使用 CDK Overlay API 创建一些 弹出框,而且还会使它们尖锐,就像工具提示一样,这就是其中的乐趣所在。

准备工作

我们将要工作的应用位于克隆的仓库中的 start/apps/chapter09/ng-cdk-popover

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-cdk-popover 
    

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

    图 9.7:运行在 http://localhost:4200 上的 ng-cdk-popover 应用

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

如何操作…

我们的应用有一个用户列表,我们可以在页面上滚动浏览。我们将在每个项目上添加一个弹出菜单,以便显示一个包含一些操作的下拉菜单。我们已安装了 @angular/cdk 包,因此我们不需要担心这一点。让我们从以下食谱开始:

  1. 我们首先添加 Overlay 的默认样式,以便当 Overlay 显示时,它能够正确定位。打开 src/styles.scss 文件,并按照以下片段更新它:

    ng-cookbook.com/s/cdk-pane-styles

  2. 现在,我们将创建变量来保存 Overlay 的原点(用于打开的 Overlay 的位置原点)和实际相对位置设置。打开 users-list.component.ts 文件,并按照以下方式更新它:

    ...
    import { CdkOverlayOrigin, **ConnectedPosition****,** OverlayModule } from '@angular/cdk/overlay';
    ...
    @Component({
      ...,
      imports: [..., OverlayModule],
    })
    export class UsersListComponent {
      @Input() listItems: AppUserCard[] = [];
      popoverMenuOrigin!: CdkOverlayOrigin | null;
      **menuPositions****:** **ConnectedPosition****[] = [**
    **{** **offsetY****:** **4****,** **originX****:** **'****end'****,** **originY****:** **'bottom'****,**
    **overlayX****:** **'end'****,** **overlayY****:** **'top'** **},**
    **{** **offsetY****:** **-4****,** **originX****:** **'****end'****,** **originY****:** **'top'****,** **overlayX****:**
    **'end'****,** **overlayY****:** **'bottom'** **},**
    **];**
         } 
    
  3. 现在,打开users-list.component.html文件,并将cdkOverlayOrigin指令添加到<app-users-list-item>选择器中,以便我们可以将每个列表项作为弹出菜单的起点,如下所示:

    <h4 class="heading">Our trusted customers</h4>
    <ul>
    <cdk-virtual-scroll-viewport class="list list-group p-2"
        [itemSize]="120">
    <app-users-list-item
     class="list__item"
     **cdkOverlayOrigin** #menuTrigger="cdkOverlayOrigin"
          *cdkVirtualFor="let item of listItems"
          [item]="item">
    </app-users-list-item>
    </cdk-virtual-scroll-viewport>
    </ul> 
    
  4. 我们需要以某种方式将模板中的#menuTrigger变量传递到UsersListComponent类中,以将其值分配给popoverMenuOrigin属性。为此,在users-list.component.ts文件中创建一个名为openMenu的方法,如下所示:

    ...
    export class UsersListComponent {
      ...
       **openMenu****(****$event****:** **Event****,** **itemTrigger****:** **CdkOverlayOrigin****) {**
    **if** **($event) {**
    **$event.****stopImmediatePropagation****();**
    **}**
    **this****.popoverMenuOrigin = itemTrigger;**
    **}**
    } 
    
  5. 我们还需要一个属性来显示/隐藏弹出菜单。让我们创建它,并在openMenu方法中将它设置为true。按照以下方式更新users-list.component.ts文件:

    ...
    export class UsersListComponent{
      ...
      menuShown = false;
      ...
      openMenu($event: Event, itemTrigger: CdkOverlayOrigin) {
        if ($event) {
          $event.stopImmediatePropagation();
        }
        this.popoverMenuOrigin = itemTrigger;
        **this****.menuShown** **=** **true****;**
      }
      closeMenu() {
        this.popoverMenuOrigin = null;
        this.menuShown = false;
      }
      ...
    } 
    
  6. 我们现在将创建一个实际的覆盖层。为此,我们将创建一个带有cdkConnectedOverlay指令的<ng-template>元素。在users-list.component.html文件的底部,添加以下链接中的代码(<ng-template> element)

    ng-cookbook.com/s/ng-popover-overlay

  7. 我们需要将每个列表项上的#menuTrigger变量传递给点击列表项时的openMenu方法。按照以下方式更新文件:

    <h4 class="heading">Our trusted customers</h4>
    <ul>
    <cdk-virtual-scroll-viewport class="list list-group p-2"
      [itemSize]="120">
    <app-users-list-item
     class="list__item"
     cdkOverlayOrigin #menuTrigger="cdkOverlayOrigin"
          *cdkVirtualFor="let item of listItems"
     **(****click****)=****"openMenu($event, menuTrigger)"**
     **[****class.list__item--active****]=****"popoverMenuOrigin ===** **menuTrigger"**
          [item]="item">
    </app-users-list-item>
    </cdk-virtual-scroll-viewport>
    </ul>
    ... 
    

    如果你现在刷新应用程序并点击任何列表项,你应该会看到一个下拉菜单,如下所示:

    图片

    图 9.8:每个列表项的工作下拉菜单

  8. 我们现在需要实现显示带有下拉菜单的尖锐小箭头的部分,以便我们可以将下拉菜单与列表项关联起来。首先,将以下样式添加到src/styles.scss文件中的menu-popover类中:

    ...
    .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 {
        transform: translateY(-20px);
        &::before {
          top: unset !important;
          transform: rotate(180deg);
          bottom: -10px;
        }
      }
      &__list {...}
    } 
    

    你现在应该能够在下拉菜单的右上角看到一个尖锐的箭头,但如果你尝试点击屏幕上的最后一个项,你会看到下拉菜单向上打开,但仍然显示指针在顶部,如下所示:

    图片

    图 9.9:指向错误列表项的下拉箭头

  9. 要指向弹出/下拉菜单的实际起点,我们需要实现一个自定义指令,将自定义类应用于弹出。让我们首先创建一个指令,如下所示:

    cd start && nx g directive positional-popover-class --directory apps/chapter09/ng-cdk-popover/src/app/directives 
    
  10. 根据以下代码片段更新popover-positional-class.directive.ts生成的文件:

    ng-cookbook.com/s/popover-pc-directive

  11. 让我们在users-list.component.ts文件中导入PopoverPositionalClassDirective类,以便我们可以在模板中稍后使用它。按照以下方式更新代码:

    ...
    **import** **{** **PopoverPositionalClassDirective** **}** **from****'../../directives/popover-positional-class.directive'****;**
    @Component({
      ...
      imports: [CommonModule, ScrollingModule, UsersListItemComponent, OverlayModule, **PopoverPositionalClassDirective**]
    })
    ... 
    
  12. 现在,打开users-list.component.html文件,将我们的指令应用到<ng-template>元素上。按照以下方式更新文件:

    ...
    <ng-template 
     cdkConnectedOverlay 
     ...
      (backdropClick)="closeMenu()"
      [cdkConnectedOverlayPositions]="menuPositions"
     **appPopoverPositionalClass****targetSelector****=****".menu-popover"**
      **inverseClass****=****"menu-popover--up"** 
      [originY]="popoverMenuPosition.originY"
      (positionChange)="popoverPositionChanged($event)"
     cdkConnectedOverlayPanelClass="menu-popover"
      >
    <div class="menu-popover__list">
        ...
      </div>
    </ng-template> 
    
  13. 我们现在需要在users-list.component.ts文件中创建一个popoverMenuPosition属性和一个popoverPositionChanged方法,以跟踪哪个列表项用于打开菜单(用于定位)以及当菜单由于窗口调整大小或内容更改而位置改变时的情况。进一步更新文件如下:

    ...
    import { ChangeDetectorRef, Component, Input, inject } from '@angular/core';
    ...
    import { CdkOverlayOrigin, ConnectedOverlayPositionChange, **ConnectedPosition****,** OverlayModule } from '@angular/cdk/overlay';
    @Component({...})
    export class UsersListComponent {
      ...
       cdRef = inject(ChangeDetectorRef); 
    
      **popoverMenuPosition****:** **Partial****<****ConnectedPosition****> = {**
    **originY****:** **undefined**
    **};**
    **popoverPositionChanged****(****$event****:**
      **ConnectedOverlayPositionChange****) {**
    **if** **(****this****.popoverMenuPosition.originY !==**
      $event.connectionPair.originY) {
          this.popoverMenuPosition.originY = $event.connectionPair.originY;
          }
        this.cdRef.detectChanges();
      }
      ...
    } 
    

    哇!如果你现在刷新页面并点击每个列表项,你会看到箭头指向正确的方向。查看以下截图以查看最后一个项目的弹出箭头向下指,因为弹出菜单在项目上方显示:

    图片

    图 9.10:指向正确列表项的下拉箭头(向下指)

太好了!你现在知道如何使用 Angular CDK 与覆盖层一起创建自定义弹出/下拉菜单。此外,你现在知道如何快速使用自定义指令在菜单上实现尖锐的箭头。查看下一节以了解这一切是如何工作的。

它是如何工作的…

使用 Angular CDK Overlay API 实现覆盖层包括几个需要处理的组件。我们首先必须在UserListComponent类的导入中导入OverlayModule类。我们这样做是因为OverlayModule包含我们在应用程序中使用的指令:例如,CdkConnectedOverlay指令和CdkOverlayOrigin指令。在导入创建覆盖层的模块之后,我们需要有一个覆盖层(在我们的例子中是菜单)和一个覆盖层源(用于打开/显示菜单)。在这个菜谱中,因为我们使用覆盖层为每个列表项创建弹出菜单,所以我们使用cdkOverlayOrigin指令在<app-users-list-item>元素上。注意,<app-users-list-item>元素是通过*ngFor指令渲染的。

因此,为了知道哪个项目被点击或确切地知道我们需要为哪个项目显示弹出菜单,我们在每个列表项元素上创建一个#menuTrigger模板变量,并且你会注意到我们还绑定了列表项上的(click)事件来调用openMenu方法,并将这个menuTrigger模板变量传递给它。

现在,如果你已经注意到了users-list.component.ts文件中的openMenu方法,它看起来是这样的:

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

注意,我们将itemTrigger属性分配给我们的类的popoverMenuOrigin属性。这是因为这个popoverMenuOrigin属性正在与我们的模板中的实际覆盖层绑定。你还可以看到我们设置了menuShown属性为true,这是因为这将决定覆盖层是否应该显示或隐藏。

现在,让我们看看实际覆盖层的代码,如下所示:

<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="popoverMenuOrigin!"
  [cdkConnectedOverlayOpen]="menuShown" [cdkConnectedOverlayHasBackdrop]="true"
  (backdropClick)="closeMenu()"
  [cdkConnectedOverlayPositions]="menuPositions"
  appPopoverPositionalClass targetSelector=".menu-popover" inverseClass="menu-popover--up"
**[originY]=****"popoverMenuPosition.originY"**
**(positionChange)=****"popoverPositionChanged($event)"**
  cdkConnectedOverlayPanelClass="menu-popover"
  >
  ...
</ng-template> 

让我们逐一讨论cdkConnectedOverlay指令的属性:

  • cdkConnectedOverlay属性:这是实际的覆盖层指令,它使<ng-template>元素成为 Angular CDK 覆盖层。

  • [cdkConnectedOverlayOrigin]属性:这告诉覆盖层 API 这个覆盖层的来源。这是为了帮助 CDK 在打开时决定覆盖层的位置。

  • [cdkConnectedOverlayOpen]属性:这决定了覆盖层是否应该显示。

  • [cdkConnectedOverlayHasBackdrop]属性:这决定了覆盖层是否应有背景——也就是说,如果它有背景,当它打开时,用户不应该能够点击除覆盖层之外的其他任何东西。

  • (backdropClick)属性:这是当点击背景时的事件处理器。在这种情况下,我们将menuShown属性设置为false,这将隐藏/关闭覆盖层。

  • [cdkConnectedOverlayPositions]属性:为覆盖 API 提供定位配置。它是一个首选位置数组,定义了覆盖层是否应显示在原点下方、原点上方、左侧、右侧、距离原点有多远等,使用originXoriginYoverlayXoverlayY等属性。

  • [cdkConnectedOverlayPanelClass]属性:应用于生成的覆盖层的 CSS 类。这用于样式化。

在所有属性都设置正确的情况下,我们能够在轻触列表项时看到覆盖层的工作。“但是,Ahsan,关于尖锐的箭头怎么办?” 好吧,等等!我们也会讨论它们。

因此,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 to invert its pointer.

现在,让我们谈谈如何以及何时应用这个--up修饰符类。我们创建了一个名为appPopoverPositionalClass的自定义指令。这个指令也应用于我们为覆盖层准备的<ng-template>元素——也就是说,这个指令与cdkConnectedOverlay指令一起应用,并期望以下输入属性:

  • appPopoverPositionalClass属性:实际的指令选择器。

  • targetSelector属性:由 Angular CDK 覆盖 API 生成的元素的查询选择器。理想情况下,这应该与我们用于cdkConnectedOverlayPanelClass的相同。

  • inverseClass属性:当覆盖层的垂直位置(originY)改变时应用的类——也就是说,从"top""bottom",反之亦然。

  • originY属性:覆盖层此时的originY位置。值是"top""bottom",基于覆盖层的位置。

我们在 CDK Overlay <ng-template> 元素上有一个 (positionChange) 监听器,当覆盖层位置改变时立即触发 popoverPositionChanged 方法。注意,在 popoverPositionChanged 方法中,获取新位置后,我们更新 popover.originY 属性,该属性正在更新 menuPopoverOrigin.originY,然后我们还将 menuPopoverOrigin.originY 作为 [originY] 属性传递给我们的 appPopoverPositionalClass 指令。由于我们将其传递给指令,指令知道覆盖层位置在任何特定时间是否为 "top""bottom"。如何做到的?因为我们使用指令中的 ngOnChanges 生命周期钩子来监听 originY 属性/输入,一旦我们得到 originY 的不同值,我们就会根据 originY 属性的值将 inverseClass 的值作为 CSS 类添加到 Overlay 元素上,或者根据 originY 属性的值将其移除。此外,根据应用的 CSS 类,覆盖层的箭头方向也会被确定。

参见

Angular CDK 输入强制转换工具

这就是那种你看到后觉得,“哦,如果我知道这个就好了。” 我确实对 Angular CDK 中的这些强制转换工具有了这样的想法。有很多次,你在 Angular 组件中有一个数值输入,被迫使用方括号表示法 ([myNumberInput]="numberValue"),因为如果你使用 "myNumberInput=numberValue",它会被解释为字面字符串 "numberValue",而不是变量。Angular CDK 中的强制转换工具正是我们需要的。

准备工作

我们将要工作的应用程序位于克隆的仓库中的 start/apps/chapter09/ng-cdk-coercion

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-cdk-coercion 
    

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

    图 9.11:使用-cdk-coercion 在 http://localhost:4200 上运行

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

如何做到这一点…

我们有一个评分组件,它接受一些输入,包括“value”和“disabled”。然而,我们实际上不能像通常的属性那样使用它们,必须使用方括号表示法为这两个属性进行 Angular 属性绑定。我们将在食谱中修复这个问题。让我们开始吧:

  1. 首先,让我们假设我们想要向评分组件添加一个新功能——那就是为评分设置一个最大数字。目前,它默认为 5。因此,我们将在 rating.component.ts 文件中创建一个新的输入,如下所示:

    ...
    export class RatingComponent {
      @Input() value = 2;
      @Input() disabled = false;
      **@Input****() max =** **5****;**
      ...
      get renderArr() {
        return new Array(**this****.****max**).fill(0).map((_, index) =>
      index + 1);
      }
      ...
    } 
    
  2. 现在,我们将从主组件向评分组件提供最大输入。我们将其值设置为 8。但我们将使用常规的 HTML 表示法来提供值。更新 home.component.html 文件中 <app-rating-component> 的使用,如下所示:

     <app-rating
              [value]="reviewForm.controls.rating.value" **max****=****"8"**
              [disabled]="ratingDisabled"
              (valueChanged)="applyRating($event)">
    </app-rating> 
    

    你会立即注意到 IDE 抛出错误,因为我们不能将字符串值赋给数字输入。

  3. 现在,我们将使用 Angular CDK 的强制转换实用工具来解决这个问题。更新 rating.component.ts 文件以使用 NumberInput 类型以及 coerceNumberProperty 方法,如下所示:

    ...
    **import** **{** **NumberInput****, coerceNumberProperty }** **from****'****@angular/cdk/coercion'****;**
    ...
    export class RatingComponent {
      @Input() value = 2;
      @Input() disabled = false;
      **@Input****() get max () {**
    **return****this****.****_max****;**
    **}**
    **set max (****val****:** **NumberInput****) {**
    **this****.****_max** **=** **coerceNumberProperty****(val);**
    **}**
    **private** **_max =** **5****;**
    @Output() valueChanged: EventEmitter<number> = new
      EventEmitter();
      ...
    } 
    

    如果你再次检查 home.component.html 文件,你会看到错误已经消失,并且应用程序可以无错误地编译。

  4. 让我们将 disabled 属性也进行强制转换。我们将尝试使用双大括号 {{}} 表示法来处理这个输入。更新 home.component.html 文件中 <app-rating> 元素的使用,如下所示:

     <app-rating
              [value]="reviewForm.controls.rating.value"
     max="8"
     **disabled****=****"{{ratingDisabled}}"**
              (valueChanged)="applyRating($event)">
    </app-rating> 
    

    你将在 IDE 和编译过程中看到错误:Type 'string' is not assignable to type 'boolean'

  5. 为了解决这个问题,让我们在 rating.component.ts 文件中使用 BooleanInputcoerceBooleanProperty 方法,如下所示:

    ...
    import { **BooleanInput****,** NumberInput, **coerceBooleanProperty**, coerceNumberProperty } from '@angular/cdk/coercion';
    ...
    export class RatingComponent {
      @Input() value = 2;
      **@Input****() get disabled () {**
    **return****this****.****_disabled****;**
    **}**
    **set disabled (****val****:** **BooleanInput****) {**
    **this****.****_disabled** **=** **coerceBooleanProperty****(val);**
    **}**
    **private** **_disabled =** **false****;**
    @Input() get max () {...}
      ...
    } 
    

哇!你的代码应该可以无错误地编译,你应该能够提供值而不需要强制使用方括号属性绑定表示法。很棒的是,即使你只是将 disabled 属性添加到 <app-rating> 元素中,它也会禁用组件,这就是当我们将 disabled 属性应用于常规 input 元素或 textarea 元素时发生的情况。

它是如何工作的...

在这个食谱中,我们使用了 Angular CDK 的强制转换实用工具中的两种不同方法。coerceNumberProperty 方法以及 coerceBooleanProperty 方法。让我们首先看看 coerceNumberProperty 方法以及 NumberInput 类型。

NumberInput 类型解析为 string | number | null | undefined,这使得我们可以以任何这些类型提供值。而且当我们不使用方括号表示法进行属性绑定时,值被视为 字符串。这似乎被 NumberInput 类型所涵盖。coerceNumberProperty 方法接受任何值并将其转换为数字。该函数还接受一个可选的第二个参数作为回退值。

BooleanInput 类型和使用 coerceBooleanProperty 方法的用法与 NumberInput 类型和使用 coerceNumberProperty 方法非常相似。然而,不同之处在于 coerceBooleanProperty 方法接受任何值并将其转换为布尔值。此方法的逻辑略有不同,因为它寻找以下条件:

value != null && `${value}` !== 'false' 

这意味着如果属性没有提供值,它将是一个空字符串。即使空字符串是一个假值,使用 coerceBooleanProperty,没有值(空字符串)的属性将解析为真值。

相关内容

使用 CDK 拖放 API 将项目从一个列表移动到另一个列表

你是否曾经使用过 Trello 板应用,或者可能是其他也允许你将列表项从一个列表拖放到另一个列表的应用?嗯,你可以很容易地使用 Angular CDK 做到这一点,在这个食谱中,你将学习如何使用 Angular CDK 拖放 API 将项目从一个列表移动到另一个列表。你还将学习如何重新排序列表。

准备工作

我们将要工作的应用位于克隆的仓库中的 start/apps/chapter09/ng-cdk-drag-drop 目录内:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目:

    npm run serve ng-cdk-drag-drop to serve the project. 
    

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

    图 9.12:运行在 http://localhost:4200 的 ng-cdk-drag-drop 应用

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

如何做到这一点...

对于这个食谱,我们有一个有趣的包含一些文件夹和文件的应用。我们将实现拖放功能,以便文件可以被拖放到其他文件夹中,这将立即更新文件夹的文件计数,并且我们能够在新的文件夹中看到该文件。让我们开始吧。

  1. 首先,我们需要导入 DragDropModule,因为我们使用的是属于它的 CdkDragCdkDropList 指令。我们将把模块导入到 FoldersListComponent 类的 imports 数组中。修改 folders-list.component.ts 文件,如下所示:

    ...
    **import** **{** **DragDropModule** **}** **from****'@angular/cdk/drag-drop'****;**
    @Component({
      ...
      imports: [CommonModule, FileComponent, FolderComponent, **DragDropModule**],
      standalone: true
    })
    ... 
    
  2. 现在,我们将把 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> 
    
  3. 我们还将通过向文件的容器元素添加 cdkDropList 指令来启用文件夹内文件的重新排序,如下所示:

    <div class="folders">
      ...
      <div class="folders__selected-folder-files"
        *ngIf="selectedFolder">
    <div **cdkDropList**>
    <app-file ...></app-file>
    </div>
    </div>
    </div> 
    
  4. 我们现在将通过指定每个 <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> 
    
  5. 我们现在需要实现文件被放下时会发生什么。为此,我们将使用 (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> 
    
  6. 最后,我们需要实现 onFileDrop 方法。更新 folders-list.component.ts 文件,如下所示:

    ...
    import { IFile, IFolder } from '../interfaces';
    ...
    import { **CdkDragDrop****,** DragDropModule, **moveItemInArray****,** **transferArrayItem** } from '@angular/cdk/drag-drop';
    ...
    export class FoldersListComponent {
      ...
      toggleFolderSelect(folder: IFolder) {...}
      **onFileDrop****(****event****:** **CdkDragDrop****<****IFile****[]>) {**
    **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**
    **);**
    **}**
    **}**
    } 
    

    moveItemInArray方法和transferArrayItem方法是 Angular CDK 内置的。这使得我们的实现变得非常简单。

  7. 为了实现拖拽预览,我们需要将 droplists 和可拖拽项包裹在一个带有cdkDropListGroup指令的元素中。更新folders-list.component.html文件,并将指令应用于具有folders类的元素(即顶级),如下所示:

    <div class="folders" **cdkDropListGroup**>
    ...
    </div> 
    
  8. 为了应用自定义拖拽预览,我们使用一个带有*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> 
    
  9. 为了使fa-icon组件正常工作,我们还需要在FoldersListComponent类中导入FontAwesomeModule模块。更新以下文件,如下所示:

    ...
    **import** **{** **FontAwesomeModule** **}** **from****'@fortawesome/angular-fontawesome'****;**
    @Component({
      ...
      imports: [CommonModule, FileComponent, FolderComponent, DragDropModule, **FontAwesomeModule**],
      standalone: true
    })
    ... 
    
  10. 我们还需要为拖拽预览添加一些样式。更新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****;**
    **}** 
    
  11. 让我们再添加一些样式,以确保在文件夹内重新排序项目时,其他列表项能够平滑移动。由于我们需要为 Angular CDK 中的元素添加样式,并且它们有样式封装,因此我们需要在我们的全局scss文件中添加样式。进一步更新folders-list.component.scss文件,如下所示:

    ... 
    *** {**
    **user-select: none;**
    **}**
    
    **.cdk-drop-list-dragging****.cdk-drag** **{**
    **transition****: transform** **250ms****cubic-bezier****(****0****,** **0****,** **0.2****,** **1****);**
    **}**
    
    **.cdk-drag-animating** **{**
    **transition****: transform** **300ms****cubic-bezier****(****0****,** **0****,** **0.2****,** **1****);**
    **}** 
    
  12. 现在,我们还需要创建一个下拉预览(一个在我们释放拖拽文件之前出现的占位符)模板。为此,我们在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> 
    
  13. 最后,让我们使用@fortawesome包中的faArrowAltCircleUp图标创建一个upArrow属性。更新folders-list.component.ts文件,如下所示:

    ...
    **import** **{ faArrowAltCircleUp }** **from****'@fortawesome/free-regular-svg-icons'****;**
    ...
    export class FoldersListComponent {
      folders = APP_DATA;
      **upArrow = faArrowAltCircleUp;**
    selectedFolder: IFolder | null = null;
      ...
    } 
    

咚!现在,我们为整个拖拽流程提供了一个无缝的用户体验UX)。喜欢吗?请确保在您的 X(Twitter)上分享一个快照,并@我@codewith_ahsan

现在我们已经完成了配方,让我们在下一节中看看它是如何工作的。

它是如何工作的...

在这个配方中,有几个有趣的指令,我们将逐一介绍。首先,作为优秀的 Angular 开发者,我们将DragDropModule类导入到名为FoldersListComponentstandalone componentimports数组中,以确保我们不会出错。然后,我们开始使文件可拖拽。我们通过在每个文件元素上添加cdkDrag指令并应用*ngFor指令来实现这一点。这告诉 Angular CDK 该元素将被拖拽,因此 Angular CDK 为每个要拖拽的元素绑定不同的处理器。

重要提示

默认情况下,Angular 组件不是块级元素。因此,当我们将cdkDrag指令应用于 Angular 组件,如<app-file>组件时,它可能会限制 CDK 在拖拽元素时应用的动画。为了解决这个问题,我们需要为我们的组件元素设置display: block;。请注意,我们在folders-list.component.scss文件(第 25 行)中为.folders__selected-folder-files__file类应用了所需的样式。

在配置拖拽元素后,我们为每个我们打算放下文件的容器 DOM 元素使用cdkDropList指令。在我们的菜谱中,这是屏幕上我们看到的每个文件夹,我们还可以重新排列文件夹内的文件。因此,我们将cdkDropList指令应用于当前显示文件的包装元素,以及每个通过*ngFor循环遍历folders数组的<app-folder>项目。

然后,我们通过为每个可拖拽文件指定[cdkDragData]="file"来指定我们正在拖拽的数据。这有助于我们在稍后过程中识别它,无论是将其放下在当前文件夹内还是其他文件夹内。我们还指定了当拖拽到特定列表上时,这个拖拽项目将被添加到哪个数组中,我们通过为应用了cdkDropList指令的元素指定[cdkDropListData]="ARRAY"语句来完成这项工作。当 Angular CDK 结合cdkDragDatacdkDropListData属性的信息时,它可以轻松地识别项目是否被拖拽并在同一列表或另一个列表内放下。

为了处理拖拽文件时可能发生的情况,我们在带有cdkDropList指令的元素上使用 Angular CDK 的(cdkDropListDropped)方法。我们接收 CDK 发出的$event并将其传递给我们的onFileDrop方法。令人兴奋的是,在onFileDrop方法中,我们使用 Angular CDK 的moveItemInArraytransferArrayItem辅助方法,通过一段非常简单的逻辑来比较容器。也就是说,Angular CDK 为我们提供了足够的信息,使我们能够非常容易地实现整个功能。

在菜谱的末尾,我们通过在元素上使用自定义模板的*cdkDragPreview指令来自定义拖拽文件时的预览外观。这告诉 Angular CDK 不要立即渲染它,而是在我们开始拖拽文件时用鼠标显示它。在我们的菜谱中,我们只显示文件的图标作为拖拽预览。最后,我们还使用*cdkDragPlaceholder指令自定义了放下预览(或拖拽占位符),它显示一个带有向上箭头图标的透明矩形,以反映当放下时项目将被添加的位置。当然,我们必须为拖拽预览和放下预览添加一些自定义样式。

参见

使用 CDK Stepper API 创建一个多步游戏

如果你在互联网上尝试寻找 CDK Stepper API 的示例,你会找到许多围绕使用 CDK Stepper API 创建多步表单的文章,但鉴于它本质上是一个步骤器,它可以用于各种用例。在这个菜谱中,我们将使用 Angular CDK Stepper API 构建一个猜谜游戏,用户将猜测掷骰子的输出结果。

准备工作

我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter09/ng-cdk-stepper目录内:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目:

    npm run serve ng-cdk-stepper 
    

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

    img/B18469_09_13.png

    图 9.13:运行在 http://localhost:4200 的 ng-cdk-stepper 应用程序

现在,让我们在下一节中看看如何使用 CDK Stepper API 创建一个多步游戏。

如何做到这一点…

我们手头有一个非常简单但有趣的应用程序,已经构建了一些组件,包括dice componentvalue-guess componentleaderboard component。我们将使用Stepper API将这个游戏作为一个多步游戏来创建。按照以下步骤进行:

  1. 首先,在game.component.ts文件中从@angular/cdk包导入CdkStepperModule类,如下所示:

    ...
    **import** **{** **CdkStepperModule** **}** **from****'@angular/cdk/stepper'****;**
    @Component({
      ...
      imports: [
        ...
        DiceComponent**,**
    **CdkStepperModule**
      ],
    })
    ... 
    
  2. 现在让我们创建我们的步骤器组件。在项目根目录下运行以下命令:

    cd start && nx g c game-stepper --directory apps/chapter09/ng-cdk-stepper/src/app/components/game-stepper 
    

    当被询问时,选择@nx/angular:component脚本来创建组件,并选择“按提供”选项。

  3. 要使我们的组件成为CdkStepper,我们需要使用CdkStepper令牌提供它,并且还需要从CdkStepper扩展我们的组件类。我们可以移除constructorOnInit实现和ngOnInit方法。修改game-stepper.component.ts文件,如下所示:

    ...
    **import** **{** **CdkStepper****,** **CdkStepperModule** **}** **from****'****@angular/cdk/stepper'****;**
    @Component({
      ...
      imports: [CommonModule, **CdkStepperModule**],
      ...
      **providers****: [{** **provide****:** **CdkStepper****,** **useExisting****:**
    **GameStepperComponent** **}],**
    })
    export class GameStepperComponent**extends****CdkStepper** {
    } 
    

    CDKStepper为我们提供了有用的方法,如nextpreviousreset,用于在步骤之间导航,以及selectedIndexChange事件发射器,用于识别何时更改了步骤。

  4. 让我们添加<game-stepper>组件的模板。我们将首先添加将显示步骤标签的标题。更新你的game-stepper.component.html文件,如下所示:

    <div class="game-stepper">
    <header>
    <h3 *ngIf="selected">
    <ng-container
            *ngIf="selected.stepLabel; else showLabelText"
            [ngTemplateOutlet]="selected.stepLabel.template"
          >
    </ng-container>
    <ng-template #showLabelText>
            {{ selected.label }}
          </ng-template>
    </h3>
    </header>
    </div> 
    
  5. 现在,我们将添加模板以显示所选步骤的主要内容——这很简单。我们需要添加一个带有[ngTemplateOutlet]属性的div,我们将在这里显示内容。更新game-stepper.component.html文件,如下所示:

    <div class="game-stepper">
    <header>...</header>
    **<****section****class****=****"game-stepper__content"****>**
    **<****div** **[****ngTemplateOutlet****]=****"selected ? selected.content :**
    **null"****></****div****>**
    **</****section****>**
      ...
    </div> 
    
  6. 最后,我们将添加一个包含步骤器导航按钮的页脚元素——也就是说,我们应该能够使用这些导航按钮跳转到下一个和上一个步骤。进一步更新game-stepper.component.html文件,如下所示:

    <div class="game-stepper">
    <header>...</header>
    <section class="game-stepper__content">...</section>
    **<****footer****class****=****"game-stepper__navigation"****>**
    **<****button** *******ngIf****=****"steps.get(selectedIndex - 1)"**
    **class****=****"game-stepper__navigation__button btn btn**
    **-primary"****cdkStepperPrevious****>**
    **&larr;**
    **</****button****>**
    **<****span****class****=****"flex-1"****></****span****>**
    **<****button** *******ngIf****=****"steps.get(selectedIndex + 1)"**
    **class****=****"game-stepper__navigation__button btn btn-**
    **primary"****cdkStepperNext****>**
    **&rarr;**
    **</****button****>**
    **</****footer****>**
    </div> 
    
  7. 让我们为我们的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: 150px;
        display: flex;
        justify-content: center;
        align-items: center;
        flex-direction: column;
      }
      header,
      footer {
        margin: 10px auto;
      }
    } 
    
  8. 现在,我们想在game组件内部使用game stepper组件。为此,首先在GameComponent类的imports数组中导入它,如下所示:

    ...
    **import** **{** **GameStepperComponent** **}** **from****'../components/game-stepper/game-stepper.component'****;**
    @Component({
      ...
      imports: [
        ...
        **GameStepperComponent**
      ],
    }) 
    
  9. 现在,我们将使用<app-game-stepper>组件将整个模板包裹在game.component.html文件中。更新文件,如下所示:

    <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> 
    
  10. 现在,我们将修改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> 
    
  11. 现在,我们将为每个步骤添加一个标签。我们需要在每个<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 的第一步和导航按钮

  12. 现在,我们需要确保只有在第一步输入了姓名后,我们才能向前移动到第二步。对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> 
    
  13. 我们还需要在第一步输入玩家姓名之前禁用第一个步骤的“下一步”按钮。为此,更新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> 
    
  14. 要处理用户提供姓名并按下Enter键的情况,导致表单提交,我们可以在GameComponent类中使用@ViewChild()跳到下一个步骤。修改game.component.ts文件如下,然后尝试输入姓名并按下Enter键:

    import { CommonModule } from '@angular/common';
    import { Component, **ViewChild** } from '@angular/core';
    import { **CdkStepper**, CdkStepperModule } from '@angular/cdk/stepper';
    ...
    export class GameComponent {
      **@ViewChild****(****CdkStepper****) stepper!:** **CdkStepper****;**
      ...
      submitName() {
        **this****.****stepper****.****next****();**
      }
    } 
    
  15. 现在,让我们编写猜测数字的流程。更新game.component.ts文件,如下所示:

    ...
    **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****:** **number** **|** **null** **=** **null****;**
    **isCorrectGuess****:** **null** **|** **boolean** **=** **null****;**
      ...
      submitName() {...}
      **rollTheDice****(****guessedValue****:** **number****) {**
    **this****.****isCorrectGuess** **=** **null****;**
    **this****.****guessedValue** **= guessedValue;**
    **this****.****diceComponent****.****rollDice****();**
    **}**
    **showResult****(****diceSide****:** **IDiceSide****) {**
    **this****.****isCorrectGuess** **=** **this****.****guessedValue** **===**
    **diceSide.****value****;**
    **}**
    } 
    
  16. 现在我们已经设置了函数,让我们更新模板以监听<app-value-guesser><app-dice>组件的事件监听器并相应地执行。我们还将添加具有 class alerts 的元素以显示成功或错误猜测的消息。更新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 [rolling]="rolling" **(****valueGuessed****)=**
    **"rollTheDice($event)"**></app-value-guesser>
    <app-dice (diceRolling)="rolling = $event;"
      **(****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> 
    
  17. 最后,我们需要填充排行榜。更新game.component.ts文件以使用LeaderboardService类,如下所示:

    ...
    import { Component, ViewChild, **inject** } from '@angular/core';
    ...
    **import** **{** **LeaderboardService** **}** **from****'../services/leaderboard.service'****;**
    **import** **{** **IScore** **}** **from****'../interfaces/score.interface'****;**
    ...
    export class GameComponent {
      ...
      **leaderboardService =** **inject****(****LeaderboardService****);**
    **scores****:** **IScore****[] =** **this****.****leaderboardService****.****getScores****();**
      ...
      showResult(diceSide: IDiceSide) {
        this.isCorrectGuess = this.guessedValue ===
          diceSide.value;
        **const** **userName =** **this****.****nameForm****.****controls****.****name****.****value****as**
    **string****;**
    **if** **(!****this****.****isCorrectGuess****) {**
    **return****;**
    **}**
    **this****.****scores** **=** **this****.****leaderboardService****.****setScores****({**
    **name****: userName,**
    **score****:** **50****,**
    **});**
    **}**
    } 
    
  18. 现在,更新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 显示排行榜结果

呼!这是一个的配方!好吧,完美需要时间和奉献。请随意使用这个游戏,甚至和你的朋友一起玩,如果你改进了它,请通过我的社交媒体告诉我。

现在你已经完成了配方,请查看下一节了解其工作原理。

它是如何工作的……

这个配方中有许多动态部分,但它们非常简单。首先,我们将CdkStepperModule类导入到GameComponent类的imports数组中。然后,我们创建一个扩展CdkStepper类的组件。扩展CdkStepper类的原因是能够创建这个GameStepperComponent组件,这样我们就可以创建一个具有一些样式和自定义功能的可重用模板。

要开始使用GameStepperComponent组件,我们在game.component.html文件中将整个模板包裹在<app-game-stepper>元素中。由于该组件扩展了CdkStepper API,我们在这里可以使用CdkStepper组件的所有功能。对于每个步骤,我们使用 CDK 中的<cdk-step>元素,并将步骤的模板包裹在其中。请注意,在game-stepper.component.html文件中,我们使用[ngTemplateOutlet]属性来显示步骤的标签和实际内容。这反映了 CDK Stepper API 的出色之处。由于我们的GameStepperComponent类扩展了CDKStepper类,它将自动为每个步骤生成一个label属性和content属性,这些属性基于我们在game.component.html文件中为每个<cdk-step>元素提供的值/模板。由于我们在game.component.html文件中的每个<cdk-step>元素内部提供了<ng-template cdkStepLabel>,CDK 会自动为每个步骤生成一个step.stepLabel.template,我们随后在game-stepper.component.html文件中使用它,分别显示每个<cdk-step>元素的标签。如果我们没有为某个步骤提供<ng-template cdkStepLabel>,而是直接使用了<cdk-step label="someValue">,那么它将使用我们根据代码在game-stepper.component.html中编写的step.label属性。

对于底部导航按钮,您会注意到我们使用了带有cdkStepperPreviouscdkStepperNext指令的<button>元素,分别用于跳转到上一步和下一步。我们还会根据条件显示/隐藏下一步和上一步按钮,以检查是否有可跳转的步骤。为了防止在提供的条件为假时在 DOM 中渲染导航按钮,我们利用*ngIf绑定动态地隐藏或显示它。

关于 CDK Stepper API 的一个有趣之处在于,我们可以判断用户是否应该能够前往下一步或返回上一步,这不受当前步骤状态的影响,或者用户是否需要首先在当前步骤中完成某些操作才能前往下一步。我们实现这一点的做法是在 <app-game-stepper> 元素上使用 [linear] 属性,将其值设置为 true。这告诉 CDK Stepper API 不要使用 cdkStepperNext 按钮移动到下一步,直到当前步骤的 completed 属性为 true。虽然仅仅提供 [linear]="true" 就足以处理功能,但我们通过禁用 下一步 按钮来提升用户体验——在这种情况下,我们在 cdkStepperNext 按钮上使用 [disabled]="!selected!.completed",因为如果点击按钮不会执行任何操作,禁用按钮更有意义。

此外,我们还需要决定何时认为一个步骤已完成。对于第一步,很明显,我们应该在输入中输入一个名称,才能认为步骤已完成——换句话说,nameForm 表单组中 'name' 属性的 FormControl 应该有一个值。对于第二步,它与条件 isCorrectGuess !== null 相关联。这确保了用户已经猜了一个数字;无论猜测是否正确,我们都标记步骤为已完成,并允许用户如果想要的话前往下一步(排行榜)。这就是全部内容。您还可以超越这个食谱,添加一个从排行榜(最后)步骤重新开始游戏的功能。

参见

使用 CDK Listbox 指令进行可访问的列表框交互

可访问性是您在考虑最终用户使用您的应用时需要关注的重点方面之一。用户的主要用例之一是在应用中进行选择,无论是选择产品的颜色或尺寸,还是选择多个标签。如果体验是可访问的,这将是一种非常好的体验。在这个食谱中,我们将使用 Angular CDK Listbox API 替换产品的颜色选择功能,以实现可访问的替代方案。

准备工作

我们将要工作的应用位于克隆的仓库中的 start/apps/chapter09/ng-cdk-listbox 目录内:

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-cdk-listbox 
    

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

    图 9.16:运行在 http://localhost:4200 的 ng-cdk-listbox 应用

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

如何做到这一点...

图 9.16中,你会注意到我们有选择产品颜色的选项——如果你点击其中一个,你可以在右侧看到所选的颜色。尽管它们在一定程度上是可访问的,但它们并不完全遵循任何 WAI ARIA 模式。让我们按照以下步骤使用 CDK Listbox 模块:

  1. 现在,我们需要将CdkListboxModule类导入到ProductCardComponent类的imports数组中,这样我们就可以使用cdkListboxcdkOptions指令。按照以下方式修改product-card.component.ts文件:

    ...
    **import** **{** **CdkListboxModule** **}** **from****'@angular/cdk/listbox'****;**
    @Component({
      ...
      imports: [CommonModule, ReactiveFormsModule, **CdkListboxModule**],
      ...
    })
    ... 
    
  2. 现在,我们将替换 HTML 模板以使用cdkListboxcdkOption指令。我们将使用无序列表(ul)和列表项(li元素)来渲染类似的颜色列表。按照以下方式更新product-card.component.html文件在提到的 HTML 注释之间:

    <!-- Color Options -->
    **<****ul****cdkListbox**
    **[****tabindex****]=****"0"**
    **aria-labelledby****=****"product-colors"**
    **cdkListboxOrientation****=****"horizontal"**
    **class****=****"flex flex-wrap justify-center gap-1**
    **[&:hover_li]:opacity-75"****>**
    **<****li****cdkOption****=****"space-gray"****class****=****"color-option**
    **color-option--space-gray"****></****li****>**
    **<****li****cdkOption****=****"silver"****class****=****"color-option**
    **color-option--silver"****></****li****>**
    **<****li****cdkOption****=****"pink"****class****=****"color-option color-**
    **option--pink"****></****li****>**
    **<****li****cdkOption****=****"green"****class****=****"color-option**
    **color-option--green"****></****li****>**
    **<****li****cdkOption****=****"blue"****class****=****"****color-option color-**
    **option--blue"****></****li****>**
    **</****ul****>**
    <!-- Color Options end --> 
    

    你会注意到选项看起来仍然一样(因为 CSS 类的原因);然而,现在功能已经消失了。

  3. 让我们再添加一些样式来突出显示所选颜色。更新src/styles文件夹内的color-options.scss文件,如下所示:

    .color-option {
      ...
      &--active,
      &[aria-selected=true] {
        @apply scale-125;
      }
    } 
    
  4. 由于 Angular CDK Listbox 与模板驱动和响应式表单都兼容,让我们将formControlName属性添加到绑定productForm表单组中的color表单控件。按照以下方式更新模板:

    <!-- Color Options -->
    <ul cdkListbox
              [tabindex]="0"
     **formControlName****=****"color"**...>
              ...
            </ul>
    **<!-- Color Options end →** 
    

    太好了!通过这个更改,当你刷新页面并选择颜色时,你应该能够看到之前的结果。

  5. Angular CDK 实现了 WAI ARIA,因此,在列表框中也可以使用typeahead功能。让我们进一步修改模板以使其工作:

    <!-- Color Options -->
    <ul cdkListbox
     ...>
    <li cdkOption="space-gray" **cdkOptionTypeaheadLabel****=****"space gray"** class="color-option color-option--space-gray"></li>
    <li cdkOption="silver" **cdkOptionTypeaheadLabel****=****"silver"** class="color-option color-option--silver"></li>
    <li cdkOption="pink" **cdkOptionTypeaheadLabel****=****"pink"** class="color-option color-option--pink"></li>
    <li cdkOption="green" **cdkOptionTypeaheadLabel****=****"green"** class="color-option color-option--green"></li>
    <li cdkOption="blue" **cdkOptionTypeaheadLabel****=****"blue"** class="color-option color-option--blue"></li>
    </ul>
    <!-- Color Options end --> 
    

    如果你选择任何颜色并聚焦到列表框上,你可以在这里输入颜色的名称以直接跳转到该选项。

太棒了!!你已经完成了配方。现在你可以看到下一部分来了解它是如何工作的。

它是如何工作的……

在配方中,我们使用了 CDK Listbox API。而且它完全实现了列表框的 WAI ARIA 模式,非常酷。向 Angular 团队表示敬意。首先,我们在独立组件(ProductCardComponent类)的imports数组中导入CdkListboxModule。然后在模板中使用无序列表<ul>,并使用cdkListbox指令。这将为<ul>元素应用 CDK Listbox 功能。你会注意到我们正在根据列表框的 WAI ARIA 模式在<ul>上使用aria-labelledby属性。最后,我们将cdkListboxOrientation属性设置为"horizontal",这告诉 CDK Listbox API 允许此列表框进行水平导航;即,我们可以使用左右箭头键进行导航。我鼓励你阅读www.w3.org/WAI/ARIA/apg/patterns/listbox/上的列表框规范,看看它提出的酷炫无障碍功能。而且令人惊讶的是,Angular CDK Listbox API 遵循了所有这些规范。

除了遵循 WAI ARIA 列表框模式外,列表框本身与 Angular 表单(无论是模板驱动还是响应式)无缝协作。由于我们已经在 product-card 组件的模板中使用了响应式表单,我们可以轻松地使用 <ul> 元素上的 formControlName 绑定。请注意,每个列表项 (<li>) 元素都有 cdkOption 绑定,这告诉 CDK 列表框模块每个选项的值。在菜谱的最后,我们还介绍了 cdkOptionTypeaheadLabel,这有助于 CDK 列表框 API 遵循 WAI ARIA 模式推荐的 typeahead 功能。如果我们不使用此属性,CDK 列表框模块将使用列表项的文本内容作为默认的 typeahead

你可能已经注意到,我们在 color-options.scss 文件中添加了另一个 CSS 选择器,即 &[aria-selected=true],以突出显示选中的颜色。这是因为 CDK 列表框 API 会根据哪个项目被选中自动将此属性设置为 truefalse。这也是 WAI ARIA 列表框模式的一部分。

相关内容

使用 Angular CDK 菜单 API 处理嵌套菜单

菜单对于今天我们使用的许多应用程序至关重要。拥有菜单的模式本身允许我们拥有不总是占用 DOM 空间的体验,并且可以根据需要相对于它们的触发器显示。在本菜谱中,你将学习如何使用 Angular CDK API 创建嵌套菜单。

准备工作

我们将要工作的应用程序位于克隆的存储库中的 start/apps/chapter09/ng-cdk-menu

  1. 在你的代码编辑器中打开代码存储库。

  2. 打开终端,导航到代码存储库目录,并运行以下命令以提供项目:

    npm run serve ng-cdk-menu 
    

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

    图片

    图 9.17:运行在 http://localhost:4200 上的 ng-cdk-menu 应用程序

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

如何操作…

我们有一个应用程序,它有一个产品卡片需要操作菜单,以便我们可以执行一些操作。我们将开始使用 Angular CDK 菜单 API 来实现嵌套菜单。让我们采取以下步骤:

  1. ng-cookbook.com/s/cdk-pane-styles 中的样式复制到项目的 styles.scss 文件中。确保保留文件中的现有样式,只需添加你复制的样式即可。

  2. product-card.component.ts 文件中按照以下方式导入 Angular CDK 菜单 API 的组件:

    ...
    **import** **{** **CdkMenu****,** **CdkMenuItem****,** **CdkMenuTrigger** **}** **from****'@angular/cdk/menu'****;**
    @Component({
      ...
      imports: [CommonModule, ReactiveFormsModule, CdkListboxModule **,** **CdkMenu****,** **CdkMenuItem****,** **CdkMenuTrigger**],
      ...
    })
    export class ProductCardComponent {...} 
    
  3. 让我们在模板中使用一些指令来创建一个菜单。我们将使用<ng-template>元素作为我们的菜单,并将其分配给一个模板变量。按照以下方式更新product-card.component.html文件:

    <article class="block group shadow-md bg-white relative">
    <button **[****cdkMenuTriggerFor****]=****"productActions"** class="...">...</button>
      ...
    </article>
    **<****ng-template** **#****productActions****>**
    **<****ul****class****=****"menu-popover__list"****cdkMenu****>**
    **<****li****cdkMenuItem****class****=****"menu-popover__list__item"****>**
    **Share**
    **</****li****>**
    **<****li****cdkMenuItem****class****=****"menu-popover__list__item"****>**
    **Manage** **&#10148;**
    **</****li****>**
    **<****li****cdkMenuItem****class****=****"menu-popover__list__item"****>**
    **Preview**
    **</****li****>**
    **</****ul****>**
    **</****ng-template****>** 
    

    如果你现在点击产品卡片上的更多按钮(三个垂直点图标),你应该能看到以下菜单:

    图片

    图 9.18:使用 Angular CDK 菜单 API 实现的菜单

    你会注意到菜单从触发器的左下角位置开始。这是由 Angular CDK 菜单 API 设置的默认菜单位置。我们可以使用连接位置来覆盖它。

  4. 让我们使用一些首选的连接位置,以便菜单默认显示在触发器的左侧。按照以下方式更新product-card.component.ts文件:

    ...
    **import** **{** **ConnectedPosition** **}** **from****'@angular/cdk/overlay'****;**
    ...
    export class ProductCardComponent {
      ...
      **menuPositions****:** **ConnectedPosition****[] = [**
    **{**
    **offsetY****:** **4****,**
    **originX****:** **'end'****,**
    **originY****:** **'bottom'****,**
    **overlayX****:** **'end'****,**
    **overlayY****:** **'top'****,**
    **},**
    **{**
    **offsetY****: -****4****,**
    **originX****:** **'end'****,**
    **originY****:** **'top'****,**
    **overlayX****:** **'****end'****,**
    **overlayY****:** **'bottom'****,**
    **},**
    **]**
    } 
    
  5. 让我们在模板中使用menuPositions变量来使用首选位置。按照以下方式更新product-card.component.html文件:

    <article class="block group shadow-md bg-white relative">
    <button **[****cdkMenuPosition****]=****"menuPositions"**
     [cdkMenuTriggerFor]="productActions" class="...">
    <span class="material-symbols-outlined">
          more_vert
        </span>
    </button>
    </article> 
    

    如果你现在刷新应用并点击产品卡片上的更多按钮(三个垂直点图标),你应该能看到菜单如下出现在按钮的左侧:

    图片

    图 9.19:具有首选位置的菜单

  6. 现在我们来实现嵌套菜单。我们将为嵌套菜单创建<ng-template>元素,并将其与"Manage"菜单项连接起来。按照以下方式更新product-card.component.html文件:

    <article class="block group shadow-md bg-white
      relative">...</article>
    <ng-template #productActions>
    <ul class="menu-popover__list" cdkMenu>
    <li cdkMenuItem class="menu-popover__list__item">...</li>
    <li cdkMenuItem class="menu-popover__list__item"
      **[****cdkMenuTriggerFor****]=****"productEditActions"**>
          Manage &#10148;
    </li>
    <li cdkMenuItem class="menu-
          popover__list__item">...</li>
    </ul>
    </ng-template>
    **<****ng-template** **#****productEditActions****>**
    **<****ul****class****=****"menu-popover__list"****cdkMenu****>**
    **<****li****cdkMenuItem****class****=****"menu-popover__list__item"****>**
    **Duplicate**
    **</****li****>**
    **<****li****cdkMenuItem****class****=****"menu-popover__list__item"****>**
    **Edit**
    **</****li****>**
    **<****li****cdkMenuItem****class****=****"menu-popover__list__item"****>**
    **Delete**
    **</****li****>**
    **</****ul****>**
    **</****ng-template****>** 
    

    通过这个更改,你应该能看到以下嵌套菜单:

    图片

    图 9.20:嵌套菜单实现

Kaboom!我们通过使用 Angular CDK,在几个步骤内就能在这个应用程序中实现嵌套菜单。相信我,如果我们自己来做这件事,可能需要几个小时,甚至可能需要几天。

它是如何工作的...

Angular CDK 提供了 CDK 菜单 API,其中包括cdkMenuTriggerFor指令。这个指令理想地指向一个<ng-template>元素的模板变量。然后,在幕后,Angular CDK 将这个菜单与触发器连接起来。尽管菜单项本身被附加到覆盖层内的<body>元素中,但覆盖层的位置对于菜单计算得非常准确,并且在屏幕大小调整时也是正确的。然而,Angular CDK 菜单 API不包含覆盖层的 CSS 样式。因此,在这个菜谱中,我们提供了覆盖层和菜单本身的相应样式链接。你也会注意到我们在菜单列表(<ul>)项上使用了cdkMenu指令,在菜单(<li>)项上使用了cdkMenuItem指令。由于我们使用了这三个指令而没有使用 Angular CDK 菜单包中的其他任何东西,你会注意到我们没有从'@angular/cdk/menu'导入CdkMenuModule。这是因为所有这些指令都是standalone指令。所以它们可以不导入模块而导入。

在这个菜谱中,我们还修改了菜单的默认定位。默认情况下,覆盖层从触发器的左下角开始,向右侧扩展。我们使用了一个带有 [cdkMenuPosition] 绑定的 ConnectionPosition 数组,将其绑定到更多按钮(触发元素)以使用我们首选的位置。第一个(首选)位置是覆盖层的右上角与触发元素的右下角对齐。也就是说,覆盖层显示在更多按钮下方,并继续向左侧扩展,如下所示:

{ 
      offsetY: 4, 
      originX: 'end', 
      originY: 'bottom', 
      overlayX: 'end', 
      overlayY: 'top', 
 } 

第二个位置是,覆盖层显示在“更多”按钮(三个垂直点图标)上方,并且根据以下选项继续向左侧扩展:

{ 
      offsetY: -4, 
      originX: 'end', 
      originY: top, 
      overlayX: 'end', 
      overlayY: bottom, 
 } 

最后,我们实现了嵌套菜单。这相当简单。我们使用与第一个菜单相同的技术创建了另一个菜单,创建了一个带有模板变量的 <ng-template>,其中包含一个 cdkMenu 和菜单内的 cdkMenuItems。然后我们将这个嵌套菜单的触发器设置为一级菜单中的“管理”菜单项。Angular CDK 足够智能,能够理解触发器是否是 cdkMenuItem。如果是,它会在触发菜单项的鼠标悬停事件上自动打开嵌套菜单。当从嵌套菜单触发鼠标离开事件时,它会隐藏。我必须向 Angular CDK 团队表示敬意,因为他们使事情变得如此简单。

参见

在 Discord 上了解更多

要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新发布的内容——请扫描下面的二维码:

packt.link/AngularCookbook2e

二维码

第十章:使用 Jest 在 Angular 中编写单元测试

“在我的机器上它运行正常”这句话随着时间的推移仍不失其美感。它是许多工程师的盾牌,但对于质量保证专业人员来说却是一场噩梦。但说实话,还有什么比编写测试更好的方法来提高应用程序的健壮性呢?当谈到编写单元测试时,我个人最喜欢的工具是Jest。这是因为它超级快,轻量级,并且有易于使用的 API 来编写测试。更重要的是,它的速度比 Angular 自带的开箱即用的 Karma 和 Jasmine 设置要快。在本章中,您将学习如何配置 Jest 与 Angular 一起并行运行这些测试。您将学习如何使用 Jest 测试组件、服务和管道。您还将学习如何为这些测试模拟依赖项。

在本章中,我们将介绍以下食谱:

  • 在 Angular 中使用 Jest 设置单元测试

  • 为 Jest 提供全局模拟

  • 使用存根模拟 Angular 服务

  • 在单元测试中监视注入的服务

  • 使用ng-mocks包模拟子组件和指令

  • 使用 Angular CDK 组件工具包编写更简单的测试

  • 使用可观察对象进行单元测试组件

  • 单元测试 Angular 管道

技术要求

对于本章的食谱,请确保您的设置已按照“Angular-Cookbook-2E”GitHub 仓库中的“技术要求”完成。有关设置详情,请访问:github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/docs/technical-requirements.md。本章的起始代码位于github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/start/apps/chapter10

使用 Jest 在 Angular 中设置单元测试

默认情况下,一个新的 Angular 项目包含了许多优点,包括配置和用于运行单元测试的工具,例如 Karma 和 Jasmine。虽然使用 Karma 相对方便,但许多开发者在大型项目中发现,如果涉及大量测试,整个测试过程会变得非常缓慢。这主要是因为您不能并行运行测试。在本食谱中,我们将为 Angular 应用程序设置 Jest 进行单元测试。此外,我们将现有测试从 Karma 语法迁移到 Jest 语法。

从 v16 版本开始,Angular 为 Jest 提供了开发者预览,这个过程变得更加简单。本食谱针对 Angular v15 及以下版本的应用程序,并在本章末尾介绍 v16 版本可以做什么。

准备工作

我们现在将要工作的应用程序位于克隆的仓库中的start/apps/chapter10/ng-jest-setup

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve:ng-jest-setup 
    

    步骤 2 中的命令与本书中其他食谱的命令不同。这是因为本食谱的项目 不是 我们拥有的 NX 工作空间的一部分;它本身就是一个独立的 Angular 应用;因此,它有不同的命令。

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

    图片

    图 10.1:在 http://localhost:4200 上运行的 ng-jest-setup 应用

    接下来,尝试运行测试并监控整个过程运行所需的时间。从工作区 root 运行命令 npm run test:ng-jest-setup;几秒钟内,应该会打开一个新的 Chrome 窗口,如下所示:

    图片

    图 10.2:使用 Karma 和 Jasmine 的测试结果

看到前面的截图,你可能会说 “Pfffttt,Ahsan,它说‘完成于 0.033s(或接近这个时间)!’你还需要什么?” 好吧,这个时间只涵盖了 Karma 在创建 Chrome 窗口后运行测试所需的时间。它不包括启动过程、启动 Chrome 窗口以及加载测试所需的时间。这可以通过为 Karma 运行无头 Chrome 浏览器来改进,这需要一些配置。然而,与替代方案(Jest)相比,它仍然较慢。此外,在编写此食谱时,我在 Macbook Pro 上运行此操作,它很快*。这就是我们要用 Jest 替换它的原因。现在,你已经了解了问题,在下一节中,让我们看看食谱的步骤。

如何做到这一点...

在这里,我们有一个简单的 Counter 组件的 Angular 应用。它显示计数器的值,并有三个操作按钮:其中一个按钮用于增加计数器的值,另一个用于减少,另一个用于重置值。此外,还有一些使用 Karma 和 Jasmine 编写的测试,如果你运行测试,所有测试都将通过。我们将首先设置 Jest。执行以下步骤:

  1. 首先,打开一个新的终端窗口/标签页,确保你位于 start/apps/chapter10/ng-jest-setup 文件夹内。一旦进入,运行以下命令来安装测试 Jest 所需的包:

    npm install --save-dev jest jest-preset-angular @types/jest 
    
  2. 现在,我们可以卸载 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 
    
  3. 现在,更新 angular.json 文件中的测试配置,如下所示:

    {
      ...
      "projects": {
    "ng-jest-setup": {
    "..."
    "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"
    } 
    
  4. 我们现在将创建一个文件来配置 Jest 以用于我们的项目。在项目文件夹内创建一个名为 jestSetup.ts 的文件,并将以下内容粘贴进去:

    import 'jest-preset-angular/setup-jest'; 
    
  5. 现在,让我们修改 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"]
    } 
    
  6. 我们现在将修改 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": {...},
    } 
    
  7. 最后,让我们通过在 package.json 文件中添加 Jest 配置来完成我们 Jest 测试的整个配置,如下所示:

    {
      ...
      "dependencies": {...},
    "devDependencies": {...},
    **"jest"****:****{**
    **"preset"****:****"jest-preset-angular"****,**
    **"setupFilesAfterEnv"****:****[**
    **"<rootDir>/jestSetup.ts"**
    **]**
    **}**
    } 
    
  8. 现在我们已经设置好了一切,只需在 ng-jest-setup 文件夹内运行 test 命令,如下所示:

    npm run test 
    

    一旦测试完成,你应该能看到以下输出:

    图 10.3:使用 Jest 的测试结果

Kaboom!你会注意到使用 Jest 运行测试的整个过程大约需要 6 秒。第一次运行时可能会更长,但后续的运行应该会更快。现在你已经知道如何配置 Angular 应用程序以使用 Jest 进行单元测试,请参阅下一节以获取更多学习资源。

奖励:将 Angular v16 迁移到 Jest

从 Angular v16 开始,要将项目迁移到 Jest(假设你的项目目前使用 Karma),你只需要更新项目中的 angular.json 文件,并使用以下配置对象属性层次结构 your-app > architect > test 设置:

{
"projects": {
"my-app": {
"architect": {
**"test"****:****{**
**"builder"****:****"@angular-devkit/build-angular:jest"****,**
**"options"****:****{**
**"tsConfig"****:****"tsconfig.spec.json"****,**
**"polyfills"****:****[****"****zone.js"****,****"zone.js/testing"****]**
**}**
**}**
}
}
}
} 

参见

为 Jest 提供全局模拟

在上一个菜谱中,我们学习了如何为 Angular 单元测试设置 Jest。可能会有一些场景,你想要使用浏览器 API,而这些 API 可能不是你的实际 Angular 代码的一部分——例如,使用 LocalStoragealert。在这种情况下,我们需要为那些我们想要返回模拟值的函数提供一些全局模拟。这样我们就可以测试涉及它们的测试了。在这个菜谱中,你将学习如何为 Jest 提供全局模拟。

准备工作

我们现在将要工作的应用程序位于克隆的仓库中的 start/apps/chapter10/ng-jest-global-mocks

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-jest-global-mocks 
    

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

    图 10.4:在 http://localhost:4200 上运行的 ng-jest-global-mocks 应用程序

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

如何操作...

我们在这个菜谱中使用的应用程序使用了两个全局 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'****,** **() =>** **{**
    **jest.****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**
    **below the MIN_VALUE'****,** **() =>** **{**
    **jest.****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 保存和检索的值是否正确呢?

  2. 我们将创建一个新的测试来确保 localStorage.getItem 方法被调用以从 LocalStorageAPI 获取最后保存的值。将测试添加到 counter.component.spec.ts 文件中,如下所示:

    ...
    describe('CounterComponent', () => {
      ...
      **it.****only****(****'****should call the localStorage.getItem method on**
    **component init'****,** **() =>** **{**
    **jest.****spyOn****(****localStorage****,** **'getItem'****);**
    **component.****ngOnInit****();**
    **expect****(****localStorage****.****getItem****).****toHaveBeenCalled****();**
    **});**
    }); 
    

    注意到我们在这个测试用例中使用了 it.only。这是为了确保我们现在只运行这个测试。如果你运行测试,你应该能看到以下截图中的内容:

    img/B18469_10_05.png

    图 10.5:覆盖 LocalStorageAPI 的测试失败了

    注意到 匹配器错误:接收到的值必须是一个模拟或间谍函数 的消息。这就是我们接下来要做的,也就是提供一个模拟。

  3. 在项目的 src 文件夹内创建一个文件,并将其命名为 jest-global-mocks.ts。然后,添加以下代码来模拟 LocalStorageAPI

    class LocalStorageMock {
      storage: Partial<Storage> = {};
      getItem(key: string) {
        return this.storage[key] ? this.storage[key] : null;
      }
    
      setItem(key: string, value: string) {
        this.storage[key] = value;
      }
    }
    
    Object.defineProperty(window, 'localStorage', {
      value: new LocalStorageMock(),
    });
    
    Object.defineProperty(window, 'alert', {
      value: jest.fn(),
    }); 
    
  4. 现在,将此文件导入到 src/test-setup.ts 文件中,如下所示:

    ...
    globalThis.ngJest = {...};
    import 'jest-preset-angular/setup-jest';
    **import****'./jest-global-mocks'****;** 
    

    现在,如果你重新运行测试,它们应该会通过。

  5. 让我们添加另一个测试以确保在组件初始化时从 LocalStorage 中检索最后保存的值。修改 counter.component.spec.ts 文件,如下所示:

    ...
    describe('CounterComponent', () => {
      ...
      it('should call the localStorage.getItem method on
    component init', () => {...});
      **it****(****'should retrieve the last saved value from**
    **localStorage on component init'****,** **() =>** **{**
    **localStorage****.****setItem****(****'counterValue'****,** **'12'****);**
    **component.****ngOnInit****();**
    **expect****(component.****counter****).****toBe****(****12****);**
    **});**
    }); 
    
  6. 最后,让我们确保在触发 incrementdecrementreset 方法时,将计数器的值保存到 LocalStorage 中。更新 counter.component.spec.ts 文件,如下所示:

    ...
    describe('CounterComponent', () => {
      ...
      **it****(****'should save the new counterValue to localStorage on**
    **increment, decrement and reset'****,** **() =>** **{**
    **jest.****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 提供了一种为测试定义全局模拟的方法。它自带了许多内置函数来定义测试套件和测试用例,以及断言函数。以下是一个简单的 Jest 测试套件示例,用于测试一个 sum 函数,该函数用于将两个数字相加:

import { sum } from './sum';
describe('Sum function', () => {
  it('should add two numbers', () => {
    expect(sum(2,3)).toBe(5);
  });
}); 

Jest 还有一种方法可以在项目中使用特定的文件来设置 Jest。当与不同的框架一起工作时,文件(或文件名)可能不同,但目的仍然是相同的,即通过这个 Jest 设置文件定义全局配置。在我们的 NX 工作区中,我们已经在每个项目的 src 文件夹下创建了 Jest 文件,文件名通常是 test-setup.ts。定义全局模拟的一种常见方法是为模拟创建一个新的文件,然后将其导入到 test-setup.ts 文件中。

注意,我们使用Object.defineProperty方法在window对象中为LocalStorage对象提供模拟实现,并且我们对window.alert模拟也做了类似处理。这实际上适用于任何未在JSDOMJavaScript Document Object Model)中实现的自定义 API。同样,你可以为你在测试中使用的每个 API 提供一个全局模拟。注意,在value属性中,我们使用new关键字创建LocalStorageMock类的新实例。本质上,这是定义模拟的一种方法。我们创建了LocalStorageMock类,在其中有一个名为storage的私有属性,它模拟了localStorage对象。我们还在其中定义了getItemsetItem方法,以便我们可以向此存储设置值并从中获取值。注意,我们没有实现removeItemclear方法,这些方法在实际的localStorage API 中是有的。我们不需要它们,因为我们实际上没有使用这些方法。

should call the localStorage.getItem method on component init测试中,我们简单地监视localStorage对象的getItem方法,自己调用ngOnInit方法,然后期望它已经被调用。简单易行!

should retrieve the last saved value from localStorage on component init测试中,我们使用setItem方法在localStorage对象中为计数器的值保存12。本质上,调用setItem方法调用的是我们的模拟实现方法,而不是实际的localStorage API 的setItem方法。注意,在这里,我们没有对getItem方法进行监视;这是因为,稍后我们希望组件的counter属性的值为12

重要提示

每当我们监视一个方法时,请记住,实际函数中的任何语句将不再执行。这就是为什么我们不会在先前的测试中监视getItem方法。如果我们这样做,模拟实现中的getItem方法将不会返回任何内容。因此,我们对计数器属性的预期值将不会是12

简而言之,如果你必须依赖于函数实现的输出,或者函数内部执行的语句,不要监视该函数,并用jest.fn().mockReturnValue(123);替换该函数。这确保了我们不仅可以检查函数是否被调用,还可以使用实际的代码使用返回值。

PS:我总是在调试和敲打了一段时间的头之后,才艰难地学到这一点。

最终的测试非常简单。在 should save the new counterValue to localStorage on increment, decrement and reset 测试中,我们监视 setItem 方法。然后,在分别运行 incrementdecrementreset 方法之前,我们手动设置计数器属性的值。此外,我们期望 setItem 方法使用正确的参数被调用以保存值到存储中。注意,我们在保存后不检查存储的值。如我之前提到的,因为我们监视了 setItem 方法,其内部语句不会触发,因此值不会被保存;因此,我们之后无法检索保存的值。

参见

使用存根模拟 Angular 服务

几乎没有 Angular 应用程序在其内部没有创建一个 服务。在整体业务逻辑方面,服务承载了大量的内容,尤其是在与 API 交互时。在这个食谱中,你将学习如何使用存根模拟服务。

准备工作

我们现在将要工作的应用程序位于克隆的仓库 start/apps/chapter10/ng-test-services-stubs 内:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-test-services-stubs 
    

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

    图 10.6:ng-test-services-stubs 应用在 http://localhost:4200 上运行

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

如何做到这一点...

我们拥有与之前食谱相同的应用程序;然而,我们将保存和检索数据的逻辑从 localStorage 移动到了我们创建的 CounterService。现在,所有测试都通过了。但是,如果我们想隐藏/封装计数器值存储的逻辑呢?也许我们想为此发送后端 API 调用。为此,测试我们的 CounterService 的方法是否被调用比检查 localStorage 方法更有意义。让我们按照食谱为我们的服务提供一个模拟存根:

  1. 首先,在 src 文件夹内创建一个名为 __mocks__ 的文件夹。在其内部,创建一个名为 services 的文件夹。然后,再次在这个文件夹内部,创建一个名为 counter.service.mock.ts 的文件,并包含以下内容:

    import { CounterService } from "../../app/services/counter.service"; 
    const counterServiceMock: CounterService = {
      storageKey: 'counterValue',
      getFromStorage: jest.fn(),
      saveToStorage: jest.fn(),
    };
    export default counterServiceMock; 
    
  2. 现在,在 counter.component.spec.ts 中,用模拟服务代替实际服务,如下所示:

    ...
    **import** **{** **CounterService** **}** **from****'../services/counter.service'****;**
    **import** **counterServiceMock** **from****'../../__mocks__/services/counter.service.mock'****;**
    describe('CounterComponent', () => {
      ...
      beforeEach(async () => {
        await TestBed.configureTestingModule({
          imports: [CounterComponent]**,**
    **providers****: [{**
    **provide****:** **CounterService****,**
    **useValue****: counterServiceMock**
    **}]**
        }).compileComponents();
      });
      ...
    }); 
    

    随着前面的更改,你应该会看到以下错误,表明 localStorage.getItem 没有被调用。这是因为我们现在正在监视我们服务模拟存根上的方法:

    图 10.7:由于监视的方法,localStorage.getItem没有被调用

  3. 现在,不要期望在测试中调用localStorage对象的methods,而是期望调用我们服务的methods。使用以下代码片段更新counter.component.spec.ts文件,以替换以下注释下面的所有测试:ng-cookbook.com/s/services-stub-tests

    ...
    describe('CounterComponent', () => {
      ...
    **// replace the tests below**
    }); 
    

太棒了!你现在知道如何模拟服务来测试具有服务依赖的组件。请参阅下一节了解这一切是如何工作的。

它是如何工作的...

为 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方法,并期望组件的计数器属性现在已设置为12。这意味着以下事情发生了:

  1. ngOnInit调用getFromStorage方法。

  2. getFromStorage返回之前保存的值(在我们的例子中,这是12,但在现实中,它将从localStorage中获取)。

  3. 组件的计数器属性被设置为检索到的值,在我们的例子中是12

现在,对于最后的测试,我们只需期望在每种必要情况下调用CounterServicesaveToStorage方法。为此,我们使用以下类型的expect语句:

expect(counterServiceMock.saveToStorage).toHaveBeenCalledWith(1); 

这基本上就是全部内容。单元测试很有趣,不是吗?现在你已经了解了这一切是如何工作的,请参阅下一节,了解一些有用的资源,你可以用来进一步阅读。

参见

在单元测试中监视注入的服务

虽然你可以在单元测试中使用 Jest 为你的服务提供存根,但有时,为每个新的服务创建存根可能会感觉像是一种负担。让我们假设,如果服务的使用仅限于一个测试文件,那么在注入的实际服务上使用间谍可能更有意义。在这个配方中,我们正是要这么做。

准备工作

我们现在将要工作的应用位于克隆的仓库中的start/apps/chapter10/ng-test-services

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-test-services 
    

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

    图 10.8:ng-test-services 应用在 http://localhost:4200 上运行

现在我们有了运行中的应用,在下一节中,让我们通过配方的步骤。

如何做到...

我们有一个包含Counter组件的应用。我们可以增加、减少和重置计数器,计数器的值也会改变。这个值也存储在localStorage中。我们有从localStorageCounterService保存和检索数据的逻辑。现在,所有的测试都通过了,但我们直接从localStorage检查测试中的值。如果我们决定改变计数器值存储的逻辑呢?也许我们想要为它发送后端 API 调用。为此,测试我们的CounterService的方法是否被调用比检查localStorage方法更有意义。然而,与之前提供的为我们的服务提供存根的配方不同,我们将直接注入服务,并为我们要确保被调用的 Angular 服务中的函数创建 jest 间谍。让我们按照配方来做:

  1. 打开counter.component.spec.ts文件。你将看到一个注释说// 替换下面的测试。让我们在那个注释下面替换第一个测试should call the localStorage.getItem method on component init,替换为以下内容:

    it(' should get counter from storage on component init',
      () => {
        jest.spyOn(component.counterService, 'getFromStorage');
        component.ngOnInit();
        expect(component.counterService.getFromStorage)
     .toHaveBeenCalled();
      }); 
    

    如果你再次运行npm run test,你应该仍然看到所有测试都通过了。

  2. 现在,让我们将测试替换为“在组件初始化时从 localStorage 检索最后保存的值”的测试,替换为以下测试:

    it(' should have the initial value of counter from storage
      on component init ', () => {
        jest.spyOn(component.counterService,
          'getFromStorage').mockReturnValue(12);
        component.ngOnInit();
        expect(component.counter).toBe(12);
      }); 
    
  3. 最后,将最后一个测试“在增加、减少和重置时将新的 counterValue 保存到 localStorage”替换为以下代码:

    it('should save the new counterValue on increment, decrement
      and reset', () => {
        jest.spyOn(component.counterService, 'saveToStorage');
        component.counter = 0;
        component.increment();
        expect(component.counterService.saveToStorage)
     .toHaveBeenCalledWith(1);
        component.counter = 20;
        component.decrement();
        expect(component.counterService.saveToStorage)
     .toHaveBeenCalledWith(19);
        component.reset();
        expect(component.counterService.saveToStorage)
     .toHaveBeenCalledWith(0);
      }); 
    

太棒了!通过这个更改,你应该看到所有八个测试都通过了。让我们看看下一节,了解它是如何工作的。

它是如何工作的...

这个食谱包含了本章之前食谱中的很多知识。然而,关键亮点是注入到组件中的CounterService类只能通过jest.spyOn方法直接模拟。这种方法消除了需要单独模拟每个服务的需求。在第一个(已替换)测试中,我们监视component.counterService属性的getFromStorage方法。这使得它成为一个 Jest 监视器,我们可以将其提供给expect块,并运行expect(component.counterService.getFromStorage).toHaveBeenCalled();语句。在第二个测试中,请注意,我们不仅监视了getFromStorage方法,还返回了一个模拟值12,这样当component.ngOnInit方法被调用时,从CounterService返回的值就是这个模拟值12。因此,counter属性被设置为 12,测试通过。

最后,在最后一个测试中,我们监视saveToStorage方法。然后,每次我们点击IncrementDecrementReset时,我们期望被监视的函数以正确的值被调用。简单易行!

参见

使用 ng-mocks 包模拟子组件和指令

单元测试主要围绕测试组件、指令、管道或服务进行。然而,如果你的组件完全依赖于另一个组件或指令才能正常工作,尤其是在一个非独立应用程序/组件中,会怎样呢?在这种情况下,你通常会提供一个模拟实现来模拟组件,但这需要大量的工作。然而,使用ng-mocks包,这变得非常简单。在这个食谱中,我们将学习如何使用ng-mocks来模拟一个依赖于子组件才能正常工作的父组件的高级示例。

准备工作

我们现在要工作的应用程序位于克隆的仓库中的start/apps/chapter10/ng-test-ng-mocks目录下:

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令来启动项目:

    npm run serve ng-test-ng-mocks 
    

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

    图 10.9:ng-test-ng-mocks 应用程序在 http://localhost:4200 上运行

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

如何做到这一点...

如果你从工作区根目录运行命令npm run test ng-test-ng-mocks,你会看到并非所有我们的测试都通过。此外,控制台还有一大堆错误,如下所示:

图 10.10:单元测试中提到 app-version-control 组件时的错误

注意,我们正在使用一个带有NgModule的应用程序,即不是一个独立的应用程序。这是为了演示如何在这样的情况下工作。让我们通过食谱来模拟相应的组件,使用ng-mocks包:

  1. 首先,让我们在我们的项目中安装ng-mocks包。为此,从终端中运行以下命令,从项目根目录开始:

    cd start && npm install ng-mocks --save 
    
  2. 现在,我们将尝试修复App组件的测试。要基于字符串正则表达式仅运行特定测试,我们可以使用测试命令的-t参数。运行以下命令,运行后按-t键提供正则表达式"``App"

    npm run test ng-test-ng-mocks –t "App" 
    

    现在,你可以看到我们只运行了AppComponent的测试,并且它们失败了如下:

    图片

    图 10.11:仅运行特定测试

  3. 要修复图 10.10中显示的错误,我们需要将VersionControlComponent导入到app.component.spec.ts文件中的TestBed定义内部。这样做是为了让我们的测试环境也知道缺失的<app-version-control>组件。为此,按照以下方式修改文件:

    ...
    **import** **{** **VersionControlComponent** **}** **from****'./components/version-control/version-control.component'****;**
    ...
    describe('AppComponent', () => {
      beforeEach(waitForAsync(() => {
        TestBed.configureTestingModule({
          imports: [RouterTestingModule],
          **declarations****: [****AppComponent****,**
    **VersionControlComponent****],**
        }).compileComponents();
      }));
      ...
    }); 
    

    如果你重新运行AppComponent的测试,你会看到一些更新鲜、更新的错误。进展!但是,由于Version Control组件依赖于另一个组件,测试没有通过。我们将在如何工作...部分更详细地讨论这是为什么。然而,为了修复这个问题,让我们按照以下步骤进行。

  4. 由于我们并不关心AppComponent测试中的VersionControlComponent,所以我们需要对其进行模拟。一种方法是在TestBed的配置中使用CUSTOM_ELEMENT_SCHEMA来告诉测试床我们并不关心AppComponent测试中的VersionControlComponent。然而,我们将使用ng-mocks中的MockComponent来模拟组件,以达到相同的效果。为此,按照以下方式更新app.component.spec.ts文件:

    ...
    **import** **{** **MockComponent** **}** **from****'ng-mocks'****;**
    ...
    describe('AppComponent', () => {
      beforeEach(waitForAsync(() => {
        TestBed.configureTestingModule({
          **declarations****: [****AppComponent****,**
    **MockComponent****(****VersionControlComponent****)],**
    **imports****: [****RouterTestingModule****],**
        }).compileComponents();
      }));
      ...
    }); 
    

    嘣!问题解决。再次运行测试,仅针对AppComponent,你应该会看到它们都通过如下:

    图片

    图片

  5. 在终端中按a键再次运行所有测试。

  6. 现在,让我们谈谈Version Control组件的测试。这依赖于VC Logs组件。这次,让我们像专业人士一样模拟VCLogsComponent类,使用MockBuilderMockRender方法,这样我们就可以在测试期间消除错误。更新后,version-control.component.spec.ts文件应该如下所示:

    import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
    import { VersionControlComponent } from './version-control.component';
    import { FormsModule } from '@angular/forms';
    import { MockBuilder, MockRender, MockedComponentFixture } from 'ng-mocks';
    import { AppModule } from '../../app.module';
    import { VcLogsComponent } from '../vc-logs/vc-logs.component';
    describe('VersionControlComponent', () => {
      let component: VersionControlComponent;
      let fixture: MockedComponentFixture
     <VersionControlComponent>;
      beforeEach(() => {
        return MockBuilder(
          VersionControlComponent,
          AppModule
        );
      });
      beforeEach(() => {
        fixture = MockRender(VersionControlComponent);
        component = fixture.point.componentInstance;
      });
      it('should create', () => {...});
    }); 
    

    如果你现在运行npm run test,你应该会看到所有的测试都通过了。接下来,让我们在下一步写另一个测试。

  7. VersionControlComponent在模板中将VCLogsComponent用作子组件。此外,它通过[vName]属性将vName属性作为@Input()提供给VCLogsComponent。我们可以检查输入值是否设置正确。为此,按照以下方式更新version-control.component.spec.ts文件:

    import { VersionControlComponent } from './version-control.component';
    import { MockBuilder, MockRender, MockedComponentFixture, **ngMocks** } from 'ng-mocks';
    import { AppModule } from '../../app.module';
    **import** **{** **VcLogsComponent** **}** **from****'../vc-logs/vc-logs.component'****;**
    describe('VersionControlComponent', () => {
      ...
      it('should create', () => {...});
      **it****(****'should set vName input value in VCLogsComponent'****,**
    **() =>** **{**
    **const** **vcLogsComponent = ngMocks.****find****<****VcLogsComponent****>(**
    **'app-vc-logs'**
    **).****componentInstance****;**
    **component.****versionName** **=** **'2.2.2'****;**
    **fixture.****detectChanges****();**
    **expect****(vcLogsComponent.****vName****).****toBe****(****'2.2.2'****);**
    **});**
    }); 
    
  8. 现在,让我们修改vc-logs.component.spec.ts文件,以确保当VCLogsComponent中的vName发生变化时,在logs数组中创建一个新的日志。为此,按照以下方式修改文件:

    ...
    import { TestBed, waitForAsync } from '@angular/core/testing';
    import { VcLogsComponent } from './vc-logs.component';
    **import** **{** **MockRender****,** **MockedComponentFixture** **}** **from****'****ng-mocks'****;**
    describe('VcLogsComponent', () => {
      ...
      beforeEach(waitForAsync(() => {...}));
      beforeEach(() => {
        fixture = **MockRender****(****VcLogsComponent****, {**
    **vName****:** **'0.0.0'**
    **});**
        component = fixture**.****point**.componentInstance;
        fixture.detectChanges();
      });
      it('should create', () => {
        expect(component).toBeTruthy();
      });
      **it****(****'should add a log after vName change'****,** **() =>** **{**
    **fixture.****detectChanges****();**
    **fixture.****componentInstance****.****vName** **=** **"2.2.3"****;**
    **fixture.****detectChanges****();**
    **expect****(component.****logs****).****toHaveLength****(****2****);**
    **});**
    }); 
    

嘣!我们已经通过使用ng-mocks包实现了一些有趣的测试。每次我使用它时,我都非常喜欢(通常与NgModule中的组件一起使用,因为standalone组件没有这个问题)。现在我们已经完成了配方,在下一节中,让我们看看这一切是如何工作的。

它是如何工作的...

在这个配方中,我们涵盖了一些有趣的事情。首先,为了避免控制台上的任何错误,抱怨未知组件,我们使用来自ng-mocks包的MockComponent方法,将我们依赖的组件声明为模拟。请注意,当一个组件被模拟时,它会失去其功能,因为所有的方法都变成了spy函数。这正是我们可以用ng-mocks包实现的简单事情。然而,我们将继续到一个更高级的情况,我必须承认这是一种相当不寻常的方法,即测试父组件中子组件的@Input属性和@Output发射器,以便测试整个流程。这就是我们通过检查@Input属性vName的值来测试VersionControlComponent的方式。再次强调,这主要适用于非独立组件。

注意,我们已经完全从version-control.component.spec.ts文件中移除了对@angular/core/testing包的使用。这是因为我们不再使用TestBed来创建测试环境。相反,我们使用ng-mocks包中的MockBuilder方法为我们的VersionControlComponent构建测试环境。MockBuilder方法有几个重载,我们使用的是将目标组件作为第一个参数,以及该组件所属的NgModule作为第二个参数。模块及其内部的所有内容都被模拟。这使得单独测试组件变得容易得多。您还可以使用像exclude这样的方法进行链式调用,例如MockBuilder(MyComponent, MyModule).exclude(OtherComponent),以不模拟特定的组件。然而,在这个配方中我们不需要这样做。我们还为VersionControlComponent编写了一个有趣的测试,即使用ngMocks.find方法获取子组件(VCLogsComponent),并检查当父组件中的相应属性发生变化时,子组件的@Input()是否设置得当。我认为这很有趣,因为我们现在正在检查另一个组件的属性,而不仅仅是我们要测试的组件。一般来说,您会想要模拟子组件或组件拥有的任何依赖项。话虽如此,您团队的方法可能不同。如果您有充分的理由不模拟子组件,并像配方中那样检查其属性,现在您知道了如何做。请参阅下一节以获取更多阅读资源。

重要提示

注意,我们不会在VersionControlComponent.addNewReleaseLog方法上使用间谍。这是因为如果我们这样做,该函数将变成 Jest 间谍函数。因此,它将失去其内部功能。作为交换,它将永远不会将新日志添加到releaseLogs数组中,并且我们的所有测试都不会通过。你可以尝试一下,看看效果如何。

参见

使用 Angular CDK 组件工具包编写更简单的测试

当编写组件测试时,可能会有一些场景,你想要与 DOM 元素交互。现在,这已经可以通过使用fixture.debugElement.query方法来查找元素,使用选择器然后触发它的事件来实现。然而,这意味着需要维护不同平台的 DOM 查询,了解所有选择器的标识符,然后在测试中公开所有这些。如果我们谈论 Angular 库,那就更糟了。当然,没有必要让每个与我库交互的开发者都知道所有元素选择器才能编写测试。只有库的作者应该知道这么多,以尊重封装。幸运的是,我们有 Angular CDK 团队提供的组件工具包,它们与 Angular 9 和 IVY 编译器一起发布。他们以身作则,为 Angular 材料组件提供了组件工具包。在这个菜谱中,你将学习如何创建自己的组件工具包。

准备工作

我们现在将要工作的应用位于克隆的仓库中的start/apps/chapter10/ng-test-cdk-harness

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-test-cdk-harness 
    

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

    图 10.13:运行在 http://localhost:4200 的 ng-test-cdk-harness 应用

现在你已经启动了应用,让我们继续到下一个部分,按照菜谱进行。

如何做到这一点...

我们有一个我们最喜欢的 Angular 版本控制应用,它允许我们创建发布日志。此外,我们已经有了一些测试,包括与 DOM 元素交互以验证一些用例的测试。让我们遵循菜谱使用组件工具包,看看在实际测试中变得多么容易:

  1. 首先,运行以下命令以运行测试:

    npm run test ng-test-cdk-harness 
    
  2. 现在,我们将为VersionControlComponent创建一个component harness。让我们在version-control文件夹内创建一个新文件,并将其命名为version-control.component.harness.ts。然后,在它里面添加以下代码:

    import { ComponentHarness } from '@angular/cdk/testing';
    export class VersionControlComponentHarness extends
      ComponentHarness {
      static hostSelector = 'app-release-form';
      protected getSubmitButton =
      this.locatorFor('button[type=submit]');
      protected getAppVersionInput =
      this.locatorFor(`#versionNumber`);
      protected getVersionErrorEl =
      this.locatorFor('small.error');
    } 
    
  3. 现在,我们需要为我们的VersionControlComponent测试设置 Harness 环境。为此,我们将使用 Angular CDK 中的TestbedHarnessEnvironment。按照以下方式更新version-control.component.spec.ts文件:

     import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
          ... 
    
  4. 现在,让我们在我们的VersionControlComponentHarness类中编写一些方法来获取相关信息。我们将在后续步骤中使用这些方法。按照以下方式更新version-control.component.harness.ts文件:

    ...
    export class ReleaseFormComponentHarness extends ComponentHarness {
      ...
      **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** **expected =** **'Version number does not match the**
    **pattern (x.x.x)'****;**
    **const** **versionErrorEl =** **await****this****.****getVersionErrorEl****();**
    **const** **versionErrorText =** **await** **versionErrorEl.****text****();**
    **const** **isErrorShown = versionErrorText.****trim****() ===**
    **expected;**
    **if** **(!isErrorShown) {**
    **console****.****log****({**
    **actual****: versionErrorText.****trim****(),**
    **expected,**
    **});**
    **}**
    **return** **isErrorShown;**
    **}** 
    
  5. 接下来,我们将使用组件 Harness 来完成我们的第一个测试,命名为should show error on wrong version number input。按照以下方式更新version-control.component.spec.ts文件:

    ...
    **import** **{** **VersionControlComponentHarness** **}** **from****'./version-control.component.harness'****;**
    describe('VersionControlComponent', () => {
      ...
      **it****(****'should show error on wrong version number input'****,**
    **async** **() => {**
    **const** **vcHarness =** **await****TestbedHarnessEnvironment**
    **.****harnessForFixture****(**
    **fixture,**
    **VersionControlComponentHarness**
    **);**
    **await** **vcHarness.****setNewAppVersion****(****'abcd'****);**
    **await** **vcHarness.****clickSubmit****();**
    **fixture.****detectChanges****();**
    **const** **isErrorShown =** **await** **vcHarness**
    **.****isVersionErrorShown****();**
    **expect****(isErrorShown).****toBe****(****true****);**
      });
      ...
    }); 
    

    现在,如果你运行npm run test,你应该会看到所有的测试都通过了,这意味着我们使用组件 Harness 的第一个测试是有效的。哇哦!

  6. 对于最终测试,我们还需要为VCLogsComponent创建一个组件 Harness。让我们快速创建它。在vc-logs文件夹内添加一个新文件,命名为vc-logs.component.harness.ts,并添加以下代码:

    import { ComponentHarness } from '@angular/cdk/testing';
    export class VCLogsComponentHarness extends ComponentHarness {
      static hostSelector = 'app-vc-logs';
      protected getLogsList = this.locatorForAll('.logs__item');
      async getLogsLength() {
        const logsElements = await this.getLogsList();
        return logsElements.length;
      }
      protected async getLogTextAt(index: number) {
        const logsElements = await this.getLogsList();
        return (await logsElements[index].text()).trim();
      }
      async getFirstLogText() {
        return await this.getLogTextAt(0);
      }
      async getSecondLogText() {
        return await this.getLogTextAt(1);
      }
    } 
    
  7. 最后,让我们修改version-control.component.spec.ts文件中的最终测试,如下所示:

    ...
    import { VersionControlComponentHarness } from './version-control.component.harness';
    **import** **{** **VCLogsComponentHarness** **}** **from****'../vc-logs/vc-logs.component.harness'****;**
    describe('VersionControlComponent', () => {
      ...
      it('should show the new log in the list after adding
      submitting a new log', async () => {
        **const** **vcHarness =** **await****TestbedHarnessEnvironment**
    **.****harnessForFixture****(**
    **fixture,**
    **VersionControlComponentHarness**
    **);**
    **const** **harnessLoader =** **TestbedHarnessEnvironment**
    **.****loader****(fixture);**
    **const** **vcLogsHarness =** **await** **harnessLoader.****getHarness****(**
    **VCLogsComponentHarness**
    **);**
    **const****VERSION** **=** **'2.3.6'****;**
    **await** **vcHarness.****setNewAppVersion****(****VERSION****);**
    **await** **vcHarness.****clickSubmit****();**
    **fixture.****detectChanges****();**
    **const** **logsLength =** **await** **vcLogsHarness.****getLogsLength****();**
    **expect****(logsLength).****toBe****(****2****);**
    **const** **firstLogText =** **await** **vcLogsHarness**
    **.****getFirstLogText****();**
    **expect****(firstLogText).****toBe****(****`initial version is 0.0.0`****);**
    **const** **secondLogText =** **await** **vcLogsHarness**
    **.****getSecondLogText****();**
    **expect****(secondLogText).****toBe****(****`version changed to**
    **2.3.6`****);**
      });
    }); 
    

哇哦!这就是使用 Angular CDK 组件 Harness 进行的一些令人惊叹的测试。如果你现在运行测试,你应该会看到它们全部通过。现在你已经完成了这个食谱,请参考下一节了解它是如何工作的。

它是如何工作的...

好的!这是一个很棒的食谱,我也很享受写它。这个食谱的关键因素是@angular/cdk/testing包。如果你之前使用 Protractor 进行过e2e测试,那么这个概念与 Protractor 中的页面页面对象POs)类似。我们首先做的事情是为VCLogsComponentVersionControlComponent创建组件 Harness。

注意,我们为两个组件 Harness 从@angular/cdk/testing中导入ComponentHarness类。然后,我们扩展我们的自定义类,称为VersionControlComponentHarnessVCLogsComponentHarness,从ComponentHarness类继承。本质上,这是编写组件 Harness 的正确方式。你注意到名为hostSelector的静态属性了吗?我们需要为每个我们创建的组件 Harness 类都设置这个属性,其值总是目标元素/组件的选择器。这确保了当我们把这个 Harness 加载到测试环境中时,环境可以在 DOM 中找到主机元素——这就是我们创建组件 Harness 的原因。在我们的组件 Harness 类中,我们使用this.locatorFor方法在主机组件中查找元素。locateFor方法接受一个参数,即要查找的元素的CSS 选择器,返回一个AsyncFactoryFn。这意味着返回的值是一个我们可以稍后用来获取所需元素的函数。

VersionControlComponentHarness类中,我们分别使用受保护的getSubmitButtongetAppVersionInput方法找到提交按钮和版本号输入。所有这些方法都是前面提到的AsyncFactoryFn类型。我们将这些方法设置为受保护的,因为我们不希望编写单元测试的开发者访问或关心 DOM 元素的信息。这使得每个人都能更容易地编写测试,而无需担心访问 DOM 的内部实现。

这里要提到的一个重要事项是,当我们调用locatorFor方法或locatorForAll方法时,我们分别得到一个包含TestElement项的Promise和一个包含TestElement项列表的Promise。每个TestElement项都有一系列方便的方法,例如clicksendKeysfocusblurgetPropertytext等。这些方法正是我们所感兴趣的,因为我们在幕后使用它们与 DOM 元素进行交互。

现在,让我们谈谈配置测试环境。在version-control.component.spec.ts文件中,我们设置环境以使用组件工具包对VCLogsComponentVersionControlComponent进行测试。TestbedHarnessEnvironment元素是这里的关键元素。我们首先使用TestbedHarnessEnvironment.harnessForFixture方法。这是因为我们正在为VersionControlComponent编写测试,并且我们还想将同一组件的工具包作为根。因此,harnessForFixture方法在这里是合理的。该方法将fixture作为第一个参数,将harness类作为第二个参数。然后,我们还使用TestbedHarnessEnvironment类的loader方法来获取HarnessLoader的实例。这个加载器可以获取VCLogsComponent的工具包,即获取VCLogsComponentHarness类的实例。请注意,fixture是我们使用TestBed.createComponent(VersionControlComponent)语句在测试环境中获得的。

注意,在测试中,我们通过提供工具包类作为参数来使用harnessLoader.getHarness方法。这使测试环境能够找到与工具包类hostSelector属性关联的 DOM 元素。此外,我们还得到了可以进一步在测试中使用的组件工具包实例。

另请参阅

单元测试 HTTP 调用响应

如果你正在构建一个 Angular 应用程序,你很可能在某个时候会在应用程序内部处理 HTTP 调用。例如,你可能从第三方 API 获取数据,或者只是向自己的服务器发送 API 调用。在两种情况下,测试具有 HTTP 调用的应用程序都会变得稍微困难一些。在这个食谱中,我们将学习如何创建带有 HTTP 调用的单元测试。

准备工作

我们现在将要工作的应用程序位于克隆的仓库中的start/apps/chapter10/ng-test-http-resp目录内:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-test-http-resp 
    

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

    图 10.14:ng-test-http-resp 应用程序在 http://localhost:4200 上运行

    让我们也运行一下测试。目前,对UserService的测试,它获取 HTTP 响应,没有通过。你应该看到类似于图 10.15的输出:

    图 10.15:UserService 测试失败

现在我们已经将应用程序和测试在本地上运行起来,在下一节中,让我们看看食谱的步骤。

如何操作...

我们将编写一个单独的测试来检查服务器的getUser方法是否返回正确的数据。如果你打开UserService,你会注意到该服务使用 HTTP 调用获取用户,然后修改数据以添加fullAddress属性。我们的测试应该期望发生相同的事情。让我们开始:

  1. 首先,我们将修复失败的测试。我们将把HttpClientModule类添加到测试的imports数组中。按照以下方式更新user.service.spec.ts

    ...
    **import** **{** **HttpClientModule** **}** **from****'****@angular/common/http'****;**
    describe('UserService', () => {
      ...
      beforeEach(() => {
        TestBed.configureTestingModule(**{**
    **imports****: [****HttpClientModule****],**
    **}**);
        service = TestBed.inject(UserService);
      });
      ...
    }); 
    

    现在,当你运行npm run test命令时,你会看到测试现在通过了,如下所示:

    图 10.16:UserService 的测试也通过

  2. 现在,我们将在user.service.spec.ts文件中导入HttpClientTestingModule,以便能够使用它来拦截 HTTP 调用。按照以下方式修改文件:

    ...
    **import** **{** **HttpClientTestingModule****,**
    **}** **from****'@angular/common/http/testing'****;**
    ...
    describe('UserService', () => {
      let service: UserService;
      beforeEach(() => {
        TestBed.configureTestingModule({
          imports: [HttpClientModule, **HttpClientTestingModule**],
        });
        service = TestBed.inject(UserService);
      });
    }); 
    
  3. 让我们使用来自同一@angular/common/http/testing包的HttpTestingController类创建一个控制器。我们稍后会使用这个控制器来期望和拦截 HTTP 调用。按照以下方式更新user.service.spec.ts文件:

    ...
    import {
      HttpClientTestingModule**,**
    **HttpTestingController****,**
    } from '@angular/common/http/testing';
    describe('UserService', () => {
      let service: UserService;
      **let****httpTestingController****:** **HttpTestingController****;**
    beforeEach(() => {
        TestBed.configureTestingModule({...});
        service = TestBed.inject(UserService);
        **httpTestingController =** **TestBed****.****inject****(**
    **HttpTestingController****);**
      });
    }); 
    
  4. 现在,我们将编写一个测试(终于)。我们将使用httpTestingController拦截对/assets/users.json的 HTTP 调用,然后我们将比较结果与我们的预期数据。通过添加以下测试来更新user.service.ts文件:

    ...
    **import** **{** **User** **}** **from****'../user.interface'****;**
    describe('UserService', () => {
      ...
      **it****(****'should return expected user data (HttpClient called**
    **once)'****,** **() =>** **{**
    **const****mockUsers****:** **User****[] = [];**
    **const** **req = httpTestingController**
    **.****expectOne****(****'assets/users.json'****);**
    **expect****(req.****request****.****method****).****toEqual****(****'GET'****);**
    **req.****flush****(mockUsers);** **// Respond with mocked data**
    **});**
    }); 
    
  5. 我们将在测试中添加一个afterEach钩子来验证测试后没有挂起的请求。让我们将其添加到user.service.spec.ts文件中,如下所示:

    ...
    describe('UserService', () => {
      ...
      **afterEach****(****() =>** **{**
    **// After every test, assert that there are no more**
    **pending requests.**
    **httpTestingController.****verify****();**
    **});**
      ...
    }); 
    
  6. 现在,让我们用两个用户填充模拟数据(mockUsers数组)。由于我们使用了req.flush(mockUsers),HTTP 调用将被拦截,并且这些数据(mockUsers数组)将作为响应从其中返回。将文件user.service.spec.ts中的测试it('should return expected user data (HttpClient called once)'替换为ng-cookbook.com/s/ng-test-http-mock-users中的代码片段。

  7. 最后,进一步更新测试,使用getUsers方法进行 HTTP 调用,并查看我们是否可以期望转换后的数据。更新user.service.spec.ts文件,如下所示:

    describe('UserService', () => {
      ...
      it('should return expected user data (HttpClient called
    once)', (**done**) => {
        const mockUsers: User[] = [...];
        **service.****getUsers****().****subscribe****({**
    **next****:** **(****data****) =>** **{**
    **expect****(data).****toEqual****([{**
    **...mockUsers[****0****],**
    **fullAddress****:** **'sample street 1, 123 ABC, Dream**
    **city, 4567'**
    **}, {**
    **...mockUsers[****1****],**
    **fullAddress****:** **'sample street 2, 123 ABC, Dream**
    **city, 890'**
    **}]);**
    **done****();**
    **},**
    **error****:** **(****err****) =>** **{**
    **console****.****log****(****'****Error: '****, err);**
    **},**
    **});**
        ...
      });
    }); 
    

    如果你运行测试,你应该看到它们都通过,如下所示:

    图 10.17:所有测试通过

太好了!你现在知道如何使用HttpTestingController来测试具有方法的服务的 HTTP 调用。尽管关于在 Angular 中测试 HTTP 调用和 Observables 还有很多东西要学习,但这个菜谱的目的是保持一切简单而甜蜜。

现在你已经完成了这个菜谱,请参考下一节了解它是如何工作的。

它是如何工作的...

这个菜谱的英雄是HttpClientTestingModule,它允许我们使用HttpTestingController的一个实例。HttpTestingController使得拦截我们代码中的 HTTP 调用变得容易,并允许我们返回一个特定的响应。这使得我们更容易不对 Angular 服务进行真实的 HTTP 调用测试,这可能会很昂贵。这种模拟响应方法的另一个好处是始终有一个相同的响应对象可以与之工作。在我们的例子中,我们的getUsers方法不仅进行了 HTTP 调用,还使用RxJsmap运算符转换了数据。因此,我们使用了一个模拟用户数组,并期望getUsers方法的结果包含转换后的数据,即包含正确的fullAddress属性。你可以在测试中看到,我们使用了HttpTestingController.expectOne方法,告诉我们的测试在运行测试时应该只有一个请求。我们期望那个请求的方法是GET。然后,我们期望getUsers方法返回的值有一个包含fullAddress属性的用户的数组。

参见

单元测试 Angular 管道

在我看来,管道是 Angular 应用程序中最容易测试的东西。为什么?因为它们(应该是)纯函数,根据相同的输入集返回相同的结果。在这个菜谱中,我们将为 Angular 应用程序中的一个简单管道编写一些测试。

准备工作

我们现在将要工作的应用位于克隆的仓库中的start/apps/chapter10/ng-test-pipes

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令来运行项目:

    npm run serve ng-test-pipes 
    

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

    图 10.18:运行在 http://localhost:4200 的 ng-test-pipes 应用

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

如何做到这一点...

在这里,我们有一个简单的食谱,它接受两个输入——数字和最大因子值。基于这些输入,我们显示一个乘法表。我们已经有了一个工作正常的MultTablePipe,根据我们的业务逻辑。现在我们将编写一些单元测试来验证我们的输入和预期输出,如下所示:

  1. 让我们为MultTablePipe编写第一个自定义测试。我们将确保当digit输入无效时,它返回一个空数组。用以下代码替换整个mult-table.pipe.spec.ts文件:

    import { MultTablePipe } from './mult-table.pipe';
    describe('MultTablePipe', () => {
      let pipe: MultTablePipe;
      beforeEach(() => {
        pipe = new 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([]);
      });
    }); 
    
  2. 让我们编写另一个测试来验证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****([]);**
    **});**
    }); 
    
  3. 现在,我们将编写一个测试来验证当digitlimit输入都有效时,管道的transform方法的输出。在这种情况下,我们应该得到包含乘法表的数组。按照以下方式编写另一个测试:

     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 = [{** **digit****:** **10****,** **factor****:** **1****,** **result****:**
    **10** **},**
    **{** **digit****:** **10****,** **factor****:** **2****,** **result****:** **20** **},];**
    **const** **outputArray = pipe.****transform****(****null****, digit, limit);**
    **expect****(outputArray).****toEqual****(expectedArray);**
    **});**
    }); 
    
  4. 目前,在应用内部,我们有提供limit输入的十进制数字的可能性。例如,我们可以将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 = [**
    **{****digit****:** **10****,** **factor****:** **1****,** **result****:** **10****},**
    **{****digit****:** **10****,** **factor****:** **2****,** **result****:** **20****},**
    **{****digit****:** **10****,** **factor****:** **3****,** **result****:** **30****}**
    **];** **// rounded off to 3 factors instead of 3.5**
    **const** **outputArray = pipe.****transform****(****null****, digit, limit);**
    **expect****(outputArray).****toEqual****(expectedArray);**
    **});**
    }); 
    

简单易行! 编写 Angular 管道的测试非常直接,我非常喜欢它。我们可以称之为纯函数的力量。现在你已经完成了食谱,请参阅下一节以获取更多信息链接。

参见

在 Discord 上了解更多

要加入这本书的 Discord 社区——在那里你可以分享反馈,向作者提问,并了解新版本——请扫描下面的二维码:

packt.link/AngularCookbook2e

第十一章:使用 Cypress 在 Angular 中进行端到端测试

拥有几个端到端E2E)(E2E)测试的应用程序肯定比没有任何测试的应用程序更可靠,在当今世界,随着新兴企业和复杂应用程序的出现,在某些时候编写 E2E 测试以捕获应用程序的整个流程变得至关重要。Cypress 是目前用于 Web 应用程序 E2E 测试的最佳工具之一。在本章中,您将学习如何使用 Cypress 测试 Angular 应用程序中的 E2E 流程。以下是本章将要涵盖的食谱:

  • 编写您的第一个 Cypress 测试

  • 验证 DOM 元素是否在视图中可见

  • 测试表单输入和提交

  • 等待XMLHttpRequestsXHRs)完成

  • 使用 Cypress 捆绑包

  • 使用 Cypress fixtures 提供模拟数据

技术要求

对于本章中的食谱,请确保您的设置已按照“Angular-Cookbook-2E”GitHub 仓库中的“技术要求”完成。有关设置详细信息,请访问:github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/docs/technical-requirements.md。本章的起始代码位于github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/start/apps/chapter11

编写您的第一个 Cypress 测试

如果您已经编写了 E2E 测试,您可能已经使用 Protractor 这样做过。使用 Cypress 是完全不同的体验。在本食谱中,您将使用现有的 Angular 应用程序设置 Cypress,并使用 Cypress 编写第一个 E2E 测试。

准备工作

我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter11/ng-cypress-starter

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-cypress-starter 
    

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

    img/B18469_11_01.png

    图 11.1:ng-cypress-starter 应用程序在 localhost:4200 上运行

如何做到这一点…

我们正在工作的应用程序是一个简单的计数器应用程序。它有最小值和最大值,以及一些可以增加、减少和重置计数器值的按钮。我们将首先为我们的应用程序配置 Cypress,然后转向编写测试:

  1. 由于我们使用的是 NX 工作区,这里的设置与常规 Angular 应用程序不同。打开一个新的终端窗口/标签,并确保您位于工作区的根目录中。一旦进入,运行以下命令将Cypress安装到我们的项目中:

    cd start && npm install --save-dev @nx/cypress 
    
  2. 现在,从工作区的根目录运行以下命令为应用程序创建一个cypress项目,如下所示:

    cd start && nx g @nx/cypress:cypress-project ng-cypress-starter-e2e --project=ng-cypress-starter --directory apps/chapter11/ng-cypress-starter-e2e 
    

    当(或如果)被问及时,在安装过程中选择 Cypress 的 Vite 打包器,因为 Vite 是一个更快的打包器,同时也提供了一个更快的开发服务器。并选择“Ap provided”,这样我们就在 start/apps/chapter11 文件夹中创建了 ng-cy-starter-e2e 文件夹。你会看到在 start/apps 文件夹内创建了一个名为 ng-cypress-starter-e2e 的新文件夹。

  3. 让我们运行一个脚本来重命名我们的应用程序,从 chapter11-ng-cypress-starter-e2e 改为 ng-cypress-starter-e2e。这将使我们更容易运行此食谱和下一个食谱的 e2e 测试。请在工作区的根目录中使用以下命令:

    node scripts/rename-app.js chapter11 ng-cypress-starter-e2e start 
    
  4. 现在,你可以从工作区的根目录(在 start 文件夹外部)运行以下命令来启动 Cypress 测试:

    npm run e2e ng-cypress-starter 
    

    你应该能够使用浏览器来启动运行测试。我将使用 Chrome 作为本书 e2e 测试的浏览器。

  5. cypress-chrome 窗口中点击 app.cy.ts(由 步骤 4 打开的浏览器窗口)来运行默认创建的测试。我们将在食谱中修改此文件以编写自己的测试。一旦运行测试,你会看到它们失败。但不要担心,因为我们还没有编写自己的测试。

  6. 让我们现在创建我们的第一个测试。我们将只是检查应用程序标题中的标题是否为 Your first Cypress test in Angular。让我们通过在文件中创建一个 PO页面对象)来替换 src/e2e/support/app.po.ts 文件的全部内容如下:

    export const getHeaderTitle = () =>
      cy.get('.toolbar__title'); 
    
  7. 现在,我们将从 src/e2e/app.cy.ts 文件中导入 getHeaderTitle 并替换第一个测试如下:

    import { getHeaderTitle } from '../support/app.po';
    
    describe('ng-cypress-starter', () => {
      beforeEach(() => cy.visit('/'));
    
      it('should display the correct header title', () => {
        getHeaderTitle().should('contain.text','Your first
    Cypress test in Angular');
      });
    }); 
    
  8. 如果你再次查看 Cypress 窗口,你应该会看到测试通过如下:![img/B18469_11_02.png]

    图 11.2:我们的第一个 Cypress 测试通过

简单,对吧? 现在你已经知道了如何为 Angular 应用程序配置 Cypress(尤其是在 NX 中),请参阅下一节了解它是如何工作的。

它是如何工作的…

Cypress 可以与任何框架和 Web 开发项目集成。一个有趣的事实是,Cypress 在幕后使用 Mocha 作为测试运行器。Cypress 的工具会监视代码更改,这样你就不必反复重新编译测试。Cypress 还在测试的应用程序周围添加了一个外壳,以捕获日志并在测试期间访问 DOM 元素,以及一些用于调试测试的功能。

在我们的 app.cy.ts 文件的最顶部,我们使用 describe 方法,它定义了测试套件,并定义了即将编写的测试的上下文。然后,我们使用 beforeEach 方法来指定在执行每个测试之前应该发生什么。由于每个测试开始时没有数据,我们首先必须确保 Cypress 导航到我们的应用程序的 URL:http://localhost:4200。我们之所以只指定 cy.visit('/') 而它仍然可以工作,是因为 NX 自动使用 @nx/cypress 包进行配置。如果您将 Cypress 添加到标准的 Angular 应用程序(不在 NX 工作区中),您将必须在 Cypress 配置文件(cypress.config.ts)中指定 baseUrl,如下所示:

import { defineConfig } from 'cypress'
export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:4200',
  },
}) 

然而,NX 为这本书的食谱做了这件事。因此,我们只需在我们的测试中提供相对 URL。

对于我们每个测试,我们使用 it 方法来指定它们的标题。您会注意到在 app.cy.ts 文件中,我们正在从 support/app.po 文件导入 getHeaderTitle 方法。如食谱中先前所述,PO 代表 page object。这是一种广泛的做法,使用这些对象来包含返回 Document Object ModuleDOM)元素的函数。这使我们的测试免于与 DOM 交互以检索元素的代码,并且我们有可重用的测试函数。在 app.po 文件中,您可以看到我们使用 cy.get 方法检索一个应用了 toolbar__title 类的单个元素。本书中所有食谱的 Angular 应用程序都有一个标题和一个显示食谱内容的标题。请注意,在 app.cy.ts 文件中,我们使用 getHeaderTitle 方法从我们的 HTML 页面获取目标元素。然后我们使用 should() 方法将标题的文本与预期的值 Your first Cypress test in Angular 进行比较。请注意,我们使用 ‘contain.text’ 而不是 'have.text',因为目标元素中可能有空白字符。以下是一些使用 should 方法的其他示例,其中包含不同的语句:

  • should('be.visible')

  • should('be.empty')

  • should('be.visible')

  • should('have.class''my-class')

  • should('have.id''newUserId')

  • should('be.visible')

  • should('have.focus')

现在您已经了解了食谱的工作原理,请参阅下一节以获取一些有用的链接。

参见

验证 DOM 元素在视图中是否可见

在大多数网络应用程序中,至少有一个元素/视图是基于某种条件显示的。否则,它会被隐藏。当确保最终用户在正确的情况下看到正确的内容时,进行良好的测试变得必要。在这个菜谱中,你将学习如何检查元素是否在 DOM 中可见。

准备工作

我们将要工作的应用程序位于克隆的仓库中的 start/apps/chapter11/ng-cy-element-visibility。然而,e2e 测试在 start/apps/chapter11/ng-cy-element-visibility-e2e 文件夹中。在这个菜谱中,我们将修改这两个文件夹中的文件。让我们按照以下步骤首先运行 e2e 测试:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目的 e2e 测试:

    npm run e2e ng-cy-element-visibility 
    

    这应该会打开 Cypress 窗口。选择 Chrome 进行测试,然后点击 app.cy.ts 文件以运行测试,你应该会看到以下内容:

    图 11.3:ng-cy-element-visibility 应用程序的 Cypress 测试运行

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

如何操作…

我们有来自上一个菜谱的同一个旧的计数应用程序。然而,有些事情已经改变了。我们现在在顶部有一个按钮,可以切换计数组件 (CounterComponent) 的可见性。此外,我们必须将鼠标悬停在计数卡片上才能看到 IncrementDecrementReset 操作按钮。

  1. 让我们创建必要的页面对象以供测试。我们将在页面对象 app.po.ts 文件中创建返回切换按钮和计数卡片的功能。更新 start/apps/chapter11/ng-cy-element-visibility-e2e 文件夹中的 src/support/app.po.ts 文件,如下所示:

    export const getHeaderTitle = () =>
     cy.get('.toolbar__title');
    **export****const****getToggleCounterButton** **= () =>**
    **cy.****get****(****'[data-test-id="toggleCounterBtn"]'****);**
    **export****const****getCounterCard** **= () => cy.****get****(**
    **'[data-test-id="counterCard"]'****);**
    **export****const****getCounterActions** **= () =>** **getCounterCard****()**
    **.****find****(****'button'****);** 
    
  2. 让我们现在将相关的测试 ID 添加到 HTML 中。我们将为切换计数按钮和计数元素添加 test-id 属性。修改 ng-cy-element-visibility/src/app/app.component.html 文件,如下所示:

    ...
    <main class="content" role="main">
    <div class="...">
    <button **data-test-id****=****"toggleCounterBtn"**
     (click)="toggleCounterVisibility()">Toggle Counter
            Visibility</button>
    <app-counter **data-test-id****=****"counterCard"**
    *ngIf="visibility ===
      visibilityOptions.Visible"></app-counter>
    </div>
    </main> 
    
  3. 现在,我们将编写一个测试来确保当点击切换计数按钮时,我们的计数卡片会显示和隐藏。为此,更新 ng-cy-element-visibility-e2e/src/e2e/app.cy.ts 文件,如下所示:

    import { **getCounterCard**, getHeaderTitle, **getToggleCounterButton** } from '../support/app.po';
    describe('ng-cypress-starter', () => {
      beforeEach(() => cy.visit('/'));
      it('should display the correct header title', () => ...});
      **it****(****'should toggle visibility of counter card when the**
    **toggle button is clicked'****,** **() =>** **{**
    **getCounterCard****().****should****(****'****exist'****);**
    **getToggleCounterButton****().****click****();**
    **getCounterCard****().****should****(****'not.exist'****);**
    **getToggleCounterButton****().****click****();**
    **getCounterCard****().****should****(****'exist'****);**
      }**);**
    }); 
    
  4. 现在,我们将编写另一个测试来检查当我们将鼠标悬停在 Counter 组件上时,我们的操作按钮(IncrementDecrementReset)是否会显示。再次更新 app.cy.ts 文件,如下所示:

    import { **getCounterActions**, getCounterCard, getHeaderTitle, getToggleCounterButton } from '../support/app.po';
    describe('ng-cypress-starter', () => {
      ...
      **it****(****'should show the action buttons when the counter card is hovered'****,**
    **() =>** **{** 
    **getCounterCard****().****trigger****(****'mouseover'****);** 
    **getCounterActions****().****should****(****'have.length'****,** **3****);** 
    **getCounterActions****().****contains****(****'Increment'****)**
    **.****should****(****'be.visible'****);** 
    **getCounterActions****().****contains****(****'Decrement'****)**
    **.****should****(****'be.visible'****);** 
    **getCounterActions****().****contains****(****'Reset'****)**
    **.****should****(****'be.visible'****);** 
    **});**
    }); 
    

    如果你现在查看 Cypress 窗口,你应该会看到测试失败,如下所示:

    图 11.4:无法在悬停时获取操作按钮

    测试失败的原因是 Cypress 目前不提供 CSS 悬停效果。为了解决这个问题,我们将在下一步安装一个包。

  5. 停止运行 e2e 测试,然后从工作区的根目录安装 cypress-real-events 包,如下所示:

    cd start && npm install --save-dev cypress-real-events 
    
  6. 现在,打开ng-cy-element-visibility-e2e项目中的src/support/e2e.ts文件并更新它,如下所示:

    ...
    // Import commands.js using ES2015 syntax:
    **/// <reference types="cypress-real-events" />**
    import './commands';
    **import****'cypress-real-events/support'****;**
    ... 
    
  7. 现在,更新app.cy.ts文件以在counter card元素上使用包中的realHover方法,如下所示:

    ...
    describe('ng-cypress-starter', () => {
      ...
    
      it('should show the action buttons when the counter card is hovered', () =>
        {
        getCounterCard()**.****realHover****();**
        ...
      })
    }); 
    
  8. 现在,再次从工作区根目录运行npm run e2e ng-cy-element-visibility命令(如果尚未运行)。你应该会看到所有测试通过,如图 11.5 所示:

图片

图 11.5:所有测试通过

太棒了!你刚刚学会了如何在不同的场景中检查 DOM 元素的可见性。当然,这些不是识别和与 DOM 元素交互的唯一选项。你可以参考 Cypress 文档以获取更多可能性。现在你已经完成了这个食谱,请查看下一节以了解它是如何工作的。

它是如何工作的…

我们首先通过构建 POs(页面对象)开始了这个食谱。这样做是个好主意,以便在编写测试时准备好函数。如果不存在获取特定页面对象的函数,我们可以在运行时创建它。请注意,我们使用should('exist')语句检查getCounterCard。如果你还不知道,我们也可以使用should('be.visible'),这同样有效。但是,当我们想要确保它不可见时,我们不能使用should('not.be.visible')语句。现在你可能正在想,“什么?!”确实如此!由于 Cypress 中的'visible'是构建得使得元素存在于 DOM 中并且可见,如果我们使用'be.visible''not.be.visible',它无法适应元素不存在于 DOM 中的情况。而且,由于我们使用*ngIf指令来显示或隐藏我们的Counter组件,它最终要么存在于 DOM 中,要么不存在。因此,使用should('exist')should('not.exis')在这里是合适的选择。

对于下一个测试,我们想看看当在计数器卡片上悬停(或进行鼠标悬停)时是否会显示操作按钮。为此,我们可以在计数器卡片上使用带有mouseover事件的trigger方法。然而,这不会起作用。为什么?因为 Cypress 中所有的悬停解决方案最终都会触发 JavaScript 事件,并且不会影响 CSS 伪选择器,而且由于我们的操作按钮(带有'.counter__actions__action'选择器)显示在具有'.counter'选择器的元素的:hover(CSS)上,我们的测试失败了,因为在测试中,我们的操作按钮实际上并没有显示。为了解决这个问题,我们使用了cypress-real-events包,它具有影响伪选择器的realHover方法,最终显示我们的操作按钮。

参见

测试表单输入和提交

如果你正在构建一个网络应用程序,那么你很可能至少会有一个表单,当涉及到表单时,我们需要确保我们有正确的用户体验UX)和正确的业务逻辑。有什么比为他们编写端到端测试更好的方法来确保一切按预期工作呢?在这个菜谱中,我们将使用 Cypress 测试一个表单,并验证在适当的情况下是否显示了正确的错误。

准备工作

我们将要工作的应用程序位于克隆的仓库中的 start/apps/chapter11/ng-cy-forms 目录下。然而,端到端测试位于 start/apps/chapter11/ng-cy-forms-e2e 文件夹中。在这个菜谱中,我们将修改这两个文件夹中的文件。让我们首先按照以下步骤运行端到端测试:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目的端到端测试:

    npm run e2e ng-cy-forms 
    

    这应该会打开 Cypress 窗口。选择 Chrome 进行测试,并点击 app.cy.ts 文件以运行测试,你应该会看到以下内容:

    图 11.6:运行 ng-cy-forms 应用程序的 Cypress 测试

现在我们有了 Cypress 测试在运行,让我们在下一节中查看菜谱的步骤。

如何做到这一点...

我们必须确保在表单成功提交并带有新版本时,我们看到一条新日志。我们还需要确保在版本输入为空或版本输入的值无效时,我们看到相关的错误。让我们开始吧:

  1. 让我们创建测试所需的页面对象。我们已经在测试中想要使用的元素上有了 data-test-id 属性。因此,我们可以在页面对象文件中引用它们。更新 start/apps/chapter11/ng-cy-forms-e2e/src/support/app.po.ts 文件,如下所示:

    **export****const****getHeaderTitle** **= () => cy.****get****(****'.toolbar__title'****);**
    **export****const****getVersionInput** **= () => cy.****get****(****'[data-test-id="versionInput"]'****);**
    **export****const****getRequiredError** **= () => cy.****get****(****'[data-test-id="versionReqErr"]'****);**
    **export****const****getMismatchError** **= () => cy.****get****(****'[data-test-id="versionMismatchErr"]'****);**
    **export****const****getSubmitButton** **= () => cy.****get****(****'[data-test-id="submitVersionBtn"]'****);**
    **export****const****getLogsListItems** **= () => cy.****get****(****'[data-test-id="logsList"] .logs__item'****);**
    **export****const****getLatestVersion** **= () => cy.****get****(****'[data-test-id="latestVersion"]'****);** 
    
  2. 我们将首先验证我们的表单在没有有效版本的情况下不能提交。为此,让我们确保在输入被清除后或当输入无效时,提交按钮被禁用。在端到端项目的 src/e2e/app.cy.ts 文件中打开并添加一个测试,如下所示:

    import { getHeaderTitle, **getSubmitButton, getVersionInput** } from '../support/app.po';
    describe('ng-cy-forms', () => {
      beforeEach(() => cy.visit('/'));
      it('should display the correct header title', () => {...});
      **it****(****'should have the submit button disabled on invalid input'****,** **() =>** **{**
    **getVersionInput****().****type****(****'invalid input'****);**
    **getSubmitButton****().****should****(****'be.disabled'****);**
    **getVersionInput****().****clear****();**
    **getSubmitButton****().****should****(****'be.disabled'****);**
    **getVersionInput****().****type****(****'0.0.1'****);**
    **getSubmitButton****().****should****(****'be.enabled'****);**
    **});**
    }); 
    

    如果你查看 Cypress 窗口并展开测试,你应该会看到测试通过,如下所示:

    图 11.7:检查当有无效输入时提交按钮是否被禁用

  3. 让我们添加另一个测试,以验证在提交有效版本时,我们看到一个新的版本日志。在 app.cy.ts 文件中添加另一个测试,如下所示:

    ...
    import { getHeaderTitle, getLatestVersion, **getLogsListItems**, getSubmitButton, getVersionInput } from '../support/app.po';
    
    describe('ng-cy-forms', () => {
      ...
      **it****(****'should add a new version log on valid version submission'****,** **() =>** **{**
    **getLogsListItems****().****should****(****'have.length'****,** **1****);**
    **getVersionInput****().****type****(****'0.0.1'****);**
    **getSubmitButton****().****click****();**
    **getLogsListItems****().****should****(****'have.length'****,** **2****);**
    **getLogsListItems****().****eq****(****1****).****then****(****el** **=>** **{**
    **expect****(**
    **el.****text****().****trim****()**
    **).****to****.****eq****(****'version changed to 0.0.1'****)**
    **});**
    **});**
    }); 
    
  4. 我们现在将添加另一个测试,以确保我们可以在版本日志上方看到最新版本。让我们修改 app.cy.ts 文件,如下所示:

    ...
    **describe****(****'ng-cy-forms'****,** **() =>** **{**
    **...**
    **it****(****'should display the latest version'****,** **() =>** **{**
    **getLatestVersion****().****should****(****'have.text'****,** **'Latest Version = 0.0.0'****);**
    **getVersionInput****().****type****(****'0.0.1'****);**
    **getSubmitButton****().****click****();**
    **getLatestVersion****().****should****(****'have.text'****,** **'Latest Version = 0.0.1'****);**
    **});**
    **});** 
    
  5. 我们现在将添加一个测试来验证当版本输入在输入某些内容后清除时(即在提交值之前),用户是否看到错误'版本号是必需的'。在app.cy.ts文件中添加测试,如下所示:

    import { getHeaderTitle, getLatestVersion, getLogsListItems, **getRequiredError**, getSubmitButton, getVersionInput } from '../support/app.po';
    
    describe('ng-cy-forms', () => {
      ...
      **it****(****'should show the version required error when the input ** **gets clered after typing something'****, () => {**
    **getVersionInput****().****type****(****'0.0.1'****);**
    **getVersionInput****().****clear****();**
    **getRequiredError****().****should****(**'exist'**);**
    **getRequiredError****().****should****(**'be.visible'**);**
    **getRequiredError****().****then****(el => {**
    **expect****(**
    **el.****text****().****trim****()**
    **).to.****eq****(**'Version number is required'**)**
    **});**
    **});**
    }); 
    
  6. 最后,让我们编写一个测试来确保在无效输入上显示错误消息。在app.cy.ts文件中添加另一个测试,如下所示:

    ...
    import { getHeaderTitle, getLatestVersion, getLogsListItems, **getMismatchError**, getRequiredError, getSubmitButton, getVersionInput } from '../support/app.po';
    describe('ng-cy-forms', () => {
      ...
      **it****(****'should show the invalid input error when the ** **version input is invalid'****,** **() =>** **{**
    **getVersionInput****().****type****(****'abc123'****);**
    **getMismatchError****().****should****(****'exist'****);**
    **getMismatchError****().****should****(****'be.visible'****);**
    **getMismatchError****().****then****(****el** **=>** **{**
    **expect****(**
    **el.****text****().****trim****()**
    **).****to****.****eq****(****'Version number does not match the pattern (x.x.x)'****)**
    **});**
    **});**
    }); 
    

    如果你现在查看测试窗口,你应该看到所有测试都通过,如下所示:

    图 11.8:应用的所有测试都通过

太棒了!你现在知道如何使用 Cypress 来测试具有一些有趣用例和断言的表单。查看下一节以了解它是如何工作的。

它是如何工作的…

我们首先在我们的app.po.ts文件中实现一些页面对象,因为我们可以在获取元素时重用这些方法。由于我们应用逻辑有一个规则,即提交按钮应该在版本输入中有有效版本之前被禁用,所以我们使用'be.disabled'断言在提交按钮上,如下所示:

getSubmitButton().should('be.disabled'); 

我们随后使用getVersionInput().type('...')函数在版本输入中输入所需的值,并检查按钮是否在版本输入有无效值或完全未输入值时被禁用。

然后我们检查在提交有效版本时是否在日志列表中添加了新的日志。此测试的重要代码块如下:

getLogsListItems().eq(1).then(el => {
  expect(
     el.text().trim()
  ).to.eq('version changed to 0.0.1')
}); 

注意,我们获取日志列表,即日志项。然后我们使用eq(1)从列表中获取第二个元素。然后我们使用then方法获取jQuery<HTMLElement>,这样我们就可以在元素的文本内容上使用trim方法。这是因为当为 Angular 应用程序编写 HTML 模板时,我们可能会在 HTML 标签中格式化内容,导致文本内容中包含空格。因此,在将文本与预期值进行比较之前修剪文本是一个巧妙的主意。或者,您也可以使用.should('contain.text', 'EXPECTED_TEXT')断言而不是.should('have.text', 'EXPECTED_TEXT')断言。

对于我们想要检查是否显示适当错误的情况,我们确保以下内容:

  • 错误元素存在于 DOM 中

  • 错误元素对用户可见

  • 错误元素具有适当的错误信息

注意,我们使用then方法获取错误元素,在测试断言之前修剪文本内容,就像我们对日志项的文本内容验证所做的那样。

参见

等待 XHR 完成

测试 用户界面UI) 转换是端到端测试的核心。虽然立即测试动作的预期结果是重要的,但可能存在结果有依赖性的情况。例如,如果用户填写了 登录 表单,我们只有在收到后端服务器的成功响应后才能显示成功提示,因此我们无法测试成功提示是否立即显示。在这个菜谱中,你将学习如何在执行断言之前等待特定的 XHR 调用完成。

准备工作

我们将要工作的应用程序位于克隆的仓库中的 start/apps/chapter11/ng-cy-http-requests 目录下。然而,端到端测试位于 start/apps/chapter11/ng-cy-http-requests-e2e 文件夹中。在这个菜谱中,我们将仅修改端到端项目的文件。让我们按照以下步骤运行端到端测试:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目的端到端测试:

    npm run e2e ng-cy-http-requests 
    

    这应该打开 Cypress 窗口。选择 Chrome 进行测试,并点击 app.cy.ts 文件以运行测试,你应该看到以下内容:

    图 11.9:ng-cy-http-requests 应用程序的 Cypress 测试运行

现在我们已经运行了 Cypress 测试,让我们在下一节中查看菜谱的步骤。

如何做到这一点...

我们将从一些可以正常工作的测试开始。然而,如果 HTTP 调用的响应有延迟,它们将失败。这是因为 Cypress 有一个 4,000 毫秒ms) (4 秒)的超时时间,在这段时间内,它会不断尝试断言,直到断言通过。如果我们的 XHR 耗时超过 4,000 毫秒怎么办?让我们在菜谱中尝试一下:

  1. 我们将首先编写我们的测试。我们将确保从 HTTP 调用的响应中获取 10 个用户。但在那之前,我们将在 users.po.ts 文件中为这个菜谱创建所需的页面对象,如下所示:

    export const getUsersCards = () => {
      return cy.get('app-users ul li');
    }
    
    export const getSearchInput = () => {
      return cy.get('[data-test-id="searchUsersInput"]');
    } 
    
  2. 更新 users.cy.ts 文件以添加以下测试:

    import { getUsersCards } from '../support/users.po';
    describe('ng-cy-http-requests > users', () => {
      beforeEach(() => cy.visit('/users'));
      it('should get the users list from the server and display', () => {
        getUsersCards().should('have.length', 10);
      });
    }); 
    
  3. 我们将编写另一个测试来检查我们是否根据搜索输入的值获取了搜索到的用户。在 users.cy.ts 文件中添加另一个测试,如下所示:

    import { **getSearchInput**, getUsersCards } from '../support/users.po';
    
    describe('ng-cy-http-requests > users', () => { 
      ...
      **it****(****'****should get the users list on searching'****,** **() =>** **{**
    **getSearchInput****().****type****(****'rube'****);**
    **getUsersCards****().****should****(****'have.length'****,** **1****);**
    **getUsersCards****().****find****(****'h4'****).****should****(**
    **el** **=>** **{**
    **expect****(**
    **el.****text****().****trim****()**
    **).****to****.****eq****(**
    **'Ruben Wheeler'**
    **)**
    **}**
    **);**
    **});**
    }); 
    

    你应该看到两个测试都通过,如图 11.10 所示。然而,这不是编写 UI 测试的最佳方式,因为它们应该与来自实际 API 服务器的数据 独立。在实践中,我们通常模拟 API 调用,你将在本章后面的 使用 Cypress 固定值提供模拟数据 菜谱中了解到这一点。

    图 11.10:用户页面测试通过

  4. 首先,我们需要模拟一个场景,即在 4,000 毫秒后出现期望的结果。我们将使用rxjs中的delay操作符来实现这一点,延迟时间为 5,000 毫秒。让我们在项目的user.service.ts文件中应用它,如下所示:

    …
    **import** **{** **EMPTY****,** **Observable****, delay, map, mergeMap,** **of** **}** **from****'rxjs'****;**
    @Injectable({
      providedIn: 'root'
    })
    export class UserService {
      http = inject(HttpClient);
      getAll(): Observable<User[]> {
        return **of****(****EMPTY****)**
    **.****pipe****(**
    **delay****(****5000****),**
    **mergeMap****(****() =>** **{**
    **return****this****.****http****.****get****<****User****[]>(**
    **'/assets/users.json'****)**
    **})**
    **);**
      }
      ...} 
    

    如果你现在检查 Cypress 测试,你应该会看到一个失败的测试,如图 11.11 所示:

    图 11.11:特定用户搜索测试的断言失败

  5. 现在,我们可以尝试修复这个问题,这样它就不会在乎 XHR 花费了多长时间——我们总是在进行断言之前等待它完成。让我们拦截 XHR 调用并为其创建一个别名,这样我们就可以稍后使用它来等待 XHR 调用。更新users.cy.ts文件,如下所示:

    ...
    describe('ng-cy-http-requests > users', () => {
      **beforeEach****(****() =>** **{**
    **cy.****intercept****(****'/assets/users.json'****).****as****(****'searchUsers'****);**
    **cy.****visit****(****'/users'****);**
    **});**
      ...
    }); 
    
  6. 现在,让我们使用别名在断言之前等待 XHR 调用完成。更新users.cy.ts文件,如下所示:

    ...
    describe('ng-cy-http-requests > users', () => {
      ...
      it('should get the users list from the server and display', () => {
        **cy.****wait****(****'@searchUsers'****, {**
    **timeout****:** **10000**
    **});**
    getUsersCards().should('have.length', 10);
      });
      it('should get the users list on searching', () => {
        getSearchInput().type('rube');
        **cy.****wait****(****'@searchUsers'****, {**
    **timeout****:** **10000**
    **});**
    getUsersCards().should('have.length', 1);
        getUsersCards().find('h4').should(...);
      });
    }); 
    

    如果你现在检查users.cy.ts的 Cypress 测试,你应该会看到所有测试都通过,如下所示:

    图 11.12:在断言之前等待 XHR 调用完成的测试

太好了!你现在知道如何使用 Cypress 实现包含在断言之前等待特定 XHR 调用完成的端到端测试。要了解配方背后的所有魔法,请参阅下一节。

它是如何工作的...

在配方中,我们使用一种称为变量别名的东西。我们首先使用cy.intercept方法,以便 Cypress 可以监听网络调用。请注意,我们使用特定的 URL /assets/users.json 作为参数,然后使用.as('searchUsers')语句为此拦截提供一个别名。请注意,我们修改了user.service.ts,这导致在 API 调用之前有 5,000 毫秒的延迟。

Cypress 有一个默认的超时时间为 4,000 毫秒,我们不想限制我们的 API 调用在测试中在 4,000 毫秒内处理。因此,我们使用cy.wait('@searchUsers');语句,使用searchUsers别名通知 Cypress 它必须等待别名拦截发生——也就是说,直到网络调用完成,不管它需要多长时间才能达到 Cypress 的第二超时(网络调用为 30,000 毫秒)。这使得我们的当前测试通过,尽管默认的 4,000 毫秒 Cypress 超时和 Cypress 中 HTTP 调用的 5,000 毫秒(大约 5 秒)超时在实际上进行网络调用之前已经过去了。魔法,不是吗?

注意,Cypress 对断言有默认的超时时间,例如检查元素是否可见或具有特定的文本,默认为 4,000 毫秒。对于 HTTP 调用初始化,默认超时时间为 5,000 毫秒。这使得我们的测试有点棘手,因为我们试图模拟服务器响应延迟的同时延迟 HTTP 调用的初始化。因此,我们不得不为cy.waitoptions参数设置超时时间为 10,000 毫秒。这允许 Cypress 在 5,000 毫秒(我们添加到用户服务中)等待调用被初始化。在实际场景中,你的 HTTP 调用将立即启动,响应可能会延迟。Cypress 等待 5,000 毫秒以等待调用被初始化,所以你应该没问题。一旦调用被启动,Cypress 默认将超时时间设置为 30,000 毫秒以等待响应。

好吧,我希望你喜欢这个食谱——查看下一节以获取进一步阅读的链接。

相关内容

使用 Cypress 捆绑包

Cypress 提供了一系列捆绑的工具和包,我们可以在测试中使用它们来简化工作,这并不是因为使用 Cypress 编写测试本身很困难,而是因为这些库已经被许多开发者使用,因此他们已经熟悉它们。在本食谱中,我们将查看捆绑的 jQuery、Lodash 和 Minimatch 库,以测试一些我们的用例。

准备工作

我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter11/ng-cy-bun-pack目录下。然而,端到端测试在start/apps/chapter11/ng-cy-bun-pack-e2e文件夹中。在本食谱中,我们只将修改端到端项目的文件。让我们按照以下步骤运行端到端测试:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目的端到端测试:

    npm run e2e ng-cy-bun-pack 
    

    这应该会打开 Cypress 窗口。选择Chrome进行测试,并点击users.cy.ts文件以运行测试,你应该会看到以下内容:

    图 11.13:使用 Cypress 运行的 ng-cy-bun-pack 应用程序测试

现在我们已经运行了 Cypress 测试,让我们在下一节中查看食谱的步骤。

如何做到这一点...

对于这个食谱,我们有users列表和一个搜索应用程序,该应用程序使用 HTTP 请求从一个 JSON 文件中获取一些用户。我们将对 DOM 进行一些断言,验证 API 的响应,并断言 URL 的变化。让我们开始:

  1. 首先,我们将尝试使用捆绑的jQuery库和 Cypress 一起。我们可以使用Cypress.$来访问它。让我们添加另一个测试并记录一些 DOM 元素。更新users.cy.ts文件,如下所示:

    ...
    describe('Users List Page', () => {
      ...
      **it****(****'should show the no results found message on search'****,** **() =>** **{**
    **const** **{ $ } =** **Cypress****;**
    **cy.****wait****(****'@searchUsers'****);**
    **const** **searchInput = $(****'[data-test- id="searchUsersInput"]'****);**
    **console****.****log****(searchInput);**
    **})**
    }); 
    

    如果你现在查看测试(Cypress 窗口),特别是控制台,你应该会看到以下日志:

    图 11.14:使用 jQuery 通过 Cypress 记录的搜索输入

  2. 现在,让我们尝试更改搜索输入的值,以便我们可以看到 'No results' 消息。进一步更新测试,如下所示:

    it('should show the no results found message on search', () => {
        const { $ } = Cypress;
        cy.wait('@searchUsers');
        const searchInput = $('[data-test-id="searchUsersInput"]');
        **cy.****wrap****(searchInput).****type****(****'abc123'****);**
    }); 
    
  3. 让我们在 users.po.ts 文件中添加一个新的页面对象元素,以便我们可以获取 noResults 消息。按照以下方式更新文件:

    ...
    export const getNoResultsMessage = () => {
      return cy.get('[data-test-id="noResultsFoundMessage"]');
    } 
    
  4. 让我们使用页面对象和 then 方法通过记录 'no results' 消息来使用 jQuery 元素。按照以下方式更新 users.cy.ts 文件中的测试:

    it('should show the no results found message on search', () => {
        const { $ } = Cypress;
        cy.wait('@searchUsers');
        const searchInput = $('[data-test-id="searchUsersInput"]');
        cy.wrap(searchInput).type('abc123');
        getNoResultsMessage().then((el) => {
          console.log(el);
        });
      }); 
    

    你应该在 Cypress 窗口的控制台中看到 no results 消息,如下所示:

    图 11.15:使用 Cypress.$ 通过 jQuery 记录的 noResults 消息

    正如你所见,jQuery 元素已在控制台中记录。现在我们将使用 Chai 断言来验证它是否存在并且有一个消息。

  5. 进一步更新测试以检查元素是否存在并且具有以下文本:

    it('should show the no results found message on search', () => {
        const { $ } = Cypress;
        cy.wait('@searchUsers');
        const searchInput = $('[data-test-
     id="searchUsersInput"]');
        cy.wrap(searchInput).type('abc123');
        getNoResultsMessage().then((el) => {
          **expect****(el).****to****.****exist****;**
    **expect****(el.****text****().****trim****()).****to****.****eq****(****'Nothing returned for ** **the following search'****);**
        });
      }); 
    

    如果你现在在 Cypress 中看到这个测试,它应该会通过,如下所示:

    图 11.16:使用 jQuery 通过 Cypress 通过的测试

  6. 我们现在将使用与 Cypress 一起捆绑的 lodash.js 包来遍历每张卡片并确保出生日期格式正确。在 users.cy.ts 文件中编写另一个测试,如下所示:

    ...
    describe('Users List Page', () => {
      ...
      **it****(****'should show the dob on each user in the correct format'****,** **() =>** **{**
    **const** **{ $, _ } =** **Cypress****;**
    **cy.****wait****(****'@searchUsers'****);**
    **getUsersCards****().****then****(****(****cards****) =>** **{**
    **_.****forEach****(cards,** **(****card****) =>** **{**
    **const** **cardItem = $(card);**
    **const** **dobText = cardItem.****find****(****'.dob'****).****text****();**
    **const** **dob = dobText.****split****(****'Birthday:'****)[****1****].****trim****();**
    **expect****(dobRegex.****test****(**
    **dob**
    **)).****to****.****be****.****true****;**
    **})**
    **});**
    **});**
    }); 
    
  7. 让我们再添加一个测试来再次使用 lodash。我们将确保用户在视图中的所有名称都是唯一的,也就是说,没有重复的用户卡片。在 users.cy.ts 文件中添加另一个测试,如下所示:

    ...
    describe('Users List Page', () => {
      ...
      **it****(****'should have unique names for all the users'****,** **() =>** **{**
    **const** **{ $, _ } =** **Cypress****;**
    **cy.****wait****(****'@searchUsers'****);**
    **getUsersCards****().****then****(****(****cards****) =>** **{**
    **const** **names = _.****map****(cards,** **(****card****) =>** **{**
    **const** **cardItem = $(card);**
    **return** **cardItem.****find****(****'h4'****).****text****();**
    **});**
    **const** **uniqueNames = _.****uniq****(names);**
    **expect****(names.****length****).****to****.****equal****(uniqueNames.****length****);**
    **});**
    **});**
    }); 
    
  8. 我们接下来要探索的下一个包是 minimatch 包。当我们点击用户卡片时,它会打开用户详情。由于我们将时间戳附加到 URL 作为查询参数,我们无法使用断言将 URL 作为精确匹配进行比较。让我们使用 minimatch 包来使用模式进行断言。添加一个新的测试,如下所示:

    ...
    describe('Users List Page', () => {
      ...
      **it****(****'should go to the user details page with the user uuid'****,** **() =>** **{**
    **const** **{ minimatch, $ } =** **Cypress****;**
    **getUsersCards****().****then****(****(****cards****) =>** **{**
    **const** **userCard = cards[****0****];**
    **const** **uuid = $(userCard).****attr****(****'ng-reflect-router-link'****)**
    **.****split****(****'/users/'****)[****1****];**
    **cy.****wrap****(userCard).****click****();**
    **cy.****url****().****should****(****(****url****) =>** **{**
    **const** **urlMatches =** **minimatch****(url,**
    **`****${location.origin}****/users/****${uuid}*****`****,**
    **{** **debug****:** **true** **});**
    **expect****(urlMatches).****to****.****equal****(****true****);**
    **});**
    **});**
    **});**
    }); 
    

现在所有测试都已通过使用 Cypress 捆绑的包完成。现在我们已经完成了配方,让我们看看下一节中它是如何工作的。

工作原理...

Cypress 将 jQuery 捆绑在一起,我们通过 Cypress.$ 属性使用它。这允许我们执行 jQuery 函数允许我们执行的所有操作。例如,你可以使用以下捆绑的 jQuery 函数:

  • each:

    • 用法:$(elements).each(function(index, element) {})

    • 描述:遍历 jQuery 对象,为每个匹配元素执行一个函数

  • text:

    • 用法:$(selector).text()

    • 描述:获取匹配元素集中每个元素的合并文本内容,包括其子元素

  • val:

    • 用法:$(selector).val()

    • 描述:获取匹配元素集中第一个元素的当前值

  • hasClass:

    • 用法:$(selector).hasClass(className)

    • 描述:确定是否有任何匹配元素被分配给给定的类

  • addClass:

    • 用法:$(selector).addClass(className)

    • 描述:将指定的类(或类集)添加到匹配元素集中的每个元素

  • removeClass:

    • 用法:$(selector).removeClass(className)

    • 描述:从匹配元素集中的每个元素中删除单个类、多个类或所有类

重要提示

Cypress.$只能从 DOM 中立即可用的文档元素中获取数据。这对于在 Cypress 测试窗口中使用 Chrome DevTools 调试 DOM 来说很棒。然而,重要的是要理解它没有关于 Angular 变更检测的任何上下文。此外,你不能查询页面一开始就不可见的任何元素,正如我们在菜谱中所经历的那样——也就是说,它不会等待 XHR 调用以使元素可见。

Cypress 还捆绑了lodash并通过Cypress._对象公开它。在菜谱中,我们使用_.each()方法遍历卡片项以执行多个任务。我们还使用了_.uniq方法,它接受一个数组并返回一个包含唯一项的数组。然后我们比较原始数组和唯一数组的长度,以确保我们的原始数组包含所有唯一的名称。请注意,我们可以在 Cypress 测试中使用任何lodash方法,而不仅仅是提到的那些方法。

我们还使用了minimatch包,Cypress 通过Cypress.minimatch对象公开了这个包。minimatch包非常适合匹配和测试字符串与全局模式。我们用它来测试在通过模式导航到用户的详细页面后,测试 URL。在使用minimatch时,有一个重要的事情要知道,它比较的是全局模式,应该包含整个 URL,而不是像正则表达式一样的字符串。这就是为什么我们使用 /users/${uuid}* ``语句来包含location.origin`。

简单易行。现在你了解了这个菜谱的工作原理,请查看下一节以获取一些有用的链接。

参见

使用 Cypress 固定数据提供模拟数据

当涉及到编写端到端测试时,固定数据在确保测试不会出现不一致(在不同测试运行中结果不同)方面发挥着重要作用。考虑一下,你的测试依赖于从你的 API 服务器获取数据,或者你的测试包括快照测试,这包括从内容分发网络CDN)或第三方 API 获取图像。尽管它们在技术上对于测试成功运行是必需的,但服务器数据和图像是否从原始来源获取并不重要;因此,我们可以为它们创建固定数据。在这个菜谱中,我们将为存储桶数据创建固定数据,以避免在执行端到端测试时需要运行服务器。

准备工作

我们将要与之合作的应用程序位于克隆的仓库中的 start/apps/chapter11/ng-cy-mock-data。然而,端到端测试在 start/apps/chapter11/ng-cy-mock-data-e2e 文件夹中。在这个菜谱中,我们将仅修改端到端项目的文件。让我们按照以下步骤运行端到端测试:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行具有 API 服务器的项目的端到端测试:

    npm run e2e ng-cy-mock-data with-server 
    

    这应该会打开 Cypress 窗口以及服务器。选择 Chrome 进行测试,并点击 app.cy.ts 文件以运行测试,你应该会看到以下内容:

    图 11.17:使用 Cypress 运行的 ng-cy-mock-data 测试

现在我们有了 Cypress 测试在运行,让我们看看下一节中菜谱的步骤。

如何做到这一点...

我们有桶应用程序,我们在本书的许多菜谱中都使用了它。然而,我们将在这个菜谱中使用 Cypress 编写一些端到端测试。有趣的部分是应用程序与后端服务器通信以管理桶项目。当使用真实 API 向桶中添加或删除项目时,我们的测试将会失败。让我们开始吧:

  1. 我们的后端(fake-be)默认返回四个桶项目。参见如何工作...部分了解详情。我们将在稍后向 app.cy.ts 文件添加一个新测试,以确保我们能够向桶中添加另一个项目。但在那之前,让我们在 app.po.ts 文件中添加一些页面对象,如下所示:

    export const getHeaderTitle = () =>
     cy.get('.toolbar__title');
    export const getFruits = () => cy.get('.fruits__item');
    **export****const****getFruitSelector** **= () => cy.****get****(****'data-test-id="fruitSelector"'****);**
    **export****const****getAddItemSubmitButton** **= () =>**
    **cy.****get****(****'[data-test-id="addItemSubmitBtn"'****);**
    **export****const****getSuccessToast** **= () => cy.****get****(****'#toast-container .toast-success'****);**
    **export****const****getErrorToast** **= () => cy.****get****(****'#toast-****container .toast-error'****);** 
    
  2. 现在,我们可以添加我们的测试以确保我们能够向桶中添加一个项目。将以下测试添加到 app.cy.ts 文件中:

    import { **getAddItemSubmitButton**, **getFruitSelector**, getFruits, getHeaderTitle, **getSuccessToast** } from '../support/app.po';
    
    describe('ng-cy-mock-data', () => {
      beforeEach(() => {
        cy.visit('/')
      });
    
      …
    
      **it****(****'should add a bucket item to the list'****,** **() =>** **{**
    **getFruitSelector****().****select****(****'Apple** **![****'****);**    **getAddItemSubmitButton****().****click****();**    **getSuccessToast****().****should****(****'be.visible'****);**    **getSuccessToast****().****then****(****el => {**    **expect****(el.****text****().****trim****()).to.****eq****(****'Bucket item added'****);**    **});**    **getFruits****().****should****(****'have.length'****,** **5****);**    **});**     });     ```    这是我们测试开始出错的地方,如*图 11.18*所示。由于我们的测试每次运行都会向实际服务器添加一个项目,所以我们不能期望服务器返回与我们的测试相同数量的项目(四个项目),除非我们重新启动服务器。    ![](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/ng-cb-2e/img/B18469_11_18.png)
    
    图 11.18:由于向真实服务器添加数据而失败的测试
    
    
  3. 我们首先将为我们的 HTTP 调用到 fake-be 后端创建一个固定装置。在 src/fixtures 文件夹下创建一个新文件,命名为 get-bucket.json。然后向其中添加以下 JSON 数据:

    {
    "bucket": 
    { "id": 1, "name": "Apple ![" },    { "id": 2, "name": "Banana ![](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/ng-cb-2e/img/B18469_11_003.png)" },
    { "id": 3, "name": "Grapes ![](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/ng-cb-2e/img/B18469_11_004.png)" },
    { "id": 4, "name": "Cherry ![](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/ng-cb-2e/img/B18469_11_005.png)" }
    ]
    } 
    
  4. 现在,让我们在我们的 app.cy.ts 文件中使用固定装置。我们将在 beforeEach 生命周期钩子中使用它,因为我们希望为文件中的所有测试使用固定装置。更新 app.cy.ts 文件,如下所示:

    ...
    describe('ng-cy-mock-data', () => {
      **beforeEach****(****() =>** **{**
    **cy.****fixture****(****"get-bucket"****)**
    **.****then****(****(****response****) =>** **{**
    **cy.****intercept****(****'GET'****,** **'http://localhost:3333/api/bucket'****,**
    **response)**
    **return** **cy.****fixture****(****"-bucket"****);**
    **})**
    **.****visit****(****'/'****)**
    **})**;
         ...
    }); 
    

    这并没有解决这个问题,因为向桶中添加项目的调用仍然发送到真实 API。我们还需要为它创建一个固定装置。

  5. src/fixtures 文件夹内创建一个新文件。命名为 add-bucket-item.json 并向其中添加以下代码:

    {
    "fruit": { "id": 5, "name": "Apple ![](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/ng-cb-2e/img/B18469_11_001.png)" }
    } 
    
  6. 我们现在将在我们的测试文件中使用 add-bucket-item 固定装置。更新 app.cy.ts 文件,如下所示以使用固定装置:

    ...
    
    describe('ng-cy-mock-data', () => {
      beforeEach(() => {
        cy.fixture("get-bucket")
          .then((getBucketResp) => {
            cy.intercept('GET', 'http://localhost:3333/api/bucket',
              getBucketResp)
            **return** **cy.****fixture****(****"add-bucket-item"****);**
          })
          **.****then****(****(****addItemResp****) =>** **{**
    **cy.****intercept****(****'POST'****,**
    **'http://localhost:3333/api/bucket'****, addItemResp)**
    **})**
    .visit('/')
      });
      ...
    }); 
    

    现在,如果你运行端到端测试,你应该会看到所有测试都通过了。无论你刷新 Cypress 窗口多少次;它们总是会通过,因为每次的响应都是相同的:

    图片

    图 11.19:使用固定值测试 add-bucket-item 通过

  7. 我们现在将创建一个测试来删除一个项目并确保我们看到通知,并且视图中的一个项目被移除。让我们在 app.cy.ts 文件中添加另一个测试,如下所示:

    ...
    describe('ng-cy-mock-data', () => {
      ...
      **it****(****'should delete a bucket item from the list'****,** **() =>** **{**
    **getFruits****().****should****(****'have.length'****,** **4****);**
    **getFruits****().****eq****(****0****).****find****(****'.fruits__item__delete-icon'****).****click****();**
    **getSuccessToast****().****should****(****'be.visible'****);**
    **getSuccessToast****().****then****(****el** **=>** **{**
    **expect****(el.****text****().****trim****()).****to****.****eq****(****'Bucket item deleted'****);**
    **});**
    **getFruits****().****should****(****'have.length'****,** **3****);**
    **});**
    }); 
    

    如果你现在运行测试,你会看到 delete item 测试失败,因为它找不到项目。那是因为这个 DELETE 调用仍然被发送到实际服务器。如果你有服务器运行,你会看到一个错误,如图 11.20 所示:

    图片

    图 11.20:找不到项目错误

  8. 我们将在 src/fixtures 文件夹中创建一个新的固定值,命名为 delete-bucket-item.json。向其中添加以下代码:

    { "success": true } 
    
  9. 现在,让我们在 app.cy.ts 文件中的 beforeEach() 钩子中使用固定值,如下所示:

    ...
    describe('ng-cy-mock-data', () => {
      beforeEach(() => {
        cy.fixture("get-bucket")
          .then((response) => {
            cy.intercept('GET', 'http://localhost:3333/api/bucket',
              response)
            return cy.fixture("add-bucket-item");
          })
          .then((response) => {
            cy.intercept('POST', 'http://localhost:3333/api/bucket',
              response)
            **return** **cy.****fixture****(****"delete-bucket-item"****);**
          })
          **.****then****(****(****deleteItemResp****) =>** **{** 
    **cy.****intercept****(****'DELETE'****,**
    **'****http://localhost:3333/api/bucket/*'****,**
    **deleteItemResp)** 
    **})**
          .visit('/')
      });
      ...
    }); 
    

    如果你现在查看测试,所有测试都应该通过,如下所示:

    图片

    图 11.21:使用固定值后所有测试通过

太好了!你现在知道如何在 Cypress E2E 测试中使用固定值。现在你已经完成了这个食谱,请看下一节了解它是如何工作的。

它是如何工作的…

我们在 app.cy.ts 文件中有一个初始测试,以确保当应用程序加载时,我们从服务器获取四个桶项目。你可以看到发送默认桶数据的后端文件,它位于 <workspace-root>/codewithahsan/e2e/fake-be/src/app/bucket/bucket.service.ts。然而,当我们开始通过测试向桶中添加项目时,我们的测试就会中断,因为我们正在处理真实数据。我们很少这样做,以确保我们可以准确地做出断言。这就是为什么大型团队有测试环境,如果他们真的想处理真实数据,就会在数据库中播种数据。由于我们的桶应用程序非常小,我们实际上不需要处理真实数据,所以我们在这个食谱中添加了固定值。在 Cypress 测试中,固定值通过 cy.fixture 方法注册,这允许我们使用文件中的数据。在这个食谱中,我们使用固定值来处理应用程序对 fake-be 服务器进行的所有 HTTP 调用,即以下内容:

  • 获取所有桶数据 – GET http://localhost:3333/api/bucket

  • 向桶中添加项目 – POST http://localhost:3333/api/bucket

  • 从桶中删除项目 – DELETE http://localhost:3333/api/bucket/ITEM_ID

注意,对于每个 HTTP 请求,我们使用 cy.fixture('FIXTURE_NAME') 而不带 .json 扩展名,这实际上指向 cypress/fixture/FIXTURE_NAME.json 文件。

首先,我们使用 cy.fixture 方法注册固定值(或获取它)。然后我们使用 then 方法获取固定值(JSON)文件的正文。然后我们使用 cy.intercept 方法使用 GET/POST/DELETE 方法以及 URL 模式作为 Minimatch glob 模式来拦截 HTTP 调用以获取固定值响应,并将其作为 HTTP 调用的响应提供。因此,所有匹配 glob 模式的拦截调用都使用我们的固定值。

现在你已经了解了这个食谱的工作原理,请查看下一节以获取一些资源。

参见

在 Discord 上了解更多

要加入这本书的 Discord 社区——在那里你可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:

packt.link/AngularCookbook2e

二维码

第十二章:Angular 中的性能优化

性能在您为最终用户构建的任何产品中始终是一个关注点。它是增加某人首次使用您的应用成为客户机会的关键元素。现在,我们真的无法提高应用性能,除非我们确定潜在的改进可能性以及实现这些方法。在本章中,您将学习一些在提高 Angular 应用程序时可以部署的方法。您将学习如何使用几种技术来分析、优化和改进您的 Angular 应用性能。以下是本章将要涵盖的食谱:

  • 使用 OnPush 变更检测修剪组件子树

  • 从组件中分离变更检测器

  • 使用 runOutsideAngular 在 Angular 外部运行 async 事件

  • 使用 trackBy*ngFor 列表

  • 将繁重计算移动到纯管道

  • 使用 Web Workers 进行繁重计算

  • 使用性能预算进行审计

  • 使用 webpack-bundle-analyzer 分析包

技术要求

对于本章的食谱,请确保您的设置已按照 'Angular-Cookbook-2E' GitHub 仓库中的 '技术要求' 完成。有关设置详细信息,请访问:github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/docs/technical-requirements.md。本章的起始代码位于 github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/start/apps/chapter12

使用 OnPush 变更检测修剪组件子树

在当今现代网络应用的世界中,性能是优秀 用户体验UX) 和最终企业转化率的关键因素之一。在本章的第一个食谱中,我们将讨论您可以在组件的任何适当位置进行的根本性或最基础的优化,即通过使用 OnPush 变更检测策略。我们正在工作的应用有一些性能问题,特别是 UserCardComponent 类。这是因为它使用一个获取器函数 randomColor 来为其背景生成随机颜色。在幕后,该函数使用 factorial 函数来增加更多处理时间。但这只是为了演示一个组件,如果发生一些复杂计算,并且触发了多个变更检测,它可能会导致 UI 挂起。

准备工作

我们将要工作的应用位于克隆的仓库中的 start/apps/chapter12/ng-on-push-strategy

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-on-push-strategy 
    

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

    图片

    图 12.1:运行在 http://localhost:4200 的 ng-on-push-strategy 应用程序

点击标有点击我的按钮或尝试搜索某个用户。你会看到应用程序运行得太慢,并且经常挂起。现在我们已经将项目在浏览器上运行,让我们看看下一节中的食谱步骤。

如何做到这一点...

我们将添加一些代码来监控randomColor获取器被调用的次数。这将显示 Angular 默认情况下触发的更改检测的次数。我们还将修复这个问题,并使用OnPush更改检测策略使其更高效(尽可能多)。让我们开始吧:

  1. 首先,让我们确保应用程序不会因为你的机器而运行得太慢,以至于让你的笔记本电脑/PC 挂起。打开src/app/app.config.ts文件,并将RANDOMIZATION_COUNT令牌的值从9调整为最适合你的值。

  2. 然后,尝试通过在搜索框中输入他们的名字来搜索名为Irineu的用户。你会注意到应用程序仍然挂起,并且需要几秒钟才能显示用户。你还会注意到,当你输入时,你甚至看不到搜索框中的输入字母。也就是说,渲染有延迟。

    让我们在代码中添加一些逻辑。我们将检查页面加载时 Angular 调用idUsingFactorial方法的次数。

  3. 让我们创建一个服务,我们将使用它来跟踪特定用户的特定卡片被调用的次数。从工作区根目录运行以下命令以创建服务:

    cd start && nx g s services/logs --project ng-on-push-strategy 
    

    当被询问时,选择@schematics/angular:service

  4. 按照以下方式更新src/app/services/logs.service.ts文件的内容:

    import { Injectable } from '@angular/core';
    
    @Injectable({
      providedIn: 'root'
    })
    export class LogsService {
      logs: Record<string, number> = {}
    
      updateLogEntry(email: string) {
        if (this.logs[email] === undefined) {
          this.logs = {
            ...this.logs,
            [email]: 1
          }
        } else {
          this.logs = {
            ...this.logs,
            [email]: this.logs[email] + 1
          }
        }
      }
    } 
    
  5. 现在,在src/app/component/user-card/user-card.component.ts文件中注入LogService。我们还将创建一个获取器(log)函数来获取用户的计数,并且每当调用randomColor获取器时,我们将更新计数。按照以下方式更新提到的文件:

    ...
    **import** **{** **LogsService** **}** **from****'../../services/logs.service'****;**
    @Component({...})
    export class UserCardComponent {
      ...
      randomizationCount = inject(RANDOMIZATION_COUNT);
      **logsService =** **inject****(****LogsService****);**
    **get****log****() {**
    **return****this****.****logsService****.****logs****[****this****.****user****.****email****] ??** **0****;**
    **}**
    get randomColor() {
        **this****.****logsService****.****updateLogEntry****(****this****.****user****.****email****);**
        ...
      }
    } 
    
  6. 现在,我们将使用用户卡片组件模板中的日志来显示计数。按照以下方式更新src/app/component/user-card/user-card.component.html文件:

    <div [style.backgroundColor]="randomColor"...>
    <img ...>
    <div class="card-body flex-1">...</div>
    **<****div****class****=****"p-4 bg-slate-900 text-green-300 rounded-md h-fit"****>**
    **<****div****>**
    **Color Generation Count:**
    **</****div****>**
    **<****pre****>****{{log}}****</****pre****>**
    **</****div****>**
    </div> 
    

    如果你现在查看应用程序,你应该会看到以下颜色生成计数

    图片

    图 12.2:页面加载时显示的颜色生成计数

  7. 现在,点击点击我按钮。然后,聚焦(点击)快速搜索输入框,然后点击外部。重复几次,你应该会看到即使卡片不应该重新渲染,颜色也会被重新生成。图 12.3显示了它应该看起来是什么样子:图片

    图 12.3:未搜索任何内容与应用程序交互后的日志

    注意,如果你开始搜索某些内容,你会得到更多的重新渲染。这是因为每个 keyup 和/或 keydown 事件都会触发更多的重新渲染。

  8. 为了解决这个问题,我们将使用OnPush策略,并观察它如何改变用户卡片组件相对于 Angular 的变更检测策略的行为。按照以下方式更新user-card.component.ts文件:

    import { **ChangeDetectionStrategy**, Component, Input, inject } from '@angular/core';
    ...
    @Component({
      ...,
      styleUrls: ['./user-card.component.scss'],
      **changeDetection****:** **ChangeDetectionStrategy****.****OnPush**
    })
    ... 
    

    现在,如果你尝试点击点击我按钮,在搜索输入框中聚焦并点击外部,或者做任何其他的事情(除了搜索用户),你将不会在卡片上的颜色生成计数中看到任何变化,如图图 12.4所示:

    图片

    图 12.4:OnPush 策略防止不必要的渲染

太好了!通过使用OnPush策略,我们能够提高UserCardComponent的整体性能。其余的只是为了好玩。现在你知道如何使用这个策略了,请看下一节了解它是如何工作的。

它是如何工作的...

默认情况下,Angular 使用Default变更检测策略——或者技术上讲,它是来自@angular/core包的ChangeDetectionStrategy.Default枚举。由于 Angular 不知道我们创建的每个组件,它使用默认策略以避免任何意外。这意味着当可能发生变化时,框架将检查整个组件树中的更改。这可能包括用户事件、定时器、XHRs、promises 等。在一个具有大量绑定的复杂 UI 中,这可能导致性能下降,尤其是在大多数这些组件不经常更改或仅依赖于特定输入的情况下。

但是,作为开发者,如果我们知道一个组件除非其@Input()变量之一发生变化,否则不会改变,我们就可以——并且应该——为该组件使用OnPush变更检测策略。为什么?因为这个策略告诉 Angular 只有在组件的@Input()变量发生变化时才运行变更检测。这种策略对于表示组件(有时称为“哑”组件)来说是一个绝对的赢家,这些组件只是应该使用@Input()变量/属性显示数据,并在交互时发出@Output()事件。这些表示组件通常不包含任何业务逻辑,如复杂的计算、使用服务进行HTTP调用等。因此,我们更容易在这些组件中使用OnPush策略,因为它们只有在父组件的@Input()属性之一发生变化时才会显示不同的数据,即它们的引用应该发生变化。例如,如果我们有一个用户数组,现在应该有一个全新的数组来运行变更检测。如果它是同一个数组,但我们只是添加或删除了一个项目,使用OnPush的变更检测将不会被触发。

由于我们现在在UserCardComponent上使用OnPush策略,它只有在搜索时替换整个users数组时才会触发变更检测。这发生在500ms的防抖之后(users.component.ts文件中的第 31 行),所以只有在用户停止输入时才执行。因此,在优化之前,默认的变更检测会在每个按键敲击(浏览器事件)时触发,而现在则不会。

重要提示

如你所知,OnPush策略仅在@Input()绑定中的一个或多个发生变化时触发 Angular 变更检测机制。这意味着如果我们更改组件(UserCardComponent)内的属性,它将不会在视图中反映出来,因为在这种情况下变更检测机制不会运行,因为这个属性不是@Input()绑定。你必须将组件标记为脏的,这样 Angular 才能检查组件并运行变更检测。你将使用ChangeDetectorRef服务来完成此操作——具体来说,使用markForCheck方法。

参见

从组件中移除变更检测器

在前面的食谱中,我们学习了如何在组件中使用OnPush策略来避免 Angular 变更检测在没有@Input()绑定发生变化的情况下运行。然而,还有一种方法可以告诉 Angular 不要为特定的组件及其子树运行变更检测。这将完全从变更检测周期中移除组件及其子树,如图 12.5 所示,这可能会导致整体性能的提升。当你想要完全控制何时运行变更检测时,这也很有用。在本食谱中,你将学习如何完全从 Angular 组件中移除变更检测器以获得性能提升。

图 12.5:变更检测器从组件树中移除

准备工作

我们将要工作的应用位于克隆的仓库中的start/apps/chapter12/ng-cd-ref

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-cd-ref 
    

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

    图 12.6:ng-cd-ref 应用在 http://localhost:4200 运行

点击写着点击我的按钮,或者尝试搜索某个用户。你会看到应用运行得太慢,经常卡住。现在我们已经将项目在浏览器上启动,让我们看看下一节中食谱的步骤。

如何做到这一点...

我们正在工作的应用程序有一些性能问题,特别是与 UserCardComponent 类有关。这是因为它使用一个获取器函数 randomColor 来为其背景生成随机颜色。在幕后,该函数使用 factorial 函数来增加处理时间。但这只是为了演示一个组件,如果同时发生一些复杂计算和多个变更检测被触发,它可能会导致 UI 挂起。我们将添加一些代码来监控 randomColor 获取器被调用的次数。这将显示 Angular 默认触发的变更检测次数。我们还将修复问题,并使其更高效(尽可能做到),通过完全从特定组件中分离变更检测器。让我们开始吧:

  1. 首先,让我们确保应用程序对于您的机器来说不会太慢,这样它就不会使您的笔记本电脑/PC 挂起。打开 src/app/app.config.ts 文件,并将 RANDOMIZATION_COUNT 令牌的值从 9 调整为您认为最合适的值。

  2. 然后,尝试通过在搜索框中输入他们的名字来搜索一个名为 Irineu 的用户。你会注意到应用程序仍然处于挂起状态,并且显示用户需要几秒钟。你还会注意到,当你输入字母时,甚至看不到搜索框中的字母。也就是说,渲染存在延迟。

    让我们在代码中添加一些逻辑。我们将检查当页面加载时,Angular 调用 idUsingFactorial 方法的次数。

  3. 让我们创建一个我们将用于跟踪特定用户的特定用户卡片被调用的次数的服务。从工作区根目录运行以下命令以创建服务:

    cd start && nx g s services/logs --project ng-cd-ref 
    

    当被询问时,请选择 @schematics/angular:service

  4. 按照以下方式更新 src/app/services/logs.service.ts 文件的内容:

    import { Injectable } from '@angular/core';
    @Injectable({
      providedIn: 'root'
    })
    export class LogsService {
      logs: Record<string, number> = {}
      updateLogEntry(email: string) {
        if (this.logs[email] === undefined) {
          this.logs = {
            ...this.logs,
            [email]: 1
          }
        } else {
          this.logs = {
            ...this.logs,
            [email]: this.logs[email] + 1
          }
        }
      }
    } 
    
  5. 现在,在 src/app/component/user-card/user-card.component.ts 文件中注入 LogService。我们还将创建一个获取器(log)函数来获取用户的计数,并且每次调用 randomColor 获取器时,我们将更新计数。按照以下方式更新提到的文件:

    ...
    **import** **{** **LogsService** **}** **from****'../../services/logs.service'****;**
    @Component({...})
    export class UserCardComponent {
      ...
      randomizationCount = inject(RANDOMIZATION_COUNT);
      **logsService =** **inject****(****LogsService****);**
    **get****log****() {**
    **return****this****.****logsService****.****logs****[****this****.****user****.****email****] ??** **0****;**
    **}**
    get randomColor() {
        **this****.****logsService****.****updateLogEntry****(****this****.****user****.****email****);**
        ...
      }
    } 
    
  6. 现在,我们将使用用户卡片组件的模板中的日志来显示计数。按照以下方式更新 src/app/component/user-card/user-card.component.html 文件:

    <div [style.backgroundColor]="randomColor"...>
    <img ...>
    <div class="card-body flex-1">...</div>
    **<****div****class****=****"p-4 bg-slate-900 text-green-300 rounded-md h-fit"****>**
    **<****div****>**
    **Color Generation Count:**
    **</****div****>**
    **<****pre****>****{{log}}****</****pre****>**
    **</****div****>**
    </div> 
    

    如果你现在查看应用程序,你应该会看到以下 颜色生成计数

    图片 B18469_12_07

    图 12.7:页面加载时显示的颜色生成计数

  7. 现在,点击 Click Me 按钮。然后(点击)将焦点放在 Quick Search 输入上,然后点击外部。重复几次,你应该会看到即使卡片不应该重新渲染,颜色也会被重新生成。图 12.8 显示了它应该看起来是什么样子!:图片 B18469_12_08

    图 12.8:与应用程序交互后(未搜索任何内容)的日志

    注意,如果你开始搜索某些内容,你会得到更多的重新渲染。那是因为每个 keyup 和/或 keydown 事件都会触发更多的重新渲染。

  8. 为了解决这个问题,当组件加载时,我们将从user-card组件中分离出更改检测器的引用,这样就不会在之后重新渲染。这是假设卡片的内容永远不会改变。按照以下方式更新user-card.component.ts文件:

    import { **AfterViewInit**, **ChangeDetectorRef**, Component, Input, inject } from '@angular/core';
          ...
    
    export class UserCardComponent**implements****AfterViewInit** {
      ...
      logsService = inject(LogsService);
      **cdRef =** **inject****(****ChangeDetectorRef****);**
    **ngAfterViewInit****():** **void** **{**
    **this****.****cdRef****.****detach****();**
    **}**
      ...
    } 
    

    现在,如果你尝试点击点击我按钮,关注搜索输入并点击外部,或者做任何其他(除了搜索用户)的事情,你将看不到卡片上的颜色生成计数发生变化,如图图 12.9所示:

    图片

    图 12.9:分离的更改检测器防止不必要的渲染

    但如果组件后来需要更改怎么办?我们如何完全控制更改检测?

  9. 假设应用程序有一个更改用户名字的功能。我们将硬编码逻辑来更改第一个用户的姓氏。在这种情况下,我们必须告诉 Angular 运行更改检测。让我们更新users.component.html文件,以更新src/app/users/users.component.html文件中的点击我按钮,如下所示:

    <div class="home">
    <section class="flex flex-col gap-4 w-full">
    <h2 class="text-center text-2xl">Users</h2>
    <form class="input-container flex gap-4 w-full items
          -center mb-4" [formGroup]="searchForm">
    <div class="relative flex-1">...</div>
    **<****button** **(****click****)=****"updateName(users[0])"****>****Update**
    **Irineu's Name****</****button****>**
    </form>
    <div class="secondary-container flex justify-center">...</div>
    </section>
    </div>
    <ng-template #loader>
    <app-loader></app-loader>
    </ng-template> 
    
  10. 让我们更新 TypeScript 文件,添加更新用户名字的功能。按照以下方式更新src/app/users/users.component.ts文件:

     ...
    export class UsersComponent implements OnInit {
      ...
    
      usersTrackBy(_index: number, user: IUser) {
        return user.uuid;
      }
    
      **updateName****(****user****:** **IUser****) {**
    **this****.****users** **=** **this****.****users****.****map****(****(****userItem****) =>** **{**
    **if** **(userItem.****uuid** **=== user.****uuid****) {**
    **return** **{**
    **...userItem,**
    **name****: {**
    **...userItem.****name****,**
    **last****:** **'Test 123'**
    **}**
    **}**
    **}**
    **return** **userItem;**
    **})**
    **}**
    } 
    

    如果你点击更新 Irineu 的名字按钮,你将看不到 UI 上的任何变化。那是因为更改检测器仍然与每个用户卡片组件分离。所以名字改变了,但你无法在 UI 上看到变化。参见图 12.10,其中 Angular (Chrome) DevTools 显示了组件中正在更新的值,但 UI 没有反映出来。

    图片

    图 12.10:由于更改检测器分离,用户卡片未重新渲染

  11. 为了解决这个问题,我们将查询用户页面上的UserCard组件,并将手动在所需组件上运行ChangeDetectorRef.detectChanges方法。按照以下方式更新src/app/users/users.component.ts文件:

    import { Component, inject, OnInit, **QueryList**, **ViewChildren**} from '@angular/core';
    ...
    export class UsersComponent implements OnInit {
      **@ViewChildren****(****UserCardComponent****) userCards!:**
    **QueryList****<****UserCardComponent****>;**
      users!: IUser[];
      ...
    
      updateName(user: IUser) {
        this.users = this.users.map((userItem) => {
          ...
        })
        **const** **matchingComponent =** **this****.****userCards****.****find****(****comp** **=>** **{**
    **return** **comp.****user****.****uuid** **=== user.****uuid****;**
    **})**
    **if** **(matchingComponent) {**
    **setTimeout****(****() =>** **{**
    **matchingComponent.****cdRef****.****detectChanges****();**
    **},** **0****);**
    **}**
      }
    } 
    

    现在,如果你点击更新 Irineu 的名字按钮四次,你应该会看到第一个用户卡的计数为5,而其余的卡片仍然渲染1次,如图图 12.11所示。

    图片

    图 12.11:完全控制的更改检测

太好了!通过几个步骤,我们使用 Angular 的ChangeDetectorRef服务提高了UserCardComponent的整体性能。我们不仅提高了性能,而且还根据我们的用例从父组件(UsersComponent)完全控制了它。现在你知道如何使用ChangeDetectorRef服务,请参见下一节了解它是如何工作的。

它是如何工作的…

ChangeDetectorRef 服务提供了一系列重要的方法来控制 Angular 中的变更检测。在配方中,我们使用组件的 ngAfterViewInit 方法中的 detach 方法来在组件首次渲染后立即将 Angular 变更检测机制从组件中断开。结果,UserCardComponent 类上不会触发任何变更检测。这是因为 Angular 有一个变更检测树,其中每个组件都是一个节点。当我们从变更检测树中断开一个组件时,该组件(作为一个树节点)被断开,其子组件(或节点)也是如此。通过这样做,我们最终确保 UserCardComponent 类上不会发生任何变更检测。如果 UserCardComponent 中使用了其他组件,它们也不会为它们运行变更检测。结果,当我们点击按钮或聚焦和失去焦点在用户页面的输入上时,即使我们像配方中那样更新了第一个用户的名称,也不会有任何渲染。

此外,当我们需要在视图中显示第一个用户名称的更改时,这需要触发 Angular 的变更检测机制,我们使用来自 ChangeDetectorRef 实例的 detectChanges 方法,在我们将更新后的用户数组分配给 UsersComponent 类中的 users 属性之后立即使用。结果,Angular 运行变更检测机制,我们在第一个用户卡片上看到更新的名称。这赋予我们完全决定是否完全断开变更检测、重新连接它或仅对需要变更检测的特定情况手动运行它的权力。

现在你已经了解了配方的工作原理,请参阅下一节以获取一些有用的链接。

参见

使用 runOutsideAngular 在 Angular 外部运行异步事件

Angular 在其几个方面运行变更检测机制,包括但不限于所有浏览器事件,如keyupkeydownclick等。它还在setTimeoutsetInterval和 Ajax HTTP 调用上运行变更检测。如果我们必须避免在这些事件上运行变更检测,我们必须告诉 Angular 不要在这些事件上触发变更检测——例如,如果您在 Angular 组件中使用setInterval方法,每次其回调方法被调用时,它将触发一个 Angular 变更检测周期。这可能导致大量的变更检测周期,甚至可能导致您的应用挂起。理想的情况是能够继续使用setInterval方法等,而不触发变更检测。在这个菜谱中,您将学习如何做到这一点。您将学习如何使用NgZone服务在zone.js之外执行代码块,特别是使用runOutsideAngular方法。参见图 12.12以了解应用程序的结构:

图 12.12:ng-run-outside-angular 应用的组件层次结构

准备中

我们将要工作的应用位于克隆的仓库中的start/apps/chapter12/ng-run-outside-angular

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-run-outside-angular 
    

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

    图 12.13:在 http://localhost:4200 上运行的 ng-run-outside-angular 应用

现在我们已经运行了应用,让我们在下一节中查看菜谱的步骤。

如何做到这一点...

我们有一个显示手表的应用。然而,目前应用中的变更检测并不优化,我们有很大的改进空间。我们将尝试使用ngZone中的runOutsideAngular方法移除任何不必要的变更检测。让我们开始吧:

  1. 时钟值正在不断更新。因此,我们为每个更新周期运行变更检测。打开 Chrome 开发者工具并切换到控制台标签。输入appLogs并按Enter键,以查看变更检测为WatchComponent以及渲染小时、分钟、秒和毫秒的组件运行了多少次。它应该看起来像这样:

    图 12.14:反映变更检测运行次数的 appLogs 对象

  2. 为了衡量性能,让我们在时间上减少我们的观察范围。让我们添加一些代码,在应用启动 4 秒后关闭时钟的间隔计时器。修改watch-box.component.ts文件,如下所示:

    ...
    @Component({...})
    export class WatchBoxComponent implements OnInit {
      ...
      ngOnInit(): void {
        this.intervalTimer = setInterval(() => {
          this.timer();
      }, 1);
        **setTimeout****(****() =>** **{**
    **clearInterval****(****this****.****intervalTimer****);**
    **},** **4000****);**
      }
      ...
    } 
    
  3. 刷新应用并等待 4 秒,直到时钟停止。然后,在控制台标签页中多次输入appLogs,按Enter键,查看结果。时钟停止了,但动画仍在运行。你应该会看到对watchComponentRender键的变更检测仍然增加,如下所示:

    图 12.15:监视组件的变更检测仍在运行

  4. 让我们在 4 秒后也在监视内部停止动画。更新watch.component.ts文件,如下所示:

    ...
    export class WatchComponent implements OnInit {
      ...
      ngOnInit(): void {
        this.intervalTimer = setInterval(...}, 30);
        **setTimeout****(****() =>** **{**
    **clearInterval****(****this****.****intervalTimer****);**
    **},** **4000****);**
      }
      ...
    } 
    

    刷新应用并等待动画停止。查看 Chrome DevTools 中的appLogs对象。你应该会看到变更检测对watch键停止,如下所示:

    图 12.16:停止动画间隔后,变更检测停止

  5. 我们希望的结果是继续运行动画和时钟,并且没有额外的变更检测周期运行。为此,现在我们只需停止监视即可。要做到这一点,更新watch-box.component.ts文件,如下所示:

    ...
    @Component({...})
    export class WatchBoxComponent implements OnInit {
      ...
      ngOnInit(): void {
        **// this.intervalTimer = setInterval(() => {**
    **//   this.timer();**
    **// }, 1);**
    **// setTimeout(() => {**
    **//   clearInterval(this.intervalTimer);**
    **// }, 4000);**
      }
    } 
    

    由于我们现在已经停止了时钟,appLogswatchComponentRender键的值现在仅基于这 4 秒的动画。刷新应用并等待动画停止。在 Chrome DevTools 中输入appLogs(在控制台标签页)。你现在应该会看到watchComponentRender键的值在250270之间。

  6. 让我们通过在ngZone服务外部运行间隔来避免在动画上运行变更检测。我们将为此使用runOutsideAngular方法。更新watch.component.ts文件,如下所示:

    import {
      ...
      ViewChild,
      **inject****,**
    **NgZone****,**
    } from '@angular/core';
    @Component({...})
    export class WatchComponent implements OnInit {zone = inject(NgZone);
      ...
      ngOnInit(): void {
      if (!window['appLogs']) {
        window['appLogs'] = {};
      }
      window['appLogs'][ watchComponentRender] = 0;
        **this****.****zone****.****runOutsideAngular****(****() =>** **{**
       ...
          setTimeout(() => {
            clearInterval(this.intervalTimer);
          }, 4000);
        **});**
      }
      ...
    } 
    

    刷新应用并等待大约 5 秒。如果你现在检查appLogs对象,你应该会看到每个属性的变更检测运行总数有所减少,如下所示:

    图 12.17:使用runOutsideAngular()后的WatchComponent中的appLogs对象

    哈哈!注意,appLogs对象中watch键的值已经从大约260下降到4。这意味着我们的动画现在根本不会对变更检测做出贡献,并且watch组件在 4 秒内只渲染 4 次。

  7. 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
            clearInterval(this.intervalTimer);
          }, 4000);
        });
      }
      ...
    } 
    
  8. 最后,从WatchBoxComponent类中移除clearInterval的使用,并取消注释setInterval以运行时钟。更新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.18:使用runOutsideAngular()进行性能优化后的appLogs对象

    观察前面的截图,你可能会问,“Ahsan!这是什么?与经过的毫秒数相比,watchComponentRender 键的变化检测运行次数仍然非常巨大。这到底是如何提高性能的呢?” 很高兴你问了!我将在 它是如何工作的… 部分告诉你原因。

  9. 作为最后一步,停止 Angular 服务器并运行以下命令以在生产模式下启动服务器:

    npm run serve:prod ng-run-outside-angular 
    
  10. 再次导航到 https://localhost:4200。等待几秒钟,然后在 控制台 选项卡中多次检查 appLogs 对象。你应该会看到如下对象:

图 12.19:使用生产构建的 appLogs 对象

嘣!如果你看前面的截图,你应该会看到 watchComponentRender 键的变化检测运行次数总是比 milliseconds 键多几个周期。这意味着 WatchComponent 类只有在 @Input()milliseconds 绑定值更新时才会(几乎)重新渲染。但为什么多几个周期呢?请看下一节了解它是如何工作的!

它是如何工作的…

在这个菜谱中,我们首先查看包含一些键值对的 appLogs 对象。每个键值对的值表示 Angular 为特定组件运行变化检测的次数。hoursmillisecondsminutesseconds 键代表时钟上显示的每个值的 WatchTimeComponent 实例。watchComponentRender 键代表 WatchComponent 实例的变化检测周期。

在菜谱的开始部分,我们看到 watch 键的值是 milliseconds 键值的两倍多。我们为什么要在乎 milliseconds 键呢?因为我们的应用程序中 @Input() 属性绑定 milliseconds 的变化最为频繁——也就是说,它每 1 毫秒ms)就会变化一次。其次是 WatchComponent 类中的 xCoordinateyCoordinate 属性,它们每 30 毫秒变化一次。xCoordinateyCoordinate 值并没有直接绑定到模板(HTML)上,因为它们会改变 stopWatch 视图子组件的 CSS 变量。这发生在 animate 方法内部,如下所示:

el.style.setProperty('--x', `${this.xCoordinate}px`);
el.style.setProperty('--y', `${this.yCoordinate}px`); 

因此,更改这些值不应触发变更检测。我们首先通过将测试时间限制在运行时钟,使用WatchBoxComponent类中的clearInterval方法来停止时钟,使其在 4 秒内停止,以便我们可以评估这些数值。在图 12.15中,我们看到即使时钟停止后,变更检测机制仍然会为WatchComponent类触发。随着时间的推移,这会增加appLogs对象中watch键的计数。然后我们通过在WatchComponent类中使用clearInterval来停止动画。这也使得背景(蓝色圆形)动画在 4 秒后停止。在图 12.16中,我们看到动画停止后,watch键的计数不再增加。

然后,我们尝试仅基于动画来查看变更检测的计数。在步骤 6中,我们停止了时钟。因此,我们只得到了appLogs对象中watch键基于动画的计数,这个值在250270之间。

然后,我们将神奇的runOutsideAngular方法引入到我们的代码中。这个方法是NgZone服务的一部分。NgZone服务包含在@angular/core包中。runOutsideAngular方法接受一个方法作为参数。这个方法在 Angular 区域外执行。这意味着在runOutsideAngular方法内部使用的setTimeoutsetInterval方法不会触发 Angular 变更检测周期。但技术上,我们为什么在区域外运行这个setInterval呢?因为我们的间隔调用animate方法,它更新 CSS 变量--x--y。由于它们会自动触发动画,并且WatchComponent类的其他属性不需要在 UI 中显示(需要重新渲染),我们可以将此代码移动到runOutsideAngular方法中。您可以在图 12.17中看到,使用runOutsideAngular方法后,计数降至4

然后,我们从WatchBoxComponentWatchComponent类中移除了clearInterval的使用——也就是说,再次运行时钟和背景(蓝色圆形)动画,就像我们一开始做的那样。在图 12.18中,我们看到watch键的计数大约是milliseconds键的两倍。那么,为什么它大约是两倍呢?这是因为,在开发模式下,Angular 运行变更检测机制两次以确保没有副作用。例如,一个state属性的更新可能在一个(子)组件中引发变更检测,等等。因此,在步骤 9步骤 10中,我们在生产模式下运行应用程序,在图 12.19中,我们看到watch键的值仅比milliseconds键的值多几个周期,这意味着动画不再触发我们应用程序的任何变更检测。

但为什么watchComponentRender键与milliseconds键相比有更多的周期?这是因为WatchComponent是显示毫秒的组件(WatchTimeComponent)的父组件。可能会有一些基于浏览器交互的变更检测周期,但如果你在刷新应用且与应用没有任何交互时,watchComponentRender与毫秒计数之间的差异在生产模式下可能低至一个变更检测周期。

太棒了,不是吗?如果你觉得这个食谱很有用,请通过我的社交媒体告诉我。

现在你已经了解了它是如何工作的,请查看下一节以获取更多阅读材料。

另请参阅

使用trackBy*ngFor列表

列表是我们今天构建的大多数应用的一个基本部分。如果你正在构建一个 Angular 应用,有很大可能性你会在某个时候使用*ngFor指令来渲染列表。*ngFor指令允许我们遍历数组或对象,为每个项目生成 HTML。然而,如果我们正在渲染大型列表,不谨慎地使用*ngFor可能会导致性能问题,尤其是在*ngFor的源被完全更改(整个数组被替换)时。在这个示例中,我们将学习如何使用带有trackBy函数的*ngFor指令来提高列表的性能。让我们开始吧。

准备工作

我们将要工作的应用位于克隆的仓库中的start/apps/chapter12/ng-for-trackby

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以运行项目:

    npm run serve ng-for-trackby 
    

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

    图 12.20:ng-for-trackby 应用在 http://localhost:4200 上运行

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

如何做到这一点…

我们有一个应用,在视图中显示了 10,000 个用户的列表。由于我们没有使用虚拟滚动(例如来自@angular/material),而是使用标准的*ngFor列表,所以我们现在确实面临一些性能问题。请注意,当你刷新应用时,即使在加载器隐藏后,你也会在列表出现前看到大约 2-3 秒的空白白色框。如果你的机器卡住了很长时间,你可以在data.service.ts文件中修改USERS_LIMIT变量的值。让我们开始重现性能问题的步骤,之后我们将修复这些问题:

  1. 首先,打开 Chrome 开发者工具并查看 控制台 选项卡。你应该会看到“用户卡片创建”消息记录了 10,000 次。每次创建/初始化用户卡片组件(UserCardComponent类的实例)时,都会记录此消息。

  2. 现在,通过卡片上的删除按钮删除第一个项目。现在你应该会看到相同的消息(用户卡片创建)再次记录了 9,999 次,如以下截图所示。这意味着我们为剩余的 9,999 个项目重新创建了list-item组件!图片 B18469_12_21

    图 12.21:删除项目后再次显示的日志

  3. 现在,点击第一个项目(这会根据现有代码更新第一个项目)。你应该再次看到用户卡片创建日志,如图 12.22所示。这意味着我们在更新列表中的任何项目时都会重新创建所有 9,999 个列表项。你会注意到用户界面UI)中第一个项目的名称更新大约在 2-3 秒内反映出来:图片 B18469_12_22

    图 12.22:更新项目后再次显示的日志

  4. 现在,让我们通过使用trackBy函数来修复性能问题。打开users-list.component.ts文件并按照以下方式更新它:

    ...
    export class UsersListComponent {
      @Input() listItems: AppUserCard[] = [];
      @Output() itemClicked = new EventEmitter<AppUserCard>();
      @Output() itemDeleted = new EventEmitter<AppUserCard>();
      **trackByFn****(****_index****:** **number****,** **item****:** **AppUserCard****) {**
    **return** **item.****id****;**
    **}**
    } 
    
  5. 现在,更新users-list.component.html文件以使用我们刚刚创建的trackByFn方法,如下所示:

    <h4 class="heading">Our trusted customers</h4>
    <ul class="list list-group p-2">
    <li class="list__item list-group-item" *ngFor="let item of
        listItems; **trackBy: trackByFn**">
        ...
      </li>
    </ul> 
    
  6. 现在,刷新应用程序,删除第一个项目,然后点击(新的)第一个列表项来更新它。你会注意到项目立即更新,并且不再记录用户卡片创建消息,如图 12.23所示:图片 B18469_12_23

    图 12.23:使用 trackBy 函数更新项目后不再有进一步的日志记录

太好了!你现在知道如何使用trackBy函数与*ngFor指令来优化 Angular 中列表的性能。要了解食谱背后的所有魔法,请参阅下一节。

它是如何工作的...

*ngFor指令允许我们遍历可迭代对象并渲染多个 HTML 元素或组件。当处理原始数组(布尔值、字符串或数字值)时,Angular 通过其值跟踪每个项目以识别元素。然而,当处理对象时,Angular 通过内存位置跟踪它们,就像 JavaScript 处理对象相等性检查一样。这意味着如果你只是更改数组中对象的属性,它不会重新渲染该对象的模板。但是,如果你提供一个新对象来替换它(内存中的不同引用),则将重新渲染该项的内容。这就是我们在本食谱中重现性能问题的方法。由于我们在更新或删除项目时替换整个数组,Angular 将数组视为一个新资源来迭代。在data.service.ts文件中,我们在名为DataService的服务中为updateUser方法有以下代码:

updateUser(updatedUser: AppUserCard) {
  this.users = this.users.map((user) => {
    if (user.id === updatedUser.id) {
      return {
        ...updatedUser,
      };
    }
    return { ...user };
  });
} 

注意,我们使用对象展开运算符({ … })为数组中的每个项目返回一个新的对象。这最终告诉*ngFor指令重新渲染UserListComponent类中listItems数组中的每个项目的 UI。假设你已经渲染了 1,000 个用户。如果你搜索一个返回 100 个用户的术语,理想情况下,Angular 不应该重新渲染这 100 个用户,因为它们已经在视图中渲染过了。然而,Angular 会重新渲染所有列表项的 UI,原因如下(但不仅限于这些):

  • 用户的排序/放置可能已更改。

  • 用户的长度可能已更改。

现在,我们想要避免使用对象引用作为每个列表项的唯一标识符。对于我们的用例,我们知道每个用户的 ID 是唯一的;因此,我们使用trackBy函数告诉 Angular 使用用户的 ID 作为唯一标识符。现在,即使我们在updateUser方法(如前所述)更新用户后为每个用户返回一个新的对象,Angular 也不会重新渲染所有列表项。这是因为新的对象(用户)具有相同的 ID,Angular 使用它来跟踪它们。很酷,对吧?

现在你已经了解了食谱的工作原理,请查看下一节以查看进一步阅读的链接。

相关内容

将重计算移动到纯管道

在 Angular 中,我们有一种特定的编写组件的方式。由于 Angular 具有强烈的意见导向,我们已经有来自社区和 Angular 团队的大量指南,关于编写组件时需要考虑的事项——例如,直接从组件中发起 HTTP 调用被认为是一种不太好的做法。同样,如果组件中有重计算且每次变更检测周期都会触发,这也不被认为是一种好的做法。想象一下,视图依赖于使用不断计算的数据的转换版本。这将在每个渲染周期中引起大量的计算和处理。一个很好的技术是将重计算移动到 Angular(纯)管道中(特别是如果计算发生在每次变更检测周期时)。

准备中

我们将要工作的应用位于克隆的仓库中的start/apps/chapter12/ng-pipes-perf

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-pipes-perf 
    

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

    图片

    图 12.24:ng-pipes-perf 应用在 http://localhost:4200 运行

点击标有点击我的按钮,或者尝试搜索一些用户。你会看到应用太慢,经常卡住。现在,我们已经将项目在浏览器上启动,让我们在下一节中查看食谱的步骤。

如何做到这一点...

我们正在工作的应用程序有一些性能问题,特别是与UserCardComponent类有关。这是因为它使用一个 getter 函数randomColor为其背景生成随机颜色。在幕后,该函数使用factorial函数来增加处理时间。但这只是为了演示一个组件,如果同时发生一些复杂计算和多个变更检测被触发,它可能会导致 UI 卡住。我们将添加一些代码来监控randomColor getter 被调用的次数。这将显示 Angular 默认触发的变更检测次数。我们还将通过完全从特定组件中分离变更检测来修复问题,并使其更高效(尽可能多)。让我们开始吧:

  1. 首先,让我们确保应用程序对您的机器来说足够慢,以至于它使您的笔记本电脑/PC 卡住。打开src/app/app.config.ts文件,并将RANDOMIZATION_COUNT令牌的值从9调整为最适合您的值。

  2. 然后,尝试通过在搜索框中输入他们的名字来搜索名为Irineu的用户。你会注意到应用程序仍然卡住,并且显示用户需要几秒钟。你还会注意到,当你输入字母时甚至看不到搜索框中的字母。也就是说,渲染存在延迟。

    让我们在代码中添加一些逻辑。我们将检查 Angular 在页面加载时调用idUsingFactorial方法的次数。

  3. 让我们创建一个服务,我们将使用它来跟踪特定用户的特定用户卡片被调用的次数。从工作区的根目录运行以下命令来创建一个服务:

    cd start && nx g s services/logs --project ng-pipes-perf 
    

    当被询问时,请选择@schematics/angular:service

  4. 按照以下方式更新src/app/services/logs.service.ts文件的内容:

    import { Injectable } from '@angular/core';
    @Injectable({
      providedIn: 'root'
    })
    export class LogsService {
      logs: Record<string, number> = {}
      updateLogEntry(email: string) {
        if (this.logs[email] === undefined) {
          this.logs = {
            ...this.logs,
            [email]: 1
          }
        } else {
          this.logs = {
            ...this.logs,
            [email]: this.logs[email] + 1
          }
        }
      }
    } 
    
  5. 现在,在src/app/component/user-card/user-card.component.ts文件中注入LogService。我们还将创建一个 getter(log)函数来获取用户的计数,并且每当randomColor getter 被调用时,我们将更新计数。按照以下方式更新提到的文件:

    ...
    **import** **{** **LogsService** **}** **from****'../../services/logs.service'****;**
    @Component({...})
    export class UserCardComponent {
      ...
      randomizationCount = inject(RANDOMIZATION_COUNT);
      **logsService =** **inject****(****LogsService****);**
    **get****log****() {**
    **return****this****.****logsService****.****logs****[****this****.****user****.****email****] ??** **0****;**
    **}**
    get randomColor() {
        **this****.****logsService****.****updateLogEntry****(****this****.****user****.****email****);**
        ...
      }
    } 
    
  6. 现在,我们将使用用户卡片组件的模板中的日志来显示计数。按照以下方式更新src/app/component/user-card/user-card.component.html文件:

    <div [style.backgroundColor]="randomColor"...>
    <img ...>
    <div class="card-body flex-1">...</div>
    **<****div****class****=****"p-4 bg-slate-900 text-green-300 rounded-md** **h-fit"****>**
    **<****div****>**
    **Color Generation Count:**
    **</****div****>**
    **<****pre****>****{{log}}****</****pre****>**
    **</****div****>**
    </div> 
    

    如果你现在查看应用程序,你应该会看到以下颜色生成计数

    图 12.25

    图 12.25:页面加载时显示的颜色生成计数

  7. 现在,点击更新 Irineu 的名字按钮。然后(点击)聚焦于快速搜索输入框,然后点击外部。重复几次,你应该会看到颜色被重新生成,尽管卡片不应该被重新渲染。图 12.26显示了它应该看起来是什么样子!图 12.26

    图 12.26:与应用程序交互后未搜索任何内容的日志

    注意,如果你开始搜索某些内容,你会得到更多的重新渲染。这是因为每个 keyup 和/或 keydown 事件都会触发更多的重新渲染。

  8. 为了解决这个问题,我们将创建一个 Angular 管道。我们将把生成随机颜色的计算移动到这个 Angular 管道中。在项目根目录中,在终端中运行以下命令:

    cd start && nx g pipe random-color --directory apps/chapter12/ng-pipes-perf/src/app/pipes 
    

    当被要求时,使用@schematics/angular:pipe脚图。

  9. 现在,将randomColor获取器函数和factorial函数从user-card.component.ts文件移动到 Angular 管道的文件pipes/random-color.pipe.ts中,如下所示:

    import { Pipe, PipeTransform, inject } from '@angular/core';
    **import** **{** **LogsService** **}** **from****'../services/logs.service'****;**
    **import** **{ randColor }** **from****'@ngneat/falso'****;**
    **import** **{** **IUser** **}** **from****'../interfaces/user.interface'****;**
    ...
    export class RandomColorPipe implements PipeTransform {
      **logsService =** **inject****(****LogsService****);**
    **factorial****(****n****:** **number****):** **number** **{**
    **if** **(n ==** **0** **|| n ==** **1****) {**
    **return****1****;**
    **}** **else** **{**
    **return** **n *** **this****.****factorial****(n -** **1****);**
    **}**
    **}**
    **randomColor****(****email****:** **string****,** **randomizationCount****:** **number****) {**
    **this****.****logsService****.****updateLogEntry****(email);**
    **let** **color;**
    **for** **(****let** **i =** **0****; i <** **this****.****factorial****(randomizationCount); i++) {**
    **color =** **randColor****();**
    **}**
    **return** **color;**
    **}**
    transform(r**andomizationCount****:** **number****,** **user****:** **IUser**):
        **string** **|** **undefined** {
        return **this****.****randomColor****(user.****email****, randomizationCount)**;
      }
    } 
    
  10. 确保从src/app/component/user-card/user-card.component.ts文件中删除那些函数(randomColor获取器和factorial)。同时删除任何未使用的导入。

  11. 现在,将randomColor管道添加到user-card.component.ts文件中的用户卡片组件中,如下所示:

    ...
    **import** **{** **RandomColorPipe** **}** **from****'../../pipes/random-color.pipe'****;**
    @Component({
      selector: 'app-user-card',
      standalone: true,
      imports: [CommonModule, RouterModule, **RandomColorPipe**],
      ...
    }) 
    
  12. 现在,更新user-card.component.html文件以使用randomColor管道代替我们之前使用的获取器。代码应该看起来像这样:

    <div [style.backgroundColor]="**randomizationCount | randomColor : user**" class="card flex flex-col max-w-sm mx-auto h-full duration-200 cursor-pointer hover:border-purple-500 hover:shadow-md p-4 border border-slate-300 rounded-md text-center" *ngIf="user" routerLink="/user/{{user.uuid}}">
      ...
    </div> 
    
  13. 现在,刷新应用并重复步骤 7。你会看到颜色只为第一张卡片生成,其他卡片不会重新渲染,如图12.27所示:

    图 12.27:只有第一张卡片重新生成颜色

嘣!现在你知道了如何通过将重计算移动到纯 Angular 管道来优化性能,请看下一节了解它是如何工作的。

它是如何工作的…

如我们所知,Angular 默认在应用中每个由浏览器事件触发的变化检测上运行。由于我们在组件模板(UI)中使用了一个randomColor获取器,这个函数每次 Angular 运行变化检测周期时都会运行。这会导致更多的计算和性能问题。如果我们使用函数调用而不是获取器,这也会成立。

我们可以退一步,从最初的实现中思考一下randomColor获取器的作用。它是基于randomizationCount属性的阶乘使用for循环工作的。这只是为了给这个例子增加很多处理时间,但你可以想象任何在变化检测周期中涉及的重计算都会导致性能问题。在这些情况下,我们可能会使用纯函数或者可能使用记忆化。

纯函数是一个函数,给定相同的输入,总是返回相同的输出。记忆化是一种技术,其中,如果输入没有改变(即,函数使用与上次相同的输入调用),则返回缓存的输出,并跳过重计算。幸运的是,Angular 纯管道是纯函数和记忆化的,因为它们只有在输入改变时才会被调用。如果不是这种情况,管道的转换函数将不会被调用,组件也不会重新渲染。

在这个菜谱中,我们将计算移动到一个新创建的 Angular 管道。管道的 transform 方法接收 randomizationCount 作为第一个值,以及 user(类型为 IUser)作为第二个输入。管道随后使用 randomColor 方法,最终使用 factorial 方法来计算一个随机颜色。当我们开始在搜索框中输入时,用户卡片的值不会改变。这导致管道直到我们根据搜索查询得到一组新的用户时才会被触发。一旦我们得到结果,用户卡片将被重新渲染,因此我们为它们得到新的颜色。结果,由于浏览器事件,没有不必要的计算运行,从而优化性能并解除 UI 线程的阻塞。

相关内容

使用 Web workers 进行重量级计算

如果你的 Angular 应用在执行动作时进行大量计算,那么它有很大可能会阻塞 UI 线程。这会导致渲染 UI 时出现延迟,因为它阻塞了主 JavaScript 线程。Web workers 允许我们在后台线程中运行重量级计算,从而释放 UI 线程,使其不被阻塞。在这个菜谱中,我们将使用一个在 UserService 类中进行重量级计算的应用程序。它为每个用户卡片创建一个唯一的 ID 并将其保存到 localStorage 中。然而,在这样做之前,它会循环几千次,这会导致我们的应用程序挂起一段时间。在这个菜谱中,我们将把重量级计算从组件移动到 Web worker,并且还会添加一个回退方案,以防 Web workers 不可用。

准备工作

我们将要工作的应用程序位于克隆的仓库中的 start/apps/chapter12/ng-ww-perf 目录内:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以启动项目:

    npm run serve ng-ww-perf 
    

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

    图片

    图 12.28:ng-ww-perf 应用程序在 http://localhost:4200 运行

现在我们已经启动了应用程序,让我们在下一节中查看菜谱的步骤。

如何操作…

一旦你打开应用,你会注意到用户卡片渲染需要一些时间。我们还可以看到用户卡片背景随机颜色生成的次数。如果你输入某些内容,或者点击更新 Irineu 的名字按钮,你会看到 UI 线程在计算完成之前被阻塞。罪魁祸首是UserCardComponent类中的randomColor获取器方法。这最终在渲染颜色之前,基于RANDOMIZATION_COUNT令牌的值运行一个for循环来生成一个随机颜色。这发生在src/app/utils.ts文件中的generateRandomColor方法内部。让我们开始配方来提高应用性能。我们将从实现一个 web worker 开始:

  1. 我们首先创建一个 web worker。在工作区根目录中运行以下命令:

    cd start && nx generate web-worker workers/randomColor --project ng-ww-perf 
    

    当被询问时,选择@nx/angular:web-worker

  2. 现在,更新workers/random-color.worker.ts文件中的代码如下:

    /// <reference lib="webworker" />
    import { generateRandomColor } from "../utils";
    type RandomColorIncomingEvent = {
      data: {
        randomizationCount: number
      }
    }
    export type RandomColorOutgoingEvent = { data: { color: string } };
    addEventListener('message', ({ data }:
      RandomColorIncomingEvent) => {
      const {
        randomizationCount
      } = data;
      console.log('inside the worker', data)
      if (!randomizationCount) {
        return;
      }
      const color = generateRandomColor(randomizationCount);
      postMessage({
        color
      });
    });
    export const getRandomColorWorker = () => {
      if (typeof Worker !== undefined) {
        return new Worker(new URL('./random-color.worker', import.meta.url), {
          type: 'module'
        })
      }
      return null;
    } 
    
  3. 让我们将UserCardComponent类中的randomColor获取器替换为一个普通属性。按照以下方式更新user-card.component.ts文件:

    ...
    export class UserCardComponent implements OnInit, OnChanges {
      ...
      randomizationCount = inject(RANDOMIZATION_COUNT);
      **randomColor =** **''****;**
      ...
    } 
    

    确保删除randomColor获取器函数。否则,TypeScript 会抛出错误,因为我们不能有一个属性和一个具有相同名称的获取器方法。

  4. 现在,我们将使用user-card.component.ts文件中的 worker。按照以下方式更新它:

    import { Component, Input, **OnInit**, inject } from '@angular/core';
    ...
    import { RANDOMIZATION_COUNT } from '../../tokens';
    **import** **{** **RandomColorOutgoingEvent** **, getRandomColorWorker }** **from****'../../workers/random-color.worker'****;**
    @Component({...})
    export class UserCardComponent **implements****OnInit** {
      @Input() user!: IUser;
      @Input() index = 0;
      logsService = inject(LogsService);
      randomizationCount = inject(RANDOMIZATION_COUNT);
      randomColor = '';
      **worker****:** **Worker** **|** **null** **=** **getRandomColorWorker****();**
    **ngOnInit****():** **void** **{**
    **if** **(!****this****.****worker****) {**
    **return****;**
    **}**
    **this****.****worker****.****onmessage** **=** **(****{ data: { color } }:**
    **RandomColorOutgoingEvent****) =>** **{**
    **console****.****log****(**
    **`received color** **${color}** **from worker for user** **${****this****.user.email}****`**
    **);**
    **this****.****logsService****.****updateLogEntry****(****this****.****user****.****email****);**
    **this****.****randomColor** **= color;**
    **};**
    **}**
     ...
    } 
    
  5. 到目前为止,我们只是添加了一个监听器,用于接收从 worker 生成的颜色。但首先,我们必须从组件向 worker 发送一条消息,以便它生成一个随机颜色。将user-card.component.ts文件更新为使用OnChanges生命周期钩子。我们将使用它向 worker 发送消息:

    import { Component, Input, **OnChanges**, OnInit, **SimpleChanges**, inject } from '@angular/core';
    ...
    export class UserCardComponent implements OnInit, **OnChanges**{
      ...
      ngOnInit(): void {...}
    
      **ngOnChanges****(****changes****:** **SimpleChanges****) {**
    **if** **(changes[****'user'****].****currentValue** **!==**
    **changes[****'user'****].****previousValue****) {**
    **if** **(!****this****.****worker****) {**
    **this****.****randomColor** **=** **generateRandomColor****(**
    **this****.****randomizationCount****);**
    **return****;**
    **}**
    **this****.****worker****.****postMessage****({** **randomizationCount****:**
    **this****.****randomizationCount** **});**
    **}**
    **}**
      ...
    } 
    
  6. 最后,让我们确保在相应的用户卡片被销毁(终止)时,worker 也被销毁。按照以下方式更新user-card.component.ts文件:

    ...
    @ import { Component, Input, OnChanges, **OnDestroy**, OnInit, SimpleChanges, inject } from '@angular/core';
    export class UserCardComponent implements OnInit, OnChanges,
      **OnDestroy**{
      ...
      **ngOnDestroy****():** **void** **{**
    **this****.****worker****?.****terminate****();**
    **}**
    get log() {...}
    } 
    
  7. 刷新应用并注意用户卡片渲染所需的时间。它们应该比之前快得多。此外,你应该能够看到以下日志反映了从应用到 web worker 以及相反的通信:img/B18469_12_29.png

    图 12.29:显示从应用向 web worker 发送消息的日志

哇哦!web worker 的力量!现在你知道如何在 Angular 应用中使用 web worker 将繁重的计算移到它们那里。由于你已经完成了配方,请查看下一节了解它是如何工作的。

它是如何工作的…

正如我们在食谱描述中讨论的那样,Web 工作者允许我们在主 JavaScript(或 UI 线程)之外的一个单独的线程中运行和执行代码。在食谱的开始部分,无论何时我们刷新应用程序或搜索用户,它都会阻塞 UI 线程。我们有时会看到加载器被挂起,或者出现一个空白屏幕。直到为每个卡片生成一个随机颜色。我们通过使用 NX 命令行界面CLI)开始食谱,创建一个 Web 工作者。这会创建一个 random-color.worker.ts 文件,其中包含一些模板代码,用于接收来自 UI 线程的消息并将其作为响应发送回它。

CLI 命令还会通过添加 webWorkerTsConfig 属性来更新 project.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"
]
} 

然而,在我们的 NX 工作区中,我们有一个 tsconfig.base.json 文件而不是 tsconfig.json。因此,我们进行了修复。

在 Web 工作者文件中,我们有 addEventListener 方法的调用,它从 UI 线程接收消息到工作者。请注意,我们期望从 UI 线程接收 randomizationCount 属性,这样我们就可以在 utils.ts 文件中的 generateRandomColor 方法中使用它。

工作者文件还有一个名为 getRandomColorWorker 的方法。每次调用它时,都会返回一个新的工作者实例。由于我们从用户卡片组件中调用它,每个卡片都会得到一个新的工作者实例。

然后,在我们的 UserCardComponent 类中,我们为每个组件获取一个新的工作者实例,并使用 ngOnInit 生命周期钩子向工作者添加一个事件监听器。这样,每当工作者发送消息时,用户卡片组件都可以读取它——也就是说,它会获取生成的颜色并将其分配给 randomColor 属性。这反过来会设置用户卡片的 backgroundColor。请注意,ngOnInit 生命周期钩子只为工作者发送的消息注册监听器。但首先,我们必须告诉工作者生成随机颜色。为此,我们使用 ngOnChanges 生命周期钩子。在钩子中,我们观察用户输入的值。如果它发生变化,我们就向工作者发送消息,为特定的用户卡片生成一个随机颜色。如果您点击 更新 Irineu 的姓名 按钮,您将看到从用户卡片发送和接收的连续日志。请注意,以这种方式将颜色生成移动到工作者,也会导致在浏览器事件(点击、按键等)触发时,其他组件不再重新渲染。最后,我们使用 ngOnDestroy 生命周期钩子来终止工作者,以避免内存泄漏。

注意,在 ngOnChanges 钩子中,如果浏览器不支持工作者,我们也会回退到 utils.ts 文件中方法的常规使用。

参见

使用性能预算进行审计

在今天的世界里,大多数人口都有良好的互联网连接来使用日常应用程序,无论是移动应用程序还是网页应用程序,我们向最终用户发送的数据量是多么令人着迷。现在发送给用户的 JavaScript 数量呈不断增长的趋势,如果您正在开发一个网页应用程序,您可能希望使用性能预算来确保包大小不超过某个限制。对于 Angular 应用程序,设置预算大小非常简单。在这个配方中,您将学习如何使用性能预算来确保我们的 Angular 应用程序的包大小保持较小。

准备工作

我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter12/ng-perf-budgets内:

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令以构建项目:

    npm run build ng-perf-budgets 
    

    这应该会构建应用程序,您应该在终端中看到以下内容:

    图 12.30:生产模式下的构建输出,没有性能预算

注意现在main.*.js文件的包大小约为 294 千字节KB)。既然我们已经构建了应用程序,接下来让我们看看下一节中的构建步骤。

如何做到这一点…

目前我们的应用程序在包大小方面很小。然而,随着未来业务需求的发展,这可能会变成一个巨大的应用程序。为了这个配方的目的,我们将故意增加包大小,然后使用性能预算来阻止 Angular 构建在包大小超过预算时生成。让我们开始配方:

  1. 打开app.component.ts文件并按照以下方式更新它:

    ...
    **import** ***** **as** **moment** **from****'../lib/moment'****;**
    **import** ***** **as****THREE****from****'three'****;**
    @Component({...})
    export class AppComponent {
      ...
      constructor() {
       const scene = new THREE.Scene(); 
       console.log(moment().format('MMM Do YYYY'));
       console.log(scene);
     }
      ...
    } 
    
  2. 现在,通过从工作区根目录运行以下命令再次构建应用程序:

    npm run build ng-perf-budgets 
    

    您应该看到main.*.js文件的包大小现在大约为 1.10 兆字节MB)。与原始的约 294 KB 相比,这是一个巨大的尺寸增加,如以下截图所示:

    图 12.31:main.*.js 的包大小增加到 1.10 MB

    由于我们使用 NX 作为我们的配方,我们已经有 NX 设置的预算。然而,如果您有一个常规的 Angular 应用程序(没有 NX),您需要更新angular.json文件以添加预算。

  3. start/apps/chapter12/ng-perf-budgets文件夹内打开project.json文件并更新它。我们要针对的属性是targets.build.configurations.production.budgets。更新的代码应如下所示:

    ...
    {
    "budgets": [
    {
    "type": "initial",
    "maximumWarning": "**800kb**",
    "maximumError": "**1mb**"
    },
    {
    "type": "anyComponentStyle",
    "maximumWarning": "2kb",
    "maximumError": "4kb"
    }
    ]
    }
    ... 
    

    注意,我们只为initial包将maximumWarning500kb更改为800kb

  4. 让我们通过不在app.component.ts文件中导入整个库,而是使用date-fns包来改进我们的应用程序,代替moment.js。从工作区的根目录运行以下命令来安装date-fns包:

    cd start && npm install --save date-fns 
    
  5. 现在,更新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() {
        **const** **scene =** **new****Scene****();**
    **console****.****log****(****format****(****new****Date****(),** **'LLL do yyyy'****));** 
    **console****.****log****(scene);**
      }
      ...
    } 
    
  6. 再次运行npm run build ng-perf-budgets命令。你应该会看到包大小减少,并且构建成功生成,如下所示:

    图 12.32:使用 date-fns 和优化导入后的减少的包大小

嘣!你刚刚学会了如何在 Angular 中使用性能预算。这些预算可以根据你的配置抛出警告和错误。请注意,预算可以根据不断变化的企业需求进行修改。然而,作为工程师,我们必须谨慎地设置性能预算,以避免向最终用户发送超过一定限制的 JavaScript。

既然你已经完成了这个食谱,请查看下一节,了解它是如何工作的。

它是如何工作的…

Angular 性能预算是为 Angular 应用程序中各种性能指标的可接受限制设定的指南。这些预算有助于确保应用程序的性能保持在可接受的范围内,并且随着代码库的增长或变化,性能不会显著下降。你可以与之工作的最重要的性能指标是初始包大小。这是用户设备首先加载的主要 JavaScript 文件集,并且它们会被急切地加载。在这个食谱中,我们有两个问题。首先,我们使用了moment.js,这是一个不可摇树的库。这意味着如果我们导入这个库,整个库都会包含在最终的包中,而可摇树库在构建时,由构建工具移除应用中未使用的代码。我们引入的第二个问题是,我们在组件中包含了整个库three,它是可摇树的,但我们的导入是不准确的。我们引入这些问题是为了看到包大小增加。但正如我们所看到的,在 NX 中,我们可以使用任何 Angular 应用的project.json文件来管理性能预算。如果你正在使用基于 Angular CLI 的应用程序,你会在angular.json文件中做同样的事情。在实践中,你会使用警告阈值和错误阈值,这确保了你不会向最终用户发送巨大的 JavaScript 包。

参见

使用 webpack-bundle-analyzer 分析包

在上一个菜谱中,我们查看为我们的 Angular 应用程序配置预算,这很有用,因为你可以知道整体包大小何时超过某个阈值,尽管你不知道代码的每一部分对最终包的贡献有多大。这就是我们所说的分析包,在本菜谱中,你将学习如何使用webpack-bundle-analyzer来审计包大小及其影响因素。

准备工作

我们将要工作的应用程序位于克隆仓库的start/apps/chapter12/ng-perf-wba目录中:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到代码仓库目录,并运行以下命令来构建项目:

    npm run build ng-perf-wba 
    

    这应该会构建应用程序,你应该在终端中看到以下内容:

    图 12.33:ng-perf-wba 应用程序在 http://localhost:4200 上运行

现在我们已经构建了应用程序,让我们在下一节中查看菜谱的步骤。

如何做到这一点...

目前,我们的应用程序在包大小方面相对较小。然而,随着未来业务需求的发展,这可能会变成一个巨大的应用程序。为了本菜谱的目的,我们将故意增加包大小,然后使用webpack-bundle-analyzer来观察导致大包大小的包。让我们开始这个菜谱:

  1. 打开app.component.ts文件并更新它,如下所示:

    ...
    **import** ***** **as** **moment** **from****'../lib/moment'****;**
    **import** ***** **as****THREE****from****'three'****;**
    @Component({...})
    export class AppComponent {
      ...
      **constructor****() {**
    **const** **scene =** **new****THREE****.****Scene****();** 
    **console****.****log****(****moment****().****format****(****'MMM Do YYYY'****));**
    **console****.****log****(scene);**
    **}**
      ...
    } 
    
  2. 现在,再次从工作区根目录运行以下命令来构建应用程序:

    npm run build ng-perf-wba 
    

    你应该看到main.*.js文件的包大小现在大约是 1.10 MB。与原始的约 294 KB 相比,这是一个巨大的尺寸增加。因此,初始总大小变为 1.15 MB,构建失败,正如你在以下屏幕截图中所看到的:

    图 12.34:由于包大小增加导致的构建失败

  3. 我们首先创建一个包含 JSON 格式包信息的stats.json文件的构建。为此,从工作区根目录运行以下命令:

    npm run build ng-perf-wba with-stats 
    
  4. 现在,从工作区根目录运行以下命令,让webpack-bundle-analyzer读取stats.json文件,如下所示:

    npx webpack-bundle-analyzer ./start/dist/apps/chapter12/ng-perf-wba/stats.json 
    

    这将启动一个带有包分析的服务器。你应该在你的默认浏览器中看到一个新标签页被打开,并且它看起来应该是这样的:

    图 12.35:使用 webpack-bundle-analyzer 进行包分析

  5. 注意到lib文件夹占据了包大小的大部分——大约 562 KB,你可以通过在lib框上悬停鼠标来检查。整体包大小是 1.16 MB。让我们尝试优化包大小。让我们安装date-fns包,这样我们就可以用它来代替moment.js。从你的项目根目录运行以下命令:

    cd start && npm install --save date-fns 
    
  6. 现在,更新app.component.ts文件以使用date-fns包的format方法,而不是使用moment().format方法。我们还将只从Three.js包中导入Scene类,而不是导入整个库。代码应该看起来像这样:

    ...
    import { AuthService } from './services/auth.service';
    **import** **{ format }** **from****'date-fns'****;**
    **import** **{** **Scene** **}** **from****'three'****;**
    @Component({...})
    export class AppComponent {
      ...
      constructor() {
        **const** **scene =** **new****Scene****();**
    **console****.****log****(****format****(****new****Date****(),** **'LLL do yyyy'****));** 
    **console****.****log****(scene);**
      }
      ...
    } 
    
  7. 重复步骤 3步骤 4以重新构建应用程序并通过webpack-bundle-analyzer进行分析。

    一旦webpack-bundle-analyzer运行,你应该会看到分析结果,如下面的屏幕截图所示。注意,我们不再有moment.js文件或lib块,整体包大小已从 1.16 MB 减少到大约 835 KB:

    图片

    图 12.36:优化后的包分析

哇哦!你现在已经知道如何使用webpack-bundle-analyzer包来审计 Angular 应用程序的包大小了。这是一个提高整体性能的好方法,因为你可以识别出导致包大小增加的块,然后优化这些包。如果你在优化前后从工作区根目录运行npm run serve ng-perf-wba,你会看到相同的控制台日志,这表明我们保留了现有功能并优化了包。

相关链接

在 Discord 上了解更多

要加入这本书的 Discord 社区——在那里你可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:

packt.link/AngularCookbook2e

二维码

第十三章:使用 Angular 构建 PWA

渐进式 Web 应用PWAs)远不止是 Web 应用;它们是 Web 技术的下一进化阶段。结合了 Web 和移动应用的最佳特性,PWAs 在不理想的网络条件下也能提供无与伦比的用户体验。但真正让它们引人入胜的是它们的优雅降级——虽然它们利用了现代浏览器的全部功能,同时也确保在旧浏览器中提供无缝的核心体验。

在本章中,我们将通过 Angular 的视角深入探索 PWA 的世界。Angular 内置了 PWA 支持,使其成为构建健壮和性能卓越的 Web 应用的理想选择。你将学习如何将你的应用构建为可安装、功能强大、快速且可靠的渐进式 Web 应用。以下是本章将要涵盖的食谱:

  • 使用 Angular CLI 将现有的 Angular 应用转换为 PWA

  • 修改你的 PWA 的主题颜色

  • 在你的 PWA 中使用暗黑模式

  • 在你的 PWA 中提供定制的可安装体验

  • 使用 Angular 服务工作者进行预缓存请求

  • 为你的 PWA 创建一个应用外壳

技术要求

对于本章的食谱,请确保你的设置符合 'Angular-Cookbook-2E' GitHub 仓库中的 'Technical Requirements'。有关设置详情,请访问:github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/docs/technical-requirements.md。本章的起始代码位于 github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/start/apps/chapter13

使用 Angular CLI 将现有的 Angular 应用转换为 PWA

PWA 包含几个引人入胜的组件,其中两个突出的特性是服务工作者和 Web 清单文件。服务工作者在缓存静态资源和处理缓存请求中扮演着至关重要的角色。

同时,Web 清单文件包含诸如应用图标和应用的主体颜色等关键信息。在本指南中,我们将把现有的 Angular 应用转换为 PWA。如果你从头开始创建一个新的 Angular 应用,这些原则同样适用。在整个演练过程中,我们将转换一个现有的 Angular 应用,突出显示所做的更改,并使用 @angular/pwa 包展示转换过程。这个包不仅使 PWA 功能化,还促进了静态资源的有效缓存。

准备工作

我们将要工作的应用位于克隆的仓库中的 start/apps/chapter13/ng-pwa-conversion 目录下。这不是我们 NX 工作空间的一部分,而是一个独立的 Angular 应用,拥有自己的 package.jsonnode_modules 等。按照以下步骤开始:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端并导航到克隆的代码仓库文件夹。

  3. 导航到start/apps/chapter13/ng-pwa-conversion并运行以下命令以提供项目服务:

    npm run build && npx http-server dist/ng-pwa-conversion 
    

    在浏览器中打开一个新标签页,并导航到http://localhost:8080。你应该能看到以下内容:

    图 13.1:ng-pwa-conversion 应用在 http://localhost:8080 上运行

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

如何操作...

我们正在处理的应用是使用第九章中提到的 Angular CDK 构建的游戏,即Angular 和 Angular CDK。你可以输入你的名字,然后猜测骰子的下一个值,并获得分数排行榜。排行榜的值是通过本地存储持久化的。但是,该应用还不是 PWA。让我们将其转换为 PWA:

  1. 首先,让我们看看我们的应用是否完全支持离线工作,因为这是 PWA 的一个特性。打开应用的Chrome 开发者工具。转到网络选项卡,并将限制改为离线,如下所示:

    图 13.2:将网络限制改为离线以查看离线体验

  2. 确保勾选了禁用缓存选项。

  3. 现在,通过从终端退出进程来停止http服务器。完成后,刷新应用的页面。你应该能看到应用不再工作,如图所示:

    图 13.3:离线时应用无法工作

  4. 要将此应用转换为 PWA,打开一个新的终端窗口/标签页,并确保你位于start/apps/chapter13/ng-pwa-conversion文件夹内。一旦进入,运行以下命令:

    ng add @angular/pwa 
    

    在命令行进程结束的过程中,你应该会看到创建和更新了一大批文件。

  5. 现在,通过运行npm run build && npx http-server dist/ng-pwa-conversion再次构建并提供服务应用。完成后,导航到http://localhost:8080

  6. 现在,确保你已经关闭了限制,通过切换到网络选项卡并将选择设置为无限制,如图图 13.4所示。同时,注意禁用缓存选项已被关闭:图 13.4 – 关闭网络限制

    图 13.4:关闭网络限制

  7. 现在刷新一次应用。你应该能看到应用正在运行,并且网络日志显示从服务器加载了诸如 JavaScript 文件等资源,如图图 13.5所示:

    图 13.5:从源(Angular 服务器)下载的资源

  8. 现在,再次刷新应用一次,你会看到相同的资源现在是通过服务工作者从缓存中下载的,如图图 13.6所示:

    图 13.6:使用服务工作者从缓存中下载的资源

  9. 现在是我们一直等待的时刻。将网络限制改为离线以进入离线模式并刷新应用程序。你应该仍然看到应用程序在离线模式下工作,这是由于服务工作者,如图图 13.7所示:图片

    图 13.7:使用服务工作者作为 PWA 的离线运行的 Angular 应用程序

  10. 现在你可以将这个 PWA 安装到你的机器上。由于我使用的是 MacBook,它被安装为 Mac 应用程序。如果你使用 Chrome,安装选项应该在地址栏附近,如图图 13.8所示:图片

    图 13.8:从 Chrome 安装 Angular PWA

    哇!仅仅通过使用@angular/pwa包,我们无需自己进行任何配置,就将现有的 Angular 应用程序转换成了 PWA。现在我们能够离线运行我们的应用程序,并且可以在我们的设备上将其安装为 PWA。见图图 13.9来查看应用程序的外观——就像 Mac OS X 上的原生应用程序一样:

图片

图 13.9:我们的 Angular PWA 作为 Mac OS X 上的原生应用程序的外观

好吧,对吧? 现在你已经知道了如何使用 Angular CLI 构建 PWA,请查看下一节以了解它是如何工作的。

它是如何工作的…

Angular 核心团队和社区在@angular/pwa包以及通常的ng add命令方面做了惊人的工作,该命令允许我们使用 Angular 模板添加不同的包到我们的应用程序中。在这个配方中,当我们运行ng add @angular/pwa时,它使用模板生成应用程序图标以及网络应用程序清单。如果你查看更改的文件,你可以看到新文件,如图图 13.10所示:

图片

图 13.10:网络清单文件和应用程序图标文件

manifest.webmanifest文件是一个包含 JSON 对象的文件。该对象定义了 PWA 的清单并包含一些信息。信息包括应用程序的名称、简称、主题颜色以及不同设备的图标配置。想象一下这个 PWA 安装在你的安卓手机上。你需要在主抽屉中有一个图标来点击以打开应用程序。此文件包含有关根据不同设备尺寸使用哪个图标的信息。

我们还看到了文件ngsw-config.json,它包含服务工作者的配置。在幕后,当ng add命令运行模板时,它也在我们的项目中安装了@angular/service-worker包。如果你打开app.config.ts文件,你会看到以下代码来注册我们的服务工作者:

...
**import** **{ provideServiceWorker }** **from****'@angular/service-worker'**;

export const appConfig: ApplicationConfig = {
  providers: [provideRouter(appRoutes,
  withEnabledBlockingInitialNavigation(), withHashLocation()),
    **provideServiceWorker****(****'ngsw-worker.js'****, {**
**enabled****: !****isDevMode****(),**
**registrationStrategy****:** **'registerWhenStable:30000'**
**})**],
}; 

让我们分解代码来理解这里发生了什么。在这里,我们正在创建独立应用程序的配置,以及一些提供者。我们已经使用了provideRouter方法来提供应用程序的所有路由。在配方中,我们添加了provideServiceWorker方法来为我们注册一个新的服务工作者。它需要两个参数:

  • 第一个参数是服务工作者脚本的文件名,'ngsw-worker.js'。这是内置的 Angular 服务工作者,它处理缓存和其他离线行为。

  • 第二个参数是服务工作者的配置对象。让我们回顾一下这个配置对象的属性:

    • enabled: !isDevMode()告诉应用程序仅在应用程序不在开发模式下时启用服务工作者。

    • registrationStrategy:'registerWhenStable:3000'指定了注册服务工作者的策略。在这种情况下,服务工作者将在应用稳定(没有正在进行的任务)30 秒后注册。

代码注册了一个名为ngsw-worker.js的新服务工作者文件。此文件使用ngsw-config.json文件中的配置来决定缓存哪些资源以及使用哪些策略。这是通过angular.json文件中的ngswConfigPath属性链接的。请注意,我们拥有的应用程序是一个独立的应用程序。这意味着我们没有为应用程序的引导过程创建NgModule。如果这是一个非独立应用程序,我们会在app.module.ts文件中看到这些更改。

现在你已经知道了食谱是如何工作的,请查看下一节以获取更多阅读内容。

参见

修改你的 PWA 主题颜色

在之前的食谱中,我们学习了如何将 Angular 应用转换为 PWA。当我们这样做时,@angular/pwa包会创建一个带有默认主题颜色的 web 应用清单文件,如图图 13.9所示。然而,几乎每个 web 应用都有自己的品牌和风格。如果你想根据你的品牌定制 PWA 的标题栏,这就是你的食谱。我们将学习如何修改 web 应用清单文件来自定义 PWA 的主题颜色。

准备工作

我们将要工作的应用位于克隆仓库中的start/apps/chapter13/ng-pwa-theme-color

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到克隆的代码仓库文件夹,并从工作区的根目录运行以下命令(以生产模式运行项目):

    npm run serve:static chapter13 ng-pwa-theme-color 5300 
    

    这应该在新的浏览器标签页中打开应用,地址为https://localhost:5300

  3. 按照如图图 13.8所示安装应用程序。

    这应该会在原生操作系统窗口中打开应用,你应该会看到以下内容:

    图片

    图 13.11:ng-pwa-theme-color 应用作为 PWA 运行

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

如何做到这一点...

如你在图 13.11中看到的,应用的头部的颜色与应用的本地头部(或工具栏)略有不同。由于这种差异,应用看起来有点奇怪。我们将修改 web 应用清单来更新主题颜色。让我们开始吧:

  1. 在你的编辑器中打开 src/manifest.webmanifest 文件,并按照以下方式更改主题颜色:

    {
    "name": "ng-pwa-theme-color",
    "short_name": "ng-pwa-theme-color",
    "theme_color": "#7E22CE",
      ...
    } 
    
  2. 我们还在我们的 index.html 文件中设置了 theme-color。默认情况下,它比 web 应用程序清单文件具有优先级。因此,我们需要更新它。打开 index.html 文件,并按照以下方式更新:

    <!DOCTYPE html>
    <html lang="en">
    <head>
        ...
        <link rel="manifest" href="manifest.webmanifest">
    **<****meta****name****=****"theme-color"****content****=****"#7E22CE"****>**
    </head>
    <body>
        ...
      </body>
    </html> 
    
  3. 再次打开 PWA 并按照图 13.12 所示卸载它。当提示时,确保勾选表示 也从 Chrome 中清除数据 (...) 的复选框:![img/B18469_13_12.png]

    图 13.12:卸载 ng-pwa-theme-color 应用程序

  4. 现在,再次使用以下命令构建应用程序:

    npm run build ng-pwa-theme-color && npm run serve:static chapter13 ng-pwa-theme-color 5300 
    
  5. 现在,转到 http://localhost:5300 并按照图 13.8 所示再次作为 PWA 安装应用程序。

  6. PWA 应该已经打开。如果没有,从你的应用程序中打开它,你应该看到更新的主题颜色,如图 13.13 所示:![img/B18469_13_13.png]

    图 13.13:更新主题颜色后的 PWA

Awesomesauce! 你刚刚学会了如何更新 Angular PWA 的主题颜色。现在你已经完成了这个食谱,请查看下一节以获取更多阅读材料。

参考信息

在你的 PWA 中使用暗黑模式

在现代设备和应用程序的时代,最终用户的偏好也发生了一些变化。随着屏幕和设备使用量的增加,健康成为了一个主要关注点。现在几乎所有的屏幕设备都支持暗黑模式。考虑到这一点,如果你正在构建一个 web 应用程序,你可能希望为它提供暗黑模式支持。如果它是一个以原生应用程序形式呈现的 PWA,那么责任就更大了。在这个食谱中,你将学习如何为你的 Angular PWA 提供暗黑模式。你将学习三种不同的方法来实现暗黑模式样式,包括 prefers-color-scheme(这是默认/原生的 CSS 方法)以及两种使用 Tailwind CSS 实现它的方法。

准备工作

我们将要工作的应用程序位于克隆的仓库中的 start/apps/chapter13/ng-pwa-dark-mode

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到克隆的代码仓库文件夹,并从工作区的根目录运行以下命令以在生产模式下运行项目:

    npm run serve:static chapter13 ng-pwa-dark-mode 6400 
    

    这应该在新的浏览器标签页中打开应用程序,在 https://localhost:6400

  3. 按照图 13.8 所示安装应用程序。

    这应该在原生操作系统窗口中打开应用程序,你应该看到以下内容:

    ![img/B18469_13_14.png]

    图 13.14:ng-pwa-dark-mode 应用程序在 http://localhost:6400 上运行

  4. 现在,确保你的机器上启用了 暗黑 主题。如果你正在运行 Mac OS X,你可以打开 设置 | 通用 并选择 暗黑 外观,如图 13.15 所示:Figure 13.14 – 在 macOS X 中更改系统外观为暗黑模式

    图 13.15:在 Mac OS X 中将系统外观更改为暗黑模式

如果你现在运行应用程序,你应该能够看到应用程序看起来与图 13.4中显示的相同。然而,对于暗色模式,我们不应该有白色背景等。

现在我们已经将 PWA 作为原生应用程序运行,并且将暗色模式应用于系统,让我们在下一节中查看食谱的步骤。

如何做到这一点...

如你所见,我们的 Angular 应用程序目前还没有完全支持暗色模式。也就是说,UI 仍然太亮,看起来与亮色模式下的完全一样。我们将从以开发模式提供服务应用程序并添加暗色模式的不同颜色开始。让我们开始吧:

  1. 通过运行以下命令以开发模式提供服务应用程序:

    npm run serve ng-pwa-dark-mode 
    

    这应该在新的浏览器标签页中在http://localhost:4200上为应用程序提供服务。

  2. 现在,打开styles.scss文件以使用prefers-color-scheme媒体查询。我们将为我们的全局 CSS 变量使用不同的值来为暗色模式创建不同的视图。更新文件如下:

     @media (prefers-color-scheme: dark) {
      html, body {
        background-color: rgb(30 41 59);
      }
    } 
    

    如果你再次在浏览器标签页中刷新应用程序,你会看到背景已更改,但文本仍然是暗色且不易看清,如图图 13.16所示:

    图 13.16:暗色文本和暗色背景用于暗色模式

    为了修复样式,我们需要为必要的元素分别提供暗色模式的样式。

  3. 由于我们正在使用 Tailwind CSS,我们将使用 Tailwind 将暗色模式应用于输入标签和输入元素的方式。更新src/app/game/game.component.html如下:

    <app-game-stepper [linear]="true">
    <cdk-step...>
        ...
        <form ...>
    <div ...>
    <label for="nameInput" class="form-label **dark:text-white**">
      Player Name
      </label>
    <input
     type="text"
     formControlName="name"
     class="form-control dark:!text-white dark:placeholder:!text-           white"
     id="nameInput"
     placeholder="Enter your name"
            />
            ...
    </app-game-stepper> 
    

    注意,对于输入,我们在dark:!text-white语句中使用感叹号。这是为了标记text-white Tailwind CSS 类为重要,从而使得相关的 CSS 样式具有!重要声明。

  4. 现在让我们将game-stepper.component.html文件更改为更新步骤标题的样式,如下所示:

    <div class="game-stepper">
    <header>
    <h3 class="text-2xl **dark:text-white**" *ngIf="selected">
          ...
        </h3>
    </header>
    ... 
    

    如果你刷新应用程序,你应该能够看到标题为白色,因为我们正在设备上使用暗色模式,如下所示:

    图 13.17:暗色模式中标题和输入颜色已修复

  5. 现在,让我们修复投掷屏幕上的编号卡片。你可以看到,在暗色模式下,卡片的背景不可见,如图图 13.18所示:

    图 13.18:卡片背景在暗色模式下不可见

  6. 让我们使用 Chrome DevTools 来模拟暗色和亮色模式,因为它提供了这样做的一种非常好的方式。打开 Chrome DevTools,然后打开命令菜单。在 macOS X 上,按键是Cmd + Shift + P。在 Windows 上,它们是Ctrl + Shift + P。然后输入Render并选择显示渲染选项,正如我们在图 13.19中看到的那样:

    图 13.19:使用显示渲染选项打开渲染视图

  7. 现在,在渲染选项卡中,切换prefers-color-scheme模拟亮色和暗色模式,如图图 13.20所示:

    图 13.20:模拟 prefers-color-scheme 模式

  8. 现在让我们更新编号卡的背景。由于这些样式是从 .scss 文件中应用的,我们将使用 Tailwind CSS 的另一种技术来在 .scss 文件中应用样式。按照以下方式更新 value-guesser.component.scss:

    ...
    .values {
      ...
      &__value-box {
        width: 5rem;
        height: 5rem;
        color: #fff;
        **@apply****dark****:bg-slate-****500****;**
        ...
      }
    } 
    
  9. 如果你现在查看掷骰子视图,你应该会看到更改后的颜色,如图 图 13.21 所示:

    图 13.21:暗黑模式下的卡片背景

  10. 现在测试两种模式使用 Chrome 开发者工具。

  11. 通过打开它并从 更多 菜单中选择 卸载 选项来卸载现有的 PWA,如图 图 13.12 所示。当提示时,请确保勾选表示 也从 Chrome 中清除数据 (...) 的复选框。

  12. 运行以下命令以在浏览器中提供生产应用,然后导航到 http://localhost:6400:

    npm run serve:static chapter13 ng-pwa-dark-mode 6400 
    

    当它在浏览器中打开时,你可能需要清除你的缓存。你可以在 Windows 上按 Ctrl + Shift + R,在 Mac OS X 上按 Cmd + Shift + R 来进行强制刷新。

  13. 等待几秒钟,直到地址栏中出现 安装 按钮。然后像 图 13.8 中所示那样安装 PWA。

  14. 一旦你现在运行了 PWA,你应该会看到暗黑模式视图,如图 图 13.22 所示,如果你的系统外观设置为暗黑模式:

    图 13.22:我们的 PWA 支持开箱即用的暗黑模式

太棒了!如果你将系统外观从暗黑模式切换到亮模式或反之亦然,你应该会看到 PWA 反映适当的颜色。你现在知道三种实现暗黑模式样式的方法,包括原生 CSS 方法和 Tailwind CSS 方法。现在你知道如何在你的 PWA 中支持暗黑模式,请参考下一节以查看进一步阅读的链接。

参见

在你的 PWA 中提供自定义的可安装体验

我们知道 PWA 是可安装的。这意味着它们可以像原生应用一样安装到你的设备上。然而,当你第一次在浏览器中打开应用时,它完全取决于浏览器如何显示 安装 选项;这因浏览器而异。它也可能不明显或难以辨认。此外,你可能想在应用的一些特殊点显示 安装 提示,而不是在应用启动时,考虑在应用内的一个特定、用户友好的点显示安装提示,而不是直接在应用启动时,因为这可能会被一些用户视为烦人。幸运的是,我们有一种方法为我们的 PWA 提供自定义的对话框/提示来选择安装选项。这正是我们将在这道菜谱中学习的。

准备工作

我们将要工作的应用位于克隆的仓库中的 start/apps/chapter13/ng-pwa-cust-installation 目录下:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到克隆的代码仓库文件夹,并运行以下命令(从工作区的根目录)以在生产模式下运行项目:

    npm run serve:static chapter13 ng-pwa-cust-installation 7000 
    

    这应该在新的浏览器标签页中打开应用程序,地址为https://localhost:7000

  3. 按照图 13.8 所示安装应用程序。

    这应该在原生 OS 窗口中打开应用程序,你应该会看到以下内容:

    图 13.23:ng-pwa-cust-installation 在 http://localhost:7000 上运行

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

如何做到这一点...

我们有一个 Dice Guesser 应用程序,在这个应用程序中你可以掷骰子并猜测输出。对于这个食谱,我们将阻止默认的安装提示,并且只有在用户做出了正确的猜测时才显示它。让我们开始吧:

  1. 首先,创建一个服务,将在下一步显示我们的自定义可安装提示。在项目根目录中运行以下命令:

    cd start && nx g service services/installable-prompt --project ng-pwa-cust-installation 
    

    当被要求时,使用@schematics/angular:service脚手架。

  2. 接下来,打开创建的文件installable-prompt.service.ts,并按以下方式更新代码:

    import { Injectable } from '@angular/core';
    
    declare global {
      interface WindowEventMap {
        beforeinstallprompt: BeforeInstallPromptEvent;
      }
    }
    @Injectable({
      providedIn: 'root'
    })
    export class InstallablePromptService {
      installPromptEvent!: BeforeInstallPromptEvent;
      constructor() {
        window.addEventListener('beforeinstallprompt',
          this.handleInstallPrompt.bind(this))
      }
    
      handleInstallPrompt(ev: BeforeInstallPromptEvent) {
        ev.preventDefault();
        this.installPromptEvent = ev;
        console.log('before install prompt event fired', ev);
        window.removeEventListener('beforeinstallprompt',
          this.handleInstallPrompt)
      }
    } 
    

    你会注意到 TypeScript 对我们的代码并不满意。

  3. 在这一步,我们需要定义BeforeInstallPrompt事件类型。让我们在src文件夹内创建一个名为types的文件夹。创建一个名为installation-prompt.d.ts的文件,并将以下代码添加到其中:

    interface BeforeInstallPromptEvent extends Event {
      readonly platforms: string[];
      readonly userChoice: Promise<{
        outcome: "accepted" | "dismissed";
        platform: string;
      }>;
      prompt(): Promise<void>;
    } 
    
  4. 我们必须在tsconfig.json中添加types文件夹,以便我们可以加载类型。更新tsconfig.json文件如下:

    {
    "compilerOptions": {
        ...,
    "noFallthroughCasesInSwitch": true,
    **"typeRoots"****:****[**
    **"./src/types"**
    **]**
    },
      ...
    } 
    
  5. 接下来,让我们构建我们将向用户显示以触发安装提示的自定义安装横幅。更新app.component.html并在<main>标签之后添加以下代码,如下所示:

    ...
    <main class="content" role="main">
    <router-outlet></router-outlet>
    </main>
    **<****div****id****=****"installPrompt"****class****=****"fixed inset-x-0 bottom-10**
    **p-4"****>**
    **<****div****class****=****"rounded-lg bg-purple-600 px-4 py-3 text-**
    **white shadow-lg"****>**
    **<****p****class****=****"text-center text-sm font-medium"****>**
    **Love the Dice Game?**
    `**>**` ****Install it!**
    **</****a****>**
    **</****p****>**
    **</****div****>**
    **</****div****>**** 
    

*** 现在再次使用以下命令运行应用程序以查看安装提示,如图 13.24 所示:

```js
npm run serve:static chapter13 ng-pwa-cust-installation 7000 
```

![](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/ng-cb-2e/img/B18469_13_24.png)

图 13.24:在 PWA 中显示的自定义安装提示

有时你会因为在同一端口上运行多个 PWA 而看到应用程序的缓存版本,例如。在这种情况下,打开 Chrome 开发者工具,转到**应用程序**标签,从左侧菜单中选择**服务工作者**选项,并检查**绕过网络**选项。

**1. 我们现在将在InstallablePromptService类内部创建一个signal,根据这个signal我们将显示或隐藏自定义安装横幅。更新installable-prompt.service.ts文件如下:

```js
import { Injectable, **signal** } from '@angular/core';
...
export class InstallablePromptService {
  installPromptEvent!: BeforeInstallPromptEvent;
  **isPromptBannerShown =** **signal****(****false****);**
constructor() {
    ...
  }
} 
```
  1. 让我们在应用程序组件中导入InstallationPromptService类,以便我们可以在自定义安装横幅的模板中使用它。更新app.component.ts文件如下:

    ...
    import { Component, **inject** } from '@angular/core';
    ...
    **import** **{** **InstallablePromptService** **}** **from****'./services/installable-prompt.service'****;**
    
    ...
    export class AppComponent {
      **promptService =** **inject****(****InstallablePromptService****);**
    } 
    
  2. 现在更新模板以使用应用程序组件中注入的InstallablePromptServicesignal。更新app.component.html文件如下:

    ...
    
    <main class="content" role="main">
    <router-outlet></router-outlet>
    </main>
     @if(promptService.isPromptBannerShown()) {
      <div id="installPrompt" class="fixed inset-x-0
    bottom-10 p-4">
        ...
      </div>
    } 
    

    通过运行npm run serve:static chapter13 ng-pwa-cust-installation 7000来重新启动服务器,你会注意到自定义安装横幅不再显示。这是因为现在当用户正确猜测分数时,我们必须在InstallationPromptService类中翻转signal

  3. 通过打开它并从更多菜单中选择卸载选项来卸载现有的 PWA,如图图 13.12所示。当提示时,请确保勾选表示也从 Chrome 中清除数据(...)的复选框。

  4. 更新src/app/game/game.component.ts文件,以翻转signal,如下所示:

    import { InstallablePromptService } from '../services/installable-prompt.service';
    ...
    export class GameComponent {
      ...
      leaderboardService = inject(LeaderboardService);
      promptService = inject(InstallablePromptService);
      ...
    
      showResult(diceSide: IDiceSide) {
        ...
        if (!this.isCorrectGuess) {
          return;
        }
        if (this.promptService.installPromptEvent) {
          this.promptService.isPromptBannerShown.set(true);
        }
        ...
      }
    } 
    

    如果你现在再次运行应用程序,如步骤 6所示,猜测骰子的点数,并且你猜对了,你应该能看到自定义安装横幅,如图图 13.25所示:

    图 13.25:猜测后显示的自定义安装横幅

  5. 现在我们将实现当从自定义安装横幅中点击安装它!链接时会发生什么。在installable-prompt.service.ts文件中创建一个新方法,如下所示:

    ...
    export class InstallablePromptService {
      ...
    
      handleInstallPrompt(ev: BeforeInstallPromptEvent) {...}
    
      **async****showInstallPrompt****() {**
    **if** **(!****this****.****installPromptEvent****) {**
    **return****;**
    **}**
    **await****this****.****installPromptEvent****.****prompt****();**
    **const** **{ outcome } =**
    **await****this****.****installPromptEvent****.****userChoice****;**
    **console****.****log****(****'The choice of user is '****, outcome);**
    **this****.****isPromptBannerShown****.****set****(****false****);**
    **}**
    } 
    
  6. 我们现在将从自定义安装横幅调用此方法以显示浏览器的安装提示。更新app.component.html文件,如下所示:

    ...
    <main class="content" role="main">
    <router-outlet></router-outlet>
    </main>
    <div id="installPrompt" *ngIf=
     "promptService.isPromptBannerShown()"
    class="fixed inset-x-0 bottom-4 p-4">
    <div class="rounded-lg bg-purple-600 
    px-4 py-3 text-white shadow-lg">
    <p class="text-center text-sm font-medium">
          Love the Dice Game?
          <a **(****click****)=****"promptService.showInstallPrompt()"**
     href="#" class="inline-block underline">
            Install it!
          </a>
    </p>
    </div>
    </div> 
    

    现在,如果你在浏览器中重新运行应用程序并猜测骰子的下一个值,你可以点击安装它!链接来查看浏览器提示,如图图 13.26所示:

    图 13.26:浏览器安装提示正在显示

  7. 最后,我们不想在应用程序作为 PWA 运行时显示自定义安装横幅。而现在它确实如此。为了解决这个问题,我们将在app.component.scss中添加一些样式,如下所示:

    @media all and (display-mode: standalone) {
      #installPrompt {
        display: none !important;
      }
    } 
    

    如果你现在安装 PWA 并正确猜测骰子的下一个值,你将不再看到自定义安装横幅。

太棒了!你现在可以通过安装和卸载 PWA 几次来玩转应用程序,并尝试用户选择安装或不安装应用程序的所有组合。这全是乐趣和游戏。现在你了解了如何为 Angular PWA 实现自定义安装横幅,接下来继续下一节了解它是如何工作的。

它是如何工作的…

这个食谱的核心是beforeinstallprompt事件。这是一个标准浏览器事件,在 Chrome、Firefox、Safari、Opera、UC 浏览器(Android 版本)和三星 Internet 的最新版本中都有支持,也就是说,几乎所有的主流浏览器都支持这个事件。该事件有一个prompt方法,它会在设备上显示浏览器的默认提示。在食谱中,我们创建InstallablePromptService并将事件存储在一个名为installPromptEvent的属性中。这样我们就可以在用户猜对了骰子滚动值后按需使用它。请注意,一旦我们收到beforeinstallprompt事件,我们就从window对象中移除事件监听器,所以我们只保存一次事件。那就是应用开始的时候。如果用户选择不安装应用,我们不会在同一个会话中再次显示提示。然而,如果用户刷新应用,他们仍然会在第一次正确猜测时收到一次提示。我们可以更进一步,将这个状态保存在localStorage中,以避免页面刷新后再次显示提示,但这不是本食谱的一部分。

对于自定义安装横幅,我们使用基于 Tailwind CSS 的模板。请注意,在自定义安装横幅上,我们有一个链接。当我们点击这个链接时,InstallablePromptService类的showInstallPrompt方法会被调用。在这个方法中,我们使用事件,即this.installPromptEvent属性的prompt方法来显示浏览器的提示。请注意,在我们显示浏览器的提示后,我们将信号isPromptBannerShown的值设置为false,这样就会隐藏自定义安装横幅。这也是为了确保在用户刷新页面之前,不会在同一个会话中再次显示提示。

最后,如果应用以 PWA 的形式启动,我们也会使用一些 CSS 来完全不显示自定义安装横幅。这很重要,因为如果它已经是 PWA,显示安装提示就没有意义了。因此,我们使用@media查询和display-mode: standalone,来检查应用是否作为 PWA 运行。在这个 CSS 规则中,我们隐藏自定义安装横幅。

现在你已经了解了所有的工作原理,请参考下一节以查看进一步阅读的链接。

参考信息

使用 Angular service worker 预缓存请求

在我们之前的食谱中添加了 service workers 之后,我们已经看到它们已经缓存了资源,并在离线模式下通过 service worker 提供服务。但是网络请求怎么办?如果用户离线并立即刷新应用,网络请求会失败,因为它们没有被 service worker 缓存。这导致离线用户体验中断。在这个食谱中,我们将配置 service worker 来预缓存网络请求,这样应用在离线模式下也能流畅运行。

准备工作

我们将要工作的应用位于克隆的仓库中的 start/apps/chapter13/ng-pwa-precaching 目录内:

  1. 在你的代码编辑器中打开代码仓库。

  2. 打开终端,导航到克隆的代码仓库文件夹,并运行以下命令(从工作区的根目录)以在生产模式下提供项目:

    npm run serve:static chapter13 ng-pwa-precaching 7600 
    

    这应该在新的浏览器标签页中打开 https://localhost:7600

  3. 刷新页面一次。

  4. 现在,切换到离线模式,如图 图 13.2 所示。如果你转到 网络 选项卡并使用查询结果过滤请求,你应该看到请求失败,如图 图 13.27 所示:

    图 13.27:由于未缓存网络请求而导致的离线体验中断

现在我们看到网络请求失败,让我们在下一节中查看修复此问题的步骤。

如何操作...

如你在 图 13.27 中所见,应用仍然可以加载。我们可以看到页眉和加载器。然而,API(fetch)调用不工作,因为我们正在使用 Chrome DevTools 模拟离线网络情况。这是因为服务工作者尚未配置为缓存数据请求。让我们开始修复此问题的配方:

  1. 要缓存网络请求,打开 start/apps/chapter13/ng-pwa-precaching 目录内的 ngsw-config.json 文件,并按以下方式更新它:

    {
    "$schema": "../../../node_modules/@angular/service-worker/config/schema.json",
    "index": "/index.html",
    "assetGroups": [
        ...
      ],
    **"dataGroups"****:****[**
    **{**
    **"name"****:****"swapi.dev"****,**
    **"urls"****:****[**
    **"https://swapi.dev/api/*"**
    **],**
    **"****cacheConfig"****:****{**
    **"strategy"****:****"freshness"****,**
    **"maxSize"****:****100****,**
    **"****maxAge"****:****"2d"**
    **}**
    **}**
    **]**
    } 
    
  2. 让我们现在测试这个应用。使用以下命令在生产模式下构建和运行应用:

    npm run serve:static chapter13 ng-pwa-precaching 7600 
    
  3. 现在,导航到 http://localhost:7600。确保网络限制没有启用,也就是说,你不在离线模式。

  4. 使用 Chrome DevTools 的 应用 选项卡 | 存储 面板清除应用数据。或者按 Cmd + Shift + R(macOS X)或 Ctrl + Shift + R(Windows)进行强制刷新。

  5. 再次刷新应用,让服务工作者缓存 API 请求。

  6. 在 Chrome DevTools 中,转到 网络 选项卡并切换到离线模式,如图 图 13.2 所示。再次刷新应用。即使离线,你也应该看到 Swapi 的数据。网络调用由服务工作者提供,如图 图 13.28 所示:

    图 13.28:服务工作者正在缓存网络调用

哇!你刚刚学会了如何在 Angular 应用中配置服务工作者以缓存网络/数据请求。即使离线,你也可以安装 PWA 并使用它。太棒了,对吧?

现在我们已经完成了配方,让我们在下一节中看看这一切是如何工作的。

它是如何工作的...

本食谱的核心是ngsw-config.json文件。当使用ng add @angular/pwa命令运行@angular/pwa脚手架时,该文件被@angular/service-worker包用于生成服务工作者文件。当我们使用@angular/pwa脚手架时,该文件默认包含一个 JSON 对象。这个 JSON 对象包含一个名为assetGroups的属性,它根据提供的配置来配置资源的缓存。对于这个食谱,我们希望缓存网络请求以及资源。因此,我们在 JSON 对象中添加了新的属性dataGroups。让我们看看配置:

 "dataGroups": [
{
"name": "swapi.dev",
"urls": [
"https://swapi.dev/api/*"
],
"cacheConfig": {
"strategy": "freshness",
"maxSize": 100,
"maxAge": "2d"
}
}
] 

如你所见,dataGroups是一个数组。我们可以向其中提供不同的配置对象作为元素。每个配置都有一个name,一个urls数组,以及一个定义缓存策略的cacheConfig。在我们的配置中,我们使用了一个通配符与 API URL,即我们使用urls: ["https://swapi.dev/api/*"]。对于cacheConfig,我们使用的是freshness策略,这意味着应用将始终首先从其源获取数据。如果网络不可用,则它将使用服务工作者缓存的响应。另一种策略是performance,它首先在服务工作者中查找缓存的响应。如果对于 URL(或 URLs)没有缓存,则从实际源获取数据。maxSize属性定义了可以缓存多少个请求以相同的模式(或 URL 集合)。maxAge属性定义了缓存数据在服务工作者缓存中存活的时间。

现在你已经知道了这个食谱的工作原理,请查看下一节以获取进一步阅读的链接。

相关内容

为你的 PWA 创建 App Shell

当涉及到为 Web 应用构建快速的用户体验时,一个主要挑战是尽量减少关键渲染路径。这包括加载目标页面的最关键资源,解析,执行 JavaScript 等。使用 App Shell,我们可以在构建时而不是运行时渲染一个页面或应用的一部分。这意味着用户最初将看到最小的预渲染内容,直到 JavaScript 和 Angular 介入。这意味着浏览器不需要工作并等待一段时间以进行第一次有意义的绘制。这不仅提供了良好的用户体验,还有助于将网站在搜索引擎中的排名提高,即实现更好的 SEO。在这个食谱中,你将为 Angular PWA 创建一个 App Shell。

准备工作

我们将要工作的应用程序位于克隆的仓库中的start/apps/chapter13/ng-pwa-app-shell目录下:

  1. 在您的代码编辑器中打开代码仓库。

  2. 打开终端,导航到克隆的代码仓库文件夹,然后从工作区根目录运行以下命令以在生产模式下运行项目:

    npm run serve ng-pwa-app-shell 
    

    这应该在新的浏览器标签页中打开应用,并在http://localhost:4200显示以下内容:

    图 13.29:ng-pwa-app-shell 在 http://localhost:4200 上运行

    现在,我们将禁用 JavaScript 来模拟解析 JavaScript 所花费的大量时间,或者模拟尚未安装 App Shell。现在打开 Chrome DevTools。然后打开命令面板。在 Mac OS X 上打开命令面板的快捷键是 Cmd + Shift + P,在 Windows 上是 Ctrl + Shift + P。输入Disable JavaScript,选择选项,然后按 Enter。在 Chrome DevTools 仍然打开的情况下刷新页面,你应该会看到以下消息:

    图 13.30:应用中没有 App Shell

现在我们已经检查了 App Shell 的不存在,让我们在下一节中查看食谱的步骤。

如何做到这一点……

我们有一个从 API 获取一些用户的 Angular 应用程序。我们将为这个应用创建一个 App Shell,以便它作为 PWA 提供更快的首次有意义的绘制。让我们开始吧:

  1. 首先,通过从项目根目录运行以下命令为应用创建 App Shell:

    cd start && nx g app-shell --project=ng-pwa-app-shell 
    

    这将为我们的项目添加一些新文件,但也会更新project.json。由于我们为食谱使用独特的文件夹结构,您需要通过将所有dist/ng-pwa-app-shell实例替换为dist/apps/chapter13/ng-pwa-app-shell来更新project.json文件。

  2. 更新src/app/app-shell/app-shell.component.ts文件以导入UsersComponent类,这样我们就可以在 App Shell 中渲染users页面。代码应如下所示:

    ...
    **import** **{** **UsersComponent** **}** **from****'../users/users.component'****;**
    @Component({
      selector: 'app-app-shell',
      standalone: true,
      imports: [CommonModule, **UsersComponent**],
      templateUrl: './app-shell.component.html',
      styleUrls: ['./app-shell.component.css'],
    })
    ... 
    
  3. 现在打开app-shell.component.html文件,并使用<app-users>元素来在 App Shell 中渲染整个UsersComponent。代码应如下所示:

    <app-users></app-users> 
    
  4. 最后,更新users.component.ts文件,在 App Shell 正在生成时显示骨架加载器。文件更新如下:

    import { Component, inject, OnInit, **PLATFORM_ID** } from '@angular/core';
    import { CommonModule, **isPlatformBrowser** } from '@angular/common';
    ...
    export class UsersComponent implements OnInit {
     ...
      **platformId =** **inject****(****PLATFORM_ID****);**
      isSearching = true;
      ngOnInit() {
        this.componentAlive = true;
       ...
        this.searchForm.controls['username'].valueChanges
          .pipe(
            startWith(''),
            tap(() => {
              this.isSearching = true;
            }),
            takeWhile(() => !!this.componentAlive **&&** 
    **isPlatformBrowser****(****this****.****platformId**)),
            mergeMap((query) => this.userService
              .searchUsers(query))
          )
          .subscribe((users) => {
            this.users = users;
            this.isSearching = false;
          });
      }
    } 
    
  5. 现在我们已经为 App Shell 编写了代码,让我们创建它。从工作区根目录运行以下命令以在production模式下生成 App Shell:

    cd start && nx run ng-pwa-app-shell:app-shell:production 
    
  6. 一旦在步骤 5中生成了 App Shell,从工作区根目录(而不是从start文件夹)运行以下命令,使用http-server包来运行它:

    npx http-server ./start/dist/apps/chapter13/ng-pwa-app-shell/browser -o -p 4200 
    
  7. 确保应用中的 JavaScript 仍然处于关闭状态。如果不是,请打开 Chrome DevTools 并按 Mac OS X 的 Cmd + Shift + P 或 Windows 的 Ctrl + Shift + P 打开命令面板。然后输入Disable Javascript并按 Enter,选择如图 13.31 所示的选项Figure 13.27 – 使用 Chrome DevTools 禁用 JavaScript

    图 13.31:使用 Chrome DevTools 禁用 JavaScript

  8. 在禁用 JavaScript 的同时刷新应用。现在你应该看到应用仍然显示带有骨架加载器的预渲染用户页面,尽管 JavaScript 已被禁用,如图 图 13.32 所示。哇哦!

    图 13.32:显示预渲染用户页面的 App Shell

  9. 为了验证我们在构建时预渲染了 用户 页面,请检查 <workspace-root>/start/dist/apps/chapter13/ng-pwa-app-shell/browser/index.html 中的生成代码。你应该在 <body> 标签内看到整个渲染的页面。

  10. 使用 App Shell 创建生产构建,并通过运行以下命令在端口 1020 上提供服务:

    cd start && nx run ng-pwa-app-shell:app-shell:production
    cd ..
    npx http-server ./start/dist/apps/chapter13/ng-pwa-app-shell/browser -o -p 1020 
    
  11. 在你的浏览器中导航到 http://localhost:1020 并将应用作为 PWA 安装,如图 图 13.8 所示。完成后,运行 PWA,它应该看起来如下所示:

    图 13.33:ng-pwa-app-shell 作为 PWA 运行

太棒了!你现在知道如何为你的 Angular PWAs 创建 App Shell。现在你已经完成了这个配方,请查看下一节了解它是如何工作的。

它是如何工作的…

这个配方从禁用我们应用程序的 JavaScript 开始。这意味着当应用运行时,我们只显示静态 HTML 和 CSS,因为没有 JavaScript 执行。我们看到了关于 JavaScript 不受支持的提示,如图 图 13.30 所示。代码来自 src/index.html 文件,其中 <body> 元素内部有如下代码:

<noscript>Please enable JavaScript to continue using this application.</noscript> 

然后运行命令 nx g app-shell--project=ng-pwa-app-shell。由于我们处于一个 NX 工作区,该命令需要正确的项目。如果这是一个常规的 Angular 项目,你只需运行 ng generate app-shell,它就会为你创建 App Shell。在任何情况下,该命令都会为我们执行以下操作:

  • 创建一个名为 AppShellComponent 的新组件并生成其相关文件。

  • 在项目中安装 @angular/platform-server 包。

  • 添加一些新文件,即 main.server.ts,以启用服务器端渲染(确切地说,为我们的 App Shell 进行构建时渲染)。

  • 最重要的是,它更新了 project.json(对于不在 NX 工作区的 Angular 项目是 angular.json)文件,添加了一组用于服务器端渲染以及生成 app-shell 的脚图。请注意,它覆盖了 build 对象(特别是 outputPath 属性),以删除 apps 文件夹和章节名称。我们不想这样,因为我们希望将包生成在 dist/apps/chapter13/<project-name>,这就是为什么我们在配方中手动更新了它。请注意,它还添加了一个新的 "server" 对象用于 SSR,以及一个配置了生成 App Shell 的 "app-shell" 对象。

在配方中,我们创建 App Shell,然后我们将 UsersComponent 类导入到 AppShellComponent 类的 imports 数组中。由于 AppShellComponent 是一个 standalone 组件(不是任何 NgModule 的部分),UsersComponent 也是如此,因此我们需要通过 AppShellComponent 中的 imports 数组将它们链接在一起。在导入 UsersComponent 类之后,我们更新 app-shell.component.html(App Shell 模板)以使用 <app-users> 选择器,这反映了 UsersComponent 类。这就是整个 用户 页面。最后,我们确保只有在浏览器正在创建时(以及对于 SSR 也是如此)才渲染骨架元素。因此,我们在 users.component.ts 文件中使用 PLATFORM_ID 令牌。令牌的值包含我们应用程序正在运行的平台的名称。当 App Shell 正在生成时,它是服务器端环境,PLATFORM_ID 的值为 "server"。我们不是将其与字符串 "server" 进行比较,即使用条件 this.platformId !== "server",而是使用 @angular/common 包中的 isPlatformBrowser 函数,仅在平台是 browser 时获取数据(在生成 App Shell 时不是这种情况)。

我们随后在 步骤 4步骤 5 中验证 App Shell。这些命令分别生成带有 App Shell 的 Angular 生产模式构建(非最小化代码),并在端口 4200 上提供项目。请注意,start/dist/apps/chapter13/ng-pwa-app-shell 文件夹中的 ng-pwa-app-shell 文件夹有两个文件夹,即 browser 文件夹和 server 文件夹,我们的 index.html 位于 browser 文件夹中。index.html 中的代码在构建时预渲染。这意味着 Angular 在构建时打开应用程序并渲染 UsersComponent,包括搜索输入和骨架加载器。因此,一旦应用程序打开,内容就被预渲染。完成所有这些步骤后,我们安装 PWA 以进行测试。

现在您已经知道了配方的工作原理,请查看下一节以获取进一步阅读的链接。

参见

在 Discord 上了解更多信息

要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:

packt.link/AngularCookbook2e

**

posted @ 2025-09-05 09:24  绝不原创的飞龙  阅读(21)  评论(0)    收藏  举报