RxJS-和-Angular-Signal-响应式模式-全-

RxJS 和 Angular Signal 响应式模式(全)

原文:zh.annas-archive.org/md5/6b9451a708fdf5156dec14c45492969b

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

从命令式编程转向响应式编程是一次重大的转变,我亲身体验了这一点。在导航这个转变的过程中,我发现自己被响应式模式的世界和它们所拥有的变革力量所吸引。这是一段充满发现、比较和强烈决心去理解这种新思维方式旅程。

受我自身经历的启发,我编写了这本书,旨在作为 Angular 应用中响应式模式领域的指南。我相信,通过将响应式方式与命令式方式进行比较,可以逐渐实现响应式思维,以区分差异和优势。在这本书的页面上,你会发现如何拥抱响应式模式可以极大地提高你管理数据、编写代码和响应用户变化的方式。从提高效率到创建更干净、更易于管理的代码库,这些好处是广泛且实用的。

因此,无需多言,让我们共同踏上这段旅程,解锁响应式编程的潜力。

本书面向对象

如果你是一位使用 Angular 和 RxJS 的开发者,这本书是为你量身定制的。本书旨在为 Angular 和 RxJS 的初学者提供指导,帮助你成为经验丰富的开发者,同时也有利于那些希望利用 RxJS 的潜力并在 Angular 应用中利用响应式范式的人。

本书涵盖内容

第一章《深入响应式范式》中,你将学习响应式编程的基础知识。

第二章《漫步我们的应用》中,你将学习我们将通过本书构建的食谱应用架构和需求。

第三章《作为流获取数据》中,你将学习获取数据的响应式模式,以便我们可以在食谱应用中响应式地检索食谱列表。

第四章《响应式处理错误》中,你将学习不同的错误策略和响应式模式来处理错误。

第五章《合并流》中,你将学习合并流的响应式模式,并使用它在我们食谱应用中实现过滤功能,同时了解常见的陷阱并分享最佳实践以实现最佳实施。

第六章《转换流》中,你将学习转换流的响应式模式,并使用它在我们食谱应用中实现自动保存和自动完成功能。

第七章《在 Angular 组件间共享数据》中,你将学习在组件间共享数据的响应式模式,并使用它在我们食谱应用中共享选定的食谱。

第八章使用 Angular 信号掌握响应性,你将深入了解 Angular 信号,学习基于 Angular 信号的不同响应式模式,以及如何释放 RxJS 和信号结合的力量。你还将发现最新的 Angular 信号改进。

第九章揭秘多播,你将学习多播的基本知识以及 RxJS 提供的不同多播概念和运算符,例如 Subjects、Behavior Subjects 和 Replay Subjects。

第十章使用响应式缓存提升性能,你将学习如何使用响应式模式缓存流,并在我们的食谱应用中根据最新的 RxJS 功能实现缓存机制。

第十一章执行批量操作,你将学习如何使用响应式模式执行批量操作,并在我们的食谱应用中实现多个异步文件上传。

第十二章处理实时更新,你将探索响应式模式以消费实时更新,并在我们的食谱应用中立即显示新创建的食谱。

第十三章测试 RxJS 可观察对象,你将学习不同的测试响应式模式的方法,并练习在我们的食谱应用中测试 API 响应。

要充分利用本书

本书假设你对 Angular、基本的 RxJS、TypeScript 和函数式编程的基础知识有所了解。所有代码示例都已使用 Angular 17 和 18 在 Windows 操作系统上测试。然而,它们也应该适用于未来的版本发布。

本书涵盖的软件/硬件 操作系统要求
Angular 17 及以上 Windows, macOS, 或 Linux
TypeScript 5.4.2 Windows, macOS, 或 Linux
RxJS 7.8.1 Windows, macOS, 或 Linux
PrimeNG 17.10.0 Windows, macOS, 或 Linux
Bootstrap 5.0.0 Windows, macOS, 或 Linux

确保你遵循此处找到的先决条件:angular.dev/tools/cli/setup-local。先决条件包括环境设置以及安装和使用 Angular 所需的技术。

我们还使用 Bootstrap 库来管理应用程序的响应性,PrimeNG 库用于其丰富的组件,当然,还有 RxJS 作为响应式库。

此外,GitHub 仓库中还有一个现成的后端服务器,我们将在我们的应用程序中仅对其进行引用。

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

下载示例代码文件

您可以从 GitHub(github.com/PacktPublishing/Reactive-Patterns-with-RxJS-and-Angular-Signals-Second-Edition)下载此书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。

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

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“在下面的代码片段中,我们有一个 Angular 服务注入HttpClient服务并使用HttpClient.get()方法从服务器获取数据的示例。”

代码块设置如下:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable} from 'rxjs';

任何命令行输入或输出都如下所示:

//console output
Full Name: John Doe

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“用户可以通过点击页面右上角的新食谱菜单项来创建一个新的食谱。”

小贴士或重要注意事项

看起来是这样的。

联系我们

欢迎读者反馈。

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

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

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

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

分享您的想法

一旦您阅读了《使用 RxJS 和 Angular Signals 的响应式模式》,我们很乐意听到您的想法!请点击此处直接访问此书的亚马逊评论页面并分享您的反馈。

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

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但无法随身携带您的印刷书籍吗?

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

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

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

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

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

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

download.packt.com/free-ebook/9781835087701

  1. 提交您的购买证明

  2. 就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的邮箱。

第一部分:反应式世界的介绍

与 Angular 一起踏上反应式编程世界的旅程!

在这部分,您将了解反应式范式的根本原理及其在 Angular 中的应用,深入了解为什么利用这种方法是至关重要的。然后,我们将介绍我们在阅读本书的过程中将逐步构建的食谱应用程序。

本部分包括以下章节:

  • 第一章深入反应式范式

  • 第二章漫步我们的应用程序

第一章:深入了解响应式范式

响应式模式是使用响应式编程对常见问题进行可重用解决方案。在这些模式背后是一种新的思维方式,新的架构,新的编码风格和新的工具。这正是整本书的基础——在 Angular 应用程序中有用的响应式模式。

现在,我知道您迫不及待地想写出您在 Angular 中的第一个响应式模式,但在这样做之前,为了帮助您充分利用所有 RxJS 模式和利用响应式范式,我们将首先详细解释所有基础知识,并为后续章节打下基础。

让我们从对响应式范式的初步理解开始,了解其优势以及它解决的问题。最好的是,让我们培养一种响应式思维,开始以响应式的方式思考。我们将首先强调响应式范式的支柱和优势。然后,我们将解释水珠图及其用途。最后,我们将突出 RxJS 在 Angular 中的应用。

对响应式范式的基本原理进行深入了解至关重要。这将确保您掌握基础知识,帮助您理解响应式方法的有用性,并因此帮助您确定在哪种情况下最好使用它。

在本章中,我们将涵盖以下主题:

  • 探索响应式编程的支柱

  • 学习水珠图(我们的秘密武器)

  • 突出 RxJS 在 Angular 中的应用

技术要求

本章不需要任何环境设置或安装步骤。

本章中的所有代码片段只是为了说明概念,所以您不需要代码仓库来跟随。然而,如果您感兴趣,本书的代码可以在github.com/PacktPublishing/Reactive-Patterns-with-RxJS-and-Angular-Signals-Second-Edition找到。

本书假设您对 Angular 和 RxJS 有基本的了解。

备注

本书使用新的 Angular 文档网站angular.dev。之前的文档网站angular.io将很快被弃用。通过此链接访问文档,以保持与最新更新和资源的联系。

探索响应式编程的支柱

响应式编程是全球开发者使用的重大编程范式之一。每种编程范式都解决了一些问题,并有其自身的优势。根据定义,响应式编程是使用异步数据流的编程,基于观察者模式。那么,让我们来谈谈响应式编程的这些支柱吧!

数据流

数据流是反应式编程的脊柱。所有可能在时间上发生变化或发生的事情(你不知道确切的时间)都表示为异步流,如事件、通知和消息。反应式编程是关于在变化发生时立即做出反应!

数据流的一个很好的例子是 UI 事件。假设我们有一个 HTML 按钮,我们希望在用户点击它时执行一个动作。在这里,我们可以将点击事件视为一个流:

//HTML code
<button id='save'>Save</button>
//JS code
const saveElement = document.getElementById('save');
saveElement.addEventListener('click', processClick);
function processClick(event) {
  console.log('Hi');
}

如前代码片段所示,为了对点击事件做出反应,我们注册了一个EventListener事件。然后,每次点击发生时,都会调用processClick方法来执行副作用。在我们的例子中,我们只是在控制台记录Hi

如你所可能收集到的,为了能够对发生的事情做出反应并执行副作用,你应该监听流以获得通知。为了更接近反应式术语,我们可以说观察而不是监听。这使我们来到了观察者设计模式,它是反应式编程的核心。

观察者模式

观察者模式基于两个主要角色——发布者和订阅者:

  • 发布者维护一个订阅者列表,并在每次更新时通知他们或传播一个变化

  • 另一方面,订阅者每次从发布者收到通知时都会执行更新或执行副作用

观察者模式在此处展示:

图 1.1 – 观察者模式

图 1.1 – 观察者模式

为了获得更新通知,你需要订阅发布者。一个现实世界的类比就是时事通讯;如果你不订阅它,你不会从特定的时事通讯中收到任何电子邮件。

这使我们来到了 RxJS 的构建块,包括以下内容:

  • 可观察对象:这些是异步数据流的表示,通知观察者任何变化

  • 观察者:这些是消费由可观察对象发出的数据流的消费者

RxJS 将观察者模式与迭代器模式和函数式编程相结合,以处理和异步事件。这是对反应式编程基础的一个提醒,知道何时实现反应式实现以及何时避免它至关重要。

通常,当你在 Angular 应用程序中处理异步任务时,总是要想到 RxJS。RxJS 相对于其他异步 API 的主要优势如下:

  • RxJS 使得处理基于事件的程序、异步数据调用和回调变得容易。

  • 可观察对象保证了一致性。它们在一段时间内发出多个值,以便你可以消费连续的数据流。

  • 可观察对象是懒加载的;它们在你订阅它们之前不会执行。这有助于编写清晰、高效、易于理解和维护的声明式代码。

  • Observables 可以在任何时候取消、完成和检索。这在许多实际场景中非常有意义。

  • RxJS 提供了许多具有函数式风格的操作符来操作集合和优化副作用。

  • Observables 将错误推送到订阅者,并提供了一种处理错误的干净方式。

  • RxJS 允许你编写干净且高效的代码来处理应用中的异步数据。

现在我们已经对反应式编程的支柱进行了介绍,并详细阐述了 RxJS 的主要优势,让我们来探索弹珠图,这对于理解和可视化 Observable 执行非常有用。

了解弹珠图(我们的秘密武器)

RxJS 随带超过一百个 操作符 – 这些是 RxJS 的构建块之一,用于操作流。本书后面将详细介绍的所有的反应式模式都是基于操作符的,当涉及到解释操作符时,最好参考视觉表示 – 这就是弹珠图的作用所在!

弹珠图是操作符执行的视觉表示,本书的所有章节都将使用它来理解 RxJS 操作符的行为。一开始可能会觉得有些令人畏惧,但实际上它非常简单。你只需理解图标的解剖结构,然后你就能很好地阅读和翻译它。

弹珠图表示操作符的执行,因此每个图都将包括以下内容:

  • 输入 Observable(s):表示作为操作符输入的一个或多个 Observable

  • 操作符:表示要执行的操作符及其参数

  • 输出 Observable:表示操作符执行后产生的 Observable

我们可以在此看到执行过程的示例:

图 1.2 – 操作符执行

图 1.2 – 操作符执行

现在,让我们放大输入/输出 Observable 的表示:

图 1.3 – 弹珠图元素

图 1.3 – 弹珠图元素

这些图标的元素包括以下内容:

  • 时间线:Observables 是异步流,在一段时间内产生数据。因此,在弹珠图中,时间的表示至关重要,它被表示为从左到右流动的箭头。

  • 弹珠值:这些是 Observable 在一段时间内发出的值。它们由彩色圆圈表示。

  • 完成状态:垂直线(|)表示 Observable 成功完成。

  • 错误状态X 代表由 Observable 发出的错误。之后既不会发出值,也不会发出表示完成的垂直线。

这就是你需要了解的所有元素。现在,让我们将这些部分组合到一个真实的弹珠图中:

图 1.4 – 自定义操作符的弹珠图示例

图 1.4 – 自定义操作符的弹珠图示例

如你所猜,我们有一个自定义操作符叫做 divideByTwo,它会发出接收到的每个数字的一半。当输入的可观察对象发出值 48 时,输出的可观察对象分别产生 24

然而,当发出非数字的 R 值时,则会抛出错误,表示异常终止。这种情况在操作符代码中没有处理。输入的可观察对象继续发射,然后成功完成。然而,该值永远不会被处理,因为在错误之后,流被关闭了。

到目前为止,我们已经走过了构成水滴图的全部元素。你将能够理解接下来章节中使用的操作符。现在,让我们来了解一下 RxJS 在 Angular 中的使用。

突出 RxJS 在 Angular 中的使用

在 Angular 中,RxJS 几乎是一个一等公民。它是 Angular 生态系统的一部分,并被用于许多功能来处理异步任务。以下是一些这些功能的例子:

  • HttpClient 模块

  • 路由模块

  • 响应式表单

  • 事件发射器

我们将在接下来的小节中讨论以下每个概念。

注意

我们建议快速浏览一下 angular.dev/overview,在那里你可以找到关于上述功能的更多详细信息。

HttpClient 模块

你可能熟悉 Angular 提供的 HttpClient API,该 API 用于通过 HTTP 协议与服务器通信。HttpClient 服务基于可观察对象来管理所有事务,这意味着调用 API 方法(如 GETPATCHPOSTPUT)的结果将是一个可观察对象。

在下面的代码片段中,我们有一个 Angular 服务的例子,它注入了 HttpClient 服务,并使用 HttpClient.get() 方法从服务器获取数据:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable} from 'rxjs';
import { Recipe } from '../model/recipe.model';
@Injectable()
export class RecipesService {
constructor(private http: HttpClient) { }
getRecipes(): Observable<Recipe[]> {
return this.http.get<Recipe[]>(`api/recipes/`);
}
}

getRecipes() 方法 – 或者更准确地说,对 this.http.get<Recipe>(`api/recipes/`) 的调用 – 返回一个可观察对象,你应该订阅它以向服务器发送 GET 请求。请注意,这是一个 HTTP 事务的例子,并且对于 API 中可用的所有其他 HTTP 方法(POSTPUTPATCH 等)都是相同的。

注意

代码中包含对 recipe.modelgetRecipes() 的引用 – 在 第二章**,漫步我们的应用程序 中,你将介绍我们将在本书的其余部分工作的 Recipe 应用程序。

对于熟悉基于 Promise 的 HTTP API 的人来说,你可能想知道在这个上下文中使用可观测量的优势。对于那些不熟悉 Promise 的人来说,Promise是表示异步操作最终完成(或失败)及其结果的 JavaScript 对象。它们提供了一种比传统基于回调的方法更干净、更有结构化的方式来处理异步代码。然而,使用可观测量而不是 Promise 有很多优势,其中最重要的如下:

  • 可观测量是可以取消的,因此你可以通过调用取消订阅方法在任何时候取消 HTTP 请求

  • 当发生错误或抛出异常时,你也可以重试 HTTP 请求

可观测量不能修改服务器的响应,尽管在 Promise 上链式调用then()可能会出现这种情况。

路由模块

@angular/router包中可用的路由模块在路由事件和激活路由中使用可观测量。我们在这里将查看这两个方面。

路由事件

路由事件允许你拦截导航生命周期。它们在路由器中定义为可观测量。

注意

我们建议快速查看angular.dev/api/router/Event,在那里你可以找到有关路由事件的更多详细信息。

大多数 Angular 应用程序都有一个路由机制。路由事件随时间频繁变化,因此监听这些变化以执行副作用是有意义的。这就是为什么可观测量是处理这些流的一种灵活方式。

要拦截路由器经过的所有事件,首先,你应该注入Router服务,它提供 URL 操作能力。然后,订阅Router对象中可用的事件可观测量,并使用 RxJS 过滤操作符过滤出RouterEvent类型的事件。

以下是一个 Angular 服务的示例,它在构造函数中注入路由器,订阅路由事件,并在控制台中跟踪事件 ID 和路径:

import { Injectable } from '@angular/core';
import { Router, RouterEvent } from '@angular/router';
import { filter } from 'rxjs/operators';
@Injectable()
export class CustomRouteService {
  constructor(public router: Router) {
    this.router.events.pipe(
      filter(event => event instanceof RouterEvent)
    ).subscribe((event: RouterEvent) => {
      console.log(`The current event is : ${event.id} |
        event.url`);
    });
  }
}

这是一个非常基础的示例,你几乎可以引入任何特定的行为。

激活路由

ActivatedRoute是一个可以将路由器注入到你的组件中以检索有关路由路径和参数信息的路由服务。许多属性基于可观测量。在这里,你可以找到ActivatedRoute类的实现:

class ActivatedRoute {
  snapshot: ActivatedRouteSnapshot
  url: Observable<UrlSegment[]>
  params: Observable<Params>
  queryParams: Observable<Params>
  fragment: Observable<string | null>
  data: Observable<Data>
  outlet: string
  component: Type<any> | string | null
  routeConfig: Route | null
  root: ActivatedRoute
  parent: ActivatedRoute | null
  firstChild: ActivatedRoute | null
  children: ActivatedRoute[]
  pathFromRoot: ActivatedRoute[]
  paramMap: Observable<ParamMap>
  queryParamMap: Observable<ParamMap>
  toString(): string
}

如你所想,urlparamsqueryParamsfragmentdataparamMapqueryParamMap都表示为可观测量。所有这些参数可能会随时间变化,因此监听这些变化以注册副作用或更新值是有意义的。

以下是一个 Angular 组件的示例,它在构造函数中注入ActivatedRoute类,然后在ngOnInit()方法中订阅以下属性:

  • ActivatedRouteurl属性,以便在控制台记录当前 URL

  • ActivatedRoutequeryParams 属性,用于检索 criteria 参数并将其存储在名为 criteria 的本地属性中:

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
@Component({
  selector: 'app-recipes',
  templateUrl: './recipes.component.html'
})
export class RecipesComponent implements OnInit {
  criteria: string;
  constructor(private activatedRoute: ActivatedRoute) { }
  ngOnInit() {
    this.activatedRoute.url
      .subscribe(url => console.log('The URL changed to: '
        + url));
    this.activatedRoute.queryParams.subscribe(params => {
      this.processCriteria(params.criteria);
    });
  }
  processCriteria(criteria: string) {
    this.criteria = criteria;
  }
}

本例展示了 urlqueryParams 属性的使用。为了全面了解所有 ActivatedRoute 属性及其功能,我鼓励您访问 Angular 文档页面 angular.dev/api/router/ActivatedRoute#properties

反应式表单

@angular/forms 包下可用的反应式表单基于 Observables 来跟踪表单控件的变化。以下是 Angular 中 FormControl 类的概述:

class FormControl extends AbstractControl {
//other properties here
valueChanges: Observable<any>
statusChanges: Observable<any>
}

FormControlvalueChangesstatusChanges 属性表示为触发更改事件的 Observables。订阅 FormControl 的值变化是触发 component 类中应用逻辑的一种方式。

以下是一个示例,它订阅了名为 ratingFormControl 属性的 valueChanges Observable,并简单地通过 console.log(value) 跟踪值:

import { Component, OnInit } from '@angular/core';
import { FormGroup } from '@angular/forms';
@Component({ ...})
export class MyComponent implements OnInit {
  form!: FormGroup;
  ngOnInit() {
    const ratingControl = this.form.get('rating');
    ratingControl?.valueChanges.subscribe(
      (value) => {
        console.log(value);
      }
    );
  }
}

这样,您将获得作为输出的更改值。

事件发射器

事件发射器,它是 @angular/core 包的一部分,用于通过 @Output() 装饰器从子组件向父组件发射数据。EventEmitter 类扩展了 RxJS subject 并为该实例发射的事件注册了处理程序:

class EventEmitter<T> extends Subject {
  constructor(isAsync?: boolean): EventEmitter<T>
  emit(value?: T): void
  subscribe(next?: (value: T) => void, error?: (error: any)
    => void, complete?: () => void): Subscription
}

当你创建事件发射器并发射一个值时,幕后发生的事情就是这样。

以下代码块是一个示例,展示了如何发射食谱评分的更新值:

import { Component, Output } from '@angular/core';
import { EventEmitter } from 'events';
@Component({ ...})
export class RecipesComponent {
  constructor() {}
  @Output() updateRating = new EventEmitter();
  updateRecipe(value: string) {
    this.updateRating.emit(value);
  }
}

因此,EventEmitter 通过允许一个组件发射自定义事件,另一个组件监听并响应这些事件,从而平滑了组件之间的通信。这种机制在实现父子组件通信、兄弟组件通信,甚至在 Angular 应用程序中实现无关组件之间的通信中起着至关重要的作用。

注意

在之前的代码片段中,为了演示目的,显式地订阅了 Observables。在现实世界的例子中,如果我们想显式地订阅,我们应该包括取消订阅的逻辑。我们将在 第三章作为流获取数据 中详细说明。

摘要

在本章中,我们向您介绍了反应式编程的基础以及它在哪些用例中表现出色。然后,我们解释了将作为我们解释所有后续章节中 RxJS 操作符参考的宝石图。最后,我们通过具体示例、实现和优势,突出了在 Angular 中使用反应式编程。

现在我们已经掌握了基础知识,是时候开始准备和解释了,在下一章中,我们将构建整个本书中的应用程序,我们将逐步实现我们将学习的所有反应式模式。

第二章:漫步我们的应用

现在,我们离深入了解响应式模式又近了一步,但在我们这样做之前,让我们展示我们将在这本书中构建的应用。

我们将首先解释技术要求,然后分析应用的用户界面,以便您了解其用户故事。此外,我们将展示应用架构的概述和组件树的视觉表示。到本章结束时,我们将准备好所有必需的组件以开始实现我们的应用。

在本章中,我们将涵盖以下主要主题:

  • 分析我们应用的用户界面

  • 审查我们的应用架构

  • 审查我们的应用组件

技术要求

虽然我们在这个章节中不会创建项目,但在继续之前,你应该了解其要求。

我们将使用Angular 18作为我们的前端,所以请确保您遵循angular.dev/tools/cli/setup-local中的先决条件。这些先决条件包括环境设置以及安装和使用 Angular 所需的技术。

我们还将使用Bootstrap版本 5.0.0([getbootstrap.com/](https://getbootstrap.com/)),这是一个用于开发响应式 Web 应用的工具包,以及RxJS的 7.8.1 版本。

您还将能够在本书的 GitHub 存储库中找到创建此项目的所有代码:https://github.com/PacktPublishing/Reactive-Patterns-with-RxJS-and-Angular-Signals-Second-Edition

分析我们应用的用户界面

作为一名美食爱好者,我希望这个应用像一本菜谱书,允许用户和家常厨师浏览和分享美味的食物菜谱。该应用的主要目标是提供用餐灵感,并帮助用户完成以下任务:

  • 分享他们的菜谱

  • 将收藏的菜谱固定以方便查找

  • 区分高分菜谱

  • 根据某些标准过滤菜谱

应用由六个界面组成。让我们逐一解决这些界面。

视图一 – 登录页面

第一页包含按受欢迎程度排序的可用菜谱列表:

图 2.1 – 登录页面视图

图 2.1 – 登录页面视图

在此视图中,用户可以进行以下操作:

  • 根据某些标准设置过滤器,快速搜索菜谱(在左侧)

  • 清除过滤器列表

  • 查看最受欢迎的菜谱

  • 通过点击星级数量来评分菜谱

  • 通过点击心形图标将菜谱添加到收藏夹

  • 通过点击查看菜谱的详细信息

  • 查看菜谱的总数

视图二 – 新菜谱界面

此页面包含一个创建新菜谱的表单:

图 2.2 – 新菜谱视图

图 2.2 – 新菜谱视图

在此视图中,用户可以通过点击页面右上角的新建食谱菜单项来创建新的食谱。将打开一个包含食谱详细信息的表单,以便填写信息并保存。以下是一些详细信息:

  • 标题:食谱的标题

  • 成分:准备食谱所需的成分

  • 图片 URL:准备好的美食的好图片

  • 烹饪时间:烹饪食物所需的时间

  • 产量:此餐可以服务的人数

  • 准备时间:准备食物所需的时间

  • 标签:描述食谱的关键标签

  • 步骤:准备和烹饪食物所需的步骤

视图三 – 我的食谱界面

此页面包含用户创建的食谱列表。此屏幕可通过点击位于右上角的我的食谱菜单项访问。用户可以通过点击编辑和删除图标来编辑和删除食谱:

图 2.3 – 我的食谱视图

图 2.3 – 我的食谱视图

视图四 – 我的收藏界面

此页面包含用户喜欢的食谱列表,可通过点击位于右上角的我的收藏菜单项访问:

图 2.4 – 我的收藏视图

图 2.4 – 我的收藏视图

视图五 – 修改食谱界面

新建食谱界面允许用户创建新的食谱时,修改食谱界面允许用户编辑现有的食谱。此页面可通过点击我的食谱界面旁边的编辑按钮访问,看起来就像图 2.2

视图六 – 食谱详情界面

此页面包含所选食谱的所有详细信息。此屏幕可通过在着陆页上点击显示的食谱访问:

图 2.5 – 食谱详情视图

图 2.5 – 食谱详情视图

现在我们已经详细说明了我们应用的用户界面,让我们看看应用的架构。

审查我们应用的架构

食谱应用的前端层将使用 Angular 18 实现,并将与基于 Node.js 的 RESTful 后端进行通信。

注意

与后端相关的内容不是本书的主题,也不会详细说明。您可以在 GitHub 仓库中找到一个名为recipes-book-api的现成假后端:github.com/PacktPublishing/Reactive-Patterns-with-RxJS-for-Angular-17-2nd-Edition

食谱应用的前端可插入任何 RESTful 后端。因此,您可以使用几乎任何其他技术作为后端。所有通信将通过 HttpClient 模块执行,并请求后端的 REST 控制器:

图 2.6 – 食谱架构书

图 2.6 – 食谱架构书

现在我们已经了解了目标应用的大致情况,让我们分解我们的应用的不同 Angular 组件。

检查我们的应用组件

一个 Angular 应用由我们创建的所有组件组成,具有树状结构。在下面的图中,你可以找到我们的食谱应用的组件树,这对于理解应用的结构非常重要:

图 2.7 – 组件概览

图 2.7 – 组件概览

让我们分解这些组件:

  • AppComponent: 应用的父组件

    HeaderComponent: 代表应用头部,包含用户空间、菜单和标志

  • HomeComponent: 代表包含RecipeFilterComponentRecipesListComponent的着陆页的组件:

    • RecipeFilterComponent: 代表包含标准字段和RecipesListComponent的筛选区域的组件:RecipesListComponent: 包含食谱列表的组件
  • RecipesDetailsComponent: 包含一个食谱详细信息的组件

  • RecipesCreationComponent: 包含一个表单,用于创建包含所有必填字段的食谱

你现在对我们的应用将包含的组件有了更好的理解。

摘要

在本章中,我们解释了我们将要工作的食谱应用的功能,以及 UI 的外观和感觉。我们还阐明了应用架构以及构成应用的组件。

现在所有这些方面都清晰了,让我们一起来探索我们的第一个响应式模式,我们将在下一章中介绍。

第二部分:深入响应式模式

在本部分,你将学习在不同实际场景中最常用的响应式模式,例如从后端 API 获取数据、处理服务器错误、过滤数据以及在下拉列表中提供自动完成搜索结果。每个响应式模式都将通过涉及我们的食谱应用的示例来支持。

你还将学习最佳实践和需要避免的陷阱,并深入了解最新的 Angular 特性,例如独立组件和新的内置控制流语法。

本部分包括以下章节:

  • 第三章, 以流的形式获取数据

  • 第四章, 响应式处理错误

  • 第五章, 合并流

  • 第六章, 转换流

  • 第七章, 在 Angular 组件之间共享数据

第三章:将数据作为流获取

您管理应用程序数据的方式对 UI 性能和用户体验有巨大影响。在我看来,如今,出色的用户体验和性能 UI 不再是可选的——它们是用户满意度的重要决定因素。此外,高效地管理数据可以优化代码并提高其质量,从而降低维护和改进成本。

那么,我们如何有效地管理我们的数据呢?这正是我们将在以下章节中回答的问题。有几个响应式模式在许多用例中都很有用,我们将从探索用于显示从 REST 端点接收到的最基本响应式模式开始,以便用户可以阅读和与之交互。

首先,我们将解释在食谱应用程序中将要实现的需求。然后,我们将介绍用于检索数据的经典模式,接着介绍您可以使用来管理取消订阅及其周围所有重要技术概念的多种方法。我们还将了解 Angular 14+的新特性——独立组件。在此之后,我们将解释用于获取数据的响应式模式,并强调响应式模式相对于经典模式的优点。最后,我们将了解 Angular 17 中引入的新内置控制流。

因此,在本章中,我们将涵盖以下主要主题:

  • 定义数据获取需求

  • 探索用于获取数据的经典模式

  • 探索用于获取数据的响应式模式

  • 突出响应式模式的优点

  • 深入了解 Angular 17 中的内置控制流

技术要求

本章假设您已经对 HttpClient、Angular 组件、Angular 模块和路由有基本的了解。

我们将使用使用 JSON Server 构建的模拟 REST API 后端,它允许您启动一个具有完整工作 API 的 REST API 服务器。我们不会学习如何使用 JSON Server,但如果您有兴趣了解更多信息,您可以在github.com/typicode/json-server找到更多信息。

您可以在 GitHub 仓库中找到本章的项目源代码,网址为github.com/PacktPublishing/Reactive-Patterns-with-RxJS-and-Angular-Signals-Second-Edition/tree/main/Chap03

该项目由两个文件夹组成:

  • recipes-book-api:这包含已设置好的模拟 RESTful 服务器。

  • recipes-book-front:这包含使用 Angular 17 和 RxJS 7 构建的前端应用程序。作为第三方依赖项,我们添加了bootstrapprimeng库,以帮助我们快速构建美观的 UI 组件。请参阅上一章的技术要求部分以获取环境和依赖项设置。

该项目符合标准的 Angular 风格指南,可在 angular.dev/style-guide# 找到。

第一次运行应用程序时,您需要先安装依赖项。您只需在 recipes-book-apirecipes-book-front 文件夹中运行 npm i 命令。

依赖项安装完成后,您需要在 recipes-book-api 文件夹中运行以下命令来启动服务器:

npm run server: start

服务器将在 http://localhost:8081 上运行。

然后,在 recipes-book-front 文件夹中运行以下命令来启动前端:

ng serve --proxy-config proxy.config.json

您可以在 angular.dev/tools/cli/serve#proxying-to-a-backend-server 阅读有关 --proxy-config 参数的更多信息。

定义数据获取需求

首先,让我们定义我们将以响应式方式实现的要求。我们希望在主页上显示从模拟后端检索到的菜谱列表,逐步构建在 第二章视图一 – 登录页面 部分中详细说明的用户故事:

图 3.1 – 登录页面视图

图 3.1 – 登录页面视图

要做到这一点,我们需要事先获取菜谱列表以将其作为卡片显示给用户,对吧?因此,菜谱列表代表我们首先需要请求的第一份数据,它可以通过以下端点在我们的 recipes-book-api 服务器上找到:

GET /api/recipes

请不要忘记按照 技术要求 部分中的详细说明启动服务器。一旦服务器启动,您可以在 http://localhost:8081/api/recipes 检查获取 API 的结果。

在以下章节中,我们将了解如何以经典和响应式风格实现获取数据的需求,以便理解它们之间的基本差异,并看到响应式编程相对于命令式编程带来的好处。

探索获取数据的经典模式

让我们先看看获取菜谱列表的经典模式的实现。

定义您数据结构

首先,我们需要定义我们数据的结构,以便我们可以对其进行强类型化。这将使我们能够利用 TypeScript 的类型检查功能,并在早期捕获类型错误。

我们可以使用 Angular CLI 生成 src/app/ core/model 文件夹:

$ ng g i Recipe

为了遵循惯例,我们将生成的文件名从 recipe.ts 更改为 recipe.model.ts。然后,我们将使用 Recipe 的具体属性填充接口,如下所示:

export interface Recipe {
id: number;
title: string;
ingredients: string;
tags?: string;
imageUrl: string;
cookingTime?: number;
prepTime?: number;
yield: number;
steps?: string;
rating:number;
}

我们将逐个输入我们将要使用的菜谱的属性,然后是每个属性的类型。每个属性的描述在 第二章视图二 – 新菜谱界面 部分中详细说明,漫步 我们的应用程序

对于可选属性,我们在声明接口时在属性类型注释之前放置一个问号(?),以告诉 TypeScript 该属性是可选的。

创建获取数据服务

下一步是创建一个名为RecipesService的 Angular 服务,该服务将负责管理所有与食谱相关的操作。该服务将封装创建、读取、更新和删除CRUD)操作,并将它们提供给各种 UI 组件。在本章中,我们只实现读取(获取)操作。

那么,我们为什么要创建一个服务呢?嗯,我们这样做是为了增加模块化并确保服务在其他组件中的可重用性。

要在core/services文件夹下生成服务,我们可以在core/services文件夹下执行ng g s命令,如下所示:

$ ng g s Recipes

现在服务已成功生成,让我们创建并实现一个负责获取数据的函数。我们将在RecipesService中注入HttpClient并定义一个获取数据的方法。该服务将如下所示:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Recipe } from '../model/recipe.model';
import { environment } from 'src/environments/environment';
const BASE_PATH = environment.basePath
@Injectable({
providedIn: 'root'
})
export class RecipesService {
constructor(private http: HttpClient) { }
getRecipes(): Observable<Recipe[]> {
return this.http.get<Recipe[]>(`${BASE_PATH}/recipes`);
}
}

让我们分析一下RecipesService级别的操作。这并不复杂——我们有一个getRecipes()方法,它通过 HTTP 获取食谱列表并返回一个强类型 HTTP 响应:Observable<Recipe[]>。这个 Observable 代表了当你发出 HTTP GET 请求时将创建的数据流。当你订阅它时,它将作为 JSON 数组发出食谱列表,然后完成。因此,这个 HTTP 请求所代表的流将在发出响应数据后完成。

作为最佳实践,我们在environment.ts文件中外部化了BASE_PATH,因为在许多情况下,服务器的基路径取决于环境(如测试或生产)。这样,在单个位置更新路径比在所有使用它的服务中更新路径要容易得多。

注意

从 Angular 15 开始,环境文件不再默认提供。然而,你可以选择在需要时通过执行$ ng g environments来生成它们。

我们还在构造函数中注入了HttpClient依赖项,如下所示:

constructor(private http: HttpClient) { }

这种技术被称为构造函数注入。Angular 内置的依赖注入系统将在创建组件或服务实例时自动提供注入的依赖项。

此外,从版本 14 开始,Angular 的依赖注入系统提供了inject()实用函数。这允许你手动解决和检索组件或服务中的依赖项,如下所示:

private http= inject(HttpClient);

当你需要动态解决依赖项或根据运行时条件执行条件依赖注入时,这种方法很有用。

我们将在整本书中使用构造函数注入技术。但是,如果你希望采用更新的方法,你有灵活性这样做。

创建 Angular 独立组件

现在,我们应该在 src/app/recipe-list 下创建负责显示食谱列表的组件,名为 RecipesListComponent。在此之前,让我们先停下来,解释一下在 Angular 14 中引入的一种非常有趣的新类型组件:独立组件。

根据定义,NgModule 可以被其他独立组件或基于模块的组件使用。

在 Angular 14 之前,我们只有一种创建组件的方法:

  $ ng g c recipesList

此命令将创建一个名为 RecipesListComponent 的组件并将其添加到 NgModule 中。那么是哪个模块呢?如果您在命令行中指定了 --module,后跟您的模块路径,那么 CLI 将将组件添加到该特定模块中。如果没有设置 --module 选项,CLI 将检查同一目录中是否有模块;如果没有,它将在最近的父目录中检查。如果这两种选项都不适用,它将在组件相同的目录中生成一个新的模块文件,并在该新模块中声明组件。

简而言之,CLI 总是将组件关联到模块,并将其添加到模块的声明数组中;否则,您将遇到编译错误。

然而,从 Angular 14 开始,您可以通过在命令行中提及 --standalone 标志来决定创建不属于任何 NgModule 的独立组件:

  $ ng g c recipesList --standalone

在我们的项目中使用此方法,RecipesListComponent 不会被添加到 NgModule 中,并且将在 @Component 装饰器内部以及 imports 属性中包含 standalone: true 标志:

@Component({
  selector: 'app-recipes-list',
  standalone: true,
  imports: [],
  templateUrl: './recipes-list.component.html',
  styleUrls: ['./recipes-list.component.scss'],
})

如果独立组件依赖于其他组件,无论是基于模块的还是独立的,您应该在 imports 数组中提及这些组件;否则,您将遇到编译错误。

独立组件也可以被基于模块的组件或其他独立组件使用。此外,它们可以在加载路由和懒加载时使用。还值得注意的是,您还可以创建独立的指令和独立的管道。

到目前为止,一切顺利!现在,为什么你应该关心呢?有几个很好的理由我们应该在我们的项目中采用独立组件:

  • 代码更少意味着更少的样板代码要编写,因此构建时间更快,代码组织、测试和可维护性也更好。

  • 由于独立组件的依赖关系直接在 imports 属性中提及,因此更容易理解组件的依赖关系。对于基于模块的组件,您将不得不扫描您的组件代码,然后检查该模块的所有组件共享的模块依赖关系。

  • 独立组件的力量在于它们的隔离和自包含特性。您只需导入组件所需的内容,而基于模块的组件有时会导入同一模块中其他组件使用的无用依赖项。

    假设我们有一个模块“M”,它导入了“A”、“B”和“C”组件以及“S1”、“S2”和“S3”服务,并且我们有一个不属于该模块但依赖于组件“B”的“D”组件。由于“B”是一个基于模块的组件,因此“D”应该导入整个模块“M”;这会导致不必要的依赖,因为“D”不需要组件“A”和“B”或服务“S1”和“S2”。因此,集成独立组件使我们能够仅导入所需的组件和服务,因为独立组件是自包含的,并且有自己的依赖关系和逻辑集合。因此,它消除了冗余代码,导致应用程序更加优化。

  • 这使得初学 Angular 开发者的学习曲线不那么陡峭。

我们将在我们的食谱应用中使用独立组件,以采用模块化和自包含的方法。我们只保留应用程序组件作为基于模块的组件,尽管我们可以使用独立组件启动应用程序。以下是一个表示我们的组件依赖关系的方案:

图 3.2 – 食谱应用组件的依赖关系

图 3.2 – 食谱应用组件的依赖关系

父组件 AppComponent 是一个基于模块的组件,它在 AppModule 的导入声明中导入了 HeaderComponent 独立组件。HeaderComponent 使用一些 PrimeNG 外部依赖,因此需要在组件的导入声明中导入。

HomeComponent 是一个独立组件,它将通过 AppComponent 进行路由。HomeComponent 在组件的导入声明中导入了 RecipesListComponent 独立组件。后者使用一些 PrimeNG 外部依赖,因此需要在组件的导入声明中导入。所有代码都可在 GitHub 仓库中找到。

注意

关于独立组件的更多信息,你可以查看 angular.dev/reference/migrations/standalone

希望独立组件的概念已经清楚,那么让我们继续下一步。

在你的组件中注入和订阅服务

在本节中,我们将 RecipesService 服务注入到 RecipesListComponent 组件中,并在 ngOnInit()(组件初始化时)调用 getRecipes() 方法。我们还将对 API 服务器执行读取操作。

为了获取发出的数据,我们需要订阅 getRecipes() 方法返回的 Observable。然后,我们将数据绑定到我们在组件中创建的本地数组属性,称为 recipes。组件的代码将如下所示:

import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { Recipe } from '../core/model/recipe';
import { RecipesService } from '../core/services/recipes.
Service';
@Component({
selector: 'app-recipes-list',
standalone: true,
imports: [CommonModule],
templateUrl: './recipes-list.component.html',
styleUrls: ['./recipes-list.component.scss']
})
export class RecipesListComponent implements OnInit {
recipes!: Recipe[];
constructor(private service: RecipesService) { }
ngOnInit(): void {
this.service.getRecipes().subscribe(result => {
this.recipes = result;
});
}
}

现在我们已经检索了数据并将其存储在本地属性中,让我们看看我们如何在 UI 中显示它。

在模板中显示数据

现在我们可以使用组件中可用的recipes属性在我们的 HTML 模板中显示食谱列表。在我们的案例中,我们使用DataView PrimeNG 组件以网格布局显示食谱列表作为卡片(有关此组件的更多详细信息,请参阅primeng.org/dataview)。

当然,我们的目标是关注模板代码之外的数据操作。正如您在下面的示例中可以看到的,我们将recipes数组传递给了数据视图组件的value输入(如果您不想包含第三方依赖项,您也可以使用结构化指令以纯 HTML 渲染数据视图组件):

<div class="card">
<p-dataView #dv [value]="recipes" [paginator]="true"
[rows]="9"    filterBy="name" layout="grid">
/** Extra code here **/
</p-dataView>
</div>

这是收集数据的基本模式,您在开始学习 Angular 时就已经发现了,所以您可能之前已经见过类似的东西。

现在只剩下一点——您应该处理 Observable 的取消订阅,因为这段代码手动管理订阅。否则,组件被销毁后,Observable的订阅将保持活跃状态,内存引用将不会被释放,导致内存泄漏。这就是为什么您在 Angular 组件内部手动订阅 Observable 时应该始终小心。

注意

虽然在服务器请求响应或超时后HttpClient的 Observable 会自动取消订阅,但我们仍将演示如何处理它们的取消订阅以确保我们的实现并展示最佳实践。这还将作为处理其他 Observable 取消订阅的展示。

管理取消订阅

管理取消订阅有两种常用的方法:强制性模式和声明性响应模式。让我们详细看看这两种模式。

强制性取消订阅管理

强制性取消订阅意味着我们手动调用我们自行管理的订阅对象的unsubscribe()方法。以下代码片段说明了这一点:

export class RecipesListComponent implements OnInit,
OnDestroy {
  recipes!: Recipe[];
  subscription: Subscription;
  constructor(private service: RecipesService) { }
ngOnInit(): void {
  this.subscription=this.service.getRecipes()
  .subscribe(result => {
    this.recipes = result;
});
}
ngOnDestroy(): void {
  this.subscription?.unsubscribe();
}

在这里,我们只是将订阅存储在一个名为subscription的变量中,并在ngOnDestroy()生命周期钩子中取消订阅。

这可以正常工作,但并不是一个推荐的模式。有一个更好的方法,利用 RxJS 的力量。

声明性取消订阅管理

第二种取消订阅的方法更为简洁且声明性更强,使用了 RxJS 的takeUntil操作符。然而,在我们深入探讨这个模式之前,让我们通过以下宝石图来了解takeUntil的作用:

图 3.3 – takeUntil 宝石图

图 3.3 – takeUntil 宝石图

takeUntil()操作符从源可观察对象(第一个时间轴)发出值,直到输入的可观察对象通知器(第二个时间轴)发出一个值。在那个时刻,takeUntil()将停止发出值并完成。在油管图中,源可观察对象发出了abcd的值——所以takeUntil()将分别发出它们。之后,可观察对象通知器发出z,然后takeUntil()将停止发出值并完成。

在我们的应用程序中,takeUntil操作符将帮助我们保持订阅在我们定义的期间活跃。我们希望它能在组件被销毁时保持活跃,所以我们将创建一个 RxJS 主题,当组件被销毁时它会发出一个值。然后,我们将这个主题传递给takeUntil作为输入:

export class RecipesListComponent implements OnInit,
OnDestroy {
  recipes!: Recipe[];
  destroy$ = new Subject<void>();
  constructor(private service: RecipesService) { }
ngOnInit(): void {
  this.service.getRecipes().pipe(
    takeUntil(this.destroy$)).
    subscribe(result => {
    this.recipes = result;
  });
}
ngOnDestroy(): void {
  this.destroy$.next();
  this.destroy$.complete();
}
}

注意

$符号是一个非正式约定,用来表示变量是一个可观察对象。

你可能首先注意到的是,这比第一种方法代码更少。此外,当我们对一个返回的订阅对象(第一种方式)调用unsubscribe()时,我们无法得到取消订阅发生的通知。然而,使用takeUntil(),我们将通过完成处理程序得到可观察对象完成的通告。

值得注意的是,这个实现可以通过使用 Angular 16 中引入的takeUntilDestroyed操作符进一步优化。这个操作符简化了 Angular 组件和指令中的可观察对象订阅管理。它会在关联的组件或指令被销毁时自动完成订阅,从而消除了在ngOnDestroy生命周期钩子中进行手动清理的需要。

你只需要按照以下方式从@angular/core/rxjs-interop包中导入takeUntilDestroyed操作符:

import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

然后,我们在订阅的管道操作符中使用这个操作符。使用takeUntilDestroyed之后,之前的代码将如下所示:

export class RecipesListComponent {
  recipes!: Recipe[];
  constructor(private service: RecipesService) {
    this.service.getRecipes().pipe(takeUntilDestroyed())
      .subscribe(result=>this.recipes = result);
  }
}

正如你所见,ngOnDestroy生命周期钩子中的手动清理代码以及destroy$主题已被移除,从而使得组件实现更加简洁易读。

takeUntilDestroyed()操作符将在RecipesListComponent被销毁时自动处理订阅清理。

除了takeUntiltakeUntilDestroyed操作符之外,还有其他操作符可以以更反应式的方式为你管理取消订阅。以下是一些示例:

  • take(X): 这会发出x个值然后完成(将不再发出值)。例如,take(3)将从给定的可观察对象中发出三个值然后完成。然而,请注意,如果你的网络速度慢,并且x次发出没有发生,那么你必须手动取消订阅。

  • first(): 这会发出第一个值然后完成。

  • last(): 这会发出最后一个值然后完成。

这是我们作为初学者都学过的经典模式,并且对于获取数据来说是一个相对有效的方法。总结一下,以下图表描述了我们走过的所有步骤:

图 3.4 – 经典模式工作流程

图 3.4 – 经典模式工作流程

然而,我们还可以使用另一种模式,它更加声明性和响应性,并且具有许多优点。我们将在下一部分发现它!

探索用于获取数据的响应式模式

这种响应式模式背后的想法是在整个应用程序中保持并使用可观察对象作为流。别担心——当你探索这一部分时,这会对你更加明显。让我们开始吧。

以流的形式检索数据

要开始使用响应式模式,我们不是定义一个方法来检索我们的数据,而是在我们的服务中声明一个变量:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Recipe } from '../model/recipe';
import { environment } from 'src/environments/environment';
const BASE_PATH = environment.basePath
@Injectable({
providedIn: 'root'
})
export class RecipesService {
recipes$ = this.http.get<Recipe[]>(
`${BASE_PATH}/recipes`);
constructor(private http: HttpClient) { }
}

在这里,我们声明recipes$变量作为 HTTP GET 的结果,它要么是一个可观察对象,要么是数据流。想象一下,随着时间的推移而改变的数据的每一部分都是一个流,并在单独的服务中将它声明为一个可观察对象。这将使它在整个应用程序中可访问,并给我们更多的灵活性,以便在应用程序的不同部分中操作它。

在你的组件中定义流

现在,在RecipesListComponent中,我们将做与经典模式相同的事情——那就是,声明一个变量来持有从我们的服务返回的流。然而,这次,变量是我们创建在RecipesService中的可观察对象:

import { Component, OnDestroy, OnInit } from '@angular/core';
import { RecipesService } from '../core/services/recipes.
Service';
@Component({
selector: 'app-recipes-list',
standalone: true,
imports: [CommonModule],
templateUrl: './recipes-list.component.html',
styleUrls: ['./recipes-list.component.css']
})
export class RecipesListComponent implements OnInit {
recipes$= this.service.recipes$;
constructor(private service: RecipesService) { }
}

但是等等!我们需要订阅以获取发出的数据,对吧?这是绝对正确的。让我们看看我们将如何做到这一点。

在你的模板中使用异步管道

对于这个模式,我们不会手动订阅,而是使用一种更好的方式,即异步管道。异步管道使得从可观察对象中渲染值变得更容易。

首先,它会自动订阅输入的可观察对象。然后,它返回最新的发出的值。最好的是,当组件被销毁时,它会自动取消订阅,以避免任何潜在的内存泄漏。这意味着当组件被销毁时,不需要手动清理任何订阅。这真是太棒了!

因此,在模板中,我们使用异步管道绑定到一个可观察对象。由于recipes描述了值被发出的数组变量,我们可以在模板中如下使用它:

<div *ngIf="recipes$ |async as recipes" class="card">
<p-dataView #dv [value]="recipes" [paginator]="true"
[rows]="9"    filterBy="name" layout="grid">
/** Extra code here **/
</p-dataView>
</div>

如你所注意到的,<div>元素包含一个*ngIf结构指令。这个指令根据recipes$ | async表达式的真值条件性地渲染其子元素。

recipes$ | async表达式订阅了recipes$可观察对象,并在可观察对象发出值时异步渲染<div>元素的子元素(在我们的例子中是DataView组件)。它还会在元素从DOM文档对象模型)中移除时取消订阅并清理订阅。

*ngIf 指令后面跟着 as recipes,这会将 Observable 发射的值赋给局部的 recipes 变量。这使得我们可以在 <div> 元素及其子元素的作用域内使用 recipes 变量来访问发射的值。

通过使用异步管道,我们不需要 ngOnInit 生命周期钩子,因为我们不会在 ngOnInit() 中订阅 Observable 通知器,也不会在 ngOnDestroy() 中取消订阅,就像我们在经典模式中所做的那样。相反,我们只需在我们的组件中设置一个本地属性,我们就准备好了——我们不需要自己处理订阅和取消订阅!

注意

HTML 模板的完整代码可在 GitHub 仓库中找到。

总结这种模式,以下图表描述了我们走过的所有步骤:

图 3.5 – 响应式模式工作流程

图 3.5 – 响应式模式工作流程

现在我们已经解释了响应式模式在实际中的应用,在下一节中,让我们回顾其优点。

突出显示响应式模式的优点

我想你可能已经猜到了响应式模式的第一大优点——我们不必手动管理订阅和取消订阅,多么令人欣慰——但还有很多其他的优点。让我们更详细地看看其他优点。

使用声明式方法

让我们来看看为什么我们不显式使用 subscribe() 方法。subscribe() 有什么问题?嗯,在组件内部订阅流意味着我们允许命令式代码泄漏到我们的函数式和响应式代码中。使用 RxJS Observables 并不能使我们的代码系统性地变得响应式和声明式。

但“声明式”究竟是什么意思呢?嗯,首先,我们将确定一些关键术语。然后,让我们从这里开始迭代:

  • 纯函数是一个函数,无论被调用多少次,对于相同的输入总是返回相同的输出。换句话说,该函数总是可预测地产生相同的输出。

  • 声明式指的是使用声明的函数来执行操作。你依赖于可以定义事件流的纯函数。在 RxJS 中,你可以以 Observables 和操作符的形式看到这一点。

那么,为什么你应该关心呢?好吧,你应该关心,因为使用 RxJS 操作符和 Observables 的声明式方法有很多优点,具体如下:

  • 它使你的代码更简洁、更易读。

  • 它使你的代码更容易测试,因为它具有可预测性。

  • 它使你能够根据一定的输入缓存流输出,这将提高性能。我们将在第七章 Angular 组件间共享数据第九章 揭秘多播第十章 使用响应式缓存提升性能中更详细地探讨这一点。

  • 它使你能够利用 RxJS 运算符,转换和组合来自不同服务或甚至同一服务中的流。这就是我们将在第五章“组合流”和第六章“转换流”中看到的内容。

  • 它可以帮助你轻松响应用户交互以执行操作。

因此,更声明式的意味着更具反应性。然而,请注意。这并不意味着你永远不能调用subscribe()方法。在某些情况下,触发Observable通知器是不可避免的。但试着问问自己:我真的需要在这里订阅吗?我能否通过组合多个流或使用 RxJS 运算符来实现所需的功能,而不需要订阅?除了不可避免的情况外,永远不要使用subscribe()

现在,让我们转向变更检测概念,看看它如何提高性能。

使用 OnPush 变更检测策略

另一件非常酷的事情是,我们可以使用changeDetection策略,OnPush

变更检测是 Angular 最强大的功能之一。它涉及检测组件数据何时发生变化,然后自动重新渲染视图或更新 DOM 以反映这种变化。默认策略“始终检查”意味着,每当任何数据被修改或更改时,Angular 都会运行变更检测器来更新 DOM。因此,直到明确停用,它是自动的。

OnPush策略中,Angular 只有在以下情况之一发生时才会运行变更检测器:

  • 条件 1:组件的@Input属性引用发生变化(请注意,当直接修改输入属性对象时,对象的引用不会改变,因此变更检测器不会运行。在这种情况下,我们应该返回属性对象的新引用以触发变更检测)。

  • 条件 2:组件事件处理程序被触发或发出。

  • 条件 3:通过异步管道绑定的 Observable 发出新值。

因此,使用ChangeDetectionOnPush策略可以最小化任何变更检测周期,并且只有在上述情况下才会检查更改以重新渲染我们的组件。此策略适用于所有子指令,并且不能被覆盖。

在我们的场景中,我们只需要在获取新值时运行变更检测器;否则,我们会得到无用的更新。因此,我们的场景符合条件 3。好消息是,我们可以使用变更检测的onPush策略,如下所示:

import { ChangeDetectionStrategy, Component} from
'@angular/ core';
@Component({
  selector: 'app-recipes-list',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './recipes-list.component.html',
  styleUrls: ['./recipes-list.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})

如果我们记得尽可能多地使用异步管道,我们将看到一些优点:

  • 如果我们需要,我们将使从默认的变更检测策略切换到OnPush变得更加容易。

  • 使用OnPush将减少变更检测周期。

通常情况下,使用异步管道可以帮助你实现高性能的用户界面,如果你的视图正在执行多个任务,这将产生很大的影响。

在本章的所有这些工作之后,以下是我们的 UI 输出:

图 3.6 – 菜单列表概述

图 3.6 – 菜单列表概述

所以,总结一下,使用响应式模式来获取数据将提高您应用程序的性能,改变检测策略,以及代码的清晰度和可读性。此外,它将使代码更具声明性和响应性,更容易利用 RxJS 操作符,并更容易响应用户操作。

现在我们已经建立了响应式模式,让我们通过探索 Angular 17 中引入的一个有趣功能来结束本章,了解其好处,并在我们的食谱应用中实际应用它。

深入了解 Angular 17 的内置控制流

在 Angular 17 之前,模板中的控制流主要使用结构化指令来管理。让我们首先探索结构化指令。

结构化指令

结构化指令负责根据某些条件改变 DOM 的结构,并编排元素如何根据条件添加、删除或重复。以下是 Angular 中用于控制模板执行的可用指令列表:

  • *ngIf:这个结构化指令用于根据表达式的真值有条件地包含或排除 DOM 中的元素。例如,考虑以下代码片段,它显示消息items数组为空:

    <div *ngIf="items.length === 0">No items found </div>
    In this code, if the `items` array is not empty, the content inside the `else` block defined by the `ng-template` element with the `#itemsFound` reference will be displayed, indicating `*ngFor`: This structural directive is used for iteration. It repeats a section of HTML for each item in an iterable collection. For example, this code renders a list of products one by one:
    
    
    • {{ product.name }}

    
    In order to improve performance, you can optionally add a custom `trackBy` function that provides a unique identifier for each item in the list. This is achieved by modifying the previous code, shown as follows:
    
    
    • Then, define the `trackProduct` function in your component class to return the unique identifier of each product item as follows:

      trackProduct(index: number, product: Product) {

      return product ? product.id : undefined;

      }

      
      This way, Angular can more efficiently track changes within the list. It will only update the DOM elements that actually changed, instead of re-rendering the entire list for minor changes. This leads to a smoother user experience, especially when dealing with large or frequently updated lists.
      
    • ngSwitch:这个结构化指令用于根据提供的表达式的评估值有条件地包含或排除 DOM 中的元素。它通常在需要评估多个条件时使用。以下是一个根据用户角色渲染不同视图的示例:

      <div [ngSwitch]="userRole">
        <admin-dashboard *ngSwitchCase="admin" >
          </admin-dashboard>
        <user-dashboard *ngSwitchCase="'user'" >
          </user-dashboard>
        <guest-dashboard *ngSwitchDefault >
          </guest-dashboard>
      </div>
      

    现在我们已经了解了 Angular 中的结构化指令,它提供了一种根据某些条件动态改变 DOM 结构的机制,我们可以深入了解 Angular 模板中控制流管理的下一个发展阶段。随着 Angular 版本 17 的发布,一个新的范式出现:内置控制流。让我们深入了解这个令人兴奋的新功能,并探索它是如何增强 Angular 开发体验的。

    内置控制流

    内置控制流提供了一种更简洁、更具声明性的方式来直接在组件模板中管理控制流逻辑,消除了对结构化指令的需求。以下是新的内置控制流语句。

    内置 if 语句

    @if语句根据布尔表达式有条件地渲染内容。

    让我们考虑之前的 *ngIf 示例:

    <div *ngIf="items.length === 0; else itemsFound">
      <div>No items found</div>
    </div>
    <ng-template #itemsFound>
      <div>Items found</div>
    </ng-template>
    

    使用新的@if@else语句,示例现在将如下所示:

    @if (items.length === 0) {
      <div> No items found </div>
    } @else {
    <div> Items found </div>
    }
    

    正如你可能注意到的,两个代码块之间存在语法差异。@if@else语句通过提供更直观且类似 JavaScript 的语法来处理组件模板中的条件渲染,从而替换了*ngIf指令和ng-template元素。你可以选择使用@else语句,当条件评估为假时提供替代内容。

    此外,虽然*ngIf需要导入CommonModule才能正常工作,但@if是一个独立的语句,可以直接在模板中使用,无需任何额外的导入。

    此外,@if块可以有一个或多个相关的@else块。在@if块之后,你可以选择性地链接任意数量的@else if块和一个@else块,如下所示:

    @if (age >= 18) {
      You are an adult.
    } @else if (age >= 13) {
      You are a teenager.
    } @else {
      You are a child.
    }
    

    内置 for 循环语句

    @for语句遍历数据集合,并为每个项目渲染内容。

    让我们再次以之前的*ngFor示例为例:

    <ul>
      <li *ngFor="let product of products; trackBy:
        trackProduct">{{ product.name }}
      </li>
    </ul>
    

    使用新的@for语句,示例将如下所示:

    @for (product of products; track product.id) {
      {{ product.name }}
    }
    

    替换之前与*ngFor一起使用的可选trackBy函数的是@for语句中的track函数。两种方法都服务于相同的核心目的,即通过关注每个项目的唯一标识符而不是其在数组中的位置,使 Angular 能够高效地跟踪迭代列表中的更改。

    注意

    虽然trackBy是可选的,但它的缺失往往会导致性能问题。然而,现在在@for循环中使用track是强制性的,默认确保了最佳的渲染速度。

    track的一个显著优势是,与trackBy相比,它易于使用。你可以在模板中直接包含一个表示每个项目唯一标识符的表达式,从而消除了在组件类(如前例中的trackProduct)中单独使用trackBy方法的需求。这简化了你的代码并提高了可读性。

    对于已经实现了trackBy函数并希望迁移而不删除这些方法的开发者,track的过渡被设计得无缝。他们可以无缝保留现有方法,只需更新模板如下:

    @for (product of products; track trackProduct($index, product) {
      {{ product.name }}
    }
    

    这确保了向后兼容性和平滑的过渡过程。

    从本质上讲,track提供了一种强制性和简化的方法来在@for循环中进行更改跟踪,促进了 Angular 应用程序中的最佳性能和更简洁的语法。

    注意

    值得注意的是,@for语句使用了一种新的 diffing 算法,与*ngFor相比提供了更优化的实现。根据社区框架基准测试,这种增强使得运行时速度提高了高达 90%。更多信息,请参阅krausest.github.io/js-framework-benchmark/current.html

    此外,内置的 @for 循环有一个快捷方式来处理空集合,称为可选的 @empty 块:

    @for (product of products; track product.id) {
      {{ product.name }}
    } @empty {
      Empty list of products
    }
    

    @empty 块提供了一个方便且高效的方式来显示信息性消息或替代内容,当没有数据可用时。它促进了更好的用户体验,并使组件逻辑保持良好的组织。

    我们在那里详细说明了,为了总结,以下是新 @for 语句的关键好处:

    • @for 语法提供了一种更干净、更易读的方式来遍历列表,在数据不可用时显示替代内容,并为列表项定义唯一标识符。

    • 通过要求 track@for 确保了高效的 DOM 更新,从而带来了更流畅的用户体验。

    • *ngFor 相比,@for 循环利用了一个新的、优化的 diffing 算法。这导致了显著的性能提升,正如社区基准测试所证明的那样。

    从本质上讲,@for 语句为在 Angular 应用程序中遍历集合提供了一个全面的升级。它赋予开发者一个更干净、更高效、更用户友好的方式来管理模板中的数据。

    内置的 switch 语句

    @switch 语句根据匹配表达式选择内容。

    让我们以前面的 *ngSwitch 示例为例:

    <div [ngSwitch]="userRole">
      <admin-dashboard *ngSwitchCase="admin" >
        </admin-dashboard>
      <user-dashboard *ngSwitchCase="'user'" >
        </user-dashboard>
      <guest-dashboard *ngSwitchDefault >
        </guest-dashboard>
    </div>
    

    使用新的 @switch 语句,它现在看起来是这样的:

    @switch (userRole) {
      @case ('admin') { <admin-dashboard/> }
      @case ('user') { <user-dashboard/> }
      @default { <guest-dashboard/> }
    }
    

    如您可能已经注意到的,@switch*ngSwitch 都在 Angular 模板中实现了条件渲染。然而,@switch 提供了一种更简洁、更现代的方法,与当前的 JavaScript 实践更一致。这种语法更直观,更接近标准的 JavaScript switch 语句,使得代码更容易理解和维护。

    @default 块是可选的,可以省略。如果没有提供匹配的 @case 块,也没有提供 @default 块,则不会显示任何内容。

    在我们的食谱应用中包含内置控制流

    既然我们已经了解了新的内置控制流,让我们利用它并使用这种新语法更新我们的模板代码。

    我们 RecipesListComponent.html 文件的 HTML 代码使用了 Angular 结构性指令 *ngIf(用于在 recipes$ 可观察对象返回值时条件性地渲染数据视图)和 *ngFor(用于遍历食谱列表并为每个食谱渲染一张卡片)。以下是代码片段:

    <div *ngIf="recipes$ | async as recipes" class="card">
      <p-dataView #dv [value]="recipes" [paginator]="true"
      [rows]="9" filterBy="name" layout="grid">
          <ng-template let-recipes pTemplate="gridItem">
            <div class="grid grid-nogutter">
              <div class="col-12" class="recipe-grid-item card"
              *ngFor="let recipe of recipes">
                /** Extra code here **/
              </div>
            </div>
          </ng-template>
      </p-dataView>
    </div>
    

    现在,让我们使用新的内置控制流来更新这段代码:

    @if (recipes$ | async; as recipes) {
      <div class="card">
        <p-dataView #dv [value]="recipes" [paginator]="true"
        [rows]="9" filterBy="name" layout="grid">
          <ng-template let-recipes pTemplate="gridItem">
            <div class="grid grid-nogutter">
              @for (recipe of recipes; track recipe.id) {
                <div class="col-12"
                class="recipe-grid-item card">
    /** Extra code here **/
                </div>
                    }
            </div>
          </ng-template>
        </p-dataView>
      </div>
    }
    

    我们用 @if 替换了 *ngIf,以便在 recipes$ 可观察对象返回值时条件性地渲染数据视图。

    我们还用 @for 替换了 *ngFor 来遍历食谱列表并为每个食谱渲染一张卡片。我们在 @for 语句中包含了跟踪函数,track recipe.id。食谱的 ID 是食谱的唯一标识符。

    我们现在有一个更新的模板,它不仅性能更优,而且与 Angular 的最新版本无缝对接。

    此外,如果您有现有项目,您可以通过使用以下迁移图轻松地将它们迁移到利用新的内置流语法:

    ng generate @angular/core:control-flow
    

    内置控制流的优点

    使用 Angular 的内置控制流语法有几个优点,如下所述:

    • 提高可读性:语法更接近 JavaScript,使代码更容易理解和维护。

    • 减少样板代码:您可以消除对单独指令导入和属性的依赖。

    • 内置可用性:无需额外导入;该功能在模板中开箱即用。

    • 增强的类型安全性:编译器提供了更稳健的类型缩小,从而提高了类型安全性和错误检测。

    • 性能提升:虽然性能提升可能因您的应用程序结构和数据大小而异,但@for语句相较于*ngFor使用了一个更优化的 diffing 算法。这可能导致渲染更加流畅,用户体验更好,尤其是在处理大型或频繁更新的列表时。

    简而言之,内置控制流语法促进了编写 Angular 模板的更直观、简洁和高效的方法。它促进了代码可读性,减少了样板代码,并提供了增强的类型安全性。

    摘要

    在本章中,我们探讨了获取数据的经典和响应式模式。我们学习了如何以命令式方式管理取消订阅和响应式模式。我们解释了一些有用的 RxJS 操作符,并阐明了使用响应式模式的优点以及与之相关的所有技术方面。我们还学习了独立组件,这是 Angular 的新增功能,以及如何创建它们以及它们的优点。最后,我们深入探讨了 Angular 17 中引入的新内置控制流,涵盖了其各种应用、语法和相关的优点。

    现在我们已经将数据作为 RxJS 流检索,在接下来的章节中,让我们开始使用 RxJS 流来响应用户操作,从而以响应式的方式构建我们的RecipesApp应用程序。在下一章中,我们将关注错误处理的响应式模式以及可用的不同策略。

    第四章:反应式处理错误

    编程中的错误时常发生,RxJS 也不例外。处理这些错误是每个应用程序的关键部分。正如我在每次培训课程中总是对我的学生说的,只涵盖愉快情况的流程会导致你的应用程序失败。然而,在 RxJS 中,有许多错误处理策略你需要学习,以便有效地处理错误。

    我们将首先解释 RxJS 中可观察者的合约,这是理解接下来内容的关键。然后,我们将学习不同的错误处理模式和 RxJS 提供的用于此目的的操作符。接下来,我们将阐明不同的错误处理策略和每种策略的用例。最后,我们将在我们的食谱应用中实践一种错误处理策略。

    在本章中,我们将涵盖以下主要主题:

    • 理解可观察者合约的结构

    • 探索错误处理模式和策略

    • 在我们的食谱应用中处理错误

    技术要求

    本章假设你已对 RxJS 有基本的了解。本章的源代码(除示例外)可在github.com/PacktPublishing/Reactive-Patterns-with-RxJS-and-Angular-Signals-Second-Edition/tree/main/Chap04找到。

    请参阅第三章作为流获取数据中的技术要求部分。

    理解可观察者合约的结构

    为了学习错误处理模式,理解可观察者合约的结构至关重要。让我们通过探索第一章深入反应范式中解释的宝石图来深入了解可观察者执行时间线:

    图 4.1 – 宝石图元素

    图 4.1 – 宝石图元素

    让我们检查之前的图。如果我们看一下流的生存周期,我们可以弄清楚流有两个最终状态:

    • 完成状态:流在没有错误的情况下结束,不会发出任何进一步的值。这是一个关闭,即可观察者完成。

    • 错误状态:流在出现错误后结束,错误发生后不会发出任何进一步的值。它也是一个关闭。

    这两种状态中只有一个可以发生,不是两者同时发生,每个流最多只能出现一次错误。这就是可观察者合约。

    在这一点上,你可能想知道,我们如何从错误中恢复?这就是我们在接下来的几节中要学习的内容。

    探索错误处理模式和策略

    我们将学习的第一个经典错误处理模式基于subscribe()方法。subscribe()方法接受作为输入的 Observer 对象,它有三个回调:

    • 成功回调:每次流发出值时都会调用,并接收发出的值作为输入

    • 错误回调:当发生错误时调用,并接收错误本身作为输入

    • 完成回调:当流完成时调用

    这是一个subscribe实现的简单示例:

    stream$.subscribe({
        next: (value) => console.log('Value Emitted', value),
        error: (error) => console.log('Error Occurred', error),
        complete: () => console.log('Stream Completed'),
    });
    

    在代码示例中,stream$代表我们的 Observable,我们将一个包含三个回调的对象传递给subscribe方法:

    • 成功回调,用于在控制台记录接收到的值

    • 将接收到的错误记录在控制台中的错误回调

    • 完整的回调,用于记录流的完成

    因此,为了处理错误,第一种可能性是实现错误回调并跟踪错误消息,显示错误弹出窗口给用户,或执行任何其他自定义行为。很简单!

    但等等!在第三章作为流获取数据中,我们看到了我们需要避免对流的显式subscribe(),并了解了背后的原因和限制,即无法从错误中恢复或发出替代回退。

    对,在大多数情况下,我们不会显式使用subscribe()。我只是想向你展示做这件事的经典方式,但这并不是最好的方式。相反,让我们看看一些高级错误处理模式,并学习更多有助于我们错误处理过程的操作符。

    我认为你可能熟悉许多编程语言中可用的try-catch语句,它由一个try块后跟一个或多个catch子句组成。在try块中,你放置你的风险语句,并在catch中处理可能的异常:

        try {
          // risky statements
      }
      catch(error) {
          // handle exceptions
       }
    

    RxJS 附带catchError操作符,它为我们提供了类似于try-catch语句的功能。catchError操作符在 RxJS 官方文档中定义为“在 Observable 上捕获错误,通过返回一个新的 Observable 或抛出一个错误来处理”的操作符。

    catchError操作符订阅可能发生错误的源 Observable,并向观察者发出值,直到发生错误。当发生错误时,catchError操作符执行一个回调函数,传入错误。此回调函数负责处理错误,并始终返回一个 Observable。

    如果没有错误,catchError返回的输出 Observable 与源 Observable 的工作方式完全相同。

    你可以在 Observable 链中多次使用catchError,如下所示:

    import { catchError} from 'rxjs/operators';
    //stream$ is the source Observable that might error out
    stream$.pipe(
          catchError(error => {
              //handle the error received
          })
    ).subscribe()
    

    在调用catchError操作符之后,我们需要实现一个处理错误的回调函数。当涉及到错误处理时,有三种策略:

    • 替换策略

    • 重新抛出策略

    • 重试策略

    让我们在接下来的几个部分中逐一分析这三种策略,并探讨一些示例和用例。

    替换策略

    替换策略之所以被命名为这样,是因为错误处理函数返回的 Observable 将替换掉刚刚发生错误的 Observable。然后,这个替换 Observable 被订阅,并且它的值被用来代替出错输入 Observable。以下代码是一个例子:

    import { from, of } from 'rxjs';
    import { catchError, map } from 'rxjs/operators';
    const stream$ = from(['5', '10', '6', 'Hello', '2']);
    stream$
      .pipe(
        map((value) => {
          if (isNaN(value as any)) {
            throw new Error('This is not a number');
          }
          return parseInt(value);
        }),
        catchError((error) => {
          console.log('Caught Error', error);
          return of();
        })
      )
      .subscribe({
        next: (res) => console.log('Value Emitted', res),
        error: (err) => console.log('Error Occurred', err),
        complete: () => console.log('Stream Completed'),
      });
    //output
    Value Emitted 5
    Value Emitted 10
    Value Emitted 6
    Caught Error Error: This is not a number
    Stream Completed
    

    让我们分解一下这个例子中发生的情况。

    首先,我们有一个由字符串值数组['5', '10', '6', 'Hello', '2']使用from创建操作符创建的 Observable,stream$。这个操作符创建了一个 Observable,当订阅它时,将依次发出数组的值,然后完成。

    注意

    关于from操作符的更多详细信息,请参阅官方文档:rxjs.dev/api/index/function/from#description

    接下来,我们在stream$的管道方法中组合了两个操作符:

    • map操作符:这个操作符用于使用parseInt()方法将发出的字符串值转换为整数。如果发出的值不是数字,则抛出一个带有消息"This is not a"number错误。

    • catchError操作符:我们向其中传递错误处理函数,该函数将记录捕获到的错误并返回of()of()创建了一个没有要发出值的 Observable,因此它将立即完成。

    然后,我们订阅了stream$,并在每个回调中记录一个自定义消息,以查看执行时间确实发生了什么。

    在执行时间,stream$将依次发出数组的字符串值(分别是'5''10''6')。map操作符逐个将这些值作为输入,并分别返回5106catchError()操作符接收来自map操作符发出的值,并将它们作为输出转发;由于没有错误,错误处理函数将不会被调用。因此,订阅者将接收到5106

    当发出'Hello'值时,catchError()操作符开始发挥作用。map操作符将抛出一个错误,并且catchError()中的错误处理函数将相应地被调用。在我们的例子中,错误处理函数只是简单地记录一个错误到控制台,并返回一个由of()操作符创建的将立即完成的 Observable。这个 Observable 将替换掉有错误的当前 Observable;这就是为什么我们称之为替换 Observable。

    catchError()在底层订阅了返回的 Observable。of() Observable 将立即完成。然后,stream$完成,所以下一个值'2'将不会发出。

    如您所注意到的,subscribe()方法中的错误回调不会被调用,因为我们已经在catchError中处理了它。我故意添加它来理解使用catchError的错误处理行为。因此,当发生错误时,当前出错流将被catchError()返回的流所替换;替换后的 Observable 的值将代替原始流值被发出。这就是我们所说的回退值

    因此,总结来说,替换策略在我们要在流内部处理错误,并且不希望错误传播给订阅者时非常有用。

    重新抛出策略

    catchError。通知订阅者关于错误的信息将帮助他们执行副作用,例如在弹出窗口中显示错误消息。

    为了更深入地了解这种策略,让我们看看以下示例;它与上一节中的示例相同,唯一的区别在于错误处理函数:

    import { from, throwError } from 'rxjs';
    import { catchError, map } from 'rxjs/operators';
    const stream$ = from(['5', '10', '6', 'Hello', '2']);
    stream$
      .pipe(
        map((value) => {
          if (isNaN(value as any)) {
            throw new Error('This is not a number');
          }
          return parseInt(value);
        }),
        catchError((error) => {
          console.log('Caught Error', error);
          return throwError(() => error);
        })
      )
      .subscribe({
        next: (res) => console.log('Value Emitted', res),
        error: (err) => console.log('Error Occurred', err),
        complete: () => console.log('Stream Completed'),
      });
    //output
    Value Emitted 5
    Value Emitted 10
    Value Emitted 6
    Caught Error Error: This is not a number
    Error Occurred Error: This is not a number
    

    在错误处理函数中,我们返回一个使用throwError操作符创建的 Observable。throwError操作符创建一个永远不会发出任何值的 Observable;相反,它会立即使用catchError捕获的错误出错。这样,错误将被推送到订阅者,如果需要,可以由 Observable 链的其余部分进一步处理。

    如您所注意到的,与预期的一样,相同的错误同时在catchError块和订阅者错误处理函数中被记录,因此重新抛出策略已经生效。

    请注意,在之前的示例中,我们只是为了演示目的在控制台中简单地记录了错误。然而,在现实世界的场景中,你可以做更多的事情,例如向用户显示消息。

    重试策略

    使用retry操作符给流提供另一次机会。retry操作符会重试一个 Observable 特定次数,这对于重试 HTTP 请求或连接非常有用。这里我们可以看到一个例子:

    import { catchError, map, retry } from 'rxjs/operators';
    import { from, throwError } from 'rxjs';
    const stream$ = from(['5', '10', '6', 'Hello', '2']);
    stream$
      .pipe(
        map((value) => {
          if (isNaN(value as any)) {
            throw new Error('This is not a number');
          }
          return parseInt(value);
        }),
        retry(2),
        catchError((error) => {
          console.log('Caught Error', error);
          return throwError(() => error);
        })
      )
      .subscribe({
        next: (res) => console.log('Value Emitted', res),
        error: (err) => console.log('Error Occurred', err),
        complete: () => console.log('Stream Completed'),
      });
    //output
    Value Emitted 5
    10
    6
    5
    10
    6
    5
    10
    6
    Caught Error Error: This is not a number
    Error Occurred Error: This is not a number
    

    如您所注意到的,源流的值被发出了两次,因为我们用2作为参数调用了retry操作符;在抛出错误之前,我们给了 Observable 两次机会。

    现在,在这种情况下,我们正在立即重试。但是,如果我们只想在特定情况下重试或者等待一段时间后再重试呢?这就是retryWhen操作符发挥作用的地方!

    要理解retryWhen操作符,没有什么比一个弹珠图更好的了:

    图 4.2 – retryWhen 操作符

    图 4.2 – retryWhen 操作符

    让我们解释一下这里发生了什么:

    • 第一行的 Observable 是通知 Observable,它将决定重试何时发生

    • 第二行的 Observable 是源 Observable,在发出12之后将会出错

    当我们订阅源 Observable 时,它将发出retryWhen并将这些值作为输出。然后,源 Observable 出错并完成。

    通知器 Observable 发出第一个值之前,什么都不会发生,retryWhen将订阅源 Observable,因为它已经完成了,所以即使它已经完成,也可以重试。

    然后,通知器 Observable 将发出另一个r值,发生同样的情况。

    接下来,retryWhen开始发出第一个1值,但很快,通知器 Observable 就完成了;这就是为什么2值不会发出。

    如你所猜,每当通知器发出值时,retryWhen都会重试源 Observable!这意味着你可以使用这个通知器 Observable 在你想要源 Observable 重试并完成的时候发出值,并在你想要停止重试尝试的时候完成它。

    现在,让我们看看retryWhen操作符的签名:

    export declare function retryWhen<T>(notifier: (errors:
    Observable<any>) => Observable<any>):
    MonoTypeOperatorFunction<T>;
    

    notifier参数表示返回通知器 Observable 并获取错误 Observable 作为参数的回调。每当源 Observable 出错时,错误 Observable 都会发出。因此,retryWhen将订阅通知器 Observable 并按之前描述的方式行为。

    这里是替换和重新抛出策略中给出的相同示例,但使用retryWhen代替:

    import { from} from 'rxjs';
    import { map, retryWhen, tap } from 'rxjs/operators';
    const stream$ = from(['5', '10', '6', 'Hello', '2']);
    stream$
      .pipe(
        map((value) => {
          if (isNaN(value as any)) {
            throw new Error('This is not a number');
          }
          return parseInt(value);
        }),
        retryWhen((errors) => {
          return errors.pipe(
            tap(() => console.log('Retrying the source
                                  Observable...'))
          );
        })
      )
      .subscribe({
        next: (res) => console.log('Value Emitted', res),
        error: (err) => console.log('Error Occurred', err),
        complete: () => console.log('Stream Completed'),
      });
    //Code runs infinitely
    

    在前面的代码中,第一次错误是在接收到值'Hello'时抛出的,这不是一个数字。retryWhen操作符将捕获这个错误并执行。然后,通知回调(retryWhen的参数)简单地以错误 Observable 作为输入并返回它。

    我们还使用了pipe来调用tap操作符,以便在控制台记录一条消息('Retrying the source Observable...')。tap()操作符用于对每个发出的值执行副作用。

    注意

    关于tap操作符的更多详细信息,请参阅官方文档中的此链接:rxjs.dev/api/operators/tap

    如果你执行这段代码,你会发现它会无限运行。为什么?因为源总是会出错,而retryWhen将无限期地订阅源 Observable。

    如果源总是出错,立即重试是不正确的。然而,错误并不总是发生,例如,在 HTTP 请求的情况下。有时,HTTP 请求失败是因为服务器宕机,或者有其他可能消失的临时原因,而在下一次尝试中可能没有任何问题。

    在那种情况下,你可以使用立即重试,甚至延迟重试,在一定的延迟后重试,例如,在发生错误后等待 5 秒钟再重试。这就是我们将在下一节学习的内容。

    现在,让我们看看另一个将帮助我们实现重试策略的操作符:delayWhen操作符。delayWhen()操作符用于通过给定的时间延迟从源 Observable 发出的值。它与delay()操作符类似,但延迟持续时间由一个输入 Observable 确定。

    更多细节,让我们看看一个弹珠图:

    图 4.3 – delayWhen 操作符

    图 4.3 – delayWhen 操作符

    第一个 Observable 是源 Observable。图中的每个值,abc,分别有自己的持续时间选择器 Observable,分别是:a持续时间选择器 Observable、b持续时间选择器 Observable 和c持续时间选择器 Observable,它们将发出一个值x,然后完成。

    源 Observable 发出的每个值在发出到输出 Observable 之前都会被延迟。实际上,当源 Observable 发出值时,delayWhen操作符不会立即将值发出到源 Observable;相反,它将等待a持续时间选择器 Observable 在ta+delay处发出值,并且在这个确切的时间,值a将被发出到输出 Observable。

    这对其他值也是如此;当b持续时间选择器发出值时,b值将在输出 Observable 的tb+delay处显示,当c持续时间选择器 Observable 发出值时,值c将在tc+delay处发出。请注意,在这里,tbtc分别代表源 Observable 发出值ab的时间。

    正如你可能已经注意到的,源 Observable 在bc之前发出(因为tbtc之前);然而,在输出 Observable 中,b的值在c的值之后显示(因为tb+delaytc+delay之后);这是因为b的选择器(图 4.3中的b持续时间选择器)在c的选择器(图 4.3中也显示的c持续时间选择器)之后发出。

    因此,正如你所看到的,延迟完全可以通过durationSelector函数来灵活调整。

    另一个函数,timer函数,在延迟重试策略中可能很有用:

    export declare function timer(dueTime?: number | Date,
    periodOrScheduler?: number | SchedulerLike, scheduler?:
    SchedulerLike): Observable<number>;
    

    这个timer函数返回一个 Observable,并接受两个参数:

    • due:一个时间周期或确切日期,在此日期之前不会发出任何值

    • scheduler:一个周期性间隔,如果我们想定期发出新值

    一个例子是timer(5000,1000)。返回的 Observable 的第一个值将在 5 秒后发出,并且每秒发出一个新值。第二个参数是可选的,这意味着timer(5000)将在 5 秒后发出一个值然后完成。

    现在,是时候将delayWhenretryWhen操作符结合起来,看看我们如何在每次错误后 5 秒重试失败的 HTTP 请求了:

    import { Injectable } from '@angular/core';
    import { HttpClient } from '@angular/common/http';
    import { Recipe } from '../model/recipe.model';
    import { catchError, delayWhen, of, retryWhen, tap, timer } from 'rxjs';
    @Injectable({
      providedIn: 'root'
    })
    export class RecipesService {
    recipes$ =
    this.http.get<Recipe[]>('http://localhost:3001/recipes')
    .pipe(
           retryWhen(errors => {
             return errors
               .pipe(
                 delayWhen(() => timer(5000)),
                 tap(() => console.log('Retrying the HTTP
                                       request...'))
               );
           }),
    );
    constructor(private http: HttpClient) { }
    }
    

    注意

    你在 GitHub 仓库中找不到前面的代码,因为它仅仅是一个说明性的示例。然而,你可以将其复制并粘贴到 RecipesService 类中以测试延迟重试。此外,记得停止 recipes-book-api 模拟服务器以模拟重试尝试。

    在这种情况下,我们的源 Observable 是 HTTP get 请求的结果。每次请求失败时,delayWhen 操作符通过 timer 函数创建一个持续时间选择器 Observable。这个持续时间选择器 Observable 将在 5 秒后发出 0 值并完成。因此,retryWhen 的通知 Observable 将发出一个值,此时,源 Observable 将在 5 秒后重试。

    当你打开控制台时,你会看到以下输出:

    图 4.4 – 失败的 HTTP 请求

    图 4.4 – 失败的 HTTP 请求

    正如你可能已经注意到的,每次 GET HTTP 请求失败后,都会在 5 秒后再次尝试。这就是我们实现延迟重试的方法!所以,总结一下,每种错误处理策略都有其自己的技术,并服务于不同的目的。在下一节中,我们将探讨何时使用每种策略。

    选择合适的错误处理策略

    在 RxJS 中选择最合适的错误处理策略取决于各种因素,例如应用程序的性质、遇到的错误类型以及期望的用户体验。以下是一些关于何时使用每种策略的指导。

    替换策略涉及用回退值或 Observable 替换错误。它适用于以下场景:

    • 当发生错误时,你有预定义的回退值或行为可以使用,例如显示占位符内容或默认设置。例如,在一个天气应用程序中,如果获取当前天气数据失败,你可以用用户位置的默认天气预报替换错误。

    • 错误是可恢复的,并且不需要用户立即干预。

    • 你希望通过优雅地处理错误而不中断应用程序流程来提供无缝的用户体验。

    重新抛出策略涉及重新抛出错误以将其传播给订阅者进行处理。它适用于以下场景:

    • 你希望将错误处理的责任委托给 Observable 的订阅者或消费者。例如,在一个身份验证服务中,如果由于凭据无效导致登录失败,你可以重新抛出错误以允许 UI 组件向用户显示错误消息。

    • 错误需要根据其发生的上下文进行特定的处理逻辑或定制。

    • 你希望为应用程序的不同部分提供灵活性,以便它们可以以不同的方式处理错误。

    重试策略涉及重试导致错误的操作一定次数。它适用于以下场景:

    • 错误是瞬时的或间歇性的,例如网络错误或暂时性的服务中断,在后续尝试之后重试操作可能会成功。例如,在一个文件上传服务中,如果由于网络错误上传文件失败,你可以在放弃之前多次重试上传操作,以确保文件成功上传。

    • 重试操作有合理的成功机会,并且可以减轻瞬时故障的影响。

    • 你希望提高容易偶尔出现故障的操作的可靠性和鲁棒性。

    此外,在选择错误处理策略时,还应考虑以下因素:

    • 用户体验:考虑每种策略如何影响用户体验,例如它是否会导致延迟、重试或回退。

    • 应用程序要求:将所选策略与您应用程序的具体要求和限制相一致,例如可靠性、响应性和错误容忍度。

    • 性能影响:重试策略可能会引入额外的开销,尤其是在操作涉及昂贵或耗时任务时。

    最终,最合适的错误处理策略取决于您应用程序的具体上下文和要求。通常,尝试不同的策略并在实际场景中观察它们的效果,以确定最佳方法是有益的。

    现在我们已经学习了处理错误的不同策略和运算符,让我们在下一节中在我们的食谱应用程序中进行实践。

    处理我们的食谱应用程序中的错误

    我们将要做的第一件事是停止我们的模拟服务。是的,你没听错;停止它。这样,对getRecipes服务的调用将会失败,因为服务器已关闭。

    现在,如果你刷新前端应用程序,你会看到没有任何内容显示,包括我们的食谱列表。为什么我们会得到这种行为?因为我们没有处理错误。错误被抛出,流完成,之后什么也没有发生。我们有一个空白屏幕,上面什么也没有显示。

    现在打开控制台,你会看到失败的请求:

    图片 1图片 2

    图 4.5 – 显示失败请求的控制台

    如果正确处理,失败的 HTTP 请求永远不会破坏我们的应用程序。这就是为什么你在前端应用程序中发起 HTTP 请求时应该非常小心。

    那么,我们该如何解决这个问题?我们之前讨论过的哪种策略最适合?

    如果我们选择重新抛出或重试策略,那么我们将阻止显示食谱列表。用户将看到一个空白页面,必须等待请求成功执行才能看到屏幕上渲染的食谱列表。当你处理与 UI 显示无关的后台进程时,这是一个有效的选项;然而,如果你提出请求以获取结果并在你的 UI 组件中显示它们,那么你应该提供数据的替代品以继续渲染页面。无论是否有错误,用户界面都应该继续工作;如果有错误,我们将显示一个空列表;如果没有,我们将显示从服务器返回的列表。

    正是因为这个替换策略最适合这个特定情况。实际上,我们希望从服务中获取食谱列表,但如果服务因任何原因失败,我不想我的应用程序冻结;我想看到包含零个元素(没有元素)、一个空表或一个列表,仅此而已。所以,我们将要做的是使用catchError并返回一个空的 Observable,这是我们备选值。

    我们的服务将看起来像这样:

    import { Injectable } from '@angular/core';
    import { HttpClient } from '@angular/common/http';
    import { Recipe } from '../model/recipe.model';
    import { environment } from 'src/environments/environment';
    import { catchError, of } from 'rxjs';
    const BASE_PATH = environment.basePath
    @Injectable({
      providedIn: 'root'
    })
    export class RecipesService {
      recipes$ = this.http.get<Recipe[]>(
        `${BASE_PATH}/recipes`).pipe(
          catchError(()=> of([])));
      constructor(private http: HttpClient) { }
    }
    

    这种方法确保了你的应用程序保持功能正常,如果你访问应用程序,将显示一个空列表。此外,你可以通过包含类似recipes-list.component.html的消息来自定义用户界面,如下所示:

    @if (recipes$ | async; as recipes) {
    <div class="card">
        <div>{{recipes.length}} Results</div>
        <p-dataView #dv [value]="recipes" [paginator]="true"
        [rows]="9" filterBy="name" layout="grid">
            <ng-template let-recipes pTemplate="gridItem">
                <div class="grid grid-nogutter">
                    @for (recipe of recipes; track recipe.id) {
                        <div class="col-12" class="recipe-grid-
                            item card">
                            /** Extra code here **/
                        </div>
                    } @empty {
                        <div>There are no recipes</div>
                    }
                </div>
            </ng-template>
        </p-dataView>
    </div>
    } @else {
        <div>There are no recipes</div>
    }
    

    如你所注意到的,我们使用了在第三章中解释的新内置控制流机制,作为流获取数据。通过将@else块应用于第一个if条件,我们能够在recipes$Observable 没有发出任何值时显示消息。此外,添加到@for语句中的@empty语句允许我们在食谱列表为空时显示相同的消息。

    摘要

    在本章中,我们学习了 Observable 合约,并探讨了 RxJS 中最常用的错误处理策略和一些不同的操作符,即catchError()delayWhen()retry()retryWhen()。我们还阐明了不同的错误处理策略以及何时选择每种策略。最后,我们在我们的食谱书应用中处理了第一个实现的功能的错误。

    现在我们知道了如何在 RxJS 中处理错误,让我们继续下一个反应式模式:合并流。

    第五章:合并流

    到目前为止,我们已经学习了关于反应式模式以流的形式获取数据以及错误处理模式。然而,我们只探索了来自单个流的异步数据。如果我们想处理来自不同流的异步数据呢?你知道我们该如何进行吗?

    幸运的是,RxJS 附带了一个最强大的概念之一:合并流。合并流是将多个 Observables 的排放汇集到一个流中的过程。这允许你将多个异步数据源视为单个流来探索。合并流背后的主要思想是以更结构化的方式处理异步数据。

    本章围绕一个常见用例展开,即过滤数据;我们将通过合并流来解决它。我们首先将解释过滤需求,然后我们将探索可以用来实现此需求的命令式、经典模式,接着是一个声明式、反应式实现。最后,我们将突出合并流时需要避免的常见陷阱,并讨论最佳实践。

    在本章中,我们将涵盖以下主要主题:

    • 定义过滤需求

    • 探索用于过滤数据的命令式模式

    • 探索用于过滤数据的声明式模式

    • 突出常见陷阱和最佳实践

    技术要求

    本章的源代码(除示例外)可在github.com/PacktPublishing/Reactive-Patterns-with-RxJS-and-Angular-Signals-Second-Edition/tree/main/Chap05找到。

    定义过滤需求

    在我们的食谱应用中,我们希望根据某些标准过滤显示的食谱以细化结果。以下图显示了在第二章视图 1 – 登录页面部分中描述的模拟实现,浏览我们的应用

    图 5.1 – 过滤需求

    图 5.1 – 过滤需求

    从用户的角度来看,用户将在过滤器区域填写一些标准,并点击查看结果按钮来查看符合填写标准的成果。用户可以通过食谱标题、食谱类别、成分、标签、准备时间和烹饪时间中的关键词进行过滤。过滤功能是大多数显示数据集合的应用程序所必需的。

    当涉及到过滤时,你可以采用很多策略,选择取决于你的数据量大小:

    • 如果你可以完全在客户端获取少量数据,那么执行服务器端过滤是不必要的;相反,执行客户端过滤更快,而且不会损害应用程序的性能。

    • 如果你有很多数据,那么在通过分页或虚拟滚动加载数据时应该谨慎,以提高性能和用户体验。因此,在这种情况下,进行服务器端过滤是不可避免的,因为你没有在客户端拥有所有数据。

    为了演示目的,我们将只过滤总共 11 道食谱。因此,我们将使用客户端过滤(然而,请注意,我们将讨论的响应式模式不需要特定的过滤类型——它可以与客户端和服务器端过滤一起使用)。

    探索过滤数据的命令式模式

    在本节中,我们将探讨从 UI 到过滤逻辑的命令式方法来处理数据过滤。

    让我们从设置 UI 代码开始,创建一个新的独立组件 RecipesFilterComponent,位于 src/app 目录下。它负责显示不同的过滤器以细化初始结果。过滤器组件的 HTML 模板代码如下:

    <div class="rp-data-view">
        <form [formGroup]="recipeForm">
            <fieldset class="rp-filters-group">
                <legend>Filters</legend>
                <div class="rp-filter-button">
                    <p-button (onClick)="clearFilter()"
                    label="Clear all"></p-button>
                </div>
                <label for="title">Keyword:</label>
                <input type="text" id="title"
                formControlName="title">
                <label for="category">Category:</label>
                <input type="text" id="category"
                formControlName="category">
                <label for="ingredient">Ingredient:</label>
                <input type="text" id="ingredient"
                formControlName="ingredient">
                <label for="text">Tags:</label>
                <input type="text" id="tags"
                formControlName="tags">
                <label for="text">Preparation Time:</label>
                <input type="text" id="prepTime"
                formControlName="prepTime">
                <label for="text">Cooking Time:</label>
                <input type="text" id="cookingTime"
                formControlName="cookingTime">
                <div class="rp-filter-button">
                    <p-button class="rp-filter-button"
                    (onClick)="filterResults()" label="See
                    results"></p-button>
                </div>
            </fieldset>
        </form>
    </div>
    

    在前面的代码中,我们使用了 Angular 响应式表单在表单内显示搜索条件。然后,我们包含了两个按钮:在 OnClick 回调中的 filterResults()。此方法将替换显示的食谱与填充的准则相匹配的食谱。然后,由于 Angular 的变更检测机制,UI 将自动更新。

    注意

    有关响应式表单的更多详细信息,请参阅 angular.dev/guide/forms/reactive-forms

    现在,让我们转到 RecipesListComponent,在那里我们应该考虑对模板进行的小幅修改。我们应该将 p-dataView 组件的 [value] 输入绑定到一个新的属性,即 filteredRecipes 属性,该属性包含过滤后的结果。filteredRecipes 属性最初包含从服务器请求的所有食谱。模板如下所示:

    <div class="card">
        <p-dataView #dv [value]="filteredRecipes"
        [paginator]="true" [rows]="9" filterBy="name"
        layout="grid">
        /** Extra code here **/
        </p-dataView>
    </div>
    

    重点当然不是 HTML 模板,而是过滤过程;然而,在提供完整的工作流程时指出这一点很重要。

    现在,让我们看看 RecipesListComponent 中的经典模式逻辑:

    export class RecipesListComponent implements OnInit,
    OnDestroy {
      filteredRecipes: Recipe[] = [];
      recipes: Recipe[] = [];
      private destroy$: Subject<boolean> = new
      Subject<boolean>();
      constructor(private service: RecipesService, private fb:
      FormBuilder) {
      }
      ngOnInit(): void {
        this.service.recipes$.pipe(takeUntil(this.destroy$))
          .subscribe((recipes) => {
            this.recipes = recipes;
            this.filteredRecipes = recipes;
          });
      }
      ngOnDestroy(): void {
        this.destroy$.next(true);
        this.destroy$.unsubscribe();
      }
      filterResults(recipe:Recipe) {
        this.filteredRecipes = this.recipes.filter(recipe =>
        recipe.title?.indexOf(recipe.title) !=
        -1)
      }
    

    让我们分析正在发生的事情。在这里,我们声明了三个变量:

    • filteredRecipes:包含过滤后食谱的数组。它是 HTML 模板中绑定到 p-dataview 组件 [value] 输入的属性。

    • recipes:初始的食谱数组。

    • destroy$:一个用于清理订阅的主题。

    然后,在 ngOninit() 方法中,我们调用了代表我们的可观察食谱的 recipesrecipes$)。之后,我们订阅它,并用从 recipes$ 发射的数组初始化食谱和 filteredRecipes 数组。

    由于在这个例子中我们没有使用异步管道,我们应该手动使用 takeUntil() 模式或 takeUntilDestroyed() 来清理订阅,正如在 第三章 中解释的,作为流获取数据

    当点击recipe.title;时,会调用filterResults(recipe:Recipe)方法,该方法包含用户在标题输入框中填写的值。

    注意

    为了演示目的,在这个命令式实现示例中,我们选择在RecipesListComponent中显示RecipesFilterComponent。这种方法涉及发送一个输出事件,该事件封装了在RecipesFilterComponent中可用的过滤器表单的值,然后RecipesListComponent根据这个输入执行filterResult方法

    如果你使用服务器端过滤,换句话说,如果你有一个根据给定标准处理过滤数据的服务的,那么filterResults()应该调用后端服务,其形式如下:

      filterResults() {
        this.filteredRecipes =
        this.http.get<Recipe[]>(`${BASE_PATH}/recipes`,
        {params:{criteria:this.recipeForm.value}});
      }
    

    就这样!它运行得很好,我会说这可能是大多数人实现 Angular 应用程序中过滤功能的方法。命令式方法几乎是显而易见的方法。

    然而,你可能已经注意到,我们不能再像在模板中那样利用recipes$作为流了,正如在第三章中解释的,以流的形式获取数据。此外,如果你的食谱服务发出新的食谱呢?这将覆盖活动过滤器,直到用户再次点击查看结果来更新带有当前过滤器的列表。这当然可以通过命令式处理,但使用 Observables 而不利用响应式能力的力量是件遗憾的事。

    所以,无需多言,让我们探索一种更好的方法来实现过滤需求,即使用组合流的基本原理以声明式和响应式的方式。

    记住,我总是想立即突出经典方法和响应式方法,以便从命令式到声明式的平稳过渡。

    探索过滤数据的声明式模式

    你应该把所有事情都想象成一条流;这是黄金法则!

    我们已经有了recipes$,它已经是我们的数据流了,但如果我们把点击查看结果按钮也视为一条流呢?我们将称之为动作流,并认为它是一个异步的数据流;我们不知道它何时发生,但每次用户点击查看结果时,动作流都应该发出过滤器的值。

    因此,总共我们需要两个流:

    • 数据流:在我们的案例中,它是recipes$,它在RecipesListComponent中定义,我们是在第三章中创建的,以流的形式获取数据

        export class RecipesListComponent {
        /*Define The data stream */
        recipes$ = this.service.recipes$;
        constructor(private service: RecipesService) { }
      }
      
    • 动作流:在我们的案例中,它被命名为filterRecipesSubject;它负责在用户每次点击RecipesService服务(我们也在第三章中创建了,以流的形式获取数据)时发出最新的过滤器值:

        /*Create The action stream */
        Private filterRecipeSubject = new
        BehaviorSubject<Recipe>({title:''});
        /* Extract The readonly stream */
        filterRecipesAction$ =
        this.filterRecipeSubject.asObservable();
      

      现在,让我们解释前面的代码块。在这里,我们创建了两个属性:

      • 一个名为 filterRecipeSubjectBehaviorSubject,以防止代码的外部部分在流中发出值、出错或完成流。我们在构造函数参数中初始化 filterRecipeSubject 为一个默认值——一个空对象。我们可以使用 SubjectBehaviorSubject 来创建我们的动作流:

        • Subject 是一种特殊的可观察对象,它使得多播成为可能。我们将在 第九章 揭秘多播 中详细探讨多播。现在,请记住,主题既是观察者又是可观察对象,因此你可以用它来在观察者之间共享值。

        • BehaviourSubject 是一种特殊的主题,它需要一个初始值,并且总是保留最后一个值以将其发送给新的订阅者。换句话说,如果你有任何后来加入游戏的订阅者,他们将获得流中发出的最后一个值。当你订阅时,这总会给你一个值。我们将在本章末尾讨论为什么我们使用 BehaviourSubject 而不是 Subject

      • 通过 filterRecipeSubjectasObservable() 方法创建一个 filterRecipeAction$(用于防止代码的外部部分在流中发出值、出错或完成流)。我们使用构造函数参数中的默认值——一个空对象——初始化 filterRecipeSubject。我们可以使用 SubjectBehaviorSubject 来创建我们的动作流:

    所以,为了总结,在添加之前的代码块后,RecipesService 服务将看起来像这样:

    export class RecipesService {
      recipes$ =
        this.http.get<Recipe[]>(`${BASE_PATH}/recipes`);
      private filterRecipeSubject = new
        BehaviorSubject<Recipe>({title: '' });
      filterRecipesAction$ =
        this.filterRecipeSubject.asObservable();
      constructor(private http: HttpClient) { }
    }
    

    现在,是时候合并流了。这两个流都相互依赖;当 recipes$ 发出一个新值时,过滤器应该保持活跃,而当过滤器发出一个新值时,食谱列表应该相应地更新。

    我们真正试图做的是从两个流中获取信息。每次你想从多个可观察对象中合并信息时,你应该考虑 RxJS 的组合策略。我们不是分别从两个流中获取数据,而是可以将其组合成一个单一的新流。RxJS 有一个用于此目的的组合操作符集。在下一节中,我们将探讨最常用的组合操作符之一,即 combineLatest 操作符。

    combineLatest 操作符

    combineLatest 操作符将组合输入可观察对象发出的最新值。所以,每当其中一个可观察对象发出时,combineLatest 将发出每个可观察对象发出的最后一个值。这说得通吗?如果不通,别担心;我们将像往常一样使用宝石图进一步详细说明:

    图 5.2 – combineLatest 的宝石图

    图 5.2 – combineLatest 的宝石图

    让我们分解一下:

    • 晶石图的第一行表示第一个输入可观察对象的时序。

    • 第二行表示第二个输入可观察对象的时序。因此,combineLatest 有两个输入。

    • 最后一行表示从 combineLatest 操作符返回的输出可观察对象的时序。

    现在,让我们深入执行过程。

    第一个 Observable 发出了combineLatest的值。为什么?因为combineLatest不会发出,直到所有输入 Observables 各自发出一个值。所以,当第二个 Observable 发出时,combineLatest将发出a1

    请记住,combineLatest将不会发出初始值,直到每个 Observable 至少发出一个值。

    然后,第一个 Observable 发出了另一个值,combineLatest将发出每个输入流发出的最后一个值,即combineLatest将发出b2,以此类推。

    让我们回到我们的例子,看看我们如何结合我们刚刚创建的数据流和动作流,以便使用combineLatest操作符反应性地过滤结果。

    RecipesListComponent中,我们将创建一个新的流名为filteredRecipes$,它代表数据和动作流的组合结果:

    filterRecipesAction$ = this.service.filterRecipesAction$;
    filteredRecipes$ = combineLatest([this.recipes$,
      this.filterRecipesAction$])
    

    因此,在这里,我们使用了combineLatest操作符,并将其传递给一个包含两个值的数组(作为参数):第一个值是recipes$数据流,第二个值是filterRecipeAction$动作流。combineLatest将返回一个包含两个值的数组(作为输入 Observables 的数量):数组的第一个元素是第一个流发出的最后一个值,数组的第二个元素是第二个流发出的最后一个值。它尊重顺序。

    现在,我们将使用RecipesListComponent模板中的filteredRecipes$,我们将将其绑定到p-dataview[value]输入,以便发生以下情况:

    • filteredRecipes$应在加载页面时返回所有食谱

    • filteredRecipes$应在过滤时仅返回符合选定标准的食谱

    然后,RecipesListComponent模板的 HTML 代码将看起来像这样:

    @if (filteredRecipes$ | async; as recipes) {
    <div class="card">
        <p-dataView #dv [value]="recipes" [paginator]="true"
        [rows]="9" filterBy="name" layout="grid">
            /** Extra code here **/
        </p-dataView>
    </div>
    } @else {
    <div>There are no recipes</div>
    }
    

    注意

    在模板中,我们包含了RecipesListRecipesFilter组件。这种组件的分离增强了代码库的可维护性和可读性,促进了应用的模块化和可扩展架构。

    尽管如此,我们还需要在RecipesListComponent中做出一个更改。在我们的 UI 中,我们希望在加载页面时显示所有食谱,在细化结果时显示过滤后的食谱;因此,我们需要编辑filteredRecipes$,使其不直接返回组合的结果(而且在大多数情况下,这种情况根本不会发生)。相反,我们将处理组合的结果(返回的数组),从组合的流中获取所需的信息,并将其转换为所需的任何形式。

    在这种情况下,我们希望修改结果流,使其不仅提供最新的食谱和标准值,而且还提供一个按接收到的标准过滤的食谱数组,如下所示:

    filteredRecipes$ = combineLatest([this.recipes$,
      this.filterRecipesAction$]).pipe(
        map((resultAsArray:[Recipe[], Recipe]) => {
          const filterTitle =
            resultAsArray[1]?.title?.toLowerCase() ?? '';
          return resultAsArray[0].filter(recipe =>
            recipe.title?.toLowerCase().includes(filterTitle));
        })
      );
    

    组合的结果是resultAsArray参数。第一个元素,resultAsArray [0],代表来自recipes$的最后发出的菜谱,第二个元素,resultAsArray [1],代表来自filterRecipesAction$的最后发出的标准。

    然而,我们可以做得更好!我们可以使用数组解构技术来增强代码,如下所示:

    filteredRecipes$ = combineLatest([this.recipes$,
      this.filterRecipesAction$]).pipe(
        map(([recipes, filter]: [Recipe[], Recipe]) => {
          const filterTitle =
            filter?.title?.toLowerCase() ?? '';
          return recipes.filter(recipe =>
            recipe.title?.toLowerCase().includes(filterTitle))
        })
      );
    

    在这里,recipes参数代表返回数组的第一元素,filter参数代表返回数组的第二元素。在:之后,我们有参数类型,就是这样。所以,我们不是直接使用索引来获取元素,数组解构技术提供了一种命名数组元素并直接从这些变量中获取值的方法。最后,我们使用了filter方法来过滤符合标准的菜谱列表。

    到目前为止,我们已经实施了一切机制来反应性地过滤值。剩下要做的就是每次过滤器值改变时更新过滤器值,这就是我们将在下一节中要探讨的内容。

    更新过滤器值

    正如我们所说,每次用户点击filterRecipesAction$都应该发出标准,以便我们的combineLatest重新执行并返回过滤后的菜谱。为了实现这一点,我们在RecipesService中创建了一个名为updateFilter的新方法,它接受过滤器值作为输入,并简单地使用filterRecipesSubject主题的下一个方法发出它:

      updateFilter(criteria:Recipe) {
        this.filterRecipeSubject.next(criteria);
      }
    

    然后,当用户点击查看结果按钮时,我们将在RecipesFilterComponent中调用此方法来更新过滤器值:

    filterResults() {
      this.service.updateFilter(<Recipe>this.recipeForm.value);
    }
    

    我们将RecipesFilterComponent中创建的“过滤器”表单的值传递给updateFilter方法。就是这样;现在,每当标准发出时,它将重新执行combineLatest运算符,并相应地过滤值。

    总结一下,这是RecipesListComponent中的完整代码看起来是这样的:

    @Component({
      selector: 'app-recipes-list',
      standalone: true,
      imports: [CommonModule],
      templateUrl: './recipes-list.component.html',
      styleUrls: ['./recipes-list.component.scss'],
      changeDetection: ChangeDetectionStrategy.OnPush
    })
    export class RecipesListComponent {
      /*The data stream */
      recipes$ = this.service.recipes$;
      filteredRecipes$ = combineLatest([this.recipes$,
      this.filterRecipesAction$]).pipe(
        map(([recipes, filter]: [Recipe[], Recipe]) => {
        const filterTitle = filter?.title?.toLowerCase() ?? '';
          return recipes.filter(recipe =>
          recipe.title?.toLowerCase().includes(filterTitle)
        })
      );
      constructor(private service: RecipesService) {
      }
    }
    

    RecipesFilterComponent组件看起来是这样的:

    @Component({
      selector: 'app-recipes-filter',
      standalone: true,
      imports: [ButtonModule, ReactiveFormsModule],
      templateUrl: './recipes-filter.component.html',
      styleUrl: './recipes-filter.component.css'
    })
    export class RecipesFilterComponent {
      recipeForm = this.fb.group<Recipe>({
        title: '',
        category: '',
        ingredients: '',
        tags: '',
        prepTime: undefined,
        cookingTime: undefined,
      });
    constructor(private service: RecipesService, private fb:
      FormBuilder) { }
    filterResults() {
    this.service.updateFilter(<Recipe>this.recipeForm.value);
    }
    clearFilters() {
    this.recipeForm.reset();
    }
    

    最后,RecipesService看起来是这样的:

    export class RecipesService {
      recipes$ = this.http.get<Recipe[]>(
      `${BASE_PATH}/recipes`);
      private filterRecipeSubject = new
      BehaviorSubject<Recipe>({ title: '' });
      filterRecipesAction$ =
      this.filterRecipeSubject.asObservable();
      constructor(private http: HttpClient) { }
      updateFilter(criteria:Recipe) {
        this.filterRecipeSubject.next(criteria);
      }
    }
    

    注意

    完整的代码可在 GitHub 仓库中找到。

    现在,让我们回答这个问题,为什么我们选择BehaviorSubject而不是Subject

    在加载页面和点击filteredRecipes$以保留所有菜谱之间,如组合流部分所述。如果我们使用普通的Subject,标准只有在点击按钮时才会发出。这意味着在加载页面时,只有recipes$会发出,combineLatest会在所有流发出一个值之前等待,然后才会发出更多的值。在我们的 UI 中,我们将会得到一个空列表。

    然而,当我们使用BehaviorSubject时,它将立即为所有订阅者发出默认值,因此combineLatest将发出第一个值,然后一切都会正常工作,就是这样。看起来像是魔法,对吧?

    这里是搜索关键字Lemon时过滤后的菜谱示例:

    图 5.3 – 过滤后的食谱

    图 5.3 – 过滤后的食谱

    总结来说,为了解决本章中我们查看的使用案例,我们首先定义了负责获取数据的 数据流;然后,我们创建了动作流。之后,我们将流合并,对合并的流进行了一些操作,将其绑定到我们的模板中,并最终在过滤事件中更新了过滤值。

    现在,让我们突出一些在使用 combineLatest 时常见的陷阱。

    突出常见的陷阱和最佳实践

    这里有一些在使用 RxJS 中的 combineLatest 时应避免的常见陷阱或场景:

    不必要的订阅

    combineLatest 会自动订阅所有提供的 Observables。确保当你不再需要合并的值时,从返回的订阅中取消订阅,特别是对于长时间运行或无限的 Observables。这可以防止内存泄漏和不必要的处理。

    缺失或不完整的数据

    combineLatest 仅在其所有源 Observables 至少发射了一个值时才发射一个值。如果任何 Observables 在所有 Observables 发射之前完成或抛出错误,combineLatest 也会相应地完成或出错。如果你只需要一个 Observables 的最新值与另一个 Observables 的整个发射历史相结合,考虑使用 withLatestFrom 操作符。

    性能开销

    使用 combineLatest 组合大量 Observables 可能会引入性能开销。评估组合这么多流的需求,并考虑如果只需要所有 Observables 完成后作为一个单一数组,使用更简单的操作符,如 forkJoin。我们将在第十一章中深入探讨 forkJoin 操作符,执行批量操作

    混乱的错误处理

    combineLatest 会从其任何源 Observables 中传播错误。如果错误处理复杂,考虑使用自定义操作符来分别处理每个 Observables 的错误,然后再将它们组合。通过在单个 Observables 上使用 catchError 操作符或使用一个结合每个源发射和错误的自定义操作符来实现适当的错误处理。

    如您所见,combineLatest 是一个强大的操作符,用于组合多个 Observables,但了解其行为和潜在陷阱对于在 RxJS 应用程序中有效地使用它非常重要。根据您的特定用例选择正确的操作符,并优先考虑清晰和可维护的代码。

    摘要

    在本章中,我们开始了通过过滤数据的学习之旅,从对命令式模式的探索开始。然后,我们过渡到了 RxJS 中最常用的数据过滤模式之一,该模式可以在许多由动作触发数据更新的用例中使用。通过深入研究,我们概述了实现响应式模式所需的不同步骤,从创建流到使用combineLatest操作符进行组合。我们学习了该操作符的工作原理以及如何在实际应用中使用它。我们在模板中使用了组合流,并响应式地处理数据更新。最后,我们探讨了在使用combineLatest时需要避免的一些常见陷阱。

    组合流是 RxJS 中的一个基本概念,它使您能够构建更复杂和强大的异步数据处理。它允许您从各种来源合并数据,并应用操作符和转换来创建满足您特定应用需求的新流。

    现在我们已经了解了这个有用的模式,让我们继续学习下一个响应式模式,即转换流。

    第六章:转换流

    在处理流时,您将面临的最常见用例之一是需要将某些值的流转换为其他值的流。这正是本章的主题。

    本章围绕向我们的项目添加自动保存功能展开,我们将通过转换流来解决。首先,我们将解释我们将要在食谱应用中实现的自定义保存需求。然后,我们将探索实现此功能的命令式方法。之后,我们将了解实现此功能的声明式模式,并研究在此情况下最常用的 RxJS 转换操作符。

    最后,我们将深入研究 RxJS 提供的不同转换操作符及其相应的用例,通过实际示例丰富我们的理解。

    因此,在本章中,我们将涵盖以下主要内容:

    • 定义自动保存需求

    • 探索自动保存功能的命令式模式

    • 探索自动保存功能的声明式模式

    技术要求

    本章假设您对 RxJS 有基本的了解。

    更多关于响应式表单的详细信息,请参阅angular.dev/guide/forms/reactive-forms

    为了演示目的,我们将使用一个假自动保存服务。其实现可在本书 GitHub 仓库的recipes-book-api模块中找到。请注意,我们不会详细介绍这个服务,因为重点不是项目的后端。

    本章的源代码可在github.com/PacktPublishing/Reactive-Patterns-with-RxJS-and-Angular-Signals-Second-Edition/tree/main/Chap06找到。

    定义自动保存需求

    第二章中“浏览我们的应用”部分的视图 2 – 新食谱界面所述,用户可以通过点击新食谱菜单项来添加新食谱。这将显示以下需要填写的表单:

    图 6.1 – 新食谱表单

    图 6.1 – 新食谱表单

    负责显示RecipeCreationComponent的独立组件,可在recipes-book-front\src\app\recipe-creation下找到。

    在这里,我们想要实现自动保存行为,这包括自动将用户更改以表单形式存储。在这个例子中,我们将把表单更改存储在后端,以便用户在断开连接、超时或其他问题后随时检索最后更改——这个功能通过防止数据丢失来提高用户体验。

    既然我们已经理解了需求,让我们看看实现自动保存功能的命令式方法。

    探索自动保存功能的命令式模式

    我们使用了 Angular 响应式表单来构建valueChanges Observable 以跟踪FormControl的变化。这使得我们的实现更加简单,因为我们想监听表单值的变化,以便在每次变化时执行保存操作。

    您可以在recipe-creation.component.html文件模板中找到 HTML 代码。然后,在recipe-creation.component.ts中,我们可以定义表单如下:

    export class RecipeCreationComponent implements OnInit {
      constructor(private formBuilder: FormBuilder) { }
      recipeForm = this.formBuilder.group<Recipe>({
        id: Math.floor(1000 + Math.random() * 9000),
        title: '',
        ingredients: '',
        tags: '',
        imageUrl: '',
        cookingTime: undefined,
        yield: 0,
        prepTime: undefined,
        steps: '',
      });
      tags = recipeTags.TAGS;
    

    在这里,我们使用了 Angular 的FormBuilder API 来构建响应式表单,并将其传递给一个 JSON 对象,在该对象中我们定义了表单的不同字段。这个 JSON 对象代表了我们的食谱数据;我们稍后会保存这个数据。每次我们打开新食谱表单时,都会创建一个新的空对象。

    注意,这个 JSON 对象的第一属性id在表单中不会显示。我们只添加它来初始化新的Recipe对象,并使用随机标识符来正确地在后端保存食谱数据。tags属性是从src/app/core/model/tags.ts中声明的常量检索的,它代表了可用的静态标签列表。

    现在我们已经准备好了表单,让我们看看如何实现自动保存功能。首先想到的是在RecipeCreationComponentngOninit()实例中订阅recipeFormvalueChanges Observable。然后,每次valueChanges Observable 发出新的表单值时,我们应该发起一个保存请求来保存表单的最新值。我们可以这样做:

    ngOnInit(): void {
        this.recipeForm.valueChanges.subscribe(
          formValue => {
            this.service.saveRecipe(<Recipe>formValue);
          }
        );
    

    然后,在RecipeService中定义并实现了saveRecipe方法,如下所示:

    saveRecipe(formValue: Recipe) : Observable<Recipe>  {
      return this.http.post<Recipe>(`${BASE_PATH}/recipes`,
        formValue);
    }
    

    在这里,我们使用了HTTPClient API 并调用了后端的保存服务。

    注意

    本书不关注后端实现。因此,我们在recipes-book-api项目中提供了一个 POST 保存服务的模拟实现。在这里,目标是模拟对 HTTP 请求的调用以保存数据。

    因此,总结一下,RecipeCreationComponent的代码将如下所示:

    export class RecipeCreationComponent implements OnInit {
      constructor(private formBuilder: FormBuilder, private
      service: RecipesService) { }
      recipeForm = this.formBuilder.group<Recipe>({
        id: Math.floor(1000 + Math.random() * 9000),
        title: '',
        ingredients: '',
        tags: '',
        imageUrl: '',
        cookingTime: undefined,
        yield: 0,
        prepTime: undefined,
        steps: '',
      });
      tags = recipeTags.TAGS;
      ngOnInit(): void {
        this.recipeForm.valueChanges
          .subscribe(
            formValue => {
              this.service.saveRecipe(<Recipe>formValue);
            }
          );
      }
    }
    

    然而,这段代码不会工作。您现在应该知道,this.service.saveRecipe(<Recipe>formValue)的结果,它调用this.http.post<Recipe>(${BASE_PATH}/recipes, formValue),是一个 Observable,由于 Observables 是懒加载的,我们应该订阅this.service.saveRecipe(<Recipe>formValue)来初始化 HTTP POST 请求。所以,让我们添加一个subscribe值,如下所示:

    ngOnInit(): void {
        this.recipeForm.valueChanges.subscribe(
          formValue => {
            this.service.saveRecipe(<Recipe>formValue)
              .subscribe(
                result => this.saveSuccess(result),
                errors => this.handleErrors(errors)
              );
          }
        );
    

    如您可能已经注意到的,我们在另一个subscribe内部调用了一个subscribe值,这被称为嵌套订阅。然而,这在 RxJS 中被认为是一种反模式,并且存在几个问题:

    • 每次我们使用subscribe()时,我们打开了命令式代码的大门。正如我们在这本书中学到的,我们应该尽可能避免这样做。

    • 嵌套订阅需要仔细清理;否则,我们可能会遇到各种性能问题。在上一个例子中,我们没有清理订阅,这意味着可能会出现严重的时序问题。如果有多个表单值连续由valueChanges发出,将并行发送许多保存请求。如果请求需要一些时间来完成,无法保证后端会按顺序处理保存请求。例如,我们无法确保最后一个有效的表单值是已保存到后端的那一个。因此,我们最终会得到不一致的数据。

    我们想要做的是在先前的请求完成后执行保存请求。幸运的是,RxJS 包括一些有趣的操作符,可以为我们解决这个问题。所以,无需多言,在接下来的部分,我们将学习如何以响应式和声明式的方式实现这一点。

    探索自动保存功能的响应式模式

    你还记得第五章中的黄金法则,对吧?我们应该把所有东西都看作是一个流。所以,让我们首先识别我们的流。

    在这里,我们可以将保存操作看作是一个流——它是this.service.saveRecipe(<Recipe>formValue)方法的结果,该方法调用this.http.post<Recipe>(\({BASE_PATH}/recipes`, formValue)`。我们将称之为`saveRecipe\)`。

    saveRecipe$可观察对象负责在后台保存数据,并在订阅时初始化http请求。

    为了避免嵌套订阅,在这种情况下我们可以将valueChanges可观察对象发出的表单值映射或转换成saveRecipe$可观察对象。结果是我们要称之为高阶可观察对象的东西。

    不清楚?不用担心——我们将在下一节中详细解释。

    高阶可观察对象

    那么,什么是高阶可观察对象?一个高阶可观察对象就像其他任何可观察对象一样,但它的值也是可观察对象。所以,它不是发出简单的值,如字符串、数字或数组,而是发出你可以单独订阅的可观察对象。

    好吧,但它在什么时候有用?你可以在使用一个可观察对象发出的数据来发出另一个可观察对象时创建高阶可观察对象。在我们的例子中,对于valueChanges可观察对象发出的每个表单值,我们想要发出saveRecipe$可观察对象。换句话说,我们想要将表单值转换(或映射)到saveRecipe$可观察对象。这将创建一个高阶可观察对象,其中每个值代表一个保存请求。

    在这种情况下,valueChanges可观察对象被称为外部可观察对象,而saveRecipe$被称为内部可观察对象。在底层,我们想要订阅每个发出的saveRecipe$可观察对象并一次性接收响应,以避免嵌套处理。

    现在我们已经了解了高阶可观察对象是什么以及何时使用它们,让我们来看看高阶映射操作符。

    高阶映射操作符

    要转换外部 Observable,我们应该使用高阶映射操作符。这些操作符的作用是将外部 Observable 的每个值映射到一个新的内部 Observable,并自动订阅和取消订阅该内部 Observable。

    但常规映射和高阶映射之间有什么区别呢?

    好吧,常规映射涉及将一个值映射到另一个值。最常用的基本映射操作符之一是 map 操作符:

    图 6.2 – 地图操作符 – 琥珀图

    图 6.2 – 地图操作符 – 琥珀图

    如此琥珀图所述,地图操作符将通过将每个发出的值乘以 10 来转换输入流的值。这里,x=>10*x 是转换函数。

    另一方面,高阶映射是关于将一个值映射到一个 Observable。

    RxJS 提供了几个高阶映射操作符。在下一节中,我们将学习 concatMap() 操作符——我们将使用它来实现自动保存行为——然后再了解一些其他常用操作符。

    concatMap 操作符

    concatMap 是连接策略和高阶映射的组合:

    concatMap = concat (连接) + map (高阶映射)

    在上一节中,我们探讨了常规和高阶映射的概念,现在让我们通过以下琥珀图来了解连接策略,以 concat 操作符为例:

    图 6.3 – concat 操作符 – 琥珀图

    图 6.3 – concat 操作符 – 琥珀图

    让我们分解一下琥珀图:

    • 第一行表示传递给 concat 操作符的第一个 Observable 的时间线。

    • 第二行表示传递给 concat 操作符的第二个 Observable 的时间线。

    • 在此示例中,concat 操作符有两个输入。它将订阅第一个 Observable,但不会订阅第二个。第一个 Observable 将发出值 ab,这些值将反映在结果 Observable(最后一行)中。

    • 然后,第一个 Observable 完成,此时,concat 操作符订阅第二个 Observable。这就是保证顺序过程的方式。

    • 第二个 Observable 将发出值 xy,这些值将反映在结果 Observable 中。

    • 当第二个 Observable 完成,输出 Observable 也会完成。

    正如你可能注意到的,Observable 连接完全是关于 Observable 完成的。这是关键点。它发出第一个 Observable 的值,等待它完成,然后发出下一个 Observable 的值,依此类推,直到所有 Observable 都完成。

    现在我们理解了连接策略,我们可以理解concatMap运算符是如何结合高阶映射和 Observable 连接的:它在处理下一个之前等待每个内部 Observable 完成。它就像在票务柜台排队,每个客户(Observable)在下一个被叫到之前等待他们的服务。

    使用 concatMap 进行自动保存

    根据之前的讨论,concatMap运算符非常适合我们的自动保存需求,原因如下:

    • 我们希望将表单值转换为saveRecipe$ Observable,并自动订阅和取消订阅saveRecipe$内部 Observable - 这就是高阶映射操作所做的事情。

    • 我们只想在完成上一个请求后执行保存请求。当一个 HTTP 保存请求正在进行时,在此期间到达的其他请求应该在被调用之前等待其完成,以确保顺序性。因此,我们需要将saveRecipe$ Observables 连接起来。

    这就是我们的代码将看起来像这样:

      valueChanges$ = this.recipeForm.valueChanges.pipe(
        concatMap(formValue =>
          this.service.saveRecipe(<Recipe>formValue)),
        catchError(errors => of(errors)),
        tap(result => this.saveSuccess(result))
      );
    

    让我们分解一下正在发生的事情:

    • 在这里,外部 Observable this.recipeForm.valueChanges发出表单值。对于每个发出的表单值,concatMap将其转换为this.service.saveRecipe(<Recipe>formValue),这是saveRecipe$ Observable - 我们的内部 Observable。

    • concatMap自动订阅内部 Observable,并将发出 HTTP POST 请求。

    • 另一个表单值可能比在后台保存上一个表单值所需的时间更快。在这种情况下,表单值将不会映射到saveRecipe$ Observable。相反,concatMap将等待之前的保存请求返回响应并完成它,然后再将新的表单值转换为saveRecipe$,订阅它,并发送新的保存请求。当所有内部 Observable 完成时,结果流完成。

    • 然后,我们使用catchError运算符来处理错误,并使用tap运算符注册副作用以在后台记录Saved successfully消息。当然,您可以自定义此操作,并向最终用户显示一条消息。

    总结一下,RecipeCreationComponent的完整代码现在将看起来像这样:

    export class RecipeCreationComponent {
      constructor(private formBuilder: FormBuilder, private
      service: RecipesService) { }
      recipeForm = this.formBuilder.group<Recipe>({
        id: Math.floor(1000 + Math.random() * 9000),
        title: '',
        ingredients: '',
        tags: '',
        imageUrl: '',
        cookingTime: undefined,
        yield: 0,
        prepTime: undefined,
        steps: '',
      });
      tags = recipeTags.TAGS;
      valueChanges$ = this.recipeForm.valueChanges.pipe(
        concatMap(formValue =>
          this.service.saveRecipe(<Recipe>formValue)),
        catchError(errors => of(errors)),
        tap(result => this.saveSuccess(result))
      );
      saveSuccess(_result: Recipe) {
        console.log('Saved successfully');
      }
    }
    

    现在,只剩下一件事要做:我们应该订阅valueChanges$ Observable 以使所有这些工作。像往常一样,我们将在RecipeCreationComponent HTML 模板中的异步管道中这样做,如下所示:

    <ng-container *ngIf="valueChanges$ | async">
      </ng-container>
    /** All the form code here**/
    

    这样,响应式实现就完成了。

    如您可能已经注意到的,使用concatMap的第一个好处是我们不再有嵌套订阅。由于异步管道,我们还消除了显式订阅。除此之外,所有表单值都将按顺序发送到后端。

    当后端保存服务(我在提供的保存食谱服务实现中默认设置)引入延迟时,你会注意到在另一个请求仍在处理时不会发起请求。相反,它们会等待当前请求完成后才被触发。这正是concatMap旨在实现的效果。

    现在,让我们看看concatMap在 Chrome DevTools 网络标签页中表现出的行为:

    图 6.4 – concatMap 网络请求

    图 6.4 – concatMap 网络请求

    在这里,在输入一个字符后,会向服务器发送一个 POST 保存请求。当尝试在初始请求之前输入其他字符时,会收到响应。你会注意到请求不是立即触发的;它们被排队,并在前一个请求完成后按顺序执行。

    我们的示例直接说明了concatMap操作符的工作原理。然而,我们可以进一步优化我们的实现,以避免为用户输入的每个字符发送请求。为此,我们可以使用debounceTime(waitingTime)操作符等待用户输入稳定后再发送请求。我们还可以通过忽略重复项并让distinctUntilChanged()操作符处理无效值来进一步优化它。

    注意

    关于debounceTimedistinctUntilChanged操作符的更多详细信息,请参阅rxjs.dev/api/operators/debounceTimerxjs.dev/api/operators/distinctUntilChanged

    使用 concatMap 进行分页

    除了自动保存,我们还可以使用concatMap进行列表分页。在我们的食谱应用中,我们在客户端处理食谱列表的分页 – 当加载RecipesListComponent时,我们通过向/api/recipes服务发送GET请求来检索所有食谱,如第三章中所述,作为流获取数据

    然而,如果我们处理的是一个懒加载机制,在组件初始加载时只获取少量项目(比如说 10 个),然后在用户点击下一页上一页按钮时按需加载更多项目,如图所示,我们需要调整我们的逻辑:

    图 6.5 – 食谱列表分页

    图 6.5 – 食谱列表分页

    这将涉及发送一个GET HTTP 请求来获取下一页的数据,使用类似于GET /api/recipes?page=1&limit=10的 URL 结构。

    在这种情况下,concatMap是一个很好的选项来为每个发出的“下一页”事件发起请求,如下所示:

    recipes$ = this.pageNumberChange$.pipe(
        concatMap((pageNumber) =>
          this.http.get<Recipe[]>(`${BASE_PATH}/recipes`, {
            params: {
              page: pageNumber,
              limit: 10,
            },
          })
        )
      );
    

    在这里,pageNumberChange$ 是一个 BehaviorSubject 主题,当用户点击 concatMap 时,它会发射当前的页面编号,然后触发后续的 HTTP GET 请求,按顺序获取基于当前页面编号和大小限制参数的下一页列表。这种顺序处理确保了数据完整性和系统化的分页流程。

    总结一下,当你想确保操作按顺序处理,并且每个内部 Observable 都一次按顺序处理时,concatMap 是理想的选择。然而,当使用 concatMap 时,重要的是要确保内部 Observable 完成,因为 concatMap 等待每个内部 Observable 完成后才订阅序列中的下一个 Observable。如果一个内部 Observable 永远不完成,concatMap 也不会订阅序列中的后续 Observable。这可能导致阻塞后续发射,并可能导致挂起的 Observable 堆积,从而引发潜在的内存泄漏或性能问题。因此,永远不要为无限流使用 concatMap

    重要的是要注意,并非所有高阶映射操作符都遵循 concat 策略。还有其他高阶映射操作符,如 switchmergeexhaust,它们提供不同的策略,并在许多情况下很有用。我们将在以下部分中分析这些操作符及其相应的策略。

    The switchMap operator

    switchMap 是 switch 和转换(或映射)策略的组合:

    switchMap = switch(switch) + map(高阶映射)

    让我们看看 switch 操作符的弹珠图,以了解 switch 策略:

    图 6.6 –  操作符 – 弹珠图

    图 6.6 – switch 操作符 – 弹珠图

    让我们分析一下这里发生的事情(我知道你们不习惯看到那些对角线,我知道!):

    • 顶部行是高阶 Observable。高阶 Observable 发射第一个内部 Observable(具有值 abcd)。switch 操作符在幕后订阅它。

    • 第一个内部 Observable 发射值 ab,并且它们会自动反射到结果 Observable 中。

    • 然后,高阶 Observable 发射第二个内部 Observable(具有值 efg)。

    • switch 将取消订阅第一个内部 Observable(a-b-c-d)并订阅第二个内部 Observable(e-f-g);这就是为什么 efg 的值在 ab 之后立即反映出来。正如你可能已经注意到的,在切换过程中,如果一个新的 Observable 开始发射值,那么 switch 将订阅新的 Observable 并取消订阅之前的那个。

    因此,switchMap运算符是一个高阶映射运算符,它会取消订阅任何先前的内部 Observable,并切换到任何新的内部 Observable。当你想要在触发新操作时取消操作时,它非常有用。换句话说,switchMap只关注最新的数据,确保在取消由先前数据触发的任何正在进行的操作的同时,只处理最新的更新。

    switchMap想象成切换电视频道:每次你按按钮,你都会切换到不同的频道,忽略之前正在播放的内容。同样,switchMap允许你在源发射时动态切换到新的 Observable 流,丢弃之前发射的任何正在进行的处理。

    使用switchMap进行自动保存

    回到我们在食谱应用中的自动保存响应式实现,如果你想要保存最新的表单值,并且希望在当前操作完成之前启动新的操作时取消任何正在进行的保存操作,那么switchMap就是你要使用的运算符(而不是concatMap):

    valueChanges$ = this.recipeForm.valueChanges.pipe(
        switchMap(formValue =>
          this.service.saveRecipe(<Recipe>formValue)),
        catchError(errors => of(errors)),
        tap(result => this.saveSuccess(result))
      );
    

    如前所述,我在后端保存服务中引入了轻微的延迟,以说明当并发提交后续请求时,如何处理正在进行的请求。因此,当检查网络控制台时,你会注意到以下情况:

    图 6.7 – switchMap 网络请求

    图 6.7 – switchMap 网络请求

    在这里,我们有两个请求:一个是标记为已取消,另一个是挂起。挂起的请求是最新的一个,这表明它在先前的请求仍在进行时被发起,导致先前的请求被取消。这正是我们想要的行为。

    在这个例子中,我们正在发送一个 HTTP POST 请求,但我们只对成功或失败状态感兴趣,不需要从响应中获取任何其他数据。然而,如果你期望从 POST 请求中获取响应以更新 UI 或执行任务,请记住,只有最新请求的响应将被传播。在这种情况下,最好使用concatMap

    使用switchMap进行自动完成

    现在,让我们探索使用switchMap的另一个实际场景:自动完成建议。这是网络应用程序中一个非常常见的功能。在我们的食谱应用中,我们将实现RecipeCreationComponenttags字段的自动完成下拉菜单。目前,该字段以单选按钮的形式显示,其静态值是从src/app/core/model/tags.ts中定义的常量标签检索的。然而,我们将将其转换为一个用户友好的自动完成下拉菜单,该菜单根据用户的输入动态获取标签建议。

    当用户键入查询时,我们将从后端服务检索相应的标签。我们已经在我们的现成后端中实现了该服务 – 那就是我们的 recipes-book-api。此服务有一个名为 /api/tags 的端点,接受一些标准(用户的输入)作为查询参数,并返回与提供的标准匹配的标签列表。代码可在本书的 GitHub 仓库中找到。

    让我们深入了解实现细节。首先,让我们准备我们的流。我们有多少个流?我们有两个:

    • 一个名为 searchTerms 的流,它发出用户输入,由一个初始化为空字符串的 BehaviorSubject 主题表示:

      private searchTerms = new BehaviorSubject<string>('');
      

      我们将使用 update 方法来更新此流,每当用户输入更改时:

          updateSearchTerm(searchTerm: string) {
            this.searchTerms.next(searchTerm);
          }
      

      searchTerms 流和 updateSearchTerm 方法将在 RecipeCreationComponent 中可用。

    • 一个名为 getTags$ 的流,它发出与用户输入匹配的检索到的标签。我们将在 RecipesService 中定义此流,如下所示:

        getTags$: (term: string) => Observable<Tag[]> =
        (term: string) => {
          return this.http.get<Tag[]>(`${BASE_PATH}/tags`,
            {  params: { criteria: term }  });
        };
      

      getTags$ 代表一个函数,它接受一个字符串参数 term,并返回一个 Tag[] 类型的可观察对象,该对象发出一个 HTTP GET 请求以检索与提供的搜索词匹配的 Tag 对象数组。

      我们在 \src\app\core\model\tags.ts 中定义了 Tag 类型。

    现在,是时候使用 switchMap 了,它将映射 searchTerms 流发出的每个值到 getTags$ 可观察对象:

    tagValues$ = this.searchTerms.pipe(
    distinctUntilChanged(), // ignore if next search term is
                               same as previous
    switchMap((term: string) => this.service.getTags$(term))
    // switch to new Observable each time
      );
    

    我们将在 RecipeCreationComponent 中定义 tagValues$。所以,总的来说,tagValues$ 为每个唯一的用户输入发出搜索请求,并确保只显示最新的搜索结果,丢弃之前的搜索结果。

    最后,我们将更新 RecipeCreationComponent 的 HTML 模板,以修改显示标签的方式,从单选按钮更改为自动完成下拉菜单:

      <div class="col-3">
          <label for="Tags">Tags</label>
          @if (tagValues$ | async; as tags) {
              <p-autoComplete formControlName="tags"
              [suggestions]="tags"
              (completeMethod)=
              "updateSearchTerm($event.query)"
              field="name"></p-autoComplete>
          }
      </div>
    

    在这里,我们使用异步管道订阅了 tagValues$,并将发出的值存储在 tags 数组中。然后,我们使用了 PrimeNG 自动完成组件来提供在用户在 "Tags" 输入字段中输入时的建议。自动完成组件绑定到一个名为 "tags" 的表单控件,从 tags 数组接收建议,并在用户开始输入时触发 updateSearchTerm 方法,使用户的查询。concatMap 将为每个唯一的用户输入发出 GET 请求,并确保只显示最新的搜索结果,取消之前的请求。

    就这样!以下是我们实现行为的说明。在这里,当我们搜索字段中输入 B 时,我们收到 Breakfast 的建议:

    图 6.8 – 自动完成建议

    图 6.8 – 自动完成建议

    现在,让我们继续介绍另一个操作符,mergeMap

    合并映射操作符

    mergeMap 是合并和转换(或映射)策略的组合:

    `mergeMap = merge(merge) + map (高阶映射)

    现在你已经理解了高阶映射的概念,让我们通过查看以下考虑 merge 操作符的玛瑙图来了解合并策略:

    图 6.9 – 合并操作符 – 玛瑙图

    图 6.9 – 合并操作符 – 玛瑙图

    concat 不同,merge 不会等待可观察对象完成再订阅下一个可观察对象。它同时订阅每个内部可观察对象,然后将值输出到组合结果中。如这个玛瑙图所示,输入可观察对象的值会立即反映在输出中。结果只有在所有合并的可观察对象都完成后才会完成。

    mergeMap 是一个高阶映射操作符,它并行处理每个内部可观察对象。这就像厨房中的多任务处理,你同时处理不同的烹饪任务,如切菜、煮菜和搅拌,所有这些同时进行。然而,只有当结果顺序不重要时才应使用 mergeMap,因为这些请求可能会以不同的顺序处理。

    假设我们旨在检索匹配特定标签的食谱列表。对于每个标签,我们想要发起一个 HTTP 请求以获取该标签对应的食谱。标签请求的顺序不重要;所有请求都应并发执行。以下是代码:

      selectedTags$ = from(['Salty', 'Sweet', 'Healthy']);
      recipesByTag$ = this.selectedTags$.pipe(
        mergeMap(tag =>
          this.getRecipesByTag(tag)),mergeAll(),toArray());
      getRecipesByTag(name: string): Observable<Recipe[]> {
        return this.http.get<Recipe[]>(
          `${BASE_PATH}/recipesByTags`, { params: { tagName:
            name } });
      }
    

    在这里,我们从一个静态的标签数组中创建了一个名为 selectedTags$ 的可观察对象。selectedTags$ 逐个发出标签(数组元素)。每当 selectedTags$ 发出一个标签时,this.getRecipesByTag(tagName) 就会发出一个 HTTP 请求以获取相应的食谱。mergeMap 操作符用于并发处理多个标签请求。当另一个标签在之前的请求仍在进行时发出,新的请求将并发执行。

    我们使用了 mergeAll 操作符将来自不同内部可观察对象的结果扁平化到一个单一的观察者流中。这确保了每个内部可观察对象发出的食谱被合并成一个连贯的食谱流。

    最后,使用 toArray 操作符将所有发出的食谱转换成一个单一的数组,这使得在 UI 中进行列表显示变得方便。

    再次,我之前向按标签返回食谱的后端服务添加了延迟。打开控制台后,我们会发现所有请求都是并发运行的,即使有挂起的请求:

    图 6.10 – mergeMap 的执行

    图 6.10 – mergeMap 的执行

    我们还可以使用 mergeMap 并行从多个来源获取数据并合并结果。想象一下,我们有多个针对食谱的评论来源,我们想要收集它们(在这种情况下,顺序并不重要)。以下是代码:

      getRecipesReviews(recipeId: number): Observable<Review[]>
      {
        return from([`${BASE_PATH}/source1/reviews`,
        `${BASE_PATH}/source2/reviews`])
          .pipe(
            mergeMap((endpoint) => this.http.get<Review[]>(
              endpoint, { params: { recipeId: recipeId } })));
      }
    

    在这里,getRecipesReviews(recipeId: number) 是一个方法,它通过向每个来源(source1source2)发出并行的 HTTP GET 请求,从两个不同的来源获取由 recipeId 标识的菜谱的评论。

    from([${BASE_PATH}/source1/reviews, ${BASE_PATH}/source2/reviews]) 这一行从一个包含两个不同端点的数组创建了一个 Observable,用于获取评论。from 操作符将数组中的每个项目作为 Observable 序列中的单独值发出。

    然后,使用 mergeMap 发起一个 GET 请求以获取每个由 from 操作符发出的端点指定的 recipeId 的评论,确保这两个请求是并发执行的。

    有了这些,让我们继续讨论最后一个我们将要讨论的操作符,exhaustMap

    排放 Map 操作符

    exhaustMap 是排放和转换(或映射)策略的组合:

    *exhaustMap = exhaust(exhaust) + map ()高阶映射

    让我们看看这个水晶图来理解排放策略:

    图 6.11 – 排放操作符 – 水晶图

    图 6.11 – 排放操作符 – 水晶图

    顶部行是一个高阶 Observable,它在一段时间内产生三个内部 Observable。当第一个内部 Observable(exhaust 将会订阅它,以便 exhaust 操作符的值;它不会被订阅(这是 exhaust 的关键部分)。

    只有当第一个内部 Observable 完成时,exhaust 才会订阅新的 Observable。因此,exhaust 现在准备好处理其他 Observable。此时,第三个内部 Observable 出现。切换将订阅第三个内部 Observable 的值,g-h-i 的值反映在输出中。

    因此,exhaustMap 等待当前内部 Observable 完成后再允许下一个 Observable 发射值。一旦内部 Observable 完成,exhaustMap 就会订阅序列中的下一个 Observable。它确保一次只有一个内部 Observable 激活,忽略在当前一个仍在进行时发出的任何新的 Observable。这在需要忽略新事件直到先前的操作完成的情况下特别有用,例如处理按钮上的用户点击,后续的点击在当前操作完成之前会被忽略。

    这类似于当你忙于重要的事情时处理任务的方式。如果有人试图用新的任务来吸引你的注意力,你可能会说:“我现在很忙,请在我完成手头的事情之前不要打扰我。” exhaustMap 以类似的方式操作,确保在考虑新的任务之前完成当前的任务。

    让我们考虑在我们的菜谱应用中的一个场景,用户可以编辑菜谱详情并通过 exhaustMap 在这里很有用,因为它会忽略后续的保存请求,直到当前的保存操作完成。以下是实现方式:

      private saveClick = new Subject<Boolean>();
      private saveRecipe$ =
        this.service.saveRecipe(<Recipe>this.recipeForm.value);
      saveClick$ = this.saveClick.pipe(exhaustMap(() =>
        this.service.saveRecipe(<Recipe>this.recipeForm.value))
      );
      saveRecipe() {
        this.saveClick.next(true);
      }
    

    在负责编辑菜谱的组件中,我们定义了以下内容:

    • 一个私有的 saveClick 主题,用于跟踪 saveClick 代表我们的第一个流。

    • 一个私有的 saveRecipe$ Observable,它发出 HTTP 保存请求以保存食谱。

    • saveClick$,我们的第二个流,它监听 saveClick 发射并使用 exhaustMap 操作符在先前的请求完成后才发出新的保存请求。

    • 一个 saveRecipe 方法。当 saveClick 主题的值为 true 时,将调用此方法。

    最后,我们必须在 HTML 模板中使用异步管道订阅 saveClick$,并将点击处理程序添加到 saveRecipe 方法中,如下所示:

    <ng-container *ngIf="saveClick$ | async"></ng-container>
    <p-button class="recipe-button" (click)="saveRecipe()"
    label="Save"></p-button>
    

    这确保了一次只处理一个保存请求,防止重复条目或数据损坏。您可以在 RecipeCreationComponent 中测试此代码。

    您还可以在拖放功能中使用 exhaustMap,以确保只有在用户的拖动动作完成后才处理操作,防止同时触发多个操作。

    总结操作符

    让我们总结一下本章中提到的所有操作符:

    • 如果顺序很重要,并且您需要在等待完成时按顺序处理操作,那么 concatMap 是正确的选择

    • 如果顺序不重要,并且您需要并行处理操作以提高性能,则 mergeMap 是最佳操作符

    • 如果您需要添加取消逻辑来释放资源并始终获取最新信息,那么 switchMap 是正确的选择

    • 要在当前 Observables 仍在进行时忽略新的 Observables,请使用 exhaustMap

    您需要做的只是根据您的特定用例选择正确的操作符。

    摘要

    在本章中,我们首先展示了在食谱应用中实现自动保存功能的传统、命令式方法。然而,我们很快遇到了这种方法的一些局限性。在探索更反应式模式来应对这些挑战之前,我们强调了这些问题。

    然后,我们深入研究了高阶 Observables 和高阶映射操作符,学习了 concatMap 操作符的工作原理以及它如何帮助我们以反应式方式实现食谱应用中的自动保存要求。

    此外,我们还扩展了我们的探索,包括其他策略,即 mergeswitchexhaust 高阶映射操作符。我们通过使用实际示例和用例来解释它们的功能,以更深入地理解这些概念。

    在下一章中,我们将探讨另一种有用的反应式模式,允许您在组件之间共享数据。像往常一样,我们将揭示这些概念,然后学习以反应式方式实现它们。

    第七章:Angular 组件之间的数据共享

    在 Web 应用程序中,组件之间的数据共享是一个非常常见的用例。Angular 提供了许多在父组件和子组件之间进行通信的方法,例如流行的 @Input()@Output() 装饰器模式。@Input() 装饰器允许父组件向其子组件提供数据,而 @Output() 装饰器允许子组件向父组件发送数据。这很好,但当数据需要在深度嵌套或非直接连接的组件之间共享时,这些技术变得效率低下且难以维护。

    那么,在兄弟组件之间共享数据最好的方法是什么?这正是本章的核心。我们将首先解释共享数据的需求,然后逐步介绍如何在我们的应用程序中实现兄弟组件之间共享数据的响应式模式。最后,我们将介绍 Angular 的新特性可延迟视图,以最大化应用程序的性能。

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

    • 定义共享数据需求

    • 探索数据共享的响应式模式

    • 利用 Angular 17 中的可延迟视图

    技术要求

    本章节假设您已经对 RxJS 有基本的了解。

    本章节的源代码可在github.com/PacktPublishing/Reactive-Patterns-with-RxJS-and-Angular-Signals-Second-Edition/tree/main/Chap07找到。

    定义共享数据需求

    假设我们拥有四个组件 – C1C2C3C4 – 它们之间没有任何关系,并且这些组件之间共享信息 – 数据

    图 7.1 – 组件之间的共享数据

    图 7.1 – 组件之间的共享数据

    组件可以同时更新和消费数据。但在任何过程中,组件都应该能够访问数据的最后一个值。

    现在,让我们通过一个更具体的例子来使这个解释更加清晰。

    在我们的食谱应用程序中,当用户点击一个食谱时,它会被选中,但我们希望所有组件都能访问用户最后选中的食谱。在这种情况下,选中的食谱代表我们的共享数据。

    需要访问选中食谱的组件之一是 RecipeDetailsComponent 组件,因为它将显示选中食谱的详细信息。

    不再赘述,在下一节中,让我们看看如何以响应式的方式使这些数据对每个人可用。

    探索数据共享的响应式模式

    Angular 服务在创建组件之间共享数据和业务逻辑的常见引用方面非常强大和高效。我们将结合 Angular 服务和 Observables – 更具体地说,BehaviorSubject 实例 – 来创建具有状态的、反应式服务,这将使我们能够高效地在整个应用程序中同步状态。因此,在接下来的小节中,让我们解释实现反应式模式以在无关或兄弟组件之间共享数据的步骤。

    步骤 1 – 创建共享服务

    首先,我们将使用 Angular CLI 创建一个名为 SharedDataService 的 Angular 服务,就像通常在 src/app/core/services 文件夹下做的那样:

    ng g s SharedData
    

    注意

    在这里,我们为了演示目的将服务命名为 SharedDataService。虽然我们确实已经有一个名为 RecipesService 的服务可以容纳共享数据,但本章的目的是强调数据共享的更广泛概念。因此,我们选择了一个更通用的术语。然而,在你的应用程序中,建议使用特定且描述性的名称,如 RecipesService 或其他准确反映服务角色和领域的名称。一个准确反映服务目的的名称对于清晰性和可维护性至关重要,尤其是在像 Angular 这样的框架中,其中约定可以指导开发者。

    然后,在 SharedDataService 类中,我们需要创建以下内容:

    • 一个名为 selectedRecipeSubject 的私有 BehaviorSubject 实例,它发出当前选定食谱的值,这代表要共享的数据:

      private selectedRecipeSubject = new BehaviorSubject<Recipe | undefined>(undefined);
      

      在这里,selectedRecipeSubject 的类型是 Recipe,初始值是 undefined,因为最初我们没有选定任何值。

      此外,selectedRecipeSubject 被声明为 private,以确保它只能在定义它的 SharedDataService 内部访问,从而保护它免受外部操作。否则,任何外部进程都可以访问该属性,并随后调用下一个方法并更改发射,这是危险的。这种封装对于维护对状态的控制和防止意外更改非常重要。

    • selectedRecipeSubject 中提取的公共 Observable,命名为 selectedRecipe$,用于处理数据作为 Observable:

      selectedRecipe$ = this.selectedRecipeSubject.asObservable();
      

      在这里,我们使用了 Subject 类型中可用的 asObservable() 方法,从 selectedRecipeSubject 中派生出只读 Observable。这确保了 selectedRecipeSubject 的发射仅在只读模式下被消费,防止外部进程更改 selectedRecipeSubject 的值。

    • 一种名为 updateSelectedRecipe 的方法,用于更新共享数据,即选定的食谱:

      updateSelectedRecipe(recipe: Recipe) {
        this.selectedRecipeSubject.next(recipe);
      }
      

      此方法仅在 selectedRecipeSubject 上调用 next,以通知所有订阅者作为参数传递的最后一个选定的食谱。更新选定食谱的过程将调用此方法,我们将在下一步讨论。

    这是将所有部件组合在一起后的服务外观:

    import { Injectable } from '@angular/core';
    import { BehaviorSubject } from 'rxjs';
    import { Recipe } from '../model/recipe.model';
    @Injectable({
      providedIn: 'root'
    })
    export class SharedDataService {
      private selectedRecipeSubject = new
        BehaviorSubject<Recipe | undefined>(undefined);
      selectedRecipe$ =
        this.selectedRecipeSubject.asObservable();
      updateSelectedRecipe(recipe: Recipe) {
        this.selectedRecipeSubject.next(recipe);
      }
    }
    

    现在我们已经通过创建我们的共享数据服务和定义将保存共享数据的行为主题来打下基础,让我们看看我们如何在下一节中更新共享数据。

    第 2 步 - 更新最后选择的食谱

    当用户在RecipesListComponent组件中点击其中一个食谱卡片时,我们应该更新共享的selectedRecipe实例。作为提醒,以下是我们Recipe应用程序中的食谱卡片:

    图 7.2 – 食谱列表

    图 7.2 – 食谱列表

    为了在用户点击卡片时更新共享的selectedRecipe实例,我们需要在RecipesListComponent HTML 模板中结合(click)事件输出,这会触发editRecipe(recipe)方法的执行。这是所需的 HTML 代码:

    @if (filteredRecipes$ | async; as recipes) {
    <div class="card">
        <p-dataView #dv [value]="recipes" [paginator]="true"
        [rows]="9" filterBy="name" layout="grid">
            <ng-template let-recipes pTemplate="gridItem">
                <div class="grid grid-nogutter">
                    @for (recipe of recipes; track recipe) {
                    <div class="col-12" style="cursor:
                        pointer;" (click)="editRecipe(recipe)"
                            class="recipe-grid-item card">
    // extra code here
    </div>
    } @empty {
    <div>There are no recipes</div>
    }
                </div>
            </ng-template>
        </p-dataView>
    </div>
    } @else {
    <div>There are no recipes</div>
    }
    

    在这里,(click)事件绑定应用于每个卡片,确保点击时调用editRecipe(recipe)方法以更新selectedRecipe实例。

    RecipesListComponent中,我们按照以下方式实现editRecipe方法:

    editRecipe(recipe: Recipe) {
      this.sharedService.updateSelectedRecipe(recipe);
      this.router.navigate(['/recipes/details']);
    }
    

    editRecipe方法接受所选食谱作为输入并执行两个操作:

    • 它通过调用SharedDataService中可用的updateSelectedRecipe(recipe:Recipe)方法来通知selectedRecipeSubjectselectedRecipe的值已更改。因此,我们应该按照以下方式在RecipesListComponent中注入SharedDataService服务:

      import { SharedDataService } from '../core/services/shared-data.service';
      export class RecipesListComponent implements OnInit {
        constructor(private sharedService:
        SharedDataService) {}
      }
      
    • 它通过路由到RecipeDetailsComponent来显示食谱的详细信息,这是负责渲染和显示食谱详细信息的独立组件。我们在app-routing-module.ts文件中添加了以下路由配置:

      import { RecipeDetailsComponent } from
      './recipe-details/recipe-details.component';
      const routes: Routes = [
        { path: 'recipes/details',
        component: RecipeDetailsComponent},
      ];
      

    到目前为止,我们已经建立了更新共享数据值的机制。现在,剩下的只是监听共享数据并消费它。

    第 3 步 - 消费最后选择的食谱

    RecipeDetails组件中,我们需要消费最后选择的食谱以便显示其详细信息。因此,我们再次需要注入SharedDataService并定义selectedRecipe$ Observable——它将发出最后选择的食谱——如下所示:

    import { SharedDataService } from '../core/services/shared-data.service';
    export class RecipeDetailsComponent {
      constructor(private sharedService: SharedDataService) { }
      selectedRecipe$ = this.sharedService.selectedRecipe$;
    }
    

    然后,我们将使用RecipeDetailsComponent HTML 模板中的async pipe 订阅selectedRecipe$ Observable,以便显示所选食谱的详细信息,如下所示:

    @if (selectedRecipe$|async;as recipe) {
    <div>
        <span> {{recipe.title}} </span>
        <span> {{recipe.steps}} </span>
        <span> {{recipe.ingredients}} </span>
    </div>
    }
    

    就是这样——这就是你如何在应用程序中共享无关组件之间的数据的方法!

    现在,我们可以在任何地方使用食谱的最新值——我们只需要将SharedDataService注入到需要共享数据的组件中,并订阅发出只读值的公共 Observable。例如,我们可以在HeaderComponent中添加以下代码以在应用程序的标题中显示最后选择的食谱的标题:

    @if(selectedRecipe$|async; as recipe) {
        <div>
            <span> {{recipe.title}} </span>
        </div>
    }
    

    如果我们在这个组件中更改共享值,所有监听共享数据的其他组件都将收到通知以更新其过程。

    注意

    我们在 第五章 中使用了这种模式,即 结合流,将 RecipesFilterComponent 中的过滤器值与 RecipesListComponent 实例共享,然后我们将流合并以显示过滤后的结果。

    总结数据共享的反应式模式

    总结一下,以下是步骤的总结:

    • 首先,创建一个将在组件间共享的 Angular 服务。在这个服务中,定义一个私有的 BehaviorSubject 实例,该实例将向其订阅者发出共享值,记得指定 BehaviorSubject 发出的数据类型,并用共享数据的初始值初始化它。

      重要的是要注意,我们使用 BehaviorSubject 有两个主要原因:

      • 它允许我们将共享数据广播到多个观察者。

      • 它存储发送给其观察者的最新值,并且任何新的订阅者一旦订阅,就会立即接收到最后发出的值。

    • 接下来,在共享服务中定义一个公共 Observable,以保存只读的共享值。

    • 在共享服务中实现一个 update 方法,通过调用 Subject 类型的 next 方法来更新共享值,并将更新的值发出给订阅者。

    • 在负责更新共享数据值的组件中注入共享服务,并调用服务中实现的 update 方法。

    • 在消费共享数据值的组件中注入共享服务,并订阅服务中公开的 Observable。

    这种反应式共享数据模式有许多优点:

    • 它提高了无关组件之间数据共享的效率。

    • 它管理可变性风险。事实上,我们只向其他消费者公开只读的提取的 Observable,并保持 BehaviorSubject 为私有,从而防止共享数据被订阅者修改,这可能导致数据损坏和意外行为。

    • 它使得组件间的通信更加容易,因为你只需在需要的地方注入共享服务,并只关注数据的更新。

    就我而言,这是在 Angular 中在无关组件之间共享数据并管理应用程序状态的最简单方法。这在许多情况下都工作得很好,但对于有大量用户交互和多个数据源的大型应用程序来说,在服务中管理状态可能会变得复杂。

    在这些情况下,我们可以使用状态管理库来管理我们应用程序的状态。有许多优秀的状态管理库可以用于 Angular 的状态管理,它们都有一个共同点——它们都是建立在 RxJS Observables 之上的,状态存储在 BehaviorSubject 中。最受欢迎的状态管理库是 NgRx,你可以在ngrx.io/guide/store了解更多信息。

    数据共享机制促进了不同组件之间的通信,并提高了用户体验以及应用的响应性。在结束这一章之前,我想介绍一下 Angular 17 中引入的新特性,可延迟视图,它可以补充数据共享,并有助于创建更响应和高效的程序。让我们在下一节中看看它是如何工作的。

    利用 Angular 17 中的可延迟视图

    可延迟视图允许你声明性地标记模板的部分为非必需的立即渲染。这就像延迟页面某些部分的渲染,以提高应用的感知性能,以及优化初始包大小和加载时间。

    在许多实际场景中,延迟渲染可以帮助实现更快的加载时间,例如电子商务产品页面 – 在这个例子中,你可以最初显示必要的产品细节,然后在用户点击阅读更多按钮或滚动页面时懒加载额外的内容,如评论。

    让我们快速看看这是如何工作的。要懒加载一个组件,你需要使用独立组件,否则延迟将不起作用。然后你想要将独立组件包裹在@defer块中,如下所示:

    @defer {
      <delayed-component />
    }
    

    你还可以定义延迟组件何时应该加载的条件。你可以通过使用触发器来实现,这些触发器指定了启动加载的事件或情况:

    @defer(on viewport) {
      <delayed-component />
    }
    

    在这里,使用on viewport触发器来显示当delayed-component进入用户浏览器窗口的视口区域时。

    除了on viewport,还有其他可以使用的触发器,例如on hover,它仅在用户的鼠标悬停在延迟内容上时启动内容加载。你可以在这里找到可用的完整触发器列表:angular.dev/guide/defer#triggers

    此外,@defer块还有一些重要的子块。例如,你可以在延迟内容加载之前使用@placeholder子块显示替代内容,如下所示:

    @defer(on viewport) {
        <delayed-component />
    }
    @placeholder {
        <div>Placeholder text here</div>
    }
    

    除了@placeholder@defer还提供了两个其他子块 – @loading@error

    • @loading块类似于@placeholder块,但它专门显示在准备实际内容时的内容,如加载消息。

    • 当在获取或处理延迟内容时出现错误时,会显示@error块。这允许你在出现问题时提供友好的错误消息或替代内容。

    现在,让我们看看如何在我们的食谱应用中利用延迟渲染。鉴于每个食谱的图片具有高分辨率,让我们在RecipesListComponent HTML 模板中延迟图片的渲染,以便它们仅在用户悬停在图片上时显示:

    @defer (on hover) {
        <img class="recipe-image" [src]="'assets/recipes/'+
        recipe.imageUrl" [alt]="recipe.title" />
    }
    @placeholder {
        <div>Hover to load the image</div>
    }
    

    正如您所看到的,我们在显示图像的代码块周围使用了@defer块,并使用了on hover触发器。然后我们使用@placeholder块指定一些在延迟内容尚未加载时应显示的文本。在这种情况下,我们在<div>元素中添加了文本,悬停以加载 图像

    有关可延迟视图功能的更多详细信息,请参阅angular.dev/guide/image-optimization

    摘要

    在本章中,我们解释了组件间共享数据的动机,并学习了如何以反应式的方式实现它。首先,我们学习了如何结合 Angular 服务使用BehaviorSubject在无关组件之间共享数据并管理我们的应用程序状态。然后,我们强调了共享数据反应模式的优点。最后,我们探讨了 Angular 的新可延迟视图功能。

    本章涵盖的特性将帮助您为您的 Web 应用程序实现一个良好的架构,使其更具反应性和性能,提高加载时间,并降低维护成本。

    现在,准备好开始一段激动人心的旅程,因为在下一章中,我们将深入探讨一个全新的功能,称为 Angular 信号!我们将介绍一些使用信号的反应模式,并将它们整合到我们迄今为止所学的内容中。

    第三部分:Angular 信号的强大之处

    沉浸在激动人心的 Angular 信号世界中!

    在本节中,您将发现 Angular 信号的核心功能和优势,以及通过利用 Angular 信号和 RxJS 一起解锁反应性的潜力。我们还将介绍最新的 Angular 信号改进。

    本部分包括以下章节:

    • 第八章使用 Angular 信号掌握反应性

    第八章:掌握使用 Angular Signals 的反应性

    现代网络应用依赖于反应性,数据自动更改,从而触发 UI 的更新。Angular Signals 在版本 17 中引入,通过提供一种强大且简洁的方式来管理 Angular 应用程序中的反应性数据,简化了这一过程。

    本章深入探讨了核心概念、API 功能、优势以及 Signals 与 RxJS 之间的关系。我们还将看到如何使用 Angular Signals 进一步提高我们的食谱应用的反应性。

    因此,在本章中,我们将涵盖以下主要主题:

    • 理解 Signals 背后的动机

    • 揭示 Signal API

    • 解锁 RxJS 和 Angular Signals 的力量

    • 将 Signals 集成到我们的食谱应用中

    • 使用 Signals 进行反应性数据绑定

    技术要求

    本章的源代码可在github.com/PacktPublishing/Reactive-Patterns-with-RxJS-and-Angular-Signals-Second-Edition/tree/main/Chap08(这仅包括与食谱应用相关的代码)找到。

    理解 Signals 背后的动机

    Angular 团队引入 Signals 的主要目标是向框架添加更多细粒度的反应性。这个基于 Signal 的反应性系统标志着框架在处理动态数据和用户交互方面的重大飞跃。它提供了一种全新的方法来检测和触发框架内的更改,取代了依赖于 Zone.js 的传统方法。

    传统 Zone.js 方法

    Angular 的传统变更检测机制假设任何事件处理程序都可能更改绑定到模板的任何数据。这就是为什么,每当您的 Angular 应用程序中发生事件时,框架都会扫描所有组件及其数据绑定,以查找任何潜在的变化。这可能有点过于强硬,尤其是在复杂的应用程序中。因此,引入了更优化的模式 OnPush 变更检测。此模式利用不可变性和 Observables 的概念,使 Angular 能够显著减少需要检查更新的组件数量。这在 第三章作为流获取数据 中进行了探讨。

    无论您使用默认的变更检测还是更优化的 OnPush 模式,Angular 仍需要在事件处理程序运行完毕后保持知情。这提出了一个挑战,因为浏览器——而不是 Angular 本身——触发了这些事件处理程序。这就是 Zone.js 发挥作用的地方,它本质上充当了一个桥梁。Zone.js 可以检测到事件处理程序的运行,告诉 Angular,“嘿,有一个新的事件;你现在可以处理任何必要的更新了。”

    虽然这种方法在过去一直表现良好,但它仍然存在一些缺点:当进行更改时,整个组件树以及每个组件上的所有表达式都会被检查。Angular 无法直接识别已更改的组件或仅更新组件的更改部分。这就是为什么 Angular 无法对发生了什么做出任何假设,并需要检查一切的原因!

    新的信号方法

    使用信号,Angular 可以轻松检测应用程序数据中的任何部分何时发生变化,并自动更新任何依赖项。信号使高效的变更检测成为可能,当数据发生变化时,智能地重新渲染,并促进对 DOM 的细粒度更新,减少 Angular 检查所有组件所需的运行时,即使它们消耗的数据保持不变。最终,这可能在未来的某个版本中消除对 Zone.js 的需求:

    图 8.1:比较各种变更检测方法

    图 8.1:比较各种变更检测方法

    除了改进变更检测外,使用信号还有其他优点:

    • 它提供了一种更直观和声明式的方式来管理反应式数据

    • 语法与 JavaScript 更接近,使代码更容易阅读、理解和维护。

    • 编译器在您的反应式代码中执行更好的类型缩小,以改善类型安全性。

    随着我们进入本章,您将更清楚地了解信号。渴望了解更多?让我们继续。

    揭示信号 API

    在本节中,我们将深入信号的世界,详细介绍它们是什么,如何工作,以及它们给 Angular 带来的革命性变化。所以,无需多言,让我们来发现信号是什么。

    定义信号

    信号是 Angular 中的一个反应式实体,它封装了一个值(作为值的容器)并在该值发生变化时自动通知消费者。您可以将 Angular 的信号视为数据值和变更通知机制的组合,提供了一种跟踪变更和无缝更新用户界面的简化方法。

    虽然信号的概念并不新颖,并且多年来以各种形式存在于不同的框架中,但它们集成到 Angular 中为开发者提供了一个熟悉且强大的工具,用于管理应用程序内的反应式行为。它们作为值的包装器,允许你高效地跟踪变化并相应地做出反应。

    使用构造函数创建信号

    我们可以使用@angular/core包中可用的signal构造函数创建信号。

    信号必须始终有一个初始值,因为信号必须始终具有值。信号可以持有广泛的价值,包括简单的原始数据类型,如字符串和数字,以及更复杂的数据结构,如数组和对象。

    此外,signal函数提供了类型灵活性。你可以显式定义信号值的类型,或者根据初始值利用类型推断。例如,以下代码创建并初始化了一个值为John Doe的信号:

    import { signal } from '@angular/core';
    const name=signal('John Doe');
    

    在这个例子中,我们没有为 Signal 的值定义类型。如果你没有明确指定类型,signal函数可以根据你提供的初始值推断类型。因此,在这里,类型可以从John Doe的初始值推断为string

    但如果你想要更清晰地说明类型呢?这是完全可以的!以下是显式定义类型的示例:

    name = signal<string>('John Doe');
    

    如你所见,我们在signal之后添加了<string>来明确指出信号将持有字符串值。虽然类型推断在许多情况下都很好用,但显式定义类型可以提高代码的可读性和可维护性,尤其是在大型项目中。

    现在,让我们看看一个持有数组的信号示例。想象一下,你想定义一个表示货币数组的信号:

    currencies=signal(['EURO', 'DOLLAR', 'Japanese yen', 'Sterling'])
    

    这里,初始值是一个字符串数组,因此信号的类型将被推断为字符串数组,string[]

    现在,考虑我们的 Recipe 应用,favouriteRecipe信号持有Recipe对象,并且是Recipe类型:

    favouriteRecipe=signal<Recipe>({
      id: 1,
      title: "Lemon cake",
      prepTime: 10,
      cookingTime: 35,
      yield: 10,
      imageUrl: "lemon-cake.jpg"
    })
    

    这里,我们显式定义了特定的Recipe类型。如果你在代码中一直使用特定类型,显式类型定义可以提供清晰度并防止潜在的类型不匹配。然而,当类型与初始值不同或可能变化时,你可以避免显式编写它,使你的代码更简洁。

    一旦我们有了信号,我们通常想读取它并检索其值。但我们如何做到这一点?我们将在下一节中找到答案。

    读取信号

    读取信号值的一种方法是通过使用信号获取器。以下是一个示例,它读取先前创建的信号值并在控制台中记录它:

    console.log(this.name());
    console.log(this.favouriteRecipe());
    //console output
    John Doe
    {"id": 1,"title": "Lemon cake","prepTime": 10, "cookingTime": 35,"yield": 10,"imageUrl": "lemon- cake.jpg" }
    }
    

    你可以使用这个 getter 在你的 Angular 组件、服务和指令中读取信号。

    你也可以在你的组件模板中读取信号以显示值:

    @for (currency of currencies(); track currency) {
    <option>{{currency}}</option>
    } @empty {
    <div>There are no currencies</div>
    }
    <div>{{favouriteRecipe().title}}</div>
    

    在模板中读取信号返回当前信号值并将信号注册为模板的依赖项。如果信号发生变化,模板的部分将被重新渲染。

    使用信号创建函数创建的信号是可写的。这意味着你可以在创建后修改它们的值。我们将在下一节中学习如何修改信号值。

    修改可写信号

    使用creation函数创建的信号属于WritableSignal类型,并提供了一个专门用于更新其值的 API。修改可写信号存储值的两种主要方法如下:

    • 使用set方法

    • 使用update方法

    让我们来看看这两个。

    使用 set 方法

    set方法允许你直接为信号设置新值。以下是一个示例:

    name = signal('John Doe');
    console.log(this.name());
    this.name.set('Mary Jane');
    console.log(this.name());
    //console output
    John Doe
    Mary Jane
    

    在这里,我们使用了set方法将信号值从John Doe更新为Mary Jane。这是一种简单而有效的方法,在你知道如何更改值时分配新值。

    使用update方法

    update方法允许我们从上一个值计算出一个新值,如下所示:

    name = signal('John Doe');
    console.log(this.name());
    this.name.update(value=>'Full Name: '+ value);
    console.log(this.name());
    //console output
    Full Name: John Doe
    

    update方法中,我们在旧的信号值John Doe后面添加了Full Name:。信号会记录随时间变化的值变化记录。当值发生变化时,信号会通知已订阅的组件或逻辑,提示必要的 UI 或数据流修改。一旦值发生变化,依赖于该信号的 Angular 组件的每个部分都会自动更新。

    到目前为止,一切顺利!现在你已经熟悉了信号的基础知识,那么如果你能创建出能够自动对其他信号变化做出反应的信号怎么办?换句话说,如果你需要依赖于其他信号的信号怎么办?嗯,这就是计算信号发挥作用的地方!

    计算信号

    计算信号从其他信号中推断其值,提供了一种声明式的方式来定义信号之间的关系,并确保你的数据保持一致性。让我们通过一个简单的例子来了解其行为:

    import { signal, computed } from '@angular/core';
    const firstName = signal('John');
    const lastName = signal('Doe');
    const fullName = computed(() => `${firstName()} ${lastName()}`);
    

    在这个代码块中,fullName计算信号从firstNamelastName信号中获取其值。computed函数只是简单地将firstNamelastName的值拼接在一起。因此,fullName依赖于firstNamelastName信号,这意味着每当firstNamelastName发生变化时,fullName信号会自动更新,反映完整的姓名。

    注意,计算信号是惰性评估并缓存的。这意味着computed函数不会执行来计算其值,直到你第一次读取计算出的信号(在我们的例子中是fullName)。计算出的值随后被缓存,如果你再次读取fullName,它将返回缓存的值而不会重新执行计算函数。然后,如果firstNamelastName的值发生变化,Angular 知道缓存的fullName值不再有效,下一次你读取fullName时,其新值将被重新计算。因此,计算函数将再次执行。

    注意

    WritableSignals不同,计算信号是只读的,因此你不能更改它们的值。即使尝试设置值也会导致编译错误。

    现在我们已经了解了计算信号,它们会自动对其他信号的变化做出反应,那么如果你需要执行超出简单更新数据之外的操作,比如进行 API 调用或与其他组件交互,怎么办?这就是信号效果介入的地方!

    信号效果

    信号效果是响应信号变化而执行的函数。它们为我们提供了执行副作用的方式,例如记录数据,或操作 DOM 以执行自定义渲染或添加自定义行为。

    让我们看看一个例子。以下是在 HTML 模板中的代码:

    <button (click)="update()">Update</button>
    

    下面是一些 TypeScript 代码:

    counter = signal(0);
    constructor() {
      effect(() => {
        console.log('The updated value is', this.counter());
      });
    }
    update() {
      this.counter.update((current) => current + 1);
    }
    

    此代码创建了一个从 0 开始的计数器。然后,点击创建的 1effect 将更新的值记录到控制台。

    注意,effects 需要一个注入上下文才能正常工作,例如在组件或服务的构建期间。这就是为什么我们在前面的例子中将其放在构造函数中的原因。这意味着它需要在 Angular 的依赖注入系统可用的特定环境中调用。

    但为什么?嗯,因为 Signal effects 可能内部依赖于其他由依赖注入系统管理的 Angular 服务或功能。因此,我们应该确保所有必要的依赖都得到适当注入且可供 effects 正常工作。在当前环境之外运行可能会导致错误,因为这些依赖将不可用。

    在探索了 Signals 的核心功能和概念之后,你可能想知道它们与 RxJS 的比较。两者都提供了管理数据流的机制,那么它们有什么不同?它们可以一起工作吗?这些问题是我们将在下一节中解决的至关重要的问题。

    解锁 RxJS 和 Angular Signals 的力量

    虽然 Angular Signals 作为具有简化 API 的反应性数据轻量级包装器,但 RxJS 提供了一个用于处理异步流的综合库,因此对于处理更复杂的反应性编程需求至关重要。

    这里是 Signals 和 RxJS Observables 的简要比较:

    特性 Signals Observables
    值表示 一次只保留一个值。 随时间发射值。
    订阅 订阅是隐式的。 需要显式订阅。
    更新能力 通过使用 set/update 方法或使用计算 Signal 来更新。 通过发射新值来更新。
    变化检测 提高变化检测性能。Angular 可以高效地跟踪变化并在需要时重新渲染。 使用 Observables 可能会触发低效的变化检测。
    提供通知 当保留的数据发生变化时通知消费者,便于值重新计算或模板重新渲染。 当事件发生或数据被发射时通知消费者,便于值重新计算或模板重新渲染。
    对通知的反应 使用 effects 对通知做出反应。 使用回调对通知做出反应。

    图 8.2:Signals 与 Observables 的比较

    但你何时应该使用哪一个呢?嗯,RxJS 在需要复杂反应性数据流的场景中表现出色。以下是一些例子:

    • 管理多个流,通常由异步操作(如 HTTP 请求)引起

    • 处理复杂的数据操作,如组合、合并、转换和过滤

    • 对每次发射做出反应

    另一方面,Signals 适用于以下方面:

    • 在组件、副作用和计算中简单管理反应性数据

    • 在需要跟踪更改并触发目标 UI 更新的数据绑定场景

    • 当希望有更简单的语法和可能改进的变更检测性能时的情况

    信号和 RxJS 不是相互排斥的;它们可以是 Angular 开发的互补工具。Angular 有几个 RxJS 互操作功能,使得信号和可观察者在同一应用程序中协同工作,这意味着您可以为更强大的数据管理方式同时获得两者的好处。这些 RxJS 互操作功能可以在@angular/core/rxjs-interop包下找到,包括toSignal()toObservable()函数。我们现在将查看这两个函数。

    理解toSignal()的行为

    toSignal()函数允许您从可观察者创建信号。它提供了对从可观察者发出的值的同步访问,始终包含可观察者发出的最新值。但最酷的部分是toSignal()会自动订阅定义的可观察者,并在调用toSignal()的组件或服务被销毁时取消订阅。

    因此,我们不需要管理订阅。这个概念是否让你想起了异步管道?确实如此;信号和异步管道都提供了在 Angular 模板中显示响应式数据的方法。然而,信号提供了更大的灵活性。与主要用于模板中的可观察者的异步管道不同,信号可以在应用程序的任何地方使用,以实现高效的数据管理。

    但等等——之前我们了解到信号应该始终有一个值,而可观察者可能不会立即发出值。这是真的。这就是为什么toSignal有提供初始值的选项,这个值将代表信号直到可观察者发出。这里有一个简单的例子:

    import { toSignal } from '@angular/core/rxjs-interop';
    value$ = of([{ name: 'EURO', id: 1 }]);
    valueAsSignal = toSignal(this.value$, { initialValue: [] });
    constructor() {
    effect = effect(() => console.log(this.valueAsSignal()));
    }
    //console output
    { name: 'EURO', id: 1 }
    

    在这个例子中,我们使用of()创建函数创建了一个名为value$的可观察者,它发出数组[{ name: 'EURO', id: 1 }]。然后,我们使用toSignal函数创建了一个名为valueAsSignal的信号。我们向toSignal函数传递了两个参数:

    • this.value$: 您想要转换为信号的观察者。

    • { initialValue: [] }: 这是一个可选对象,允许您自定义信号的行为。在这里,我们将initialValue属性设置为空数组([])。这确保了即使在可观察者发出第一个项目之前,信号也有一个已定义的值。

    最后,我们注册了一个效果来在控制台记录信号值。

    注意,如果您在toSignal函数中没有提到初始值,信号将具有undefined作为初始值。请注意,使用undefined作为初始值可能会始终导致许多错误或不一致,因此最好在创建时管理它并提供初始值。

    明确这一点后,为什么这与使用可观察者不同?让我们关注另一个例子:

    import { toSignal } from '@angular/core/rxjs-interop';
    values$ = of(10, 20, 30);
    this.values$.subscribe(value=> console.log(value));
    //console output
    10, 20, 30
    

    在这里,我们创建了一个使用of创建函数的可观察对象——我们订阅它并在控制台记录值。这个可观察对象分别发出102030,这些值将记录在控制台。

    现在,让我们将这个可观察对象转换为信号:

    import { toSignal } from '@angular/core/rxjs-interop';
    values$ = of(10, 20, 30);
    valuesAsSignal = toSignal(this.values$, { initialValue: 0 });
    Constructor() {
      effect = effect(() =>
        console.log(this.valuesAsSignal()));
    }
    //console output
    30
    

    在这里,我们使用了相同的values$可观察对象,并通过toSignal将其转换为信号,同时设置了一个初始值0。然后,我们定义了一个效果来记录信号值。控制台输出是30。是的,只有30。为什么?

    of()创建函数在订阅时立即发出其值。因此,当toSignal订阅时,所有值都会立即发出。当效果计划运行时,30已经是信号中最后一个发出的值,这就是它在信号中记录的内容。

    现在,让我们使用delay操作符将values$的发射延迟 5 秒:

    import { toSignal } from '@angular/core/rxjs-interop';
      values$ = of(10, 20, 30).pipe(delay(5));
    valuesAsSignal = toSignal(this.values$, { initialValue: 0 });
      effect = effect(() =>
        console.log(this.valuesAsSignal()));
    //console output
    10, 20, 30
    

    当你重新执行代码时,你将在控制台看到102030。现在,效果有机会在每次发射后运行,因为我们设置了一个 5 秒的延迟。

    这里的问题是,当我们创建信号时,信号并不一定会通知所有发出的项目;这取决于可观察对象是如何创建的以及它的操作符集合。

    注意

    通过toSignal()函数创建的信号是只读的——这是有道理的,因为这里的信号只是可观察对象发出的值的消费者。此外,请注意,toSignal()创建了一个订阅——你应该避免对同一个可观察对象重复调用它,而是重用它返回的信号。

    关于toSignal(),你需要知道的就是这些。现在,让我们来探索toObservable()函数。

    理解toObservable()的行为

    如果你想要对信号的变化做出反应并执行异步操作,如发起 HTTP 请求,toObservable()函数是你的好朋友!

    toObservable()函数允许你将信号转换为可观察对象。每当信号值发生变化时,可观察对象会自动发出一个包含新值的通知。这允许你根据更新的信号数据轻松触发你的异步操作。在底层,toObservable()使用效果来跟踪信号值并向可观察对象发出最新值,正如本章前面所讨论的。

    toObservable()函数可能会让你想起可用于主题的asObservable函数,这是我们曾在第七章中探讨的,在 Angular 组件之间共享数据,但这两个函数的行为并不相同。

    首先,让我们看看asObservable函数的一个例子:

      value = new BehaviorSubject(10);
      constructor() {
        this.value.asObservable().pipe(tap(x=>console.log(
          `The value is : ${x}`))).subscribe();
        this.value.next(20);
        this.value.next(30);
    }
    //console output
    The value is : 10
    The value is : 20
    The value is : 30
    

    当使用SubjectBehaviorSubject时,通知是异步的。在这里,我们定义了一个初始值为10BehaviourSubject主题value。然后,我们使用asObservable()函数提取主题的readonly可观察者部分。每个发出的值都通过一个tap操作符传递以在控制台记录。然后,我们订阅以开始接收通知。最后,在constructor中,我们使用next方法发出新的值(2030)。

    然而,toObservable的行为不同。它使用一个效果,其中信号变化通知被安排而不是立即处理,就像可观察者(Observable)通知一样。让我们通过使用信号和toObservable来修改相同的示例:

    value = signal(10);
      constructor() {
        toObservable(this.value).pipe(tap(x=>console.log(
          `The value is : ${x}`))).subscribe();
        this.value.set(20);
        this.value.set(30);
    }
    //console output
    The value is : 30
    

    在这里,我们定义了一个名为value的信号(Signal),其初始值为10,而不是名为BehaviorSubject的主题。然后,我们调用toObservable(this.value)来在信号值变化时发出通知。在管道中,我们再次记录发出的值,并订阅可观察者以开始接收通知。最后,我们使用set方法更新信号值。

    然而,看看控制台输出——即The value is : 30。这可能不是你预期的,对吧?这是因为toObservable背后的效果仅在信号稳定了值之后才运行。当时信号的当前值是最后一个发出的值,即30

    当你决定使用主题(subject)或信号(Signal)时,请记住这种行为——主题将发出其来源的所有值,而toObservable只发出信号当前值。

    注意

    请注意,toObservabletoSignal函数需要一个注入上下文才能正常工作。

    随着我们深入探讨了toSignaltoObservable的强大功能,你可能会注意到 RxJS 和信号之间的协同作用潜力。在下一节中,我们将学习如何在我们的食谱应用中使用 RxJS 和信号,并得到两者的最佳效果。

    将信号集成到我们的食谱应用中

    在本节中,我们将通过集成信号(Signals)来提升食谱应用(recipe app)的响应式模式。我们将从回顾我们在第三章中实现的数据获取用例开始,然后看看我们如何通过在RecipesListComponent中使用信号来调整实现,以最大化效率。

    使用信号作为流来获取数据

    让我们简要回顾一下我们在RecipesServiceRecipesListComponent中实现数据获取所涵盖的代码片段。

    recipes.service.ts中,我们有以下代码:

    export class RecipesService {
      recipes$ =
        this.http.get<Recipe[]>(`${BASE_PATH}/recipes`);
      constructor(private http: HttpClient) { }
    }
    

    recipes-list.component.ts中,我们有以下代码:

    export class RecipesListComponent {
      recipes$ = this.service.recipes$;
      constructor(private service: RecipesService) {
    }
    

    最后,在recipes-list.component.html中,我们有以下代码:

    @if (recipes$ | async; as recipes) {
    // extra code here
    }
    

    在这里,recipes$ 是在 RecipesService 中创建的,它代表包含食谱列表的可观察对象。然后,recipes$RecipesListComponent 中定义,并在模板中使用异步管道进行订阅。这段代码在第 第三章 中进行了详细解释。

    现在,我们不再将 recipes$ 作为 RecipesListComponent 中的可观察对象(Observable)暴露,而是可以考虑在模板中使用信号(Signal)来绑定它。为了实现这一点,我们将使用 toSignal() 函数将 recipes$ 可观察对象转换为名为 recipes 的信号。

    首先,为了在单个位置集中管理数据,我们将在 RecipesService 中创建 recipes 信号:

    import { toSignal } from '@angular/core/rxjs-interop';
    export class RecipesService {
      recipes$ =
        this.http.get<Recipe[]>(`${BASE_PATH}/recipes`);
      recipes=toSignal(this.recipes$, {initialValue: [] as
        Recipe[]});
      constructor(private http: HttpClient) { }
    }
    

    在这里,recipes 信号是通过 toSignal 函数创建的,该函数接受两个参数:

    • This.recipes$:要转换为信号的观察对象。

    • {initialValue: [] as Recipe[]}:这是一个可选的配置对象,指定了一个空数组 [] 的初始值。这确保信号始终有一个值,即使在可观察对象发出任何数据之前也是如此。我们使用了 TypeScript 的 as 断言来定义 Recipe[] 的类型。

    注意

    我们可以通过删除 recipes$ 属性并将结果包含在 recipes 属性中来优化 RecipesService 代码,如下所示:

    recipes = toSignal(this.http.get<Recipe[]>(`${BASE_PATH}/recipes`), { initialValue: [] as Recipe[] });

    接下来,在 RecipesListComponent 中,我们将定义在 RecipesService 中创建的信号:

    export class RecipesListComponent {
    recipes = this.service.recipes;
    constructor(private service: RecipesService) {}}
    

    最后,由于 toSignal 会自动订阅 recipes$ 可观察对象,我们将 RecipesListComponent 模板中的 recipes$ |async 更改为 recipes(),以便它读取信号值:

    @if (recipes(); as recipes) {
    // extra code here
    }
    

    不需要做其他任何更改。如果你访问应用,列表会显示出来,我们的应用仍然可以正常工作。通过这种方式,我们保留了 RecipesService 中的基于可观察对象的逻辑,用于通过 HTTP 客户端管理异步操作,然后从该可观察对象创建一个信号用于模板。通过这样做,我们可以提高模板中的变更检测。

    那么,我们如何在我们的信号中处理错误呢?如果它们只是简单的值容器,它们如何产生错误?

    RecipesService 中,我们使用 catchError 操作符(在第 第四章反应式错误处理 中讨论)处理错误,并提供了替代的可观察对象:

    recipes$.pipe(catchError(() => of([])));
    

    当使用 toSignal 时,这段代码运行正常。这是一个在可观察对象级别处理错误的选择,以便当在 toSignal 中使用的可观察对象抛出错误时,稍后使用 catchError 操作符捕获该错误,并提供替代的可观察对象。

    然而,如果 toSignal 中调用的可观察对象重新抛出错误而没有处理它(在第 第四章 中详细说明的捕获和重新抛出策略),那么每次读取信号时都会抛出这个错误。因此,如果信号被读取多次,错误将反复抛出。

    因此,如果您打算重新抛出错误并在 UI 中显示弹出消息等操作,那么在 Observable 级别捕获错误并返回一个错误对象作为值是非常推荐的。以下是一个示例:

    observable$.pipe(
        catchError((error: HttpErrorResponse) =>of({ status: 'error', description: error })));
    

    在这里,我们有一个捕获HttpErrorResponse类型错误的 Observable,并返回一个包含状态(指示是错误还是成功)和错误描述的对象。在此阶段,您可以在组件级别注册一个效果来处理这个错误。

    另一个选项是使用toSignalrejectErrors参数完全拒绝错误:

      recipes = toSignal(this.http.get<Recipe[]>(`${BASE_PATH}/recipes`), { initialValue: [] as Recipe[], rejectErrors:true });
    

    当启用时,错误会被抛回 Observable,并成为未捕获的异常。你可以想象toSignal说,“我不想你的错误;把它们拿回去。”然后你可以注册一个全局错误处理器来处理未捕获的异常并执行你的操作:

    export class GlobalErrorHandler implements ErrorHandler {
        handleError(error: any): void {
          alert(error.message);
        }
    }
    

    注意

    如果在toSignal中使用的 Observable 完成,信号将继续返回完成前的最新发出的值。

    现在我们已经使用toSignal改进了我们的实现,并理解了它在处理错误时的行为,以及可用的各种推荐选项,让我们回到过滤流的概念,这是我们曾在第五章中探讨的,即结合流。我们将使用计算信号(computed Signals)来满足过滤需求,使用 RxJS 和信号(Signals)。

    使用信号结合流

    在食谱应用中,我们使用BehaviorSubjects实现了过滤,这有效地在过滤更改时通知组件以细化结果。然而,信号(Signals)也提供了一种机制来响应值的变化。它们可以在效果或计算信号(computed Signals)中触发操作。

    这个功能与BehaviorSubjects有些重叠,这引发了一个问题:我们能否用信号(Signals)来替换BehaviorSubjects以过滤流?让我们回顾一下在第五章中提供的代码。

    recipes.service.ts中,我们有以下代码:

    export class RecipesService {
      recipes$ =
        this.http.get<Recipe[]>(`${BASE_PATH}/recipes`);
      private filterRecipeSubject = new
        BehaviorSubject<Recipe>({ title: '' });
      filterRecipesAction$ =
        this.filterRecipeSubject.asObservable();
      constructor(private http: HttpClient) { }
      updateFilter(criteria: Recipe) {
        this.filterRecipeSubject.next(criteria);
      }
    }
    

    recipes-list.component.ts中,我们有以下代码:

    export class RecipesListComponent {
      recipes$ = this.service.recipes$;
      filterRecipesAction$ = this.service.filterRecipesAction$;
      filteredRecipes$ = combineLatest([this.recipes$,
        this.filterRecipesAction$]).pipe(
        map(([recipes, filter]: [Recipe[], Recipe]) => {
        const filterTitle = filter?.title?.toLowerCase() ?? '';
        return recipes.filter(recipe =>
        recipe.title?.toLowerCase() .includes(filterTitle))
      })
      );
      constructor(private service: RecipesService) {
    }}
    

    在这里,filterRecipesAction$是包含最新过滤值的 Observable。它在RecipesService中定义,并在RecipesListComponent中用于细化搜索。过滤值通过RecipesFilterComponentupdateFilter方法更新。filteredRecipes$代表过滤的结果;我们在RecipesListComponent模板中使用异步管道订阅了它。这段代码在第五章中有详细解释。

    现在,使用信号(Signals),我们可以用单个名为filterRecipe的信号替换BehaviorSubject和我们在RecipesService中创建的 Observable,并用空值初始化它:

    export class RecipesService {
      recipes =
        toSignal(this.http.get<Recipe[]>(
        `${BASE_PATH}/recipes`), { initialValue: [] as Recipe[]
        });
      filterRecipe = signal({ title: '' } as Recipe);
      constructor(private http: HttpClient) { }
      updateFilter(criteria: Recipe) {
        this.filterRecipe.set(criteria);
      }}
    

    在这里,我们创建了filterRecipe Signal 并将其初始化为空标准。在updateFilter方法中,它用于通知行为主题的变化,我们将简单地使用set方法更新 Signal 的值。

    然后,在RecipesListComponent中,我们不会使用combineLatest来组合流,而是创建一个计算出的 Signal,该 Signal 将根据 Signal 的过滤器和 Signal 的食谱列表返回一个食谱数组。然后,我们将使用相同的过滤函数根据过滤值细化食谱列表:

    export class RecipesListComponent {
      recipes = this.service.recipes;
      recipesFilter = this.service.filterRecipe;
      filteredRecipes = computed(() => {
        const filterTitle =
          this.recipesFilter()?.title?.toLowerCase() ?? '';
        return this.recipes().filter(recipe =>
          recipe.title?.toLowerCase()
          .includes(filterTitle));
      })
      constructor(private service: RecipesService) {
      }
    }
    

    最后,在RecipesListComponent模板中,我们将移除异步管道,并用对filteredRecipes Signal 的调用替换它,如下所示:

    @if (filteredRecipes(); as recipes) {
        // Extra code here// Extra code here
    }
    

    这样,我们就有更干净的代码和增强的变化检测机制。

    我们在第七章中使用了BehaviorSubjects,以在整个食谱应用中从RecipesList组件共享最后选中的食谱。然后,我们消费了最后共享的选中食谱,并在RecipeDetailsComponent中显示了其详情。让我们在这个实现中使用 Signal 达到相同的目的。

    使用 Signal 共享数据

    在深入使用 Signal 之前,让我们回顾一下第七章中涵盖的步骤。

    shared-data.service.ts文件中,我们有以下代码:

    export class SharedDataService {
    private selectedRecipeSubject = new BehaviorSubject<Recipe>({});selectedRecipeAction$ = this.selectedRecipeSubject.asObservable();updateSelectedRecipe(recipe: Recipe) { this.selectedRecipeSubject.next(recipe);
      }
    }
    

    recipe-details.component.ts文件中,我们有以下代码:

    export class RecipeDetailsComponent {
    constructor(private sharedService: SharedDataService) { }
    selectedRecipe$ = this.sharedService.selectedRecipeAction$;
    }
    

    并且在recipe-details.component.html文件中,我们有以下代码:

    @if (selectedRecipe$ | async; as recipe) {
    }
    

    selectedRecipeAction$是持有最新选中食谱的 Observable。它在SharedDataService中定义,并在RecipeDetailsComponent中用于显示详情。最后选中的食谱通过RecipeListComponentupdateSelectedRecipe方法更新。然后,我们在模板中使用异步管道订阅了selectedRecipe$。这个代码片段在第七章中有详细解释。

    现在,我们将从BehaviorSubject切换到SharedDataService中的 Signal。我们将初始化创建的 Signal,selectedRecipe,为一个空对象,并更改updateSelectedRecipe方法,使其使用set方法更新存储在selectedRecipe Signal 中的值:

    export class SharedDataService {
      selectedRecipe = signal({} as Recipe);
      updateSelectedRecipe(recipe: Recipe) {
        this.selectedRecipe.set(recipe);
      }
    }
    

    到目前为止,一切顺利——我们有一个总是持有最后选中食谱的 Signal。

    接下来,让我们在RecipeDetailsComponent中消费这个 Signal 的值。我们首先定义在SharedDataService中创建的 Signal,如下所示:

    export class RecipeDetailsComponent {
      constructor(private sharedService: SharedDataService) { }
      selectedRecipe = this.sharedService.selectedRecipe;
    }
    

    然后,在模板中,将selectedRecipe$ | async替换为selectedRecipe()以读取 Signal 的值。

    完成了。当运行此代码时,你会注意到功能保持不变。每次从列表中选择一个食谱时,RecipeDetailsComponent都会显示其详情。现在,让我们使用 Signal 和toObservable从服务器获取特定的食谱。

    使用 Signal 转换流

    考虑到前面的示例,显示在RecipesListComponent中的配方数组已经包含了所有配方对象及其详细信息,因此我们只需在列表中点击配方时使用客户端配方对象。

    现在,想象一下,我们需要根据其 ID 动态地从后端服务(具有/api/recipes/:recipeID端点)获取配方详情。这个服务在我们的recipes-book-api后端服务器中实现;代码可在本书的 GitHub 仓库中找到。这是我们可以如何调整我们的先前实现以处理此用例的方法。

    我们可以继续使用信号来跟踪当前选定的配方 ID。因此,在SharedDataService中,我们将调整我们的实现如下:

    export class SharedDataService {
      selectedRecipeId = signal<number | undefined>(undefined);
      updateSelectedRecipe(recipeId: number | undefined) {
        this.selectedRecipeId.set(recipeId);
      }
    }
    

    在这里,我们定义了一个名为selectedRecipeId的信号,它被初始化为undefined,因为我们没有初始选择。

    updateSelectedRecipe方法现在接受recipeId(可以是数字或未定义)作为输入,并使用set方法更新selectedRecipeId信号。

    现在,在RecipeListComponent中,我们将更新editRecipe方法,使其只发送配方标识符而不是整个配方对象:

    editRecipe(recipe: Recipe) {
        this.sharedService.updateSelectedRecipe(recipe.id);
        this.router.navigate(['/recipes/details']);
    }
    

    现在,我们需要在从列表中选择配方时发出异步 HTTP 请求以获取配方详情。Observables 非常适合这个过程!正如我们在第六章中学习的,转换流,我们需要一个高阶映射运算符来完成以下操作:

    • 将每个发出的配方标识符转换为一个新 Observable,该 Observable 发出 HTTP 请求

    • 当新的配方标识符到达时取消先前的 HTTP 请求,并切换到为最新 ID 创建的新 HTTP 请求

    你可能已经猜到了,但switchMap是这里使用的理想运算符。

    但等等!我们需要两个关键流参与这种情况:

    • HTTP 请求流:这个流是通过this.http.get<Recipe>(\({BASE_PATH}/recipes/\){id})创建的,它代表了基于提供的 ID 检索配方数据的实际 HTTP 请求。

    • 选定的配方 ID 流:目前,选定的配方 ID 存储在selectedRecipeId信号中。在这里,我们可以使用toObservable函数将selectedRecipeId信号转换为 Observable 流,该流将在选定的配方 ID 在信号中发生变化时发出通知。它看起来像这样:toObservable(this.selectedRecipeId)

    现在,使用switchMap运算符,我们将在SharedDataService中定义recipe$流,如下所示:

      recipe$ =
        toObservable(this.selectedRecipeId).pipe(filter(
        Boolean), switchMap(id =>
        this.http.get<Recipe>(`${BASE_PATH}/recipes/${id}`)
      ));
    

    生成的 Observable,recipe$,代表一个特定的配方对象流。每当选定的配方 ID 发生变化并且成功发起 HTTP 请求时,它都会发出一个新的配方。

    最后,在RecipeDetailsComponent内部,我们可以使用toSignal函数将recipe$Observable 转换回信号:

    selectedRecipe = toSignal(this.sharedService.recipe$);
    

    这允许我们使用信号在组件的模板中绑定配方数据。

    太棒了,对吧?这种使用信号的转换模式适用于每个需要在你 Angular 应用程序中组合或转换多个数据流的类似用例!

    通过利用 Angular 信号和 RxJS,你可以在 Angular 应用程序中实现一种平衡的反应性数据管理方法。这种和谐的融合允许你构建高度动态和响应式的用户界面。现在,让我们深入了解一些关于信号反应性数据绑定的有趣新特性。

    探索使用信号的反应性数据绑定

    Angular 的数据绑定能力一直在稳步提升,以支持反应性。从版本 17.1 开始,Angular 引入了一些强大的功能,利用信号在组件交互和数据绑定中实现反应性,例如输入信号、模型输入(从 17.2 版本开始)以及对内容和视图查询的支持。为了与输入信号保持一致,版本 17.3 提供了一个新的输出 API。

    我们将在本节中探讨这些新特性。

    信号输入

    Angular 的@Input装饰器用于在组件中定义一个输入属性,允许从父组件或模板传递数据到组件。它本质上创建了一个从父组件到子组件的单向数据流。

    Angular 17.1 引入了信号输入,允许将输入数据作为信号传递。这为 Angular 中父组件和子组件之间的数据绑定增添了强大的功能,将传统的 Angular 输入转换成了反应性数据源。以下是一个示例:

    TypeScript
    @Component({
      selector: 'app-shipping',
    })
    export class ShippingComponent {
      addressLine2 = input<string>();
      identifier = input(0);
      addressLine1 = input.required<string>();
    }
    

    在这个示例中,我们定义了三个信号输入:

    • addressLine2: 一个可选输入,可以存储字符串值或未定义。

    • identifier: 一个可选输入,存储一个数字,默认值为 0。

    • AddressLine1: 一个必需输入,存储字符串值。它使用input.required函数声明,默认情况下,输入是可选的(这就是为什么信号输入是类型安全的)。如果没有提供,将抛出编译错误,如下所示:NG8008:组件 ShippingComponent 必需输入‘addressLine1’必须 指定

    注意

    必需输入不能有默认值。因此,在它们被绑定之前,你不能读取它们的值,Angular 会抛出异常。因此,你无法在构造函数中访问它们的值。然而,你可以在ngOnInitngOnChangescomputedeffects中安全地访问值,因为它们只会在组件初始化后被触发。

    注意

    当在模板中引用时,信号输入将自动将OnPush组件标记为脏的。

    现在我们已经掌握了创建信号输入和理解它们的语法,你可能想知道如何使用它们。信号输入是只读的。你可以在模板中通过调用 getter 函数来访问值,如下所示:

    {{addressLine1()}}
    {{addressLine2()}}
    

    你也可以这样绑定到一个输入信号:

    < app-shipping addressLine1 ="2300 Vision Lane">
    < app-shipping [addressLine1]="addressProperty">
    < app-shipping [label]="addressAsSignalProperty()">
    

    在这个例子中,我们将信号输入属性 addressLine1 绑定到不同的值:一个名为 2300 Vision Lane 的字符串,一个组件属性名为 addressProperty,以及一个名为 addressAsSignalProperty() 的信号值。

    将绑定到信号打开了动态数据流全新水平的大门;父组件中对输入值所做的任何更改都将自动反映在子组件中。这就是真正的魔法所在。在下面的例子中,我们使用信号输入属性的名称来绑定值,但你可以使用以下语法提供一个输入名称的别名:

      identifier = input(0,{alias: 'id'});
    

    这允许你使用 <app-shipping [id]=50> 作为模板中的别名来引用输入,同时在组件内部仍然使用 this.identifier 作为属性名称。

    除了在模板中使用信号输入进行值绑定外,它们还可以在 effectscomputed 函数中使用。你在想如何做到这一点吗?让我们看看一些例子。

    在这里,我们将计算函数中 addressLine1addressLine2 的值附加起来以构建 fullAddress

      fullAddress = computed(() => `${this.addressLine1()}
        ${this.addressLine2()}` );
    

    可以使用 effect 函数跟踪信号输入的变化,如下所示:

    constructor() {
        effect(() => {
          console.log(this.identifier());
        });
    }
    

    在这个例子中,每当标识符输入发生变化时,都会调用 console.log 函数。这是一种跟踪值变化的新方法。

    因此,生命周期钩子如 ngOnInitngOnChanges 现在可以用 computedeffect 来替换,使值监控变得更容易。我们不需要在 ngOnInitngOnChanges 中实现额外的代码,只需简单地注册 effect 来监控值,并使用 computed 来执行自动计算。

    这样,我们就涵盖了信号输入的基本知识,它实现了一向数据绑定。接下来,我们将探讨如何使用信号实现双向数据绑定。

    模型输入

    模型输入与之前解释的信号输入类似,允许你将值绑定到一个属性中。然而,模型输入允许组件将值写入属性,而与其他只读的输入不同。这实现了双向响应式数据绑定,允许子组件不仅接收来自父组件的数据变化,还可以通知父组件其数据所做的任何更改。

    让我们来看一个例子。这里有一些 TypeScript 代码:

        identifier = model(0,{alias: 'id'});;
      constructor() {
        setInterval(() => {
          this. identifier.set("000524");
        }, 4000)
      }
    

    下面是 HTML 模板中的代码:

    <app-shipping [(id)]=counter></ app-shipping>
    {{counter()}}
    

    在 TypeScript 代码中,我们将名为 identifier 的信号输入转换为初始值为 0 的模型输入。然后,在构造函数中,我们设置了一个计时器,将在 4 秒后更新 identifier 输入的值。

    然后,在 HTML 模板中,我们简单地使用了双向数据绑定语法,将一个名为 counter 的属性绑定到父组件中定义的属性上,并显示 counter 的值。

    当运行此代码时,您将看到模型输入值在 4 秒后更新为000524,计数器属性也将以000524为其值。父组件会自动接收通知。

    在定义模型输入时,还有一点需要注意,即底层,Angular 为该模型生成一个输出。输出的名称只是模型输入名称后缀为Change

    <app-shipping [(id)]=counter (idChange)="updateMessage()" ></ app-shipping>
    

    在这里,我们调用了idChange输出并触发了updateMessagethat方法,该方法将在模型值变化时显示一个警告。每当您将新值写入模型输入时,idChange事件都会被触发。

    信号查询

    信号查询提供了使用@ContentChild@ContentChildren@ViewChild@ViewChildren装饰器声明的传统查询的反应式替代方案。信号查询将查询结果公开为信号,这意味着查询结果可以与其他信号(使用computedeffect)组合,并驱动变更检测。

    如需更多详细信息,您可以查看官方文档:angular.dev/guide/signals/queries

    摘要

    本章深入探讨了 Angular 信号。我们首先弄清楚信号存在的原因以及它们如何帮助管理数据反应式。

    然后,我们探索了信号 API,从创建和读取当前值到在值变化时使用计算信号和效果。

    接下来,我们比较了信号与 RxJS 的可观察对象。我们看到了每个的优点以及何时使用一个而不是另一个。Angular 甚至提供了特殊的互操作函数,允许信号和可观察对象很好地协同工作,包括toObservable()toSignals(),这两个我们都有所讨论。

    最后,为了将所有内容付诸实践,我们在我们的食谱应用中使用了信号,以了解它们在实际场景中与 RxJS 是如何协同工作的。这种动手经验帮助我们巩固了关于如何一起使用信号和 RxJS 的知识。我们还探讨了有关使用 Angular 信号进行响应式数据绑定和组件交互的最新改进。

    通过将 Angular 信号集成到您的 Angular 应用程序中,您可以简化数据管理,提高代码可读性,并利用响应式编程的力量。记住,信号和 RxJS 协同工作,让您能够构建动态和响应式的用户界面。

    在下一章中,我们将继续探讨多播的基本知识,这将在以下章节中很有帮助。

    第四部分:多播冒险

    在本部分中,我们将了解 RxJS 中多播的基本知识,以及许多实际用例中推荐的响应式模式,例如缓存数据、多个异步操作和实时功能。

    您还将深入了解使用多播操作符、Subject 和 Behavior Subject 的最佳实践,并学习在多播上下文中应避免的陷阱。

    本部分包括以下章节:

    • 第九章, 揭秘多播

    • 第十章, 使用响应式缓存提升性能

    • 第十一章, 执行批量操作

    • 第十二章, 处理实时更新

    第九章:揭秘多播

    多播指的是在多个订阅者之间共享相同的 Observable 执行。这个概念最初可能难以理解,尤其是对于那些不熟悉响应式编程范式的人来说。然而,它非常有用,解决了许多 Web 应用程序中的问题。

    在本章中,我将揭秘这个概念,解释何时何地使用它,RxJS 主题如何参与其中,以及它的优势。

    因此,在本章中,我们将涵盖以下主要内容:

    • 解释多播与单播的区别

    • 探索 RxJS 主题

    • 突出多播的优势

    技术要求

    本章假设您对 RxJS 有基本的了解。

    本章中的所有源代码都是用于演示目的,因此您不需要访问本书的 GitHub 仓库。

    解释多播与单播的区别

    在我们解释多播与单播之前,让我们先解释另一个关键概念,即生产者,我们将在本章中大量使用这个概念。

    生产者 是产生 Observable 值的来源——例如,DOM 事件、WebSockets 和 HTTP 请求被认为是生产者。它是任何用于获取值的 数据源。

    Observables 分为两种类型:

    • 冷,或单播,Observables

    • 热的,或多播,Observables

    让我们了解它们之间的区别。

    单播和冷 Observables

    在 RxJS 中,冷 Observable 就像是一个个人讲故事会话。想象一下你正在和一个朋友分享一个故事。你和他们一起讲述这个故事,这是你互动的独特体验。每次你和一个不同的朋友分享这个故事,就像开始了一个新的会话,有一个全新的叙述。

    在 RxJS 术语中,这意味着 Observable 本身生成它发出的数据。每次有人订阅 Observable,他们都会得到一个私人的讲故事会话。故事(或数据)不会在不同听众之间共享——这是一个一对一的体验。这就是为什么我们称冷 Observables 为“单播”——每个发出的值只被一个订阅者观察:

    图 9.1 – 单播冷 Observable

    图 9.1 – 单播冷 Observable

    因此,默认情况下,RxJS 中的 Observables 是冷的——它们为每个订阅者创建和传递数据,就像您的个性化讲故事会话一样。

    这里是一个冷 Observable 的例子:

    import { Observable} from 'rxjs';
    const coldObservable$ = new Observable(observer => {
      observer.next(Math.random());
      observer.next(Math.random());
      observer.complete();
    });
    /** First subscriber */
    coldObservable$.subscribe(data => {
      console.log(`The first Observer : ${data}`);
    });
    /** Second subscriber */
    coldObservable$.subscribe(data => {
      console.log(`The second Observer : ${data}`);
    });
    //console output
    The first Observer: 0.043246216289945405
    The first Observer: 0.7836505017199451
    The second Observer: 0.18013755537578624
    The second Observer: 0.23196314531696882
    

    让我们分析一下这段代码中发生了什么。

    在这里,Math.random() 是我们的生产者——它在 Observable 内部被调用。因此,数据是由 Observable 本身产生的。

    第一个订阅者在订阅后将会得到两个随机值,而第二个订阅者在订阅后将会得到两个不同的值。每个订阅者都会启动一个新的执行,导致 Math.random() 的新调用,从而产生不同的值。

    每个订阅者都得到自己独特的一组项目。它只在观察者订阅后才开始发出项目。由于有两个不同的执行,每个 Observable 都将接收到不同的值。这意味着数据是单播的,不会在订阅者之间共享。

    简单地看一个现实世界的例子,当用户登录应用程序时,他们的个人资料或仪表板信息会被检索并显示出来。这些数据对每个用户都是唯一的,不应该在多个用户之间共享。使用冷 Observable 确保每个用户在登录时都能收到个性化的数据,从而保持隐私和安全。所以,总结一下,对于冷 Observable,以下适用:

    • Observable 本身生成它发出的数据

    • 它只在观察者订阅后才开始发出数据

    • 每个观察者(或订阅者)都得到自己独特的一组项目

    现在,让我们看看热 Observable。

    多播和热 Observable

    在 RxJS 中,多播就像举办一场现场广播节目。想象一下,你从演播室播出节目,听众可以在任何时候调谐收听相同的内容。一旦开始播出,任何调谐收听的听众都能听到相同的音乐、访谈或讨论。

    在 RxJS 术语中,热或多播 Observable 是一个其发出的值在订阅者之间共享的 Observable。有一个单一的数据源,就像广播电台播出内容一样。当你订阅一个多播 Observable 时,你加入了“广播”,你将收到与其他调谐收听的任何人相同的数据:

    图 9.2 – 多播热 Observable

    图 9.2 – 多播热 Observable

    与每个订阅者都得到一个私有会话的冷 Observable 不同,多播允许多个订阅者同时收听相同的数据流。

    下面是一个热 Observable 的例子:

    import { Observable, fromEvent } from 'rxjs';
    // Hot Observable
    const hotObservable$ = fromEvent(document, 'click');
    hotObservable$.subscribe(({ x, y }: MouseEvent) => {
      console.log(`The first subscriber: [${x}, ${y}]`);
    });
    hotObservable$.subscribe(({ x, y }: MouseEvent)=> {
      console.log(`The second subscriber: [${x}, ${y}]`);
    });
    //console output
    The first subscriber: [108, 104]
    The second subscriber: [108, 104]
    

    让我们分析一下这段代码中发生的事情。

    我们使用 RxJS 的fromEvent函数创建了一个 Observable。当订阅时,这个 Observable 将发出 DOM 文档上的点击事件。

    注意

    关于fromEvent的更多详细信息,请参阅rxjs.dev/api/index/function/fromEvent

    在这种情况下,数据是在 Observable 外部发出的,正如你可能猜到的,两个订阅者将获得相同的数据。这意味着订阅者共享相同的 DOM 点击事件实例。因此,热 Observable 在多个订阅者之间共享数据。我们称这种行为为多播。换句话说,Observable 对所有订阅者进行多播。

    考虑另一个现实世界的场景,比如一个聊天应用,您可能有一个全局聊天服务,该服务公开一个热 Observable,表示来自聊天室中所有用户的消息流。多个组件,如消息源和通知,可以订阅这个热 Observable 以实时显示新消息,而无需为每个组件创建单独的 Observables。

    因此,总结一下,对于热 Observables,以下规则适用:

    • 数据是在 Observable 外部产生的

    • 它可能一创建就开始发射项目

    • 发射的项目在订阅者之间共享(多播)

    将冷 Observables 转换为热 Observables

    如果我们要将冷 Observable 转换为热 Observable,我们必须将生产者移出 Observable 之外——这样,我们的订阅者将接收到相同的数据。

    让我们回顾一下我们的冷 Observable 示例。我们不会在 Observable 内部生成值,而是在 Observable 外部使用Math.random()预先计算值,如下所示:

    const value = Math.random();
    const coldObservable$ = new Observable(observer => {
      observer.next(value);
      observer.next(value);
      observer.complete();
    });
    /** first subscriber */
    coldObservable$.subscribe(data => {
      console.log(`The first subscriber: ${data}`);
    });
    /** second subscriber */
    coldObservable$.subscribe(data => {
      console.log(`The second subscriber: ${data}`);
    });
    //console output
    The first subscriber: 0.6642828154026537
    The first subscriber: 0.6642828154026537
    The second subscriber: 0.6642828154026537
    The second subscriber: 0.6642828154026537
    

    如您可能已经注意到的,执行此代码后,所有订阅者都接收到相同的预先计算值。

    现在,在我们结束本节之前,让我们快速总结一下单播和多播:

    • 当您想要确保每个订阅者拥有独立的执行和为每个订阅者提供独立的数据流时,应该使用单播

    • 另一方面,当您想要确保多个订阅者共享相同的执行和结果时,应该使用多播,尤其是在涉及热 Observables、广播或缓存结果的场景中。

      多播还有助于在执行数据昂贵时优化和改进性能。作为一个快速的最终例子,假设 Observable 的执行是发起一个网络请求。如果我们选择冷 Observable(或单播),那么每个订阅者都会发起一个网络请求。相反,多播更适合这种特定场景,因为它将在订阅者之间共享网络请求的执行,从而避免冗余的请求调用。

    既然我们已经理解了多播和热 Observables,让我们来探索在 RxJS 中将值多播给观察者的最流行方式,即 RxJS subjects。

    探索 RxJS subjects

    Subjects是特殊类型的 Observables。而普通的 Observables 是单播的,subjects 是多播的,允许值被广播给所有订阅者。

    您可以将 subjects 视为同时是观察者和 Observables:

    • 您可以订阅 subjects 以获取生产者发出的值(这就是为什么它们充当 Observables):

    图 9.3 – RxJS subject

    图 9.3 – RxJS subject

    • 您可以通过使用Observer接口中可用的nexterrorcomplete方法来发送值、错误和完成信号(这就是为什么它们充当观察者的原因):

      const observer = {
        next: x => console.log('Observer got a next value: '
                               + x),
        error: err => console.error('Observer got an error:
                                    '+err),
        complete: () => console.log('Observer got a
                                    completion'),
      };
      

    简而言之,主题维护一个订阅者列表,并在新值发出时通知他们。但要深入一点,RxJS 中有多种主题类型。让我们探索最常用的几种。

    一个普通主题

    plainSubject是所有主题的父类型。让我们看一个快速示例:

    const plainSubject$ = new Subject();
    plainSubject$.next(10);
    plainSubject$.next(20);
    plainSubject$.subscribe({
        next: (message) => console.log(message),
        error: (error) => console.log(error),
        complete: () => console.log('Stream Completed'),
      });
    plainSubject$.subscribe({
        next: (message) => console.log(message),
        error: (error) => console.log(error),
        complete: () => console.log('Stream Completed'),
      });
    plainSubject$.next(30);
    //console output
    30
    30
    

    在前面的代码中,我们创建了plainSubject$,它发出了1020作为值。之后,我们创建了两个订阅者,它们记录了传入的值、错误和流的完成。最后,plainSubject$发出了一个值为30的值。

    执行此代码后,请注意,在控制台中只有30被追踪了两次。这意味着订阅者只接收到了30。为什么他们没有收到1020?因为那些值是在订阅plainSubject$之前发出的,并且每个在订阅之前发生的发射都会丢失。这就是普通主题多播值的方式。

    这就是普通主题的行为和值发射方式。

    你可以在你的 Web 应用程序中将主题用作通信中心,在 Angular 组件之间共享数据,正如我们在第七章中探讨的,在 Angular 组件之间共享数据

    此外,主题可以用来管理 Web 应用程序中的认证状态。例如,你可以使用主题在用户登录或登出时发出一个值。这个发出的值可以用来有条件地显示某些组件或根据用户的认证状态触发特定的行为。

    如果你希望为后来加入游戏的用户保留之前发出的值的缓冲区,那么ReplaySubject可以帮到你!

    replaySubject

    replaySubject是一种主题变体,类似于plainSubject,但具有内存功能:它们会记住并重放给新订阅者的之前的消息。重放主题有内存。

    让我们通过以下示例来解释它是如何工作的:

    const replaySubject$ = new ReplaySubject();
    replaySubject$.next(10);
    replaySubject$.next(20);
    replaySubject$.next(50);
    replaySubject$.subscribe({
      next: (message) => console.log(message),
      error: (error) => console.log(error),
      complete: () => console.log('Stream Completed'),
    });
    replaySubject$.subscribe({
      next: (message) => console.log(message),
      error: (error) => console.log(error),
      complete: () => console.log('Stream Completed'),
    });
    replaySubject$.next(30);
    //console output
    10
    20
    50
    10
    20
    50
    30
    30
    

    如你所见,所有值都被重放给了新的订阅者。现在,为了控制缓冲区大小(你希望Replay主题存储的值的数量),你可以在创建ReplaySubject时将其作为参数传递,如下所示:

    const  replaySubject$ = new ReplaySubject(2);
    

    这只会重放最后两个值。控制台输出将如下所示:

    20
    50
    20
    50
    30
    30
    

    作为实际应用案例,让我们考虑一个用户晚些时候加入聊天室的情况。使用ReplaySubject,他们仍然可以看到在他们加入之前发送的之前的消息。这对于向新用户提供完整的聊天历史非常有用。

    有了这个,让我们继续探讨Subject的另一个变体——BehaviorSubject

    BehaviorSubject

    BehaviorSubject只是ReplaySubject,缓冲区大小等于一,因此它只能重放之前的项。我们在第五章中使用了BehaviorSubject组合流

    BehaviorSubject 需要一个初始值,并且总是保留最后一个值,以便它可以将其发射给新的订阅者。换句话说,如果你有任何后来加入的订阅者,他们将获得流中之前发射的值。这将在你订阅时始终给你带来价值。

    下面是一个例子:

    const behaviourSubject$ = new BehaviorSubject(1);
    behaviourSubject$.next(10);
    behaviourSubject$.next(20);
    behaviourSubject$.next(50);
    behaviourSubject$.subscribe({
      next: (message) => console.log(message),
      error: (error) => console.log(error),
      complete: () => console.log('Stream Completed'),
    });
    behaviourSubject$.subscribe({
      next: (message) => console.log(message),
      error: (error) => console.log(error),
      complete: () => console.log('Stream Completed'),
    });
    behaviourSubject$.next(30);
    //console output
    50
    50
    30
    30
    

    在这里,behaviourSubject$ 被创建并具有初始值 1。然后,behaviourSubject 分别发射了 102050。在我们两次订阅 behaviourSubject$ 后,两个订阅者将立即接收到 behaviourSubject$ 发射的最后一个值,即 50 —— 这就是为什么在控制台中 50 被追踪两次的原因。最后,behaviourSubject$ 发射了 30;因此,订阅者将接收到 30 并追踪它。

    如果在订阅之前没有发射任何值,那么 behaviourSubject$ 将发射初始值,即 1

    const behaviourSubject$ = new BehaviorSubject(1);
    behaviourSubject$.subscribe({
      next: (message) => console.log(message),
      error: (error) => console.log(error),
      complete: () => console.log('Stream Completed'),
    });
    behaviourSubject$.subscribe({
      next: (message) => console.log(message),
      error: (error) => console.log(error),
      complete: () => console.log('Stream Completed'),
    });
    behaviourSubject$.next(30);
    //console output
    1
    1
    30
    30
    

    作为另一个例子,想象你正在构建一个显示当前温度的天气应用。你可以使用 BehaviorSubject 来表示温度数据。每当温度发生变化时,你都会用新值更新 BehaviorSubjectBehaviorSubject 的订阅者将始终接收到最新的温度,即使他们在温度多次变化后才开始使用应用。

    总结来说,PlainSubjectBehaviorSubjectReplaySubject 是 RxJS 中使用最频繁的主题,这就是为什么我们在这里讨论它们。然而,还有其他类型的主题,例如 WebSocketSubject,使用得较少(尽管我们将在 第十二章处理实时更新)中进一步探讨。有关其他类型的详细信息,请参阅 rxjs.dev/guide/subject

    注意

    在 RxJS 6 中,也有许多用于多播(或共享值/执行)的有用 RxJS 操作符,即 multicastpublishshareshareReplaypublishReplaypublishLastrefcount。有关这些操作符的更多详细信息,您可以查看官方文档:rxjs.dev/api/operators

    在版本 7 中,多播操作符被合并为 shareconnectableconnect。其他多播 API 现已弃用,将在 RxJS 8 中删除。唯一未被弃用的操作符是 shareReplay,因为它非常受欢迎。现在它是一个围绕高度可配置的 share 操作符的包装器。

    由于我们在本书中使用 RxJS 7,我认为遍历所有已弃用的操作符是无用的。相反,我们专注于 share 操作符,因为它满足大多数情况。我们将在下一章,第十章通过响应式缓存提升性能中,通过考虑一个现实世界的用例来学习 share 操作符的行为。

    有关 RxJS 7 多播操作符的详细信息,请参阅 rxjs.dev/deprecations/multicasting

    现在您已经很好地理解了多播以及 RxJS 提供的不同实现方式,让我们来探讨多播的主要优点。

    突出多播的优点

    在 RxJS 中进行多播或能够在多个订阅者之间共享相同的 Observable 执行有许多优点。以下是一些关键点:

    • 优化资源: 多播通过避免冗余处理来帮助优化资源。在处理昂贵的操作,如建立 HTTP 网络或执行复杂计算时,多播可以帮助您一次完成工作,并将结果共享给所有订阅者。

    • 一致的数据和结果: 多播确保所有订阅者都接收到由 Observable 发出的相同值集。在数据一致性至关重要的场景中,您希望所有订阅者观察相同的数据序列时,这可能至关重要。

    • 广播: 多播使您能够一次性将同一组值发送给多个订阅者。这就是我们所说的广播,当您有一个具有多个组件的复杂应用程序,这些组件需要响应同一组值时,这非常有用。

    • 迟到订阅者: 多播允许迟到订阅者接收与较早加入的订阅者相同的值。这是通过使用 BehaviorSubjectReplaySubject 以及一些多播 RxJS 操作符(例如 shareReplay 操作符)来实现的,我们将在 第十章 中解释,通过响应式缓存提升性能

    当使用 RxJS 设计应用程序时,多播是一个强大的机制,应该被考虑,因为它增强了应用程序不同部分之间的性能、效率、一致性和交互。然而,根据您的具体用例,谨慎使用多播并意识到潜在的风险是至关重要的。以下是一些例子:

    • 数据修改: 多播本质上会在所有订阅者之间共享相同的数据流。如果一个订阅者在它的订阅逻辑中修改了数据(例如,使用 mapfilter 等操作符),这种修改可能会无意中影响其他订阅者接收到的数据。这可能导致意外的行为和调试挑战。

      例如,想象一个多播 Observable 发射产品列表。一个组件订阅并过滤列表,只显示有折扣的产品。然而,这种过滤修改了原始数据流。如果稍后另一个组件订阅,期望获得完整的产品列表,它将只会收到由于第一次订阅中的意外修改而导致的折扣产品。这就是为什么将主题保持私有,并通过asObservable()方法仅公开数据的只读部分是一种常见且有效的做法。这确保了外部组件或消费者不能直接修改主题的内部状态。相反,他们只能观察发射的值,而不干扰数据流。

    • 内存泄漏:与单播 Observables 不同,单播 Observables 在单个订阅者取消订阅后即完成,而多播 Observables 只要至少有一个订阅者存在就会继续发射数据。如果你不仔细管理订阅,这可能会导致内存泄漏,尤其是在处理无限或长期存在的 Observables 时。

      例如,想象一个发射实时股票价格的多播 Observable。如果组件订阅了这个 Observable,但在不再需要时没有取消订阅,Observable 将继续发射,这可能导致由于 Observable 及其内部状态的引用积累而引起的内存泄漏。

    在下一章中,我们将探讨其他多播陷阱和最佳实践。

    摘要

    在本章中,我向您介绍了理解多播最重要的概念和词汇。我们首先解释了生产者的角色,然后学习了冷 Observables 和热 Observables 之间的区别,这使我们到达了多播和单播的定义。然后,我们探讨了 RxJS 的主题,不同类型的主题以及每种主题的使用案例,在介绍 RxJS 中的多播操作符之前。

    在下一章中,我们将在一个实际用例中练习所有这些内容。我们将学习如何通过在 RxJS 中使用多播,更具体地说,通过结合多播操作符和主题,在我们的食谱应用中实施一个高效的缓存机制。

    第十章:通过反应式缓存提升性能

    缓存数据和资源是我们提高 Web 应用程序用户体验最有效的方法之一。这是一个加快我们 Web 应用程序加载时间并保持网络请求数量最小化的好方法。

    我们将从这个章节开始,定义我们应用程序客户端的缓存需求及其动机。然后,我们将学习如何使用 RxJS 操作符反应性地实现这个需求。之后,我们将描述使用 RxJS 7 的最新特性来做这件事的更好方法。最后,我们将强调缓存流的一个其他用途,即用于副作用。

    在本章中,我们将涵盖以下主要主题:

    • 定义缓存需求

    • 探索使用反应式模式缓存流

    • 强调缓存用于副作用的使用

    技术需求

    本章假设你已对 RxJS 有基本的了解。

    本章的源代码可在github.com/PacktPublishing/Reactive-Patterns-with-RxJS-and-Angular-Signals-Second-Edition/tree/main/Chap10找到。

    定义缓存需求

    如你在前几章中学到的,HTTPClient模块是基于 Observable 的,这意味着getpostputdelete等方法返回一个 Observable。多次订阅这个 Observable 会导致源 Observable 被反复创建,因此每次订阅都会执行一个请求——正如我们在第九章,“揭秘多播”中学到的,这意味着它是一个冷 Observable。这种行为会导致 HTTP 请求的开销,这可能会降低你的 Web 应用程序的性能,尤其是如果服务器响应需要一些时间的话。

    通过在客户端缓存结果来减少 HTTP 请求是优化 Web 应用程序最常用的技术之一。客户端缓存涉及存储之前请求的数据,这样你就不需要向服务器发送重复的请求,从而损害你的应用程序性能。

    让我们用一个流媒体服务场景来形象地说明。想象一下,你正在流媒体服务上观看你最喜欢的电视剧。当你开始观看时,流媒体服务从互联网上获取剧集并将它们流式传输到你的设备。现在,假设你想倒退一点,再看一遍某个场景。而不是再次从互联网上获取剧集,流媒体服务已经将你观看过的剧集存储在一个特殊的记忆库中。这个记忆库允许你倒退并重新观看场景,而无需再次从互联网上重新获取。

    但我们何时应该缓存数据呢?当数据被共享(在您的应用程序中由多个组件使用)且不经常变化时,缓存数据并在多个组件之间共享它是非常有意义的。例如,用户的个人资料数据适合进行缓存。我们通常在用户登录后检索用户的个人资料信息,并且在用户会话期间它不会发生变化。

    此外,参考数据,如国家列表、货币或类别,也是缓存的主题。由于这些数据不经常变化,您可以将其缓存并在多个组件之间共享。

    RecipesApp的情况下,每当RecipesList组件被渲染以加载菜谱列表时,都会调用/api/recipes GET 请求。换句话说,无论何时用户点击菜谱应用的标志或在不同组件HomeComponentRecipeCreationComponent之间导航,都会发出 GET 请求,即使菜谱列表没有变化。

    下面的屏幕截图显示了网络选项卡中的引发请求:

    图 10.1 – GET HTTP 请求及其开销

    图 10.1 – GET HTTP 请求及其开销

    如您所注意到的,所有这些发出的请求都是由于在HomeComponent和其他组件之间的导航而产生的。

    在本章中,我们将假设菜谱列表不经常变化。在这种情况下,在每次组件加载时请求服务器是没有用的;最好是缓存结果并从缓存中读取数据以提高性能和用户体验。

    但如果出现新的菜谱怎么办?更新怎么办?好吧,我们可以利用两种技术来处理更新:

    • 我们可以在每个时间间隔之后更新缓存数据以检索数据的最新版本 – 这种技术称为轮询

    • 我们可以放置一个服务器推送通知以立即获取实时更新

    在本章中,为了通过基本示例了解 RxJS 中的缓存行为,我们将保持简单,并实现具有和没有刷新功能的客户端缓存。

    注意

    尽管我们将在本章中介绍轮询技术,但我们将介绍第十一章中的第二种技术,处理实时更新

    因此,无需多言,让我们看看我们如何实现这一点。

    探索反应式模式以缓存流

    您会高兴地知道,RxJS 附带了一个非常有用的操作符来实现流缓存机制 – 这就是shareReplay多播操作符。让我们看看。

    shareReplay 操作符

    在 RxJS 中,shareReplay的工作方式与流媒体服务的内存银行类似,与多个订阅者共享 Observable 的执行。当你订阅使用shareReplay的 Observable 时,它会获取数据,就像流式传输一个节目一样。然而,shareReplay会缓存或记住 Observable 发出的值。如果你稍后再次订阅,它不会再次获取数据,而是从其内存银行中重新播放缓存的值。

    当你有多个订阅者订阅 Observable,但你不想每个订阅者都触发新的数据获取时,这很有用。相反,你希望他们共享相同的数据集,就像多个观众共享相同的电视剧集一样。这可以提高性能并减少应用程序中不必要的重复数据获取。

    因此,简而言之,shareReplay操作符执行以下操作:

    • 与多个订阅者共享 Observable 的执行

    • 为订阅者提供重新播放指定数量的发射值的可能性

    现在,让我们看看我们如何使用shareReplay操作符来满足我们的需求。

    在 RecipesApp 中使用 shareReplay

    我们的目标是在我们的应用程序中缓存食谱列表。这由RecipesService中定义的recipes$流表示,如下所示:

    export class RecipesService {
    recipes$ = this.http.get<Recipe[]>(`${BASE_PATH}/recipes`);
    }
    

    recipes$流最初是一个冷 Observable,这意味着对于每个订阅者,流的数据都会重新发射,这会导致 HTTP 请求的开销。这不是我们想要的。我们希望与所有订阅者共享最后一个流的发射值——换句话说,我们希望使用shareReplay操作符将冷流转换为热流,如下所示:

    export class RecipesService {
    recipes$ =
    this.http.get<Recipe[]>(`${BASE_PATH}/recipes`).pipe(
    shareReplay(1));
    }
    

    通过传递1作为参数,shareReplay缓存了recipes$的最后一次发射。

    现在,让我们解释完整的数据共享工作流程:

    • 首先,初始化HomeComponent

    • 然后,HomeComponent触发子组件的渲染——即RecipesListComponent

    • RecipesListComponent加载RecipeService中可用的recipes$Observable。它将执行 GET HTTP 请求以检索食谱列表,因为这是我们第一次请求数据。

    • 然后,缓存将由从服务器返回的数据初始化。

    • 下次请求数据时,它将利用shareReplay操作符从缓存中检索。在底层,shareReplay操作符创建一个ReplaySubject实例,该实例将重新播放源 Observable 的所有未来订阅者的发射值。在第一次订阅之后,它将连接主题到源 Observable 并广播所有其值。

    这就是我们之前在第九章中解释的多播概念——“揭秘多播”。下次我们请求食谱列表时,我们的缓存将重新播放最新的值并发送给订阅者。不涉及任何额外的 HTTP 调用。因此,当用户离开页面时,它会取消订阅并从缓存中重新播放值。

    下面的图表也说明了完整的流程:

    图 10.2 – ShareReplay 执行

    图 10.2 – ShareReplay 执行

    当数据根本不需要刷新时,这工作得非常好。但如要求所述,我们需要每隔一段时间刷新RecipesList。如果使用轮询技术,我们可以这样更新缓存:

    import { switchMap, shareReplay, timer } from 'rxjs/operators';
    const REFRESH_INTERVAL = 50000;
    const timer$ = timer(0, REFRESH_INTERVAL);
    export class RecipesService {
    recipes$ = timer$.pipe(
        switchMap(_ =>
        this.http.get<Recipe[]>(`${BASE_PATH}/recipes`)),
        shareReplay(1)
      );
    }
    

    在这里,我们创建了一个每 50 秒发出一次的timer$Observable。这个间隔是在REFRESH_INTERVAL常量中配置的,使用 RxJS 中的timer函数创建timer$Observable。有关timer函数的更多详细信息,请参阅rxjs.dev/api/index/function/timer#examples

    然后,对于每次发出,我们使用switchMap操作符将值转换为 HTTP 客户端返回的 Observable。这将每 50 秒发出一个 HTTP GET 请求,并相应地更新缓存。

    这是一个已知的 RXJS 模式,用于每x秒执行一次处理。

    现在,让我们看看我们如何自定义shareReplay操作符。

    自定义shareReplay操作符

    在 RxJS 6.4.0 中,提供了一个新的shareReplay签名来定制操作符的行为。新的签名接受一个ShareReplayConfig类型的单个config参数,如下所示:

    function shareReplay<T>(config: ShareReplayConfig): MonoTypeOperatorFunction<T>;
    

    ShareReplayConfig接口包含以下属性:

    interface ShareReplayConfig {
      refCount: boolean;
      bufferSize?: number;
      windowTime?: number;
      scheduler?: SchedulerLike;
    }
    

    让我们了解每个属性的目的:

    • refCount: 如果启用refCount(设置为true),当没有订阅者时,shareReplay流将取消订阅源 Observable。因此,源将不再发出。这意味着如果稍后出现新的订阅者,则将创建一个新的流来订阅源 Observable。如果禁用refCount(设置为false),则不会取消订阅源,这意味着内部的ReplaySubject仍然会订阅源,并且可能永远运行。为了避免内存问题,强烈建议将refCount属性设置为true

    • bufferSize: 这指的是您想要回放多少个值。例如,如果您只想为每个新的共享流订阅者回放一个前一个值,那么您应该将1作为bufferSize值提及,如下所示:shareReplay({bufferSize: 1})

    • windowTime: 这指的是存储在缓冲区中的数据在毫秒内被发出到未来订阅者的时间限制。

    • scheduler: 这用于控制执行并提供一种管理并发的方式(有关更多详细信息,请参阅官方文档:rxjs.dev/api/index/interface/SchedulerLike)。

    在我们的案例中,我们需要将bufferSize配置为1以存储最新的值,并将refCount设置为true以防止内存泄漏。

    因此,使用shareReplayConfig对象,RecipesService的最终代码将如下所示:

    import { switchMap, shareReplay, timer } from 'rxjs/operators';
    const REFRESH_INTERVAL = 50000;
    const timer$ = timer(0, REFRESH_INTERVAL);
    export class RecipesService {
    recipes$ = timer$.pipe(
        switchMap(_ =>
        this.http.get<Recipe[]>(`${BASE_PATH}/recipes`)),
        shareReplay({bufferSize: 1, refCount: true })
      );
    }
    

    当在不需要自行完成的可观察对象上使用 shareReplay 时,始终要考虑 refCount 标志。

    既然我们已经了解了 shareReplay 的行为,我想谈谈从 RxJS 7 开始可用的改进,它允许您用 share 操作符替换 shareReplay 操作符。

    用 share 操作符替换 shareReplay 操作符

    share 操作符与 shareReplay 类似,但默认情况下,它没有缓冲区,并且在订阅时不会重放该缓冲区。

    使用 share 操作符,一旦订阅者计数达到 0,源可观察对象将自动取消订阅。另一方面,当 shareReplayrefCount 选项设置为 true 时,它在引用计数方面与 share 操作符的行为相似,但它还提供了重放发射值的能力。

    下面是一个比较两个操作符的表格:

    特性 share shareReplay
    行为 创建一个多播可观察对象。 创建一个多播可观察对象。
    重放 不重放之前的发射。它使用底层的 Subjects 重放最新的或指定数量的之前的发射给新订阅者。它使用底层的 ReplaySubject
    取消订阅逻辑 当最后一个订阅者取消订阅时取消订阅。 提供一个 refCount 选项,当最后一个订阅者取消订阅时取消订阅。默认情况下,refCount 设置为 false。但是,如果您将其保持为 false,即使订阅者计数达到零,源可观察对象也将保持活跃。这种情况可能存在风险,因为如果源可观察对象永远不会完成,可能会导致内存泄漏。

    图 10.3 – share 和 shareReplay 对比表

    在 RxJS 7 中,share 操作符通过将可选配置对象作为参数 share(config) 进行增强,使其更加灵活,并准备好执行其他操作符的工作,例如 shareReplay()。在这个配置对象中,有四个属性:

    • 连接器:使用此选项,您可以控制 share 是否重放发射。您可以选择连接的主题类型(例如 ReplaySubject)。

    • resetOnRefCountZero:使用此选项,您可以控制您的可观察对象何时应该重置。如果此选项启用,并且我们可观察对象的所有订阅都取消订阅,则可观察对象将被重置。然而,如果此选项被禁用,主题将保持连接到源。

    • resetOnComplete:如果启用,结果可观察对象将在完成时重置。

    • resetOnError:如果启用,结果可观察对象将在错误后重置。

    因此,shareReplay 实际上就是一个使用 ReplaySubject 作为连接器和特定重置策略的 share 操作符。

    以下代码展示了如何通过使用 share 操作符而不是 shareReplay 操作符来实现优化后的 shareReplay 操作符的行为:

      recipes$ = timer$.pipe(
        switchMap(_ =>
          this.http.get<Recipe[]>(`${BASE_PATH}/recipes`)),
        share({
          connector: () => new ReplaySubject(),
          resetOnRefCountZero: true,
          resetOnComplete: true,
          resetOnError: true
        })
      );
    

    上一段代码展示了与shareReplay操作符具有相同行为的share操作符。这是因为我们将ReplaySubject作为连接器进行引用,因此我们告诉share使用重放逻辑。

    然后,对于重置策略,我们启用了所有重置选项——resetOnRefCountZeroresetOnCompleteresetOnError——以获得优化的行为和增强的性能。

    就这样——通过使用share操作符,我们可以实现与shareReplay操作符相同的行为!

    注意

    除了shareReplay操作符之外,在 RxJS 7 中做了大量工作来巩固多播操作符。multicastpublishpublishReplaypublishLastrefCount操作符已被弃用,并将从 RxJS 8 中移除,唯一保留的操作符是shareReplayshareconnectable

    正如我们在本节中看到的,share操作符是万能的,这意味着在大多数情况下,强烈建议使用share操作符而不是connectableshareReplayshareReplay操作符非常流行,因此不建议弃用,但可能在未来的版本中弃用,特别是因为它有一个替代品,尤其是由于shareReplay如果不小心使用,可能会导致内存泄漏,尤其是在无限流的情况下。

    因此,如果你正在使用 RxJS 7,强烈建议调用share操作符而不是shareReplay

    现在我们已经学习了如何通过使用shareReplayshare操作符来缓存我们的数据以优化 HTTP 请求,并将这些操作符放置在RecipesApp中以缓存食谱列表,让我们发现另一个缓存流非常有用的场景。

    突出缓存用于副作用的使用

    本章中我们讨论的使用案例涉及优化 HTTP 请求以提升我们 Web 应用的性能。你所要做的就是将结果放入缓存,这个缓存作为所有消费者的共享位置。

    存在其他使用场景,其中缓存流非常有意义,特别是当考虑到流上的昂贵副作用时。一般来说,我们称在值发出后执行的操作为副作用。这可能是记录、显示消息、执行映射等等。

    这里是一个使用tap操作符的副作用示例:

    import {map, from } from 'rxjs';
    import { tap } from 'rxjs/operators';
    const stream$ = from([1, 2, 'Hello', 5]);
    stream$
      .pipe(
        tap((value) => console.log(value)),
        map((element) => {
          if (isNaN(element as number)) {
            throw new Error(element + ' is not a number');
          }
          return (element as number) * 2;
        })
      )
      .subscribe({
        next: (message) => console.log(message),
        error: (error) => console.log(error),
        complete: () => console.log('Stream Completed'),
      });
    //console output
    1
    2
    2
    4
    Hello
    Error
    

    在上一段代码中,我们对每个发出的数字执行转换,将其乘以 2,并返回乘积值。如果值不是数字,则会抛出错误。然而,我们需要在转换之前记录初始值。这就是为什么我们在map操作符之前调用tap操作符的原因——这样我们就可以记录原始值。这是一个基本的副作用示例,但其他副作用也可能发生,例如处理错误或显示消息。

    注意

    关于tap操作符的更多详细信息,请参阅官方文档:rxjs.dev/api/operators/tap

    在某些情况下,副作用可以执行比记录更复杂的其他操作,例如显示消息和处理错误。这可能包括一些代表性能上昂贵处理的计算。不幸的是,每个订阅者都会执行这些处理,即使只需要运行一次就足够了。否则,这会损害你应用程序的性能。

    如果你的应用程序中有这种用例,强烈建议你使用share操作符来缓存结果,并且只执行一次重处理。

    摘要

    在本章中,我们解释了网络应用程序中的各种缓存概念,包括它们的优点和用例。我们以我们的食谱应用为例,详细说明了需求,并实现了它。通过这个过程,我们了解了shareReplay操作符的行为,以及替代实现——即在 RxJS 7 中使用share操作符。最后,我们强调了缓存如何帮助我们处理应用程序中的重副作用。

    在下一章中,我们将探讨批量操作的响应式模式。

    第十一章:执行批量操作

    批量操作是在大规模上执行的任务,例如一次性上传多个文件、一次性删除或插入多个项目,或者同时对列表中的多个元素应用转换或计算。

    这些操作旨在处理单个操作中的多个更新,通常与单独处理每个项目相比,效率更高,性能更好。跟踪批量操作的进度对于向用户提供反馈、监控操作的健康状况以及识别潜在问题至关重要。

    在本章中,我们将首先解释批量操作需求以及我们将考虑的批量操作类型。然后,我们将向您介绍实现批量操作的响应式模式的各个步骤。最后,我们将学习用于跟踪批量操作进度的响应式模式。

    在本章中,我们将涵盖以下主要主题:

    • 定义批量操作需求

    • 学习用于批量操作的响应式模式

    • 学习用于跟踪批量操作进度的响应式模式

    技术要求

    本章假设您对 RxJS 有基本的了解。

    本章的源代码可在github.com/PacktPublishing/Reactive-Patterns-with-RxJS-and-Angular-Signals-Second-Edition/tree/main/Chap11找到。

    定义批量操作需求

    在 Web 应用程序中,批量操作由一个动作或事件表示;然而,在后台,有两种可能的行为:

    • 为所有任务运行一个网络请求

    • 为每个任务运行并行网络请求

    在本章中,我们将使用第二种行为。我们希望允许用户一次性上传菜谱图片,跟踪上传操作的进度,并向用户显示进度条。我们可以在这里看到它将是什么样子:

    图 11.1 – 上传菜谱的图片

    图 11.1 – 上传菜谱的图片

    RecipeCreation接口中,我们将更改ImageUrl字段的布局,将其更改为我们组件库 PrimeNG 中可用的文件上传布局,如图所示。文件上传布局允许用户选择多个文件、清除选择并上传文件。

    上传将在服务器上完成,我们有一个专门的上传服务,该服务接受要上传的文件和关联菜谱的标识符作为输入。由于后端上传 API 一次只支持一个文件,我们将并行运行N个网络请求来上传N个文件(即,如果我们上传两个文件,将发送两个请求)。这是我们将在本章中考虑的大规模更改用例。

    在 UI 中,我们将有一个事件,该事件将同时触发多个请求。以下图表提供了批量操作的图形表示:

    图 11.2 – 批量操作可视化

    图 11.2 – 批量操作可视化

    因此,总结一下,我们想要做以下事情:

    • 允许用户在点击一次 上传 按钮后上传多个文件

    • 显示此批量操作的进度

    既然我们已经定义了需求,让我们看看我们如何以响应式的方式实现它。

    学习批量操作的响应式模式

    如同往常,我们必须将我们的任务视为流。由于我们即将执行的任务是在后端上传食谱图片,让我们想象一个名为 uploadRecipeImage$ 的流,它将文件和食谱标识符作为输入并执行 HTTP 请求。如果我们有 N 个文件需要上传,那么我们将创建 N 个流。

    我们希望一起订阅所有这些流,但我们对每个流在过程中发射的值不感兴趣。相反,我们只关心最终结果(最后一次发射)——文件是否成功上传,或者发生错误导致上传失败。

    有没有 RxJS 操作符可以收集一组可观察对象以获得累积结果?幸运的是,是的:我们有 forkJoin 操作符。

    forkJoin 操作符

    forkJoin 操作符属于组合操作符类别。如果我们查看官方文档,我们会找到以下定义:

    “接受一个 ObservableInput 的数组或一个包含 ObservableInput 的字典对象,并返回一个 Observable,该 Observable 会以与传入数组相同的顺序发射值数组,或者以与传入字典相同形状的值字典。”

    换句话说,forkJoin 接受一个可观察对象的列表作为输入,等待可观察对象完成,然后将它们最后发射的值合并到一个数组中并返回。结果数组中值的顺序与输入可观察对象的顺序相同。

    让我们考虑以下大理石图来更好地理解这一点:

    图 11.3 – 一个 forkJoin 大理石图

    图 11.3 – 一个 forkJoin 大理石图

    在这里,forkJoin 有三个输入可观察对象(由操作符框之前的三个时间线表示)。

    第一个可观察对象发射的 forkJoin 不发射任何内容(查看操作符框之后的最后一个时间线,它代表了 forkJoin 返回的结果)。

    然后,第三个可观察对象发射了 forkJoin。为什么?因为,正如我们在定义中所说的,当所有可观察对象都完成时,forkJoin 才会发射一次。

    因此,如图中大理石图所示,当最后一个可观察对象(第二个)完成时,forkJoin 只发射了一次。让我们来分析一下:

    • 第三个可观察对象(由第三个时间线表示)首先完成,最后发射的值是 4

    • 然后,第一个可观察对象(由第一个时间线表示)完成,最后一个发出的值是forkJoin没有发出任何值,因为还有一个可观察对象正在运行。

    • 最后,最后一个可观察对象(由第二个时间线表示)完成,最后一个发出的值是forkJoin返回一个包含每个输入可观察对象结果的数组,顺序与输入可观察对象(ej4)的顺序相同。

    完成顺序不考虑;否则,我们会有[4,e,j]。即使第三个可观察对象在第一个和第二个可观察对象之前完成,forkJoin也尊重输入可观察对象的顺序,并在4j值之前返回e值。

    因此,请记住,当所有输入可观察对象都完成时,forkJoin会发出一次,并保留输入可观察对象的顺序。

    这很好地符合了我们的要求!forkJoin在您有一系列可观察对象且只关心每个可观察对象的最终发出值时使用最佳。这正是我们想要做的。在我们的情况下,我们将发出多个上传请求,并且我们只想在收到所有输入流的响应时采取行动。

    现在让我们看看批量操作响应式模式在实际中的应用。

    批量操作响应式模式

    要在我们的菜谱应用中利用此模式,首先,我们需要在src/app/core/services下创建一个名为UploadRecipesPreviewService的新服务,该服务负责上传文件。以下是该服务的代码:

    import { HttpClient } from '@angular/common/http';
    import { Injectable } from '@angular/core';
    import { Observable } from 'rxjs';
    import { UploadStatus } from '../model/upload.status.model';
    import { environment } from 'src/environments/environment';
    const BASE_PATH = environment.basePath
    @Injectable({
      providedIn: 'root'
    })
    export class UploadRecipesPreviewService {
      constructor(private http: HttpClient) { }
      upload(recipeId: number|undefined|null, fileToUpload:
      File): Observable<UploadStatus> {
        const formData = new FormData()
        formData.append('fileToUpload', fileToUpload as File)
        return this.http.post< UploadStatus >(
          `${BASE_PATH}/recipes/upload/${recipeId}`,
          formData
        )
      }
    }
    

    upload方法发出 HTTP 上传请求并返回上传状态(是否成功或失败)。此方法接受两个参数作为输入:

    • recipeId:菜谱的标识符

    • fileToUpload:要上传的文件

    然后我们使用FormData将文件发送到服务器。FormData是 JavaScript 中的一个对象,它允许您轻松构建一组键值对,分别代表表单字段及其值。

    现在我们需要实现RecipeCreationComponent模板的行为,我们需要指定当点击我们的onUpload方法时将被调用的方法——并将其作为值放入由我们使用的组件库提供的回调——uploadHandler——以在用户上传文件时触发。以下是 HTML 模板片段:

        <div class="form-row">
            <div class="col-12">
                <label for="ImageUrl">ImageUrl</label>
                <p-fileUpload name="imageUrl" [multiple]=true
                    [customUpload]="true" (uploadHandler)=
                        "onUpload($event.files)">
                </p-fileUpload>
            </div>
        </div>
    

    注意

    为了简洁起见,这里已经从模板中删除了一些代码。您可以在书籍的 GitHub 仓库中找到完整的模板代码,该链接可以在技术要求部分找到。

    接下来,我们需要实现onUpload方法并在RecipeCreationComponent中定义我们的响应式流。因此,我们将定义以下内容:

    • 一个BehaviorSubject,它将始终发出上传文件的最后一个值,称为uploadedFilesSubject$,并用空数组初始化它:

      uploadedFilesSubject$ = new BehaviorSubject<File[]>([]);
      
    • onUpload (files: File[])方法,当点击uploadedFilesSubject$时调用,并带有最后一个上传文件的数组如下:

        onUpload(files: File[]) {
          this.uploadedFilesSubject$.next(files);
        }
      
    • 一个名为 uploadRecipeImages$ 的流,负责执行批量上传,如下所示:

        uploadRecipeImages$ =
        this.uploadedFilesSubject$.pipe(
            switchMap(uploadedFiles=>forkJoin(
            uploadedFiles.map((file: File) =>
              this.uploadService.upload(
              this.recipeForm.value.id, file))))
        )
      

      让我们逐个分析这里代码中正在发生的事情。

      每次我们点击 uploadedFilesSubject$ 时,都会发射要上传的文件。我们需要监听 uploadedFilesSubject$ 的发射,然后使用 switchMap(我们在 第六章转换流) 将 uploadedFilesSubject$ 发射的每个值转换为我们将使用 forkJoin 构建的 Observable。

      对于 forkJoin,我们传递一个数组,其中包含负责上传每个文件的 Observables。我们通过将 uploadedFiles 数组中的每个文件映射到由调用 UploadRecipesPreviewService 中的 upload 方法生成的流来构建 Observables 数组,该方法接受来自 recipeForm 的菜谱的 id 属性(我们从中检索)和文件作为输入。

    现在我们已经建立了上传逻辑并定义了上传流,是时候订阅 uploadRecipeImages$ 流了。我们需要在构造函数中注入 UploadRecipesPreviewService 并在模板中订阅 uploadRecipeImages$,如下所示:

    <ng-container *ngIf="uploadRecipeImages$ | async"></ng-
      container>
    

    现在,假设其中一个内部流出现错误。forkJoin 操作符将不再为我们发射任何值。这是在使用此操作符时需要注意的另一个重要事项。如果你没有正确捕获内部 Observable 上的错误,你将丢失任何其他已经完成的流的值。因此,在这种情况下捕获错误是至关重要的!

    这就是我们处理它的方式:

      uploadRecipeImages$ = this.uploadedFilesSubject$.pipe(
        switchMap(uploadedFiles=>forkJoin(uploadedFiles.map((
          file: File) =>
          this.uploadService.upload(this.recipeForm.value.id,
            file).pipe(
              catchError(errors => of(errors)),
          ))))
    

    在这里,我们在 upload 方法返回的内部流上调用 catchError。然后,我们将错误包装在另一个 Observable 中并返回它。这样,forkJoin 流将保持活跃并发射值。

    捕获错误以向用户显示一些有意义的内容是非常有意义的 - 例如,在我们的案例中,如果上传失败是因为达到了最大图像文件大小或图像扩展名不被允许,那么系统应该向用户显示这样的异常,帮助他们修复文件。

    forkJoin 操作符的优点

    总结一下,forkJoin 有以下优点:

    • 当你对组合结果并只获取一次值感兴趣时,它非常有用

    • 它只发射一次,当所有 Observables 完成时

    • 它保留了输入 Observables 在发射中的顺序

    • 当其中一个流出现错误时,它将完成,所以请确保你处理了错误

    现在,在这个阶段,我们的代码运行得很好。但如果我们需要在过程中了解某些信息,比如已经上传了多少文件?操作进度如何?我们还需要等待多长时间?

    在当前的 forkJoin 实现中,这是不可能的,但让我们看看在下一节中我们如何做到这一点。

    学习反应式模式以跟踪批量操作进度

    跟踪大量操作的进度非常重要,因为它为用户提供反馈并可以识别潜在问题。当涉及到跟踪进度的方法时,根据大量操作的性质和所使用的技术堆栈,有不同的策略和技术。例如,你可以使用递增计数器来显示每次操作的处理情况,使用百分比来跟踪操作的进度,或者甚至将进度记录到文件或数据库中。

    在我们的食谱应用中,为了跟踪大量上传的进度,我们将使用完成百分比策略。为了实现此策略,我们将使用一个非常有用的运算符,称为finalize

    finalize运算符允许你在 Observable 完成或出错时调用一个函数。想法是调用此运算符并执行一个计算进度的函数。这样,每次 Observable 完成时,进度都会得到更新。

    这就是代码的样子:

      counter: number = 0;
      uploadProgress: number=0;
    uploadRecipeImages$ = this.uploadedFilesSubject$.pipe(
      switchMap(uploadedFiles =>
      forkJoin(uploadedFiles.map((file: File) =>
        this.uploadService.upload(this.recipeForm.value.id,
          file).pipe(
            catchError(errors => of(errors)),
            finalize(() => this.calculateProgressPercentage(
              ++this.counter, uploadedFiles.length))
          ))))
      )
      private calculateProgressPercentage(completedRequests:
      number, totalRequests: number) {
        this.uploadProgress =
          Math.round((completedRequests / totalRequests) *
            100);
      }
    onUpload(files: File[]) {
      this.uploadProgress=0;
      this.counter=0;
      this.uploadedFilesSubject$.next(files);
    }
    

    finalize运算符调用calculateProgressPercentage私有函数,该函数接受以下参数:

    • 完成的请求数量:我们只声明一个counter属性,每次 Observable 完成时我们将增加它

    • 请求总数:此数字是从uploadedFiles数组中检索的

    calculateProgressPercentage函数内部,我们执行一个简单的计算来识别完成百分比并将结果存储在uploadProgress属性中。当用户点击uploadProgresscounter属性时,应将它们重置为0

    然后,你可以将此属性的值映射到 UI 中的任何ProgressBar组件。在我们的例子中,我们使用了 PrimeNG 的p-progressBar组件,如下所示:

        <div class="row">
            <div class="col-12">
                <label for="ImageUrl">ImageUrl</label>
                <!-- <input type="text" name="imageUrl"
                formControlName="imageUrl"> -->
                <p-fileUpload name="imageUrl" [multiple]=true
                    [customUpload]="true"
                    (uploadHandler)="onUpload($event.files)"
                    accept="image/*"></p-fileUpload>
                @if(uploadProgress>0) {
                <p-progressBar [value]=uploadProgress>
                    </p-progressBar>
                }
            </div>
        </div>
    

    在这里,我们只在上传过程中显示p-progressBar(当uploadProgress>0时)并将uploadProgress值作为输入传递给进度组件。这样,你就能向用户显示进度。

    在我们的应用中,这是结果:

    图 11.4 – 文件上传进度条

    图 11.4 – 文件上传进度条

    摘要

    在本章中,我们解释了大量操作的概念,并学习了如何以响应式的方式实现一个实际的大量任务示例。我们学习了forkJoin运算符的行为和用例,并了解了实现大量上传的不同步骤。最后,我们通过使用finalize运算符实现跟踪进度功能的方法进行了响应式技术介绍。

    在下一章中,我们将探讨实时更新模式以及 RxJS 中可用的不同技术,以最低的成本实现它们。

    第十二章:处理实时更新

    实时指的是应用程序能够立即处理和响应数据或事件的能力,没有任何明显的延迟或延迟。这在当今是一个非常热门的话题,因为对实时功能的需求在 Web 应用程序中不断增长,尤其是在实时金融交易、实时跟踪系统、实时监控、分析和医疗保健等领域。最终,你获取数据越快,你就能越快做出反应和决策,从而提高获得更高利润的机会。

    那么,你如何在前端处理实时消息并自动更新 UI 中显示的数据?这正是本章将要涵盖的内容。我们将首先解释实时要求,然后我们将向您介绍实现消费实时更新的反应式模式的各个步骤。最后,我们将学习用于处理重连的反应式模式。

    在本章中,我们将涵盖以下主要主题:

    • 定义实时要求

    • 学习用于消费实时消息的反应式模式

    • 学习用于处理重连的反应式模式

    技术要求

    本章假设您对 RxJS 有基本的了解。

    我们使用了 ws 库,这是一个 WebSocket Node.js 库,以便在我们的后端支持 WS。更多详情,请查看此链接:github.com/websockets/ws

    本章的源代码可在github.com/PacktPublishing/Reactive-Patterns-with-RxJS-for-Angular-16-2nd-Edition/tree/main/Chap12找到。

    定义实时要求

    在网络上发布实时数据有两种技术可用:

    • 拉取技术:这是客户端发起请求以获取最新数据版本的地方。HTTP 轮询HTTP 长轮询是这种拉取技术实现的两个例子。

    • 推送技术:这是服务器将更新推送到客户端的地方。WebSocket服务器发送事件是这种推送技术的两种实现。

    我们不会详细讨论或比较这些技术,因为这不是本章的目标;然而,一般来说,推送技术相比拉取技术具有更低的延迟。因此,我们将使用推送技术和 WebSocket 作为我们需求的实现。

    简而言之,WebSocket 协议是一种有状态的通信协议,它在一个客户端和服务器之间建立了一个低延迟的双向通信通道。这样,消息可以在服务器和客户端之间来回发送。

    下图说明了 WebSocket 通信流程:

    图 12.1 – WebSocket 通信

    图 12.1 – WebSocket 通信

    如上图所示,WebSocket 通信有三个步骤:

    1. 打开连接:在这个步骤中,客户端发出一个 HTTP 请求来告诉服务器将发生协议升级(从 HTTP 到 WebSocket)。如果服务器支持 WebSocket,则协议切换将被接受。

    2. 建立通信通道:一旦完成协议升级,将创建一个双向通信通道,服务器和客户端之间开始发送和接收消息。

    3. 关闭连接:当通信结束时,将发出一个请求来关闭连接。

    在这个层面,这就是你需要了解的所有关于 WebSocket 的内容。现在,让我们快速回顾一下我们将在应用中做什么。

    在我们的食谱应用中,RecipesListComponent 负责显示食谱列表。我们将在 RecipesListComponent 渲染后延迟 5 秒模拟添加一个新的食谱(辣子鸡的食谱)。然后,UI 应立即更新,通过在 RecipesList 页面上渲染来包括这个新食谱。

    你将在 recipes-book-api 文件夹下找到一个现成的 WebSocket 后端;这是在建立连接 5 秒后向前端推送新食谱的。我们还将使用后端中的计时器来模拟新食谱的到达。然后 RecipesListComponent 应该消费来自 WebSocket 服务器的消息,并将新接收到的食谱推送到已显示的食谱列表中。UI 应自动更新,无需触发任何 刷新 按钮来获取更新。

    因此,无需多言,在下一节中,让我们看看如何使用 RxJS 的 WebSocketSubject 来实现所有这些。

    学习用于消费实时消息的反应式模式

    RxJS 有一种特殊类型的主题称为 WebSocketSubject;这实际上是对 W3C WebSocket 对象的包装,它在浏览器中可用。它允许你通过 WebSocket 连接与 WebSocket 服务器进行通信,发送和消费数据。

    让我们探索 WebSocketSubject 的功能,并学习如何在我们项目中使用它来消费实时消息。

    创建和使用 WebSocketSubject

    为了使用 WebSocketSubject,你必须调用 webSocket 工厂函数,它产生这种特殊类型的主题,并接受你的 WebSocket 服务器端点作为输入。以下是其函数签名:

    webSocket<T>(urlConfigOrSource: string | WebSocketSubjectConfig<T>): WebSocketSubject<T>;
    

    它接受两种类型的参数,以下两种之一:

    • 表示你的 WebSocket 服务器端点 URL 的字符串

    • 一个包含你的端点 URL 以及其他属性的 WebSocketSubjectConfig 类型的特殊对象(我们将在 学习用于处理 重连 的反应式模式部分详细探讨 WebSocketSubjectConfig

    以下代码是调用 webSocket 工厂函数并使用第一种类型参数的示例:

    import { webSocket } from "rxjs/webSocket";
    const subject = webSocket("ws://localhost:8081");
    

    下一段代码是使用第二种类型的参数调用 webSocket 工厂函数的示例:

    import { webSocket } from 'rxjs/webSocket';
    const subject$ = webSocket({url:'ws://localhost:8081'});
    

    在我们这个例子中,我们端点的 URL 是 ws://localhost:8081。你可以使用 wss 来进行安全的 WebSocket 连接(这和安全的 HTTP 连接中的 HTTPS 相同)。

    在本章中,我们将使用这两种类型的参数。

    现在我们来看一下如何在下一节中建立 WebSocket 的连接。

    打开连接

    现在你有了 WebSocketSubject 的参考,你应该订阅它:

    import { webSocket } from 'rxjs/webSocket';
    const subject$ = webSocket({url:'ws://localhost:8081'});
    subject$.subscribe();
    

    这将建立与你的 ws 端点的连接,并允许你开始接收和发送数据。当然,如果你不订阅,连接将不会创建。

    监听来自服务器的传入消息

    WebSocketSubject 仅仅是一个常规的 RxJS 主题,你可以注册回调来监听和处理来自 WebSocket 服务器的传入消息。

    为了监听消息,你应该从 webSocket 工厂函数订阅生成的 WebSocketSubject 并注册一个回调,如下所示:

    const subject$ = webSocket('ws://localhost:8080');
    // Listen to messages from the server
    const subscription = subject$.subscribe(msg => {
      console.log('Message received from the socket'+ msg);
    });
    

    在这里,我们只是订阅 WebSocket 主题以与 WebSocket 服务器建立连接,并将接收到的任何消息记录到控制台。

    向服务器推送消息

    要向服务器发送消息,我们只需使用 subject 类型中可用的 next 方法:

    // Push messages to the server
    subject$.next('Message to the server');
    

    处理错误

    你也可以像往常一样使用 catchError 来捕获来自服务器的错误,并通过调用 error 方法将错误推送到服务器。以下是一个示例:

    // Push errors to the server
    subject$.error('Something wrong happens')
    // Handle incoming errors from the server
    subject$.pipe(catchError(error=>of('Something wrong happens')))
    

    然而,请注意,当你发送错误时,服务器将收到这个错误的通知,然后连接将被关闭。因此,之后将不会发出任何内容。

    关闭连接

    你可以使用 unsubscribecomplete 来关闭连接:

    // Close the connection
    subject$.complete();
    //or
    subject$.unsubscribe();
    

    因此,为了总结我们讨论的内容,只有 WebSocketSubject 的创建是特定于这种特殊类型的主题。然而,所有其他使用的 API(subscribeunsubscribecompletecatchErrornext 等)与常规主题使用的相同。以下图示展示了整个过程:

    图 12.2 – WebSocketSubject 可能的事件

    图 12.2 – WebSocketSubject 可能的事件

    现在我们已经涵盖了各种 WebSocket 操作,从创建和建立连接到发送消息、处理错误以及消费传入的消息,让我们来探讨一个你应该注意的常见陷阱。

    连接管理

    在这一点上,你应该注意一个特定的行为。如果同一个 WebSocketSubject 实例有多个订阅者,那么它们将共享相同的连接以节省资源。然而,如果我们有两个不同的 WebSocketSubject 实例,即使它们引用的是同一个端点,它们也会建立两个不同的连接。

    以下代码解释了两种用例的连接管理:

    const firstSubject$ = webSocket('ws://localhost:8080');
    const  secondSubject$ = webSocket('ws://localhost:8080');
    // the first subscriber, opens the WebSocket connection
    const subscription1 = firstSubject$.subscribe(msg => {
    });
    // the second subscriber, uses the already opened WebSocket
       connection
    const subscription2 = firstSubject$.subscribe(msg => {
    });
    //this subscriber opens a new connection
    const subscription3 = secondSubject$.subscribe(msg => {
    });
    

    让我们解释一下这段代码中发生的事情。首先,我们创建了两个名为firstSubject$secondSubject$WebSocketSubject实例,它们都引用了同一个ws端点。

    然后,我们创建了一个订阅firstSubject$;这个第一个订阅将打开 WebSocket 连接。接着,我们为同一个 Observable,即firstSubject$,创建了一个第二个订阅;这个第二个订阅将使用已经打开的 WebSocket 连接。

    然而,对secondSubject$的订阅将打开一个新的 WebSocket 连接。为什么?因为它是对 WebSocket 主题的新引用,尽管它引用了与firstSubject$相同的ws端点。

    现在,如果我们有多个共享相同连接的订阅者,并且其中一个订阅者决定完成,那么除非没有更多的订阅者监听,否则连接将被释放,正如以下代码块中描述的那样:

    const subject$ = webSocket('ws://localhost:8080');
    // the first subscriber, opens the WebSocket connection
    const subscription1 = subject$.subscribe(msg => {});
    // the second subscriber, uses the already opened WebSocket connection
    const subscription2 = subject$.subscribe(msg => {});
    // the connection stays open
    subscription1.unsubscribe();
    // closes the connection
    subscription2.unsubscribe();
    

    这就是你需要知道的所有内容,以便使基本场景工作。简单,对吧?

    现在,让我们看看将我们的食谱应用部署到位的推荐模式。

    WebSocketSubject 在行动

    既然我们已经知道了如何创建到ws端点的连接,那么现在是时候探索在RecipesApp中消费实时消息的不同步骤了。特别是,我们将建立与 WebSocket 服务器的连接,一旦新的食谱被发送到前端,我们将在 UI 中更新它。让我们深入了解满足这一要求所需的各个步骤。

    第一步 - 创建实时服务

    第一步是将与WebSocketSubject的所有交互隔离到一个单独的 Angular 服务中。为此,我们将在src/app/core/services路径下创建一个名为RealTimeService的 Angular 服务。RealTimeService将如下所示:

    import { Injectable } from '@angular/core';
    import { webSocket, WebSocketSubject } from 'rxjs/webSocket';
    import { environment } from '../../../environments/environment';
    import { Recipe } from '../model/recipe.model';
    export const WS_ENDPOINT = environment.wsEndpoint;
    @Injectable({
      providedIn: 'root'
    })
    export class RealTimeService {
      private socket$: WebSocketSubject<Recipe[]> | undefined;
      private messagesSubject$ = new
        BehaviorSubject<Observable<Recipe[]>>(EMPTY);
      private getNewWebSocket(): WebSocketSubject<Recipe[]> {
        return webSocket(WS_ENDPOINT);
      }
      sendMessage(msg: Recipe[]) {
        this.socket$?.next(msg);
      }
      close() {
        this.socket$?.complete();
      } }
    

    让我们分析一下在定义的服务中,代码层面的具体操作:

    • 我们有一个私有属性socket$,其类型为WebSocketSubject<Recipe[]>|undefined,因为我们将从后端接收包含一个或多个食谱的数组。socket$包含我们将使用getNewWebSocket()方法创建的 WebSocket 主题的引用。

    • 我们有一个名为messagesSubject$的私有BehaviorSubject,它负责将 WebSocket 服务器发送的最新消息传输给新的订阅者。我们为messagesSubject$提供了类型Observable<Recipe[]>,因为它将发出包含一系列食谱对象的 Observable。最初,我们将其设置为EMPTY,这是一个立即完成而不发出任何值的 Observable。

    • 我们有一个名为getNewWebSocket()的私有方法,它调用webSocket工厂函数,传入一个名为WS_ENDPOINT的常量作为输入,并返回WebSocketSubject

      WS_ENDPOINT 代表在 src/environments/environment.ts 文件中定义的 WebSocket 服务器端点,作为 wsEndpoint。请注意,URL 是特定于环境的配置,这意味着它们可以从一个环境更改为另一个环境(例如,开发、测试和生产)。在 environment.ts 文件中定义端点 URL 是 Angular 应用程序中的常见做法,因为它提供了一个集中位置来处理特定于环境的配置设置,因此您可以轻松地在环境之间切换,而无需修改应用程序代码。

    • 我们有一个公共方法 sendMessage(),它将作为输入发送的消息发送到套接字,该套接字将消息转发到服务器。

    • 最后,我们有一个公共方法 close(),它通过完成主题来关闭连接。

    然后,我们将添加 connect() 方法,该方法将以响应式的方式监听传入的消息,并将消息作为如下所示发送给订阅者:

      public connect(): void {
          if (!this.socket$ || this.socket$.closed) {
          this.socket$ = this.getNewWebSocket();
          const messages = this.socket$.pipe(
            tap({
              error: error => console.log(error),
            }), catchError(_ => EMPTY));
          this.messagesSubject$.next(messages);
        }
      }
    

    让我们分解这个方法中正在发生的事情。如果 socket$ 未定义(尚未创建)或已关闭,则 socket$ 将由 getNewWebSocket 方法产生的新 WebSocketSubject 填充。

    然后,我们将组合 tapcatchError 操作符;tap 操作符用于在发生错误或连接关闭时记录消息,而 catchError 操作符处理错误并返回一个空的可观察对象。

    管道操作返回的可观察对象将被存储在一个名为 messages 的常量中。messagesSubject$ 可观察对象将发出消息的可观察对象(因此,它是一个可观察对象的可观察对象):

    之后,我们将通过在 RealTimeService 中定义的 messages$ 公共可观察对象提供 messagesSubject$ 可观察对象的只读副本,如下所示:

      public messages$ = this.messagesSubject$.pipe(
      switchAll(), catchError(e => { throw e }));
    

    我们使用了 SwitchAll 操作符来展平可观察对象的可观察对象,我们将订阅 messages$ 在每个需要消费实时更新的组件中。我们为什么要这样做?想法是保护 Subject$ 和传入的消息免受任何外部更新的影响,并将消息作为只读形式暴露给消费者。这样,任何对消费实时消息感兴趣的组件都必须订阅 messages$,而与套接字相关的所有逻辑都将在这个服务中私下处理。

    第二步 – 触发连接

    在放置服务之后,我们应该调用 connect 方法。由于我们希望 connect 方法只触发一次,我们将在注入 RealTimeService 之后从根组件 src/app/app.component.ts 中调用它。以下是需要添加的代码:

    constructor(private service: RealTimeService ) {
    this.service.connect();
    }
    

    第三步 – 定义发出实时更新的可观察对象

    接下来,我们应该在适当的 Angular 组件中调用 messages$ 可观察对象。由于我们希望用最新的食谱更新列表,我们应该在 RecipesListComponent 中定义可观察对象。

    但是等等!我们已经在 RecipesListComponent 中有一个名为 recipes$ 的 Observable,它从 RecipesService 获取食谱列表:

    recipes$=this.service.recipes$;
    

    我们能否使用这个现有的 Observable 而不是创建一个新的?当然可以!

    我们的目标是首先显示 recipes$ 发射的食谱列表,然后无缝地结合 messages$ 发射的任何新添加的食谱。我们可以使用 RxJS 中的 combineLatest 操作符来实现这一点。

    combineLatest 操作符将多个 Observables 的最新值合并到一个数组中,并在任何源 Observable 发射值时发射一个新的数组。通过利用这个操作符,我们可以将 recipes$messages$ 结合如下:

    recipes$=combineLatest([this.service.recipes$,
    this.realTimeservice.messages$]).pipe(map(([recipes,
    updatedRecipes]) => {
        // Merge or concatenate the two arrays into a single
           array
        return [...recipes, ...updatedRecipes];
      }));
    

    在代码中,我们结合了 recipes$messages$,然后使用 map 操作符提取每个发射的最新值。然后我们将这些值合并到一个数组中,然后返回。这确保了 recipes$ 一致地发射包含所有食谱的统一数组。

    使用 scan 操作符防止数据丢失

    现在,让我们快速考虑一个场景,即一个 ID 为 12 的食谱最初被推送到并添加到食谱列表中。如果之后从服务器推送另一个 ID 为 14 的食谱,那么最新的推送食谱(ID 14)将覆盖之前的(ID 12)。因此,ID 12 的食谱将会丢失。为了防止这种数据丢失,我们可以使用 scan 操作符。

    RxJS 中的 scan 操作符类似于 JavaScript 中的 reduce 函数。它对 Observable 序列应用一个累加函数,并返回每个中间结果,每次源 Observable 发射新值时都会发射累加的值。用更简单的术语来说,它持续地对源 Observable 发射的每个值应用一个函数,随着时间的推移积累这些值,并发射中间结果。这个操作符对于维护状态、累加值或对 Observable 流执行任何类型的带状态转换非常有用。

    因此,在我们的情况下,我们可以如下使用 scan 操作符:

      recipes$ = combineLatest([
        this.service.recipes$,
        this.realTimeService.messages$
      ]).pipe(
        scan((acc: Recipe[], [recipes, updatedRecipes]:
        [Recipe[], Recipe[]]) => {
          // Merge or concatenate the two arrays into a single
             array
          return acc.length === 0 &&
            updatedRecipes.length === 0 ? recipes : [...acc,
              ...updatedRecipes,];
        }, [])
      );
    

    在这种情况下,scan 确保所有发射的食谱,包括从 this.service.recipes$ 流中获取的初始食谱和从 this.realTimeService.messages$ 收到的任何后续更新,都被累积到一个数组中。这防止了如果使用简单的映射操作可能发生的数据丢失。因此,recipes$ Observable 流包含了一个全面且最新的食谱列表,反映了其整个生命周期中从两个来源的所有变化。

    第四步 - 订阅发射实时更新的 Observable

    最后,我们只需在我们的组件模板中使用异步管道订阅 recipes$ Observable,这在 recipes-list.component.html 中已经完成:

    @if ( recipes$ | async; as recipes) {
    ....
    }
    

    然而,我们还有一个需要考虑的调整!既然我们已经确定messages$recipes$发出后的 5 秒延迟后发出,就有一个小问题:combineLatest只在两个 Observables 都发出值时才发出。

    为了在等待messages$发出时绕过这段短暂的延迟,在RealTimeService中,我们可以在messages$主题上使用startWith()运算符来提供一个空数组的初始值,如下所示:

      public messages$ =
      this.messagesSubject$.pipe(switchAll(), startWith([]),
      catchError(e => { throw e }));
    

    执行此代码后,您会注意到在显示 11 个菜谱后的 5 秒内,ID 为12的菜谱(辣子鸡)将被添加到我们卡片列表的第二页上。如果之后推送另一个菜谱,它将被累积到当前的菜谱列表中。

    注意,在 UI 频繁更新的情况下,强烈建议将更改检测策略设置为onPush以优化性能,如下所示:

    @Component({
      selector: 'app-recipes-list',
      standalone: true,
      changeDetection: ChangeDetectionStrategy.OnPush
    })
    

    就这样!您将能够使用这种模式以响应式的方式消费实时更新。

    到目前为止,您可能想知道如何处理重新连接。当服务器重启或连接因任何原因崩溃时,这个主题会在幕后恢复丢失的连接吗?

    答案是WebSocketSubject

    然而,您可以使用 RxJS 轻松地在您的 Web 应用程序中实现这一点。让我们在下一节中学习您如何做到这一点。

    学习处理重新连接的响应式模式

    当 WebSocket 服务器的连接丢失时,通道将被关闭,WebSocketSubjet将不再发出值。在实时世界的预期行为中,这不是预期的行为。在大多数情况下,重新连接功能是必需的。

    因此,让我们假设,例如,在断开连接后,系统每 3 秒尝试重新连接一次。在这种情况下,解决方案是拦截 socket 的关闭并重试连接。我们如何拦截连接的关闭?

    这一切都要归功于WebSocketSubjectConfig,它负责自定义 socket 生命周期中的某些行为。RxJS 中的WebSocketSubjectConfig接口提供了几个属性,您可以使用这些属性来配置 WebSocketSubject。这些属性允许您自定义 WebSocket 通信的各个方面:

    export interface WebSocketSubjectConfig<T> {
      url: string;
      protocol?: string | Array<string>;
      /** @deprecated Will be removed in v8\. Use {@link
      deserializer} instead. */
      resultSelector?: (e: MessageEvent) => T;
      openObserver?: NextObserver<Event>;
      serializer?: (value: T) => WebSocketMessage;
      deserializer?: (e: MessageEvent) => T;
      closeObserver?: NextObserver<CloseEvent>;
      closingObserver?: NextObserver<void>;
      WebSocketCtor?: { new(url: string,
      protocols?:string|string[]): WebSocket };
      binaryType?: 'blob' | 'arraybuffer';
    }
    

    让我们解释WebSocketSubjectConfig中可用的不同属性:

    • url:此属性指定要连接的 WebSocket 端点的 URL(我们已经在本章中解释并使用了这个属性)。

    • protocol:此属性指定 WebSocket 握手期间要使用的子协议(参见图 12.1)。它可以是单个字符串或表示子协议的字符串数组。

    • resultSelector:此属性指定一个函数,该函数接受 WebSocket 事件作为输入,并返回由WebSocketSubject发出的值。它通常用于从 WebSocket 事件中提取特定数据;然而,它已被弃用,将在 RxJS 的版本 8 中删除。

    • closeObserver: 这个属性指定了一个监听 WebSocket 连接关闭的观察者对象。它可以用来处理清理任务或在连接关闭时执行操作。

    • openObserver: 这个属性指定了一个监听 WebSocket 连接打开的观察者对象。它可以用来在连接成功建立时执行操作。

    • binaryType: 这个属性指定了 WebSocket 消息的二进制类型。它可以是 JavaScript 类型blobarraybuffer之一。默认情况下,它设置为blob

    • serializer: 这个属性指定了一个用于在通过 WebSocket 连接发送之前序列化输出消息的函数。它通常用于将对象或复杂的数据结构转换为字符串。

    • deserializer: 这个属性指定了一个用于在 WebSocket 连接上接收到的消息进行反序列化的函数。它通常用于将接收到的字符串解析回对象或其他数据类型。

    这些属性提供了对 RxJS 中 WebSocket 通信的灵活性和控制力。您可以根据具体需求自定义它们,以优化应用程序中的 WebSocket 交互。

    注意

    每个属性的完整描述都可以在官方文档链接中找到:bit.ly/RxJS-WebSocket

    为了从WebSocketSubjectConfig中受益,你应该调用webSocket工厂函数,它接受第二种类型的参数。以下代码使用WebSocketSubjectConfig创建WebSocketSubject,并简单地拦截关闭事件以显示自定义消息:

    private getNewWebSocket() {
      return webSocket({
        url: WS_ENDPOINT,
        closeObserver: {
          next: () => {
            console.log('[RealTimeService]: connection
                        closed');
          }
        },
      });
    }
    

    现在我们知道了如何拦截连接的关闭,让我们学习如何重试重新连接。我们可以使用retryWhen操作符结合delayWhen操作符来设置两次连续连接之间的延迟,以在Observable完成之后有条件地重新订阅。

    因此,让我们创建一个函数,该函数将尝试为每个可配置的RECONNECT_INTERVAL重新连接到给定的可观察对象;我们将在每次重新连接尝试时在浏览器控制台记录日志:

        private reconnect(observable: Observable< Recipe[] >):
        Observable< Recipe[] > {
          return observable.pipe(retryWhen(errors =>
            errors.pipe(
              tap(val => console.log('[Data Service]
                Try to reconnect', val)),
                  delayWhen(_ => timer(RECONNECT_INTERVAL)))));
        }
    

    这个reconnect函数将被用作 RxJS 自定义操作符,在RealTimeServiceconnect()方法中处理套接字关闭后的重新连接,如下所示:

    public connect(cfg: { reconnect: boolean } = { reconnect: false }): void {
      if (!this.socket$ || this.socket$.closed) {
        this.socket$ = this.getNewWebSocket();
        const messages = this.socket$.pipe(cfg.reconnect ?
        this.reconnect : o => o,
          tap({
            error: error => console.log(error),
          }), catchError(_ => EMPTY))
        this.messagesSubject$.next(messages);
      }
    }
    

    如您所见,connect函数中添加了一个新的布尔参数reconnect,用于区分重新连接和第一次连接。这优化了代码,避免了添加额外的函数。

    然后,您只需在拦截连接关闭时调用connect函数并传递reconnect: true即可:

        private getNewWebSocket() {
          return webSocket({
            url: WS_ENDPOINT,
            closeObserver: {
              next: () => {
                console.log('[DataService]: connection
                            closed');
                this.socket$ = undefined;
                this.connect({ reconnect: true });
              }
            },
          });
    

    以这种方式,在连接关闭后,您将看到客户端每 3 秒尝试连接服务器的许多输出请求。

    在实时世界的世界中,重连能力是必不可少的。这就是我们如何使用 RxJS 在几行代码中处理它的。许多开发者不知道 RxJS 提供了这个功能,它使你能够消费来自 WebSocket 的实时消息,并添加许多第三方库来处理这个需求,而且它也是现成的。因此,在这种情况下选择 RxJS,就少了一个依赖!

    摘要

    在本章中,我们深入探讨了以响应式方式从 WebSocket 服务器消费实时消息的实践演示。我们首先概述了需求并提供了实现背景。随后,我们探讨了 WebSocketSubject 的功能,并详细描述了从建立连接到处理来自套接字的传入消息的逐步过程。接下来,我们将这些概念应用于食谱应用中的实际场景,从而获得了实现实时功能并确保稳健连接控制的最佳实践见解。

    最后,我们通过在响应式方式中引入重连机制,利用 WebSocketSubjectConfig 和 RxJS 运算符实现了无缝的连接管理,从而扩展了我们的理解。

    现在,随着我们接近本书的最后一章,让我们转换思路,专注于测试可观察对象。

    第五部分:最终润色

    在这部分,你将了解测试响应式流的多种策略。我们将探讨它们的优点以及何时使用每种策略,并通过实际示例巩固你的学习。

    本部分包括以下章节:

    • 第十三章测试 RxJS 可观察对象

    第十三章:测试 RxJS 可观察者

    可观察者在管理异步数据流和事件驱动交互中扮演着核心角色。通过彻底测试可观察者,开发者可以验证其异步代码的正确性,预测并处理各种边缘情况,并确保在不同环境和用例中的一致行为。

    对可观察者的全面测试不仅增强了应用程序的健壮性,还提高了代码质量,减少了错误和回归的可能性,并最终提升了整体用户体验。有了严格的测试实践,开发者可以自信地部署符合高标准的可靠性、性能和可用性的响应式应用程序。

    许多开发者认为测试可观察者是一项具有挑战性的任务。这是真的。然而,如果您掌握了正确的技术,您就可以以非常有效的方式实现可维护和可读的测试。

    在本章中,我们将向您介绍三种常用的测试流模式。我们将首先解释订阅和断言模式,然后讨论弹珠测试模式。最后,我们将通过关注我们的食谱应用中的具体示例,突出一种适合测试由HTTPClient返回的流的模式。

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

    • 了解订阅和断言模式

    • 了解弹珠测试模式

    • 使用HTTPClientTestingModule突出显示测试流

    技术要求

    本章假设您对 RxJS 和 Angular 中使用 Jasmine 进行单元测试有基本的了解。有关更多信息,请点击此链接:angular.dev/guide/testing#set-up-testing

    注意

    angular.dev将成为 Angular 开发者的新文档网站;它提供了更新的功能和文档。angular.io将在未来的版本中弃用。

    我们将在 Angular 环境中测试可观察者。本章的源代码可在github.com/PacktPublishing/Reactive-Patterns-with-RxJS-for-Angular-16-2nd-Edition/tree/main/Chap13找到。

    我们将完成对saveRecipe方法的单元测试,该方法位于RecipesService类下。您可以在recipes.service.spec文件中找到完整的代码。

    了解订阅和断言模式

    如您所知,可观察者是懒惰的,我们只有在订阅它们之后才能获得任何值。在测试中,情况也是如此;可观察者不会发出任何值,直到我们订阅它们。为了解决这个问题,程序员总是倾向于在测试中手动订阅可观察者,然后对发出的值进行断言。这就是我们所说的订阅和断言模式。

    让我们深入探讨使用订阅和断言模式在三个不同场景下的测试。我们将演示测试返回单个值的方法、返回多个值的方法以及返回定时值的方法(在指定时间后返回的值)。

    测试单值输出方法

    假设我们必须测试一个返回单个值的方法。该方法名为 getValue(value: boolean),并在名为 SampleService 的 Angular 服务中可用。

    该方法本身非常简单,返回一个 Observable,将按照以下方式发出布尔输入值:

    import { Observable, of } from 'rxjs';
    export class SampleService {
      getValue(value: boolean): Observable<boolean> {
        return of(value);
      }
    }
    

    该方法的测试看起来是这样的:

    describe('SampleService', () => {
      let service: SampleService;
      beforeEach(() => {
        service = TestBed.inject(SampleService);
      });
      it('should return true as a value', () => {
        service.getValue(true).subscribe(
          result=>expect(result).toEqual(true))
      });
    });
    

    在这里,我们首先使用 Jasmine 的 describe() 函数定义我们的测试套件。该函数用于定义一个测试套件,它是一组逻辑上相关的测试用例,用于执行单个任务的不同测试场景。它作为组织和管理测试的方式,使测试更加可读和可维护。

    describe() 函数接受两个参数:

    • 测试套件的字符串描述(在前面的代码片段中,SampleService 是我们要测试的服务名称,它指的是测试套件的描述)。

    • 包含该套件测试用例的函数(在测试框架中,“测试用例”和“规格”通常指测试套件内的单个测试单元)。在这个函数中,我们将 SampleService 注入到 beforeEach 语句中,以提供我们将要在所有测试用例中使用的服务的共享实例。最后,我们使用 Jasmine 的 it() 函数定义我们的 getValue(value: boolean) 方法的测试用例。it() 函数接受两个参数:

      • 测试用例的字符串描述(在前面的代码片段中,should return true as a value 指的是测试用例的描述)。

      • 包含测试逻辑的函数。在这个函数中,我们订阅 getValue(true) 方法,并期望结果等于 true,因为我们传递了 true 作为输入值。期望是通过使用 Jasmine 的 expect() 函数构建的,并在测试执行期间用于断言或验证是否满足某些条件。

    现在,让我们运行 ng test;测试通过,一切正常:

    图 13.1 – ng 测试输出

    图 13.1 – ng 测试输出

    非常简单,对吧?这是运行正面场景时的预期行为。正面场景通常涉及提供与正在测试的代码预期行为一致的输入或条件,从而在没有错误或失败的情况下成功执行。

    现在,让我们通过提供旨在触发正在测试的代码中失败的输入或条件来处理一个负面场景。为此,我们将以下断言中的 true 替换为 false

      it('should return true as a value', () => {
      service.getValue(true).subscribe(
        result=>expect(result).toEqual(false))
      });
    

    当你再次运行 ng test 时,我们的测试将失败。

    然而,在某些情况下,测试仍然会通过。这是怎么可能的?

    测试中的预期问题是,如果你有一个未满足的断言,它会抛出一个错误。此外,如果你在 RxJS 订阅中有一个未处理的错误,它将在另一个调用堆栈上抛出,这意味着它是异步抛出的。因此,使用订阅和断言模式的测试有时可能会显示为绿色,尽管实际上它们是失败的。

    为了克服这个问题,我们应该向测试函数传递一个done回调,并在测试完成后的预期时间手动调用它,如下所示:

       it('should return true as a value', (done) => {
        service.getValue(true).subscribe(
          result => {
            expect(result).toEqual(false);
            done();
          }
        );
      });
    

    done回调是在异步测试中用于向测试框架发出测试用例完成信号的一种机制。它被许多测试框架支持,如 Jasmine、Jest 和 Mocha。调用done回调确保在所有异步任务执行和断言验证之前,测试不会提前完成。因此,我们防止了假阳性,并确保我们的测试准确地反映了被测试代码的行为,尤其是在异步场景中。所以,不要忘记在异步场景中调用done回调!

    现在,让我们考虑一个更复杂的方法,该方法将返回多个值而不是一个值。

    测试多值输出方法

    让我们考虑一个名为getValues的方法,它将返回多个值,如下所示:

    export class SampleService {
      getValues(): Observable<String> {
        return of('Hello', 'Packt', 'Readers');
      }
    }
    

    值将按照上述顺序逐个发出。

    当使用断言和订阅模式时,测试将看起来像这样:

    it('should return values in the right order', (done) => {
      const expectedValues = ['Hello', 'Packt', 'Readers'];
      let index = 0;
      service.getValues().subscribe(result => {
        expect(result).toBe(expectedValues[index]);
        index++;
        if (index === expectedValues.length) {
          done();
        }
      });
    });
    

    在前面的代码中,我们创建了一个数组,表示预期的值顺序;然后,我们订阅了getValues方法,并使用计数器(expectedValues[index])将发出的值与预期值进行比较。完成后,我们调用了done()回调。

    然而,我们可以使用 RxJS 的toArray操作符而不是计数器,它将发出的值放入一个数组中,然后比较得到的数组与我们定义的预期数组:

    it('should return values in the right order', (done) => {
      const expectedValues = ['Hello', 'Packt', 'Readers'];
      service.getValues().pipe(toArray()).subscribe(result => {
        expect(result).toEqual(expectedValues);
        done();
      });
    });
    

    好吧,这工作得很好,ng test将会通过。然而,在这两种情况下,尽管我们处理的是一个简单的流,但我们被迫添加一些逻辑;在第一个例子中,我们添加了一个计数器,而在第二个例子中,我们使用了toArray操作符。这简化了测试并添加了一些不必要的测试逻辑;这些都是订阅和断言模式的最显著缺点。

    现在,让我们转向一个不同的例子,并探索输出定时值的测试方法。

    测试定时值输出方法

    让我们更新getValues()方法,并添加一个计时器,在特定持续时间后返回值,如下所示:

      getValues(): Observable<String> {
        return timer(0, 5000).pipe(
          take(3),
          switchMap((result) => of('Hello', 'Packt',
                                   'Readers'))
        )
    

    这里,我们在该方法中使用了 RxJS 的timer,每 5 秒发出一个值。由于timer产生一个无止境的流,我们调用take操作符来返回前三个发出值并完成它们。然后,对于每个发出值,我们使用switchMap操作符返回一个连续发出三个值的 Observable。

    这很棘手,对吧?如果我们在这里使用 subscribe 和 assert 模式,测试将会非常复杂,并且可能需要很多时间,这取决于传递给timer的值。然而,单元测试应该是快速且可靠的。

    在这种情况下,拥有一个虚拟计时器可能会有所帮助。虚拟计时器指的是由测试框架控制的模拟时间流逝。我们不需要等待实际时间的流逝,这可能导致测试缓慢且不可靠,虚拟计时器允许测试人员以编程方式控制时间。这意味着他们可以根据需要向前或向后推进时间,以触发某些事件或测试场景,这使得为依赖于基于时间的行为的代码编写可靠且确定性的测试变得更容易。这种方法确保测试快速、可预测,并且独立于实时条件。

    简而言之,subscribe 和 assert 模式是一种有效且易于采用的技术,大多数开发者都会采用。然而,它也有一些我在本节中指出的缺点:

    • 我们需要记住在异步测试中调用done回调;否则,测试将返回无效的结果。

    • 在某些情况下,我们可能会遇到过度的测试和不需要的测试逻辑。

    • 定时观察者非常难以测试。

    现在,让我们探索另一种测试可观察者的方法:使用 RxJS 测试工具的弹珠测试。

    了解弹珠测试模式

    弹珠图对于可视化可观察者执行非常有用。您已经知道了这一点,因为我们早在第一章中介绍了弹珠图,深入响应式范式,并且我们在本书中实现的几乎所有响应式模式中都用到了它们。它们易于理解,阅读起来令人愉悦。那么,为什么不在代码中也使用它们呢?您可能会惊讶地知道,RxJS 引入了弹珠测试作为一种直观且干净的测试可观察者的方式。

    让我们首先解释下一节中的语法,然后学习我们如何在代码中编写弹珠测试。

    理解语法

    要理解语法,我们应该了解以下语义:

    字符 含义
    ' ' 这代表一个特殊字符,它不会被解释。它可以用来对齐您的弹珠字符串。
    '-' 这代表虚拟时间的流逝帧。
    '&#124;' 这代表了一个可观察对象的完成。
    [``a-z] 这代表由可观察者发出的值。它是一个字母数字字符。
    '#' 这代表了一个错误。
    '()' 这代表在同一帧中发生的事件组。它可以用来组合任何发出的值、错误和完成。
    '^' 这代表订阅点,并且仅在您处理热可观察者时使用。
    [``0-9]+[ms&#124;s&#124;m] 这代表时间进度,并允许你通过特定数量推进虚拟时间。它是一个数字,后面跟着一个时间单位,以 毫秒ms)、s)或 分钟m)表示,它们之间没有空格。

    图 13.2 – 宝石测试语法

    这是基本语法。让我们看看一些例子来练习语法:

    • ---: 这代表一个永远不会发出的可观察对象。

    • -x--y--z|: 这代表一个在第一帧发出 x,在第四帧发出 y,在第七帧发出 z 的可观察对象。在发出 z 之后,可观察对象完成。

    • --xy--#: 这代表一个可观察对象,在第二帧发出 x,在第三帧发出 y,并在第六帧发出错误。

    • -x^(yz)--|: 这是一个在订阅之前发出 x 的热可观察对象。

    你已经明白了,现在让我们学习如何在我们的代码中实现宝石测试。

    介绍 TestScheduler

    有不同的包可以帮助你编写宝石测试,包括 jasmine-marblesjest-marblesrxjs-marbles。然而,RxJS 提供了开箱即用的测试实用工具,所有库都是围绕 RxJS 测试实用工具的包装。我建议使用 RxJS 实用工具,以下是一些原因:

    • 你不需要包含第三方依赖项

    • 你可以保持核心实现的最新状态

    • 你可以保持对最新功能的了解

    提供的 RxJS API 用于测试是基于 TestScheduler 的。这个 API 允许你以可控和确定性的方式测试基于时间的 RxJS 代码,这对于编写基于时间操作符的可靠和可预测的可观察对象测试至关重要。

    要定义我们的测试逻辑,TestScheduler API 提供了一个具有以下签名的 run 方法:

    run<T>(callback: (helpers: RunHelpers) => T): T;
    

    run 方法接受一个 callback 函数作为参数。这个 callback 函数是你定义测试逻辑的地方,包括设置可观察对象、定义期望和进行断言。callback 函数接受一个名为 helpers 的参数,其类型为 RunHelpers,它提供了各种实用函数和属性,以帮助你编写可观察对象的宝石测试。

    RunHelpers 接口包含以下属性:

        export interface RunHelpers {
        cold: typeof TestScheduler.prototype.
        createColdObservable;
        hot: typeof TestScheduler.prototype.
        createHotObservable;
        flush: typeof TestScheduler.prototype.flush;
        expectObservable: typeof TestScheduler.
        prototype.expectObservable;
        expectSubscriptions: typeof TestScheduler.
        prototype.expectSubscriptions;
    }
    

    让我们逐一查看这些属性:

    • cold: 这根据给定的宝石图产生一个冷可观察对象。以下是该方法的签名:

      /**
          * @param marbles A diagram in the marble DSL.
            Letters map to keys in `values` if provided.
          * @param values Values to use for the letters in
            `marbles`. If ommitted, the letters themselves
            are used.
          * @param error The error to use for the `#`
            marble (if present).
          */
      createColdObservable<T = string>(marbles: string,
      values?: {
              [marble: string]: T;
          }, error?: any): ColdObservable<T>;
      
    • hot: 这根据给定的宝石图产生一个热可观察对象。以下是该方法的签名:

      /**
          * @param marbles A diagram in the marble DSL.
            Letters map to keys in `values` if provided.
          * @param values Values to use for the letters in
            `marbles`. If ommitted, the letters themselves
            are used.
          * @param error The error to use for the `#`
            marble (if present).
          */
      createHotObservable<T = string>(marbles: string,
      values?: {
              [marble: string]: T;
          }, error?: any): HotObservable<T>;
      

      当你创建一个热可观察对象时,可以使用 ^ 来指出第一帧:

    • flush: 这开始虚拟时间。只有在你在 run 回调之外使用辅助工具或想要多次使用 flush 时才需要。

    • expectObservable: 这断言一个可观察对象与宝石图匹配。

    • expectSubscriptions: 这断言一个可观察对象与预期的订阅匹配。

    现在,让我们学习如何使用 TestScheduler 在下一节中实现弹珠测试。

    实现弹珠测试

    在本节中,我们将考虑实现之前在订阅和断言模式中提到的 getValues 方法的弹珠测试:

    export class SampleService {
      getValues(): Observable<String> {
        return of('Hello', 'Packt', 'Readers');
      }
    }
    

    编写弹珠测试实现模式的步骤很简单:

    1. rxjs/testing 中导入 TestScheduler

      import { TestScheduler } from 'rxjs/testing';
      
    2. beforeEach 语句中,注入 SampleService。然后,实例化 TestScheduler 并传递一个输入函数,该函数比较实际输出与 Observable 的预期输出:

      import { TestScheduler } from 'rxjs/testing';
      describe('Service: SampleService', () => {
        let scheduler : TestScheduler;
        let service: SampleService;
        beforeEach(() => {
            service = TestBed.inject(SampleService);
            scheduler = new TestScheduler((actual, expected) => {
            expect(actual).toEqual(expected);
          });
        });
      });
      

      如果预期输出和实际输出不相等,它会抛出一个错误,导致测试失败。

    3. 使用 TestScheduler 通过调用 run 方法并传递一个回调来测试你的流(记住,回调需要接受 RunHelpers 作为第一个参数):

      it('should return values in the right order', () => {
        scheduler.run((helpers) => {
        });
      });
      

      将辅助函数解构到变量中并直接使用它们来实现弹珠测试也是有用的。我们将解构 expectObservable 变量,因为我们将会使用它来断言 Observable 是否与弹珠图匹配,如下所示:

      it('should return values in the right order', () => {
        scheduler.run(({expectObservable}) => {
        });
      });
      
    4. 最后,声明预期的弹珠和值,并执行期望:

      it('should return values in the right order', () => {
        scheduler.run(({expectObservable}) => {
        const expectedMarble = '(abc|)' ;
        const expectedValues = {a:'Hello', b:'Packt',
                                c:'Readers'};
        expectObservable(service.getValues()).toBe(
          expectedMarble, expectedValues)
        });
      });
      

      expectedMarble 常量代表弹珠图。由于 getValues 方法连续返回三个值,我们使用了括号来分组 abc 的发射。然后流完成,所以我们使用 | 字符。

      expectedValues 常量代表我们放入 expectedMarble 中的 abc 字符的值。它代表 'Hello''Packt''Readers',连续的,这不过是我们要测试的 Observable 发射的值。

      最后一条指令是期望;我们应该提供我们的方法应该返回的预期结果。在这里,我们必须使用 expectObservable,它接受我们想要测试的 Observable 作为参数,并将其与 expectedMarbleexpectedValues 匹配。

    就这些。让我们看看完整的测试设置:

    describe('SampleService marble tests', () => {
    let scheduler : TestScheduler ;
    let service: SampleService;
    beforeEach(() => {
      service = TestBed.inject(SampleService);
      scheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });
    });
    it('should return values in the right order', () => {
      scheduler.run(({expectObservable}) => {
      const expectedMarble = '(abc|)' ;
      const expectedValues = {a:'Hello', b:'Packt',
                              c:'Readers'};
      expectObservable(service.getValues()).toBe(
        expectedMarble, expectedValues)
      });
    });
    });
    

    当你运行 ng test 时,这个测试将会通过。如果你在 expectedValues 中输入错误值,测试将会失败:

    图 13.3 – ng test 失败

    图 13.3 – ng test 失败

    好吧,这比订阅和断言模式实现要干净。

    现在,让我们看一个更复杂的例子,看看我们如何使用弹珠测试来实现它。

    测试定时值输出方法

    我们将考虑测试一个使用订阅和断言模式实现起来复杂的定时 Observable。让我们回顾一下我们在订阅和断言模式部分中解释过的定时器示例:

      getValues(): Observable<String> {
        return timer(0, 5000).pipe(
          take(3),
          switchMap((result) => of('Hello', 'Packt',
          'Readers'))
        )
      }
    

    在这里能帮到我们的酷炫功能是虚拟时间;这允许我们通过虚拟化时间来同步测试异步流,并确保正确的时间发出正确的内容。多亏了时间进度语法,我们可以以毫秒(ms)、秒(s)甚至分钟(m)为单位推进虚拟时间。这在测试定时 Observables 的情况下非常有用。

    让我们考虑以下大理石图:

    e 999ms (fg) 996ms h 999ms (i|)';
    

    在这里,图表明e立即发出。然后,1 秒后,fg发出。然后,1 秒后,h发出,之后I发出,流最终完成。

    为什么使用999996?嗯,我们使用999是因为e发出需要 1 毫秒,而996是因为(fg)组中的字符每个需要 1 毫秒。

    考虑到所有这些,getValues的大理石测试将看起来像这样:

      const expectedMarble ='(abc) 4995ms (abc) 4995ms
        (abc|)' ;
    

    值组(abc)每 5 秒或 5000 毫秒发出一次,由于字符在组内计数,所以我们放置4995ms。因此,整个测试用例将看起来像这样:

    it('should return values in the right time', () => {
      scheduler.run(({expectObservable}) => {
      const expectedMarble ='(abc) 4995ms (abc) 4995ms (abc|)';
      const expectedValues = {a:'Hello', b:'Packt',
                              c:'Readers'};
      expectObservable(service.getValues()).toBe(
        expectedMarble, expectedValues)
      });
    });
    

    这就是我们如何通过大理石测试解决了定时 Observables 的测试。

    大理石测试非常强大且有用。它允许您测试非常详细和复杂的事情,如并发和定时 Observables。它还使您的测试更加简洁。然而,它要求您学习一种新的语法,并且不建议用于测试业务逻辑。大理石测试是为测试具有任意时间的操作符而设计的。

    注意

    关于大理石测试的更多细节,您可以在rxjs.dev/guide/testing/marble-testing的官方文档中查看。

    现在,让我们突出一个用于测试业务逻辑的非常常见的模式。

    使用 HttpClientTestingModule 突出测试流

    从 HTTP 客户端返回的 Observables 在我们的 Angular 代码中经常被使用,但我们是怎样测试这些流的呢?让我们看看我们可以用来测试这些 Observables 的模式。我们将把我们的重点从一般测试实践转移到具体测试我们的食谱应用。

    考虑以下RecipeService中的方法:

      saveRecipe(formValue: Recipe): Observable<Recipe> {
        return this.http.post<Recipe>(
          `${BASE_PATH}/recipes`, formValue);
      }
    

    saveRecipe方法发出一个 HTTP 请求并返回一个食谱的 Observable。为了测试输出 Observable,有一个非常有用的 API 可以用来:HttpClientTestingModule。此 API 允许我们测试使用 HTTP 客户端的 HTTP 方法。它还允许我们通过提供HttpTestingController服务来轻松模拟 HTTP 请求。简而言之,它使我们能够在测试时模拟请求,而不是向我们的 API 后端发出真实的 API 请求。

    让我们看看使用HttpClientTestingModule测试saveRecipe方法所需的步骤:

    1. 在您可以使用HttpClientTestingModule之前,请在beforeEach语句中导入并注入它到您的TestBed中,如下所示:

      import { TestBed } from '@angular/core/testing';
      import { HttpClientTestingModule} from '@angular/common/http/testing';
      describe('RecipesService', () => {
        beforeEach(() => {
          TestBed.configureTestingModule({
            imports: [HttpClientTestingModule],
          });
        });
      });
      
    2. 然后,导入并注入 HttpTestingControllerRecipesService,并为每个测试提供一个共享实例:

      import { TestBed } from '@angular/core/testing';
      import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
      import { RecipesService } from './recipes.service';
      describe('RecipesService', () => {
        let service: RecipesService;
        let httpTestingController: HttpTestingController;
        beforeEach(() => {
          TestBed.configureTestingModule({
            imports: [HttpClientTestingModule],
            providers: [RecipesService]
          });
          httpTestingController =
            TestBed.inject(HttpTestingController)
          service = TestBed.inject(RecipesService)
        });
      });
      
    3. 接下来,实现保存菜谱的测试用例。我们将按照以下方式模拟 saveRecipe

        it('should save recipe from API', () => {
          const recipeToSave : Recipe= {
            "id": 9,
            "title": "Lemon cake",
            "prepTime": 10,
            "cookingTime": 35,
            "rating": 3,
            "imageUrl": "lemon-cake.jpg"
          }
          const subscription =
          service.saveRecipe(recipeToSave)
            .subscribe(_recipe => {
              expect(recipeToSave).toEqual(_recipe, 'should
              check mock data')
            });
          const req = httpTestingController.expectOne(
            `/api/recipes`);
          req.flush(recipeToSave);
          subscription.unsubscribe();
        });
      

      在这里,我们创建了一个名为 recipeToSave 的常量,它代表我们将要发送到服务器保存的模拟菜谱。然后,我们订阅了 saveRecipe 方法,并将 recipeToSave 作为参数传递给它。在订阅内部,我们定义了我们的期望。然后,我们调用了 expectOne 方法,它期望一个已发送到匹配给定 URL(在我们的情况下,是 /api/recipes)的单个请求,并使用 flush 方法返回模拟数据,该方法通过返回模拟体来解析请求。最后,我们释放了订阅。

    4. 最后一步是添加一个 afterEach() 块,在其中运行我们控制器的 verify 方法:

        afterEach(() => {
          httpTestingController.verify();
        });
      

      verify() 方法确保没有未处理的 HTTP 请求未被处理或刷新。当您在测试中使用 HttpClientTestingModule 发送 HTTP 请求时,它们会被 httpTestingController 截获,而不是通过网络发送。verify() 方法确保所有请求都得到了适当的处理,并且只有当没有挂起的请求时,测试才能通过。

      总结来说,在 Angular 测试中使用 afterEach() 块和 httpTestingController.verify() 来清理并验证在每个测试用例之后没有未处理的 HTTP 请求留下。这有助于确保您的测试是隔离和可靠的,没有意外的网络交互。

    就这样,测试发出 HTTP 请求的方法的模式就完成了。只需运行 ng test 命令并确保一切正常。

    注意

    HttpClientTestingModule 在这个用例中非常有用。有关更多详细信息,请参阅 angular.dev/guide/testing/services#httpclienttestingmodule

    摘要

    在本章中,我阐述了在 RxJS 和 Angular 中测试可观察对象的三种常见方法。每种解决方案都有其优点和缺点,并没有一种适合所有情况的答案。

    首先,我们学习了订阅和断言模式,以及它的优点和缺点。这种模式易于理解,但可能无法涵盖所有边缘情况,尤其是在处理复杂的异步行为时。

    然后,我们学习了宝石测试模式及其语法、特性、优点和缺点。我们研究了一个基本示例和一个使用虚拟时间来测试定时可观察对象的示例。宝石测试提供了可观察对象行为的可视化表示;它适用于测试复杂的异步场景。然而,它需要特殊的语法,这意味着对于初学者来说可能有一个陡峭的学习曲线。

    最后,我们了解了一种可以用来测试从 HTTP 客户端返回的流的模式。这个模式可以控制响应,并且不依赖于外部 API。然而,设置和维护它可能很繁琐,并且在某些情况下可能无法准确模拟现实世界的网络行为。

    总之,每种测试方法都提供了其优势和权衡。根据您的项目需求,您可以选择最适合您测试需求和项目约束的解决方案。

    到目前为止,我们对反应式模式的探索即将结束。在这本书中,我试图突出最常用的反应式模式,这些模式解决了许多在 Web 应用程序中反复出现的用例。您可以直接在当前项目中使用它们,根据您的需求进行修改,或者从中获得灵感来创建您自己的反应式模式。

    尽管这本书不仅仅关于模式;它还涉及反应式方法以及如何将你的思维方式从命令式转变为反应式思考;在大多数章节中,这就是为什么我在反应式模式之前先强调经典模式,以便您在这两种模式之间有一个平滑的过渡。

    就这样,我们的共同之旅到达了终点。感谢您阅读并与我一起踏上这场反应式冒险之旅!

posted @ 2025-09-08 13:03  绝不原创的飞龙  阅读(34)  评论(0)    收藏  举报