Angular-高效指南-三-

58 阅读54分钟

Angular 高效指南(三)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:在 Angular 中精通响应式编程

响应式编程有助于提高您应用程序的性能,并允许 Angular 更好地利用变更检测机制,减少应用程序需要重新渲染的次数。

在本章中,您将了解响应式编程。您将学习响应式编程是什么以及如何使用它来改进您的 Angular 应用程序。您还将了解 RxJS 库以及它如何反应性地管理异步数据流。本章将教会您如何使用不同的 RxJS 操作符,创建可重用的 RxJS 操作符,重用一组 RxJS 操作符,以及使用 RxJS 将其他 Observables 映射到视图模型中。

您还将了解 Angular Signals 以及如何使用 Signals 来反应性地管理同步数据流。最后,您将学习如何结合 RxJS 和 Signals,何时使用 RxJS,以及何时 Signals 占据主导地位。

到本章结束时,您将能够反应性地管理数据流,并了解有关 Angular Signals 的一切。

本章将涵盖以下主题:

  • 什么是响应式编程?

  • 使用 RxJS 进行响应式编程

  • 使用 Angular Signals 进行响应式编程

  • 结合 Signals 和 RxJS

什么是响应式编程?

响应式编程是一种像函数式、模块化、过程式或面向对象编程OOP)一样的声明式编程范式。编程范式是一组规则和原则,它指定了您编写代码的方式。它与建筑和设计模式类似,但它们在抽象的不同层面上运作。

编程范式是指导编写代码的整体风格、结构和方法的宏观概念,而架构和设计模式则提供了可重用的模板或蓝图,用于结构化代码、处理组件之间的通信、管理关系以及解决代码库中其他常见的设计挑战。

响应式编程处理数据流和变化的传播。简单来说,响应式编程决定了您如何处理可能在任何给定时间发生的事件和数据变化,也称为异步变化。正如其名称所暗示的,您使用响应式编程来对变化做出反应。使用响应式编程,代码的依赖部分将自动通知事件和数据变化,以便这些部分可以自动对变化做出反应。您可以将响应式编程视为一个系统,其中变化被推送到需要根据变化做出反应的代码部分,而不是您拉取当前状态,检查是否已更改,然后相应地更新依赖代码。

响应式编程的亮点

反应式系统通过仅在数据发生变化时订阅和处理数据来优化资源分配,减少了不必要的处理,提高了整体系统性能。这种方法允许系统更加响应和可扩展,同时节省计算资源,使其在高效处理异步数据流方面特别有利。你可以使用反应式编程来处理数据流和事件的常见示例包括 HTTP 请求、表单更改以及浏览器事件,如点击、鼠标移动和键盘事件。

反应式编程通过允许开发者轻松地将更简单的行为组合成更复杂的组合,从而促进了可组合性。通过mapfiltermerge等运算符,反应式系统允许你转换、组合和操作数据流。

这种固有的模块化使开发者能够构建更模块化和灵活的应用程序,其中不同的数据流可以无缝集成、转换和适应,以创建复杂且易于维护的系统。这种对可组合性的强调促进了代码的可重用性,并促进了高度可扩展和适应性强应用的创建。

另一个反应式编程的重要部分是处理非阻塞的事件和数据变化;这主要是在你开始进行反应式编程时性能提升的地方。非阻塞代码确保多个任务、事件和数据变化可以并行执行。换句话说,非阻塞代码在任务开始后直接运行代码,而不需要等待任务完成,因此你的代码在任务开始后直接继续到下一行代码。相比之下,阻塞代码会在代码完成之前等待,然后才移动到下一行代码。

反应式编程的缺点

反应式编程很棒,但它不仅仅是阳光和玫瑰;反应式编程也有一些缺点。

反应式系统可能会变得复杂,学习曲线,尤其是对于初级开发者来说,可能会很陡峭。除了一些难以理解的概念外,反应式编程可能难以调试,并且使用自动化测试进行测试也更困难。特别是当你为你的应用程序编写单元测试时,高度反应式系统可能会给你带来一些头疼。

现在,你已经了解了什么是反应式编程以及它如何提高你的应用程序性能并有效地处理事件和数据流。反应式编程提供了数据流和事件的简单可组合性,并以非阻塞的方式运行你的代码。你还知道反应式编程可能给你的代码库带来哪些挑战,以及对于初级开发者来说,理解一些反应式模式可能具有挑战性。现在,让我们更多地了解反应式编程在 Angular 应用程序中的应用。

反应式编程在 Angular 中的应用

响应式编程是 Angular 框架的核心。Angular 强烈依赖于 观察者(我们将在下一节中详细解释观察者)并且内置了 RxJS 库,以强大和可组合的方式管理观察者数据流。观察者是一种响应式设计模式,因为它包含了观察者,即数据发布者,以及订阅者或数据流的接收者。当观察者发出新的值时,订阅者会自动收到通知并相应地采取行动。

观察者(Observables)在 Angular 框架中被广泛应用于多个方面。以下是一些例子,展示了在 Angular 框架中你可以找到观察者的地方:

  • HTTP 请求:在 Angular 框架中,HTTP 请求默认返回观察者。在纯 JavaScript 中,HTTP 请求是通过 Promise 处理的。

  • events 观察者。使用路由 events 观察者,你可以监听诸如 NavigationStartNavigationEndGuardCheckStartGuardCheckEnd 等事件。

  • Angular 框架暴露了 valueChanges 观察者。使用这个观察者,你可以对表单或表单字段中的变化做出响应。

  • ViewChildren 装饰器,它返回的 QueryList 对象有一个 changes 事件。这个 changes 事件是一个观察者,当 ViewChildren 选择的项发生变化时,它会通知你。

这些例子只是 Angular 框架依赖观察者的几个实例。在用 Angular 制作的应用程序代码中,以及与 Angular 常常一起使用的库中,你会在许多地方遇到观察者。

除了观察者之外,Angular 还以其他方式使用响应式编程,例如在处理浏览器事件时使用 @Hostlistener() 装饰器:

@HostListener('document:keydown', ['$event'])
handleTheKeyboardEvent(event: KeyboardEvent) { …… }

在前面的代码片段中,我们使用了 @Hostlistener() 装饰器来监听 keydown 事件并对其进行响应式处理。Angular 使用响应式编程范式的另一个地方是 Angular Signals,这是在 Angular 16 中引入的。Angular Signals 是一个跟踪值变化并相应地通知感兴趣消费者的系统。Signals API 包含了计算属性和效果,这些属性和效果会在 Signal 值变化时自动更新或运行。Signals 适用于处理同步值的响应式,而 RxJS 在处理异步数据流方面表现出色。我们将在本章的 使用 Angular Signals 进行响应式编程 部分更深入地探讨 Signals。

既然你已经了解到 Angular 框架内置了响应式编程,从观察者和 RxJS 到事件处理和新的 Angular Signals,让我们继续下一节,学习如何在 Angular 应用程序中充分利用 RxJS。

使用 RxJS 进行响应式编程

在 Angular 应用程序的上下文中,关于响应式编程,RxJS 处于核心地位。RxJS 代表 Reactive Extensions Library for JavaScript。正如其名所示,它是一个用于处理 JavaScript 中响应性的库,并且它是内置的,默认情况下在 Angular 框架中使用。

RxJS 用于创建、消费、修改和组合异步和基于事件的流数据。在其核心,RxJS 围绕四个主要概念:可观测量、观察者、主题和操作符。让我们逐一深入探讨这些概念,从可观测量开始。

什么是可观测量?

可观测量是 RxJS 的基石。你可以将可观测量视为一个流或管道,它以异步方式在一段时间内发出不同的值。要接收可观测量数据流发出的值,你需要订阅可观测量。

现在,想象一个可观测量数据流就像一个水管。当你打开水龙头(订阅)时,水(数据)会通过水管(可观测量)流动,你在你的末端接收水滴(值)。水(数据)可能在你打开水龙头(订阅)之前就已经流动了,除非你安装了特殊的系统;你将只从你打开水龙头(订阅)的那一刻起接收水(数据),直到你关闭水龙头(取消订阅)。如果其他水龙头(订阅者)是打开的,水(数据)可能还会流向其他水龙头。简而言之,要接收数据流中的值,你需要订阅,而你之前订阅之前发出的所有值都将丢失,除非你有特殊的逻辑来存储这些值。要停止接收值,你需要取消订阅,并且发出值的流就像一条河流一样。

可观测量有两种类型:热可观测量和冷可观测量冷可观测量是单播的,意味着每个订阅者都会从头开始。就像 Netflix 上的电影一样;当有人开始播放电影时,电影将从开头开始。如果有人在另一个账户或电视上开始播放同一部电影,电影也将从开头开始。每个人从开始观看的那一刻起都会获得独特的观看体验。

相反,热可观测量是多播的,意味着有一个数据流被广播给每个订阅者。热可观测量可以与现场电视相比较。不同的人可以收听现场节目(数据流),但你已经错过的内容将不会为你重播。即使他们没有从开始观看,观看的人也会同时体验到相同的内容。

现在你已经清楚地理解了可观测量,并且知道了热可观测量和冷可观测量之间的区别,让我们来学习观察者。

使用观察者订阅可观测量

观察者是订阅可观察对象并接收数据流中可观察对象值的实体。你可以将观察者视为观看现场表演或播放 Netflix 电影的人(或订阅者)。订阅者有两个任务:订阅他们想要接收的流,并取消订阅他们不再想要或需要的流。

其中最关键的部分是成功取消不再需要的订阅。不取消可观察对象可能是使用代码中的可观察对象时最大的风险,也是最常见的错误来源。如果你没有正确清理你的订阅,你会遇到内存泄漏。内存泄漏会导致应用程序出现奇怪的行为,运行速度变慢,并最终在内存耗尽时崩溃你的应用程序。

让我们假设你有一个订阅的杂志,每周都会送到你家。如果你搬到了一个新的地址,你需要从订阅中取消订阅,并在新的地址上创建一个新的订阅。如果你不取消旧地址的订阅,而只是为新地址开始一个新的订阅,你将开始支付双倍费用,杂志将同时送到两个地址。如果你继续重复这个过程,你最终会耗尽资金,你的生活将崩溃。在你的应用程序中,情况相同,只是你不用金钱支付,而是用内存。

当你在组件内部创建一个订阅时,必须在组件销毁时取消订阅。否则,订阅将继续运行。下次你打开相同的组件时,将启动第二个订阅,因为旧的订阅仍在运行。结果,所有值都将被两个观察者接收。如果你继续重复这个过程,最终会有许多观察者接收相同的值,而你只需要一个观察者来接收这些值。当观察者太多时,应用程序将耗尽处理所有值的内存,应用程序将崩溃。

取消可观察对象的订阅

取消可观察对象的订阅可以以许多不同的方式完成。在 Angular 应用程序中,你通常可以在ngOnDestroy生命周期钩子内部取消订阅。你可以手动取消订阅,如下所示:

ngOnInit() {
  this.observable$.subscribe(…)
}
ngOnDestroy() {
  this.observable$.unsubscribe();
  this.observable$.complete();
}

在前面的代码中,我们在ngOnInit生命周期钩子内部订阅了一个可观察对象,并在ngOnDestroy生命周期钩子中通过在可观察对象上调用unsubscribe()complete()方法手动取消订阅。

当你的组件内部有多个可观察对象时,这种方法可能不是最佳选择。如果你有五个可观察对象,你必须手动取消和完成所有五个可观察对象的订阅。手动完成所有订阅会增加遗漏一个可观察对象的风险,并导致ngOnDestroy方法中包含大量重复代码的大方法。

你还需要为所有 Observables 创建一个本地属性,这会进一步污染你的文件,增加样板代码。在这种情况下,你需要将 Observable 订阅保存在一个属性中,以便你可以在ngOnDestroy方法中取消订阅。

另一个更好的选择是创建一个Subscription对象,并通过在Subscription对象上使用add()方法将所有订阅添加到这个对象中。在这种情况下,你只需要从Subscription对象中取消订阅,它将自动取消订阅并完成添加到Subscription对象中的所有订阅。

这里有一个如何使用Subscription对象方法的示例:

subscriptions = new Subscription()
ngOnInit() {
  this.subscriptions.add(this.observableA$.subscribe(…));
  this.subscriptions.add(this.observableB$.subscribe(…));
}
ngOnDestroy() {
  this.subscriptions.unsubscribe();
}

在前面的代码片段中,我们创建了一个subscriptions属性并将其分配给Subscription对象。接下来,我们使用subscriptions属性,并通过Subscription类的add方法将所有订阅添加到subscriptions属性中。最后,在ngOnDestroy方法内部,我们调用subscriptions对象的unsubscribe()方法,这将取消订阅并完成所有内部订阅。

使用Subscription类是一个不错的选择,但将活动订阅添加到Subscription类的语法看起来很杂乱。当你开始使用 RxJS 可连接操作符时,有一种方法更符合你代码的其他部分。我们将在本节稍后更详细地讨论可连接操作符,但现在,我想向你展示如何使用它们来自动取消订阅。

使用takeUntil()操作符取消订阅

首先,我们将看看takeUntil()操作符。你可以在 RxJS 管道方法内部添加takeUntil()操作符,该操作符用于你的 Observables。takeUntil()操作符会在触发器触发时自动取消你的订阅。这个触发器通常是 RxJS 的Subject(我们将在本节稍后更详细地讨论Subject)。当我们调用这个Subject的下一个方法时,takeUntil()操作符将被触发并取消订阅:

private destroy$ = new Subject<void>();
ngOnInit() {
  this.observable$.pipe(takeUntil(this.destroy$))
  .subscribe(……)
}
ngOnDestroy() {
  this.destroy$.next();
  this.destroy$.complete();
}

在前面的示例中,我们创建了一个名为destroy$Subject Observable。然后,我们在我们的 Observable 上使用pipe()函数,并在pipe()函数内部添加了takeUntil()操作符。takeUntil()操作符接收destroy$属性作为参数,一旦我们调用destroy$属性的next()方法,它就会被触发。最后,在ngOnDestroy生命周期方法内部,我们调用destroy$属性的next()方法,并通过调用complete()方法完成对destroy$ Observable 本身的完成。

使用takeUntil()操作符是许多 Angular 开发者取消订阅的首选解决方案。这是因为它与 RxJS 的pipe函数和其他可连接操作符配合得非常好。我想展示的最后一种取消订阅的选项是takeUntilDestroyed()操作符。

使用 takeUntilDestroyed() 操作符取消订阅

takeUntilDestroyed() 操作符是在 Angular 16 版本中添加的。它可以用来在组件销毁时自动取消订阅。当你将你的订阅声明在注入上下文(在构造函数中或声明属性的地方)内时,你只需要添加 takeUntilDestroyed() 操作符,它将为你管理一切:

data = observable$.pipe(ngOnInit, you must provide the takeUntilDestroyed() operator with a reference, DestroyRef. Here, DestroyRef is the ngOnDestroy life cycle in the form of an injectable:

protected readonly destroy = inject(DestroyRef);

ngOnInit() {

this.observable$.pipe(takeUntilDestroyed(this.destroy)).subscribe(…);

}


 As you can see, we created a property and assigned it to `DestroyRef`. We add this property as a function parameter to the `takeUntilDestroyed()` operator. That is all you need to do, and it will unsubscribe and complete your subscriptions automatically.
You now know why it’s essential to unsubscribe from Observables and how to do so using different approaches. Now, we will move on to the next major concept of RxJS: `Subject`.
Using special ObservablesRxJS Subjects
The `Subject` Observables are `Subject` Observables are like `EventEmitter` Observables, which maintain a registry of all listeners.
Every `Subject` is an Observable, meaning you can subscribe to `Subject` to receive the values it emits. Each `Subject` is also an internal Observer object with the `next()`, `error()`, and `complete()` methods. The `next()` method is called to emit the next value in the data stream, `error()` is called automatically when an error occurs, and `complete()` can be called to complete the data stream.
Within RxJS, there are four different `Subjects`. Let’s take a look.
Subject
This is the basic RxJS `Subject` type. The `Subject` class allows you to create a hot Observable data stream. You can emit a new value using the `next()` method; the `Subject` class has no initial value or memory of the values that have already been emitted. Subscribers will only receive values that are emitted after they are subscribed; anything emitted before that point will not be received:

const subject = new Subject();

subject.subscribe({next: (v) => console.log(A: ${v})});

subject.next(1);

subject.subscribe({next: (v) => console.log(B: ${v})});

subject.next(2);

// 日志输出:

// A:1, A:2, B:2


 As shown in the preceding example, we use a `Subject` class to emit two values (`1` and `2`). The first value is emitted after the first subscriber (`A`), and the second value is emitted after both subscribers have subscribed. Because of this, subscriber `A` receives both values, while subscriber `B` only receives the second value.
A good use case for the `Subject` class is when multiple Observers must respond to a specific event, such as a selection or a changing toggle. The `Subject` class can be used if components only have to react to the change if the components are active during the event. Now that you know how `Subject` works, let’s examine `BehaviorSubject`.
BehaviorSubject
The `BehaviorSubject` class extends the `Subject` class and has two main differences from the regular `Subject`. The `BehaviorSubject` class receives an initial value and stores the last emitted value. When a subscriber subscribes to `BehaviorSubject`, the subscriber will immediately receive the last emitted value. When no value is emitted, the subscriber will receive the initial value instead.
The `Subject` class is good for emitting values that have to notify subscribers when an event happens, such as when multiple Observers need to react when something is added. The `BehaviorSubject` class, on the other hand, is well suited for values with state, such as `lastAddedItem`, where subscribers receive the last added item. Here, `lastAddedItem` will always emit the last item that has been added. In contrast, an `itemAdded` event using a `Subject` class will only notify subscribed Observers the moment the item is added and not after the fact:

const subject = new BehaviorSubject(0); // 初始值 0

subject.subscribe({next: (v) => console.log(A: ${v})});

subject.next(1);

subject.subscribe({next: (v) => console.log(B: ${v})});

subject.next(2);

// 日志输出:

// A:0, A:1, B:1, A:2, B:2


 As you can see, the `BehaviorSubject` class receives an initial value; in our case, the initial value is `0`. When subscriber `A` subscribes to `BehaviorSubject`, the subscriber immediately gets the initial value, `0`, and logs the value. After subscriber `A` has subscribed, we emit a new value: `1`. This new value is received and logged by subscriber `A`.
Next, subscriber `B` subscribes to `BehaviorSubject`. Because `1` is the last emitted value, subscriber `B` gets and logs it. Lastly, we emit a new value, `2`, which is received and logged by subscribers `A` and `B`.
Now that you know how `BehaviorSubject` works and how it differs from the regular `Subject`, let’s learn about `ReplaySubject`.
ReplaySubject
The `ReplaySubject` class is also an extension of the regular `Subject` class and behaves a bit like `BehaviorSubject` with some differences. The `ReplaySubject` class also stores values, just like `BehaviorSubject`, but unlike `BehaviorSubject`, `ReplaySubject` can store more than one value and doesn’t have an initial value.
Instead of an initial value, the `ReplaySubject` class receives a buffer size as a parameter. The buffer size determines how many values the `ReplaySubject` class stores and shares with a new subscriber upon subscription. Besides the buffer size, the `ReplaySubject` class can take a second parameter to determine how long the `ReplaySubject` class will store the emitted values in the buffer of `ReplaySubject`:

const subject = new ReplaySubject(100, 500);

subject.subscribe({

next: (v) => console.log(A: ${v}),

});

let i = 1;

setInterval(() => subject.next(i++), 200);

setTimeout(() => {

subject.subscribe({

next: (v) => console.log(B: ${v}),

});

}, 1000);

// 日志输出

// A:1, A:2, A:3, A:4, A:5, B:3, B:4, B:5, A:6, B:6

// ...


 In the preceding example, we have a `ReplaySubject` class with a buffer of `100` and a time window of `500` milliseconds. Subscriber `A` subscribes before we emit the first value. Next, we create an interval that emits a new number every 200 milliseconds. As a result, subscriber `A` will receive and log a new value every 200 milliseconds.
Lastly, we create a timeout of `1` second and add the second subscriber. Because we have a time window of `500` milliseconds, subscriber `B` will immediately receive all values that are emitted after the first `500` milliseconds – that is, the timeout of `1` second that has passed minus the time window of the `ReplaySubject` class.
As a result, subscriber `A` logs `1` to `5`; after 1 second, subscriber `B` joins and immediately receives values `3`, `4`, and `5`. After subscriber `B` receives the replay values, both subscribers receive all values emitted after that point.
AsyncSubject
The `AsyncSubject` class is also an extension of the regular `Subject` class. The `AsyncSubject` class only emits the last value to all its subscribers and only when the Observable data stream is completed:

const subject = new AsyncSubject();

subject.subscribe({

next: (v) => console.log(A: ${v}),

});

subject.next(1);

subject.next(2);

subject.subscribe({

next: (v) => console.log(B: ${v}),

});

subject.next(3);

subject.complete();

// 日志输出:

// A:3, B3


 In the preceding example, you can see that `AsyncSubject` only emitted the last value that was emitted before we completed the data stream. First, subscriber `A` subscribed to `AsyncSubject`. Next, we emitted two values, and then subscriber `B` subscribed to `AsyncSubject`. Lastly, we emitted the third value and completed the Observable stream. After we complete the stream, the last value is emitted to and logged by subscribers `A` and `B`.
Now, you know about Observables, Observers, and `Subjects`. You know that there are hot and cold Observables and the difference between the two. You also learned that Observers subscribe to Observables and how to unsubscribe from Observable data streams. You discovered that `Subjects` is a special kind of Observable that’s used to multicast values and that they can emit values using the `next()` method. Lastly, you learned about the four different `Subject` types and saw how you can visualize their differences. Next, we will learn about the last major concept in RxJS: operators.
Using and creating RxJS operators
In this section, you will learn about **RxJS operators**. You will learn what operators are, what types of operators there are, and how to use some of the most commonly used operators in the RxJS library. You will also learn how to create your own RxJS operators and combine multiple operators into a single operator.
While Observables are the foundation of the RxJS library, operators are what make the library so useful and powerful for handling Observable data streams. Operators allow you to easily compose and handle complex asynchronous code declaratively.
Types of operators
RxJS operators come in two different types: creational and pipeable operators.
In short, creational operators can be used to create new Observables with a standalone function, whereas pipeable  operators can be used to modify the Observable stream. Let’s explore both in more detail, starting with creational operators.
Creational operators
`of()` operator. The `of()` operator takes in one or more comma-separated values and turns these values into an Observable stream that emits one value after the other:

of() 操作符带有三个值:1、2 和 3。我们订阅了使用 of() 操作符创建的 Observable 流,并记录了这些值。这个订阅将导致三个独立的日志输出:值:1、值:2 和值:3。正如你所见,of() 操作符是一个相当简单直接的方式来创建 Observable 流。

另一个常用的创建操作符是 from() 操作符。from() 操作符创建一个数组、可迭代对象、Promise 或字符串的 Observable 流。如果你使用 from() 操作符将字符串转换成 Observable 流,字符串将被逐字符发射。以下是一个使用 from() 操作符的例子:

from([1, 2, 3, 4, 5]).subscribe(val => console.log(val));
//output: 1,2,3,4,5
from(new Promise(resolve => resolve('Promise to Observbale!'))).subscribe(val => console.log(val));
//output: Promise to Observbale!

在前面的例子中,我们使用了 from() 操作符从一个数组和一个 Promise 创建了一个 Observable 流。

另一个有用的创建操作符是 fromEvent() 操作符。fromEvent() 操作符可以从事件目标(如 clickhover)创建一个 Observable:

fromEvent() operator takes two arguments. The first is the target element – in our case, we took the document. Then, you declared the event you wanted to listen for; in our example, this is a click event.
With that, you’ve learned how to create a new Observable stream from scratch using creational operators. Next, you will learn how to create a new Observable stream by combining multiple existing Observable streams.
Creating an Observable from multiple Observable streams
As your Angular applications grow and the state of these applications becomes more complex, you often find yourself in a situation where you need the result of multiple Observable streams simultaneously. When you need the result from various Observable streams, you may be tempted to create nested subscriptions, but this isn’t a good solution since nested subscriptions can lead to strange behavior and hard-to-debug bugs.
In scenarios where you need the result of multiple Observables, you can use creational RxJS operators that focus on combining various Observables into a new single Observable stream. When using these operators, the combined Observables are referred to as `combineLatest()` operator.
The `combineLatest()` operator is best used when you have multiple long-lived Observables and need the values of all these Observables to construct the object or perform the logic you want. The `combineLatest()` operator will only output its first value when all of its inner Observables output at least one value; after that, `combineLatest()` outputs another value each time one of the inner Observables emits a new value. The `combineLatest()` operator will always use the last emitted value of all its inner Observables:

const amountExclVat = of(100);

const vatPercentage = of(20);

combineLatest([amountExclVat, vatPercentage]).subscribe({

next: ([amount, percentage]) => {

console.log('总计:', amount * (percentage / 100 + 1));

}

});


 As you can see, we provided `combineLatest()` with an array containing two Observables: one Observable with the amount excluding VAT and another Observable containing the VAT percentage. We need both Observable values to log the amount, including VAT. Instead of creating a nested subscription, we handled this with `combineLatest()`.
Inside the `combineLatest()` subscription, we also declare an array for the value of the Observable stream. We used `amount` and `percentage` as values, but you can name these properties however you like. Alternatively, you can use a different syntax and provide an object to `combineLatest()` instead of an array:

combineLatest({ amount: amountExclVat, percentage: vatPercentage }).subscribe({

next: (data) => { console.log('总计:', data.amount * (data.percentage / 100 + 1)) }

});


 Now, let’s consider another example where we emit different values over time so that you get a better understanding of how `combineLatest()` works and when and what values it will emit:

const a = new Subject();

const b = new Subject();

combineLatest([a, b]).subscribe({

next: ([a, b]) => { console.log(‹data›, a, b) }

});

a.next(1);

setTimeout(() => { b.next(2) }, 5000);

setTimeout(() => { a.next(10) }, 10000);


 In the preceding example, it takes 5 seconds before the `combineLatest()` operator emits the first value; this is because, after 5 seconds, both Observable `a` and `b` have emitted a value. Even though Observable A directly emits a value, `combineLatest()` will only emit a value after both A and B have emitted at least one value.
After 5 seconds have passed and both Observables have emitted a value, `combineLatest()` will emit a value, and we log `data: 1, 2`. After both Observables emit a value, `combineLatest()` will emit a new value whenever one of its Observables emits a new value. So, when Observable A emits a new value after another 5 seconds have passed, we log `data: 10, 2` inside the subscription of `combineLatest()`.
If, for example, you first emitted two values with Observable A (`1`, `10`) and then emitted a value with Observable B (`2`), `combineLatest()` will only emit one value, `data: 10, 2`. This is the case because both A and B need to emit a value before `combineLatest()` starts emitting values.
Now that you have a good idea of how `combineLatest()` works and how to use RxJS to create a new Observable based on multiple Observables, let’s explore other operators that create an Observable from multiple Observables:

*   `forkJoin()` operator is best used when you have multiple Observables and are only interested in the final value of each of these Observables. This means that each Observable has to be completed before `forkJoin()` emits a value. A good example of when `forkJoin()` is useful is when you must make multiple HTTP requests and only want to do something when all requests return a result. The `forkJoin()` operator can be compared with `Promise.all()`. It’s important to note that if one or more of the inner Observables has an error (and you don’t catch that error correctly), `forkJoin()` will not emit a value:

    ```

    forkJoin({ posts: this.http.get('…'), post2: this.http.get('…)}).subscribe(console.log);

    concat()操作符用于当你有多个内部 Observables 时,并且这些内部 Observables 的发射和完成顺序是重要的。所以,如果你有两个内部 Observables,concat()将发射第一个内部 Observables 的所有值,直到该 Observables 完成。在第一个 Observables 完成之前,第二个 Observables 已经发射的所有值将不会由 concat()发射。当第一个 Observables 完成时,`concat()`操作符将订阅第二个 Observables 并开始发射第二个 Observables 发射的值。如果你有更多的内部 Observables,这个过程将重复进行,并且`concat()`将在前一个 Observables 完成时订阅下一个 Observables:

    ```js
    const a = new Subject();
    const b = new Subject();
    concat(a, b).subscribe(console.log);
    a.next(1);
    a.next(2);
    b.next(3);
    a.complete();
    b.next(4);
    b.complete();
    merge() operator combines all inner Observables and emits the values as they come in. The merge() operator doesn’t wait for all Observables to emit a value, nor does it care about the order. When one of the Observables emits a value, the merge() operator will process it:

    ```

    const a = new Subject();

    const b = new Subject();

    merge(a, b).subscribe(console.log);

    b.next('B:1');

    a.next('A:1');

    a.next('A:2');

    b.next('B:2');

    // Logs: B:1, A:1, A:2, B:2

    ```js

    ```

    ```js

You now know how to create Observables with creational operators. You know there are creational operators such as `of()` and `from()` to create simple Observables and creational operators such as `combineLatest()` to create a new Observable based on multiple inner Observables.
Next, we will learn about pipeable operators and how they can be used to filter, modify, and transform Observable streams.
Pipeable operators
**Pipeable operators** take in an Observable as input and return a new and modified Observable without modifying the original Observable. When you subscribe or unsubscribe to the piped Observable, you also subscribe or unsubscribe to the original Observable. Pipeable operators can filter, map, transform, flatten, or modify the Observable stream. For example, pipeable operators can be used to unsubscribe upon a trigger automatically, take the first or last emission of an Observable steam, only emit an Observable value if specific conditions are met, or map the output of the Observable stream into a new object.
Using pipeable operators starts with using the `pipe()` function on an Observable. The `pipe()` function acts like a path for your Observable data, guiding it through different tools called operators. It’s like how materials in a factory move through various stations before becoming a finished product. Here, your data can go through these operators, where you can change it, pick out specific parts, or make it fit your needs. It’s a common scenario that developers use four, five, or even more operators inside a single `pipe()` function.
Let’s examine an example and learn about some commonly used pipeable operators:

const observable = of(1, 1, 2, 3, 4, 4, 5);

observable.pipe(

distinctUntilChanged(),

filter(value => value < 5),

map(value => value as number * 10)

).subscribe(results => {console.log('results:', results)});

// Logs: 10, 20, 30, 40


 In the preceding code, we created an Observable using the `of()` operator. On the Observable, we use the `pipe()` function with three different pipeable operators declared inside the pipe function: `distinctUntilChanged()`, `filter()`, and `map()`. At the end of the pipe function, we subscribe to the Observable stream. The values of the Observable stream move through the pipe and perform the operators on them one by one before ending up in the subscribe block of our code.
The first `distinctUntilChanged()` operator checks if the Observable value differs from the previous and filters it out if the value is the same as the last emitted value. Next, the `filter()` operator works similarly to the filter function on an array; in our case, we filter out all values that aren’t smaller than `5`. Lastly, we use the `map()` operator; this is also similar to the `map` function on an array and lets you map the value to a new value; in our case, we multiply by a factor of `10`. After applying all our operators, the Observable values that are logged in the subscription are `10`, `20`, `30`, and `40`; all other values of our Observable are filtered out.
As you can see, the pipeable operators are performed one after another, guiding the Observable value through a pipe where changes are applied to the value until it reaches the subscription or is filtered out. The `distinctUntilChanged()`, `filter()`, and `map()` operators are some of the most commonly used operators. In this chapter, you also learned about the `takeUntil()` and `takeUntilDestroyed()` operators, which are also commonly used.
Now, let’s continue by exploring some other powerful and commonly used operators and scenarios when pipeable operators are helpful, starting with flattening operators.
Flattening multiple Observable streams using flattening operators
As we’ve seen earlier in this section, sometimes, you need the value of multiple Observables. In some cases, you need all these values at once; in these scenarios, you can use the creational operators that create a new Observable based on multiple inner Observables.
But in other scenarios, you first need the value of one Observable to pass as an argument to another Observable. These scenarios where you have an outer Observable and an inner Observable, where the inner Observable relies on the value of the outer Observable, are commonly referred to as `concatAll()`, `mergeAll()`, `swtichAll()`, and `exhaustAll()`. To get a better understanding of this concept, let’s look at some examples.
Let’s say you have an Observable yielding an API URL. Next, you want to use this URL to make an HTTP request. In actuality, you’re only interested in the result of the API request and not so much in the result of the Observable yielding the URL. The URL is only needed to make the API request, and the API response is required to render your page or perform some logic. One approach would be to nest the two subscriptions, but as you’ve learned, this isn’t a good approach. The correct solution is to use a flattening operator to flatten the Observable stream into a single stream:

ngOnInit() {

this.urlObservable.pipe(

map((url) => this.http.get(url)),

concatAll()

).subscribe((data) => { console.log('data ==>', data) })

}


 As you can see, we have an outer Observable receiving an API URL and using two pipeable operators on this Observable. First, we use the `map()` operator to take the result of the URL Observable and use it to make the API request, which results in our second Observable. Next, we use the `concatAll()` operator to flatten the two Observables into a single Observable, only returning the result of the API request.
Inside the subscription, we log the result, which will be the data that’s returned by the API call. You can simplify this code even more by using the combined operator, `concatMap()`, which combines the `map()` and `concatAll()` operators into a single operator:

this.urlObservable.pipe(

concatMap((url) => this.http.get(url)),

).subscribe(……)


 These combined operators exist for all four flattening operators, so you have the following operators:

*   `concatMap()`
*   `mergeMap()`
*   `switchMap()`
*   `exhaustMap()`

Now that you’ve seen how you can use a flattening operator and that there are map operators that combine the map and flattening operators, let’s learn about the difference between them.
The concatMap() operator
The `concatAll()` operator is used when you want the first value of the outer Observable and all its inner concatenated Observables to complete before the second value of the outer Observable and its inner Observables are processed. Let’s consider the following example:

const clicks = fromEvent(document, 'click');

clicks.pipe(

concatMap(() => interval(1000).pipe(take(4)));

).subscribe(number => console.log(number));


 In the preceding code, we use the `fromEvent()` creational operator to create an Observable whenever we click the browser document (that would be any place in our app). Next, we use `concatMap()` to map the result of the click Observable into a new Observable using the RxJS `interval()` creational operator. The `interval()` operator will emit sequential numbers starting at zero; in our case, it will emit the following number every `1000` milliseconds.
We also used the `take()` pipeable operator on the interval Observable. This limits the number of emissions we take to `4`, so the interval Observable will emit `0`, `1`, `2`, and `3` as values and be unsubscribed and completed by the `take()` operator afterward.
Because we use the `concatMap()` flattening operator, when we click twice on the screen, both the outer and inner Observables will be triggered two times, but the first click Observable and its inner Observables will be processed first and only when that is completed the second sequence will start. So, our subscription part will log `0, 1, 2, 3, 0, 1,` `2, 3`.
The mergeMap() operator
Now, let’s consider the same scenario with the other flattening operators, starting with the `mergeMap()` operator:

const clicks = fromEvent(document, 'click');

clicks.pipe(

mergeMap(() => interval(1000).pipe(take(4))),

).subscribe(x => console.log(x));


 In the preceding example, we only changed `concatMap()` for the `mergeMap()` operator, yet the result will be completely different. The `mergeMap()` operator will not wait for the first inner and outer Observables to complete but will process the values as they come in.
So, if you click on the screen, wait for 2 seconds, and then click on the screen again, the values of the second click and its inner interval Observable will start to come in before the first stream has completed. If you click the first time on the screen, the first log will come in after one second and another one for every second.
Then, when you click again after a second, the first log of the second stream will come in and log another value for every second after that. In this case, the result of all logs would be `0, 1, 2, 0, 3, 1, 2, 3`. As you can see, the result is entirely different from the result we had with `concatMap()`. Now, let’s see what happens when we change `mergeMap()` to `switchMap()`.
The switchMap() operator
The `switchMap()` operator will switch the Observable stream from the first to the second stream when the second stream starts to emit values. The `switchMap()` operator will unsubscribe and complete the first stream so that the first stream will stop emitting values; the next stream will keep emitting until that stream is completed or until another stream comes:

const clicks = fromEvent(document, 'click');

clicks.pipe(

switchMap(() => interval(1000).pipe(take(4)));

).subscribe(x => console.log(x));


 So, with the preceding code, if we click on the screen now, wait for 2 seconds, and then click another time, our log will look like `0, 1, 0, 1, 2, 3`. As you can see in the logs, the first stream is completed the moment the second stream starts to emit values. Lastly, we have the `exhaustMap()` operator.
The exhaustMap() operator
The `exhaustMap()` operator will start to emit the values of the first Observable stream as soon as it starts to emit values. The `exhaustMap()` operator will not process any other Observable streams that come in while the first stream is still running. Only when the stream has been completed will new values be processed, so if you click while the first stream is still running, it will never be processed:

const clicks = fromEvent(document, 'click');

clicks.pipe(

exhaustMap(() => interval(1000).pipe(take(4))),

]).subscribe(x => console.log(x));


 In the preceding example, where we used the `exhaustMap()` operator and clicked and waited for 2 seconds before we made another click, only the first click will be processed because the first stream takes 4 seconds to complete. So, when we make the second click, the first stream is not completed yet, so `exhaustMap()` doesn’t process the second stream. The log of the preceding code will look like `0, 1,` `3, 4`.
Lastly, it’s important to note that when you use the `take()`, `takeUntil()`, or `takeUntilDestroyed()` operators inside the pipe of the outer Observable and also use flattening operators in the same `pipe()` function, the `take()`, `takeUntil()`, and `takeUntilDestroyed()` operators need to be declared after the flattening operators. The flattening operators will create their own Observables, and if you declare `take()`, `takeUntil()`, or `takeUntilDestroyed()` before the flattening operator, the Observables created by the flattening operators will not be unsubscribed and closed by `take()`, `takeUntil()`, or `takeUntilDestroyed()`.
You now know what higher-order Observables are and how you can handle them using flattening operators. You learned about combined operators that combine the `map()` and flattening operators, and you learned about using `take()`, `takeUntil()`, or `takeUntilDestroyed()` in combination with the flattening operators. Lastly, you learned about the `interval()` and `take()` operators.
Now, let’s start exploring other useful pipeable operators that serve a few more straightforward use cases and scenarios.
Powerful and useful RxJS operators
You have already learned much about RxJS and seen how you can handle some complex Observable scenarios by combining or flattening Observables. You’ve also seen how to unsubscribe Observables or filter values using pipeable operators. We will now walk through some commonly used pipeable operators that are useful in more straightforward scenarios:

*   `debounceTime()`: The `debounceTime()` operator takes a pause and waits for another value to come in within the defined timeframe. A good real-world example of this is a search or filter input field. Instead of bombarding your system with an update for every keystroke, a more efficient solution would be waiting until the user stops typing for a specific interval. This waiting can be done by using `debounceTime()`. You provide `debounceTime()` with a parameter indicating the milliseconds it should wait (`debounceTime(300)`) before processing the value; only when no new value is received within the specified timeframe will the value be passed on to the next operator or the subscription block.
*   `Skip()`: The `skip()` operator can skip a fixed number of emissions. Let’s say you have `ReplaySubject`, and for one of your subscriptions on `ReplaySubject`, you aren’t interested in the replayed emissions, only in the new emission. In this scenario, you can use the `skip()` operator and define the number of emissions you want to skip inside the operator: `skip(5)`.
*   `skipUntil()`: The `skipUntil()` operator works a bit like the `takeUntil()` operator; only it will skip the emissions until the inner Observable of `skipUntil()` receives a value. You could provide `skipUntil()` with a `Subject` class or something like an RxJS timer so that you only take values after a predefined interval has passed: `skipUntil(timer(5000))`.
*   `find()`: The `find()` operator works similarly to the `find` method on an array. It will only emit the first value it finds that matches the condition you provide the `find()` operator with. So, `find((item: any) => item.size === 'large')` will only pass on the first item through the pipe where the size property is equal to `large`.
*   `scan()`: The `scan()` operator is comparable to the `reduce` function on an array. It gives you access to the previous and current value and allows you to emit a new value based on the previous and current value. For example, you can combine the results or take the lowest or highest result of the two: ``scan((prev, curr) => `${prev} ${curr}`, '')``. Here, we combined the previous and current values using the `scan()` operator.

With that, we have covered some of the more commonly used operators and learned how to filter, map, limit, or transform the value stream with pipeable operators. You can find them in the official documentation if you want to learn more about operators and check out a complete list: [`rxjs.dev/guide/operators`](https://rxjs.dev/guide/operators).
Before we move on to the next section and start to learn about Angular Signals, let’s finish this section on RxJS by creating combined and reusable operators.
Creating combined and reusable RxJS operators
Creating a reusable operator or combining multiple operators can easily be done by creating a function that returns an RxJS `pipe()` function. Let’s say you find yourself making a filter pipe that filters odd numbers multiple times. Creating a function that does this would be easier so that you don’t have to repeat the logic numerous times. You can do this by creating a function that returns an RxJS pipe implementing the filter operator with the filter logic predefined in the operator:

export const discardOdd = () => pipe(

filter((v: number) => !(v % 2)),

);


 You can now use this pipeable operator like any other operator:

of(1, 2, 4, 5, 6, 7).pipe(discardOddDoubleEven()).subscribe(console.log);


 If you want to combine multiple operators, it works the same way: you must create a function that returns an RxJS pipe and declares all the operators you want to use inside the pipe function:

const discardOddDoubleEven = () => pipe(

filter((v: number) => !(v % 2)),

map((v: number) => v * 2)

);


 With that, you’ve learned all about operators and how to use pipeable and creational operators. You know how to combine and flatten multiple Observable streams and create reusable and combined operators using the `pipe()` function. You’ve seen how to create Observable streams and handle code reactively and asynchronously with Observables and RxJS.
Since the introduction of Angular Signals, you can also handle reactivity more synchronously, allowing you to do almost everything in your Angular applications in a reactive manner, both with synchronous and asynchronous code.
In the next section, you will learn everything about Angular Signals. You will learn what Signals are and how and when to use them.
Reactive programming using Angular Signals
We briefly discussed **Angular Signals** in *Chapter 2*, but let’s reiterate that and dive a bit deeper so that you can get a good grasp of Angular Signals and how they can help you handle code more reactively.
Angular Signals was introduced in Angular 16, and it’s one of the most significant changes for the framework since it went from AngularJS to Angular. With Signals, the Angular framework now has a reactive primitive in the Angular framework that allows you to declare, compute, mutate, and consume synchronous values reactively. A **reactive primitive** is an immutable value that alerts consumers when the primitive is set with a new value. Because all consumers are notified, the consumers can automatically track and react to changes in this reactive primitive.
Because Signals are reactive primitives, the Angular framework can better detect changes and optimize rendering, resulting in better performance. Signals are the first step to an Angular version with fully fine-grained and local change detection that doesn’t need Zone.js to detect changes based on browser events.
At the time of writing, Angular assumes that any triggered browser event handler can change any data bound to an HTML template. Because of that, each time a browser event is triggered, Angular checks the entire component tree for changes because it can’t detect changes in a fine-grained manner. This is a significant drain on resources and impacts performance negatively.
Because Signals notify interested parties of changes, Angular doesn’t have to check the entire component tree and can perform change detection more efficiently. While we aren’t at a stage yet where Angular can perform fully local change detection and only update components or properties with changes, by using Signals combined with OnPush change detection, you reduce the number of components Angular has to check for changes. Eventually, Signals will allow the framework to perform local change detection, where the framework only has to check and update components and properties that have changed values.
Besides change detection, Signals bring more advantages. Signal allows for a more reactive approach within your Angular code. While RxJS already does a fantastic job facilitating reactive programming within your Angular applications, RxJS focuses on handling asynchronous Observable data streams and isn’t suited to handle synchronous code.
On the other hand, Signals shine where RxJS falls short; Signals reactively handle synchronous code by automatically notifying all consumers when the synchronous value changes. All dependent code can then react and update accordingly when the Signal pushes a new value. Especially when you start to utilize Signal effects and computed Signals, you can take your reactivity to the next level! Signal effects and computed Signals will automatically compute new values or run side effects when the Signal value changes, making it easy to automatically update and run logic as a reaction to the changed value of synchronous code.
Another problem that Signals solves is the infamous and dreaded `ExpressionChanged` **AfterItHasBeenCheckedError** error. If you’ve worked with Angular, changes are pretty significant you’ve seen this error before. This error occurs because of how Angular currently detects changes. Because the change detection on Signals is different, as Angular knows when they change and doesn’t have to check for changes, the dreaded `ExpressionChangedAfterItHasBeenCheckedError` error will not occur when working with Signal values.
Signals wrap around values such as strings, numbers, arrays, and objects. The Signal then exposes the value through a getter, which allows the Angular framework to track who is consuming the Signal and notify the consumers when the value changes. Signals can wrap around simple values or complex data structures such as objects or arrays with nested structures. Signals can be read-only and writable. As you might expect, writeable Signals can be modified, whereas read-only Signals can only be read.
Now that you understand the theory behind Signals, let’s dive into some examples and learn how and when to use Angular Signals within Angular applications.
Using Signals, computed Signals, and Signals effects
The best way to better understand something is to use it. So, without further ado, let’s start learning about Signals by writing some code. Start by cleaning up your `expenses-overview` component, clear the entire HTML template, and remove any logic you still have in the component class. Your component class and the corresponding HTML template should be empty when you’re done.
To explain Signals step by step, we will initially use hardcoded expenses inside the `expenses-overview` component. We’ll start by creating an `expenses` Signals with an initial value containing an array with some expenses inside:

expenses = signal<ExpenseModel[]>([ …… ]);


 As you can see, we created a property and assigned it a `signal()` function. This function receives a parameter that sets the initial value of the Signal. In our example, we have added an array with some expenses for the initial value (you can create the mocked expenses based on `ExpenseModel`). You can manually add a type for your Signal using the arrow syntax, `<ExpenseModel[]>`, but the Signal also infers the type from the initial value. Let’s use this Signal inside our HTML template to output the expenses.
You can access a Signal like any other function – you use the property name of the Signal and add function brackets after it. In general, I don’t recommend using functions inside your HTML template, but Signals are an exception as they are non-computational functions; they return a value without computing anything. So, let’s output our Signal inside the HTML template:

Expenses Overview

……

@for (expense of expenses(); track expense.id){

……

}

Description
{{ expense.description }}

 Here, we’ve created an HTML table and used the control flow syntax to output a table row for each expense within our `expenses` Signal. We accessed the expenses by calling our Signal with `expenses()`. You can compose your own table headers and data rows or copy the HTML and CSS from this book’s GitHub repository. Now that you know how to create and use Signal values, next, you will learn how to update your Signals.
Updating Signals
A Signal can be updated by using the `set()` or `update()` method on it. The `set()` method sets an entirely new value, whereas the `update()` method allows you to use the current Signal value and construct a new value based on the current value of the Signal.
To demonstrate this, let’s add a modal with `AddExpenseComponent` inside the modal. Before you add the form inside the template, let’s update `AddExpenseComponent` so that it uses our new `ExpenseModel` instead of the `AddExpenseReactive` model we used in *Chapter 4*. Replace all instances of `AddExpenseReactive` with `ExpenseModel`. Now, change the `date` and `tags` fields in the `addExpenseForm` property to this:

date: new FormControl<string | null>(null, [Validators.required]),

tags: new FormArray<FormControl<string | null>>([

new FormControl('');

])


 Now that we’ve updated the form so that it uses `ExpenseModel`, let’s import `AddExpenseComponent` and `ModalComponent` into `ExpensesOverviewComponent` so that we can use them inside the HTML template. Next, create a new Signal in `ExpensesOverviewComponent` to control the state of the modal component:

showAddExpenseModal = signal(false);


 After adding the Signal to control the modal state, you can add both the modal and expense components to the HTML template, like this:

<bt-libs-modal [shown]=" showAddExpenseModal()" (shownChange)=" showAddExpenseModal.set(false)" [title]="'添加费用'">

<bt-libs-ui-add-expense-form #form (addExpense)="onAddExpense($event)" />


 As you can see, when the modal outputs the `shownChange` event, we use the `set()` method on the `showAddExpenseModal` Signal to set a new value for the Signal. In this scenario, we don’t care about the previous signal value because we know we want to close the modal when this event is fired. Because we don’t care about the previous value, we can use the `set()` method on the Signal to set a new value. Inside the component class, we need to add the `onAddExpense` method so that we can the expense we submit in the add expense form:

onAddExpense(expenseToAdd: ExpenseModel) {

this.expenses.update(expenses => [...expenses, expenseToAdd]);

this. showAddExpenseModal.set(false);

}


 In the preceding code, we used the `update()` method to change the `expenses` Signal and the `set()` method for the `showAddExpenseModal` Signal. We use the `update()` method for the `expenses` Signal to access the current state of `expenses` and add the new expense to the existing expenses. When we submit the form, we also want to close the modal. For this, we can use the `set()` method because we just wish to change the Signal to a `false` value and are not interested in the current value of the Signal. Lastly, we need a button to open the modal:

<button (click)="showAddExpenseModal.set(true)">添加费用


 After adding the button, you can open the modal and create a new expense using `addExpenseForm`.
Lastly, it’s good to know that when you update a Signal using `set()` or `update()` in a component with `OnPush` change detection, the component will automatically be marked by Angular to be checked for changes. As a result, Angular will automatically update the component on the next change detection cycle.
Now that you know how to create and update Signals, let’s learn about computed Signals.
Computed Signals
`set()` or `update()` method on them. Instead, computed Signals automatically update when one or more Signals they derive their value from changes.
Let’s start with a basic example to better understand computed Signals and how they work. You don’t have to add this example inside `ExpensesOverviewComponent`; it’s just for demonstration purposes:

const count: WritableSignal = signal(0);

const double: Signal = count Signal and a computed Signal named double. The computed Signal uses the computed function, and the count Signal is used inside the callback of the computed function. When the value of the count Signal changes to 1, the value of the computed Signal is automatically updated to 2.

重要的是要知道,计算信号仅在它所依赖的信号有新的稳定值时才会更新。注意我说的是稳定值;这是因为信号异步提供更新,并且有点像 RxJS 的switchMap操作符,如果在旧数据流完成之前新数据流到来,它会取消前一个数据流。所以,如果您连续多次更新信号而不暂停,信号将不会稳定其值,因此计算信号将仅在信号的最后一次值变化上运行。

计算信号非常强大且效率很高。计算信号将不会在首次读取计算值之前计算任何值。接下来,计算信号将缓存其值,当您再次读取计算信号时,它将简单地返回缓存的值而无需运行任何计算。如果计算信号使用的信号值发生变化,计算信号将运行新的计算并更新其值。

由于计算信号缓存其结果,您可以在计算信号的回调函数内部安全地使用计算密集型操作,如过滤和映射数组。就像常规信号一样,计算信号在值变化时通知所有消费者。因此,所有计算信号的消费者都将显示最新的计算值。

现在,让我们向ExpensesOverviewComponent添加一个计算信号以显示总金额,包括增值税:

totalInclVat = computed(() => this.expenses().reduce((total, { amount: { amountExclVat, vatPercentage } }) => amountExclVat / 100 * (100 + vatPercentage) + total, 0));

正如您所看到的,我们使用了computed函数,并在computed函数的回调中使用了expenses信号来检索当前的费用列表。我们可以使用Array.reduce函数在expenses数组上检索包括增值税在内的总成本。您可以像访问常规信号一样访问计算信号:

this.totalInclVat()

让我们在 HTML 模板中创建一个新的表格行以显示总价值。您可以在 HTML 模板中的for循环下方添加表格行:

<tr class="summary">
  <td>Total: {{totalInclVat()}}</td>
</tr>

假设你使用添加支出表单添加了一笔新支出。在这种情况下,你会注意到总金额会自动更新,因为计算信号使用expenses信号来评估总金额。当expenses信号发生变化时,计算信号也会根据expenses信号进行更新。

关于计算信号,还有一点值得了解的是,只有用于计算的信号才会被跟踪。例如,假设你添加了一个信号来控制是否显示或隐藏表格行摘要,以及另一个用于相应按钮文本的信号:

showSummary = signal(false);
summaryBtnText = computed(() => this.showSummary() ? 'Hide summary' : 'Show summary');

你现在可以在计算信号内部使用以下showSummary信号,如下所示:

totalInclVat = computed(() => this.showSummary() ? this.expenses().reduce(
    (total, { amount: { amountExclVat, vatPercentage } }) => amountExclVat / 100 * (100 + vatPercentage) + total,
    0
  ) : null);

在这个场景中,计算信号只会跟踪expenses信号,如果showSummary信号设置为true。如果showSummary信号设置为falseexpenses信号在computed函数内部永远不会被访问,因此它不会因为变化而被跟踪。所以,如果你在showSummary信号设置为false时更新expenses信号,计算信号将不会计算新的值。

现在你已经了解了计算信号是什么,如何在代码中使用它们以及计算信号如何更新它们的值,让我们来探索信号效果。

信号效果

信号效果是每次信号变化时都会运行的副作用。你可以在信号效果内部执行任何你想要的逻辑。信号效果的用例可能包括记录日志、更新本地存储、显示通知或执行无法从 HTML 模板内部处理的 DOM 操作。

你可以通过使用effect函数并为其提供一个回调来创建信号效果:

effect function is initialized. Furthermore, when you use a Signal inside the callback of the effect function, the effect function becomes dependent on that Signal, and the effect function will run each time one of the Signals it depends on has a new stable value.
It is also good to know that just as with computed Signals, a Signal effect only runs if the Signal within the `effect` function can be reached:

effect(() => {

if (this.showSummary()) {

console.log('更新后的支出:', this.expenses());

}

});


 In the preceding example, the `effect` function will not run if the `expenses` signal updates while the `showSummary` signal is evaluated to be `false`. Besides unreached Signals, you can also prevent the Signal’s `effect` function from reacting to a Signal by wrapping that Signal in the `untracked` function:

effect = effect(() => {

console.log('摘要:', this.showSummary());

console.log('支出:', untracked(this.expenses()));

});


 Another good thing to know about Signal effects is that they need access to the injection context. This means you need to declare the Signal inside the constructor or directly assign it to a property where you declare your component properties. An error will be thrown when you create a Signal effect outside the injection context. If you need to declare a Signal effect outside the injection context, you can provide the effect with the injection context like so:

injector = inject(Injector);

effect(() => {

console.log('更新后的支出:', this.expenses());

}, { injector: this.injector });


 By default, effects clean up when the injection context where the effect is declared is destroyed. If you don’t want this to happen and you need manual control over the destruction of the signal effect, you can configure the effect so that it uses manual cleanup:

expenseEffect = effect(() => {

console.log('更新后的支出:', this.expenses());

}, { manualCleanup to true, you have to call the destroy() function on the effect:

expenseEffect.destroy();

你还可以通过使用回调函数来挂钩信号效果的清理。这在你想在信号效果清理时执行一些逻辑时非常有用。以下是一个onCleanup回调的示例:

effect((onCleanup) => {
  onCleanup(() => { console.log('Cleanup logic')})
})

除了manualCleanuponCleanup回调之外,信号效果还有一个最后的配置选项。默认情况下,你不允许在信号效果内部更新信号;这是因为这很容易导致信号效果的无限执行。然而,你可以通过在信号效果上设置allowSignalWrites属性来规避这一点:

expenseEffect = effect(() => {
  console.log(‹Updated expenses:›, this.expenses());
}, { allowSignalWrites: true });

现在你已经了解了关于信号效果的所有内容,包括如何使用它们以及如何触发或配置它们,让我们来学习信号组件的输入。

Signal 组件输入

自 Angular 17.1 以来,您也可以使用 Signal 作为组件输入,而不是使用@Input()装饰器和ngOnChanges生命周期钩子。

让我们看看 Signal 输入的一个示例,并将其与@Input()装饰器进行比较(您不需要在 monorepo 中添加示例,只是为了说明目的):

@Input() data!: DataModel; // The old way of doing things
data = input<DataModel>(); // The signal input

如您所见,Signal 输入在声明输入属性方面有一个更直接的方法。您声明一个属性并使用input()函数为其赋值;可选地,您还可以添加箭头语法来为 Signal 输入添加类型。如果您想为 Signal 输入提供一个初始值,您可以将其作为函数参数提供,如下所示:

data = input({ values: [……], id: 1 });

就像输入装饰器一样,您也可以使输入成为必需的,使用输入别名,或创建输入上的转换函数:

data = input.required<DataModel>();
data = input({ values: [……], id: 1 }, { alias: 'product'});
data = input({ values: [……], id: 1 }, transform: sort<DataModel>);
export function sort<T>(data: T[]): T[] {
  return data.sort((a, b) => a.id - b.id)
}

如您所见,您可以使输入成为必需的,并使用配置对象为 Signal 输入提供一个别名和转换函数。Signal 输入使用更简单的语法,有助于改进变更检测机制,并允许您移除ngOnChanges生命周期钩子。

当您将 Signal 输入与计算 Signal 结合使用时,可以移除ngOnChanges生命周期钩子,因为所有需要在特定属性输入时更新的属性现在都可以通过基于输入 Signal 的计算 Signal 自动更新。您想要运行的任何附加逻辑都可以在响应 Signal 输入的 Signal 效果内部声明。

既然您已经了解了 Signal 输入,让我们来学习 Signal 查询,它用于以响应式的方式与 HTML 元素交互。

Signal 查询

通常,您需要从模板中选择 HTML 元素并在组件类内部与之交互。在 Angular 中,这通常是通过使用@ContentChild@ContentChildren@ViewChild@ViewChildren装饰器来实现的。在 Angular 17.2 中,引入了一种基于 Signal 的新方法,允许您以响应式的方式与 HTML 元素交互,并将它们与计算 Signal 和 Signal 效果相结合。使用基于 Signal 的方法而不是装饰器提供了一些额外的优势:

  • 您可以使查询结果更加可预测。

  • 所有基于 Signal 的查询都返回一个 Signal,当有多个值时,Signal 返回一个常规数组。装饰器返回多种返回类型,当您的查询返回多个值时,它们返回一个查询列表而不是常规数组。

  • 基于 Signal 的查询可以用于随时间变化的 HTML 元素,因为它们是条件渲染的或通过for循环输出的。指令方法在这两种情况下都有问题,并且不会在模板更新时自动通知您。

  • TypeScript 可以自动推断查询 HTML 元素或组件的类型。

现在您已经了解了基于查询的信号与指令方法相比的优势,让我们来探索基于查询的信号的语法。与定义指令不同,基于信号的方法与简单的函数一起工作。有四个不同的函数:viewChild()contentChild()viewChildren()contentChildren()。您向这些函数提供查询选择器,类似于您可以使用装饰器组合的查询选择器。以下是如何使用信号查询的示例:

@Component({
  template: `
      <div #el></div>
      <my-component />
  `
})
export class TestComponent {
  divEl = viewChild<ElementRef>('el');
  cmp = viewChild(MyComponent);
}

在前面的示例中,我们使用了viewChild()查询信号来检索具有#el ID 的<div>元素和<my-component>元素。或者,如果您想检索具有相同选择器的多个元素,您可以使用viewChildren()函数。

通过这些,您知道了如何使用基于信号的新方法查询模板元素。您还了解了新的基于信号的方法相对于装饰器的优势。如果您更喜欢装饰器或者代码库中有装饰器,仍然可以使用装饰器。

在本节中,您学习了关于信号的内容。您学习了如何使用set()update()方法创建、读取和更新信号。然后,我们在ExpensesOverviewComponent中添加了一些信号并学习了计算信号。最后,您学习了信号效果、信号组件输入和查询信号。

在上一节中,您学习了关于 RxJS 的内容;在下一节中,您将学习如何结合 RxJS 和信号。

结合信号和 RxJS

在本章中,您已经看到了信号和 RxJS 如何帮助您以响应式的方式管理数据变化。信号和 RxJS 都允许您在值发生变化时做出反应,并通过组合多个数据流或根据数据变化执行副作用来创建新值。因此,可能会出现以下问题:信号是否取代了 RxJS?我何时使用信号,何时使用 RxJS?

RxJS 有时可能感觉令人畏惧且复杂,因此一些开发者可能会倾向于完全用信号取代 RxJS。虽然这可能适用于某些应用程序,但 RxJS 和信号都在您的应用程序中有其位置,并解决不同的问题和需求。在许多情况下,您可以使用信号或 RxJS 为您的解决问题,但其中之一将更好地解决问题,并且用更少的代码行处理它。信号并不是要取代 RxJS,但信号将与之互补,并且在许多情况下,与您的 RxJS 代码一起工作。

由于信号和 RxJS 应该共存,Angular 创建了两个 RxJS 互操作性函数:toSignal函数。

使用 toSignal

toSignal函数用于将可观察对象转换为信号。toSignal函数与ASYNC管道非常相似,但具有更多的灵活性和配置选项,并且可以在应用程序的任何位置使用。语法相当简单;您使用toSignal函数并给它提供一个可观察对象:

counter = counterObservable$ into a counter Signal. The toSignal function will immediately subscribe to counterObservable$, receiving any values the Observable emits from that point. As with regular Signals, you can use the Signals that were created with the toSignal function inside computed Signals and Signal effects. When toSignal changes its value because the Observable emits a new value, any Signal effect or computed Signal depending on that Signal will be triggered.
The `toSignal` function will also automatically unsubscribe from the Observable, given that the `toSignal` function is used within the injection context. When you use the `toSignal` function outside the injection context or want to make it dependent on a different injection context, you can provide the `toSignal` function with an injection context, like so:

injector = inject(Injector);

counter = toSignal(this.countObs$,toSignal 函数中包含一个额外的配置对象,其中我们提供了 injector 属性。也可能存在您不希望 toSignal 函数在组件或注入上下文被销毁时自动取消订阅 Observable 的场景。

您可能需要根据系统的需求在更早或更晚的时候停止 Observable。对于这种情况,您可以为 toSignal 函数提供一个 manualCleanup 配置,类似于 Signal 效应:

counter = toSignal(this.countObs$, {manualCleanup to true, the toSignal function will receive values up to the point the Observable it depends on is completed. When the inner Observable has been completed, the Signal will keep returning the last emitted value; this is also the case if you don’t use the manualCleanup configuration. Besides having control over the unsubscribe process of the Observable used by the toSignal function, you can also provide an initialValue configuration.
The Observable you convert into a Signal might not immediately and synchronously emit a value upon subscription. Yet Signals always require an initial value, and if one isn’t provided or the value comes in asynchronously, the initial value of the Signal will be `undefined`. Because the initial value of the Signal is `undefined`, the type of the Signal will also be `undefined`. To prevent `undefined` being the initial, you can provide the `toSignal` function with an initial value using this syntax:

counter = toSignal(this.countObs$,{undefined 作为您的初始值可能在计算信号或使用 undefined 值的 Signal 效应中引起问题。

另一方面,一些 Observable 在订阅时会发出同步值;例如,考虑 BehaviorSubject。如果您使用在订阅时发出同步值的 Observable,您还需要在 toSignal 函数内部使用 requireSync 配置来配置这一点:

counter = toSignal(this.countObs$, {requireSync: true});

通过将 requireSync 选项设置为 truetoSignal 函数强制在订阅时接收初始值是同步的,跳过初始的 undefined 值并将 Signal 类型化为 undefined。最后,您可以配置 toSignal 函数应该如何处理 Observable 中发生的错误。

默认情况下,如果 Observable 抛出错误,Signal 将在读取 Signal 时抛出错误。您还可以将 rejectErrors 选项设置为 true;在这种情况下,toSignal 函数将忽略错误并继续返回 Observable 发射的最后一个良好值:

counter = toSignal(this.countObs$, {rejectErrors option to true, errors are handled in the same way the ASYNC pipe handles errors within Observables.
Using toObservable
The `toObservable` function is used to convert a Signal into an Observable. Here’s an example of how you can use the `toObservable` function:

counter = toSignal(this.countObs$);

countObs$ = toObservable 函数背后的场景,Angular 将使用 Signal 效应来跟踪在 toObservable 函数内部使用的 Signal 的值,并将更新的值发射到 Observable。因为 Angular 使用 Signal 效应来更新 Observable 的值,所以它只会发射 Signal 中的稳定变化。因此,如果您连续多次设置 Signal 而没有间隔,使用 toObservable 创建的 Observable 将只在 Signal 稳定时发射最后一个值。

默认情况下,toObservable 函数使用当前的注入上下文来创建 Signal 效应;如果您在注入上下文外部声明 toObservable 函数或想在另一个注入上下文中创建 Signal 效应,您可以向 toObservable 函数提供一个注入上下文:

injector = inject(Injector);
countObs$ = toObservable(this.counter, {toObservable function – there are no other configuration options; you use the toObservable function and provide the function with your Observable to convert the Signal into an Observable. You can also combine both the toSignal and toObservable functions in one go.
Here’s an example of how you could use a Signal input and the `toObservable` and `toSignal` functions to fetch a new product and convert it into a Signal each time the component receives a new ID input:

id = input(0);

product = toSignal(

toObservable(this.id).pipe(

switchMap((id) => this.service.getProduct(id as number)),

),

{ initialValue: null }

);


 Now that you know how to combine RxJS and Signals by using the `toSignal` and `toObservable` functions, let’s finish this chapter by providing a bit more clarity about when to use Signals and when to use RxJS.
Choosing between Signals and RxJS
As mentioned previously, neither Signals nor RxJS are one-size-fits-all solutions. When you’re building an application, chances are you’ll need both Signals and RxJS to create the most optimal code. The most straightforward distinction is that Signals handle synchronous code, and RxJS is used to handle synchronous code, but things aren’t always as simple. You could also convert synchronous code using the `toSignal` function. For clarity, we’ll go through some examples at face value and determine if using Signals or RxJS would be better. In the real world, there are always nuances, and you should take whatever best fits your scenario, the team, and the existing code of the application.
Let’s start with an HTML template. In an HTML template, you can use an RxJS Observable by using the `ASYNC` pipe, or you can use a Signal. I would try to use Signals in the HTML template as this simplifies the HTML template by maintaining a synchronous approach. Using Signals will also help improve the change detection mechanism Angular uses, which can improve your application’s performance.
There are more gray areas in the component classes where it might be more complex to determine whether to use a Signal or RxJS Observable. If we look at the local component state, I would use Signals and computed Signals to define the component state; this also allows you to consume the component state as signals inside the HTML template.
When it comes to handling user events, it depends a bit on how you need to process the values of the event. If it’s a simple event such as handling a form submission or a button click, a Signal will work perfectly fine to update the correlating values. If you need more control over the delivery of the value stream, combine multiple events, or map, filter, and transform the data stream before it reaches your application logic, RxJS will be a better fit.
A typical example is when you have a search input field that makes API requests. You don’t want to make too many API requests by firing an API call on each key-up event. Instead, you want to check if the user stopped typing for a specified interval. Using RxJS, this can be done using the `debounceTime` operator. You can handle the same functionality using a Signal, but this requires a lot more code, and it becomes more complex and less readable. Depending on your architecture, most other scenarios that are handled inside your components are connected with your facade services or state management.
Now, let’s discuss some different scenarios and compare Signals with RxJS. Events that have to be distributed throughout the application and where different parts of your application have to react differently are also best handled using RxJS, more specifically an RxJS `Subject`. Using a `Subject` class, each part of your application can listen for the Observable and react how it needs to react.
Defining simple synchronous global application states can be done using Signals and computed Signals. The current value of the state can be retrieved by using Signals. Additionally, you can define change events using RxJS subjects if different application parts need to perform different logic when the values change. You can trigger the RxJS subjects inside your state management using a signal effect. Using the Signal effect might only work if reacting on stabilized value changes is enough; if you need to react to multiple changes that follow on from each other, this approach will not work for you.
When you have more complex state or asynchronous sources that need to be modified, combined, filtered, or mapped before you can provide the values to the rest of your application, RxJS is the best solution to handle the data streams. Especially when you need to handle multiple nested Observables or if you want to combine various streams and need control over when and how the values of these different streams are processed, RxJS offers many more tools to handle this gracefully.
Inside your facade services, you can combine RxJS and Signals. Depending on the complexity and setup of your state, a good approach is to use the `toObservable` function and RxJS to create the models you need to expose to the view layer. Once you’ve mapped all the data streams into the models and values you need, you can use `toSignal`, Signals, and computed Signals to expose the values to the view layer. Then, inside the view layer, you can consume the Signals synchronously while the facade service updates them asynchronously.
Now that you have a better idea of when to use Signals and when to use RxJS, let’s move on to the next chapter and start learning about state management.
Summary
In this chapter, you learned about reactive programming. You learned what reactive programming is, how the Angular framework uses it, and how it can be utilized to make your code efficient, event-driven, and performant.
Next, we did a deep dive into RxJS and saw how it can be used to create and handle Observable streams. You learned about different types of Observables and how to combine, flatten, and modify Observable streams using RxJS operators. We also explored some of the most used RxJS operators and learned how to create operators using the pipe function.
After understanding RxJS, we moved on to Angular Signals. You learned why Angular introduced Signals into the framework and how they help simplify your Angular code and improve the performance of your applications. You learned about Signals, computed Signals, the Signal effect, and interoperability functions for Signals and RxJS. We finished this chapter by exploring when you should use Signals and when to use RxJS within your applications.
In the next chapter, we will take a deep dive into state management.

第八章:以优雅的方式处理应用状态

在本章中,你将了解应用状态。理解和处理应用状态是前端开发中最基本的部分之一。如果应用的状态变得混乱、纠缠且难以理解,你的开发过程和应用的品质都将受到影响。

为了帮助你更好地管理应用状态,我们将讨论你将在应用中找到的不同状态层级。你将学习如何划分和分割你的状态以实现最大效率。你还将创建一个使用 RxJS 和 Signals 的状态管理解决方案,并构建一个外观服务以从组件层访问状态。

接下来,你将学习如何使用 NgRx 库处理更复杂的状态。NgRx 是 Angular 社区中最常用的状态管理库,它使用 Redux 模式来管理状态。自从 Angular Signals 引入以来,NgRx 也提供了不同的方法来使用 Signals,同时使用我们喜爱的 NgRx 工具。

到本章结束时,你将使用不同的方法实现了状态管理解决方案。你将了解到在使用外观服务时更改状态管理解决方案是多么容易,并看到 Angular Signals 如何改变了我们在 Angular 应用中处理状态的方式。

本章将涵盖以下主题:

  • 理解应用状态

  • 使用 RxJS 处理全局应用状态

  • 使用 Signals 处理全局应用状态

  • 使用 NgRx 处理全局应用状态

理解应用状态

简而言之,应用状态是在特定时间点你的数据、配置和视图当前条件(或状态)的快照。应用状态是从浏览器加载应用的那一刻起,在应用中执行的所有动作的总和。状态是一个动态的景观,它影响着应用视图、用户交互、数据流和整体功能。

在你的应用中拥有良好的状态管理至关重要,这样所有组件都能向最终用户显示正确的数据,你也能在应用代码中有准确的数据进行操作。良好的状态管理可以防止意外的数据更改,从而避免在应用代码中执行你未打算执行的不正确视图和操作。

既然你已经了解了应用状态是什么以及为什么你需要它,那么让我们更深入地探讨,从应用状态的不同层级开始。

应用状态的层级

在前端开发的领域,我们可以区分两个层面的状态:全局状态和局部状态。在本节中,我们将深入探讨 Angular 上下文中全局和局部应用状态之间的细微差别,阐明它们在构建健壮和可维护的前端应用中的作用。

正如它们的名称所暗示的,局部状态是局部化到文件、组件或应用程序中的元素,而全局状态是通过整个应用程序共享的。全局应用程序状态作为共享信息在各个组件之间的中央存储库,确保应用程序行为的一致性和同步性。另一方面,局部应用程序状态封装了特定于单个 Angular 组件和服务的内部数据和配置。

通过理解全局和局部状态的二重性,你的 Angular 应用程序可以在可重用性、封装性和共享数据完整性之间达到和谐的平衡。让我们先深入了解一下 Angular 应用程序中的局部应用程序状态。

局部应用程序状态

当我们提到局部状态时,我们指的是那些被局部化到组件或服务中的属性,这些属性决定了该组件或服务如何行为以及如何向应用程序的用户展示数据。

一个局部状态的简单例子是一个具有count状态的Counter组件:

export class Counter {
  count = signal(0);
  add() { this.count.update((count) => count + 1) }
  subtract() { this.count.update((count) => count - 1) }
}

count属性用于向用户显示当前计数。count属性的声明和更新行为在当前组件内部处理。

在组件内部,你可以将状态视为局部状态,当状态属性不是在多个智能组件之间共享,并且在你从一个页面导航到另一个页面时不需要持久化时。

在服务内部,当状态涉及一个不与外界共享的私有属性,并且该属性不需要比服务文件的生命周期更长的时间来持久化时,可以将其视为局部状态。如果属性不符合这些标准,你可能需要将其定位在全局应用程序状态中的某个位置。

这里有一些 Angular 应用程序中局部状态的常见例子:

  • 禁用按钮状态

  • 形式有效性状态

  • 模态可见性

  • 排序和过滤

  • 手风琴状态

  • 选定的标签页状态

你现在对局部状态有了很好的理解。你知道什么是局部状态,如何识别它,以及处理 Angular 应用程序中局部状态的首选工具是什么。你还了解了一些局部状态的常见例子。接下来,你将学习关于全局应用程序状态的内容。

全局应用程序状态

与局部状态相对,全局应用程序状态指的是在 Angular 应用程序中跨多个组件和服务共享的数据和配置。你可以将你的全局应用程序状态视为一个数据集中式存储库。这个信息集中式存储库对于确保应用程序各个部分之间的一致性、同步性和高效通信至关重要。

与局限于特定组件或服务的局部状态不同,全局应用状态在整个应用程序中持续存在,这使得它在需要在不同组件和服务之间共享和同步数据,以及在整个用户会话期间特别有用。

在 Angular 应用程序中,全局状态通常在服务中处理。通过创建一个专门用于管理全局状态的服务,开发者可以确保组件有一个集中的访问点来获取关键信息。包含全局应用状态的服务通常被称为存储。例如,您可以使用名为UserStore的类调用服务来存储全局用户状态user.store.ts

在较小的 Angular 应用程序中,状态通常使用Subjects进行管理。更具体地说,BehaviorSubject存储和分发状态属性,而常规的Subject分发全局事件。随着 Signals 的引入,一些BehaviorSubject类可以被 Signals 替换。我们将在开始构建全局状态管理时,在使用 RxJS 处理全局应用状态部分详细看到这一点。

对于较大的 Angular 应用程序,NgRx、NgXs、Akita 和 Angular Query 等库是处理全局状态的首选方法。这些库增强了您优雅地管理状态的能力,并实现了结构化和经过实战检验的设计模式,以可预测和可扩展的方式管理和更新全局状态。

理解何时利用全局应用状态至关重要。如果需要将状态属性在多个智能组件之间共享或超出单个组件的生命周期,则全局状态可能更为合适。现在您已经了解了局部和全局状态是什么,何时使用哪一个,以及有哪些工具可以优雅地管理它们,让我们来学习一些状态管理中的重要概念。

状态管理中的基本概念

要在您的 Angular 应用程序中构建一个健壮的状态管理系统,您需要了解状态管理的基本概念。您需要知道这些概念,为什么它们是必要的,以及不使用它们的危险。

在本节中,我们将学习单向数据流、不可变性和副作用。状态管理的一些其他重要基础包括响应性和设计模式,如 Redux 模式,但我们已经在第六章和第七章中讨论过,所以我们将不会深入探讨这一点。

单向数据流

单向数据流是我们将要讨论的第一个状态管理概念。正如其名称所暗示的,这个概念表明数据应该在整个应用程序中单向流动。数据的变化通过定义良好的动作或事件发生,确保信息流清晰且可预测。单向数据流简化了调试,使代码更可预测,并提高了可维护性。它通过强制应用程序中的数据清晰流动来防止意外的副作用。

没有单向数据流,追踪状态变化的原因会变得具有挑战性,导致调试困难以及数据一致性的潜在问题。不受控制的数据流可能导致不可预测的行为,尤其是在大型和复杂的应用程序中。

单向数据流的概念在整个应用程序中都很重要,无论是局部还是全局应用程序状态。对于全局应用程序状态,我建议始终使用单向数据流。在局部组件状态中,有时你可以通过使用 Angular 双向数据绑定来做出例外。

为了帮助你理解单向数据流在 Angular 应用程序中的样子,这里有一个流示例:

  1. 状态从存储传递到外观服务。

  2. 状态从外观服务传递到智能组件。

  3. 智能组件将数据传递给(无状态的)子组件。

  4. 视图根据智能组件及其子组件的状态进行渲染。

  5. 视图中可以触发一个动作。

  6. 动作的事件和相关信息从(无状态的)子组件向上移动到智能组件。

  7. 智能组件或外观向存储派发一个动作。

  8. 存储根据派发的动作更新状态。

  9. 状态从存储传递到外观。

正如你所见,数据从存储开始,单向流动直到视图可以被渲染。当用户在视图中触发一个动作时,数据会以单向和可预测的方式流回存储,直到形成一个完整的循环。现在你已经了解了单向数据流是什么以及为什么它在状态管理中很重要,让我们来学习不可变性的概念。

不可变性

不可变性涉及不直接修改现有数据结构的实践。相反,会创建带有所需更改的新副本,以保持原始数据的完整性。不可变性通过提供一个单一的位置来修改你的状态,简化了状态管理。它有助于防止意外的状态更改和副作用,在跟踪和管理 Angular 应用程序中的复杂状态时尤其有价值。

通过不可变性,你可能发现跟踪状态变化和保持状态同步更容易。直接修改状态对象可能导致错误和意外的行为。不可变性主要在全局状态管理中使用,但随着 Signals 的引入,现在它也应用于 Angular 应用程序的本地状态。

副作用

副作用指的是当你的状态中的某个特定部分发生变化时,你执行的操作或更改。副作用可能包括以下内容:

  • 获取数据

  • 更新本地存储

  • 分发额外的动作

  • 设置局部变量

通过隔离副作用,你可以在你的应用程序中保持关注点的清晰分离。核心应用程序逻辑(reducers、actions 和 selectors)专注于状态变化,而副作用则单独处理。副作用在 Angular 框架的 Signals API 中自然引入,并在像 NgRx 和 NgXs 这样的流行状态管理库中使用。

因此,总结一下,在你的应用程序中存在本地和全局状态。本地状态局限于组件或服务,而全局状态影响整个应用程序。状态管理的一些基本概念包括单向数据流、不可变性和副作用。你了解了这些概念的优势以及为什么它们对于状态管理解决方案很重要。你还了解了状态管理是什么以及为什么你需要在应用程序中使用它。

在下一节中,你将开始构建一个全局状态管理解决方案,并创建一个门面服务以从你的智能组件内部访问状态。

使用 RxJS 处理全局应用程序状态

在本节中,你将创建一个简单的状态管理解决方案,使用 RxJS。这个状态管理解决方案的核心是 RxJS 的BehaviorSubject类。你还将创建一个门面服务以与状态管理解决方案交互。

门面将负责与状态管理解决方案和智能组件的所有通信。这使我们的智能组件与状态管理解决方案解耦,便于在需要时轻松交换我们的状态管理实现。

一旦我们创建了 RxJS 状态管理解决方案并将其与应用程序的组件层连接起来,我们就可以将状态管理和门面更改为在可能且合理的地方使用 Signals。

通过将状态管理解决方案从 RxJS 转换为 Signals,你将能够理解这两个概念并了解它们之间的区别。构建这两种方法也将为你提供最佳服务,以便你在加入的项目中遇到它们时能够识别并与之合作。让我们从构建 RxJS 状态管理解决方案开始。

使用 RxJS 构建状态管理解决方案

要开始构建状态管理解决方案,在财务域的data-access库中创建一个名为stores的文件夹。stores文件夹应位于lib文件夹内部,与adaptersHTTPmodelsservices文件夹处于同一级别。

创建一个服务

首先,你可以通过在新建的stores文件夹中使用Nx 生成器来创建一个服务。将新服务命名为expenses.store。因为我们使用的是 Nx 生成器,它将创建一个名为expenses.store.service.ts的文件;你可以手动删除.service部分,并对spec文件做同样的处理。

接下来,将类名从ExpensesStore改为ExpensesStoreService并移除constructor;当你准备好时,这应该在你的文件中:

@Injectable({ providedIn: 'root' })
export class ExpensesStore {}

接下来,你需要一个可以保存你支出列表状态的东西。我们将使用一个BehaviorSubject类,该类将发出一个ExpenseModel数组。BehaviorSubject类将是一个私有属性,因此你无法直接从我们的ExpensesStore类外部修改状态。

只有ExpenseStore类应该能够直接修改状态;应用程序的其他部分应该通过ExpenseStore以及更精确地说,通过外观(facade),来修改状态。允许应用程序的其他部分直接修改状态可能导致意外的状态修改,破坏你的应用程序。

由于BehaviorSubject类是私有的,你还需要一个公共属性,该属性将BehaviorSubject类暴露给外部世界作为一个可观察对象(Observable):

private expenses = new BehaviorSubject<ExpenseModel[]>([]);
expenses$ = this.expenses.asObservable();

如你所见,我们首先定义了expenses BehaviorSubject类,并通过在BehaviorSubject类上调用asObservable()方法创建了公共的expenses$可观察对象(Observable)。我们给expenses BehaviorSubject类提供了一个空数组作为其默认值。接下来,让我们添加一些逻辑来获取和分发我们的数据。

在我们的存储中获取和分发数据

接下来,我们将添加一些逻辑来执行 API 请求以检索支出,并通过BehaviorSubject类发出接收到的支出。为了实现这一点,首先注入我们在第六章中创建的ExpensesHttpService类:

protected expensesApi = inject(ExpensesHttpService);

接下来,你需要创建一个方法来执行 API 请求并更新BehaviorSubject类:

fetchExpenses(): void {
  this.expensesApi.get().subscribe({
    next: (expenses) => { this.expenses.next(expenses) },
    error: (err) => { console.log(‹err ==>›, err) }
  });
}

如你所见,我们创建了一个名为fetchExpenses的方法,并在该方法内部使用expensesApi来执行get请求。我们订阅了get请求并处理了订阅的nexterror事件。当get请求的订阅收到响应时,处理next事件;当get请求失败并返回错误状态时,处理error事件。

如果 API 请求成功响应,我们在expenses BehaviorSubject类上调用next()方法,并给它传递接收到的expenses作为参数。如果 API 返回错误,我们简单地记录错误。在生产应用程序中,你应该更好地处理这种情况,并使用托盘消息或类似的方式提醒用户。

添加额外的费用方法

接下来,你将想要添加通过 ID 获取费用、更新、删除和添加费用的方法。在创建这些方法之前,必须调整MockInterceptor以处理deletegetByID请求。你可以自己修改拦截器,或者从本书的 GitHub 仓库中获取调整后的MockInterceptorgithub.com/PacktPublishing/Effective-Angular

在调整MockInterceptor后,你可以在我们的费用存储中实现adddeleteupdategetByID方法。在这些方法内部,我们需要访问当前的费用列表。你可以通过expenses BehaviorSubject类的value属性访问当前的费用列表。让我们创建一个获取器,从我们的状态中检索当前的费用:

private get currentExpenses() {return this.expenses.value}

现在,我们可以开始添加方法。

添加费用

让我们先创建一个添加费用的方法:

addExpense(expense: ExpenseModel): void {
  this.expensesApi.post(expense).subscribe({
    next: (addedExpense) => {
      addedExpense.id = !addedExpense.id ? this.currentExpenses.length + 1 : addedExpense.id;
      this.expenses.next([...this.currentExpenses, addedExpense]);
    },
    error: (err) => { console.log(‹err ==>›, err) }
  })
}

如你所见,addExpense代码将expense作为函数参数。这个expense参数用于在expenseApi上调用POST请求。

当 API 返回响应时,我们更新ID属性(我们只更新ID属性,因为我们没有实际的后端。通常,ID会由后端填充)。更新ID属性后,我们将新创建的expense添加到expenses状态中。

删除费用

在创建addExpense方法后,你可以创建一个删除费用的方法:

deleteExpense(id: number): void {
  this.expensesApi.delete(id).subscribe({
    next: () => {
      this.expenses.next(this.currentExpenses.filter(expense => expense.id !== id));
    },
    error: (err) => { console.log(‹err ==>›, err) }
  })
}

delete方法相当直接。我们发起 API 请求,当 API 响应时,我们通过调用next()方法更新expenses状态,以获取新的费用列表。作为next()方法的参数,我们使用当前的expenses列表并使用filter过滤掉已删除的费用。如果 API 返回错误,我们记录错误,同样在生产应用程序中,我们向用户显示某种消息。

获取、获取和选择费用

在添加delete方法后,我们将添加getExpenseselectExpensefetchExpenseById方法。getExpenseselectExpense方法将是公共方法,而fetchExpenseById将是私有方法。我们还将创建一个expense Subject类和一个selectedExpense状态,使用BehaviorSubject类。

让我们先添加Subject类和selectedExpense状态:

private expense: Subject<ExpenseModel> = new Subject();
expense$: Observable<ExpenseModel> = this.expense.asObservable();
private selectedExpense: BehaviorSubject<ExpenseModel | null> = new BehaviorSubject<ExpenseModel | null>(null);
selectedExpense$ = this.selectedExpense.asObservable();

expense Subject 类和 selectedExpense 状态可以用来响应式地检索选定的费用。当您需要将选择持久化到全局应用程序状态时,使用 selectedExpense 状态。相比之下,expense Subject 类可以用来发出一个事件,该事件只被在事件发出时订阅的观察者接收。在添加了 expense Subject 类和 selectedExpense 状态之后,我们将继续使用私有的 fetchExpenseById 方法:

private fetchExpenseById(id: number, select = false) {
  this.expensesApi.getById(id).subscribe({
    next: (expense) => { select ? this.selectedExpense.next(expense) : this.expense.next(expense) },
    error: (err) => { console.log(‹err ==>›, err) }
  })
}

fetchExpenseById 方法有 idselect 参数。id 参数是必需的,而 select 属性是可选的,默认值为 false。该方法首先通过 API 调用来通过 ID 获取费用。当 API 响应费用时,我们将使用 expense Subject 发出一个新值,或者使用 BehaviorSubject 类发出一个值并设置 selectedExpense 状态。根据您应用程序的需求,您还可以将获取的费用添加到 expenses 状态中,但对我们这个演示应用程序来说,这不是必需的。

现在,为了完成通过 id 获取费用的逻辑,我们需要实现公共的 getExpenseselectExpense 方法:

getExpense(id: number): void {
  const expense = this.currentExpenses.find(expense => expense.id === id);
  expense ? this.expense.next(expense) : this.fetchExpenseById(id);
}
selectExpense(id: number): void {
  const expense = this.currentExpenses.find(expense => expense.id === id);
  expense ? this.selectedExpense.next(expense) : this.fetchExpenseById(id, true);
}

如您所见,getExpenseselectExpense 方法非常相似。两种方法都接收 id 作为参数,并检查提供的 id 参数是否可以在当前的 expenses 状态中找到。

当在当前状态中找到费用时,会在 expense Subject 类或 selectedExpense BehaviorSubject 类上调用 next() 方法。当在当前的 expenses 状态中没有找到费用时,会调用 fetchExpenseById 方法从后端获取费用;在这种情况下,fetchExpenseById 方法将调用 expense SubjectselectedExpense BehaviorSubject 类。既然我们已经添加了获取或选择费用的响应式方法,让我们添加 updateExpense 方法。

更新费用

update 方法将接收 expense 作为函数参数。接下来,它将使用 expensesApi 发起 PUT 请求来更新后端中的请求。在 API 成功响应后,该方法将更新 expenses 状态:

updateExpense(expense: ExpenseModel): void {
  this.expensesApi.put(expense).subscribe({
    next: (expense) => {
      this.expenses.next(this.currentExpenses.map(exp => exp.id === expense.id ? expense : exp));
    },
    error: (err) => { console.log(‹err ==>›, err) }})
}

如您所见,我们发起 API 请求并在 expenses BehaviorSubject 上使用 next() 方法来更新 expenses 状态。作为 next() 方法的参数,我们使用 currentExpenses 获取器并使用 map() 函数来替换更新的费用。

现在我们已经添加了添加、更新、删除和获取费用的方法,让我们通过添加一些额外的状态和重置状态的方法来完成这个存储。

扩展 ExpensesStore

我们将首先添加一个额外的状态来管理是否显示价格,包括或排除增值税。我们可以通过创建一个新的 BehaviorSubject 类和一个调整 BehaviorSubject 类值的方法来实现这一点:

private inclVat = new BehaviorSubject<boolean>(false);
inclVat$ = this.inclVat.asObservable();
adjustVat(): void {
  this.inclVat.next(!this.inclVat.value);
}

如您所见,增值税状态只是一个简单的布尔值,表示我们是否显示包含或不含增值税的价格。

最后,我们需要一些逻辑来重置我们的应用程序状态并清除所选产品状态。我们将为resetState创建两个不同的方法来将所有状态重置为默认值。我们将使用clearExpenseSelection方法来清除selectedExpense状态:

clearExpenseSelection(): void {
  this.selectedExpense.next(null);
}
resetState(): void {
  this.expenses.next([]);
  this.selectedExpense.next(null);
  this.inclVat.next(false);
}

这是我们费用存储的最后一部分。您创建了一个简单而有效的状态管理解决方案来处理费用的全局应用程序状态。您这样做使用了 RxJS 的SubjectBehaviorSubject类。现在,ExpensesStore可以成为您应用程序中所有费用数据的单一事实来源。

如果一个组件需要某些费用数据的当前状态,它将来自这个ExpensesStore。当您的应用程序增长,并且您有除了费用之外的其他实体具有状态,例如用户、报告或设置时,每个实体都将有一个存储文件来管理该实体的状态。

现在您已经使用 RxJS 创建了一个状态管理解决方案,我们将开始构建门面服务,并通过门面将视图层与存储连接起来。

使用门面服务连接您的状态管理和视图层

现在您已经有一个状态管理解决方案,是时候将其连接到您应用程序的视图层了。正如本书中多次提到的,最佳方法是为此创建一个门面服务。这个门面提供了一层额外的抽象,为您的视图层提供了一个简单的接口来与应用程序状态交互。图 8.1展示了门面服务以及数据如何从您的状态通过门面流入组件:

图 8.1:使用门面、组件和状态的数据流

图 8.1:使用门面、组件和状态的数据流

如您所见,您的组件向门面服务发出一个简单的请求,门面将从您的不同状态服务中收集数据,并以组件所需格式将其发送回组件。这确保了您的组件只有一个依赖项,而门面将托管所有其他必要的依赖项以检索您组件所需的数据。

创建门面服务

首先,在您的expenses data-access库的lib文件夹内创建一个facades文件夹。新的facades文件夹将位于与store文件夹相同的文件夹中。

在新的facades文件夹内,您必须创建一个名为expenses.facade.ts的文件,并包含一个名为ExpensesFacade的可注入类。您可以使用 Nx 生成器创建一个服务并重命名它,或者手动创建门面。此外,在index.ts文件中添加一个导出,以便您可以在库外部使用门面。

当您完成时,您应该在expenses.facade.ts文件中有以下内容:

@Injectable({ providedIn: 'root' })
export class ExpensesFacade {}

创建外观接口

接下来,在 expenses.facade.ts 文件旁边创建一个名为 expensesFacade.interface.ts 的文件。在这个接口中,我们将声明外观的蓝图。只要你的外观实现了这个接口,你就可以在不接触组件层的情况下切换状态实现。如果你更改了接口,你也需要调整组件层。

在接口文件中,声明以下接口:

export interface IExpensesFacade {
  expenseSelector$: Observable<ExpenseModel>;
  selectedExpenseSelector$: Observable<ExpenseModel>;
  inclVatSelector$: Observable<boolean>;
  addExpense(expense: ExpenseModel): void;
  adjustVat(): void;
  clearExpenseSelection(): void;
  deleteExpense(id: number): void;
  fetchExpenses(): void;
  getExpense(id: number): void;
  getExpenses(id: number): Observable<ExpensesViewModel>;
  resetExpenseState(): void;
  selectExpense(id: number): void;
  updateExpense(expense: ExpenseModel): void;
}

在定义了接口之后,我们可以开始实现外观服务。首先实现接口:

export class ExpensesFacade implements IExpensesFacade {…}

现在,你想要在外观服务内部注入 ExpensesStore

protected readonly expensesStore = inject(ExpensesStore);

现在我们已经注入了存储库,我们将添加一个获取费用的方法。

将外观与存储库连接

让我们在外观中添加一个简单的方法,该方法简单地调用存储库中的 fetch 方法:

fetchExpenses() {
  this.expensesStore.fetchExpenses();
}

接下来,我们将创建一个获取已获取费用的方法。但在我们这样做之前,我们将创建一个新的接口,称为 ExpensesViewModel

export interface ExpensesViewModel {
  total: number;
  inclVat: boolean;
  expenses: ExpenseModel[];
}

你也可以稍微调整 ExpenseModel 并将 amountExclVat 属性重命名为 value。如果你使用 VS Code,你可以选择属性并按 F2 键来重命名它。当你使用 F2 键重命名时,属性将在每个实例中重命名(除了 HTML 模板之外)。

现在你已经创建了 ExpensesViewModel 并调整了 ExpenseModel,让我们在外观内部创建 getExpenses 方法:

getExpenses(): Observable<ExpensesViewModel> {
  return combineLatest([this.expensesStore.expenses$, this.expensesStore.inclVat$]).pipe(
    distinctUntilChanged(),
    map(([expenses, inclVat]) => ({
      expenses: structuredClone(expenses).map(expense => {
        expense.amount.value = inclVat ? expense.amount.value * (1 + expense.amount.vatPercentage / 100) : expense.amount.value;
        return expense;
      }),
      inclVat,
      total: expenses.reduce((acc, expense) => {
        return acc + (inclVat ? (expense.amount.value * (1 + expense.amount.vatPercentage / 100)) : expense.amount.value);
      }, 0),
    }))
  );
}

如你所见,这个方法中有很多事情在进行。这是使用外观服务有益的原因之一。

在大型应用程序中,你需要在多个组件中使用这个 ExpensesViewModel 的可能性很高。你不需要在多个组件类中定义这块逻辑,你可以在外观内部定义它,在组件层内部,你可以使用简单的函数调用,保持你的组件简单和干净。此外,当你需要调整逻辑时,你只需要在这个单一位置进行调整,而不是在多个组件类中。现在,为了更好地理解我们在函数内部做了什么,让我们逐行分解:

  1. 我们首先命名了方法为 getExpenses 并指定它将返回一个 ExpensesViewModel 可观察对象。

  2. getExpenses() 方法内部,我们使用 combineLatest() 方法返回了一个可观察对象。

  3. combineLatest() 内部,我们将存储库中的 expenses$inclVat$ 可观察对象组合起来,并应用了 RxJS 的 pipe() 函数到 combineLatest()

  4. pipe() 函数内部,我们应用了两个操作符,从 distinctUntilChanged() 操作符开始,这样我们只有在值发生变化时才发出新的值。

  5. 接下来,我们使用了 map() 操作符将两个可观察对象流映射到 ExpensesViewModel

  6. 根据 inclVat$ 可观察对象的状态,我们返回费用值属性和总属性,包括或排除增值税。

现在你已经在外观内部创建了 fetch-getExpenses 方法,让我们调整费用概览页面。

调整支出概览页面

在页面组件内部,首先注入外观服务:

protected readonly expensesFacade = inject(ExpensesFacade);

在注入外观后,你可以在页面组件的ngOnInit()方法中获取支出:

ngOnInit() { this.expensesFacade.fetchExpenses() }

接下来,你可以清理组件。在第七章中,我们使用模拟数据为expenses创建了一个信号;在本节中,我们将使用外观内部getExpenses方法接收到的支出。首先,像这样重新分配expenses属性:

expenses = this.expensesFacade.getExpenses();

在重新分配expenses属性后,由于你不再拥有expenses信号,你将在支出概览页面的组件和模板文件中遇到一些错误。继续移除totalInclVat计算信号;你还可以移除组件中的信号效果,并在onAddExpense方法内部清除逻辑。

接下来,我们需要对 HTML 模板做一些调整。

首先在 HTML 表格周围添加一个if-else块:

@if(expenses | async; as expensesVm) {……} @else {Loading… }

if块内部,你将使用带有async管道的expenses属性,以便从外观中检索expenses并使用这些值在模板中。

在添加if块后,你需要调整 HTML 模板内部的for块,并将expenses信号切换为你从外观中检索到的expenses属性:

@for (expense of expensesVm.expenses; track expense.id){…}

调整for块后,你需要调整表格行以正确反映新的模型结构并改进 UI。通过将值四舍五入到两位小数,然后添加currency管道和百分比(%)符号来完成此操作:

<td>{{ expense.amount.value.toFixed(2) | currency }}</td>
<td>{{ expense.amount.vatPercentage }}%</td>

最后,你需要将模板中使用的totalInclVat计算信号切换为expensesVm上的total属性:

<td>Total: {{expensesVm.total}}</td>

在这里,我们将文本调整为total,因为我们现在显示包括或排除增值税的总金额。在做出这些调整后,你应该再次在表格中看到总金额和支出,但现在使用 RxJS 和全局状态而不是带有模拟expenses的信号。

接下来,你想要一个可以切换增值税的选项,以便在增值税状态改变时自动更新显示的支出和总金额。

首先在组件服务内部添加一个新方法:

adjustVat() { this.expensesStore.adjustVat() }

如你所见,这只是一个调用存储中adjustVat方法的简单方法调用。这将改变存储中inclVat BehaviorSubject类。这反过来将触发我们在外观内部的getExpenses方法中使用的combineLatest()方法。

因此,当你更改增值税状态时,通过getExpenses方法检索到的ExpensesViewModel将自动更新并显示总金额和支出金额,包括或排除增值税,具体取决于状态。

一旦你添加了调整增值税的方法,你还需要在组件内部检索inclVat状态。你可以简单地创建一个属性并使用存储中的inclVat$可观察对象来分配它:

inclVatSelector$ = this.expensesStore.inclVat$;

在添加了调整和检索增值税状态的方法和属性后,让我们在费用概述页面的 HTML 模板中添加一个切换来调整增值税状态:

<div class="vatToggle">
  <span>Incl. VAT:</span>
  <label class=»switch»>
    <input (click)=»expensesFacade.adjustVat()" type="checkbox"
      [checked]=»expensesFacade.inclVatSelector$ | async">
    <span class=»slider round»></span>
  </label>
</div>

我在inclVatSelector$旁边添加了增值税切换,并结合了async管道来设置增值税切换的checked属性。

我们还向切换按钮的input值添加了一个click事件,以便在门面中调用adjustVat方法。如果你点击切换按钮,你将看到表格中的费用金额和表格摘要中的总金额根据增值税状态的变化而包含或排除增值税金额。

如您可能已经注意到的,这是一个非常响应式的方法,因为所有内容都会在状态变化时自动做出反应。代码也非常高效,因为更新是以非阻塞方式执行的,允许所有代码继续运行。

现在我们已经实现了getExpenses方法和增值税状态,让我们完成门面服务的开发。

完成门面服务的开发

对于存储公开的所有其他方法,你可以在门面服务中添加简单的方法来调用它们,类似于我们处理fetchExpensesadjustVat方法的方式。

对于存储中的selectedExpenseexpense属性,你需要在门面服务中添加一个选择器属性。因为我们还将映射由selectedExpenseexpense发出的费用,所以我们将映射行为抽象到一个新的函数中,以便我们可以重用它:

private mapExpense(expense: ExpenseModel, inclVat: boolean) {
  const expenseClone = structuredClone(expense) as ExpenseModel;
  expenseClone.amount.value = inclVat ? expenseClone.amount.value * (1 + expenseClone.amount.vatPercentage / 100) : expenseClone.amount.value;
  return expenseClone;
}

接下来,你可以像这样调整getExpenses方法内部的费用映射:

expenses: expenses.map(expense => this.mapExpense(expense, inclVat)),

最后,我们将为selectedExpenseexpense添加选择器属性,从expenseSelector$开始:

expenseSelector$ = this.expensesStore.expense$.pipe(withLatestFrom(this.expensesStore.inclVat$), map(([expense, inclVat]) => this.mapExpense(expense, inclVat)));

如您所见,对于expenseSelector$,我们使用了withLatestFrom()运算符而没有使用combineLatest()。我们这样做是因为expenseSelector$将只使用Subject类而不是BehaviorSubject作为事件发出值。这里没有状态,我们不希望选择器在增值税切换变化时发出新值。我们只想在expense Subject类发出值时做出反应,并且当这种情况发生时,使用当前inclVat$ Observable 的值来映射费用。

selectedExpense的选择器属性将使用combineLatest()函数将selectedExpense$ Observable 和inclVat$ Observable 结合起来,如下所示:

selectedExpenseSelector$ = combineLatest([this.expensesStore.selectedExpense$, this.expensesStore.inclVat$]).pipe(filter(([expense]) => !!expense), map(([expense, inclVat]) => this.mapExpense(expense as ExpenseModel, inclVat)));

对于selectedExpenseSelector$,我们使用了combineLatest()函数,因为选定的费用是状态性的,并且持续存在于我们的存储中。当我们可以更改增值税时,我们可以在视图中使用选定的费用,因此我们希望它在增值税状态变化时做出反应,并更新视图中的金额。因为我们希望selectedExpense对增值税状态也是响应式的,所以我们使用了combineLatest()运算符,它在组合的任何一个 Observables 发出新值时都会触发。

这就是使用 RxJS 实现状态管理解决方案的最后一部分。这种状态管理方法通常用于较小的 Angular 应用程序中,其中状态在许多不同的组件和服务中不被使用。该解决方案提供了良好的响应性,并且易于构建和理解。

现在,让我们学习如何将这个状态管理解决方案转换为使用信号(Signals)而不是 RxJS。使用信号将简化你的外观服务(facade service)和组件层。它还允许 Angular 进行更好的变更检测。如果你需要组合许多数据流并应用定制逻辑,RxJS 方法将更适合你的应用程序。

话虽如此,对于简单的状态和数据流,使用信号(Signals)要简单得多。即使你需要组合一些数据流而不需要过多控制这个过程,信号方法也将最适合你。如果你发现自己只使用combineLatest()withLatestFrom()以及一些基本操作符,如map()filter(),那么信号将是你的状态管理方式。

使用信号处理全局应用程序状态

为了将你的状态管理解决方案转换为使用信号而不是 RxJS,你必须将ExpensesStore中的BehaviorSubject类更改为信号。你仍然想要确保状态仅在存储库中设置时才发出新值;你不想能够在存储库外部设置状态。

为了实现这一点,我们将创建一个私有的WritableSignal和一个公共的只读Signal。你可以使用以下语法将所有BehaviorSubject类更改为信号:

private expensesState = signal<ExpenseModel[]>([]);
expenses = this.expensesState as Signal<ExpenseModel[]>;

在这里,我们使用signal()函数声明了一个私有信号(Signal)。以这种方式声明信号将创建WritableSignal。在下一行,我们创建了一个公共属性,并将其分配给WritableSignal,但使用as关键字将其转换为Signal类型;这里的Signal类型是只读的。在调整所有BehaviorSubject类之后,你需要更改在存储库内部对它们的引用。

首先移除currentExpenses获取器,并将所有this.current Expenses实例更改为以下内容:

this.expenses()

接下来,在adjustVat()函数内部,将!this.incluVat.value更改为以下内容:

!this.inclVat()

最后,你需要调整所有使用next()方法在某个BehaviorSubject类上的实例。

下面是一个如何转换resetState()函数的示例:

resetState(): void {
  this.expensesState.set([]);
  this.selectedExpenseState.set(null);
  this.inclVatState.set(false);
}

现在,将所有其他next()方法实例更改为Subject类和set()方法。这就是我们为ExpensesStore需要做的所有事情;你现在拥有使用信号而不是 RxJS BehaviorSubject类的状态管理。在调整状态后,我们需要调整ExpensesFacade,使其能够与信号而不是观察者(Observables)一起工作。

通常来说,外观服务的一个优点是它是一个抽象层,在改变状态管理解决方案时我们不需要触及组件层。但在这个情况下,我们需要调整外观服务和组件层;这是因为我们将要改变外观服务的接口。

理论上,我们可以保持接口不变,并在服务中将信号转换回可观察对象,这样就可以不触及组件层。然而,我们想要充分利用这些信号的力量,并在我们的组件中实现它们,以便 Angular 可以执行更好的变更检测,我们也可以使我们的模板同步。为了实现这一点,我们需要从我们的外观服务返回信号而不是可观察对象,改变外观服务的接口。

我们将通过改变接口来开始改变外观。将接口内部的 getExpenses 方法替换为 expenses 属性,并像这样调整 selectedExpenseSelector$inclVatSelector$ 属性:

selectedExpense: Signal<ExpenseModel | null>;
inclVat: Signal<boolean>;
expenses: Signal<ExpensesViewModel>

在接口中做出上述调整后,你就可以开始在外观服务内部实现接口。为了在接口中实现更改,删除 getExpenses 方法。而不是 getExpenses 方法,你必须创建一个计算信号,它返回与 getExpenses 方法相同的价值:

expenses = computed<ExpensesViewModel>(() => {
  const inclVat = this.expensesStore.inclVat();
  return {
    expenses: this.expensesStore.expenses().map(expense => this.mapExpense(expense, inclVat)),
    inclVat,
    total: this.expensesStore.expenses().reduce((acc, expense) => {
      return acc + (inclVat ? (expense.amount.value * (1 + expense.amount.vatPercentage / 100)) : expense.amount.value);
    }, 0),
  }
});

如你所见,计算信号与 getExpenses 方法非常相似。主要区别是我们不再需要 combineLatest()map() 操作符。现在我们可以在计算信号中使用 inclVatexpenses 信号。

当两个信号中的任何一个接收到新值时,计算信号将自动计算一个新的值。计算信号可以看作是信号领域的 combineLatest()withLatestFrom() 的等价物将是在计算信号中使用信号并使用 untracked() 函数包装信号,正如我们在 第七章 中讨论的那样。

在添加了计算信号之后,我们需要在外观服务内部实现 inclVatselectedExpense 信号。这很简单——你只需定义属性,并用从 ExpensesStore 获取的信号分配给它:

inclVat = this.expensesStore.inclVat;
selectedExpense = this.expensesStore.selectedExpense;

在这里,我们通过从存储中获取的信号来分配属性;我们不是通过添加函数括号 () 来调用信号。我们不添加这些函数括号是因为我们想在组件层中使用实际的信号,而不是 Signal 值。如果你在这里调用信号并检索组件层内的值,更新行为将不会按预期工作,并且当你的状态改变时视图不会更新。

最后要做的事情是调整 ExpensesOverviewPageComponent 及其模板。在组件类内部,你可以调整 expenses 属性,并用外观服务中的 expenses Subject 类代替 getExpenses() 函数分配给它:

expenses = this. expensesFacade.expenses;

现在,在 HTML 模板内部,你需要将 inclVatSelector$ 改为 inclVat(),移除 async 管道,并将带有 async 管道的 expenses 改为不带 async 管道的 expenses()

[checked]="expensesFacade.inclVat()"
@if(expenses(); as expensesVm) { …… }

在前面的更改中,你已经调整了组件类和 HTML 模板以使用 Signals 而不是 Observables。正如你所见,使用 Signal 方法稍微简单一些,并且需要更少的代码行。它还使你的 HTML 模板同步,并帮助 Angular 进行更好的变更检测,从而提高性能。

另一方面,你对数据流的控制较少,在数据流到达你的应用程序逻辑之前修改流并不容易。与 RxJS 相比,当你想要组合不同的数据流时,Signals 也提供了较少的控制,因此根据你的需求,你可以决定是否使用 Signals 或 RxJS。

你还可以创建一个混合解决方案,将 Observables 转换为 Signals,这样你就可以兼得两者之优。在这种情况下,你可以使用你需要的 RxJS 操作符,并且仍然可以在组件类和 HTML 模板中将值作为 Signals 消费。

通过这样,你已经学会了如何使用 RxJS 和 Signals 创建状态管理解决方案。你创建了一个外观服务作为额外的抽象层,并学习了在与外观服务一起工作时,何时需要更改组件层,何时只需要更改状态管理层。

我们创建的两个状态管理解决方案对于具有相对简单全局状态的小型应用程序都表现良好。RxJS 方法得到了广泛实现,随着 Signals 的普及,我想 Signal 方法也将得到广泛实现。但是,当你有一个较大的应用程序,其中状态在许多组件和服务中使用时,你将遇到我们当前实现的问题。在下一节中,你将了解这些问题以及如何解决它们。

使用 RxJS 或 Signals 进行全局状态管理的问题

虽然我们当前的状态管理解决方案被用于许多应用程序并且对我们的当前应用程序表现良好,但存在一个巨大的问题:我们当前的全局状态管理解决方案不是不可变的。

你不能从存储外部修改你的 BehaviorSubject 类或 Signals,因此从这个意义上说,它是不可变的。此外,当使用原始值作为状态时,状态本身也是不可变的。然而,当你使用引用对象作为 BehaviorSubject 类或 Signals 的值时,状态本身并不是不可变的。

当你使用数组或对象作为你的状态,并通过 BehaviorSubjectSignal 检索状态时,你可能会无意中修改状态值。当你在一个组件或服务类中调整检索到的状态对象时,BehaviorSubjectSignal 的值也会被修改!

这也是我们在 mapExpenses() 函数内部使用 structuredClone() 函数的原因。如果你移除 structuredClone() 并在视图中切换增值税几次,你会注意到金额持续增加,而不是添加和移除增值税。这是因为我们每次在门面服务内部调整对象时,都会在 SignalBehaviorSubject 内部修改对象。

下次我们检索状态时,它仍然具有调整后的值,而不是我们期望的真实状态。依赖开发者始终在修改对象时进行克隆是危险的,这不是你想要的方式。

允许你的状态在商店外部被修改,且没有在 BehaviorSubjectSignal 上调用 next()set() 方法,这为意外的状态变化打开了大门,导致状态损坏。当你的状态不是你所期望的那样时,你可能会向用户显示错误的数据,并在你的代码中执行非预期的操作。

对于那些状态在多处不常被使用的小型应用来说,这可能是一个可管理的问题,但当你的应用增长,状态在多个地方被使用,并且经常在本地修改检索到的状态时,问题会迅速显现。

要有一个真正不可变、响应式且可以处理任何应用状态(无论它变得多大)的状态管理系统,你的最佳选择是选择一个专注于状态管理的优秀库。Angular 社区中的一些流行选择如下:

  • NgRx

  • NgXs

  • RxAngular

  • Angular Query

所有这些库都有它们的优缺点。我个人的最爱是 RxAngular、NgXs 和 NgRx。NgRx 是社区中最常用的状态管理解决方案,它提供了基于 Observable 和 Signal 的状态管理支持。RxAngular 正在获得越来越多的关注,它以非常直观的方式管理状态,几乎不需要样板代码;它还允许你放弃 ZoneJS,提高你应用的性能。

在下一节中,我们将把我们的状态管理解决方案转换为 NgRx 状态管理解决方案。我选择 NgRx 是因为它是最常用的解决方案,但我建议你调查一些其他解决方案。

使用 NgRx 处理全局应用状态

当您在开发企业软件或具有广泛或复杂状态管理的应用程序时,您应该使用经过实战检验的状态管理解决方案,该解决方案提供真正的不可变性、单向数据流以及良好的工具来执行副作用并安全地修改状态。最佳做法是使用专注于状态管理的经过实战检验的库。

在 Angular 社区中,最常用的状态管理库是 NgRx;它拥有庞大的社区和您可能需要的所有工具来处理最复杂的状态。NgRx 实现了 Redux 模式,并包含四个主要构建块:actions、reducers、selectors 和 effects。

在本节中,我们将修改我们自定义的状态管理解决方案,使其使用 NgRx。我们将保留上一节中创建的存储文件作为参考,并在新文件中构建 NgRx 状态管理。

在生产环境中,您应该删除旧的未使用存储文件。在门面服务中,我们将简单地用 NgRx 实现替换当前实现,这次我们不会调整 IExpensesFacade 接口,这意味着我们不需要更改我们的组件层。让我们回顾一下实现 NgRx 状态管理的逐步过程。

安装 @ngrx/store 和 @ngrx/effects 包

要开始实现 NgRx 状态管理,您需要通过在您的 Nx monorepo 根目录中运行以下 npm 命令来安装一些包:

npm install @ngrx/store --save
npm i @ngrx/effects

在安装了 @ngrx/store@ngrx/effects 包之后,您需要创建一些文件夹和文件。有一个 Nx 生成器可以为您创建 NgRx 存储的初始设置,但我们将手动设置一切,以便您更好地理解一切是如何工作的,以及在使用 NgRx 时需要什么。

首先,在 expenses 数据访问库的 lib 文件夹内创建一个名为 state 的文件夹(位于 stores 文件夹旁边)。在新建的 state 文件夹内,创建另一个名为 expenses 的文件夹。现在,在新建的 expenses 文件夹内,创建以下五个文件:

  • expenses.actions.ts

  • expenses.reducers.ts

  • expenses.selectors.ts

  • expenses.effects.ts

  • index.ts

当您完成文件夹和文件的创建后,您可以在 expenses.actions.ts 文件内添加一些动作。

定义您的第一个 NgRx 动作

createAction() 函数,这是 @ngrx/store 包向您暴露的。

您必须向 createAction() 函数提供动作的描述,并且可选地提供一个 props() 函数来定义您必须提供给动作以执行动作的属性。

或者,您可以使用 createActionGroup() 函数来创建多个事件并将它们组合成一个单独的常量。我们不会使用 createActionGroup() 函数,但您始终可以在官方 NgRx 文档中阅读有关它的信息:ngrx.io/docs

我们将从一项简单的任务开始:定义一个从 API 获取费用的动作。你不需要提供任何参数来获取费用,因此该动作将只包含一个描述。NgRx 动作的描述通常使用以下命名约定:

[Unique State Name] Description of the action

expenses.actions.ts 文件内,定义获取费用的动作,如下所示:

export const fetchExpenses = createAction(`[Expenses] Fetch Expenses`);

通常,当定义包含 API 请求的 NgRx 动作时,你也会定义一个成功和失败的动作。所以,继续定义一个在获取费用成功或失败时的动作:

export const fetchExpensesSuccess = createAction(`[Expenses] Fetch Expenses Success`, props<{ expenses: ExpenseModel[] }>());
export const fetchExpensesFailed = createAction(`[Expenses] Fetch Expenses Failed`);

在这里,我们声明了两个动作;它们都接收了一个描述,而 fetchExpensesSuccess 动作还接收了 props() 函数。在箭头括号内,我们定义了 props() 函数的类型——在这种情况下,一个包含 expenses 属性的 ExpenseModel 数组的对象。fetchExpensesSuccess 动作需要 expenses 作为 props(),因为我们将使用 fetchExpensesSuccess 动作来更新状态,以包含从 API 请求中检索到的费用。

现在你已经添加了 fetchExpensesfetchExpensesSuccessfetch ExpensesFailed 动作,让我们更新 state/expenses 文件夹内的 index.ts 文件,通过定义我们的费用动作的导出:

export * as ExpenseActions from './expenses.actions';

在将导出添加到 index.ts 文件后,我们可以继续下一个难题。下一步是创建一个 NgRx 效果,该效果将向 API 发送请求以获取费用,并相应地分发成功或失败的动作。

创建你的第一个 NgRx 效果

你将创建你的 expenses.effects.ts 文件。效果允许你在动作分发时执行副作用。效果通常用于执行数据获取、分发其他事件或更新本地存储等任务。副作用将一些逻辑从组件中隔离出来,使组件类尽可能简单。

你将创建的第一个效果是 fetchExpeses$ 效果。每当 fetchExpenses 动作被分发时,此效果将会运行。然后,该效果将向 API 发送请求以获取费用,并将 API 调用的结果映射到一个新分发的动作——fetchExpensesSuccessfetchExpensesFailed 动作。

要开始,在 expenses.effects.ts 文件内创建一个名为 ExpensesEffects 的可注入类:

@Injectable({ providedIn: 'root' })
export class ExpensesEffects {}

在创建 ExpensesEffects 类之后,你需要在 ExpensesEffects 类中注入来自 @ngrx/effectsActions 类和 ExpensesHttpService

private readonly actions = inject(Actions);
private readonly expensesApi = inject(ExpensesHttpService);

接下来,使用 @ngrx/effects 提供的 createEffect() 函数创建你的第一个效果:

fetchExpeses$ = createEffect(() =>
  this.actions.pipe(
    ofType(ExpenseActions.fetchExpenses.type),
    switchMap(() => this.expensesApi.get().pipe(
      map((expenses: ExpenseModel[]) => ExpenseActions.fetchExpensesSuccess({ expenses })),
      catchError(() => of(ExpenseActions.fetchExpensesFailed()))
    ))
  )
);

在前面的代码片段中,你创建了第一个名为 fetchExpenses$ 的效果。正如你所见,那里有很多事情在进行,所以让我们逐行分析。

我们首先定义了一个名为 fetchExpenses$ 的属性,并将其分配给 createEffect() 函数。在 createEffect() 函数中,我们定义了一个返回 this.actions.pipe() 方法的 callback 函数。this.actions 实例指的是我们在前面的代码块中注入的 Actions 类。Actions 类发出我们派发的动作,并扩展了 Observable 类,这意味着你可以在类上使用 RxJS 的 pipe() 函数。

pipe() 函数的链式动作中,我们定义了一些操作符,从 ofType() 操作符开始。ofType() 操作符是一个过滤器操作符,它通过动作类型过滤动作。在 ofType() 操作符的函数括号内,你定义了动作的类型。在我们的例子中,我们向它提供了 fetchExpenses 动作的类型。在这里,ExpenseAction 用于导出和导入我们的动作,fetchExpenses 是我们赋予动作的属性名,而 type 是一个属性,它暴露在我们使用 createAction() 函数创建的所有动作上。

每当派发 fetchExpenses 动作时,我们将继续在效果函数的 pipe() 函数中的下一个操作符。下一个操作符是 switchMap() 操作符,它用于平铺由 HTTP 请求获取费用所创建的附加 Observable 流。

switchMap() 操作符的回调中,我们进行了 HTTP 请求,并将一个额外的 pipe() 函数添加到 HTTP 请求中。在 HTTP 请求的 pipe() 函数中,我们使用了 map() 操作符将成功的 HTTP 响应映射到 fetchExpensesSuccess 动作,并将从 API 响应中检索到的费用提供给 fetchExpensesSuccess 动作。如果 API 请求失败,我们使用 catchError 操作符将其映射到 fetchExpensesFailed 动作。

createEffect() 函数将自动派发返回的动作;这就是为什么我们不需要显式调用 dispatch() 函数,只需返回一个包含我们想要派发动作的 Observable 即可。在我们的例子中,这是 fetchExpensesSuccessfetchExpensesFailed 动作。

最后,你需要将 index.ts 文件中的效果导出,该文件位于 state/expenses 文件夹内:

export * from './expenses.effects';

现在我们已经定义了动作并创建了一个处理 fetchExpenses 动作并派发 fetchExpensesSuccessfetchExpensesFailed 动作的效果,让我们通过创建我们的状态和还原函数来覆盖我们 NgRx 状态的下一个构建块。

创建你的初始状态和第一个还原函数

现在你已经创建了一些动作和第一个效果,你需要一个状态来执行这些动作,并在 expenses.reducer.ts 文件中,你将定义你的初始状态对象和还原器,以便在派发动作时调整状态。

首先,在 expenses.interface.ts 文件中为你的状态对象创建一个新的接口:

export interface ExpensesState {
  expenses: ExpenseModel[];
  selectedExpense: ExpenseModel | null;
  isLoading: boolean;
  inclVat: boolean;
  error: string | null;
}

在创建接口之后,你可以在expenses.reducer.ts文件中创建你的初始状态对象:

export const initialExpensesState: Readonly<ExpensesState> = {
  expenses: [],
  selectedExpense: null,
  isLoading: false,
  inclVat: false,
  error: null
};

在定义了接口和初始状态对象之后,你可以使用createReducer()函数创建 reducer。createReducer()函数接受你的初始状态作为参数,并根据分发动作来减少你的状态。

首先,我们需要定义 reducer 函数并给它提供初始状态:

export const expensesReducer = createReducer<ExpensesState>(initialExpensesState);

在前面的代码片段中,我们创建了一个名为expensesReducer的属性,并将其分配给createReducer()函数。在箭头括号内,我们提供了 reducer 将修改的类型;在我们的例子中,这是ExpensesState接口。在函数括号内,我们提供了初始状态对象,initialExpensesState

接下来,你需要在createReducer()函数内部添加函数,以便在分发动作时更新状态,从fetchExpenses动作开始。为了更新状态,你必须定义一个on()函数,并给on()函数提供它需要响应的动作的引用,以及一个callback函数来修改状态:

createReducer<ExpensesState>(
  initialExpensesState,
  on(ExpenseActions.fetchExpenses, (state) => ({
    ...state,
    isLoading: true
  }))
)

在这里,我们在createReducer()函数内部初始状态对象下面添加了一个on()函数。我们给on()函数提供了ExpenseActions.fetchExpenses,以便在分发fetchExpenses动作时做出反应。

在动作引用之后,我们声明了一个callback函数来修改状态。在callback函数的函数括号内,你可以定义一个参数,它将填充当前状态对象供你使用;按照惯例,将此参数命名为state

最后,我们通过将当前状态扩展到对象中并设置我们想要更改的状态属性来返回一个新的状态对象。在fetchExpenses动作的情况下,我们只想将isLoading状态属性设置为true

接下来,我们可以在fetchExpenses动作的reducer函数下面添加fetchExpensesSuccessfetch ExpensesFailed动作的 reducer 函数:

on(ExpenseActions.fetchExpensesSuccess, (state, { expenses }) => ({
  ...state,
  isLoading: false,
  expenses,
  error: null
})),
on(ExpenseActions.fetchExpensesFailed, (state) => ({
  ...state,
  isLoading: false,
  error: ‹Failed to fetch expenses!›
})),

在这里,我们声明了两个额外的on()函数,并给它们提供了fetchExpensesSuccessfetchExpensesFailed动作。在fetchExpensesSuccess动作 reducer 的callback函数的函数括号内,我们使用了解构来从分发动作中提取expenses对象。你可能还记得,你定义了fetchExpensesSuccess动作,以便将 API 请求获取的支出作为参数。

接下来,在callback函数内部,我们更新了状态中的expenses属性,将isLoading设置为false,并将error设置为null。如果我们成功获取expenses属性,将不会向用户显示任何错误。

对于fetchExpensesFailed,我们在分发动作时没有提供参数,所以我们只提供状态对象到回调中,就像我们在fetchExpenses动作 reducer 中所做的那样。在fetchExpensesFailedreducer 的回调中,我们将isLoading设置为false并设置一个错误消息。

这样,您已经创建了初始状态,并为您定义的每个动作创建了一个reducer函数。当fetchExpenses动作被分发时,您使用reducer函数将isLoading状态设置为true。当您完成获取后,并且fetchExpensesSuccessfetchExpensesFailed动作被分发时,您使用reducer函数将isLoading状态设置为false,并相应地更新expenseserror状态。您可以使用isLoading状态来显示加载指示器,使用error状态来显示错误消息,以及使用expenses来显示您的费用列表。

现在,在expensesReducer下面,您需要为expenses状态定义一个唯一的键:

export const expensesFeatureKey = 'expenses';

作为最后一步,您需要在index.ts文件内添加 reducer 文件:

export * from './expenses.reducer';

在导出index.ts文件内部之后,您的 reducer 文件就准备好了。在继续到 NgRx 状态管理的最后一个构建块——选择器之前,我们将我们的 reducer 添加到expenses-registration应用的ApplicationConfig对象中。在app.config.ts文件中,在providers数组内添加以下内容:

provideStore(),
provideState({ name: expensesFeatureKey, reducer: expensesReducer }),

在前面的代码中,我们在providers数组内添加了provideStore()函数和provideState()函数。在provideState()函数内部,我们添加了一个包含名称和reducer属性的对象。名称接收我们在 reducer 文件内部提供的唯一键,而reducer属性接收expensesReducer函数。

现在您已经创建了 reducer 并在ApplicationConfig对象中添加了配置,现在是时候继续我们的 NgRx 状态的最后部分:选择器。

定义 NgRx 选择器

expenses状态:

export const selectExpensesState = createFeatureSelector<ExpensesState>(expensesFeatureKey);

在这里,我们使用了一个createFeatureSelector()函数,并向它提供了我们在expenses.reducer.ts文件内部声明的键。接下来,我们可以使用createSelector()函数定义额外的选择器,以检索expenses状态的具体部分:

export const selectExpenses = createSelector(selectExpensesState, (state) => state.expenses);
export const selectError = createSelector(selectExpensesState, (state) => state.error);
export const selectIsLoading = createSelector(selectExpensesState, (state) => state.isLoading);

在前面的代码片段中,我们声明了三个额外的选择器——一个用于检索expenses状态,一个用于检索error状态,还有一个用于检索isLoading状态。为了完成选择器,让我们在index.ts文件内部导出文件:

export * as ExpenseSelectors from './expenses.selectors';

在添加此export之后,还需要从您的state文件夹中导出index.ts文件;这个文件可以在data-access库的index.ts文件中找到:

export * from './lib/state/expenses/index';

现在我们已经将 NgRx 状态管理系统的所有部分都设置好了,是时候调整外观服务了。

调整外观服务以使用 NgRx 状态管理

我们将调整门面服务中的fetchExpenses方法和expenses信号。我们尚未为所有其他属性创建动作、效果、还原器和选择器。为了转换门面服务,我们需要首先注入Store类,该类由@ngrx/store包暴露给你:

protected readonly store = inject(Store);

在注入Store类后,我们可以调整门面服务中的fetchExpenses函数。只需在fetchExpenses函数内部移除this.expensesStore.fetchExpenses()并分发fetchExpenses动作:

this.store.dispatch(ExpenseActions.fetchExpenses());

这里,你使用了Store类,并在其上调用dispatch()函数来分发一个动作。调整fetchExpenses()方法后,是时候调整expenses计算信号了。

在这个计算信号内部,我们使用来自存储的expenses Subject类。我们需要将其更改为基于你的 NgRx 状态的expenses信号。

为了调整expenses计算信号,你需要创建一个新的属性来从 NgRx 状态中检索expenses状态并将其转换为信号。

我们可以通过在Store类上使用selectExpenses选择器并调用select()方法来从 NgRx 状态中检索expenses。使用Store类上的select()方法和我们的选择器将返回expenses状态作为 Observable,因此我们需要使用toSignal()函数将其转换为信号:

expensesSignal = toSignal(this.store.select(ExpenseSelectors.selectExpenses), { initialValue: [] });

现在我们已经从 NgRx 状态中获取了expenses状态,并在门面服务中作为一个信号,我们可以调整expenses计算信号,使其使用 NgRx 状态的expenses而不是存储中的expenses。只需将计算信号内部的this.expensesStore.expenses()实例替换为this.expensesSignal()即可。

通过这样,你已经更改了所有需要更改的内容,并且通过 NgRx 动作和状态来获取和检索expenses状态。在继续之前,让我们添加一个额外的 NgRx 状态,以便你可以理解 NgRx 状态管理中正在发生的一切。

添加额外的动作、效果、还原器和选择器

为了更好地掌握我们构建的 NgRx 状态管理,让我们通过添加额外的动作、效果、还原器和选择器来扩展它。

我们将首先添加一个动作来调整inclVat状态,就像我们之前做的那样,通过添加一个动作。因为inclVat状态只涉及状态变化而没有 HTTP 请求,所以你只需要一个动作来调整inclVat状态,不需要成功和失败的动作,因为你没有进行可能成功或失败的 HTTP 请求。调整inclVat状态的动作也不需要参数,因为我们只是将状态更改为它当前不是的状态。

你可以简单地创建一个动作并为其提供一个描述:

export const adjustVat = createAction(`[Expenses] Adjust incl vat`);

对于inclVat状态更改不需要效果,因为你没有执行 HTTP 请求或需要分发额外的动作。然而,你确实需要在expensesReducer内部添加一个新的还原器函数来调整状态对象。

expensesReducercreateReducer()函数内部,添加一个额外的on()函数来改变inclVat状态,当adjustVat动作被分发时:

on(ExpenseActions.adjustVat, (state) => ({
  ...state,
  inclVat: !state.inclVat
})),

如你所见,在分发adjustVat动作后,我们将inclVat状态更改为它目前不是的状态。在添加reducer函数之后,你需要添加一个选择器来从状态对象中检索inclVat属性:

export const selectInclVat = createSelector(selectExpensesState, (state) => state.inclVat);

现在,唯一剩下要做的事情是调整外观服务,并使用 NgRx 状态中的inclVat属性而不是expenses.store.ts中的信号。

要调整外观服务,首先添加一个inclVat属性,并使用toSignal()函数将selectInclVat选择器转换为信号:

inclVat = toSignal(this.store.select(ExpenseSelectors.selectInclVat), { initialValue: false });

在添加了inclVat属性之后,你只需在expenses计算信号内部将this.expensesStore.inclVat()更改为this.inclVat()即可。

最后,你需要调整外观服务中的adjustVat()函数。移除函数中的当前内容,并用分发adjustVat动作来替换它:

this.store.dispatch(ExpenseActions.adjustVat());

在添加了前面的代码之后,你已经做出了所有必要的更改,现在你正在使用 NgRx 状态中的inclVat属性而不是expenses.store.ts中的信号。现在,你只需要添加剩余的动作、效果、还原器和选择器,这样你就可以完全从外观服务中移除存储,并使用 NgRx 状态来做所有事情。

作为练习,你可以尝试根据我们为费用列表所做的工作,自己添加额外的动作、效果、还原器和选择器。在添加了额外的动作、效果、还原器和选择器之后,你应该能够完全调整费用外观,并完全移除存储实现。如果你遇到了困难或者只是想复制代码,你可以从本书的 GitHub 仓库中获取:github.com/PacktPublishing/Effective-Angular

在本节中,你探索了 NgRx,并学习了如何使用它来管理你应用程序的状态。我们讨论了默认的 NgRx 实现来管理状态。请注意,该库还有更多解决方案和包可以提供,但这超出了本书的范围。

NgRx 提供的一些其他功能包括signalStoresignalState,这两个解决方案你可以使用它们来管理你的状态,使用 NgRx 和信号而不必使用toSignal()转换 Observables,这是我们在这个部分所做的工作。NgRx 库中有有用的 RxJS 操作符。我们只使用了ofType()操作符,但 NgRx 还提供了更多实用操作符,例如concatLatestFrom()tapResponse()

NgRx 还提供了管理组件状态和在路由变更时分发访问状态动作的解决方案。我强烈建议你自己探索 NgRx 和其他状态管理库。

摘要

在本章中,你学到了很多,并将我们在 第七章 中学到的所有内容结合起来。你学习了状态管理是什么以及为什么你需要一个好的状态管理解决方案。你还了解了不可变性、单向数据流和副作用。在理论学习之后,你开始使用 RxJS 的 BehaviorSubjectSubject 类构建状态管理解决方案。

当你使用 RxJS 构建完状态管理解决方案后,你创建了一个门面服务,该服务将你的组件层连接到应用程序的数据访问和状态管理层。为了结束你的自定义状态管理解决方案,你将 RxJS 的状态实现转换为 Signals 实现,进一步简化了你的组件层和门面服务。

最后,你了解了使用 RxJS 和 Signals 作为你的状态管理解决方案的不足,并用 NgRx 实现替换了它们,该实现使用动作、效果、还原器和选择器。

在下一章中,你将学习如何提高你的 Angular 应用程序的性能和安全性。

第三部分:使用自动化测试、性能、安全性和可访问性为生产做准备

在最后一部分,你将学习如何提高你的 Angular 应用程序的性能,并使它们对每个人来说更加安全和易于访问。从性能开始,你将深入了解 Angular 的变更检测机制,学习 Angular 如何检测变更以及你可以采取哪些行动来减少变更检测周期数。当你详细了解变更检测的工作原理后,你将学习如何防止其他因素影响你的 Angular 应用程序的性能。然后,你将探讨在开发 Angular 应用程序时的一些常见安全风险以及如何减轻它们。此外,你将深入研究可访问性,使用 Transloco 使你的应用程序内容可翻译,并学习如何开发适用于来自不同地区和能力的用户的可访问应用程序。此外,你将学习如何使用 Jest 编写和运行单元测试,以及使用 Cypress 进行端到端测试,这让你在部署更改时更有信心,而不会破坏任何东西。最后,你将进行一些最后的改进,学习如何分析和优化你的包大小,并自动化你的部署流程。

本部分包括以下章节:

  • 第九章增强 Angular 应用程序的性能和安全性

  • 第十章Angular 应用程序的国际化和本地化以及可访问性

  • 第十一章测试 Angular 应用程序

  • 第十二章, 部署 Angular 应用程序

第九章:提升 Angular 应用程序的性能和安全

在本章中,你将学习如何提高你的 Angular 应用程序的性能和安全。你将深入研究 Angular 的变更检测机制,以便你知道如何减少 Angular 在浏览器中需要检查变更和重新渲染的组件数量。接下来,你将了解你可以采取哪些措施来优化 Angular 应用程序的页面加载时间和运行时性能。一旦你知道如何提升 Angular 应用程序的性能,你将学习有关安全性的知识。你将了解在构建 Angular 应用程序时可能遇到的风险,以及如何减轻这些风险,以便为你的最终用户提供安全的应用程序。

本章将涵盖以下主题:

  • 理解 Angular 变更检测

  • 提升 Angular 应用程序的性能

  • 构建安全的 Angular 应用程序

理解 Angular 变更检测

对于小型应用程序,性能通常不是瓶颈。然而,当应用程序增长并且你开始添加和组合更多组件时,你的应用程序可能会变慢,损害用户体验并降低用户留存率。你的应用程序变慢的一个原因是,如果你在开发时没有采取措施帮助 Angular 执行更好的变更检测,Angular 将检查越来越多的组件以查找变更。因此,为了构建性能良好的 Angular 应用程序,你需要了解变更检测机制是如何工作的,这样你就可以减少框架需要检查变更和重新渲染的组件数量。

为了更好地理解问题,你必须首先了解 Angular 如何执行变更检测以及问题从何开始。

假设你有一个简单的组件,具有标题属性和changeTitle()函数,如下所示:

title = 'Some title';
changeTitle(newTitle) { this.title = newTitle }

如果你调用changeTitle()函数,Angular 可以在更改标题后保持一切同步。在调用栈中,Angular 将首先调用changeTitle()函数,随后所有后续函数都将由于调用changeTitle()函数而被调用。然后,在幕后,Angular 将调用一个tick()函数来运行变更检测。变更检测将运行整个组件树,因为你可能在一个或多个组件内部更改了服务中使用的值。这种场景将按预期工作;尽管 Angular 必须检查整个组件树,但它将保持应用程序状态和视图的同步。

现在,假设你在更新标题属性之前运行一些异步代码;问题将始于这个场景。Angular 会检测到 changeTitle() 被调用并运行变更检测。由于调用栈的工作方式,Angular 不会在调用后台运行变更检测的函数之前等待异步操作完成。因此,Angular 将在更新标题属性之前运行变更检测,导致应用程序损坏,因为仍然显示旧值。

重要提示

实际上,异步更改不会破坏代码和视图之间的同步,因为 Angular 使用 Zone.js 来解决这个问题!

现在你已经知道异步更改可能导致未检测到的更改。接下来,让我们了解 Zone.js 以及 Angular 如何使用它来处理这个问题,以便它可以成功执行同步和异步更改的变更检测。

Zone.js 和 Angular

Zone.JS 库通过猴子补丁(即动态更新运行时行为)浏览器 API,并允许你挂钩到浏览器事件的生存周期。这意味着你可以在浏览器事件发生前后运行代码。使用 Zone.js,你可以创建一个 Zone,在 Zone 内部代码执行之前和所有 Zone 内部代码完成之后(包括异步事件)运行代码。

为了演示这一点,这里有一个这样的 Zone 的简单示例:

const zone = Zone.current.fork({
  onInvokeTask: (delegate, current, target, task, applyThis, applyArgs) => {
    console.log(‹Before zone.run code is executed');
    delegate.invokeTask(target, task, applyThis, applyArgs);
    console.log('After zone.run code is executed');
  }
});
zone.run(() => {
  setTimeout(() => {
    console.log(‹Hello from inside the zone!›);
  }, 1000);
});

在前面的代码中,创建了一个 Zone,并在 Zone 内部执行异步代码——在我们的例子中是一个 setTimeout 函数。前面的代码将首先记录在 delegate.InvokeTask() 方法之前声明的消息。接下来,它将运行在 zone.run() 回调函数内部声明的代码;这可以是同步和异步代码。最后,当回调函数内部的代码完成时,将记录在 delegate.InvokeTask() 方法之后声明的消息。

在幕后,Angular 使用与我们的 Zone 示例类似的方法创建了一个围绕整个应用程序的 Zone,称为 onMicrotaskEmpty,当队列中没有更多微任务时,它会发出一个值。Angular 使用这个 onMicrotaskEmpty 可观察对象来确定 NgZone 内部的所有同步和异步代码何时完成,Angular 可以安全地运行变更检测而不会错过已更改的值。

图 9.1 中,你可以看到 Angular 创建的 NgZone 如何围绕整个组件树,允许 Angular 安全地监控异步更改:

图 9.1:NgZone 内部的组件树

图 9.1:NgZone 内部的组件树

在运行变更检测时,Angular 将检查组件树中的所有组件,如果任何绑定发生变化(绑定是绑定到 HTML 模板的值),则更新和重新渲染组件。

现在您知道了 Angular 如何在所有同步和异步任务完成后使用 Zone.js 触发变化检测,以及 Angular 在变化检测运行时检查整个组件树。让我们学习为什么 Angular 检查整个组件树,以及您如何在变化检测运行时减少 Angular 必须检查和重新渲染的组件数量。

提高变化检测效率

Angular 将组件标记为 OnPush 变化检测策略,如下面的代码所示:

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush
})

当使用 OnPush 变化检测策略时,Angular 只会对标记为脏的组件执行变化检测,这显著减少了必须检查和重新渲染的组件数量。有几个因素会将组件标记为脏:

  • 组件内部处理的浏览器事件(悬停、点击、键入等)

  • 改变的组件输入值

  • 组件输出发射

当组件被标记为脏时,Angular 也会将组件的所有祖先标记为脏。在 图 9*.2* 中,您可以可视化地看到这一点,以更好地理解概念:

图 9.2:脏组件树

图 9.2:脏组件树

现在,使用 OnPush 变化检测策略且未标记为脏的组件在 Angular 运行变化检测时不会被检查更改,这减少了框架必须检查的组件数量。Angular 也会跳过使用 OnPush 且未标记为脏的组件的所有子组件。

图 9*.3* 展示了使用 OnPush 策略的变化检测机制:

图 9.3:使用 OnPush 策略的变化检测

图 9.3:使用 OnPush 策略的变化检测

如您在 图 9*.3* 中所见。Angular 不会检查是否需要使用 OnPush 变化检测策略刷新非脏组件的所有子组件的绑定。这也说明了为什么在使用 OnPush 时,所有祖先组件都必须标记为脏。Angular 从顶部向下检查是否需要刷新绑定,从根组件开始。因此,如果您在组件树底部的组件上点击,Angular 会从根组件开始,逐层向下遍历组件树。如果点击的组件的父组件使用 OnPush 变化检测策略,并且该组件未标记为脏,Angular 将跳过其子组件。结果,Angular 不会检查您点击的组件,导致代码和视图不匹配,因为与点击相关的更改将不会被处理。由于上述原因,当组件使用 OnPush 变化检测时,Angular 必须将所有父组件标记为脏。

对于OnPush变更检测,另一个有趣的案例是 Observables。Observables 是 Angular 框架中处理异步事件和数据流的主要工具,但接收新值的 Observables 不会将组件标记为脏。因此,当使用OnPush变更检测策略时,如果 Observables 接收新值,组件将不会更新。为了解决这个问题,你可以使用async管道,因为async管道会自动标记组件为检查,并像常规事件一样处理更新,标记组件为脏并在之后运行变更检测。或者,你可以使用ChangeDetectorRef并手动调用markForCheck()detectChanges()方法,如下所示:

cd = inject(ChangeDetectorRef)
this.cd.markForCheck();
this.cd.detectChanges();

markForCheck()方法将在下一个变更检测周期中将组件标记为检查,而detectChanges()方法将立即将组件标记为脏并触发该特定组件的变更检测。

然而,在使用detectChanges()方法时你必须小心,因为它也可能导致性能问题。detectChanges()方法将在单个浏览器任务中运行整个变更检测,直到该任务完成才会释放主线程。例如,当你需要在屏幕上显示一个大型数组并且必须频繁检测该数组的变更时,这会给浏览器带来大量工作,从而减慢你的 Angular 应用程序。

现在你已经更好地理解了OnPush变更检测的工作原理以及如何标记组件为脏或手动运行变更检测,让我们学习一下 Angular 变更检测机制是如何处理信号的。

Angular 变更检测和信号

在 Angular 17 中,信号作为开发者预览版发布,随之变更检测机制也得到了升级。当在模板中使用信号时,Angular 会注册一个效果,该效果监听模板中使用的信号。当信号值发生变化时,效果会运行并将组件标记为 Angular 变更检测需要检查的组件。

当组件被标记为检查,因为信号值发生变化时,变更检测周期的工作方式将有所不同。首先,信号值发生变化的组件将收到一个RefreshView标志。接下来,它将遍历组件树并标记所有祖先组件为HAS_CHILD_VIEWS_TO_REFRESH。它不会将祖先组件标记为脏。现在,当变更检测运行时,Angular 将执行所谓的全局+局部glo-cal)变更检测。

当运行全局变更检测时,组件树将自顶向下进行检查,就像通常一样。但是,当 Angular 遇到带有 HAS_CHILD_VIEWS_TO_REFRESH 标志的非脏 OnPush 组件时,它将跳过 OnPush 组件,但会继续向下遍历组件树以查找带有 RefreshView 标志的组件。因此,只有带有 RefreshView 标志的组件将被更新和重新渲染;所有使用 OnPush 变更检测策略的父组件将不会被检查或重新渲染,这进一步提高了 Angular 变更检测机制的效率。

你现在知道了 Angular 变更检测的工作原理以及如何使用 OnPush 变更检测策略来使变更检测过程更高效。然后,你学习了在使用 OnPush 变更检测为组件处理可观察对象的方法。你还知道如何使用 markForCheck()detectChanges() 函数手动标记组件以进行检查或运行变更检测。最后,你看到了如何通过结合使用信号和 OnPush 变更检测策略以及触发全局变更检测来进一步提高变更检测的效率。所有这些更改都将显著提高应用程序的性能,尤其是在应用程序增长并且你有大型且复杂的组件树时。

在下一节中,我们将探讨其他方法来提高您的 Angular 应用程序的性能。

提高 Angular 应用程序的性能

使用尽可能多的组件在 OnPush 变更检测策略上理解 Angular 的变更检测工作,并使用信号来进一步改进变更检测,这是构建高性能应用程序的良好第一步。然而,当开发高性能应用程序时,框架还有更多可以提供的内容。

在本节中,我们将探讨可用于提高 Angular 应用程序性能的内置工具和技巧,以确保快速页面加载和良好的运行时性能。我们将首先探索用于提高性能的第一个内置工具 runOutsideAngular() 方法。

理解和使用 runOutsideAngular() 方法

在 Angular 应用程序中,优化性能有时需要执行特定任务在 Angular 区域之外。在前一节中,你学习了关于 Zone.js 的内容,Angular 如何使用它来创建 NgZone,以及它与变更检测和应用程序的更新行为之间的关系。runOutsideAngular() 方法提供了一种在 Angular 的变更检测机制之外运行特定代码的方式,这可以提高应用程序的响应性和效率。

通过使用runOutsideAngular()在 Angular 的 Zone 外执行任务,您可以防止不必要的变更检测周期被触发。这可以导致更平滑的用户交互,并减少与 Angular 的变更检测机制相关的开销。在 Angular Zone 外执行的任务不会被 Angular 的变更检测周期自动检测,从而提高应用程序的整体性能。

runOutsideAngular()方法由 Angular 的 NgZone 服务提供。runOutsideAngular()方法可以在 NgZone 外运行重计算函数。一些重计算函数的例子包括复杂的数学计算、排序大型数组以及处理大型数据集。您可能希望在 NgZone 外运行的其他场景如下:

  • 运行第三方库中的代码:在 Angular Zone 外运行与初始化、配置或与第三方库交互相关的代码,可以防止 Angular 执行不必要的变更检测,从而提高性能并避免潜在的副作用。

  • 处理 WebSocket 通信或长轮询请求:这涉及到频繁更新应用程序状态,而不触发用户发起的操作。

  • 涉及低级 DOM 操作或 canvas 绘图操作的动画或渲染优化:在 Angular Zone 外运行相关代码可以通过绕过 Angular 的变更检测并允许对渲染更新有更直接的控制来提高性能。

通过战略性地使用runOutsideAngular(),您可以提高 Angular 应用程序的性能和响应速度,尤其是在处理计算密集型任务或与外部库交互时。然而,平衡性能优化与保持应用程序的完整性和功能至关重要。当在runOutsideAngular()内运行任务时,变更检测将不会检测到这些任务,因此您可能会向用户显示错误的数据。对此的一个良好对策是在runOutsideAngular()方法内运行重计算,然后通过使用run()方法再次在 NgZone 内将值分配给组件属性,如下面的代码所示:

@Component({……})
export class ExampleComponent {
  protected readonly ngZone = inject(NgZone);
  performTask(): void {
    this.ngZone.runOutsideAngular(() => {
      console.log(‹Task performed outside Angular Zone›);
      // Run inside the runOutsideAngular method again
      this.ngZone.run(() => {
        console.log(‹Running inside NgZone again›);
      });
    });
  }
}

在前面的代码中,您可以看到如何使用runOutsideAngular()run()方法。您注入 NgZone 并调用 Angular 提供的服务上的方法。在每个方法的回调中,您可以在 NgZone 内或外执行任何逻辑。

现在您已经了解了如何使用runOutsideAngular()在 NgZone 外运行代码以提高应用程序的性能,让我们继续了解 Angular 提供的下一个工具,以开发更高效的应用程序:NgOptimizedImage指令。

理解和使用 NgOptimizedImage 指令

在构建高性能应用程序时,优化图像是另一个关键方面。您的图像加载时间对网站的最大内容渲染时间(Largest Contentful PaintLCP)有很大影响,这是三个核心 Web Vital 指标之一[其他两个核心 Web Vital 指标是首次输入延迟FID)和累积布局偏移CLS)]。LCP 表示网页主要内容的加载速度,具体测量从用户触发页面加载到浏览器窗口可见区域内显示最大图像或文本块之间的持续时间。由于图像的加载时间通常比文本内容长,因此图像的加载和显示方式在应用程序的 LCP 中起着至关重要的作用。

在 Angular 框架中,您可以通过使用 NgOptimizedImage 指令来改进图像的加载方式。NgOptimizedImage 指令专注于优先加载 LCP 图像。

默认情况下,此指令为非优先图像启用懒加载,节省带宽并提高初始页面加载时间。此外,NgOptimizedImage 在文档头部生成一个 preconnect 链接标签,优化资源获取策略。NgOptimizedImage 自动在 img 标签上设置 fetchpriority 属性,强调 LCP 图像的加载优先级。此外,该指令简化了生成 srcset 属性的过程。通过使用 srcset 属性,浏览器请求适合用户视口的图像大小,因此不会浪费时间和资源下载过大的图像。

除了优先加载 LCP 图像外,NgOptimizedImage 还确保应用一系列图像最佳实践:

  • 图像 CDN 利用:该指令鼓励使用图像内容分发网络(CDN)的 URL,以促进图像优化并在全球网络中高效传输。

  • 如果 NgOptimizedImage 设置错误或未设置尺寸,将导致警告。通过设置宽度和高度属性,您可以减轻布局偏移,提高您的 CLS,并确保正确的渲染。

  • NgOptimizedImage 会提醒开发者注意渲染图像中可能出现的视觉扭曲。

既然您已经知道了为什么需要 NgOptimizedImage 指令,让我们看看如何使用它。NgOptimizedImage 指令是独立的,因此您首先需要将 NgOptimizedImage 指令直接导入必要的 NgModule 或独立组件。接下来,您可以通过将 img 标签上的 src 属性替换为 ngSrc 来使用 NgOptimizedImage

<img ngSrc="dog.jpg">

如前所述,您还需要设置宽度和高度属性:

<img NgOptimizedImage directive, but there are some additional options you can add. Let’s start by exploring the priority attribute. When you mark an image with priority, the following optimizations are applied for you:

*   `fetchpriority=high`
*   `loading=eager`

When you use server-side rendering, it automatically generates a preload link element.
You can mark an image with priority as follows:


 All LCP images should be marked as priority. If you don’t mark an LCP image as priority during development, Angular will log an error.
Besides the `priority` attribute, another useful attribute used with the `NgOptimizedImage` directive is the `fill` attribute, like so:

当你想要图像填充包含元素时,使用ngSrc="dog.jpg"fill属性。当你想将图像用作背景图像,或者当你不知道图像的确切大小,但想将其适应一个你知道其相对于屏幕大小的大小时,可以使用填充属性。当使用填充属性时,你不需要设置宽度和高度属性,因为 Angular 会在大小解决后为你设置它们。

要控制图像如何填充容器,你可以使用object-fit CSS 属性。

更多信息

除了priorityfill属性之外,当使用第三方服务处理你的图片时,NgOptimizedImage指令还有更多酷炫的功能,例如低分辨率占位符和自定义图片加载器。这些功能超出了本书的范围,但如果你有兴趣,可以在官方 Angular 文档中阅读有关它们的内容:angular.io/guide/image-directive

现在你已经了解了NgOptimizedImage以及如何使用它来优化你的图像并提高应用程序的 LCP,让我们深入了解下一个性能优化步骤:在 HTML 模板中使用trackBytrack函数进行循环。

理解和使用 trackBy 和 track 函数

在 Angular 应用程序中,渲染大量列表或数据集合有时会导致性能问题,因为频繁的 DOM 操作。为了优化你的 Angular 应用程序的性能,了解并利用像trackBytrack函数这样的工具至关重要。

trackBy函数是 Angular 提供的一个功能,它通过在*ngFor指令渲染列表时提高性能。track函数是 Angular 控制流语法的对应物。trackBy函数是可选的,而track函数在使用控制流语法时是必需的。

默认情况下,Angular 使用对象标识符来跟踪*ngFor指令提供的数据的变化。然而,这种方法可能导致 DOM 元素的不必要重渲染,尤其是在处理动态数据时。tracktrackBy函数允许 Angular 通过为每个项目提供一个唯一标识符来高效地跟踪集合中的变化。这导致 DOM 操作更少,显著提高了渲染性能,尤其是在处理大量数据集时。

当你使用*ngFor指令时,你需要将trackBy属性分配给一个函数,并在你的组件类内部声明相应的函数。该函数应该返回你想要用来跟踪你正在渲染的列表中项目的唯一标识符:

<div *ngFor="let item of items; *ngFor directive and defining the trackBy property. The trackBy property is assigned with a function named trackById. This trackById function has to be declared inside the component class, like this:

trackById(index: number, item: Item) { return item.id }


 In the preceding example, you use the `id` property from the objects you are rendering with the `*ngFor` directive as the unique identifier (this assumes the objects have an `id` property, otherwise you return another unique property). It’s important to note that the `trackBy` function should only be used when the items in the collection have a unique identifier. Using a non-unique identifier or omitting the `trackBy` function altogether can lead to unexpected behavior and performance issues.
When using the control flow syntax to output a list inside your HTML template, the syntax is a bit simplified. Instead of a `trackBy` function, you now use the `track` function and directly provide it with the unique property to check instead of creating a function that returns the unique property, like so:

@for (item of items; track item.id) { … }


 Now that you know why you need to use `trackBy` and `track` functions when rendering lists in your HTML templates, let’s explore web workers, the next performance optimization that Angular has at its disposal.
Understanding and using web workers in Angular
**Web workers** allow you to execute CPU-intensive tasks within a separate thread running in the background, thereby making the primary thread free to update the user interface and run the main threat without any hiccups. Whether it involves intricate tasks such as producing **computer-aided design** (**CAD**) drawings or conducting complex geometric computations, applications can leverage web workers to enhance overall performance significantly.
You add a web worker to your application with an Nx generator. Open the **NX console**, click on **generate**, search for web worker, and select the **@nx/angular – web worker** generator. Next, you need to give your web worker a name and select a project to add the web worker to. If you work without Nx, you can run the following CLI command:

ng generate web-worker


 Running the Nx generator or Angular CLI command will configure your project to use web workers if it isn’t configured already. It will also generate a file with your web workers. If you named your web worker `heavy-duty`, the generated file will be named `heavy-duty.worker.ts`; when using the Angular CLI, the name of the file will equal the location you provided in the CLI command.
Inside the generated worker file, you will find the initial scaffolded code you need for your web worker. When using Nx, you’ll find the following code in the generated file:

addEventListener('message', ({ data }) => {

const response = worker response to ${data}

postMessage(response);

});

if (typeof Worker !== 'undefined') {

const worker = new Worker(new URL('./heavy-duty.worker', import.meta.url));

worker.onmessage = ({ data }) => {

console.log(页面收到消息 ${data});

};

worker.postMessage('hello');

} else { // 环境回退 }


 The `addEventListener` function will stay in the worker file, and the rest of the code must be located in the component or service where you want to use the web worker. By moving everything but the `addEventListner` function, you can send messages from the component or service to the web worker. As you can see, in the code that must be moved, there is a fallback for environments where the web worker doesn’t work. This is because when using server-side rendering, web workers do not work and you need to have a fallback.
To work with the web worker, you need to send messages to and from the web worker to perform the logic you need to perform. For example, let’s say you want to use the web worker when a component is initialized. To achieve this, you add the following code inside the component where you want to use the web worker:

@Component({……})

export class FooComponent {

heavyDutyResult;

heavyDutyInput = {……};

constructor() { this.runWebWorker() }

runWebWorker () {

if (typeof Worker !== 'undefined') {

const worker = new Worker(new URL('./heavy-duty.worker', import.meta.url));

worker.onmessage = ({ data }) => {

this.heavyDutyResult = data;

};

worker.postMessage(this.heavyDutyInput);

} else { // 回退 }

}

}


 As you can see in the preceding code, you use `worker.postMessage` to send a message to the web worker. This is received inside the event listener of the web worker. When the `postMessage()` function is called in the web worker, it will be received in the `worker.onmessage()` callback function inside the component. Now, you only need to update the web worker file to perform the heavy-duty logic:

addEventListener('message', ({ data }) => {

const response = heavyDutyFunction(data);

postMessage(response);

});


 As you can see in the preceding code, we perform some logic – in this example, an imaginary `heavyDutyFunction()` – and send the response back to the component using the `postMessage()` function. Now the circle is complete. You can send some data from the component to the web worker and the web worker will receive this data, perform the heavy-duty logic with the data, and returns the `response` constant to the component class.
Now you know how to use a web worker to create multithreading and run resource-intensive code without blocking your main threat. To wrap up the section, I will mention some other methods you can use to improve the performance of your Angular applications:

*   **Lazy loading**: Lazy loading routes help to only load sections of your app that the user actually reaches. We already showcased this in *Chapter 2*, but it’s worth mentioning as a performance optimalization.
*   `preloadingStrategy` on your routes, you can also pre-load routes you anticipate the user will navigate.
*   `Record` classes, for example. For API requests, I can recommend using `ts-cachable`.
*   **Using pure pipes**: We already explained the usage of pipes and what pure pipes are in *Chapter 3*, but they are worth mentioning as a performance optimalization.
*   `canMatch` route guard combined with lazy-loaded routes prevents you from loading modules and components the user is not allowed to access.
*   **Using RxJS effectively**: Running code asynchronously doesn’t block your threat and can help to improve the performance of your application.
*   `ng add @angular/ssr` command, you can enable server-side rendering, greatly improving the performance of your application. We will not cover server-side rendering in further detail, but as of Angular 17, you can also include page hydration when using server-side rendering, further enhancing the performance.
*   **Virtual scrolling**: Virtual scrolling is a feature in the Angular Material CDK that enables you to effectively render large lists. The virtual scroll will ensure that only items within the viewport are rendered.

You now know how to improve the performance of your Angular applications using `OnPush` and Signals, run code outside the NgZone or create multithreading using web workers, optimize images using the `NgOptimizedImage` directive, and render lists in a performant way by utilizing the `trackBy` and `track` functions. You also learned about other tools and tips to further enhance the performance of your Angular applications. Next, we will learn how you can improve the security of your Angular applications.
Building secure Angular applications
In a world where hacks and exploits are more frequent than ever, you are also responsible for developing secure applications. In this section, we’ll delve into the various security risks that Angular applications may face and explore strategies to mitigate them effectively.
When it comes to securing frontend applications, you want to ensure that the users can’t reach parts of your application they are not intended to go to and that they can’t perform malicious actions that will compromise your application. We will first look at the first scenario and ensure that users can’t reach sections of your applications they are not intended to reach.
Setting up route guards
**Route guards** are used to guard specific routes within your Angular application. They prevent unauthorized users from accessing certain parts of your application. For example, most parts of your application should only be accessible to users who are logged in; other routes might be restricted based on user roles or other factors. Within Angular, there are four different types of route guards:

*   **canActivate**: Determines whether the user can activate a specific route.
*   **canActivateChild**: Determines whether the user can activate the child routes of a specific route.
*   **canDeactivate**: Determines whether a user can deactivate a specific route.
*   `canActivate` is that if the `canMatch` guard fails, the module or standalone component related to the route is not loaded at all. Using `canMatch` offers some performance benefits when combined with lazy-loaded routes.

Since Angular 15, route guards have been implemented using a functional approach; in earlier versions, a class-based approach was used. The class-based approach is currently deprecated, so we will only cover the functional approach. You can declare each guard type you want to use in your route configuration, like this:

{

path: '…',

loadComponent: () => import('……'),

canMatch: [],

canActivate: [],

}


 As you can see, you define the guards in the route configuration object. Each guard type is assigned an array containing the guard function that it should resolve before the user can access the route. Each guard function returns a Boolean: `true` if the guard passes and the user can access the route, or `false` if the guard fails and the user can’t access the route.
In its simplest form, you can define the guard function directly inside the array assigned to the guard type property:

canMatch: [() => inject(UserService).loggedIn],


 In the preceding example, we `inject` a service and check whether the user is logged in (we did not create the service in this book; this is just an example). If the `loggedIn` property is `true`, the user can access the route. If the `loggedIn` property is `false`, the user can’t access the route.
In some scenarios, you might need access to route properties or the current component. If this is the case, you create a function that implements the `CanActivateFn`, `CanActivateChildFn`, `CanDeactivateFn`, and `CanMatchFn` type aliases. When using these type aliases, Angular provides the function with some function parameters you can use inside the guard logic:

*   `ActivatedRouteSnapshot` and state of type `RouterStateSnapshot`.
*   `CanActivateFn` type alias.
*   `currentRoute` of type `ActivatedRouteSnapshot`, `currentState` of type `RouterStateSnapshot`, and `nextState` of type `RouterStateSnapshot`.
*   `route` of type `Route` and `segments` of type `UrlSegment[]`.

You use the type aliases by defining a function that resolves in a `Boolean`. You type the function with the type alias and include the function parameters inside the function brackets. Here is an example implementing the `CanMatchFn` type alias:

export const hasRouteSegments: CanMatchFn = (route: Route, segments: UrlSegment[]) => {

return inject(UserService).loggedIn && segments.length > 1;

};


 In the preceding example, we check whether the user is logged in and whether there is more than one route segment. To use this guard, you add it to the array of the `canMatch` property inside the route configuration:

canMatch: [hasRouteSegments]


 You can also directly implement the type alias inside the array without defining the function elsewhere:

canDeactivate: [(component: UserComponent) => !component.hasUnsavedChanges]


 Now you know how to define functional route guards and prevent unauthorized users from accessing routes they aren’t allowed to access.
Although this already makes your application more secure, there are other risks when building Angular applications whereby users can perform malicious activities. So, let’s outline some attack surfaces and learn how you can mitigate them.
Angular attack surfaces and how to mitigate them
Before delving into +Angular-specific security measures, it’s essential to understand the common threats that web applications face. These threats include **cross-site scripting** (**XSS**), **cross-site request forgery** (**CSRF** or **XSRF**), injection attacks, and HTTP-level vulnerabilities such as **cross-site script** **inclusion** (**XSSI**).
Angular has some built-in tools to reduce the security risks of these attacks for you and there are some preventive measures you can take yourself when developing your Angular application. Let’s start with the most prevalent risk when developing frontend applications: XSS attacks.
Mitigating XSS attacks
In simple terms, you block XSS attacks by preventing malicious code from entering the `<script>` tag into the DOM. Other HTML elements that allow code exaction and can be used by attackers include the `<img>` and `<a>` tags. An attacker can use an XSS attack to hijack user sessions, steal sensitive data, or deface websites.
Angular takes a proactive approach to security, treating all values as untrusted by default. This means that when values are inserted into the DOM via template binding or interpolation, Angular automatically sanitizes and escapes untrusted values. This approach significantly reduces the risk of XSS attacks, a prevalent security vulnerability. Even though Angular proactively sanitizes and escapes untrusted values, there are still some actions you can take to make your applications even safer and protect them from security vulnerabilities.
Values inserted into the DOM via template binding or interpolation are automatically sanitized and escaped if the values are not trusted. On the other hand, Angular trusts HTML templates by default, because of which you should treat HTML templates as executable code. Never directly concatenate user input and template syntax because this would enable an attacker to inject harmful code into your application. Here is an example of what you should avoid:

{{ data }}
+ userInput

 One way to reduce the template risks is by using the default **ahead-of-time** (**AOT**) template compiler when creating production builds. Because the AOT compiler is the default, you don’t have to do anything unless you change the default compile settings.
Other possible attack surfaces for an XSS attack are `style`, `innerHTML`, `href`, and `src` bindings where the bound value is provided by the user:

...

 Attackers can use unsafe binding to inject harmful code or URLs into your application. Besides unsafe bindings, you also should avoid direct interaction with the DOM. If you bind an unsafe value, Angular will recognize it in most cases and sanitize it by removing the unsafe value. It’s good to be aware of this because it can lead to broken functionality in your application. Also, some attackers might be able to circumvent the sanitation, so be careful when using unsafe binding options. If you want to bind a URL, script, or other value that Angular will sanitize and you know the value is safe, you can bypass the sanitation using the `DomSanitizer` service provided by Angular.
If you want to bypass sanitation, you start by injecting the `DomSanitizer` service:

protected readonly sanitizer = inject(DomSanitizer);


 Next, you can use the bypass methods exposed by the service to bypass sanitation:

this.trustedUrl = this.sanitizer.bypassSecurityTrustUrl(this.dangerousUrl);


 The `DomSanitizer` service exposes five different options to bypass sanitation:

*   `bypassSecurityTrustHtml`
*   `bypassSecurityTrustScript`
*   `bypassSecurityTrustStyle`
*   `bypassSecurityTrustUrl`
*   `bypassSecurityTrustResourceUrl`

Depending on what value you are bypassing, you use the corresponding `bypassSecurity` method, so to bypass the sanitation of a piece of HTML, you would use the `bypassSecurityTrustHtml` method.
Besides binding unsafe values, another possible attack surface is direct manipulation of the DOM. The built-in browser DOM APIs don’t protect you from security vulnerabilities unless `Trusted Types` are configured. For example, elements accessed through `ElementRef` instances, the browser document, and many third-party APIs contain unsafe methods. You should avoid interacting with the DOM directly and instead use the `Renderer2` service when you need to manipulate DOM nodes.
Lastly, you can configure a **Content Security Policy** (**CSP**) to prevent XSS attacks. A CSP can be enabled on the web server and falls out of scope for this book.
You now know what XSS attacks are, what Angular does to prevent them, and what measures you can take to prevent them. Next, you will learn what vulnerabilities there are when making HTTP requests and what you can do in your Angular applications to prevent them.
Mitigating HTTP-related security risks
Ensuring robust security measures against HTTP-related risks is paramount to safeguarding your application and its users. Two significant threats to consider are CSRF (or XSRF) and XSSI. In this section, we will dive deeper into CSRF and XSSI and explain what they are, how they can affect your applications and users, and what measures you can take to prevent CSRF and XSSI exploits.
While CSRF and XSSI predominantly have to be mitigated on the server side, Angular does provide some tools to make the integration with the client side a bit easier. We will start by explaining what CSRF is and what you need to do on the client side to prevent it.
What CSRF/XSRF attacks are and how to prevent them
Imagine you’re logged into your online banking account in one tab of your browser. Now, if you visit a malicious website in another tab, that site can secretly make requests to your banking website without your knowledge. These requests could transfer money, change your password, or perform any action that your banking website allows – all without your consent.
CSRF/XSRF attacks can have serious consequences. They can lead to unauthorized transactions, data manipulation, and even account takeovers. Since the attacker doesn’t need to know your login credentials, these attacks can bypass traditional authentication mechanisms.
To protect against CSRF/XSRF attacks, websites typically use techniques such as CSRF tokens. These tokens are unique identifiers generated by the server and sent to the frontend. The frontend includes these random tokens with each request so the server can verify the token, ensuring that the request originated from a legitimate source and not from a malicious website. Commonly, the token is sent to the frontend using a cookie flagged with `SameSite`. If the cookie also includes the `httpOnly` flag, you don’t have to do anything on the frontend and everything will be handled on the backend, but this isn’t always the case; often, you must include the token in the request headers.
Using a CSRF token is an effective measure because all browsers have the same-origin policy. The same-origin policy ensures that only the code of the website where a cookie is set can read the cookie. The same-origin policy also ensures that a custom request header can be set by the code of the application making the request. That means that malicious code from the website the attacker tricked you into using cannot read the cookie or set the headers for your request. Only the code of your own application can do this.
If the cookie with the CSFR token is not an `httpOnly` cookie and the client is required to add the cookie in the request header, you can create an HTTP interceptor for this purpose. Here is an example of how the interceptor could be implemented:

export const MockInterceptor: HttpInterceptorFn = (

req: HttpRequest,

next: HttpHandlerFn,

) => {

const csrfToken = inject(AuthService).getCsrfToken();

const csrfReq = req.clone({

setHeaders: {

'X-XSRF-TOKEN': csrfToken,

},

});

return next(csrfReq);

};


 Besides adding a CSRF token, there isn’t anything you can do on the frontend to protect your application from CSRF attacks. If you need to add the token, it depends on how the server side implements the cookie, so consult with the backend team about this topic.
Now that you know what CSRF/XSRF attacks are, let’s learn about XSSI attacks.
What XSSI attacks are and how to prevent them
XSSI attacks occur when an attacker injects malicious scripts into a web page from an external domain. These scripts are executed in the context of the victim’s session, potentially compromising sensitive information and performing unauthorized actions. XSSI attacks can lead to data theft, session hijacking, and unauthorized manipulation of user interactions.
XSSI attacks are also known as the `<script>` tag, malicious actors can execute unauthorized requests and retrieve sensitive information from the targeted JSON API.
The success of this exploit hinges on the JSON data being executable as JavaScript. To prevent XSSI attacks, servers can adopt a preventive measure by prefixing all JSON responses, rendering them non-executable. Conventionally, this is achieved by appending the widely recognized `)]}',\``n` string.
The `HttpClient` of the Angular framework is equipped to handle this security measure seamlessly. It detects and removes the `)]}',\n` string from incoming responses automatically before proceeding with further parsing, thus fortifying the application against potential exploits. Because Angular automatically detects the `)]}',\n` string and removes it for you, you don’t have to do anything for XSSI prevention in the frontend, but it’s always good to be aware of the attack and how it actually can be prevented. If your backend team uses a different prevention measure, align with it to see whether you need to do anything in the frontend.
Summary
In this chapter, you learned about performance and security. You took a deep dive into Angular change detection, giving you a better understanding of how Angular detects changes and how you can reduce the number of components and bindings that Angular has to check when performing change detection.
You also learned about other measures you can take to ensure your Angular applications remain performant. You learned how to run code outside of the Angular zone, you learned about the `NgOptimizedImage` directive, you learned about the `trackBy` and `track` functions, and you’ve created your own web worker to run code in a separate threat. Furthermore, you learned that you can use lazy loading, `canMatch`, server-side rendering, and other tools provided by the Angular framework to enhance application performance even more.
After taking a deep dive into Angular application performance, you learned how you can develop secure frontend applications using the Angular framework. You learned how to prevent users from accessing pages they aren’t intended to reach. You also learned about common exploits, what measures Angular takes to prevent these attacks, and what steps you can take to make your application even more secure.
In the next chapter, you will learn how to make your applications more accessible and tailored to the users visiting them. You will learn about translatable content, using the correct formatting and symbols for each user, and making your website accessible to people of all abilities.