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 的性能和开发人员体验,有几个目标指导着框架的演变:

  • 高效的 UI 更新:核心目标是通过仅同步需要更改的界面部分来实现更高效的 UI 更新。这意味着将更新定位到单个组件,而不是在整个视图层次结构中运行更改检测。这种精确度将显著提高性能,尤其是在具有多个组件的复杂应用中。
  • 无区域应用程序:另一个目标是消除对 zone.js 的依赖,旨在消除与之相关的开销和偶尔出现的怪癖。没有 zone.js,Angular 可以简化其更新检测并减少依赖性,从而打造更轻量且更易于维护的应用程序。
  • 通过删除 zone.js 来减少包大小:通过删除 zone.js,Angular 可以将主包大小减少多达 100 KB,这可以显著缩短加载时间。这种减少对于移动设备上的性能以及互联网连接较慢的用户尤其有用。
  • 简化的生命周期钩子和组件查询:Angular 致力于简化其生命周期钩子和组件查询,使其更易于理解和使用。这将降低开发人员的学习曲线并提高生产力,尤其是对于刚接触该框架的开发人员而言。
  • 原生 async/await 支持:另一个目标是让 Angular 能够原生处理 async 和 await 操作,而无需将其转换为 Promise。这一变化将绕过 zone.js 的限制,让 Angular 能够更顺畅地使用现代 JavaScript 功能,从而生成更高效、更易读的异步代码。
  • 这些改进旨在使 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()**。每种方法都有其独特的用途:

  • set():此方法允许您直接为信号分配新值,有效地替换先前的值。当您想用新值覆盖现有值而不需要引用信号的当前状态时,set() 是理想的选择。
  • update():当新值取决于信号的当前值时,使用 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** 函数创建的,并且只要其依赖的任何信号发生变化,就会自动重新运行。

    下面我们来深入了解一下它们的主要特征:

  • 始终至少运行一次:效果在创建后立即执行,以确保其响应其跟踪信号的初始值。然后,每当一个或多个相关信号更新时,它就会重新运行,确保效果与变化的数据保持同步。
  • 动态依赖性跟踪:与计算信号一样,效果使用动态依赖性跟踪。这意味着在效果执行期间读取的任何信号都将成为依赖项。如果这些信号将来发生变化,效果会自动重新运行,从而让副作用保持最新状态,而无需手动重新订阅。
  • 异步执行:效果作为 Angular 变更检测过程的一部分运行,从而能够与 Angular 的反应式数据流顺利集成。效果中的异步任务(例如 API 调用或超时)可以在框架内自然管理,从而避免意外延迟或竞争条件。
  • 自动清理:当效果与组件、指令或服务绑定时,当上下文从 DOM 中移除时,效果会自动销毁。此清理可确保效果在其上下文不再活跃后不会持续存在,从而有助于防止内存泄漏。
  • 假设我们有一个 **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 信号中的影响可能不是每个应用都需要的,但它们在需要管理副作用以响应反应状态变化的特定场景中非常有用。以下是使用影响特别有益的一些情况:

  • 记录数据:效果可以记录信号的变化,提供一种直接的方法来跟踪应用程序随时间的变化状态。
  • 与本地存储同步:效果非常适合将应用状态与 localStorage 或其他持久存储解决方案保持同步。每当信号发生变化时,都可以触发效果来更新本地存储中的相应值,确保数据在会话之间得到保留。
  • 自定义 DOM 行为:在某些情况下,开发者可能需要为 DOM 中 Angular 本身无法处理的元素实现自定义行为。可以使用效果根据信号变化直接操作 DOM,从而实现动态更新,而无需完全依赖 Angular 的模板系统。
  • 自定义渲染:效果可以方便与第三方 UI 库或渲染工具(如元素、图表或图形库)集成。当控制渲染参数的信号发生变化时,效果可以重新渲染这些视觉元素,从而提供无缝且动态的用户体验。
  • 何时不使用效果

    虽然效果在 Angular 信号中提供了有价值的功能,但在某些情况下应避免使用它们以保持应用的稳定和高效。以下是何时不使用效果的关键注意事项:

  • 状态变更传播:不应使用效果在信号之间传播状态变更。这样做可能会导致常见的陷阱,例如 ExpressionChangedAfterItHasBeenChecked 错误,当 Angular 在运行变更检测后检测到某个值已更改时,就会发生这种错误。这可能会在应用流程中造成混乱并导致行为不稳定。
  • 无限循环更新:使用效果来触发相互依赖的信号中的更新可能会造成无限循环。例如,假设一个效果修改了一个信号,而该信号又触发了相同的效果。在这种情况下,它可能会导致无限的更新循环,从而导致性能问题并可能导致应用崩溃。
  • 信号写入限制:默认情况下,Angular 会阻止在效果内设置信号,以避免前面提到的问题。虽然可以使用 allowSignalWrites 标志覆盖此限制,但建议仅在开发人员有信心管理潜在复杂性的特殊情况下才这样做。误用此功能很容易导致与状态更改传播相关的问题。
  • 改用计算信号:对于依赖于其他信号的状态,通常最好使用计算信号而不是效果。计算信号旨在有效管理派生值,确保它们根据依赖项的变化自动更新。这种方法提供了更顺畅、更可预测的反应模型,而没有效果可能带来的复杂性。
  • 信号与可观察量

    信号和可观察对象在管理 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 image

    {{ 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){
    Name {{ element.product.title }} Quantity {{ element.quantity }} Total Cost Price {{ element.product.price | currency }} {{ totalCost() | currency }}
    }@else {

    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 的不断发展,掌握信号将成为构建高性能现代应用的关键。

    相关博客

  • Syncfusion Essential® UI Kit for Angular:使用可自定义的块加速 UI 开发
  • Angular 18 中的无区域变化检测:提升性能
  • Angular 19 有什么新功能?
  • 如何使用 Angular Proxy 解决 CORS 错误?