Angular-学习指南第五版-四-

75 阅读39分钟

Angular 学习指南第五版(四)

原文:zh.annas-archive.org/md5/0c949428ebde6e02b5e977a141696e15

译者:飞龙

协议:CC BY-NC-SA 4.0

第十章:使用表单收集用户数据

网络应用程序使用表单从用户那里收集输入数据。用例多种多样,从允许用户登录、填写支付信息、预订航班,甚至执行搜索。表单数据可以稍后保存在本地存储中或通过后端 API 发送到服务器。

在本章中,我们将介绍以下关于表单的主题:

  • 介绍网络表单

  • 构建模板驱动表单

  • 构建响应式表单

  • 使用表单构建器

  • 在表单中验证输入

  • 操作表单状态

技术要求

本章包含各种代码示例,引导你创建和管理 Angular 中的表单。你可以在以下 GitHub 仓库的ch10文件夹中找到相关源代码:

www.github.com/PacktPublishing/Learning-Angular-Fifth-Edition

介绍网络表单

表单通常具有以下特性,这些特性可以增强网络应用程序的用户体验:

  • 定义不同类型的输入字段

  • 设置不同类型的验证并向用户显示验证错误

  • 如果表单处于错误状态,支持不同的数据处理策略

Angular 框架提供了两种处理表单的方法:模板驱动响应式。两种方法都没有被认为是更好的;你必须选择最适合你场景的方法。两种方法之间的主要区别在于它们管理数据的方式:

  • 模板驱动表单:这些表单易于设置并添加到 Angular 应用程序中。它们仅通过组件模板来创建元素和配置验证规则;因此,它们不易于测试。它们还依赖于框架的变更检测机制。

  • 响应式表单:在扩展和测试时更为稳健。它们在组件类中操作,以管理输入控件和设置验证规则。它们还使用中间表单模型来操作数据,保持其不可变性质。如果你广泛使用响应式编程技术,或者你的 Angular 应用程序包含许多表单,那么这项技术适合你。

网络应用程序中的表单由一个包含用于输入数据的 HTML 元素(如<input><select>元素)以及用于与数据交互的<button>元素的<form>HTML 元素组成。表单可以本地检索和保存数据,或将其发送到服务器进行进一步处理。以下是一个用于在 Web 应用程序中登录用户的简单表单示例:

<form> 
  <div>
    <input type="text" name="username" placeholder="Username" /> 
  </div> 
  <div>
    <input type="password" name="password" placeholder="Password" /> 
  </div> 
  <button type="submit">Login</button> 
</form> 

前面的表单有两个<input>元素:一个用于输入用户名,另一个用于输入密码。password字段的类型设置为password,这样在输入时输入控件的内容是不可见的。<button>元素的类型设置为submit,这样表单可以通过用户点击按钮或按下任何输入控件上的Enter键来收集数据。

如果我们想要重置表单数据,可以添加另一个具有 reset 类型的按钮。

注意,一个 HTML 元素必须位于 <form> 元素内部,才能成为其一部分。以下截图显示了在页面上渲染的表单外观:

图形用户界面,文本,应用程序  自动生成的描述

图 10.1:登录表单

通过使用提供如输入控件中的自动完成或提示用户保存敏感数据等功能的表单,Web 应用程序可以显著提升用户体验。现在我们已经了解了 Web 表单的外观,让我们学习所有这些如何在 Angular 框架中结合在一起。

构建模板驱动的表单

模板驱动的表单是两种不同的将表单集成到 Angular 的方式之一。在需要为我们的 Angular 应用程序创建小型和简单表单的情况下,这些功能可能非常强大。

我们在 第三章使用组件构建用户界面 中学习了数据绑定,以及我们如何使用不同类型从 Angular 组件中读取数据并将其写入。在这种情况下,绑定可以是单向的或双向的,称为 单向绑定。在模板驱动的表单中,我们可以结合两种方式,创建一个可以同时读取和写入数据的 双向绑定。模板驱动的表单提供了 ngModel 指令,我们可以在我们的组件中使用它来获得这种行为。要了解更多关于模板驱动的表单,我们将把产品详情组件的更改价格功能转换为与 Angular 表单一起工作。

为了跟随本章的其余部分,你需要我们创建在 第九章使用路由导航应用程序 中的 Angular 应用程序的源代码。

让我们开始吧:

  1. 打开 product-detail.component.ts 文件,并添加以下 import 语句:

    import { FormsModule } from '@angular/forms'; 
    

我们使用来自 @angular/forms npm 包的 FormsModule 类将模板驱动的表单添加到 Angular 应用程序中。

  1. @Component 装饰器的 imports 数组中添加 FormsModule

    @Component({
      selector: 'app-product-detail',
      imports: [CommonModule, **FormsModule**],
      templateUrl: './product-detail.component.html',
      styleUrl: './product-detail.component.css'
    }) 
    
  2. 打开 product-detail.component.html 文件,并按如下方式修改 <input> 元素:

    <input placeholder="New price" type="number" name="price" [(ngModel)]="product.price" /> 
    

在前面的代码片段中,我们将 product 模板变量的 price 属性绑定到 <input> 元素的 ngModel 指令。name 属性是必需的,这样 Angular 可以在内部创建一个唯一的表单控件来区分它。

ngModel 指令的语法被称为 香蕉盒,我们通过以下两个步骤创建它。首先,我们通过括号 ()ngModel 包围起来制作出 香蕉。然后,我们通过方括号 [()] 将它放入 盒子 中。

  1. 修改 <button> 元素如下:

    <button class="secondary" type="submit">Change</button> 
    

在前面的代码片段中,我们从 <button> 元素中移除了 click 事件,因为提交表单将更新价格。我们还添加了 submit 类型来表示表单提交可以通过用户点击按钮来实现。

  1. <input><button> 元素包裹在以下 <form> 元素中:

    **<form (ngSubmit)="changePrice(product)">**
      <input placeholder="New price" type="number" name="price" [(ngModel)]="product.price" />
      <button class="secondary" type="submit">Change</button>
    **</form>** 
    

在前面的代码片段中,我们将 changePrice 方法绑定到表单的 ngSubmit 事件。如果我们在输入框内按下 Enter 键或点击按钮,绑定将触发方法执行。ngSubmit 事件是 Angular FormsModule 的一部分,它挂钩于 HTML 表单的本地 submit 事件。

  1. 打开 product-detail.component.ts 文件,并按如下方式修改 changePrice 方法:

    changePrice(product: Product) {
      this.productService.updateProduct(
        product.id,
        product.price
      ).subscribe(() => this.router.navigate(['/products']));
    } 
    
  2. 使用 ng serve 命令运行应用程序,并从列表中选择一个产品。

  3. 你会注意到当前产品价格已经显示在输入框中。尝试更改价格,你会注意到当你键入时,产品的当前价格也在变化:

img

图 10.2:双向绑定

前面图像中所示的应用程序行为是双向绑定和 ngModel 的魔法所在。

当 AngularJS 在 2010 年推出时,双向绑定是其最大的卖点。在当时使用纯 JavaScript 和 jQuery 实现该行为是复杂的。

当我们在输入框中键入时,ngModel 指令会更新产品价格的价值。新价格会直接反映在模板中,因为我们使用了 Angular 插值语法来显示其值。

在我们的案例中,在输入新价格的同时更新当前产品价格是一种糟糕的用户体验。用户应该能够始终查看产品的当前价格。我们将修改产品详情组件,以便正确显示价格:

  1. 打开 product-detail.component.ts 文件,并在 ProductDetailComponent 类中创建一个 price 属性:

    price: number | undefined; 
    
  2. changePrice 方法修改为使用 price 组件属性:

    changePrice(product: Product) {
      this.productService.updateProduct(
        product.id,
        **this.price!**
      ).subscribe(() => this.router.navigate(['/products']));
    } 
    
  3. 打开 product-detail.component.html 文件,并将 <input> 元素中的绑定替换为使用新的组件属性:

    <input placeholder="New price" type="number" name="price" [(ngModel)]="**price**" /> 
    

如果我们运行应用程序并尝试在 新价格 输入框中输入新价格,我们会注意到显示的当前价格没有变化。更改价格的功能也像以前一样正常工作。

我们已经看到,当创建小型和简单的表单时,模板驱动的表单非常有用。在下一节中,我们将更深入地探讨 Angular 框架提供的另一种方法:响应式表单。

构建响应式表单

如其名所示,响应式表单能够动态地提供对网页表单的访问。它们是考虑到响应性而构建的,其中输入控件及其值可以通过可观察流进行操作。它们还保持表单数据的不可变状态,这使得它们更容易测试,因为我们有信心可以明确且一致地修改表单状态。

响应式表单采用程序化方法来创建表单元素和设置验证规则,通过在组件类中设置一切来实现。在此方法中涉及的 Angular 关键类如下:

  • FormControl:表示单个表单控件,例如 <input> 元素。

  • FormGroup:表示一组表单控件。<form> 元素是响应式表单层次结构中最顶层的 FormGroup

  • FormArray:表示一组表单控件,就像 FormGroup 一样,但可以在运行时进行修改。例如,我们可以根据需要动态添加或删除 FormControl 对象。

前面的类都来自 @angular/forms npm 包,并包含可用于以下场景的属性:

  • 根据表单或控件的状态渲染不同的 UI

  • 检查我们是否与表单或控件进行了交互

我们将通过 Angular 应用程序中的示例来探索每个表单类。在下一节中,我们将使用产品创建组件在我们的应用程序中介绍响应式表单:

与响应式表单交互

我们构建的 Angular 应用程序包含一个用于添加新产品的组件。该组件使用模板引用变量来收集输入数据。我们将使用 Angular 表单 API 通过响应式表单来完成相同任务:

  1. 打开 product-create.component.ts 文件并添加以下 import 语句:

    import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; 
    
  2. @Component 装饰器的 imports 数组中添加 ReactiveFormsModule 类:

    @Component({
      selector: 'app-product-create',
      imports: [**ReactiveFormsModule**],
      templateUrl: './product-create.component.html',
      styleUrl: './product-create.component.css'
    }) 
    

Angular 表单库提供了 ReactiveFormsModule 类,用于在 Angular 应用程序中创建响应式表单。

  1. ProductCreateComponent 类中定义以下 productForm 属性:

    productForm = new FormGroup({
      title: new FormControl('', { nonNullable: true }),
      price: new FormControl<number | undefined>(undefined, { nonNullable: true }),
      category: new FormControl('', { nonNullable: true })
    }); 
    

FormGroup 构造函数接受一个包含表单控件键值对的对象。键是唯一的控件名称,值是 FormControl 实例。FormControl 构造函数接受控件在第一个参数中的默认值。对于 titlecategory 控件,我们传递一个空字符串,这样我们就不设置任何初始值。对于应该接受数字作为值的 price 控件,我们将其初始值设置为 undefined。传递给 FormControl 的第二个参数是一个对象,它将 nonNullable 属性设置为指示控件不接受空值。

  1. 在我们创建了表单组和其控件之后,我们需要将它们与模板中相应的 HTML 元素关联起来。打开 product-create.component.html 文件,并在 <input><select><button> HTML 元素周围添加以下 <form> 元素:

    **<form [formGroup]="productForm">**
      <div>
        <label for="title">Title</label>
        <input id="title" #title />
      </div>
      <div>
        <label for="price">Price</label>
        <input id="price" #price type="number" />
      </div>
      <div>
        <label for="category">Category</label>
        <select id="category" #category>
          <option>Select a category</option>
          <option value="electronics">Electronics</option>
          <option value="jewelery">Jewelery</option>
          <option>Other</option>
        </select>
      </div>
      <div>
        <button (click)="createProduct(title.value, price.value, category.value)">Create</button>
      </div>
    **</form>** 
    

在前面的模板中,我们使用从 ReactiveFormsModule 类导出的 formGroup 指令将 FormGroup 实例连接到 <form> 元素。

  1. ReactiveFormsModule 类还导出了 formControlName 指令,我们使用它将 FormControl 实例连接到 HTML 元素。按照以下方式修改表单 HTML 元素:

    <div>
      <label for="title">Title</label>
      <input id="title" **formControlName="title"** />
    </div>
    <div>
      <label for="price">Price</label>
      <input id="price" **formControlName="price"** type="number" />
    </div>
    <div>
      <label for="category">Category</label>
      <select id="category" **formControlName="category"**>
        <option>Select a category</option>
        <option value="electronics">Electronics</option>
        <option value="jewelery">Jewelery</option>
        <option>Other</option>
      </select>
    </div> 
    

在前面的代码片段中,我们将formControlName指令的值设置为相应的FormControl实例的名称。我们还删除了模板引用变量,因为我们可以直接从FormGroup实例获取它们的值。

  1. 根据需要在product-create.component.ts文件中修改createProduct方法:

    createProduct() {
      this.productsService.addProduct(this.productForm.value).subscribe(() => {
        this.router.navigate(['/products']);
      });
    } 
    

在前面的方法中,我们使用FormGroup类的value属性来获取表单值。

注意,value属性不包括表单禁用字段的值。相反,我们可以使用getRawValue方法来返回所有字段的值。

在这种情况下,我们可以使用表单值,因为表单模型与Product接口相同。

如果它不同,我们可以使用FormGroup类的controls属性来单独获取表单控件值,如下所示:

createProduct() {
  this.productsService.addProduct({
    **title: this.productForm.controls.title.value,**
 **price: this.productForm.controls.price.value,**
 **category: this.productForm.controls.category.value**
  }).subscribe(() => {
    this.router.navigate(['/products']);
  });
} 

FormControl类包含一个value属性,它返回表单控件的值。

  1. product-create.component.html文件中修改<form>元素,以便在表单提交时创建新产品:

    <form [formGroup]="productForm" **(ngSubmit)="createProduct()"**>
      <div>
        <label for="title">Title</label>
        <input id="title" formControlName="title" />
      </div>
      <div>
        <label for="price">Price</label>
        <input id="price" formControlName="price" type="number" />
      </div>
      <div>
        <label for="category">Category</label>
        <select id="category" formControlName="category">
          <option>Select a category</option>
          <option value="electronics">Electronics</option>
          <option value="jewelery">Jewelery</option>
          <option>Other</option>
        </select>
      </div>
      <div>
        <button **type="submit"**>Create</button>
      </div>
    </form> 
    
  2. 打开全局styles.css文件并添加以下 CSS 样式:

    label {
      margin-bottom: 4px;
      display: block;
    } 
    

我们希望前面的样式在全局范围内可用,因为我们将在本章后面的购物车组件中使用它们。

  1. 打开product-create.component.css文件并删除<label>标签的样式。

如果我们运行应用程序,我们会看到添加新产品的功能仍然按预期工作。

我们了解到FormGroup类将一组表单控件分组。表单控件可以是一个单独的表单控件或另一个表单组,正如我们将在下一节中看到的。

创建嵌套表单层次结构

产品创建组件由一个包含三个表单控件的单一表单组组成。在企业应用程序中,某些用例需要更高级的表单,这些表单涉及创建嵌套的表单组层次结构。考虑以下表单,它用于添加新产品及其附加详细信息:

包含文本的图片,屏幕截图,字体,编号  自动生成的描述

图 10.3:带有附加信息的新产品表单

前面的表单可能看起来像一个单一的表单组,但如果我们深入查看组件类,我们会看到productForm由两个FormGroup实例组成,一个嵌套在另一个内部:

productForm = new FormGroup({
  title: new FormControl('', { nonNullable: true }),
  price: new FormControl<number | undefined>(undefined, { nonNullable: true }),
  category: new FormControl('', { nonNullable: true }),
  **extra: new FormGroup({**
    **image: new FormControl(''),**
    **description: new FormControl('')**
  **})**
}); 

productForm属性是父表单组,而extra是其子项。一个父表单组可以有它需要的任意多个子表单组。如果我们查看组件模板,我们会看到子表单组与父表单组定义不同:

<form [formGroup]="productForm" (ngSubmit)="createProduct()">
  <div>
    <label for="title">Title</label>
    <input id="title" formControlName="title" />
  </div>
  <div>
    <label for="price">Price</label>
    <input id="price" formControlName="price" type="number" />
  </div>
  <div>
    <label for="category">Category</label>
    <select id="category" formControlName="category">
      <option>Select a category</option>
      <option value="electronics">Electronics</option>
      <option value="jewelery">Jewelery</option>
      <option>Other</option>
    </select>
  </div>
  <h2>Additional details</h2>
  **<form formGroupName="extra">**
    **<div>**
      **<label for="descr">Description</label>**
      **<input id="descr" formControlName="description" />**
    **</div>**
    **<div>**
      **<label for="photo">Photo URL</label>**
      **<input id="photo" formControlName="image" />**
    **</div>**
  **</form>**
  <div>
    <button type="submit">Create</button>
  </div>
</form> 

在前面的 HTML 模板中,我们使用formGroupName指令将内部表单元素绑定到extra属性。

你可能期望直接将其绑定到 productForm.extra 属性,但 Angular 非常聪明,因为它理解 extraproductForm 的子表单组。它能推断出这个信息,因为与 extra 相关的表单元素位于绑定到 productForm 属性的表单元素内部。

在嵌套表单层次结构中,子表单组的值与其父表单共享。在我们的例子中,extra 表单组的值将包含在 productForm 组中,从而保持一致的表单模型。

我们已经涵盖了 FormGroupFormControl 类。在下一节中,我们将学习如何使用 FormArray 类与动态表单进行交互。

动态修改表单

考虑以下场景:我们在我们的电子商务应用的购物车中添加了一些产品,并想在结账前更新它们的数量。

目前,我们的应用程序没有购物车的任何功能,因此我们现在将添加一个:

  1. 运行以下命令以创建 Cart 接口:

    ng generate interface Cart 
    
  2. 打开 cart.ts 文件,并按如下方式修改 Cart 接口:

    export interface Cart {
     **id: number;**
      **products: { productId :number }[];**
    } 
    

在前面的代码片段中,products 属性将包含属于当前购物车的产品 ID。

  1. 通过运行以下 Angular CLI 命令创建一个新的服务来管理购物车:

    ng generate service cart 
    
  2. 打开 cart.service.ts 文件,并按如下方式修改 import 语句:

    import { Injectable, **inject** } from '@angular/core';
    **import { HttpClient } from '@angular/common/http';**
    **import { Observable, defer, map } from 'rxjs';**
    **import { Cart } from './cart';**
    **import { APP_SETTINGS } from './app.settings';** 
    
  3. CartService 类中创建以下属性:

    cart: Cart | undefined;
    private cartUrl = inject(APP_SETTINGS).apiUrl + '/carts'; 
    

cartUrl 属性用于 Fake Store API 的购物车端点,而 cart 属性用于保存用户购物车的本地缓存。

  1. constructor 中注入 HttpClient 服务:

    constructor(**private http: HttpClient**) { } 
    
  2. 添加以下方法以将产品添加到购物车:

    addProduct(id: number): Observable<Cart> {
      const cartProduct = { productId: id, quantity: 1 };
    
      return defer(() =>
        !this.cart
        ? this.http.post<Cart>(this.cartUrl, { products: [cartProduct] })
        : this.http.put<Cart>(`${this.cartUrl}/${this.cart.id}`, {
          products: [
            ...this.cart.products,
            cartProduct
          ]
        })
      ).pipe(map(cart => this.cart = cart));
    } 
    

在前一种方法中,我们使用了一个名为 defer 的新 RxJS 操作符。defer 操作符在观察者中充当 if/else 语句的作用。

如果 cart 属性尚未初始化,这意味着我们的购物车目前为空,我们将向 API 发起一个 POST 请求,并将 cartProduct 变量作为参数传递。否则,我们将发起一个包含 cartProduct 以及购物车中现有产品的 PATCH 请求。

我们已经完成了服务的设置,使其能够与 Fake Store API 进行通信。现在,我们需要将服务与相应的组件连接起来:

  1. 打开 product-detail.component.ts 文件,并添加以下 import 语句:

    import { CartService } from '../cart.service'; 
    
  2. ProductDetailComponent 类中注入 CartService

    constructor(
      private productService: ProductsService,
      public authService: AuthService,
      private route: ActivatedRoute,
      private router: Router,
      **private cartService: CartService**
    ) { } 
    
  3. 修改 addToCart 方法,使其调用 CartService 类的 addProduct 方法:

    addToCart(**id: number**) {
      **this.cartService.addProduct(id).subscribe();**
    } 
    
  4. 最后,打开 product-detail.component.html 文件,并修改 Add to cart 按钮的 click 事件:

    <button (click)="addToCart(**product.id**)">Add to cart</button> 
    

我们已经实现了存储用户想要购买的产品的基本功能。现在,我们必须修改购物车组件以显示购物车项目:

  1. 打开 cart.component.ts 文件,并按如下方式修改 import 语句:

    import { Component, **OnInit** } from '@angular/core';
    **import {**
      **FormArray,**
      **FormControl,**
      **FormGroup,**
      **ReactiveFormsModule**
    **} from '@angular/forms';**
    **import { Product } from '../product';**
    **import { CartService } from '../cart.service';**
    **import { ProductsService } from '../products.service';** 
    
  2. @Component装饰器的imports数组中添加ReactiveFormsModule类:

    @Component({
      selector: 'app-cart',
      imports: [**ReactiveFormsModule**],
      templateUrl: './cart.component.html',
      styleUrl: './cart.component.css'
    }) 
    
  3. OnInit接口添加到CartComponent类的实现接口列表中:

    export class CartComponent **implements OnInit** 
    
  4. 在 TypeScript 类中创建以下属性:

    cartForm = new FormGroup({
      products: new FormArray<FormControl<number>>([])
    });
    products: Product[] = []; 
    

在前面的代码片段中,我们创建了一个包含products属性的FormGroup对象。我们将products属性的值设置为FormArray类的实例。FormArray类的构造函数接受一个参数,该参数是一个具有number类型的FormControl实例列表。目前这个列表是空的,因为购物车中没有产品。FormGroup实例外的products属性将用于查找原因,以显示购物车中每个产品的标题。

  1. 添加一个constructor来注入以下服务:

    constructor(
      private cartService: CartService,
      private productsService: ProductsService
    ) {} 
    
  2. 创建以下方法以从购物车获取产品:

    private getProducts() {
      this.productsService.getProducts().subscribe(products => {
        this.cartService.cart?.products.forEach(item => {
          const product = products.find(p => p.id === item.productId);
          if (product) {
            this.products.push(product);
          }
        });
      });
    } 
    

在前面的方法中,我们最初订阅了ProductsService类的getProducts方法以获取可用产品。然后,对于购物车中的每个产品,我们提取productId属性并检查它是否在购物车中存在。如果找到产品,我们就将其添加到products组件属性中。

  1. 创建另一个方法来构建我们的表单:

    private buildForm() {
      this.products.forEach(() => {
        this.cartForm.controls.products.push(
          new FormControl(1, { nonNullable: true })
        );
      });
    } 
    

在前面的方法中,我们遍历products属性并为products表单数组中的每个产品添加一个FormControl实例。我们将每个表单控件的值设置为1,以表示购物车默认包含每种产品的一个项目。

  1. 创建以下ngOnInit方法,该方法结合了步骤 6步骤 7

    ngOnInit(): void {
      this.getProducts();
      this.buildForm();
    } 
    
  2. 打开cart.component.html文件,并用以下内容替换其 HTML 模板:

    <div [formGroup]="cartForm">
      <div formArrayName="products">
        @for(product of cartForm.controls.products.controls; track $index) {
          <label>{{products[$index].title}}</label>
          <input [formControlName]="$index" type="number" />
        }
      </div>
    </div> 
    

在前面的模板中,我们使用@for块遍历products表单数组的controls属性并为每个创建一个<input>元素。我们使用@for块的$index关键字通过formControlName绑定动态地为每个表单控件提供一个名称。我们还添加了一个<label>标签,用于显示products组件属性中的产品标题。产品标题是通过使用数组中当前产品的$index获取的。

  1. 最后,打开cart.component.css文件并添加以下 CSS 样式:

    :host {
      width: 500px;
    }
    input {
      width: 50px;
    } 
    

要查看购物车组件的实际效果,请使用ng serve命令运行应用程序并将一些产品添加到购物车中。

不要忘记先登录,因为将产品添加到购物车的功能仅对认证用户可用。

在将一些产品添加到购物车后,点击我的购物车链接以查看您的购物车。它应该看起来像以下这样:

包含文本的图片、屏幕截图、字体、图表 自动生成的描述

图 10.4:购物车

由于我们已经为管理购物车建立了业务逻辑,我们也可以更新上一章中创建的结账守卫:

  1. 打开checkout.guard.ts文件并添加以下import语句:

    import { inject } from '@angular/core';
    import { CartService } from './cart.service'; 
    
  2. 使用以下语句在checkoutGuard函数中注入CartService类:

    const cartService = inject(CartService); 
    
  3. 修改checkoutGuard箭头函数的剩余部分,以便仅在购物车不为空时显示确认对话框:

    **if (cartService.cart) {**
      const confirmation = confirm(
        'You have pending items in your cart. Do you want to continue?'
      );
      return confirmation;
    **}**
    **return true;** 
    

使用FormArray,我们已经完成了对 Angular 表单最基本构建块的探索。我们学习了如何使用 Angular 表单类创建结构化 Web 表单并收集用户输入。在下一节中,我们将学习如何使用FormBuilder服务构建 Angular 表单。

使用表单构建器

使用表单类构建 Angular 表单可能会在复杂场景中变得重复和繁琐。Angular 框架提供了FormBuilder,这是一个内置的 Angular 表单服务,包含用于构建表单的辅助方法。让我们看看我们如何使用它来构建创建新产品的表单:

  1. 打开product-create.component.ts文件并导入OnInitFormBuilder组件:

    import { Component, **OnInit** } from '@angular/core';
    import { FormControl, FormGroup, ReactiveFormsModule, **FormBuilder** } from '@angular/forms'; 
    
  2. OnInit添加到ProductCreateComponent类实现的接口列表中:

    export class ProductCreateComponent **implements OnInit** 
    
  3. constructor中注入FormBuilder类:

    constructor(
      private productsService: ProductsService,
      private router: Router,
      **private builder: FormBuilder**
    ) {} 
    
  4. 按照以下方式修改productForm属性:

    productForm: FormGroup<{
      title: FormControl<string>,
      price: FormControl<number | undefined>,
      category: FormControl<string>
    }> | undefined; 
    

在前面的代码片段中,我们只定义了表单的结构,因为它现在将使用FormBuilder服务创建。

  1. 创建以下方法来构建表单:

    private buildForm() {
      this.productForm = this.builder.nonNullable.group({
        title: [''],
        price: this.builder.nonNullable.control<number | undefined>(undefined),
        category: ['']
      });
    } 
    

在前面的方法中,我们使用FormBuilder类的nonNullable属性创建一个不能为空的表单组。group方法用于组合表单控件。titlecategory表单控件使用空字符串作为默认值创建。price表单控件采用与其他不同的方法,因为我们不能因为 TypeScript 语言限制而分配undefined作为默认值。在这种情况下,我们使用nonNullable属性的control方法来定义表单控件。

  1. ngOnInit生命周期钩子添加到执行buildForm方法:

    ngOnInit(): void {
      this.buildForm();
    } 
    
  2. createProduct方法中访问productForm属性时添加非空断言运算符:

    createProduct() {
      this.productsService.addProduct(this.**productForm!**.value).subscribe(() => {
        this.router.navigate(['/products']);
      });
    } 
    
  3. 打开product-create.component.html文件,并在<form>HTML 元素中也添加非空断言运算符:

    <form [formGroup]="**productForm!**" (ngSubmit)="createProduct()">
      <div>
        <label for="title">Title</label>
        <input id="title" formControlName="title" />
      </div>
      <div>
        <label for="price">Price</label>
        <input id="price" formControlName="price" type="number" />
      </div>
      <div>
        <label for="category">Category</label>
        <select id="category" formControlName="category">
          <option>Select a category</option>
          <option value="electronics">Electronics</option>
          <option value="jewelery">Jewelery</option>
          <option>Other</option>
        </select>
      </div>
      <div>
        <button type="submit">Create</button>
      </div>
    </form> 
    

使用FormBuilder服务创建 Angular 表单时,我们不需要显式处理FormGroupFormControl数据类型,尽管底层正在创建这些类型。

使用ng serve命令运行应用程序,并验证新产品创建过程是否正确工作。尝试在不输入任何表单控件值的情况下点击创建按钮,并观察产品列表中的情况。应用程序创建了一个标题为空的产物。这是我们应在实际场景中避免的情况。我们应该意识到表单控件的状态并相应地采取行动。

本章其余部分的示例代码在处理响应式表单时没有使用FormBuilder服务。

在下一节中,我们将调查我们可以检查的不同属性,以获取表单状态并向用户提供反馈。

在表单中验证输入

一个 Angular 表单应该验证输入并提供视觉反馈以增强用户体验并指导用户成功完成表单。我们将探讨以下在 Angular 应用程序中验证表单的方法:

  • 使用 CSS 的全局验证

  • 组件类中的验证

  • 组件模板中的验证

  • 构建自定义验证器

在下一节中,我们将学习如何在 Angular 应用程序中使用 CSS 样式全局应用验证规则。

使用 CSS 的全局验证

Angular 框架在表单和模板驱动或响应式表单中自动设置以下 CSS 类,我们可以使用它们来提供用户反馈:

  • ng-untouched : 表示我们尚未与表单交互

  • ng-touched : 表示我们已与表单交互

  • ng-dirty : 表示我们已经向表单设置了一个值

  • ng-pristine : 表示我们尚未修改表单

此外,Angular 还会在表单控制的 HTML 元素上添加以下类:

  • ng-valid : 表示表单的值有效

  • ng-invalid : 表示表单的值无效

Angular 根据其状态在表单及其控件中设置上述 CSS 类。表单状态是根据其控件的状态评估的。例如,如果至少有一个表单控件无效,Angular 将设置ng-invalid CSS 类到表单和相应的控件。

在嵌套表单层次结构的情况下,子表单组的状态会冒泡到层次结构中,并与父表单共享。

我们可以使用内置的 CSS 类和仅使用 CSS 来样式化 Angular 表单。例如,为了在第一次与控件交互时在输入控件中显示浅蓝色高亮边框,我们应该添加以下样式:

input.ng-touched {
  border: 3px solid lightblue;
} 

我们还可以根据应用程序的需要组合 CSS 类:

  1. 打开全局的styles.css文件并按如下方式修改input.valid样式:

    input.valid, **input.ng-dirty.ng-valid** {
      border: solid green;
    } 
    

上述样式将在用户输入有效值时显示绿色边框。

  1. 根据需要修改input.invalid样式:

    input.invalid, **input.ng-dirty.ng-invalid** {
      border: solid red;
    } 
    

上述样式将在用户输入无效值时显示红色边框。

  1. 打开product-create.component.html文件并在<input>表单控件中添加required属性:

    <div>
      <label for="title">Title</label>
      <input id="title" formControlName="title" **required** />
    </div>
    <div>
      <label for="price">Price</label>
      <input id="price" formControlName="price" type="number" **required** />
    </div> 
    
  2. 使用ng serve命令运行应用程序并导航到http://localhost:4200/products/new

  3. 标题字段中输入一些文本并点击输入控件之外。注意它有一个绿色边框。

  4. 标题字段中删除文本并点击输入控件之外。现在边框应该变成红色。

我们学习了如何在模板中使用 CSS 样式定义验证规则。在下一节中,我们将学习如何在模板驱动的表单中定义它们,并使用适当的消息提供视觉反馈。

模板驱动的表单验证

在上一节中,我们了解到 Angular 在验证 Angular 表单时添加了一系列内置的 CSS 类。每个类在相应的表单模型中都有一个对应的布尔属性,无论是在模板驱动的表单还是响应式表单中:

  • untouched:表示我们尚未与表单交互

  • touched:表示我们已与表单交互

  • dirty:表示我们已经为表单设置了一个值

  • pristine:表示我们尚未修改表单

  • valid:表示表单的值有效

  • invalid:表示表单的值无效

我们可以利用前面的类来通知用户当前的表单状态。首先,让我们调查产品详情组件中更改价格过程的行为:

  1. 运行ng serve命令以启动应用程序并导航到http://localhost:4200

  2. 从列表中选择一个产品。

  3. 新价格输入框中输入一个0的值并点击更改按钮。

  4. 从列表中选择相同的产品并观察输出:

包含文本的图片,屏幕截图,字体描述自动创建

图 10.5:产品详情

组件的展示逻辑未能检测到用户可以为产品价格输入0。产品应该始终有一个价格。

产品详情组件需要验证价格值的输入,如果发现输入无效,则禁用更改按钮,并向用户显示一条信息消息。

处理验证是个人偏好或业务规范的问题。在这种情况下,我们决定通过禁用按钮并显示适当的消息来展示一种常见的验证方法。

模板驱动的验证是在组件模板中执行的。打开product-detail.component.html文件并执行以下步骤:

  1. 创建priceCtrl模板引用变量并将其绑定到ngModel属性:

    <input
      placeholder="New price"
      type="number"
      name="price"
      **#priceCtrl="ngModel"**
      [(ngModel)]="price" /> 
    

ngModel属性使我们能够访问底层表单控件模型。

  1. requiredmin验证属性添加到 HTML 元素:

    <input
      placeholder="New price"
      type="number"
      name="price"
      **required min="1"** 
      #priceCtrl="ngModel"
      [(ngModel)]="price" /> 
    

min验证属性只能与<input> HTML 元素中的number类型一起使用。它用于在数字控件使用箭头时定义最小值。

  1. 在表单的<button>元素下方添加以下<span>HTML 元素:

    @if (priceCtrl.dirty && (priceCtrl.invalid || priceCtrl.hasError('min'))) {
      <span class="help-text">Please enter a valid price</span>
    } 
    

当我们输入一个价格值然后留空或输入零时,将显示前面的 HTML 元素。我们使用表单控件模型的hasError方法来检查min验证是否抛出错误。

所有验证属性都可以使用 hasError 方法进行检查。控件的有效性状态是根据我们附加到 HTML 元素的所有验证属性的状态来评估的。

  1. <form> HTML 元素中添加一个 priceForm 模板引用变量,并将其绑定到 ngForm 属性:

    <form (ngSubmit)="changePrice(product)" **#priceForm="ngForm"**>
      <input
        placeholder="New price"
        type="number"
        name="price"
        required min="1"
        #priceCtrl="ngModel"
        [(ngModel)]="price" />
      <button class="secondary" type="submit">Change</button>
      @if (priceCtrl.dirty && (priceCtrl.invalid || priceCtrl.hasError('min'))) {
        <span class="help-text">Please enter a valid price</span>
      }    
    </form> 
    

ngForm 属性为我们提供了访问底层表单模型的权限。

  1. <button> HTML 元素的 disabled 属性绑定到表单模型的 invalid 状态:

    <button
      class="secondary"
      type="submit"
      **[disabled]="priceForm.invalid">**
      Change
    </button> 
    

    在前面的模板中,由于表单只有一个控件,我们可以直接绑定到 priceCtrl.invalid 状态。出于演示目的,我们选择表单。

  2. 打开 styles.css 文件,并为 <span> 标签和 disabled 按钮添加以下 CSS 样式:

    .help-text {
      display: flex;
      color: var(--hot-red);
      font-size: 0.875rem;
    }
    button:disabled {
      background-color: lightgrey;
      cursor: not-allowed;
    } 
    

为了验证验证是否按预期工作,执行以下步骤:

  1. 运行 ng serve 命令以启动应用程序,并从列表中选择一个产品。

  2. 新价格 输入框中输入 0 并观察输出:

包含文本的图片,屏幕截图,字体,编号  自动生成的描述

图 10.6:验证错误

  1. 输入一个有效值,并验证错误消息是否消失,以及 更改 按钮是否被启用。

  2. 新价格 输入框中留空,并验证错误消息是否再次显示,以及 更改 按钮是否被禁用。

现在我们已经学习了如何在模板驱动表单中完成验证,让我们看看如何验证响应式表单中的输入数据。

响应式表单的验证

模板驱动表单完全依赖于组件模板来执行验证。在响应式表单中,真相之源是我们组件 TypeScript 类中驻留的表单模型。我们在构建 FormGroup 实例时程序化地定义响应式表单中的验证规则。

为了演示响应式表单中的验证,我们将在产品创建组件中添加验证规则:

  1. 打开 product-create.component.ts 文件,并从 @angular/forms npm 包中导入 Validators 类:

    import {
      FormControl,
      FormGroup,
      ReactiveFormsModule,
      **Validators**
    } from '@angular/forms'; 
    
  2. 修改 productForm 属性的声明,以便 titleprice 表单控件在 FormControl 实例中传递一个 validators 属性:

    productForm = new FormGroup({
      title: new FormControl('', {
        nonNullable: true,
        **validators: Validators.required**
      }),
      price: new FormControl<number | undefined>(undefined, {
        nonNullable: true,
        **validators: [Validators.required, Validators.min(1)]**
      }),
      category: new FormControl('', { nonNullable: true })
    }); 
    

Validators 类包含每个可用验证规则的静态字段。它包含几乎与模板驱动表单中可用的相同验证规则。我们可以通过将它们添加到数组中,如 price 表单控件中的 validators 属性所示,来组合多个验证器。

当我们使用 FormControl 类添加验证器时,我们可以从 HTML 模板中删除相应的 HTML 属性。然而,出于可访问性的目的,建议保留它,以便屏幕阅读器应用程序可以使用它。

  1. 打开 product-create.component.html 文件,并使用 productForm 属性的 invalid 属性来禁用 创建 按钮:

    <button type="submit" **[disabled]="productForm.invalid"**>Create</button> 
    
  2. 在每个<input>表单控件中添加一个<span> HTML 元素,以在控件被触摸且required验证抛出错误时显示错误消息:

    <div>
      <label for="title">Title</label>
      <input id="title" formControlName="title" required />
      **@if (productForm.controls.title.touched && productForm.controls.title.invalid) {**
        **<span class="help-text">Title is required</span>**
      **}**
    </div>
    <div>
      <label for="price">Price</label>
      <input id="price" formControlName="price" type="number" required />
      **@if (productForm.controls.price.touched && productForm.controls.price.invalid) {**
    **<span class="help-text">Price is required</span>**
    **}**
    </div> 
    

在前面的代码片段中,我们使用productForm属性的controls属性来访问单个表单控件模型并获取它们的状态。

  1. 根据验证规则显示不同的消息会很方便。例如,当price控件的min验证抛出错误时,我们可以显示一个更具体的消息。我们可以使用前面章节中看到的hasError方法来显示这样的消息:

    <div>
      <label for="price">Price</label>
      <input id="price" formControlName="price" type="number" required />
      **@if (productForm.controls.price.touched && productForm.controls.price.hasError('required')) {**
        **<span class="help-text">Price is required</span>**
      **}**
      **@if (productForm.controls.price.touched && productForm.controls.price.hasError('min')) {**
        **<span class="help-text">Price should be greater than 0</span>**
      **}**
    </div> 
    

Angular 框架提供了一套内置验证器,我们已经在我们的表单中学习了如何使用它们。在下一节中,我们将学习如何为模板驱动和响应式表单创建自定义验证器以满足特定的业务需求。

构建自定义验证器

内置验证器可能无法涵盖我们在 Angular 应用程序中可能遇到的所有场景;然而,编写自定义验证器并在 Angular 表单中使用它是很容易的。在我们的例子中,我们将构建一个验证器来检查产品的价格不能超过指定的阈值。

我们可以使用内置的max验证器来完成同样的任务。然而,我们将为了学习目的构建验证器函数。

当我们想要使用自定义代码验证表单或控件时,会使用自定义验证器。例如,为了与 API 通信以验证值,或者执行复杂的计算以验证值。

  1. src\app文件夹中创建一个名为price-maximum.validator.ts的文件,并添加以下内容:

    import { ValidatorFn, AbstractControl, ValidationErrors } from '@angular/forms';
    export function priceMaximumValidator(price: number): ValidatorFn {
      return (control: AbstractControl): ValidationErrors | null => {
        const isMax = control.value <= price;
        return isMax ? null : { priceMaximum: true };
      };
    } 
    

表单验证器是一个返回包含指定错误或null值的ValidationErrors对象的函数。它接受将被应用到的表单控件作为参数。在前面的代码片段中,如果控件值大于通过导出函数的price参数传递的特定阈值,它将返回一个验证错误对象。否则,它返回null

验证错误对象的键指定了验证器错误的描述性名称。这是一个我们可以稍后通过控件的hasError方法进行检查的名称,以找出它是否有任何错误。验证错误对象的值可以是任何任意值,我们可以将其传递到错误消息中。

  1. 打开product-create.component.ts文件,并添加以下import语句:

    import { priceMaximumValidator } from '../price-maximum.validator'; 
    
  2. price表单控件的validators数组中添加验证器,并将阈值设置为1000

    price: new FormControl<number | undefined>(undefined, {
      nonNullable: true,
      validators: [
        Validators.required,
        Validators.min(1),
        **priceMaximumValidator(1000)**
      ]
    }) 
    
  3. product-create.component.html文件中为价格表单控件添加一个新的<span> HTML 元素:

    <div>
      <label for="price">Price</label>
      <input id="price" formControlName="price" type="number" required />
      @if (productForm.controls.price.touched && productForm.controls.price.hasError('required')) {
        <span class="help-text">Price is required</span>
      }
      @if (productForm.controls.price.touched && productForm.controls.price.hasError('min')) {
        <span class="help-text">Price should be greater than 0</span>
      }
      **@if (productForm.controls.price.touched && productForm.controls.price.hasError('priceMaximum')) {**
        **<span class="help-text">Price must be smaller or equal to 1000</span>**
      }
    </div> 
    
  4. 运行ng serve命令以启动应用程序并导航到http://localhost:4200/products/new

  5. 价格字段中输入1200的值,点击输入框外部,并观察输出结果:

包含文本的图像,屏幕截图,字体,编号  自动生成的描述

图 10.7:响应式表单中的验证

要在模板驱动的表单中使用价格最大值验证器,我们必须遵循不同的方法,该方法涉及创建 Angular 指令:

  1. 运行以下命令创建 Angular 指令:

    ng generate directive price-maximum 
    

前面的指令将作为我们已创建的priceMaximumValidator函数的包装器。

  1. 打开price-maximum.directive.ts文件并按如下方式修改import语句:

    import { Directive, **input, numberAttribute** } from '@angular/core';
    **import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator****} from '@angular/forms';** 
    **import { priceMaximumValidator } from './price-maximum.validator';** 
    
  2. @Directive装饰器中添加NG_VALIDATORS提供者:

    @Directive({
      selector: '[appPriceMaximum]',
      **providers: [**
        **{**
          **provide: NG_VALIDATORS,**
          **useExisting: PriceMaximumDirective,**
          **multi: true**
        **}**
      **]**
    }) 
    

NG_VALIDATORS令牌是 Angular 表单的内置令牌,它帮助我们注册 Angular 指令作为表单验证器。在上面的代码片段中,我们使用提供者配置中的multi属性,因为我们可以使用NG_VALIDATORS令牌注册多个指令。

  1. PriceMaximumDirective类的实现接口中添加Validator接口:

    export class PriceMaximumDirective **implements Validator** 
    
  2. 添加以下输入属性,该属性将用于传递最大阈值值:

    appPriceMaximum = input(undefined, {
      alias: 'threshold',
      transform: numberAttribute
    }); 
    

在前面的属性中,我们将包含两个属性配置对象作为input函数的参数。alias属性定义了我们将用于绑定的输入属性名称。transform属性用于将输入属性值转换为不同类型。numberAttribute是 Angular 框架的内置函数,它将输入属性值转换为数字。

Angular 还包含booleanAttribute函数,该函数将输入属性值解析为布尔值。

  1. 按如下方式实现Validator接口的validate方法:

    validate(control: AbstractControl): ValidationErrors | null {
      return this.appPriceMaximum
        ? priceMaximumValidator(this.appPriceMaximum()!)(control)
        : null;
    } 
    

validate方法的签名与priceMaximumValidator函数返回的函数相同。它检查appPriceMaximum输入属性,并根据情况将值委托给priceMaximumValidator函数。

我们将在产品详情组件中使用我们创建的新指令:

  1. 打开product-detail.component.ts文件并添加以下import语句:

    import { PriceMaximumDirective } from '../price-maximum.directive'; 
    
  2. @Component装饰器的imports数组中添加PriceMaximumDirective类:

    @Component({
      selector: 'app-product-detail',
      imports: [
        CommonModule,
        FormsModule,
        **PriceMaximumDirective**
      ],
      templateUrl: './product-detail.component.html',
      styleUrl: './product-detail.component.css'
    }) 
    
  3. 打开product-detail.component.html文件并在<input>HTML 元素中添加新的验证器:

    <input
      placeholder="New price"
      type="number"
      name="price"
      required min="1"
      **appPriceMaximum threshold="500"**
      #priceCtrl="ngModel"
      [(ngModel)]="price" /> 
    
  4. 添加一个新的<span>HTML 元素,当验证器抛出错误时显示不同的消息:

    @if (priceCtrl.dirty && priceCtrl.hasError('priceMaximum')) {
      <span class="help-text">Price must be smaller or equal to 500</span>
    } 
    
  5. 运行ng serve命令以启动应用程序并从列表中选择一个产品。

  6. 新价格输入框中输入值600并观察输出:

包含文本、屏幕截图、字体样式的自动生成的描述

图 10.8:模板驱动表单中的验证

Angular 自定义验证可以同步或异步工作。在本节中,我们学习了如何使用前者。异步验证是一个高级主题,我们不会在本书中涉及。然而,你可以在angular.dev/guide/forms/form-validation#creating-asynchronous-validators了解更多信息。

在下一节中,我们将探讨操作 Angular 表单的状态。

操作表单状态

Angular 表单的状态在模板驱动和响应式表单之间有所不同。在前者中,状态是一个普通对象,而在后者中,它保存在表单模型中。在本节中,我们将学习以下概念:

  • 更新表单状态

  • 对状态变化做出反应

我们将首先探讨如何更改表单状态。

更新表单状态

在模板驱动的表单中处理表单状态相对简单。我们必须与绑定到表单控件ngModel指令的组件属性进行交互。

在响应式表单中,我们可以使用FormControl实例的value属性或FormGroup类的以下方法来更改整个表单中的值:

  • setValue:替换表单中所有控件的值

  • patchValue:更新表单中特定控件的值

setValue方法接受一个对象作为参数,该对象包含所有表单控件的键值对。如果我们想以编程方式在产品创建组件中填写产品的详细信息,以下代码片段可以作为示例:

this.productForm.setValue({
  title: 'TV monitor',
  price: 600,
  category: 'electronics'
}); 

在前面的代码片段中,传递给setValue方法的对象中的每个键都必须与每个表单控件的名称匹配。如果我们省略一个,Angular 将抛出错误。

如果我们想填写一些产品的详细信息,可以使用patchValue方法:

this.productForm.patchValue({
  title: 'TV monitor',
  category: 'electronics'
}); 

FormGroup类的setValuepatchValue方法帮助我们设置表单中的数据。

表单的另一个有趣方面是,当这些值发生变化时,我们可以收到通知,正如我们将在下一节中看到的那样。

对状态变化做出反应

在使用 Angular 表单时,一个常见的场景是我们希望在表单控件的值变化时触发副作用。副作用可以是以下任何一种:

  • 要更改表单控件的值

  • 为了发起一个 HTTP 请求来过滤表单控件的值

  • 为了启用/禁用组件模板的某些部分

在模板驱动的表单中,我们可以使用ngModel指令的扩展版本来在值变化时得到通知。ngModel指令包含以下可绑定属性:

  • ngModel:一个输入属性,用于将值传递到控件

  • ngModelChange:当控件值变化时得到通知的输出属性

我们可以在产品详情组件的<input>HTML 元素中以下方式编写ngModel绑定:

<input
  placeholder="New price"
  type="number"
  name="price"
  required min="1"
  appPriceMaximum threshold="500"
  #priceCtrl="ngModel"
  **[ngModel]="price"**
  **(ngModelChange)="price = $event"** /> 

在前面的代码片段中,我们使用属性绑定设置了ngModel输入属性的值,并使用事件绑定设置了price组件属性的值。Angular 会自动触发ngModelChange事件,并将新值包含在$event属性中。当价格表单控件的值发生变化时,我们可以使用ngModelChange事件在我们的组件中进行任何副作用。

在响应式表单中,我们使用基于可观察的 API 来响应状态变化。FormGroupFormControl类包含valueChanges可观察流,我们可以使用它来订阅并在表单或控件的值发生变化时接收通知。

我们将使用它来在类别更改时重置产品创建组件中price表单控件的值:

  1. 打开product-create.component.ts文件并从@angular/core npm 包中导入OnInit实体:

    import { Component, **OnInit** } from '@angular/core'; 
    
  2. OnInit接口添加到ProductCreateComponent类实现的接口列表中:

    export class ProductCreateComponent **implements OnInit** 
    
  3. 创建以下ngOnInit方法以订阅category表单控件的valueChanges属性:

    ngOnInit(): void {
      this.productForm.controls.category.valueChanges.subscribe(() => {
        this.productForm.controls.price.reset();
      });
    } 
    

在前面的方法中,我们通过使用FormControl类的reset方法来重置price表单控件的值。

FormControl类的valueChanges属性是一个标准的可观察流。当组件被销毁时,不要忘记取消订阅。

当然,我们还可以使用valueChanges可观察流做更多的事情;例如,我们可以通过将其发送到后端 API 来检查产品标题是否已被预留。然而,希望前面的例子已经传达了如何利用表单的响应性特性并相应地做出反应。

摘要

在本章中,我们了解到 Angular 提供了两种不同的创建表单的方法——模板驱动和响应式——并且没有一种方法比另一种更好。我们探讨了如何构建每种类型的表单以及如何对输入数据进行验证,并涵盖了自定义验证以实现额外的验证场景。我们还学习了如何更新表单的状态以及当状态中的值发生变化时如何做出反应。

在下一章中,我们将探讨处理应用程序错误的各种方法。错误处理是 Angular 应用程序的一个非常重要的特性,并且可能具有不同的来源和原因,正如我们将看到的。

第十一章:处理应用程序错误

应用程序错误是网络应用程序生命周期的一个组成部分。它们可能发生在运行时或开发应用程序期间。运行时错误的可能原因包括失败的 HTTP 请求或不完整的 HTML 表单。网络应用程序必须处理运行时错误并减轻不良影响,以确保流畅的用户体验。

开发错误通常发生在我们没有根据其语义正确使用编程语言或框架的情况下。在这种情况下,错误可能会覆盖编译器并在运行时在应用程序中暴露出来。通过遵循最佳实践和推荐的编码技术可以减轻开发错误。

在本章中,我们将学习如何在 Angular 应用程序中处理不同类型的错误,并理解来自框架本身的错误。我们将更详细地探讨以下概念:

  • 处理运行时错误

  • 揭秘框架错误

技术要求

本章中描述的代码示例可以在以下 GitHub 仓库的 ch11 文件夹中找到:

www.github.com/PacktPublishing/Learning-Angular-Fifth-Edition

处理运行时错误

在 Angular 应用程序中,最常见的运行时错误来自于与 HTTP API 的交互。输入错误的登录凭证或以错误格式发送数据可能导致 HTTP 错误。Angular 应用程序可以通过以下方式处理 HTTP 错误:

  • 在执行特定 HTTP 请求期间显式处理

  • 在应用程序的全局错误处理器中全局处理

  • 使用 HTTP 拦截器集中处理

在以下部分,我们将探讨如何处理特定 HTTP 请求中的 HTTP 错误。

捕获 HTTP 请求错误

处理 HTTP 请求中的错误通常需要手动检查错误响应对象中返回的信息。RxJS 提供了 catchError 操作符来简化这个过程。它可以在使用 pipe 操作符发起 HTTP 请求时捕获潜在的错误。

要跟随本章的其余部分,您需要我们在第十章 使用表单收集用户数据 中创建的 Angular 应用程序的源代码。

让我们看看我们如何使用 catchError 操作符来捕获在应用程序中获取产品列表时的 HTTP 错误:

  1. 打开 products.service.ts 文件,并从 rxjs npm 包中导入 catchErrorthrowError 操作符:

    import { Observable, map, of, tap, **catchError, throwError** } from 'rxjs'; 
    
  2. @angular/common/http 命名空间导入 HttpErrorResponse 接口:

    import { HttpClient, HttpParams, **HttpErrorResponse** } from '@angular/common/http'; 
    
  3. 修改 getProducts 方法:

    getProducts(limit?: number): Observable<Product[]> {
      if (this.products.length === 0) {
        const options = new HttpParams().set('limit', limit || 10);
        return this.http.get<Product[]>(this.productsUrl, {
          params: options
        }).pipe(
          map(products => {
            this.products = products;
            return products;
          }),
          **catchError((error: HttpErrorResponse) => {**
            **console.error(error);**
            **return throwError(() => error);**
          **})**
        );
      }
      return of(this.products);
    } 
    

catchError 操作符的签名包含从服务器返回的实际 HttpErrorResponse 对象。在捕获错误后,我们使用 throwError 操作符,它将错误重新抛出为一个可观察对象。

或者,我们可以使用标准 Web API 方法中的throw关键字来抛出错误。然而,throwError方法通常过于强大。请相应地使用它。

这样,我们确保应用程序执行将继续并完成,而不会造成潜在的内存泄漏。

在实际场景中,我们可能会创建一个辅助方法来在一个更稳固的跟踪系统中记录错误,并根据错误的原因返回一些有意义的信息:

  1. 在同一文件products.service.ts中,从@angular/common/http命名空间导入HttpStatusCode枚举:

    import { HttpClient, HttpParams, HttpErrorResponse, **HttpStatusCode** } from '@angular/common/http'; 
    

HttpStatusCode是一个枚举,包含所有 HTTP 响应状态码的列表。

  1. ProductsService类中创建以下方法:

    private handleError(error: HttpErrorResponse) {
      let message = '';
      switch(error.status) {
        case HttpStatusCode.InternalServerError:
          message = 'Server error';
          break;
        case HttpStatusCode.BadRequest:
          message = 'Request error';
          break;
        default:
          message = 'Unknown error';
      }
    
      console.error(message, error.error);
    
      return throwError(() => error);
    } 
    

上述方法根据错误状态在浏览器控制台中记录不同的消息。它使用switch语句来区分内部服务器错误和错误请求。对于其他任何错误,它回退到default语句,在控制台中记录一个通用的消息。

  1. 重构getProducts方法以使用handleError方法来捕获错误:

    getProducts(limit?: number): Observable<Product[]> {
      if (this.products.length === 0) {
        const options = new HttpParams().set('limit', limit || 10);
        return this.http.get<Product[]>(this.productsUrl, {
          params: options
        }).pipe(
          map(products => {
            this.products = products;
            return products;
          }),
          catchError(**this.handleError**)
        );
      }
      return of(this.products);
    } 
    

当前handleError方法仅管理来自 HTTP 响应的 HTTP 错误。然而,在 Angular 应用程序中,其他错误也可能发生,例如由于网络错误而未到达服务器的请求或在 RxJS 操作符中抛出的异常。为了处理上述任何错误,我们应该在handleError方法中添加一个新的case语句:

private handleError(error: HttpErrorResponse) {
  let message = '';
  switch(error.status) {
    **case 0:**
      **message = 'Client error';**
      **break;**
    case HttpStatusCode.InternalServerError:
      message = 'Server error';
      break;
    case HttpStatusCode.BadRequest:
      message = 'Request error';
      break;
    default:
      message = 'Unknown error';
  }

  console.error(message, error.error);

  return throwError(() => error);
} 

在前面的代码片段中,状态为0的错误表示它是在应用程序客户端发生的错误。

在 HTTP 请求中处理错误时,可以结合一个机制,在处理错误之前重试特定的 HTTP 调用特定次数。对于几乎所有事情,RxJS 都有一个操作符,甚至有一个用于重试 HTTP 请求的操作符。它接受重试次数,即特定请求必须执行直到成功完成:

getProducts(limit?: number): Observable<Product[]> {
  if (this.products.length === 0) {
    const options = new HttpParams().set('limit', limit || 10);
    return this.http.get<Product[]>(this.productsUrl, {
      params: options
    }).pipe(
      map(products => {
        this.products = products;
        return products;
      }),
      **retry(2)**,
      catchError(this.handleError)
    );
  }
  return of(this.products);
} 

我们了解到我们使用catchError RxJS 操作符来捕获错误。我们处理它的方式取决于场景。在我们的情况下,我们在服务中为所有 HTTP 调用创建了一个handleError方法。在实际场景中,我们会在应用程序的其他 Angular 服务中遵循相同的错误处理方法。为每个服务创建一个方法可能不方便,并且扩展性不好。

或者,我们可以利用 Angular 提供的全局错误处理器来在中央位置处理错误。我们将在下一节学习如何创建全局错误处理器。

创建全局错误处理器

Angular 框架提供了ErrorHandler类来处理 Angular 应用程序中的全局错误。ErrorHandler类的默认实现将在浏览器控制台窗口中打印错误消息。

要为我们自己的应用程序创建一个自定义错误处理器,我们需要对 ErrorHandler 类进行子类化,并提供我们定制的错误记录实现:

  1. 在 Angular CLI 工作区的 src\app 文件夹中创建一个名为 app-error-handler.ts 的文件。

  2. 打开文件并添加以下 import 语句:

    import { HttpErrorResponse, HttpStatusCode } from '@angular/common/http';
    import { ErrorHandler, Injectable } from '@angular/core'; 
    
  3. 创建一个实现 ErrorHandler 接口的 TypeScript 类:

    @Injectable()
    export class AppErrorHandler implements ErrorHandler {} 
    

AppErrorHandler 类必须使用 @Injectable() 装饰器进行装饰,因为我们将在应用程序配置文件中稍后提供它。

  1. 按照以下方式实现 ErrorHandler 接口的 handleError 方法:

    handleError(error: any): void {
      const err = error.rejection || error;
      let message = '';
    
      if (err instanceof HttpErrorResponse) {
        switch(err.status) {
          case 0:
            message = 'Client error';
            break;
          case HttpStatusCode.InternalServerError:
            message = 'Server error';
            break;
          case HttpStatusCode.BadRequest:
            message = 'Request error';
            break;
          default:
            message = 'Unknown error';
        }
      } else {
        message = 'Application error';
      }
      console.error(message, err);
    } 
    

在前面的方法中,我们检查 error 对象是否包含一个 rejection 属性。来自负责 Angular 中变更检测的 Zone.js 库的错误,封装了实际的错误在该属性中。

在从 err 变量中提取错误后,我们使用 HttpErrorResponse 类型检查它是否是 HTTP 错误。这个检查最终会捕获使用 throwError RxJS 操作符的任何 HTTP 调用的错误。所有其他错误都被视为客户端发生的应用错误。

  1. 打开 app.config.ts 文件,并从 @angular/core npm 包中导入 ErrorHandler 类:

    import { ApplicationConfig, **ErrorHandler**, provideZoneChangeDetection } from '@angular/core'; 
    
  2. app-error-handler.ts 文件中导入我们创建的自定义错误处理器:

    import { AppErrorHandler } from './app-error-handler'; 
    
  3. 通过将其添加到 appConfig 变量的 providers 数组中,将 AppErrorHandler 类注册为应用程序的全局错误处理器:

    export const appConfig: ApplicationConfig = {
      providers: [
        provideZoneChangeDetection({ eventCoalescing: true }),
        provideRouter(routes),
        provideHttpClient(),
        { provide: APP_SETTINGS, useValue: appSettings },
        **{ provide: ErrorHandler, useClass: AppErrorHandler }**
      ]
    }; 
    

要调查全局应用错误处理器的行为,请执行以下步骤:

  1. 运行 ng serve 命令以启动应用程序。

  2. 断开您的计算机与互联网的连接。

  3. 导航到 http://localhost:4200

  4. 打开浏览器开发者工具并检查控制台窗口的输出:

img

图 11.1:应用错误

在一个网络企业应用中最常见的 HTTP 错误之一是 401 未授权 的响应错误。我们将在下一节学习如何处理这个特定的错误。

响应 401 未授权错误

在 Angular 应用程序中,401 未授权错误可能发生在以下情况:

  • 用户在登录应用程序时没有提供正确的凭据

  • 用户登录应用程序时提供的身份验证令牌已过期

处理 401 未授权错误的好地方是在负责身份验证的 HTTP 拦截器内部。在 第八章通过 HTTP 与数据服务通信 中,我们学习了如何创建一个身份验证拦截器,以便将授权令牌传递给每个 HTTP 请求。为了处理 401 未授权错误,auth.interceptor.ts 文件可以修改如下:

import { HttpErrorResponse, HttpInterceptorFn, HttpStatusCode } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
import { catchError, EMPTY, throwError } from 'rxjs';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService);
  const authReq = req.clone({
    setHeaders: { Authorization: 'myToken' }
  });
  return next(authReq).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status === HttpStatusCode.Unauthorized) {
        authService.logout();
        return EMPTY;
      } else {
        return throwError(() => error);
      }
    })
  );
}; 

当发生 401 未授权错误时,拦截器将调用AuthService类的logout方法并返回一个EMPTY可观察对象以停止发出数据。它将使用throwError运算符将错误冒泡到全局错误处理器中的所有其他错误。正如我们之前看到的,全局错误处理器将检查返回的错误并根据状态码采取行动。

正如我们在上一节中创建的全局错误处理器中看到的,一些错误与 HTTP 客户端的交互无关。有一些应用程序错误是在客户端发生的,我们将在下一节中学习如何理解它们。

揭秘框架错误

在 Angular 应用程序中,客户端发生的应用程序错误可能有多种原因。其中之一是我们源代码与 Angular 框架的交互。开发者在构建应用程序时喜欢尝试新事物和方法。有时,一切都会顺利进行,但有时可能会在应用程序中引起错误。

Angular 框架提供了一个机制,以以下格式报告一些常见错误:

NGWXYZ: {Error message}.<Link> 

让我们分析前面的错误格式:

  • NG:表示这是一个 Angular 错误,用于区分来自 TypeScript 和浏览器的其他错误

  • W:一个一位数,表示错误的类型。0 代表运行时错误,而 1 到 9 的所有其他数字代表编译器错误

  • X:一个一位数,表示框架运行区域类别,例如变更检测、依赖注入和模板

  • YZ:一个两位数代码,用于索引特定错误

  • {错误消息}:实际的错误消息

  • <链接>:指向 Angular 文档的链接,提供有关指定错误的更多信息

符合上述格式的错误消息将在浏览器控制台发生时显示。让我们通过使用ExpressionChangedAfterChecked错误(Angular 应用程序中最著名的错误)来查看一个错误示例:

  1. 打开app.component.ts文件,从@angular/corenpm 包中导入AfterViewInit实体:

    import { **AfterViewInit**, Component, inject } from '@angular/core'; 
    
  2. AfterViewInit添加到实现接口的列表中:

    export class AppComponent **implements AfterViewInit** 
    
  3. AppComponent类中创建以下title属性:

    title = ''; 
    
  4. 实现ngAfterViewInit方法并在方法体内更改title属性:

    ngAfterViewInit(): void {
      this.title = this.settings.title;
    } 
    
  5. 打开app.component.html文件,将title属性绑定到<h2>HTML 元素:

     <h2>{{ **title** }}</h2> 
    
  6. 运行ng serve命令并导航到http://localhost:4200

初始时,一切看起来都工作正常。title属性的值在页面上正确显示。

  1. 打开浏览器开发者工具并检查控制台窗口:

    Application error RuntimeError: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: ''. Current value: 'My e-shop'. Expression location: _AppComponent component. Find more at https://angular.dev/errors/NG0100 
    

前面的消息表明更改title属性的值导致了错误。

  1. 点击angular.dev/errors/NG0100链接将带我们转到 Angular 文档中适当的错误指南,以获取更多信息。错误指南解释了具体的错误,并描述了如何在我们的应用程序代码中修复问题。

当我们理解了源自 Angular 框架的错误信息时,我们可以轻松地修复它们。

摘要

在运行时或开发过程中处理错误对于每个 Angular 应用程序至关重要。在本章中,我们学习了如何在 Angular 应用程序运行时处理错误,例如 HTTP 或客户端错误。我们还学习了如何理解和修复由 Angular 框架抛出的应用程序错误。

在下一章中,我们将学习如何在 Angular Material 的帮助下美化我们的应用程序,使其看起来更美观。Angular Material 拥有许多组件和样式,这些组件和样式已经准备好供你在项目中使用。所以,让我们给你的 Angular 项目带来应有的关爱。

第十二章:Angular Material 简介

在开发 Web 应用程序时,您必须决定如何创建您的用户界面UI)。它理想上应使用适当的对比色,具有一致的外观和感觉,响应式,并在不同的设备和浏览器上运行良好。简而言之,关于 UI 和 UX 有很多事情要考虑。许多开发者认为创建 UI/UX 是一项艰巨的任务,并转向 UI 框架来承担大部分繁重的工作。有些框架比其他框架使用得更多,即BootstrapTailwind CSS。然而,基于 Google 的Material Design技术的Angular Material框架已经获得了流行。在本章中,我们将解释 Material Design 是什么,以及 Angular Material 如何使用它为 Angular 框架提供一个组件 UI 库。我们还将通过在我们的 e-shop 应用程序中应用它们来学习使用各种 Angular Material 组件。

在本章中,我们将进行以下操作:

  • 介绍 Material Design

  • 介绍 Angular Material

  • 集成 UI 组件

技术要求

本章包含各种代码示例,以向您介绍 Angular Material 的概念。您可以在以下 GitHub 仓库的ch12文件夹中找到相关的源代码:

www.github.com/PacktPublishing/Learning-Angular-Fifth-Edition

介绍 Material Design

Material Design 是由 Google 开发的一种设计语言,其目标是:

  • 开发一个单一的基础系统,允许在各个平台和设备尺寸上提供统一的使用体验。

  • 移动原则是基本的,但触摸、语音、鼠标和键盘都是一等输入方法。

设计语言的目的在于让用户处理 UI 和用户交互在各个设备上的外观和感觉。Material Design 基于以下三个主要原则:

  • 材料是隐喻:它受到物理世界中的不同纹理和介质(如纸张和墨水)的启发。

  • 粗体、图形化和有意图的:它受到不同的印刷设计方法(如排版、网格和颜色)的指导,为用户提供沉浸式的体验。

  • 运动赋予意义:通过创建动画和交互来重新组织环境,元素在屏幕上显示。

Material Design 背后有许多理论,如果您想深入了解,可以查阅相关的适当文档。您可以在官方文档网站上找到更多信息:material.io

如果你不是设计师,设计语言本身可能并不那么有趣。在下一节中,我们将学习 Angular 开发者如何通过 Angular Material 库从 Material Design 中受益。

介绍 Angular Material

Angular Material 库是为了实现 Angular 框架的 Material Design 而开发的。它基于以下概念:

  • 从零开始构建应用:目的是让作为应用开发者的您能够迅速上手。设置所需的工作量应尽可能小。

  • 快速且一致:性能一直是重点,Angular Material 保证在所有主要浏览器上都能良好工作。

  • 通用性:许多主题应该很容易自定义,并且还有对本地化和国际化的强大支持。

  • 针对 Angular 优化:Angular 团队构建了它的事实意味着对 Angular 的支持是一个重要优先事项。

该库分为以下主要部分:

  • 组件:许多 UI 组件,如不同类型的输入、按钮、布局、导航、模态以及其他展示表格数据的方式,都已准备好以帮助您成功。

  • 主题:该库附带预安装的主题,但如果您想创建自己的主题,也可以参考material.angular.io/guide/theming中的主题指南。

Angular Material 库的每个部分和组件都封装了开箱即用的 Web 无障碍最佳实践。

Angular Material 库的核心是Angular CDK,它是一组实现与任何展示风格无关的类似交互模式的工具集合。Angular Material 组件的行为是使用 Angular CDK 设计的。Angular CDK 如此抽象,以至于您可以用它来创建自定义组件。如果您是 UI 库的作者,您应该认真考虑它。

我们已经涵盖了关于 Angular Material 的所有基本理论,所以让我们在以下部分通过将其与 Angular 应用集成来将其付诸实践。

安装 Angular Material

Angular Material 库是一个 npm 包。为了安装它,我们需要手动执行npm install命令并将几个 Angular 工件导入到我们的 Angular 应用中。Angular 团队通过创建必要的 schematics 来自动化这些交互,以便使用 Angular CLI 安装它。

您需要我们在第十一章处理应用错误中创建的 Angular 应用的源代码,以跟随本章的其余部分。

我们可以使用 Angular CLI 的ng add命令将 Angular Material 安装到我们的电子商务应用中:

  1. 在当前 Angular CLI 工作区中运行以下命令:

    ng add @angular/material 
    

Angular CLI 将找到 Angular Material 库的最新稳定版本,并提示我们下载它。

在这本书中,我们使用 Angular Material 19,它与 Angular 19 兼容。如果提示的版本不同,您应该运行命令ng add @angular/material@19将最新的 Angular Material 19 安装到您的系统中。

  1. 下载完成后,它将询问我们是否想为我们的 Angular 应用使用预构建的主题或自定义主题:

    Choose a prebuilt theme name, or "custom" for a custom theme: (Use arrow keys) 
    

通过按Enter键接受默认值Azure/Blue

  1. 选择主题后,Angular CLI 将询问我们是否想在应用程序中设置全局排版样式。排版指的是文本在我们应用程序中的排列方式:

    Set up global Angular Material typography styles? (y/N) 
    

我们希望尽可能保持应用程序的简单性,因此通过按 Enter 键接受默认值,No

Angular Material 排版基于 Material 设计指南,并使用 Roboto Google 字体进行样式设计。

  1. 接下来的问题是关于动画。动画不是严格必需的,但我们希望当点击按钮或打开模态对话框时,我们的应用程序能够显示一个漂亮的动画:

    Include the Angular animations module? (Use arrow keys) 
    

通过按 Enter 键接受默认值,Include and enable animations

Angular CLI 将开始安装和配置 Angular Material 到我们的应用程序中。它将构建和导入所有必要的组件,以便我们立即开始使用 Angular Material:

  • angular.json : 它在 Angular CLI 工作区的配置文件中添加了主题样式表文件:

    "styles": [
      **"@angular/material/prebuilt-themes/azure-blue.css",**
      "src/styles.css"
    ] 
    
  • package.json : 它添加了 @angular/cdk@angular/material npm 包。

  • index.html : 它在主 HTML 文件中添加了 Roboto 字体和 Material 图标的样式表文件。

  • styles.css : 它为 <html><body> 标签添加必要的全局 CSS 样式:

    html, body { height: 100%; }
    body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } 
    
  • app.config.ts : 它在应用程序配置文件中启用动画:

    import { provideHttpClient } from '@angular/common/http';
    import { ApplicationConfig, ErrorHandler, provideZoneChangeDetection } from '@angular/core';
    import { provideRouter } from '@angular/router';
    import { routes } from './app.routes';
    import { APP_SETTINGS, appSettings } from './app.settings';
    import { AppErrorHandler } from './app-error-handler';
    **import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';**
    export const appConfig: ApplicationConfig = {
      providers: [
        provideZoneChangeDetection({ eventCoalescing: true }),
        provideRouter(routes),
        provideHttpClient(),
        { provide: APP_SETTINGS, useValue: appSettings },
        { provide: ErrorHandler, useClass: AppErrorHandler },
        **provideAnimationsAsync()**
      ]
    }; 
    

在过程完成后,我们可以开始将 Angular Material 库中的 UI 组件添加到我们的应用程序中。

添加 UI 组件

按钮组件是 Angular Material 库中最常用的组件之一。例如,我们将学习如何轻松地将按钮组件添加到我们的电子商务应用程序中。在我们可以在 Angular 应用程序中使用它之前,我们必须删除我们迄今为止使用的所有原生 <button> 标签的 CSS 样式:

  1. 打开 styles.css 文件,并删除 buttonbutton:hoverbutton:disabled CSS 样式。

  2. 打开 product-detail.component.css 文件,并从 button.secondarybutton.delete 样式中删除 --button-accent 变量。

  3. 完全删除 .button-group CSS 样式。

  4. button.delete 样式中添加一个 color

    button.delete {
      display: inline;
      margin-left: 5px;
      **color: brown;**
    } 
    

要开始使用 Angular Material 库中的 UI 组件,我们必须导入其相应的 Angular 组件。让我们通过在 Angular 应用程序的认证组件中添加按钮组件来查看这是如何完成的:

  1. 打开 auth.component.ts 文件,并添加以下 import 语句以使用 Angular Material 按钮:

    import { MatButton } from '@angular/material/button'; 
    

我们不直接从 @angular/material 包中导入,因为每个组件都有一个专门的命名空间。按钮组件可以在 @angular/material/button 命名空间中找到。

Angular Material 组件也可以通过导入它们各自的模块来使用,例如 MatButtonModule 用于按钮。然而,我们建议直接导入组件,因为这有助于我们保持与现代 Angular 模式的一致性。但是,我们将看到一些功能需要导入太多的组件。在这些情况下,直接导入模块是可以接受的。

  1. @Component 装饰器的 imports 数组中添加 MatButton 类:

    @Component({
      selector: 'app-auth',
      imports: [**MatButton**],
      templateUrl: './auth.component.html',
      styleUrl: './auth.component.css'
    }) 
    
  2. 打开 auth.component.html 文件,并在 <button> HTML 元素中添加 mat-button 指令:

    @if (!authService.isLoggedIn()) {
      <button **mat-button** (click)="login()">Login</button>
    } @else {
      <button **mat-button** (click)="logout()">Logout</button>
    } 
    

在前面的模板中,mat-button 指令本质上修改了 <button> 元素,使其看起来并表现得像一个 Material Design 按钮。

如果我们运行 ng serve 命令并导航到 http://localhost:4200,我们会注意到按钮的样式与之前不同。它看起来更像是一个链接,这是 Material 按钮的默认外观。在下一节中,我们将学习关于主题化和按钮组件的变化。

主题化 UI 组件

Angular Material 库自带四个内置主题:

  • Azure/蓝色

  • Rose/红色

  • Magenta/紫色

  • 青色/橙色

当我们将 Angular Material 添加到 Angular 应用程序中时,我们可以选择应用前面提到的哪个主题。我们总是可以通过修改 angular.json 配置文件中包含的 CSS 样式表文件来更改它。以下是一个示例:

"styles": [
  **"/@angular/material/prebuilt-themes/azure-blue.css",**
  "src/styles.css"
] 

正如我们在前面的章节中看到的,按钮组件被显示为一个链接。当我们将鼠标悬停在按钮上时,mat-button 指令会显示背景颜色。要永久设置背景颜色,我们必须使用 mat-flat-button 指令,如下所示:

@if (!authService.isLoggedIn()) {
  <button **mat-flat-button** (click)="login()">
    Login
  </button>
} @else {
  <button **mat-flat-button** (click)="logout()">
    Logout
  </button>
} 

现在我们已经知道了如何在 Angular 应用程序中与按钮组件交互,让我们学习一些它的变化:

  1. 打开 product-create.component.ts 文件,并添加以下 import 语句:

    import { MatButton } from '@angular/material/button'; 
    
  2. @Component 装饰器的 imports 数组中添加 MatButton 类:

    @Component({
      selector: 'app-product-create',
      imports: [ReactiveFormsModule, **MatButton**],
      templateUrl: './product-create.component.html',
      styleUrl: './product-create.component.css'
    }) 
    
  3. 打开 product-create.component.html 文件,并在 <button> HTML 元素中添加 mat-raised-button 指令:

    <button
      **mat-raised-button**
      type="submit"
      [disabled]="productForm.invalid">
      Create
    </button> 
    

mat-raised-button 指令将为按钮元素添加阴影:

包含文本、标志、字体、设计 自动生成的描述

图 12.1:提升按钮

  1. 打开 product-detail.component.ts 文件并重复 步骤 1步骤 2

  2. 打开 product-detail.component.html 文件,并在 Change 按钮中添加 mat-stroked-button 指令:

    <button
      **mat-stroked-button**
      class="secondary"
      type="submit"
      [disabled]="priceForm.invalid">
      Change
    </button> 
    

mat-stroked-button 指令在按钮元素周围添加一个边框:

包含字体、标志、图形、白色 自动生成的描述

图 12.2:描边按钮

  1. 移除具有 button-group 类的 <div> HTML 元素,并在两个 <button> HTML 元素中添加 mat-raised-button 指令:

    @if (authService.isLoggedIn()) {  
      <button
        **mat-raised-button**
        (click)="addToCart(product.id)">
        Add to cart
      </button>
    }
    <button
      **mat-raised-button**
      class="delete"
      (click)="remove(product)">
      Delete
    </button> 
    

当我们运行应用程序时,两个按钮将如下所示:

包含文本、字体、标志、白色 描述由系统自动生成

图 12.3:产品详情操作按钮

  1. 打开product-list.component.ts文件,并添加以下import语句:

    import { MatMiniFabButton } from '@angular/material/button';
    import { MatIcon } from '@angular/material/icon'; 
    
  2. @Component装饰器的imports数组中添加前面导入的类:

    @Component({
      selector: 'app-product-list',
      imports: [
        SortPipe,
        AsyncPipe,
        RouterLink,
        **MatMiniFabButton,**
        **MatIcon**
      ],
      templateUrl: './product-list.component.html',
      styleUrl: './product-list.component.css'
    }) 
    
  3. 打开product-list.component.html文件,并用以下 HTML 片段替换导航到产品创建组件的锚元素:

    <button mat-mini-fab routerLink="new">
      <mat-icon>add</mat-icon>
    </button> 
    

mat-mini-fab指令显示一个带圆角的方形按钮和一个由<mat-icon>HTML 元素指示的图标。<mat-icon>元素的文本对应于 Material Design 图标集合中的add图标名称:

包含符号、标志 描述由系统自动生成

图 12.4:FAB 按钮

Angular Material 的主题非常广泛,我们可以使用现有的 CSS 变量来创建自定义主题,但这本书的范围不包括这个话题。

为了继续我们的 Angular Material 风格之旅,我们将在下一节学习如何集成各种 UI 组件。

集成 UI 组件

Angular Material 包含许多组织在类别中的 UI 组件,可以在material.angular.io/components/categories找到。在本章中,我们将探索前面集合的子集,可以归纳为以下类别:

  • 表单控件:这些可以在 Angular 表单内部使用,例如自动完成、输入和下拉列表。

  • 导航:这些提供导航功能,例如页眉和页脚。

  • 布局:这些定义了数据如何表示,例如卡片或表格。

  • 弹出窗口和覆盖层:这些是显示信息并可以阻止用户交互的覆盖窗口,直到以任何方式关闭。

在接下来的章节中,我们将更详细地探讨每个类别。

表单控件

在第十章使用表单收集用户数据中,我们了解到表单控件是关于以不同方式收集输入数据并采取进一步行动的,例如通过 HTTP 将数据发送到后端 API。

在 Angular Material 库中有很多不同类型的表单控件,具体如下:

  • 自动完成:允许用户在输入字段中开始输入,并在输入时提供建议。这有助于缩小输入可以接受的值。

  • 复选框:一个经典的复选框,表示已选中或未选中的状态。

  • 日期选择器:允许用户在日历中选择一个日期。

  • 输入:一个经典的输入控件,在输入时增强了有意义的动画。

  • 单选按钮:一个经典的单选按钮,在编辑时增强了动画和过渡,以创造更好的用户体验。

  • 选择:一个下拉控件,提示用户从列表中选择一个或多个项目。

  • 滑块:允许用户通过拉动滑块按钮向右或向左增加或减少一个值。

  • 滑动开关:用户可以滑动来设置开或关的开关。

  • 芯片:一个显示、选择和过滤项目的列表。

在以下章节中,我们将更详细地检查这些表单控件中的几个。让我们从输入组件开始。

输入

输入组件通常附加到<input> HTML 元素上。我们还可以添加在输入字段中显示错误的能力。

在我们可以在我们的 Angular 应用程序中使用输入组件之前,我们必须移除我们迄今为止使用的所有原生<input>标签的 CSS 样式:

  1. 打开styles.css文件,移除任何引用input标签的 CSS 样式。

  2. product-create.component.csscart.component.css文件中移除input CSS 样式。

要了解如何使用输入组件,我们将将其集成到我们的应用程序组件中:

  1. 打开product-create.component.ts文件,并添加以下import语句:

    import { MatInput } from '@angular/material/input';
    import { MatFormField, MatError, MatLabel } from '@angular/material/form-field'; 
    
  2. @Component装饰器的imports数组中添加前面导入的类:

    @Component({
      selector: 'app-product-create',
      imports: [
        ReactiveFormsModule,
        MatButton,
        **MatInput,**
        **MatFormField,**
        **MatError,**
        **MatLabel**
      ],
      templateUrl: './product-create.component.html',
      styleUrl: './product-create.component.css'
    }) 
    
  3. 打开product-create.component.html文件,并按以下方式替换<input> HTML 元素的<div>标签:

    <mat-form-field>
      <mat-label>Title</mat-label>
      <input formControlName="title" matInput required />
      <mat-error>Title is required</mat-error>
    </mat-form-field>
    <mat-form-field>
      <mat-label>Price</mat-label>
      <input formControlName="price" matInput type="number" required />
      @if (productForm.controls.price.touched && productForm.controls.price.hasError('required')) {
        <mat-error>Price is required</mat-error>
      }
      @if (productForm.controls.price.touched && productForm.controls.price.hasError('min')) {
        <mat-error>Price should be greater than 0</mat-error>
      }
      @if (productForm.controls.price.touched && productForm.controls.price.hasError('priceMaximum')) {
        <mat-error>Price must be smaller or equal to 1000</mat-error>
      }
    </mat-form-field> 
    

在前面的 HTML 代码片段中,我们使用matInput指令来指示<input> HTML 元素是 Angular Material 输入组件。在 Angular Material 中,表单控件必须被包含在<mat-form-field>元素中。

我们已将所有<label> HTML 元素替换为<mat-label>元素。一个<mat-label> HTML 元素是一个针对特定 Angular Material 表单控件的标签。

当 Angular 触发验证错误时,<mat-error>元素会在表单控件中显示错误消息。当表单控件的状态无效时,它默认显示。在其他所有情况下,我们可以使用@if块来控制<mat-error>元素何时显示。

  1. 打开全局styles.css文件,并添加以下 CSS 样式:

    mat-form-field {
      width: 100%;
    } 
    

在前面的代码片段中,我们配置了mat-form-field元素以占用所有可用宽度。

  1. 运行ng serve命令以启动应用程序,并导航到http://localhost:4200/products/new。关注输入字段的显示:

包含文本、屏幕截图、矩形、自动生成的描述

图 12.5:输入组件

在前面的图中,每个表单控件的标签后面都跟着一个星号。星号是表示表单控件必须具有值的常见指示。Angular Material 会自动添加它,因为它识别了<input> HTML 元素上的required属性。

  1. 打开cart.component.ts文件,重复步骤 1 和 2,但不要包含MatError类。

  2. 打开cart.component.html文件,并按以下方式修改@for块的内容:

    @for(product of cartForm.controls.products.controls; track $index) {
      **<mat-form-field>**
        **<mat-label>{{products[$index].title}}</mat-label>**
        **<input**
          **[formControlName]="$index"**
          **placeholder="{{products[$index].title}}"**
          **type="number"**
          **matInput />**
      **</mat-form-field>**
    } 
    

我们应用程序中包含<input> HTML 元素的其余组件是产品详情组件。产品详情组件是 Angular Material 输入的特殊情况,因为我们必须将其与更改产品价格的按钮组合在一起:

  1. 打开product-detail.component.ts文件,并按照以下方式修改从 Angular Material npm 包的import语句:

    import { MatButton, **MatIconButton** } from '@angular/material/button';
    **import { MatInput } from '@angular/material/input';**
    **import { MatFormField, MatError, MatSuffix } from '@angular/material/form-field';**
    **import { MatIcon } from '@angular/material/icon';** 
    
  2. @Component装饰器的imports数组中添加前面导入的类:

    @Component({
      selector: 'app-product-detail',
      imports: [
        CommonModule,
        FormsModule,
        PriceMaximumDirective,
        MatButton,
        **MatInput,**
        **MatFormField,**
        **MatError,**
        **MatIcon,**
        **MatSuffix,**
        **MatIconButton**
      ],
      templateUrl: './product-detail.component.html',
      styleUrl: './product-detail.component.css'
    }) 
    
  3. 打开product-detail.component.html文件,并按照以下方式修改<form> HTML 元素:

    <form (ngSubmit)="changePrice(product)" #priceForm="ngForm">
      **<mat-form-field>**
        <input
          placeholder="New price"
          type="number"
          name="price"
          required min="1"
          appPriceMaximum threshold="500"
          **matInput**
          #priceCtrl="ngModel"
          [(ngModel)]="price" />
        <button
          **mat-icon-button**
          **matSuffix**
          type="submit"
          [disabled]="priceForm.invalid">
          **<mat-icon>edit</mat-icon>**
        </button>    
        @if (priceCtrl.dirty && (priceCtrl.invalid || priceCtrl.hasError('min'))) {
          **<mat-error>****</mat-error>**Please enter a valid price
        }
        @if (priceCtrl.dirty && priceCtrl.hasError('priceMaximum')) {
          **<mat-error>****</mat-error>**Price must be smaller or equal to 500
        }
      **</mat-form-field>**
    </form> 
    

在前面的片段中,我们修改了更改价格的按钮,使其显示铅笔图标,并且它与<input> HTML 元素对齐。

mat-icon-button指令表示按钮将没有任何文本。相反,它将显示由<mat-icon> HTML 元素定义的图标。matSuffix指令将按钮放置在<input> HTML 元素的行内和末尾。

  1. 在浏览器中导航到产品列表并选择一个产品。更改产品价格输入应该是以下内容:

包含文本的图像,矩形,屏幕截图,字体  自动生成的描述

图 12.6:带有行内按钮的输入组件

在下一节中,我们将学习如何使用 Angular Material 选择组件在产品创建组件中选择一个类别。

选择

选择组件的工作方式与原生的<select> HTML 元素类似。它显示一个下拉元素,其中包含用户可以选择的选项列表。

我们将在产品创建组件中添加一个来选择新产品的类别:

  1. 打开product-create.component.ts文件并添加以下import语句:

    import { MatSelect, MatOption } from '@angular/material/select'; 
    
  2. @Component装饰器的imports数组中添加前面导入的类:

    @Component({
      selector: 'app-product-create',
      imports: [
        ReactiveFormsModule,
        MatButton,
        MatInput,
        MatFormField,
        MatError,
        MatLabel,
        **MatSelect,**
        **MatOption**
      ],
      templateUrl: './product-create.component.html',
      styleUrl: './product-create.component.css'
    }) 
    
  3. 打开product-create.component.html文件,并用以下 HTML 片段替换包围<select>元素的<div> HTML 元素:

    <mat-form-field>
      <mat-label>Category</mat-label>
      <mat-select formControlName="category">
        <mat-option value="electronics">Electronics</mat-option>
        <mat-option value="jewelery">Jewelery</mat-option>
        <mat-option>Other</mat-option>
      </mat-select>
    </mat-form-field> 
    

在前面的片段中,我们将<select><option> HTML 元素分别替换为<mat-select><mat-option>元素。

  1. 导航到http://localhost:4200/products/new并点击类别下拉列表:

包含文本的图像,屏幕截图,字体  自动生成的描述

图 12.7:选择组件

产品详情组件将产品类别显示为具有特定 CSS 类的段落元素。在下一节中,我们将学习如何使用 Angular Material 芯片组件表示产品类别。

芯片

芯片组件通常用于按特定属性分组显示信息。它还可以提供数据过滤和选择功能。我们可以在我们的应用程序中使用芯片来在产品详情组件中显示类别。

我们的产品只有一个类别,但如果我们的产品有额外的类别分配,那么芯片会更有意义。

让我们开始吧:

  1. 打开product-detail.component.ts文件,添加以下import语句:

    import { MatChipSet, MatChip } from '@angular/material/chips'; 
    
  2. @Component装饰器的imports数组中添加前面导入的类:

    @Component({
      selector: 'app-product-detail',
      imports: [
        CommonModule,
        FormsModule,
        PriceMaximumDirective,
        MatButton,
        MatInput,
        MatFormField,
        MatError,
        MatIcon,
        MatSuffix,
        MatIconButton,
        **MatChipSet,**
        **MatChip**
      ],
      templateUrl: './product-detail.component.html',
      styleUrl: './product-detail.component.css'
    }) 
    
  3. 打开product-detail.component.html文件,将包含pill-group类的<div> HTML 元素替换为以下内容:

    <mat-chip-set>
      <mat-chip>{{ product.category }}</mat-chip>
    </mat-chip-set> 
    

<mat-chip> HTML 元素表示一个芯片组件。芯片必须始终使用容器元素封装。芯片容器最简单的形式是<mat-chip-set>元素。

  1. 打开product-detail.component.css文件,添加以下 CSS 样式:

    mat-chip-set {
      margin-bottom: 1.375rem;
    } 
    
  2. 运行ng serve命令以启动应用程序,并从列表中选择一个产品。例如,类别应该看起来像以下这样:

包含字体、文本、白色、标志的图片,自动生成的描述

图 12.8:芯片组件

芯片组件完成了我们对 Angular Material 表单控件的探索。在下一节中,我们将通过为应用程序的导航布局添加样式来获得实际操作经验。

导航

在 Angular 应用程序中导航有不同的方式,例如点击链接或菜单项。Angular Material 为此类交互提供了以下组件:

  • 菜单:一个弹出列表,您可以从预定义的选项集中进行选择。

  • 侧边栏:一个作为菜单固定在页面左侧或右侧的组件。它可以作为覆盖在应用程序上的叠加层,同时变暗应用程序内容。

  • 工具栏:一个标准工具栏,允许用户访问常用操作。

在本节中,我们将演示如何使用工具栏组件。我们将把主应用程序组件的<header><footer> HTML 元素转换为 Angular Material 工具栏。

要创建工具栏,我们将按照以下步骤进行:

  1. 打开app.component.ts文件,添加以下import语句:

    import { MatToolbarRow, MatToolbar } from '@angular/material/toolbar';
    import { MatButton } from '@angular/material/button'; 
    
  2. @Component装饰器的imports数组中添加前面导入的类,并删除RouterLinkActive类:

    @Component({
      selector: 'app-root',
      imports: [
        RouterOutlet,
        RouterLink,
        CopyrightDirective,
        AuthComponent,
        MatToolbarRow,
        MatToolbar,
        MatButton
      ],
      templateUrl: './app.component.html',
      styleUrl: './app.component.css'
    }) 
    
  3. 打开app.component.html文件,按照以下方式修改<header> HTML 元素:

    <header>
      **<mat-toolbar>**
        **<mat-toolbar-row>**
          <h2>{{ settings.title }}</h2>
          <span class="spacer"></span>
          **<button mat-button routerLink="/products">Products</button>**
          **<button mat-button routerLink="/cart">My Cart</button>**
          **<button mat-button routerLink="/user">My Profile</button>**
          <app-auth></app-auth>
        **</mat-toolbar-row>**
      **</mat-toolbar>**
    </header> 
    

在前面的模板中,我们在<mat-toolbar>元素内添加了主应用程序链接和身份验证组件。工具栏组件由一个由<mat-toolbar-row> HTML 元素表示的单行组成。

  1. 打开app.component.css文件,删除header标签和menu-links的 CSS 样式。

  2. 如果我们使用ng serve命令运行应用程序,我们将在页面顶部看到我们应用程序的新工具栏:

img

图 12.9:应用程序标题

  1. 现在,修改<footer> HTML 元素,将其转换为 Angular Material 工具栏组件:

    <footer>
      **<mat-toolbar>**
        **<mat-toolbar-row>**
          **<span appCopyright> - v{{ settings.version }}</span>** 
        **</mat-toolbar-row>**
      **</mat-toolbar>**
    </footer> 
    
  2. 保存更改,等待应用程序刷新,并观察应用程序底部的工具栏:

img

图 12.10:应用页脚

工具栏组件是完全可定制的,我们可以根据应用程序的需求进行调整。我们可以添加图标,甚至创建多行的工具栏。现在你已经了解了创建简单工具栏的基础,你可以探索更多的可能性。

在下一节中,我们将学习如何在应用程序内部以不同的方式布局内容。

布局

当我们提到布局时,我们讨论如何在模板中放置内容。Angular Material 为我们提供了不同的组件来完成这个目的:

  • 列表:将内容以项目列表的形式可视化。它可以添加链接、图标,甚至多行内容。

  • 网格列表:帮助我们以块的形式排列内容。我们只需要定义列数;组件将填充视觉空间。

  • 卡片:包装内容并添加阴影。我们还可以为它定义一个标题。

  • 标签页:将内容分割成不同的标签页。

  • 步骤条:将内容分割成类似向导的步骤。

  • 展开面板:允许我们将内容以类似列表的方式放置,并为每个项目添加标题。项目一次只能展开一个。

  • 表格:以行和列的表格格式表示数据。

在这本书中,我们将介绍卡片和表格组件。

卡片

我们将学习如何将列表中的每个产品显示为卡片:

  1. 打开product.ts文件,并在Product接口中添加一个image属性:

    export interface Product {
      id: number;
      title: string;
      price: number;
      category: string;
      **image: string;**
    } 
    

image属性是一个指向 Fake Store API 中产品图片文件的 URL。

  1. 打开product-list.component.ts文件,并添加以下import语句:

    import { MatCardModule } from '@angular/material/card'; 
    
  2. @Component装饰器的imports数组中添加MatCardModule类:

    @Component({
      selector: 'app-product-list',
      imports: [
        SortPipe,
        AsyncPipe,
        RouterLink,
        MatMiniFabButton,
        MatIcon,
        **MatCardModule**
      ],
      templateUrl: './product-list.component.html',
      styleUrl: './product-list.component.css'
    }) 
    

Angular Material 卡片组件由许多其他组件和指令组成。我们选择导入整个 Angular 模块,因为单独导入它们不太方便。

  1. 打开product-list.component.html文件,将无序列表元素替换为以下 HTML 片段:

    @for (product of products | sort; track product.id) {
      <mat-card [routerLink]="[product.id]">
        <mat-card-header>
          <mat-card-title-group>
            <mat-card-title>{{ product.title }}</mat-card-title>
            <mat-card-subtitle>{{ product.category }}</mat-card-subtitle>
            <img mat-card-sm-image [src]="product.image" />
          </mat-card-title-group>
        </mat-card-header>
      </mat-card>
    } @empty {
      <p>No products found!</p>
    } 
    

Angular Material 卡片组件由一个标题组成,由<mat-card-header>HTML 元素表示。标题组件包含一个<mat-card-title-group>HTML 元素,该元素将卡片标题、副标题和图像排列成一个单独的部分。由<mat-card-title>HTML 元素表示的卡片标题显示产品标题。由<mat-card-subtitle>HTML 元素表示的卡片副标题显示产品类别。最后,通过将mat-card-sm-image指令附加到<img>HTML 元素上,显示产品图片。指令中的sm关键字表示我们想要渲染图像的小尺寸。

Angular Material 还支持mdlg,分别代表中等和大型尺寸。

  1. 打开product-list.component.css文件,并添加以下 CSS 样式:

    mat-card {
      margin: 1.375rem;
      cursor: pointer;
    } 
    
  2. 使用ng serve命令运行应用程序,并导航到http://localhost:4200

包含文本的图像,屏幕截图,自动生成的描述

图 12.11:产品列表卡片表示

您可以通过导航到material.angular.io/components/card/overview来探索更多卡片组件的选项。

在下一节中,我们将学习如何将产品列表切换到表格视图。

数据表

Angular Material 库中的表格组件使我们能够以列和行的形式显示我们的数据。要创建表格,我们必须从@angular/material/table命名空间导入MatTableModule类。

Angular Material 数据表由许多其他组件和指令组成。我们选择导入整个 Angular 模块,因为单独导入它们将不方便。

让我们开始吧:

  1. 打开product-list.component.ts文件,并导入CurrencyPipeMatTableModule组件:

    import { AsyncPipe, **CurrencyPipe** } from '@angular/common';
    **import { MatTableModule } from '@angular/material/table';** 
    
  2. 将之前导入的类添加到@Component装饰器的imports数组中:

    @Component({
      selector: 'app-product-list',
      imports: [
        SortPipe,
        AsyncPipe,
        **CurrencyPipe**,
        RouterLink,
        MatMiniFabButton,
        MatIcon,
        MatCardModule,
        **MatTableModule**
      ],
      templateUrl: './product-list.component.html',
      styleUrl: './product-list.component.css'
    }) 
    
  3. ProductListComponent类中创建以下属性来定义表格列名:

    columnNames = ['title', 'price']; 
    

每个列的名称都与Product接口中的一个属性匹配。

  1. 打开product-list.component.html文件,在@for块之后添加以下代码片段:

    <table mat-table [dataSource]="products"></table> 
    

Angular Material 表格是一个带有mat-table指令的标准<table> HTML 元素。

mat-table指令的dataSource属性定义了我们想在表格上显示的数据。它可以是指任何可枚举的数据,例如数组。在我们的例子中,我们将其绑定到products模板引用变量。

  1. 为我们想要显示的每个列添加一个<ng-container>元素:

    <table mat-table [dataSource]="products">
      **<ng-container matColumnDef="title">**
        **<th mat-header-cell *matHeaderCellDef>Title</th>**
        **<td mat-cell *matCellDef="let product">**
          **<a [routerLink]="[product.id]">{{ product.title }}</a>**
        **</td>**
      **</ng-container>**
      **<ng-container matColumnDef="price">**
        **<th mat-header-cell *matHeaderCellDef>Price</th>**
        **<td mat-cell *matCellDef="let product">{{ product.price |****currency }}</td>** 
      **</ng-container>**
    </table> 
    

    <ng-container>元素是一个具有独特用途的元素,它将具有相似功能的元素分组在一起。它不会干扰子元素的样式,也不会在屏幕上渲染。

<ng-container>元素使用matColumnDef指令设置特定列的名称。

matColumnDef指令的值必须与columnNames组件属性的值匹配;否则,应用程序将抛出一个错误,表明它找不到定义的列的名称。

它包含一个带有mat-header-cell指令的<th> HTML 元素,表示单元格的标题,以及一个带有mat-cell指令的<td> HTML 元素,用于单元格的数据。<td> HTML 元素使用matCellDef指令创建一个用于当前行数据的本地模板变量,我们可以在以后使用它。

  1. <ng-container>元素之后添加以下代码片段:

    <tr mat-header-row *matHeaderRowDef="columnNames"></tr>
    <tr mat-row *matRowDef="let row; columns: columnNames;"></tr> 
    

在前面的代码片段中,我们定义了表格的标题行,显示列名和包含数据的实际行。

如果我们运行应用程序,输出应该是以下内容:

包含文本的图像,屏幕截图,编号,字体,自动生成的描述

图 12.12:表格组件

产品列表组件同时显示数据的卡片表示和表格表示。我们将使用 Angular Material 的按钮切换组件来区分它们。按钮切换组件根据特定条件切换按钮的开启或关闭:

  1. 打开product-list.component.ts文件并添加以下import语句:

    import { MatButtonToggle, MatButtonToggleGroup } from '@angular/material/button-toggle'; 
    
  2. @Component装饰器的imports数组中添加前面导入的类:

    @Component({
      selector: 'app-product-list',
      imports: [
        SortPipe,
        AsyncPipe,
        CurrencyPipe,
        RouterLink,
        MatMiniFabButton,
        MatIcon,
        MatCardModule,
        MatTableModule,
        **MatButtonToggle,**
        **MatButtonToggleGroup**
      ],
      templateUrl: './product-list.component.html',
      styleUrl: './product-list.component.css'
    }) 
    
  3. 打开product-list.component.html文件,并在具有caption类的<div>HTML 元素内添加以下 HTML 片段:

    <span class="spacer"></span>
    <mat-button-toggle-group #group="matButtonToggleGroup">
      <mat-button-toggle value="card" checked>
        <mat-icon>list</mat-icon>
      </mat-button-toggle>
      <mat-button-toggle value="table">
        <mat-icon>grid_on</mat-icon>
      </mat-button-toggle>
    </mat-button-toggle-group> 
    

在前面的代码片段中,我们使用<mat-button-toggle-group>元素创建两个并排的切换按钮。按钮切换组的实例被分配给group模板引用变量,这样我们可以在以后访问它。

我们使用<mat-button-toggle>元素声明切换按钮,并设置适当的value。当点击任一按钮时,将设置value属性。我们还为每个切换按钮添加了一个图标,以增强用户体验,当用户与产品列表交互时。

  1. 在具有caption类的<div>HTML 元素之后创建一个新的@if块,并将@for块移动到其中:

    @if (group.value === 'card') {
      @for (product of products | sort; track product.id) {
        <mat-card [routerLink]="[product.id]">
          <mat-card-header>
            <mat-card-title-group>
              <mat-card-title>{{ product.title }}</mat-card-title>
              <mat-card-subtitle>{{ product.category }}</mat-card-subtitle>
              <img mat-card-sm-image [src]="product.image" />
            </mat-card-title-group>
          </mat-card-header>
        </mat-card>
      } @empty {
        <p>No products found!</p>
      }
    } 
    

根据前面的代码片段,当按钮切换组的value属性设置为card时,将显示产品的卡片表示。

  1. 添加以下@else块,并将数据表组件移动到其中,以便在点击第二个切换按钮时以表格格式显示产品列表:

    @else {
      <table mat-table [dataSource]="products">
        <ng-container matColumnDef="title">
          <th mat-header-cell *matHeaderCellDef>Title</th>
          <td mat-cell *matCellDef="let product">
            <a [routerLink]="[product.id]">{{ product.title }}</a>
          </td>
        </ng-container>
        <ng-container matColumnDef="price">
          <th mat-header-cell *matHeaderCellDef>Price</th>
          <td mat-cell *matCellDef="let product">{{ product.price | currency }}</td>
        </ng-container>
        <tr mat-header-row *matHeaderRowDef="columnNames"></tr>
        <tr mat-row *matRowDef="let row; columns: columnNames;"></tr>
      </table>
    } 
    
  2. 运行ng serve命令以启动应用程序并验证卡片表示最初显示。

包含文本的图像,屏幕截图,自动生成的描述

图 12.13:产品列表

  1. 点击第二个切换按钮并验证产品现在以表格格式显示。

在本节中,我们学习了如何以表格格式显示产品列表。我们还使用了切换按钮在卡片视图和表格视图之间切换。

在以下部分,我们将学习如何使用弹出窗口和覆盖层向用户提供额外信息。

弹出窗口和覆盖层

在 Web 应用程序中,有不同方式来吸引用户的注意力。其中一种是在页面内容上显示弹出对话框,并提示用户相应地采取行动。另一种方式是在页面的不同部分显示信息作为通知。

Angular Material 提供三个不同的组件来处理此类情况:

  • 对话框:一个模态弹出对话框,它显示在页面内容之上。

  • 徽章:一个小圆形指示,用于更新 UI 元素的状态。

  • Snackbar:在页面底部显示的信息消息,短暂可见。其目的是通知用户操作的结果,例如保存表单。

我们将学习如何在我们的电子商务应用程序中使用前面的组件,从如何创建一个简单的模态对话框开始。

创建确认对话框

对话框组件功能强大,可以轻松地进行自定义和配置。它是一个普通的 Angular 组件,带有自定义指令,强制其表现出对话框的行为。为了探索 Angular Material 对话框的功能,我们将在结账守卫中使用确认对话框来通知用户他们购物车中剩余的商品:

  1. 运行以下 Angular CLI 命令以创建一个新的 Angular 组件:

    ng generate component checkout 
    

上述命令将创建一个 Angular 组件,该组件将托管我们的对话框。

  1. 打开 checkout.component.ts 文件并添加以下 import 语句:

    import { MatButton } from '@angular/material/button';
    import { MatDialogModule } from '@angular/material/dialog'; 
    

Angular Material 对话框组件由许多其他组件和指令组成。我们选择导入整个 Angular 模块,因为单独导入它们将不太方便。

  1. @Component 装饰器的 imports 数组中添加前面导入的类:

    @Component({
      selector: 'app-checkout',
      imports: [**MatButton, MatDialogModule**],
      templateUrl: './checkout.component.html',
      styleUrl: './checkout.component.css'
    }) 
    
  2. 打开 checkout.component.html 文件并将其内容替换为以下 HTML 模板:

    <h1 mat-dialog-title>Cart Checkout</h1>
    <mat-dialog-content>
      <span>You have pending items in your cart. Do you want to continue?</span>
    </mat-dialog-content>
    <mat-dialog-actions>
      <button mat-raised-button>Yes</button>
      <button mat-button>No</button>
    </mat-dialog-actions> 
    

组件模板包含属于 Angular Material 对话框组件的各种指令和元素。mat-dialog-title 指令定义对话框的标题,<mat-dialog-content> 是实际内容。<mat-dialog-actions> 元素定义对话框可以执行的操作,通常包含按钮元素。

  1. 对话框必须被触发才能在页面上显示。打开 checkout.guard.ts 文件并添加以下 import 语句:

    import { MatDialog } from '@angular/material/dialog';
    import { CheckoutComponent } from './checkout/checkout.component'; 
    
  2. checkoutGuard 函数的主体中注入 MatDialog 服务:

    const dialog = inject(MatDialog); 
    
  3. 按如下方式修改 confirmation 变量的赋值:

    if (cartService.cart) {
      const confirmation = **dialog.open(CheckoutComponent).afterClosed();**
      return confirmation;
    } 
    

在前面的代码片段中,我们使用 MatDialog 服务来显示结账组件。MatDialog 服务接受一个参数,该参数是表示对话框的组件类类型。

MatDialog 服务的 open 方法返回一个 afterClosed 可观察属性,它将在对话框关闭时通知我们。该可观察对象会发出从对话框发送回的任何值。

在本章的后面部分,我们将学习如何从对话框组件返回一个布尔值,该值与 CanDeactivateFn 函数返回的类型相匹配。

我们现在可以通过执行以下步骤来验证对话框组件是否按预期工作:

  1. 使用 ng serve 命令运行应用程序并导航到 http://localhost:4200

  2. 登录应用程序。

  3. 从列表中选择一个产品并将其添加到购物车。

  4. 重复上述步骤以向购物车添加更多产品。

  5. 导航到购物车,然后点击浏览器的后退按钮或任何应用程序链接以离开购物车。屏幕上将会显示以下对话框:

包含文本的图像,屏幕截图,字体  自动生成的描述

图 12.14:结账对话框组件

我们可以通过在对话框中显示我们添加到购物车中的项目数量来进一步改善应用程序的 UX。在下一节中,我们将学习如何在对话框中传递数据并显示购物车项目数量。

配置对话框

在实际场景中,你可能会需要创建一个可重用的组件来在 Angular 项目中显示对话框。该组件最终可能成为 Angular 库中的一个包。因此,你应该配置对话框组件以动态接受数据。

在当前的 Angular 项目中,我们希望显示我们添加到购物车中的产品数量:

  1. 打开 checkout.component.ts 文件,并按如下方式修改 import 语句:

    import { Component, **inject** } from '@angular/core';
    import { MatButton } from '@angular/material/button';
    import { MatDialogModule, **MAT_DIALOG_DATA** } from '@angular/material/dialog'; 
    
  2. 以以下方式在 CheckoutComponent 类中注入 MAT_DIALOG_DATA

    export class CheckoutComponent {
      **data = inject(MAT_DIALOG_DATA);**
    } 
    

MAT_DIALOG_DATA 是一个注入令牌,它使我们能够将任意数据传递给对话框组件。当调用其 open 方法时,data 变量将包含我们传递给对话框的任何数据。

  1. 打开 checkout.component.html 文件,并将 data 属性添加到 <span> HTML 元素的内部文本:

    <span>
      You have **{{ data }}** pending items in your cart.
      Do you want to continue?
    </span> 
    
  2. 打开 checkout.guard.ts 文件,并在对话框配置对象中设置 data 属性,这是 open 方法的第二个参数:

    const confirmation = dialog.open(
      CheckoutComponent,
      **{ data: cartService.cart.products.length }**
    ).afterClosed(); 
    
  3. 如果我们在运行应用程序时尝试离开购物车页面,我们将得到一个类似于以下对话框:

包含文本的图像,屏幕截图,字体  自动生成的描述

图 12.15:带有自定义数据的结账对话框组件

对话框组件的按钮目前还没有做任何特定的事情。在下一节中,我们将学习如何配置它们并将数据返回给守卫。

从对话框获取数据

Angular Material 对话框模块公开了 mat-dialog-close 指令,我们可以使用它来配置哪个按钮将关闭对话框。打开 checkout.component.html 文件,并将 mat-dialog-close 指令添加到两个按钮:

<mat-dialog-actions>
  <button mat-raised-button **mat-dialog-close**>Yes</button>
  <button mat-button **[mat-dialog-close]="false"**>No</button>
   </mat-dialog-actions> 

在前面的代码片段中,我们以两种方式使用 mat-dialog-close 指令:

  • 如果在 Yes 按钮中不传递值,对话框将默认返回 true,允许守卫从购物车页面导航离开。

  • No 按钮的属性绑定中,我们传递 false 作为值以取消从守卫处的导航。

执行以下步骤以验证对话框行为是否正确:

  1. 运行 ng serve 命令以启动应用程序并导航到 http://localhost:4200

  2. 登录到应用程序。

  3. 从列表中选择一个产品并将其添加到购物车。

  4. 单击My Cart链接以导航到购物车。

  5. 在结账对话框中选择No,然后单击Products链接,并验证应用程序是否停留在购物车页面上。

  6. 再次单击Products链接,在对话框中选择Yes,你应该会导航到产品列表。

对话框是 Angular Material 的一个优秀功能,可以为您的应用程序提供强大的功能。在下一节中,我们将探讨徽章和 Snackbar 组件,以便在产品添加到购物车时通知用户。

显示用户通知

Angular Material 库强制执行提高应用程序 UX 的模式和行为。应用程序 UX 的一个方面是,在特定操作后向用户提供通知。Angular Material 为我们提供了在这种情况下可以使用的徽章和 Snackbar 组件。

应用徽章

徽章组件是一个位于另一个元素顶部的圆形,通常显示一个数字。我们将学习如何通过在My Cart应用程序链接中显示购物车项目数量来应用徽章:

  1. 打开app.component.ts文件并添加以下import语句:

    import { MatBadge } from '@angular/material/badge';
    import { CartService } from './cart.service'; 
    

MatBadge类导出徽章组件。CartService类将为我们提供购物车中的项目数量。

  1. @Component装饰器的imports数组中添加MatBadge类:

    @Component({
      selector: 'app-root',
      imports: [
        RouterOutlet,
        RouterLink,
        CopyrightDirective,
        AuthComponent,
        MatToolbarRow,
        MatToolbar,
        MatButton,
        **MatBadge**
      ],
      templateUrl: './app.component.html',
      styleUrl: './app.component.css'
    }) 
    
  2. AppComponent类中注入CartService类:

    cartService = inject(CartService); 
    
  3. 打开app.component.html文件并将matBadge指令添加到My Cart按钮:

    <button
      mat-button
      routerLink="/cart"
      **[matBadge]="cartService.cart?.products?.length">**
      My Cart
    </button> 
    

在前面的代码片段中,matBadge指令指示徽章中显示的数字。在这种情况下,我们将其绑定到当前购物车中存在的products数组的length

  1. 打开app.component.css文件并添加以下 CSS 样式:

    button {
      margin: 5px;
    } 
    

上述样式将为每个应用程序链接周围添加空间,以便按钮不会与徽章组件重叠。

  1. 运行ng serve命令以启动应用程序并向购物车添加一些产品。注意,当产品添加到购物车时,徽章图标会更新其值;以下是一个示例:

包含文本、字体、屏幕截图、徽标的图片,自动生成的描述

图 12.16:徽章组件

应用 Snackbar

当我们与 CRUD 应用程序一起工作时,另一个好的 UX 模式是在操作完成后显示通知。我们可以通过在产品添加到购物车时显示通知来应用这种模式。我们将使用 Angular Material 的 Snackbar 组件来显示通知:

  1. 打开product-detail.component.ts文件并添加以下import语句:

    import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar'; 
    

Snackbar 不是一个像我们之前看到的所有 Angular Material 组件的实际 Angular 组件。它是一个名为MatSnackBar的 Angular 服务,可以通过将MatSnackBarModule类导入我们的组件来使用。

  1. @Component装饰器的imports数组中添加MatSnackBarModule类:

    @Component({
      selector: 'app-product-detail',
      imports: [
        CommonModule,
        FormsModule,
        PriceMaximumDirective,
        MatButton,
        MatInput,
        MatFormField,
        MatError,
        MatIcon,
        MatSuffix,
        MatIconButton,
        MatChipSet,
        MatChip,
        **MatSnackBarModule**
      ],
      templateUrl: './product-detail.component.html',
      styleUrl: './product-detail.component.css'
    }) 
    
  2. MatSnackBar服务注入到ProductDetailComponent类的constructor中:

    constructor(
      private productService: ProductsService,
      public authService: AuthService,
      private route: ActivatedRoute,
      private router: Router,
      private cartService: CartService,
      **private snackbar: MatSnackBar**
    ) { } 
    
  3. 修改addToCart方法,当产品添加到购物车时显示一个通知栏:

    addToCart(id: number) {
      this.cartService.addProduct(id).subscribe(() => {
        **this.snackbar.open('Product added to cart!', undefined, {**
          **duration: 1000**
        **});**
      **}**);
    } 
    

在前面的方法中,我们使用MatSnackBar服务的open方法来显示一个通知栏。open方法接受三个参数:我们想要显示的消息,当通知栏被关闭时我们想要采取的操作,以及一个配置对象。配置对象使我们能够设置各种选项,例如通知栏在毫秒数内的可见duration

我们没有传递操作参数,因为我们不希望在通知栏被关闭时做出反应。

  1. 运行ng serve命令以启动应用程序并从列表中选择一个产品。

  2. 确保您已登录并点击添加到购物车按钮。以下通知信息将在页面底部显示:

包含文本、字体、屏幕截图、矩形框的图片,自动生成的描述

图 12.17:Snackbar 组件

可以通过配置选项更改通知栏的位置。更多信息请参阅material.angular.io/components/snack-bar/overview

在本节中,我们学习了如何使用弹出模型和通知覆盖来增强应用程序的用户体验,并为用户提供一个出色的工作流程。

摘要

在本章中,我们探讨了 Material Design 系统的基本知识。我们主要关注 Angular Material,这是为 Angular 设计的 Material Design 实现,以及它由不同的组件组成。我们查看了一个关于如何安装它、设置它以及使用其核心组件和主题的动手说明。

希望您已经阅读了这一章,并发现您现在已经掌握了 Material Design 的一般知识,特别是 Angular Material,并且可以确定它是否适合您的下一个 Angular 应用程序。

网络应用程序必须是可测试的,以确保它们的功能性和符合应用程序要求。在下一章中,我们将学习如何在 Angular 应用程序中应用不同的测试技术。