Angular Signals:简化响应式编程
**TL;DR:** Angular Signals 提供了一种同步且高效的方式来管理状态变化,从而带来更灵敏的 UI。这是朝着更快、更适合开发人员的 Angular 框架迈出的重要一步。
Angular 一直以其强大的响应式工具而闻名,尤其是其对 **Observables** 的使用,它可以高效处理异步数据和事件流。然而,最近新增的 **Angular Signals** 引入了一种新的响应式方法,可以简化应用程序状态和 UI 之间的同步。
Angular 信号提供了一种全新的同步方式来管理状态变化,通过仅针对受数据更新影响的元素来提高 UI 性能。这使得信号特别适用于优化变化检测、减少不必要的更新,并允许开发人员维护响应更快的界面。
在本篇博文中,我们将探讨 Angular Signals,了解它们与**Observables**的区别,以及它们如何融入 Angular 现有的生态系统。我们还将介绍实际用例以及如何利用信号构建高效、反应灵敏的应用程序。
什么是反应式编程?
反应式编程是一种编程范式,旨在创建能够自动响应数据和事件变化的软件。反应式系统无需等待用户点击或键盘输入等操作,而是动态响应其管理的数据变化。这种方法使应用能够保持实时响应和更新,从而提供更流畅的用户体验。
在 Angular 中,响应式编程是指根据组件底层数据(例如组件类或服务中的数据)的变化更新组件模板。例如,如果组件的数据通过服务更新,Angular 的响应式编程原则可确保视图自动重新渲染以反映这些新数据。通过使用响应式编程,Angular 应用可以保持高响应性和效率,使其更加直观和用户友好。
反应性在 Angular 应用中如何发挥作用?
在 Angular 中,响应性主要通过 **zone.js** 来处理,这是一个库,它使 Angular 能够检测应用中何时发生了更改。但是,这种检测有些有限。尽管 **zone.js** 可以识别出某些内容已发生更改,但它并未指定更改的内容、更改发生的位置或更改对应用的不同部分的影响。因此,Angular 仅知道发生了某些更改,而没有关于视图的哪些特定元素或部分受到影响的更多详细信息。
为了管理这些更改,Angular 启动了一个名为**更改检测**的过程。在此过程中,它会遍历整个组件和视图树,检查所有数据绑定以查看是否有任何内容已更新。这种方法可确保捕获所有更改,但效率可能很低,因为 Angular 会检查每个绑定(即使是那些没有更改的绑定),从而导致不必要的处理。
为了提高性能,Angular 提供了 **OnPush 变更检测** 策略,将变更检测限制在具有特定触发器的组件上,例如更新的输入属性或可观察的数据流。OnPush 允许 Angular 跳过视图树中未更改的部分,从而优化流程。但是,许多组件仍需进行变更检测,这可能会在较大的应用中产生开销。
此外,在开发模式下,Angular 会运行两次变更检测,旨在检测数据更新带来的意外副作用。虽然有助于识别错误,但重复检查会增加处理负载,可能会影响开发效率。这种方法凸显了 Angular 反应式系统的优势和局限性,强调了需要进行 OnPush 等优化,以便在应用复杂性增加时保持性能。
更好的 Angular 框架的目标
为了提高 Angular 的性能和开发人员体验,有几个目标指导着框架的演变:
这些改进旨在使 Angular 成为一个更快、更高效、更适合开发人员的框架,以满足现代 Web 应用程序日益增长的需求。
什么是 Angular 信号?
Angular 信号是 **Angular 16** 中引入的一种新的反应原语,它提供了一种强大而高效的方式来管理应用中的状态和反应性。Angular 信号引入了一种管理应用中反应性的新方法,注重精度和效率。信号本质上是一个值的包装器,每当该值发生变化时,它都会通知应用中依赖它的任何部分。与其他反应结构不同,信号始终保存一个值,该值可以是任何类型 - 从数字或字符串等简单数据到复杂对象或数组。
信号的一个关键特性是它们没有副作用;读取信号不会触发任何其他代码或造成意外更改。这使得信号具有高度可预测性和安全性,因为开发人员可以相信访问信号不会意外更改应用程序的不同部分。信号可以精确跟踪更改,仅更新直接受影响的 UI 部分,而 **zone.js** 方法则不同,Angular 会在整个组件树中运行更改检测以捕获更新。
从 **Angular 18** 开始,变更检测系统仍然依赖于 **zone.js**,这限制了信号的直接性能优势。尽管与现有的基于 zone.js 的反应性相比,使用信号尚未带来显着的速度提升,但信号代表了向前迈出的重要一步。它们为未来的无区域应用奠定了基础,为更高效、更简化的 Angular 反应性管理方法铺平了道路。
Angular 信号提供三种主要类型的反应结构:
每种类型在管理 Angular 应用内的数据反应性方面都有不同的用途。让我们详细探讨一下。
可写信号
这些基本的可变信号保存一个值并允许更新。可写信号充当状态容器,可以存储任何类型的数据 - 无论是数字、字符串、对象还是数组。开发人员可以创建可写信号,设置初始值,并根据需要更新它。当可写信号的值发生变化时,它会自动通知依赖于它的 UI 的每个部分,从而仅更新这些部分。信号是 getter 函数;调用它们将返回信号的当前值。
Angular 有两种主要方法来更改信号的值:**set()** 和 **update()**。每种方法都有其独特的用途:
例子:
import { signal } from '@angular/core; // Writable signal initialized with a value of 0. const counter = signal(0); // Signals are getter functions, calling them will return the current value of the signal. console.log('Count is: ', counter()); // 0 // Set the value of a signal. counter.set(3); // Update the value of a signal. counter.update((value) => value + 1);
计算信号
计算信号是只读信号,其值来自其他信号,非常适合依赖于现有状态的计算或转换。计算信号是使用计算函数创建的,该函数根据一个或多个相关信号定义导出值。
以下是其主要特征的细分:
例如,我们可以使用计算信号将可写信号的值加倍,如下面的代码所示。
import { signal, computed } from '@angular/core'; // Writable signal with an initial value of 5. const counter = signal(5); // Automatically doubles the value. // doubleCounter will be 10, and it updates automatically if the counter changes. const doubleCounter = computed(() => counter() * 2);
在此示例中,**doubleCounter** 依赖于计数器信号。如果 **counter** 发生变化,**doubleCounter** 将自动重新计算其值而无需任何直接赋值,从而提供一种简化的、反应式的方式来在 Angular 应用中获取值。计算信号对于保持获取的数据同步非常有用,并且是 Angular 实现精确、高效反应性方法不可或缺的一部分。
效果
效果允许开发者通过调用副作用(例如发出 API 请求、记录或更新 DOM)来响应信号的变化。效果是使用 **effect** 函数创建的,并且只要其依赖的任何信号发生变化,就会自动重新运行。
下面我们来深入了解一下它们的主要特征:
假设我们有一个 **userId** 信号和一个每次在 **userId** 改变时获取用户数据的效果:
import { signal, effect } from '@angular/core; const userId = signal(1); effect(() => { console.log(`Fetching data for user ID: ${userId()}`); // Make an API call or any other side effect here. });
在此示例中,每当 **userId** 更新时,效果都会记录一条消息。动态依赖项跟踪允许效果在 **userId** 每次更改时自动重新运行,确保副作用与最新数据保持同步。
对于执行与外界交互或依赖异步操作的任务,效果非常有用。它们提供了一种干净、响应式的方法来处理 Angular 中的副作用。
何时使用效果
虽然 Angular 信号中的影响可能不是每个应用都需要的,但它们在需要管理副作用以响应反应状态变化的特定场景中非常有用。以下是使用影响特别有益的一些情况:
何时不使用效果
虽然效果在 Angular 信号中提供了有价值的功能,但在某些情况下应避免使用它们以保持应用的稳定和高效。以下是何时不使用效果的关键注意事项:
信号与可观察量
信号和可观察对象在管理 Angular 应用内的数据流时有不同的用途。信号是 Angular 用来管理和同步应用状态与 UI 的同步机制。它们的同步特性使其成为可预测地处理实时状态变化的理想选择。当信号的值发生变化时,它会自动更新相关的 UI 组件。这种方法通过仅更新需要的 UI 部分来增强 Angular 的变化检测,从而使状态管理更高效、响应更快。
另一方面,可观察对象是异步的,因此非常适合等待外部资源或处理延迟响应的任务,例如从 API 获取数据。根据设计,可观察对象可以随时间发出多个值,因此非常适合异步处理数据流或事件。此功能在处理用户交互或以不可预测的间隔更新的数据时特别有用。
借助 Angular 信号实用函数,开发人员可以在同一应用中集成信号和可观察对象。这些实用工具使开发人员能够利用信号的同步特性以及可观察对象的异步优势。这种集成提供了一种简化的方式来利用这两种方法的优势,从而可以根据特定应用需求进行灵活且反应灵敏的编程。
将可观察量转换为信号
Angular 提供了一种便捷的方法,即使用 **toSignal** 函数将可观察对象转换为信号。与传统的 **async** 管道相比,此函数允许开发人员以更灵活的方式利用可观察对象的响应性。与仅限于模板使用的 **async** 管道不同,**toSignal** 可以在应用程序的任何位置应用,从而使开发人员能够更好地控制响应数据的处理位置和方式。
**toSignal** 函数首先立即订阅可观察对象。这种立即订阅意味着可观察对象发出的任何信号都会立即触发信号值的更新。但是,开发人员应注意,这种立即订阅也可能引发副作用,例如 HTTP 请求或可观察对象中定义的其他操作。
**toSignal** 的另一个重要方面是其内置的清理机制。当使用 **toSignal** 的组件或服务被销毁时,Angular 会自动取消对可观察对象的订阅。这种自动清理功能可确保关闭未使用的订阅,从而防止潜在的内存泄漏,这在大型或复杂的应用中尤其有用,因为手动管理订阅可能具有挑战性。通过使用 **toSignal**,开发人员可以创建反应灵敏且高效的应用,同时保持代码的干净易管理。
例子:
import { toSignal } from '@angular/core/rxjs-interop; private readonly httpClient = inject(HttpClient); private products$ = this.httpClient.get('api/endpoint/'); // Get a `Signal` representing the `products$`'s value. productList = toSignal(this.products$);
将信号转换为可观测值
Angular 提供了一个 **toObservable** 函数来将信号转换为可观察对象,从而可以轻松地在需要可观察对象的响应式环境中利用信号。当您需要使用需要以可观察对象形式接收数据的其他库或组件时,此转换特别有用。
**toObservable** 函数通过 **effect** 监控信号的值来运行。每当信号的值发生变化时,**toObservable** 都会捕获这些变化并将它们作为可观察值发出。但是,为了防止不必要的排放,**toObservable** 仅在信号快速连续多次更新时才发出最终的稳定值。这可确保只传播有意义的变化,保持可观察流的高效并避免冗余处理。
例子:
fruitList = signal(['apple', 'orange', 'grapes']); fruitList$: Observable= toObservable(this.fruitList);
使用 Angular 信号的示例应用
让我们使用真实应用程序来理解信号的概念。我们将创建一个电子商务应用程序的迷你版本。
运行以下命令来创建 Angular 应用程序。
ng new signals-demo
运行以下命令并按照屏幕上的说明为您的项目设置 Angular Material。
ng add @angular/material
在 **src\app** 文件夹中添加一个名为 **model** 的新文件夹。在 models 文件夹中创建一个名为 **product.ts** 的新文件,并添加以下代码。
export interface Product { id: number; title: string; images: string[]; price: number; } export interface ProductResponse { limit: number; skip: number; total: number; products: Product[]; }
在 **models** 文件夹中添加一个名为 **shopping-cart.ts** 的文件,并将以下代码放入其中。
import { Product } from './product'; export interface ShoppingCart { product: Product; quantity: number; }
使用以下命令添加新的服务文件。
ng g s services\product
更新**\app\services\product.service.ts**文件中的**ProductService**类,如下所示。
export class ProductService { private readonly httpClient = inject(HttpClient); private products$ = this.httpClient.get( 'https://dummyjson.com/products?limit=8' ); cartItemsList = signal ([]); productList = toSignal(this.products$); }
**cartItemsList** 是一个公共属性,它保存着一个空的 **Product** 对象数组的信号。**productList** 是一个公共属性,它保存着一个从 **products$** 可观察对象转换而来的信号。
该服务从公开可用的 API 中获取产品列表,并为产品和购物车项目列表提供反应信号。
使用以下命令创建一个新组件。
ng g c components\product-list
更新**src\app\components\product-list\product-list.component.ts**文件中的**ProductListComponent**类,如下所示。
export class ProductListComponent { private readonly productService = inject(ProductService); protected productList = this.productService.productList; addItemToCart(id: number) { const selectedItem = this.productList()?.products.find( (product) => product.id === id ); this.productService.cartItemsList.update((cartItems) => { if (selectedItem) cartItems?.push(selectedItem); return cartItems; }); } }
**ProductListComponent** 负责显示产品列表并允许用户将产品添加到购物车中。**addItemToCart** 函数在产品列表中搜索具有特定 ID 的产品。找到产品后,它会将其添加到购物车商品列表中并更新它以包含新添加的产品。
在**src\app\components\product-list\product-list.component.html**文件中添加以下代码。
@for (product of productList()?.products; track $index) {} ![]()
{{ product.title }}
此模板使用 Angular Material 卡片以响应式网格布局呈现产品列表。每张卡片显示产品图片、标题和 **添加到购物车** 按钮。**@for** 指令循环遍历产品并动态生成卡片。
使用以下命令创建购物车组件。
ng g c components\cart
更新 **src\app\components\cart\cart.component.ts** 文件中的 **CartComponent** 类,如下所示:
export class CartComponent { private readonly productService = inject(ProductService); displayedColumns: string[] = ['name', 'quantity', 'price']; protected finalCartItems = computed(() => { const shoppingCart: ShoppingCart[] = []; this.productService.cartItemsList().map((item) => { const index = shoppingCart.findIndex( (finalCart) => item.id === finalCart.product.id ); if (index > -1) { shoppingCart[index].quantity += 1; } else { shoppingCart.push({ product: item, quantity: 1 }); } }); return shoppingCart; }); protected totalCost = computed(() => { return this.finalCartItems().reduce((acc, item) => { return acc + item.product.price * item.quantity; }, 0); }); }
**finalCartItems** 计算信号通过合并具有相同 ID 的商品数量来汇总购物车商品。同时,**totalCost** 计算信号通过对每件商品的价格和数量的乘积求和来计算购物车中商品的总成本。
在**src\app\components\cart\cart.component.html**文件中添加以下代码。
@if(finalCartItems().length > 0){}@else {
Name {{ element.product.title }} Quantity {{ element.quantity }} Total Cost Price {{ element.product.price | currency }} {{ totalCost() | currency }} No data found
}
此模板使用 Angular Material 组件呈现购物车。如果购物车中有商品,它会有条件地显示购物车商品表,其中显示商品名称、数量和价格。如果购物车为空,则会显示 **未找到数据** 消息。该表包括表头、数据和表尾行,其中表尾显示购物车中商品的总价格。
使用以下命令创建导航栏组件。
ng g c components\nav-bar
更新 **src\app\components\nav-bar\nav-bar.component.ts** 文件中的 **NavBarComponent** 类:
export class NavBarComponent { private readonly productService = inject(ProductService); cartItemsList = this.productService.cartItemsList; }
在**src\app\components\nav-bar\nav-bar.component.html**文件中添加以下代码。
此模板创建一个带有主页和购物车按钮的导航栏。主页按钮导航到主页并显示主页图标。购物车按钮显示购物车图标和带有购物车中商品数量的徽章。
更新**app.route.ts**文件中的路由。
import { Routes } from '@angular/router'; import { ProductListComponent } from './components/product-list/product-list.component'; import { CartComponent } from './components/cart/cart.component'; export const routes: Routes = [ { path: '', component: ProductListComponent }, { path: 'cart', component: CartComponent }, ];
更新**src\app\app.component.html**文件,如下所示。
最后,使用命令 **ng serve** 运行应用程序。您可以在此处查看输出。
概括
**Angular 信号** 为 Angular 应用中的反应式编程带来了简单性和精确性。通过提供一种同步、高效的状态管理方式,信号允许开发人员仅在必要时更新 UI,通过最大限度地减少冗余变更检测周期来提高性能。与非常适合处理异步数据流的**可观察对象**不同,**信号** 在将应用状态与 UI 同步方面表现出色,使其成为在响应式界面中管理实时数据的强大补充。
在本篇博文中,我们探讨了信号如何融入 Angular 的反应式生态系统、信号和可观察对象之间的差异以及何时使用每种方法。我们还讨论了信号的实际用例,例如使用计算值和效果以及将信号与可观察对象集成以建立灵活高效的反应式模型。
借助 Angular 信号,开发人员现在可以更好地控制应用的反应性,为未来无区域应用和更简化的变化检测奠定坚实的基础。随着 Angular 的不断发展,掌握信号将成为构建高性能现代应用的关键。