Angular-企业级应用第三版-四-

69 阅读55分钟

Angular 企业级应用第三版(四)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第八章:菜谱 – 可重用性、表单和缓存

在接下来的两个章节中,我们将完成 LemonMart 的大部分实现,并完善我们对路由优先方法的覆盖。在本章中,我将通过创建一个可重用可路由的组件来支持数据绑定来强化解耦组件架构的概念。我们将使用Angular 指令来减少样板代码,并利用类、接口、枚举、验证器和管道,通过 TypeScript 和 ES 功能最大化代码重用。

此外,我们还将创建一个多步骤表单,它在架构上具有良好的扩展性,并支持响应式设计。然后,我们将通过引入一个柠檬评分器和一个封装了名称对象的可重用表单部分来区分用户控件和组件。

本章涵盖了大量的内容。它以菜谱格式组织,因此你可以在处理项目时快速参考特定的实现。我将涵盖实现架构、设计和主要组件。我将突出显示重要的代码片段来解释解决方案是如何组合在一起的。利用你迄今为止所学到的知识,我期望你填写常规实现和配置细节。然而,如果你遇到困难,你始终可以参考 GitHub 项目。

在本章中,你将学习以下主题:

  • 使用缓存实现 CRUD 服务

  • 多步骤响应式表单

  • 使用指令重用重复模板行为

  • 计算属性和 DatePicker

  • 自动完成支持

  • 动态表单数组

  • 创建共享组件

  • 审查和保存表单数据

  • 具有可重用部分的可扩展表单架构

  • 输入掩码

  • 使用ControlValueAccessor的自定义控件

  • 使用网格列表布局

  • 恢复缓存数据

技术要求

书籍的示例代码的最新版本可在以下步骤中链接的 GitHub 仓库中找到。该仓库包含代码的最终和完成状态。你可以在本章末尾通过查看projects文件夹下的代码末尾快照来验证你的进度。

对于第八章

确保服务端lemon-mart-server正在运行。请参阅第七章与 REST 和 GraphQL API 一起工作

  1. github.com/duluca/lemon-mart克隆仓库。

  2. 在根目录中执行npm install以安装依赖项。

  3. 项目的初始状态反映在:

    projects/stage10 
    
  4. 项目的最终状态反映在:

    projects/stage11 
    
  5. 将阶段名称添加到任何ng命令中,以仅对该阶段执行操作:

    npx ng build stage11 
    

注意,仓库根目录下的dist/stage11文件夹将包含编译结果。

请注意,书中提供的源代码和 GitHub 上的版本可能会有所不同。这些项目周围的生态系统一直在不断发展。在 Angular CLI 生成新代码的方式、错误修复、库的新版本或多种技术的并行实现之间,有很多变化是无法预料的。如果您发现错误或有疑问,请创建一个 issue 或在 GitHub 上提交一个 pull request。

让我们从实现一个用户服务来检索数据并构建一个用于显示和编辑个人资料信息的表单开始。稍后,我们将重构这个表单,以抽象出其可重用部分。

实现具有缓存的 CRUD 服务

我们需要一个能够对用户执行 CRUD 操作的服务,以便我们可以实现用户资料。然而,该服务必须足够健壮,以承受常见的错误。毕竟,当用户无意中丢失他们输入的数据时,用户体验非常糟糕。表单数据可能会因为超出用户控制的情况而重置,比如网络或验证错误,或者用户错误,比如不小心点击了后退或刷新按钮。我们将创建一个利用我们在 第五章 中构建的 CacheService 的用户服务,在服务器处理数据的同时,将用户数据保存在 localStorage 中。该服务将实现以下接口,并且,像往常一样,引用抽象的 IUser 接口而不是具体的用户实现:

export interface IUserService {
  getUser(id: string): Observable<IUser>
  updateUser(id: string, user: IUser): Observable<IUser>
  getUsers(
    pageSize: number,
    searchText: string,
    pagesToSkip: number
  ): Observable<IUsers>
} 

在创建服务之前,启动 lemon-mart-server 并将您的应用程序的 AuthMode 设置为 CustomServer

您可以使用 npm run start:backend 来启动数据库和服务器。

在本节中,我们将实现 getUserupdateUser 函数。我们将在 第九章 中实现 getUsers食谱 – 主/详细信息、数据表和 NgRx,以支持数据表分页。

首先创建用户服务:

  1. src/app/user/user 下创建一个 UserService

  2. 从前面的代码片段中声明 IUserService 接口,不包括 getUsers 函数。

  3. 确保 UserService 类实现了 IUserService

  4. CacheServiceHttpClientAuthService 注入,如下所示:

    **src/app/user/user/user.****service****.****ts**
    export interface IUserService {
      getUser(id: string): Observable<Iuser>
      updateUser(id: string, user: Iuser): Observable<Iuser>
    }
    @Injectable({
      providedIn: 'root',
    })
    export class UserService implements IUserService {
      private readonly cache = inject(CacheService)
      private readonly httpClient = inject(HttpClient)
      private readonly authService = inject(AuthService)
      getUser(id: string): Observable<IUser> {
        throw new Error('Method not implemented.')
      }
      updateUser(id: string, user: IUser): Observable<IUser> {
        throw new Error('Method not implemented.')
      }
    } 
    
  5. 实现如下的 getUser 函数:

    **src/app/user/user/user.****service****.****ts**
    getUser(id: string | null): Observable<IUser> {
      if (id === null) {
        return throwError('User id is not set')
      }
      return this.httpClient.get<IUser>(
        `${environment.baseUrl}/v2/user/${id}`
      )
    } 
    

我们提供了一个可以加载任何用户个人资料信息的 getUser 函数。请注意,此函数的安全性由服务器实现中的 authenticate 中间件提供。请求者可以获取他们的个人资料,或者需要是管理员。我们将在本章后面使用 getUser 与 resolve 守卫一起使用。

更新缓存

实现 updateUser,它接受一个实现 IUser 接口的对象,以便可以将数据发送到 PUT 端点:

**src/app/user/user/user.****service****.****ts**
  updateUser(id: string, user: IUser): Observable<IUser> {
    if (id === '') {
      return throwError('User id is not set')
    }
    // cache user data in case of errors
    this.cache.setItem('draft-user', Object.assign(user, { _id: id }))
    const updateResponse$ = this.httpClient
      .put<IUser>(`${environment.baseUrl}/v2/user/${id}`, user)
      .pipe(map(User.Build), catchError(transformError))
    updateResponse$.subscribe(
      (res) => {
        this.authService.currentUser$.next(res)
        this.cache.removeItem('draft-user')
      },
      (err) => throwError(err)
    )
    return updateResponse$
  } 

注意缓存服务是如何使用 setItem 来保存用户输入的数据,如果 put 调用失败。当调用成功时,我们使用 removeItem 删除缓存数据。此外,注意我们是如何使用 map(User.Build) 将从服务器返回的用户作为 User 对象进行填充,这调用的是 class User 的构造函数。

填充是用于从数据库或网络请求中填充对象数据的常用术语。例如,我们在组件之间传递或从服务器接收的 User JSON 对象符合 IUser 接口,但不是 class User 类型。我们使用 toJSON 方法将对象序列化为 JSON。当我们填充并从 JSON 实例化一个新对象时,我们会反转并反序列化数据。

需要强调的是,在传递数据时,你应该始终坚持使用接口,而不是像 User 这样的具体实现。这是 SOLID 原则中的 D(依赖倒置原则)。引用像 User 这样的具体实现会带来很多风险,因为它们经常变化,而像 IUser 这样的抽象则很少变化。毕竟,你不会直接将灯泡焊接在墙上的电线中。相反,你会将灯泡焊接在插头上,然后使用插头获取所需的电力。

代码完成后,UserService 现在可以用于基本的 CRUD 操作。

多步骤响应式表单

总体而言,表单与你的应用程序的其他部分不同,它们需要特殊的架构考虑。我不建议使用动态模板或启用路由的组件过度设计你的表单解决方案。根据定义,表单的不同部分是紧密耦合的。从可维护性和易于实现的角度来看,创建一个巨大的组件比使用上述一些策略和过度设计更好。

我们将实现一个多步骤输入表单,在单个组件中捕获用户配置文件信息。我将在本章的“可重用表单部分和可扩展性”部分介绍我推荐的将表单拆分为多个组件的技术。

由于表单的实现在这部分和章节后面的部分之间发生了巨大变化,你可以在 GitHub 上的 projects/stage11/src/app/user/profile/profile.initial.component.tsprojects/stage11/src/app/user/profile/profile.initial.component.html 找到初始版本的代码。

我们还将使用媒体查询使这个多步骤表单对移动设备做出响应:

  1. 让我们先添加一些辅助数据,这将帮助我们显示带有选项的输入表单:

    **src/app/user/profile/data.****ts**
    export interface IUSState {
      code: string
      name: string
    }
    export function USStateFilter(value: string): IUSState[] {
      return USStates.filter((state) => {
        return (
          (state.code.length === 2 && 
           state.code.toLowerCase() === value.toLowerCase()) ||
           state.name.toLowerCase().indexOf(value.toLowerCase()) === 0
        )
      })
    }
    const USStates = [
      { code: 'AK', name: 'Alaska' },
      { code: 'AL', name: 'Alabama' },
      ...
      { code: 'WY', name: 'Wyoming' },
    ] 
    
  2. common/validations.ts 中添加新的验证规则:

    **src/app/common/validations.****ts**
    ...
    export const OptionalTextValidation = [
      Validators.minLength(2), 
      Validators.maxLength(50)
    ]
    export const RequiredTextValidation = 
      OptionalTextValidation.concat([Validators.required])
    export const OneCharValidation = [
      Validators.minLength(1), 
      Validators.maxLength(1)
    ] 
    export const USAZipCodeValidation = [ 
      Validators.required, 
      Validators.pattern(/^\d{5}(?:[-\s]\d{4})?$/),
    ]
    export const USAPhoneNumberValidation = [ 
      Validators.required, 
      Validators.pattern(/^\D?(\d{3})\D?\D?(\d{3})\D?(\d{4})$/), 
    ] 
    
  3. 现在,按照以下方式实现 profile.component.ts

    **src/app/user/profile/profile.****component****.****ts**
    import { Role } from '../../auth/auth.enum' 
    import { $enum } from 'ts-enum-util'
    import { IName, IPhone, IUser, PhoneType } from '../user/user'
    ...
    @Component({ 
      selector: 'app-profile', 
      templateUrl: './profile.component.html', 
      styleUrls: ['./profile.component.css'],
    })
    export class ProfileComponent implements OnInit { 
      Role = Role 
      PhoneType = PhoneType 
      PhoneTypes = $enum(PhoneType).getKeys() 
      formGroup: FormGroup 
      states$: Observable<IUSState[]> 
      userError = '' 
      currentUserId: string 
      constructor( 
        private formBuilder: FormBuilder, 
        private uiService: UiService, 
        private userService: UserService, 
        private authService: AuthService
      ) {}
      private destroyRef = inject(DestroyRef)
      ngOnInit() { 
        this.buildForm() 
        this.authService.currentUser$.pipe(
          filter((user) => user !== null), 
          tap((user) => { 
            this.currentUserId = user._id 
            this.buildForm(user) 
          }),
          takeUntilDestroyed(this.destroyRef) 
        ).subscribe()
      }
      private get currentUserRole() { 
        return this.authService.authStatus$.value.userRole 
      }
      buildForm(user?: IUser) {}
      ...
    } 
    

在加载时,我们从authService请求当前用户,但这可能需要一些时间,所以我们首先使用this.buildForm()作为第一条语句构建一个空表单。我们还将在currentUserId属性中存储用户的 ID,我们将在实现save功能时需要它。

注意,我们过滤掉nullundefined用户,以避免尝试在无效状态下构建表单。

上述实现中,如果从 API 获取authService.currentUser$时,会引入一个 UX 问题。如果 API 需要超过半秒钟(实际上,340 毫秒)来返回数据,表单上会出现明显的新信息弹出。这将覆盖用户可能已经输入的任何文本。

为了防止这种情况,我们可以在收到信息后禁用和重新启用表单。然而,该组件并不知道信息来自何处;它只是订阅了authService.currentUser$,它可能永远不会返回值。即使我们能够可靠地判断我们正在从 API 接收数据,我们也必须在每个组件中实现一个定制的解决方案。

使用HttpInterceptor,我们可以全局检测 API 调用何时被触发和完成;我们可以暴露一个signal,让组件可以单独订阅以显示加载指示器,或者我们可以利用UiService来启动全局加载指示器,在从服务器获取数据时阻塞 UI。在第九章食谱 – 主/详情,数据表和 NgRx中,我介绍了如何实现全局加载指示器。

全局加载指示器是 80-20 的终极解决方案。然而,您可能会发现,在具有数十个组件持续检索数据的大型应用程序中,全局加载指示器可能不可行。复杂的 UI 需要昂贵的 UX 解决方案。在这种情况下,您确实需要实现一个组件级别的加载指示器。这在第九章的数据表分页部分进行了演示,食谱 – 主/详情,数据表和 NgRx

在本章的后面部分,我们将实现一个基于路由提供的userId来加载用户的解析守卫,以提高该组件的可重用性。

表单控件和表单组

如您所忆,FormControl对象是表单的最基本部分,通常代表单个输入字段。我们可以使用FormGroup将一组相关的FormControl对象组合在一起,例如一个人的名字的单独的姓、名和姓。FormGroup对象也可以将FormControlFormGroupFormArray对象的混合组合在一起。后者允许我们拥有动态重复的元素。FormArray将在本章的动态表单数组部分进行介绍。

我们的表单有许多输入字段,因此我们将使用由this.formBuilder.group创建的FormGroup来存放我们的各种FormControl对象。此外,子FormGroup对象将允许我们保持数据结构的正确形状。

由于表单的实现在这部分和章节后面的部分之间有显著变化,你可以在 GitHub 上找到初始版本的代码,地址为projects/stage11/src/app/user/profile/profile.initial.component.tsprojects/stage11/src/app/user/profile/profile.initial.component.html

开始构建buildForm函数,如下所示:

**src/app/user/profile/profile.****component****.****ts**
...
  buildForm(user?: IUser) { 
    this.formGroup =
    this.formBuilder.group({
      email: [
        {
          value: user?.email || '',
          disabled: this.currentUserRole !== Role.Manager,
        },
        EmailValidation,
      ],
      name: this.formBuilder.group({
        first: [user?.name?.first || '', RequiredTextValidation],
        middle: [user?.name?.middle || '', OneCharValidation],
        last: [user?.name?.last || '', RequiredTextValidation],
      }),
      role: [
        {
          value: user?.role || '',
          disabled: this.currentUserRole !== Role.Manager,
        },
        [Validators.required],
      ],
      dateOfBirth: [user?.dateOfBirth || '', Validators.required], 
      address: this.formBuilder.group({
        line1: [user?.address?.line1 || '', RequiredTextValidation],
        line2: [user?.address?.line2 || '', OptionalTextValidation],
        city: [user?.address?.city || '', RequiredTextValidation],
        state: [user?.address?.state || '', RequiredTextValidation],
        zip: [user?.address?.zip || '', USAZipCodeValidation],
      }),
    })
  } 

buildForm可以接受一个IUser来预填充表单。否则,所有字段都设置为它们的默认值。formGroup属性本身是顶级FormGroup。根据需要,向其中添加各种FormControls,例如email,并附加必要的验证器。

注意nameaddress是它们自己的FormGroup对象。这种父子关系确保了表单数据序列化到 JSON 时的正确结构,以适应IUser的结构,这样我们的应用程序和服务器端代码就可以利用它。

你将通过遵循本章提供的示例代码独立完成formGroup的实现。在接下来的几节中,我将逐段审查代码,解释某些关键功能。

步进器和响应式布局

Angular Material 的步进器自带MatStepperModule。步进器允许将表单输入分成多个步骤,这样用户就不会同时处理数十个输入字段而感到不知所措。用户仍然可以跟踪他们在过程中的位置,作为副作用,作为开发者,我们可以将我们的<form>实现拆分,并逐步实施验证规则,或者创建可选的工作流程,其中某些步骤可以跳过或为必填项。与所有 Material 用户控件一样,步进器已经设计为具有响应式 UX。在接下来的几节中,我们将实现三个步骤,涵盖过程中的不同表单输入技术:

  1. 账户信息:

    • 输入验证

    • 响应式布局与媒体查询

    • 计算属性

    • DatePicker

  2. 联系信息:

    • 自动完成支持

    • 动态表单数组

  3. 复习:

    • 只读视图

    • 保存和清除数据

让我们先添加 Angular material 依赖项:

  1. profile.component.ts中导入以下 Material 模块:

    MatAutocompleteModule,
    MatButtonModule,
    MatDatepickerModule,
    MatFormFieldModule,
    MatIconModule,
    MatInputModule,
    MatListModule,
    MatNativeDateModule,
    MatOptionModule,
    MatRadioModule,
    MatSelectModule,
    MatStepperModule,
    MatToolbarModule, 
    
  2. 导入其他支持模块:

    FlexModule,
    ReactiveFormsModule,
    ... 
    
  3. 实现包含第一步的横向步进器表单:

    **src/app/user/profile/profile.component.html**
    <mat-toolbar color="accent">
    <h5>User Profile</h5>
    </mat-toolbar>
    <mat-horizontal-stepper #stepper="matHorizontalStepper">
      <mat-step [stepControl]="formGroup">
        <form [formGroup]="formGroup">
          <ng-template matStepLabel>Account Information</ng-template>
          <div class="stepContent">
            ...
          </div>
        </form>
      </mat-step>
    </mat-horizontal-stepper> 
    
  4. 现在,开始实现上一步骤中省略号的Account Information步骤的name行:

    **src/app/user/profile/profile.component.html**
    <div fxLayout="row" fxLayout.lt-sm="column"   
         [formGroup]="formGroup.get('name')" fxLayoutGap="10px">
      <mat-form-field appearance="outline" fxFlex="40%">
        <input matInput placeholder="First Name"
               aria-label="First Name" formControlName="first">
          @if (formGroup.get('name.first')?.hasError('required'))
          {
             <mat-error>First Name is required</mat-error>
          }
          @if (formGroup.get('name.first')?.hasError('minLength'))
          {
            <mat-error>Must be at least 2 characters</mat-error>
          }
          @if (formGroup.get('name.first')?.hasError('maxLength'))
          {
            <mat-error>Can't exceed 50 characters</mat-error>
          }
      </mat-form-field>
      <mat-form-field appearance="outline" fxFlex="20%">
        <input matInput placeholder="MI" 
          aria-label="Middle Initial" formControlName="middle">
          @if (formGroup.get('name.middle')?.hasError('invalid'))
          {
            <mat-error>Only initial</mat-error>
          }
      </mat-form-field>
      <mat-form-field appearance="outline" fxFlex="40%">
        <input matInput placeholder="Last Name" 
          aria-label="Last Name" formControlName="last">
          @if (formGroup.get('name.last')?.hasError('required'))
          {
            <mat-error>Last Name is required</mat-error>
          }
          @if (formGroup.get('name.last')?.hasError('minLength'))
          {
            <mat-error>Must be at least 2 characters</mat-error>
          }
          @if (formGroup.get('name.last')?.hasError('maxLength'))
          {
            <mat-error>Can't exceed 50 characters</mat-error>
          }  
      </mat-form-field>
    </div> 
    
  5. 仔细理解到目前为止的步进器和表单配置工作原理。你应该能看到第一行渲染,从lemon-mart-server获取数据:

    图 8.1:多步表单 – 第一步

注意,将fxLayout.lt-sm="column"添加到具有fxLayout="row"的行中,可以为表单启用响应式布局,如下所示:

图 8.2:移动端多步表单

在我们继续介绍如何实现出生日期字段之前,让我们通过实现错误消息来重新评估我们的策略。

使用指令重用重复的模板行为

在上一节中,我们为 name 对象的每个字段部分的每个验证错误都实现了一个 mat-error 元素。对于三个字段,这会迅速增加到七个元素。在 第五章设计身份验证和授权 中,我们实现了 common/validations.ts 以重用验证规则。我们可以使用属性指令重用我们在 mat-error 中实现的行为,或者任何其他 div

属性指令

第一章Angular 的架构和概念 中,我提到 Angular 组件代表 Angular 应用中最基本的单元。通过组件,我们定义可以重用模板和一些 TypeScript 代码所表示的功能和特性的自定义 HTML 元素。相反,指令增强了现有元素或组件的功能。从某种意义上说,组件是一个增强基本 HTML 功能的超指令。

基于这个观点,我们可以定义三种类型的指令:

  • 组件

  • 结构性指令

  • 属性指令

基本上,组件是带有模板的指令;这是你将最常使用的指令类型。结构性指令通过添加或删除元素来修改 DOM,*ngIf*ngFor 是典型的例子。

截至 Angular 17,你可以使用 @-语法来实现控制流和可延迟视图,分别用 @if@for 替代 *ngIf*ngFor。下面是一个示例代码片段:

@if (user.isHuman) {
  <human-profile [data]="user" />
} @else if (user.isRobot) {
  <!-- robot users are rare, so load their profiles lazily -->
  @defer {
    <robot-profile [data]="user" />
  }
} @else {
  <p>The profile is unknown!
} 

最后,属性指令允许你定义可以添加到 HTML 元素或组件中的新属性,添加新的行为。

让我们实现一个可以封装字段级错误行为的属性指令。

字段错误属性指令

想象一下使用指令来减少显示字段错误时的重复元素。以下是一个使用姓名字段作为示例的例子:

**example**
<mat-form-field appearance="outline" fxFlex="40%">
  <mat-label>First Name</mat-label>
  <input matInput aria-label="First Name" formControlName="first" #name />
  <mat-error **[****input****]=****"name"** **[****group****]=****"formGroup.get('name')"**
    **[****appFieldError****]=****"ErrorSets.RequiredText"****>**
  </mat-error>
</mat-form-field> 

我们有 Material 表单字段的常规布局结构,但只有一个 mat-error 元素。mat-error 上有三个新属性:

  • input 绑定到被标记为 #name 的 HTML 输入元素,使用模板引用变量,这样我们就可以监听输入元素的 blur 事件并读取 placeholderaria-labelformControlName 属性。

  • group 绑定到包含表单控件的父 FormGroup 对象,因此通过使用输入的 formControlName 属性,我们可以检索 formControl 对象,同时避免额外的代码。

  • appFieldError 绑定到一个数组,该数组包含必须与 formControl 对象(如 requiredminlengthmaxlengthinvalid)进行校验的验证错误。

使用前面的信息,我们可以创建一个指令,可以在 mat-error 元素内渲染一行或多行错误信息,有效地复制我们在上一节中使用的方法。

让我们继续创建一个名为 FieldErrorDirective 的属性指令:

  1. src/app/user-controls下创建FieldErrorDirective

  2. 将指令的选择器定义为名为appFieldError的可绑定属性:

    **src/app/user-controls/field-error/field-error.****directive****.****ts**
    @Directive({
      selector: **'[appFieldError]'**,
    }) 
    
  3. 在指令之外,定义两个新类型ValidationErrorValidationErrorTuple,它们定义我们将要处理的错误条件类型以及允许我们将自定义错误消息附加到错误类型上的结构:

    **src/app/user-controls/field-error/field-error.****directive****.****ts**
    export type ValidationError = 
       'required' | 'minlength' | 'maxlength' | 'invalid'
    export type ValidationErrorTuple = {
      error: ValidationError;
      message: string
    } 
    
  4. 就像我们分组验证一样,让我们定义两组常见的错误条件,这样我们就不必一遍又一遍地输入它们:

    **src/app/user-controls/field-error/field-error.****directive****.****ts**
    export const ErrorSets: { [key: string]: ValidationError[] } = {
      OptionalText: ['minlength', 'maxlength'],
      RequiredText: ['minlength', 'maxlength', 'required'],
    } 
    
  5. 接下来,让我们定义指令的@Input目标:

    **src/app/user-controls/field-error/field-error.****directive****.****ts**
    export class FieldErrorDirective implements OnDestroy, OnChanges {
      @Input() appFieldError:
        | ValidationError
        | ValidationError[]
        | ValidationErrorTuple
        | ValidationErrorTuple[]
      @Input() input: HTMLInputElement | undefined
      @Input() group: FormGroup
      @Input() fieldControl: AbstractControl | null
      @Input() fieldLabel: string | undefined 
    

    注意,我们已经讨论了前三个属性的目的。fieldControlfieldLabel是可选属性。如果指定了inputgroup,则可选属性可以自动填充。由于它们是类级变量,如果用户想要覆盖指令的默认行为,则公开它们是有意义的。这是一个容易的胜利,可以创建灵活且可重用的控件。

  6. constructor中导入元素引用,这可以在稍后由renderErrors函数用于在mat-error元素的内部 HTML 中显示错误:

    **src/app/user-controls/field-error/field-error.****directive****.****ts**
      private readonly nativeElement: HTMLElement
      constructor(private el: ElementRef) {
        this.nativeElement = this.el.nativeElement
      }
      renderErrors(errors: string) {
        this.nativeElement.innerText = errors
      } 
    
  7. 实现一个函数,该函数可以根据错误类型返回预定义的错误消息:

    **src/app/user-controls/field-error/field-error.****directive****.****ts**
      getStandardErrorMessage(error: ValidationError): string {
        const label = this.fieldLabel || 'Input'
        switch (error) {
          case 'required':
            return `${label} is required`
          case 'minlength':
            return `${label} must be at least ${
              this.fieldControl?.getError(error)?.requiredLength ?? 2
            } characters`
          case 'maxlength':
            return `${label} can\'t exceed ${
              this.fieldControl?.getError(error)?.requiredLength ?? 50
            } characters`
          case 'invalid':
            return `A valid ${label} is required`
        }
      } 
    

    注意,我们可以从fieldControl动态提取所需的minlengthmaxlength,这大大减少了我们需要生成的自定义消息的数量。

  8. 实现一个算法,该算法可以使用getStandardErrorMessage方法遍历appFieldError中的所有元素和需要显示的错误:

    **src/app/user-controls/field-error/field-error.****directive****.****ts**
    updateErrorMessage() {
        const errorsToDisplay: string[] = []
        const errors = Array.isArray(this.appFieldError)
          ? this.appFieldError
          : [this.appFieldError]
        errors.forEach(
          (error: ValidationError 
                | { error: ValidationError; message: string }) => {
            const errorCode = 
              typeof error === 'object' ? error.error : error 
            const message =
              typeof error === 'object'
                ? () => error.message
                : () => this.getStandardErrorMessage(errorCode)
            const errorChecker =
              errorCode === 'invalid'
                ? () => this.fieldControl?.invalid
                : () => this.fieldControl?.hasError(errorCode)
            if (errorChecker()) {
              errorsToDisplay.push(message())
            }
          }
        )
        this.renderErrors(errorsToDisplay.join('<br>'))
      } 
    

    最终,我们可以使用renderErrors方法显示错误信息。

    注意函数委托的使用,这是一种允许函数被传递并用作变量的技术。由于此代码每分钟将执行数百次,因此避免不必要的调用很重要。函数委托有助于更好地组织我们的代码,并在绝对必要时才延迟其逻辑的执行。这种编码模式允许使用记忆技术进一步提高性能。有关更多详细信息,请参阅进一步阅读部分。

  9. 现在,初始化fieldControl属性,它代表一个formControl。我们将监听控制的valueChanges事件,如果验证状态无效,则执行我们的自定义updateErrorMessage逻辑来显示错误信息:

    **src/app/user-controls/field-error/field-error.****directive****.****ts**
    private controlSubscription: Subscription | undefined
    ngOnDestroy(): void {
      this.unsubscribe()
    }
    unsubscribe(): void {
      this.controlSubscription?.unsubscribe()
    }
    initFieldControl() {
        if (this.input && this.group) {
          const controlName = this.input.
            getAttribute('formControlName') ?? ''
          this.fieldControl =
            this.fieldControl || this.group.get(controlName)
          if (!this.fieldControl) {
            throw new Error( 
              `[appFieldError] couldn't bind to control ${controlName}`
            )
          }
          this.unsubscribe()
          this.controlSubscription = this.fieldControl?.valueChanges
            .pipe(
              filter(() => this.fieldControl?.status === 'INVALID'),
              tap(() => this.updateErrorMessage())
            )
            .subscribe()
        }
      } 
    

    注意,由于我们正在订阅valueChanges,我们也必须取消订阅。我们使用ngOnDestroy取消订阅一次,然后在再次订阅之前再次取消订阅。这是因为initFieldControl可能会被多次调用。如果我们不清除之前的订阅,将导致内存泄漏和相关性能问题。

    此外,如果我们无法绑定到fieldControl,我们会抛出一个错误信息,这通常指向一个编码错误。

  10. 最后,我们使用 ngOnChanges 事件来配置所有主要属性,该事件在 @Input 属性更新时触发。这确保了,在表单元素可能动态添加或删除的情况下,我们始终考虑最新的值。我们调用 initFieldControl 来开始监听值的变化,实现一个触发 updateErrorMessage()onblur 事件处理器,并为 HTML 输入元素分配 fieldLabel 的值:

    **src/app/user-controls/field-error/field-error.****directive****.****ts**
      ngOnChanges(changes: SimpleChanges): void {
        this.initFieldControl()
        if (changes.input.firstChange) {
          if (this.input) {
            this.input.onblur = () => this.updateErrorMessage()
            this.fieldLabel =
              this.fieldLabel ||
              this.input.placeholder ||
              this.input.getAttribute('aria-label') ||
              ''
          } else {
            throw new Error(
              `appFieldError.[input] couldn't bind to any input element`
            )
          }
        }
      } 
    

注意,如果我们无法绑定到 HTML input 元素,这通常意味着开发者忘记正确连接这些元素。在这种情况下,我们抛出一个新的 Error 对象,这在控制台中生成一个有用的堆栈跟踪,以便你可以确定模板中错误发生的位置。

这完成了指令的实现。现在,我们需要将指令打包到一个名为 field-error.module.ts 的模块中:

**src/app/user-controls/field-error/field-error.****directive****.****ts**
  @NgModule({
  imports: [CommonModule, ReactiveFormsModule],
  declarations: [FieldErrorDirective],
  exports: [FieldErrorDirective],
})
export class FieldErrorModule {} 

现在,继续在我们的现有表单中使用这个指令:

  1. app.module.tsuser.module.ts 中导入该模块。

  2. 使用新指令更新 profile.component.html

  3. 使用新指令更新 login.component.html

确保在 component 类中将 ErrorSets 定义为一个公共属性变量,以便你可以在模板中使用它。

测试你的表单,以确保我们的验证消息按预期显示,并且没有控制台错误。

恭喜!你已经学会了如何使用指令将新行为注入到其他元素和组件中。通过这样做,我们可以避免大量的重复代码,并在我们的应用程序中标准化错误消息。

在继续之前,通过查看 GitHub 上的实现来完成表单的实现。你可以在 projects/stage11/src/app/user/profile/profile.initial.component.html 中找到表单模板的代码,以及在 projects/stage11/src/app/user/profile/profile.initial.component.ts 中找到 component 类。

不要包含 app-lemon-raterapp-view-user 元素,并从电话号码中移除 mask 属性,我们将在本章后面实现它。

在这里,你可以看到在 LemonMart 上显示的用户资料:

图片

图 8.3:基本完成的资料组件

接下来,我们将回顾 profile 组件,看看 出生日期 字段是如何工作的。

计算属性和日期选择器

我们可以根据用户输入显示计算属性。例如,为了显示一个人的年龄,基于他们的出生日期,引入计算年龄的类属性,并如下显示:

**src/app/user/profile/profile.****component****.****ts**
now = new Date()
get dateOfBirth() {
  return this.formGroup.get('dateOfBirth')?.value || this.now
}
get age() {
  return this.now.getFullYear() - this.dateOfBirth.getFullYear()
} 

age属性获取器的实现并不是最有效率的选项。为了计算年龄,我们调用this.nowthis.dateOfBirthgetFullYear()函数。作为一个在模板中引用的属性,Angular 的变更检测算法将每秒调用age多达 60 次,与其他屏幕上的元素混合,这可能导致严重的性能问题。你可以通过创建一个纯自定义管道来解决这个问题,这样 Angular 只会在其依赖值之一发生变化时检查age属性。

你可以在angular.dev/guide/pipes/change-detection了解更多关于纯管道的信息。

另一个选项是使用计算信号。与计算属性类似,计算信号是只读信号,其值来自其他信号。

我们可以将上面的代码重写如下:

 now = new Date()
  dateOfBirth = 
    signal(
     this.formGroup.get('dateOfBirth')?.value || this.now
    )
  age = computed(() => 
    this.now.getFullYear() - this.dateOfBirth().getFullYear()) 

我们将dateOfBirth创建为一个信号,将age创建为一个计算信号。使用这种设置,只有当dateOfBirth发生变化时,age才会更新。正如你所看到的,实现很简单,Angular 的变更检测算法将默认做正确的事情。

除了一个小问题!由于缺少基于信号的组件和必需的FormGroup支持,我们无法直接在响应式表单中使用dateOfBirthage

这有助于你理解对于 Angular 来说,信号变化有多大。更多内容请参阅angular.dev/guide/signals#computed-signals

要验证过去一百年内的日期,实现一个minDate类属性:

**src/app/user/profile/profile.****component****.****ts**
  minDate = new Date(
    this.now.getFullYear() - 100,
    this.now.getMonth(),
    this.now.getDate()
  ) 

模板中计算属性的用法如下:

**src/app/user/profile/profile.component.html**
<mat-form-field appearance="outline" fxFlex="50%">
  <mat-label>Date of Birth</mat-label>
  <input matInput aria-label="Date of Birth" formControlName="dateOfBirth"
    **[****min****]=****"minDate"** **[****max****]=****"now"** [matDatepicker]="dateOfBirthPicker" #dob />
  @if (formGroup.get('dateOfBirth')?.value) {
    <mat-hint> {{ age }} year(s) old </mat-hint>
  }
  <mat-datepicker-toggle matSuffix [for]="dateOfBirthPicker">
  </mat-datepicker-toggle>
  <mat-datepicker #dateOfBirthPicker></mat-datepicker>
  <mat-error [input]="dob" [group]="formGroup"
    [appFieldError]=
      "{error: 'invalid', message: 'Date must be within the last 100 years'}">
  </mat-error>
</mat-form-field> 

参考前面代码片段中突出显示的[min][max]属性,以应用一百年的日期范围。

DatePicker的实际使用效果如下:

图 8.4:使用 DatePicker 选择日期

注意,4 月 26 日之后的日期变灰。选择日期后,计算出的年龄将显示如下:

图 8.5:计算年龄属性

现在,让我们继续下一步,联系信息,看看我们如何能够方便地显示和输入地址字段的状态部分。

类型提示支持

buildForm中,我们为address.state设置一个监听器,以支持类型提示过滤下拉体验:

**src/app/user/profile/profile.****component****.****ts**
const state = this.formGroup.get('address.state')
if (state != null) {
  this.states$ = state.valueChanges.pipe(
    startWith(''),
    map((value) => USStateFilter(value))
  )
} 

在模板中,实现mat-autocomplete,通过async管道绑定到过滤后的状态数组:

**src/app/user/profile/profile.component.html**
...
<mat-form-field appearance="outline" fxFlex="30%">
  <mat-label>State</mat-label>
  <input type="text" aria-label="State" matInput formControlName="state"
    [matAutocomplete]="stateAuto" #state />
  <mat-autocomplete #stateAuto="matAutocomplete">
    @for (state of states$ | async; track state) {
      <mat-option [value]="state.name">
        {{ state.name }}
      </mat-option>
    }
  </mat-autocomplete>
  <mat-error [input]="state" [group]="formGroup.get('address')"
    appFieldError="required">
  </mat-error>
</mat-form-field> 
... 

当用户输入字符V时,它看起来是这样的:

图 8.6:具有类型提示支持的下拉菜单

在下一节中,让我们启用多个电话号码的输入。

动态表单数组

注意,phones 属性是一个数组,可能允许有多个输入。我们可以通过使用 this.formBuilder.array 函数构建 FormArray 来实现这一点。我们还定义了几个辅助函数,以使构建 FormArray 更加容易:

  • buildPhoneFormControl 有助于构建单个条目的 FormGroup 对象。

  • buildPhoneArray 会创建所需数量的 FormGroup 对象,或者如果表单为空,则创建一个空条目。

  • addPhoneFormArray 添加一个新的空 FormGroup 对象。

  • get phonesArray() 是一个方便的属性,用于从表单中获取 phones 控件。

让我们看看实现是如何结合在一起的:

**src/app/user/profile/profile.****component****.****ts**
...
phones: this.formBuilder.array(this.buildPhoneArray(user?.phones || [])),
...
  private buildPhoneArray(phones: IPhone[]) {
    const groups = []
    if (phones?.length === 0) {
      groups.push(this.buildPhoneFormControl(1))
    } else {
      phones.forEach((p) => {
        groups.push(
          this.buildPhoneFormControl(p.id, p.type, p.digits)
        )
      })
    }
    return groups
      } 
  private buildPhoneFormControl(
    id: number, type?: string, phoneNumber?: string
  ) {
    return this.formBuilder.group({
      id: [id],
      type: [type || '', Validators.required],
      digits: [phoneNumber || '', USAPhoneNumberValidation],
  })
} 
... 

buildPhoneArray 支持使用单个电话输入初始化表单,或者通过填充现有数据来实现,与 buildPhoneFormControl 一起工作。当用户点击 Add 按钮创建新行时,后者非常有用:

**src/app/user/profile/profile.****component****.****ts**
...
addPhone() { this.phonesArray.push(
this.buildPhoneFormControl(
  this.formGroup.get('phones').value.length + 1)
)
}
get phonesArray(): FormArray {
  return this.formGroup.get('phones') as FormArray
}
... 

phonesArray 属性的获取器是一个常见的模式,它使得访问某些表单属性变得更加容易。然而,在这种情况下,它也是必要的,因为 get('phones') 必须转换为 FormArray 类型,这样我们才能在模板上访问其 length 属性:

**src/app/user/profile/profile.component.html**
...
<mat-list formArrayName="phones">
  <h2 mat-subheader>Phone Number(s)
    <button mat-button (click)="addPhone()">
      <mat-icon>add</mat-icon>
      Add Phone
    </button>
  </h2>
  @for (position of phonesArray.controls; track position; let i = $index) 
  {
    <mat-list-item [formGroupName]="i"> 
      <mat-form-field appearance="outline" fxFlex="100px">
        <mat-label>Type</mat-label>
        <mat-select formControlName="type">
          @for (type of PhoneTypes; track type) {
            <mat-option [value]="convertTypeToPhoneType(type)">
              {{ type }}
            </mat-option>
          }      
       </mat-select>
    </mat-form-field>
    <mat-form-field appearance="outline" fxFlex fxFlexOffset="10px">
      <mat-label>Number</mat-label>
      <input matInput type="text" formControlName="digits"
        aria-label="Phone number" prefix="+1" />
        @if (phonesArray.controls[i].invalid && 
             phonesArray.controls[i].touched) {
          <mat-error>A valid phone number is required</mat-error>
        }     
    </mat-form-field>
    <button fxFlex="33px" mat-icon-button
      (click)="**phonesArray.removeAt(i)**"> 
      <mat-icon>delete</mat-icon>
    </button>
  </mat-list-item>
  }
</mat-list> 
... 

注意突出显示的 convertTypeToPhoneType 函数,它将 string 转换为 enum PhoneType

在前面的代码块中,还突出了 remove 函数是如何在模板中内联实现的,这使得它更容易阅读和维护。

让我们看看动态数组应该如何工作:

图 8.7:使用 FormArray 的多个输入

现在我们已经完成了数据输入,我们可以继续到步骤器的最后一步 Review。然而,如前所述,Review 步骤使用 <app-view-user> 指令来显示其数据。让我们首先构建这个视图。

创建共享组件

这里是 <app-view-user> 指令的最小实现,这是 Review 步骤的先决条件。

user 文件夹结构下创建一个新的 viewUser 组件,如下所示:

**src/app/user/view-user/view-user.****component****.****ts**
import { AsyncPipe, DatePipe } from '@angular/common'
import {
  Component, inject, Input, OnChanges, SimpleChanges
} from '@angular/core'
import { MatButtonModule } from '@angular/material/button'
import { MatCardModule } from '@angular/material/card'
import { MatIconModule } from '@angular/material/icon'
import { Router } from '@angular/router'
import { IUser, User } from '../user/user'
@Component({
  selector: 'app-view-user',
  template: `
    @if (currentUser) {
      <div>
        <mat-card appearance="outlined">
          <mat-card-header>
            <div mat-card-avatar>
              <mat-icon>account_circle</mat-icon>
            </div>
            <mat-card-title>{{ currentUser.fullName }}</mat-card-title>
            <mat-card-subtitle>{{ currentUser.role }}</mat-card-subtitle>
          </mat-card-header>
          <mat-card-content>
            <p><span class="mat-input bold">E-mail</span></p>
            <p>{{ currentUser.email }}</p>
            <p><span class="mat-input bold">Date of Birth</span></p>
            <p>{{ currentUser.dateOfBirth | date: 'mediumDate' }}</p>
          </mat-card-content>
          @if (editMode) {
            <mat-card-actions>
              <button mat-button mat-raised-button (click)="editUser(currentUser._id)">
                Edit
              </button>
            </mat-card-actions>
          }
        </mat-card>
      </div>
    }
  `,
  styles: `
    .bold {
      font-weight: bold;
    }
`,
  standalone: true,
  imports: [MatCardModule, MatIconModule, MatButtonModule, AsyncPipe, DatePipe],
})
export class ViewUserComponent implements OnChanges {
  private readonly router = inject(Router)
  @Input() user!: IUser
  currentUser = new User()
  get editMode() {
    return !this.user
  }
  ngOnChanges(changes: SimpleChanges): void {
    this.currentUser = User.Build(changes['user'].currentValue)
  }
  editUser(id: string) {
    this.router.navigate(['/user/profile', id])
  }
} 

前面的组件使用 @Input 输入绑定来从外部组件获取符合 IUser 接口的用户数据。我们实现了 ngOnChanges 生命周期钩子,它在绑定数据更改时触发。在这个事件中,我们使用 User.Build 将存储在 user 属性中的简单 JSON 对象作为 User 类的实例进行填充。

然后,我们将 User 对象分配给属性 this.currentUser。即使我们想直接绑定到用户属性,也是不可能的,因为像 fullName 这样的计算属性只能在数据被注入到 User 类的实例中时才能工作。Angular 17.1 在开发者预览中引入了基于信号的输入。我们可以定义用户为 user = input<IUser>() 并利用效果和计算信号来简化我们的实现。在我们的代码当前状态下,由于我们绑定的属性数量众多,我们承受着沉重的变更检测惩罚。然而,在基于信号的组件中则不会有这样的惩罚。我期待着在基于信号的组件发布时重构这个组件。

现在,我们准备完成多步骤表单。

审查和保存表单数据

在多步骤表单的最后一步,用户应该能够审查并保存表单数据。作为一个好的实践,成功的 POST 请求将返回保存的数据,并将其返回到浏览器。然后我们可以用从服务器接收到的信息重新加载表单:

**src/app/user/profile/profile.****component****.****ts**
...
async save(form: FormGroup) {
    this.userService
      .updateUser(this.currentUserId, form.value)
      .pipe(first())
      .subscribe({
        next: (res: IUser) => {
          this.patchUser(res)
          **this****.****formGroup****.****patchValue****(res)**
          this.uiService.showToast('Updated user')
        },
        error: (err: string) => (this.userError = err),
      })
  }
... 

注意,updateUser 返回用户的保存值。数据库可能返回与之前不同的 user 版本,因此我们使用 formGroup.patchValue 来更新表单背后的数据。表单会自动更新以反映任何更改。

如果在保存数据时出现错误,它们将被设置为 userError 以在表单上显示。在保存数据之前,我们使用可重用的 app-view-user 组件以紧凑的格式展示数据,我们可以将表单数据绑定到该组件:

**src/app/user/profile/profile.component.html**
...
<mat-step [stepControl]="formGroup">
  <form [formGroup]="formGroup" (ngSubmit)="save(formGroup)">
    <ng-template matStepLabel>Review</ng-template>
    <div class="stepContent">
      Review and update your user profile.
      **<****app-view-user** **[****user****]=****"formGroup.getRawValue()"****></****app-view-user****>**
    </div>
    <div fxLayout="row" class="margin-top">
      <button mat-button matStepperPrevious>Back</button>
      <div class="flex-spacer"></div>
      @if (userError) {
        <div class="mat-caption error">{{ **userError** }}</div>
      } 
      <button mat-button color="warn" (click)="**stepper.reset()**">
        Reset
      </button>
      <button mat-raised-button matStepperNext color="primary" 
        type="submit" [disabled]="formGroup.invalid">
        Update
      </button>
    </div>
  </form>
</mat-step>
... 

注意,我们使用 formGroup.getRawValue() 来提取表单数据的 JSON。看看我们是如何将 userError 绑定到显示错误消息的。此外,重置按钮使用 stepper.reset(),可以方便地重置所有用户输入。

这就是最终产品应该呈现的样子:

图 8.8

图 8.8:审查步骤

现在用户资料输入已完成,我们离最终目标——创建一个主/详细视图还有一半的路要走,在这个视图中,经理可以点击用户并查看其资料详情。我们还有更多的代码要添加,并且在过程中,我们陷入了添加大量样板代码以加载组件所需数据的模式。

接下来,让我们重构我们的表单,使其代码可重用和可扩展,这样即使我们的表单有数十个字段,代码仍然是可维护的,并且我们不会引入指数级成本增加来做出更改。

具有可重用部分的可扩展表单架构

如在 多步骤响应式表单 部分的介绍中提到的,表单是紧密耦合的怪物,可能会变得很大,使用错误的架构模式来扩展你的实现,在实现新功能或维护现有功能时可能会引起重大问题。

为了展示如何将你的表单拆分成多个部分,我们将重构它,提取以下截图中的突出部分,即名称FormGroup,作为一个单独的组件。完成这一点的技术与你想要将表单的每一步放入单独组件时使用的技术相同:

图片

图 8.9:用户个人资料名称部分被突出显示

通过使FormGroup名称可重用,你还将了解如何将你构建到FormGroup中的业务逻辑在其他表单中重用。我们将把FormGroup逻辑提取到一个名为NameInputComponent的新组件中。在这个过程中,我们也有机会将一些可重用的表单功能提取到BaseFormComponent作为一个抽象类。

这里将会有几个组件协同工作,包括ProfileComponentViewUserComponentNameInputComponent。我们需要这三个组件中的所有值在用户输入时保持最新。

ProfileComponent将拥有主表单,我们需要将任何子表单注册到这个主表单上。一旦完成注册,你之前学到的所有表单验证技术仍然适用。

这是一种关键的方法,可以使你的表单能够在许多组件之间扩展,并且继续易于操作,同时不会引入不必要的验证开销。因此,回顾这些对象之间的不同交互对于巩固你对它们行为异步和解耦性质的理解是有用的:

图片

图 8.10:表单组件交互

在本节中,我们将结合你在本书中学到的许多概念。利用前面的图来理解各种表单组件之间的交互。

在前面的图中,粗体属性表示数据绑定。下划线函数元素表示事件注册。箭头显示了组件之间的连接点。

工作流程从ProfileComponent的实例化开始。组件的OnInit事件开始构建formGroup对象,同时异步加载可能需要修补到表单中的任何潜在initialData。参考前面的图来查看从服务或缓存中进入的initialData的视觉表示。

NameInputComponentProfileComponent表单中作为<app-name-input>使用。为了将initialDataNameInputComponent同步,我们使用async管道绑定一个nameInitialData$主题,因为initialData是异步到达的。

NameInputComponent实现了OnChanges生命周期钩子,所以每当nameInitialData$更新时,其值就会被修补到NameInputComponent表单中。

ProfileComponent一样,NameInputComponent也实现了OnInit事件来构建其formGroup对象。由于这是一个异步事件,NameInputComponent需要公开一个formReady事件,ProfileComponent可以订阅它。一旦formGroup对象就绪,我们发出事件,ProfileComponent上的registerForm函数触发。registerFormNameInputComponentformGroup对象作为子元素添加到父formGroup上。

ViewUserComponentProfileComponent表单中作为<app-view-user>使用。当父表单中的值发生变化时,我们需要<app-view-user>保持最新状态。我们绑定到ViewUserComponent上的user属性,该组件实现了OnChanges以接收更新。每次更新时,User对象都会从IUser对象中恢复,因此计算字段如fullName可以继续工作。然后,更新的User被分配给currentUser,绑定到模板上。

我们将首先构建BaseFormComponent,然后NameInputComponentProfileComponent将实现它。

抽象表单组件

我们可以通过实现一个基抽象类来共享通用功能并标准化实现所有实现表单的组件。一个抽象类不能单独实例化,因为它这样做没有意义,因为它将没有模板,使其单独使用变得无用。

注意,BaseFormComponent只是一个class,而不是 Angular 组件。

BaseFormComponent将标准化以下内容:

  • @Input initialDatadisable作为绑定目标

  • @Output formReady事件

  • formGroup,以及要在模板的buildForm函数中使用的FormGroup来构建formGroup

在前面的假设下,基类可以提供一些通用功能:

  • patchUpdatedData可以在不重建的情况下更新formGroup中的数据(部分或全部)。

  • registerFormderegisterForm可以注册或注销子表单。

  • deregisterAllForms可以自动注销任何已注册的子表单。

  • hasChanged可以根据由ngOnChange事件处理器提供的SimpleChange对象确定initialData是否已更改。

  • patchUpdatedDataIfChanged利用hasChanged并使用patchUpdatedDatainitialDataformGroup已初始化的情况下更新数据,仅当initialData有更新时。

src/common下创建一个新的类,BaseFormComponent,如下所示:

**src/app/common/base-form.****class****.****ts**
import { EventEmitter, Input, Output, SimpleChange, SimpleChanges } 
   from '@angular/core'
import { AbstractControl, FormGroup } from '@angular/forms'
export abstract class BaseFormComponent<TFormData extends object> {
  @Input() initialData: TFormData
  @Input() disable: boolean
  @Output() formReady: EventEmitter<AbstractControl>
  formGroup: FormGroup
  private registeredForms: string[] = []
  constructor() {
    this.formReady = new EventEmitter<AbstractControl>(true)
  }
  abstract buildForm(initialData?: TFormData): FormGroup
  patchUpdatedData(data: object) {
    this.formGroup.patchValue(data, { onlySelf: false })
  }
  patchUpdatedDataIfChanged(changes: SimpleChanges) {
    if (this.formGroup && this.hasChanged(changes.initialData)) {
      this.patchUpdatedData(this.initialData)
    }
  }
  emitFormReady(control: AbstractControl | null = null) {
    this.formReady.emit(control || this.formGroup)
  }
  registerForm(name: string, control: AbstractControl) {
    this.formGroup.setControl(name, control)
    this.registeredForms.push(name)
  }
  deregisterForm(name: string) {
    if (this.formGroup.contains(name)) {
      this.formGroup.removeControl(name)
    }
  }
  protected deregisterAllForms() {
    this.registeredForms.forEach(() => this.deregisterForm(name))
  }
  protected hasChanged(change: SimpleChange): boolean {
    return change?.previousValue !== change?.currentValue
  }
} 

让我们使用BaseFormComponent来实现NameInputComponent

实现可重用的表单部分

首先在profile组件代码和模板文件中识别名称FormGroup

  1. 以下是对FormGroup名称的实现:

    **src/app/user/profile/profile.****component****.****ts**
    ...
    name: this.formBuilder.group({
      first: [user?.name?.first || '', RequiredTextValidation],
      middle: [user?.name?.middle || '', OneCharValidation],
      last: [user?.name?.last || '', RequiredTextValidation],
    }),
    ... 
    

注意,当我们将这些验证规则移动到新的组件时,我们仍然希望它们在确定父表单的整体验证状态时仍然有效。我们通过使用上一节中实现的registerForm函数来实现这一点。一旦我们的新FormGroup与现有的一个注册,它们的工作方式与重构之前相同。

  1. 接下来是FormGroup模板:

    **src/app/user/profile/profile.component.html**
    ...
    <div fxLayout="row" fxLayout.lt-sm="column"
      [formGroup]="formGroup.get('name')" fxLayoutGap="10px">
      <mat-form-field appearance="outline" fxFlex="40%">
        <mat-label>First Name</mat-label>
        <input matInput aria-label="First Name" 
               formControlName="first" #name />
        ...
    </div>
    ... 
    

你将把大部分代码移动到新的组件中。

  1. user文件夹下创建一个新的NameInputComponent

  2. BaseFormComponent扩展类。

  3. constructor中注入FormBuilder

对于具有小型或有限功能的功能组件,我更喜欢使用内联模板和样式来创建它们,这样更容易从一处更改代码。

**src/app/user/name-input/name-input.****component****.****ts**
export class NameInputComponent extends BaseFormComponent<IName> {
  constructor(private formBuilder: FormBuilder) {
    super()
  }
  buildForm(initialData?: IName): FormGroup {
    throw new Error("Method not implemented.");
  }
  ...
} 

记住,基类已经实现了formGroupinitialDatadisableformReady属性,因此你不需要重新定义它们。

注意,我们必须实现buildForm函数,因为它被定义为抽象的。这是强制开发者之间统一标准的好方法。此外,注意实现类可以通过使用override关键字重新定义函数来覆盖任何由基类提供的函数。当我们在重构ProfileComponent时,你会看到这个功能的具体实现。

  1. 实现函数buildForm

  2. ProfileComponent中的formGroupname属性设置为null

    **src/app/user/name-input/name-input.****component****.****ts**
    export class NameInputComponent implements OnInit {
      ...
      buildForm(initialData?: IName): FormGroup {
        const name = initialData
        return this.formBuilder.group({
          first: [name?.first : '', RequiredTextValidation],
          middle: [name?.middle : '', OneCharValidation], 
          last: [name?.last : '', RequiredTextValidation],
        })
      } 
    
  3. 通过将ProfileComponent中的内容迁移过来来实现模板:

    **src/app/user/name-input/name-input.****component****.****ts**
    template: `
        <form [formGroup]="formGroup">
          <div fxLayout="row" fxLayout.lt-sm="column"
            fxLayoutGap="10px">
            ...
          </div>
        </form>
      `, 
    
  4. 实现事件处理器ngOnInit

    **src/app/user/name-input/name-input.****component****.****ts**
    ngOnInit() {
      this.formGroup = this.buildForm(this.initialData)
      this.formReady.emit(this.formGroup)
    } 
    

在每个BaseFormComponent实现中正确实现ngOnInit事件处理器至关重要。前面的示例是任何你可能实现的child组件的标准行为。

注意,ProfileComponent中的实现将会有所不同。

  1. 实现事件处理器ngOnChanges,利用基类的patchUpdatedDataIfChanged行为:

    **src/app/user/name-input/name-input.****component****.****ts**
    ngOnChanges(changes: SimpleChanges) {
      this.disable ?
        this.formGroup?.disable() : this.formGroup?.enable()
      this.patchUpdatedDataIfChanged(changes)
    } 
    

注意,在patchUpdatedDataIfChanged中,将onlySelf设置为false将导致父表单也更新。如果你想要优化此行为,可以覆盖该函数。

现在,你已经有一个完全实现的NameInputComponent,可以将其集成到ProfileComponent中。

为了验证你未来的ProfileComponent代码,请参考projects/stage11/src/app/user/profile/profile.component.tsprojects/stage11/src/app/user/profile/profile.component.html

在开始使用NameInputComponent之前,执行以下重构。

  1. ProfileComponent重构为扩展BaseFormComponent,并根据需要符合其默认值。

  2. 定义一个只读的nameInitialData$属性,其类型为BehaviorSubject<IName>,并用空字符串初始化它。

  3. ProfileComponent中的内容替换为新的<app-name-input>组件:

    **src/app/user/profile/profile.component.html**
    <mat-horizontal-stepper #stepper="matHorizontalStepper">
      <mat-step [stepControl]="formGroup">
        <form [formGroup]="formGroup">
          <ng-template matStepLabel>Account Information</ng-template>
            <div class="stepContent">
              **<****app-name-input** **[****initialData****]=****"nameInitialData$ | async"**
                **(****formReady****)=****"registerForm('name', $event)"****>**
              </app-name-input>
            </div>
            ...
          </ng-template>
        </form>
      </mat-step>
      ...
    </mat-horizontal-stepper> 
    

注意,这里使用了基础表单组件函数registerForm

  1. 确保你的ngOnInit被正确实现:

注意,在更新的ProfileComponent中存在一些额外的重构,例如以下片段中看到的patchUser函数。当您更新组件时,不要错过这些更新。

**src/app/user/profile/profile.****component****.****ts**
ngOnInit() {
  this.formGroup = this.buildForm()
  this.authService.currentUser$
    .pipe(
      filter((user) => user != null),
      tap((user) => this.patchUser(user)),
      takeUntilDestroyed(this.destroyRef)
    )
    .subscribe()
} 

initialData更新时,重要的是要使用pathUpdatedData以及nameInitialData$更新当前表单的数据。

  1. 确保正确实现了ngOnDestroy

    **src/app/user/profile/profile.****component****.****ts**
      ngOnDestroy() {
        this.deregisterAllForms()
      } 
    

您可以利用基类功能来自动注销所有子表单。

接下来,让我们学习如何通过屏蔽用户输入来提高我们的数据质量。

输入屏蔽

屏蔽用户输入是一种输入 UX 工具,也是一种数据质量工具。我是ngx-mask库的粉丝,它使得在 Angular 中实现输入屏蔽变得容易。我们将通过更新电话号码输入字段来演示输入屏蔽,确保用户输入有效的电话号码,如图所示:

图片

图 8.11:带有输入屏蔽的电话号码字段

按如下方式设置您的输入屏蔽:

  1. 使用npm i ngx-mask通过 npm 安装库。

  2. 要么在app.config.ts中使用环境提供者provideEnvironmentNgxMask(),要么在您的功能模块user.module.ts中使用provideNgxMask()

  3. profile.component.html中导入NgxMaskDirective

  4. 按如下方式更新ProfileComponent中的number字段:

    **src/app/user/profile/profile.component.html**
    <mat-form-field appearance="outline" fxFlex fxFlexOffset="10px">
      <mat-label>Number</mat-label>
      <input matInput type="text" formControlName="number"
        prefix="+1" **mask****=****"(000) 000-0000"** **[****showMaskTyped****]=****"true"** />
        @if (phonesArray.controls[i].invalid && 
             phonesArray.controls[i].touched) {
          <mat-error>A valid phone number is required</mat-error>
        } 
    </mat-form-field> 
    

简单来说,您可以在 GitHub 上了解更多关于该模块及其功能的信息:github.com/JsDaddy/ngx-mask

使用 ControlValueAccessor 的自定义控件

到目前为止,我们已经学习了使用 Angular Material 提供的标准表单控件和输入控件来使用表单。然而,您也可以创建自定义用户控件。如果您实现了ControlValueAccessor接口,那么您的自定义控件将与表单和ControlValueAccessor接口的验证引擎很好地协同工作。

我们将创建以下截图所示的定制评分控件,并将其放置在ProfileComponent的第一步控件上:

图片

图 8.12:柠檬评分用户控件

用户控件本质上是高度可重用、紧密耦合且定制的组件,以实现丰富的用户交互。让我们来实现一个。

实现自定义评分控件

Lemon Rater 将在用户与控件实时交互时动态突出显示所选柠檬的数量。因此,创建高质量的定制控件是一项昂贵的任务。然而,在定义您的品牌和/或构成 UX 核心的应用程序元素上投入精力是完全值得的。

Lemon Rater 是 Jennifer Wadella 在github.com/tehfedaykin/galaxy-rating-app找到的 Galaxy 评分应用示例的修改版。我强烈推荐观看 Jennifer 在 Ng-Conf 2019 上关于ControlValueAccessor的演讲,链接在进一步阅读部分。

按照以下方式设置您的自定义评分控件:

  1. user-controls文件夹下生成一个新的组件名为LemonRater

  2. LemonRater中实现ControlValueAccess接口:

    **src/app/user-controls/lemon-rater/lemon-rater.****component****.****ts**
    export class LemonRaterComponent implements ControlValueAccessor {
      disabled = false
      private internalValue: number
      get value() {
        return this.internalValue
      }
      onChanged: any = () => {}
      onTouched: any = () => {}
      writeValue(obj: any): void {
        this.internalValue = obj
      }
      registerOnChange(fn: any): void {
        this.onChanged = fn
      }
      registerOnTouched(fn: any): void {
        this.onTouched = fn
      }
      setDisabledState?(isDisabled: boolean): void {
        this.disabled = isDisabled
      }
    } 
    
  3. 添加具有multi属性设置为trueNG_VALUE_ACCESSOR提供者。这将使我们的组件注册到表单的更改事件中,因此当用户与评分器交互时,表单值可以更新:

    **src/app/user-controls/lemon-rater/lemon-rater.****component****.****ts**
    @Component({
      selector: 'app-lemon-rater',   
      templateUrl: 'lemon-rater.component.html',
      styleUrls: ['lemon-rater.component.css'],
      providers: [
        {
          provide: NG_VALUE_ACCESSOR,
          useExisting: forwardRef(() => LemonRaterComponent),
          multi: true,
        },
      ],  
      standalone: true,
      imports: [NgClass], 
    

    forwardRef允许我们引用尚未定义的组件。更多内容请参阅angular.dev/api/core/forwardRef

  4. 使用函数实现一个自定义评分方案,允许我们根据用户输入设置所选评分:

    **src/app/user-controls/lemon-rater/lemon-rater.****component****.****ts**
    export class LemonRaterComponent implements ControlValueAccessor { 
      @ViewChild('displayText', { static: false }) displayTextRef!: ElementRef
      disabled = false
      private internalValue!: number
      get value() {
        return this.internalValue
      }
    
      ratings = Object.freeze([
        {
          value: 1,
          text: 'no zest',
        },
        {
          value: 2,
          text: 'neither a lemon or a lime ',
        },
        {
          value: 3,
          text: 'a true lemon',
        },
      ])
      ...
      setRating(lemon: any) {
        if (!this.disabled) {
          this.internalValue = lemon.value
          this.ratingText = lemon.text
          this.onChanged(lemon.value)
          this.onTouched()
        }
      }
      setDisplayText() {
        this.setSelectedText(this.internalValue)
      }
      private setSelectedText(value: number) {
        this.displayTextRef.nativeElement.textContent = 
          this.getSelectedText(value)
      }
      private getSelectedText(value: number) {
        let text = ''
        if (value) {
          text = this.ratings
            .find((i) => i.value === value)?.text || ''
        }
        return text
      }
    } 
    

    注意,通过使用@ViewChild,我们正在获取名为#displayText的 HTML 元素(在以下模板中突出显示)。使用setSelectText,我们替换元素的textContent

  5. 实现模板,参考svg标签内容的示例代码:

    **src/app/user-controls/lemon-rater/lemon-rater.component.html**
    **<****i** **#****displayText****></****i****>**
    <div class="lemons" [ngClass]="{'disabled': disabled}"> 
      @for (lemon of ratings; track lemon) { 
        <svg width="24px" height="24px" viewBox="0 0 513 513"
             [attr.title]="lemon.text" class="lemon rating"
             [ngClass]="{'selected': lemon.value <= value}"
             (mouseover)=
               "displayText.textContent = !disabled ? lemon.text : ''"
             (mouseout)="setDisplayText()"
             (click)="setRating(lemon)"
         >
         ...
         </svg>
      }
    </div> 
    

    模板中最重要的三个属性是mouseovermouseoutclickmouseover属性显示用户当前悬停的评分文本,mouseout将显示文本重置为所选值,click调用我们实现的setRating方法来记录用户的选择。然而,控件可以通过突出显示用户悬停在评分或选择它时柠檬的数量来提供更丰富的用户交互。我们将通过一些 CSS 魔法来实现这一点。

  6. 实现用户控件的 CSS:

    **src/app/user-controls/lemon-rater/lemon-rater.component.css**
    .lemons {
      cursor: pointer;
    }
    .lemons:hover .lemon #fill-area {
      fill: #ffe200 !important;
    }
    .lemons.disabled:hover {
      cursor: not-allowed;
    }
    .lemons.disabled:hover .lemon #fill-area {
      fill: #d8d8d8 !important;
    }
    .lemons .lemon {
      float: left; margin: 0px 5px;
    }
    .lemons .lemon #fill-area {
      fill: #d8d8d8;
    }
    .lemons .lemon:hover~.lemon #fill-area {
      fill: #d8d8d8 !important;
    }
    .lemons .lemon.selected #fill-area {
      fill: #ffe200 !important;
    }
    .lemons .dad.heart #ada { 
      fill: #6a0dad !important;
    } 
    

最有趣的部分是.lemons .lemon:hover~.lemon #fill-area。注意操作符~,或通用兄弟组合器,选择一系列元素,这样当用户悬停在它们上面时,会有一个动态数量的柠檬被突出显示。

#fill-area指的是在柠檬.svg内部定义的<path>,允许动态调整柠檬的颜色。我不得不手动将此 ID 字段注入到.svg文件中。

现在,让我们看看您如何在表单中使用这个新的用户控件。

在表单中使用自定义控件

我们将在profile组件中使用柠檬评分器来捕捉员工的柠檬等级。

Limoncu 是土耳其语中指种植或出售柠檬的人,也是 LemonMart 的专有员工参与度和绩效测量系统。

让我们集成柠檬评分器:

  1. 首先在profile.component.ts中导入LemonRaterComponent

  2. 确保在buildForm中初始化等级表单控件:

    **src/app/user/profile/profile.****component****.****ts**
    buildForm(initialData?: IUser): FormGroup {
      ...
      level: [user?.level || 0, Validators.required], 
      ...
    } 
    
  3. 将柠檬评分器作为第一个mat-step中的最后一个元素插入到form元素内:

    **src/app/user/profile/profile.component.html**
    <div fxLayout="row" fxLayout.lt-sm="column" class="margin-top" fxLayoutGap="10px">
      <mat-label class="mat-body-1">Select the Limoncu level:
        <app-lemon-rater formControlName="level">
        </app-lemon-rater>
      </mat-label>
    </div> 
    

我们通过实现formControlName像其他控件一样与自定义控件集成。

恭喜!您应该有一个集成了您表单的工作自定义控件。

使用网格列表布局

Flex Layout 库非常适合使用 CSS Flexbox 布局内容。Angular Material 提供了另一种布局内容的方法,即使用 CSS Grid 及其网格列表功能。演示这种功能的一个好方法是在LoginComponent中实现一个有用的假登录信息列表,如下所示:

图 8.13:带有网格列表的登录助手

按照以下方式实现你的列表:

  1. 首先定义一个roles属性,它是一个包含所有角色的数组:

    **src/app/login/login.****component****.****ts**
    roles = Object.keys(Role) 
    
  2. login.component.ts中导入MatExpansionModuleMatGridListModule

  3. 在现有的mat-card-content下方实现一个新的mat-card-content

    **src/app/login/login.component.html**
    <div fxLayout="row" fxLayoutAlign="center">
      <mat-card fxFlex="400px">
        <mat-card-header>
          <mat-card-title>
            <div class="mat-headline">Hello, Limoncu!</div>
          </mat-card-title>
        </mat-card-header>
        <mat-card-content>
          ...
        </mat-card-content>
        **<****mat-card-content****>**
        **</****mat-card-content****>**
      </mat-card>
    </div> 
    
  4. 在新的mat-card-content内部,放入一个标签来显示认证模式:

    **src/app/login/login.component.html**
    <div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="10px">
      <span>Authentication Mode: </span><i>{{ authMode }}</i>
    </div> 
    
  5. 在标签下方实现一个展开列表:

    **src/app/login/login.component.html**
    <mat-accordion>
      <mat-expansion-panel>
        <mat-expansion-panel-header>
            <mat-panel-title>
              Fake Login Info
            </mat-panel-title>
        </mat-expansion-panel-header>
        ...
      </mat-expansion-panel>
    </mat-accordion> 
    
  6. mat-expansion-panel-header之后,在上一步中用省略号标记的区域,实现一个包含角色和电子邮件地址的表格,以及一些关于密码长度的提示文本,使用mat-grid-list,如下面的代码块所示:

    **src/app/login/login.component.html**
    <mat-grid-list cols="3" rowHeight="48px" role="list">
      <mat-grid-tile [**colspan**]="3" role="listitem"
      style="background: pink">
        Use any 8 character string as password
      </mat-grid-tile>
      <mat-grid-tile>
        <mat-grid-tile-header>Role</mat-grid-tile-header>
      </mat-grid-tile>
      <mat-grid-tile [colspan]="2">
        <mat-grid-tile-header>E-mail</mat-grid-tile-header>
      </mat-grid-tile>
      @for (role of roles; track role; let oddRow = $odd) {
        <div>
          <mat-grid-tile
            role="listitem"
            [style.background]="oddRow ? 'lightGray' : 'white'">
            {{ role }}
          </mat-grid-tile>
          <mat-grid-tile
            [colspan]="2"
            role="listitem"
            [style.background]="oddRow ? 'lightGray' : 'white'">
            <div fxFlex fxLayoutAlign="end center">
              @if (role.toLowerCase() === 'none') {
                <div>Any &#64;test.com email</div>
              } @else {
                {{ role.toLowerCase() }}&#64;test.com
              }
              <button
                mat-button
                (click)="
                  this.loginForm.patchValue({
                    email: role.toLowerCase() + '@test.com',
                    password: 'whatever'
                  })
                ">
                Fill
              </button>
            </div>
          </mat-grid-tile>
        </div>
      }
    </mat-grid-list> 
    

我们使用colspan来控制每行和每个单元格的宽度。我们利用fxLayoutAlign电子邮件列的内容右对齐。我们使用@if; @else控制流运算符来选择性地显示内容。最后,一个填充按钮帮助我们用假登录信息填充登录表单。

在你的应用程序中,你可以使用展开面板来向用户传达密码复杂性的要求。

你可以在material.angular.io/components/expansion了解更多关于展开面板的信息,以及在material.angular.io/components/grid-list/overview了解更多关于网格列表的信息。

恢复缓存数据

在本章的开始,当在UserService中实现updateUser方法时,我们缓存了user对象,以防任何可能清除用户提供的数据的错误:

**src/app/user/user/user.****service****.****ts**
updateUser(id: string, user: IUser): Observable<IUser> {
  ...
  This.cache.setItem('draft-user', user)
  ...
} 

考虑一个场景,当用户尝试保存数据时,他们可能暂时离线。在这种情况下,我们的updateUser函数将保存数据。

让我们看看如何在加载用户资料时在ProfileComponent中恢复这些数据:

  1. 首先向ProfileComponent类中添加名为loadFromCacheclearCache的函数:

    **src/app/user/profile.****component****.****ts**
    private loadFromCache(): Observable<User | null> {
      let user = null
      try {
        const draftUser = this.cache.getItem('draft-user')
        if (draftUser != null) {
          user = User.Build(JSON.parse(draftUser))
        }
        if (user) {
          this.uiService.showToast('Loaded data from cache')
        }
      } catch (err) {
        this.clearCache()
      }
      return of(user)
    } 
    clearCache() {
      this.cache.removeItem('draft-user')
    } 
    

    加载数据后,我们使用JSON.parse将数据解析为 JSON 对象,然后使用User.BuildUser对象填充。

  2. 更新模板以调用clearCache函数,以便当用户重置表单时,我们也清除缓存:

    **src/app/user/profile.component.html**
    <button mat-button color="warn"
        (click)="stepper.reset(); **clearCache()**">
      Reset
    </button> 
    
  3. 更新ngOnInit以有条件地从缓存或authService的最新currentUser$加载数据:

    **src/app/user/profile.****component****.****ts**
    ngOnInit() {
      this.formGroup = this.buildForm()
      combineLatest([
            this.loadFromCache(),
            this.authService.currentUser$,
          ])
            .pipe(
            takeUntilDestroyed(this.destroyRef),
              filter(
                ([cachedUser, me]) => 
                  cachedUser != null || me != null
              ),
              tap(
                ([cachedUser, me]) => 
                  this.patchUser(cachedUser || me)
              )
            )
            .subscribe()
    } 
    

我们利用combineLatest运算符将loadFromCachecurrentUser$的输出组合起来。我们检查其中一个流返回一个非空值。如果存在缓存用户,它将先于从currentUser$接收到的值。

你可以通过将浏览器网络状态设置为离线来测试你的缓存,如下所示:

图片

图 8.14:离线网络状态

将浏览器网络状态设置为离线,方法如下:

  1. 在 Chrome DevTools 中,导航到网络标签页。

  2. 在前一张截图标记为2的下拉菜单中选择离线

  3. 更新表单数据,例如名称,然后点击更新

  4. 你会在表单底部看到错误信息发生未知错误

  5. 你会在网络标签页中看到你的 PUT 请求失败。

  6. 现在,刷新你的浏览器窗口,观察你输入的新名称仍然存在。

参考以下截图,它显示了从缓存加载数据后你收到的吐司通知:

图片

图 8.15:从缓存加载数据

实现一个优秀的缓存用户体验极具挑战性。我提供了一个基本的方法来展示什么是可能的。然而,许多边缘情况可能会影响你的应用程序中缓存的工作方式。

在我的情况下,缓存固执地存在,直到我们成功将数据保存到服务器。这可能会让一些用户感到沮丧。

恭喜!你已经成功实现了一个复杂的表单来从你的用户那里捕获数据!

练习

通过更新UserService和多步骤的ProfileComponent表单来练习 Angular 中的新概念,如信号和@defer

  • 更新UserService及其相关组件,使用signal而不是BehaviorSubject

  • 使用@defer来延迟条件视图的渲染。

  • LoginComponent中实现一个展开面板,以向用户传达密码复杂性的要求。

摘要

本章介绍了 LemonMart 的表单、指令和用户控制相关功能。我们使用数据绑定创建了可重用的组件,这些组件可以嵌入到另一个组件中。我们展示了你可以使用 PUT 向服务器发送数据并缓存用户输入的数据。我们还创建了一个多步骤输入表单,它可以适应屏幕大小的变化。我们通过利用可重用表单部分、基类表单以存放公共功能以及属性指令来封装字段级别的错误行为和消息,从我们的组件中移除了样板代码。

我们使用日期选择器、自动完成支持和表单数组创建了动态表单元素。我们通过输入掩码和柠檬评分器实现了交互式控件。使用ControlValueAccessor接口,我们将柠檬评分器无缝集成到我们的表单中。我们展示了我们可以通过提取名称作为其表单部分来线性扩展表单的大小和复杂性。此外,我们还介绍了使用网格列表构建布局。

在下一章中,我们将进一步增强我们的组件,使用路由器来编排它们。我们还将实现主/详细视图和数据表,并探索 NgRx 作为 RxJS/BehaviorSubject 的替代方案。

进一步阅读

问题

尽可能好地回答以下问题,以确保你已理解本章的关键概念,而无需使用 Google。你知道你是否答对了所有问题吗?请访问 angularforenterprise.com/self-assessment 获取更多信息:

  1. 组件和用户控件之间的区别是什么?

  2. 属性指令是什么?

  3. @-语法的含义是什么?

  4. ControlValueAccessor 接口的目的是什么?

  5. 序列化、反序列化和活化是什么?

  6. 在表单上修补值意味着什么?

  7. 如何将两个独立的 FormGroup 对象相互关联?

第九章:食谱 – 主/详细信息,数据表,和 NgRx

在本章中,我们通过实现 LemonMart 中的前三个在业务应用程序中最常用的功能(主/详细信息视图,数据表和状态管理)来完成以路由器为首要的架构实现。我将使用 LemonMart 和 LemonMart Server 展示具有服务器端分页的数据表,突出前端和后端的集成。

我们将利用路由编排概念来编排我们的组件如何加载数据或渲染。然后,我们将使用解析守卫在导航到组件之前减少加载数据时的样板代码。我们将使用辅助路由通过路由配置来布局组件,并在多个上下文中重用相同的组件。

然后,我们将使用 LocalCast 天气应用程序深入探讨 NgRx,并使用 LemonMart 探索 NgRx Signal Store,这样你就可以熟悉 Angular 中更高级的应用程序架构概念。到本章结束时,我们将触及 Angular 和 Angular Material 提供的主要功能 – 如果你愿意,就是好的部分。

本章涵盖了大量的内容。它以食谱格式组织,因此你可以在项目工作时快速参考特定的实现。我涵盖了实现架构、设计和主要组件,并突出显示重要的代码片段来解释解决方案是如何组合在一起的。利用你所学到的知识,我期望读者能够填补常规实现和配置细节。然而,如果你遇到困难,可以始终参考 GitHub 仓库。

在本章中,你将学习以下主题:

  • 使用解析守卫加载数据

  • 使用绑定和路由数据重用组件

  • 使用辅助路由的主/详细信息视图

  • 带分页的数据表

  • NgRx 存储 和 影响

  • NgRx 生态系统

  • 实现全局旋转器

  • 使用 Angular CLI 配置服务器代理

技术要求

书籍的样本代码的最新版本可以在以下列表中链接的 GitHub 仓库中找到。该仓库包含代码的最终和完成状态。你可以在本章结束时通过查找projects文件夹下的代码章节末尾快照来验证你的进度。

对于第九章

确保lemon-mart-server正在运行。请参阅第七章与 REST 和 GraphQL API 一起工作

  1. 克隆以下仓库:github.com/duluca/local-weather-appgithub.com/duluca/lemon-mart

  2. 在根目录下执行npm install以安装依赖。

  3. 项目的初始状态反映在:

    projects/stage11 
    
  4. 项目的最终状态反映在:

    projects/stage12 
    
  5. 将阶段名称添加到任何ng命令中,以仅对该阶段执行操作:

    npx ng build stage12 
    

注意,存储库根目录中的dist/stage12文件夹将包含编译结果。

第八章食谱 - 可重用性、表单和缓存中,我们创建了一个带有editUser函数的ViewUserComponent。在章节后面实现系统中的主/详细视图时,我们需要这个功能,其中经理可以看到系统中的所有用户并编辑他们。在启用editUser功能之前,我们需要确保ViewUserComponent组件和ProfileComponent组件可以加载任何用户,给定他们的 ID。

在接下来的几节中,我们将学习关于解析保护器的内容,以简化我们的代码并减少样板代码的数量。让我们首先实现一个我们可以用于两个组件的解析保护器。

使用解析保护器加载数据

解析保护器是一种不同类型的路由保护器,如第六章基于角色的导航实现中提到的。解析保护器可以通过从route参数中读取记录 ID,异步加载数据,并在组件激活和初始化时准备好数据来为组件加载数据。

解析保护器的主要优势包括加载逻辑的可重用性、减少了样板代码,以及减少了依赖性,因为组件可以在不导入任何服务的情况下接收所需的数据:

  1. user/user下创建一个新的user.resolve.ts类:

    **src/app/user/user/user.****resolve****.****ts**
    import { inject } from '@angular/core'
    import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router'
    import { catchError, map } from 'rxjs/operators'
    import { transformError } from '../../common/common'
    import { User } from './user'
    import { UserService } from './user.service'
    export const userResolver: ResolveFn<User> = (route: ActivatedRouteSnapshot) => {
      return inject(UserService)
        .getUser(route.paramMap.get('userId'))
        .pipe(map(User.Build), catchError(transformError))
    } 
    

    UserService中的updateUser方法类似,我们使用map(User.Build)来填充user对象,使其在组件从route快照加载数据时准备好使用,正如我们接下来将看到的。

  2. 修改user-routing.module.ts以添加一个新的路径profile/:userId,带有路由解析器和canActivate authGuard

    **src/app/user/user-routing.****module****.****ts**
    ...
    {
        path: 'profile/:userId',
        component: ProfileComponent,
        resolve: {
          user: userResolver,
        },
        canActivate: [authGuard],
      },
      ... 
    

    当与身份验证保护器结合使用时,resolve函数只有在保护器成功后才会执行。

  3. 更新profile组件,如果存在,则从route加载数据:

    **src/app/user/profile/profile.****component****.****ts**
    ...
      constructor(
        ...
        **private****route****:** **ActivatedRoute**
      ) {
        super()
      }
      **private****readonly** **destroyRef =** **inject****(****DestroyRef****)**
    
      ngOnInit() {
        this.formGroup = this.buildForm()
        if (**this****.****route****.****snapshot****.****data****[**'**user**'**]**) {
          **this****.****patchUser****(****this****.****route****.****snapshot****.****data****[**'**user**'**]**)
        } else {
           combineLatest(
            [this.loadFromCache(), 
             this.authService.currentUser$]
           )
          .pipe(
            takeUntilDestroyed(this.destroyRef),
            filter(
              ([cachedUser, me]) => 
                cachedUser != null || me != null
            ),
            tap(
              ([cachedUser, me]) => 
               this.patchUser(cachedUser || me)
            )
          )
          .subscribe()
        }
      } 
    

我们首先检查用户是否存在于route快照中。如果是,我们调用patchUser来加载此用户。否则,我们回退到我们的条件缓存加载逻辑。

注意,patchUser方法还设置了currentUserIdnameInitialDate$可观察对象,并调用patchUpdateData基类来更新表单数据。

您可以通过导航到带有您用户 ID 的配置文件来验证解析器是否正常工作。使用出厂设置,此 URL 将类似于http://localhost:4200/user/profile/5da01751da27cc462d265913

使用绑定和路由数据重用组件

现在,让我们重构viewUser组件,以便我们可以在多个上下文中重用它。根据创建的模拟图,应用程序中显示用户信息的地方有两个。

第一个地方是我们之前章节中实现的用户资料审查步骤。第二个地方是在/manager/users路由的用户管理屏幕上,如下所示:

计算机屏幕截图,描述自动生成

图 9.1:经理用户管理模拟图

为了最大化代码重用,我们必须确保我们的共享 ViewUser 组件可以在两种上下文中使用。

在第一个用例中,我们将当前用户绑定到多步输入表单的 Review 步骤。在第二个用例中,组件需要使用 resolve 守卫加载数据,因此我们不需要实现额外的逻辑来实现我们的目标:

  1. 更新 viewUser 组件以注入 RouterActivatedRoute。在 ngOnInit 中,我们需要从路由中设置 currentUser 并订阅未来的路由更改事件,以使用辅助函数 assignUserFromRoute 更新用户,并在 ngOnDestroy 中取消订阅事件:

    **src/app/user/view-user/view-user.****component****.****ts**
    ...
    export class ViewUserComponent 
      implements OnInit, OnChanges, OnDestroy {
      private readonly route = inject(ActivatedRoute)
      private readonly router = inject(Router)
      private routerEventsSubscription?: Subscription
      ...
      ngOnInit() {
        // assignment on initial render
        this.assignUserFromRoute()
        this.routerEventsSubscription = 
          this.router.events.subscribe((event) => {
          // assignment on subsequent renders
          if (event instanceof NavigationEnd) {
            this.assignUserFromRoute()
          }
        })
      }
      private assignUserFromRoute() {
        if (this.route.snapshot.data['user']) {
          this.currentUser = this.route.snapshot.data['user']
        }
      }
      ngOnDestroy(): void {
        this.routerEventsSubscription?.unsubscribe()
      }
      ...
    }} 
    

ngOnInit 只会在组件在另一个组件内部初始化或在路由器上下文中加载时触发一次。如果已经解析了路由的任何数据,我们将更新 currentUser。当用户想要查看另一个用户时,将发生一个新的导航事件,带有不同的用户 ID。由于 Angular 会重用组件,我们必须订阅路由事件以对后续的用户更改做出反应。在这种情况下,如果发生 NavigationEnd 事件,并且已解析了用户数据,我们将再次更新 currentUser

我们现在有三个独立的事件来更新和处理数据。在父组件上下文中,ngOnChanges 处理 @Input 值的更新,如果 this.user 已经绑定,则更新 currentUser。我们上面添加的代码处理了第一次导航和后续导航事件中的剩余两个情况。

由于 LemonMart 是作为一个独立的应用程序自举的,并且 viewUser 是一个独立的组件,因此我们可以在多个懒加载的模块中使用这个组件,而无需额外的编排。

如果你没有使用独立组件,你必须在这个组件内部包装一个 SharedComponentsModule,并在你的懒加载模块中导入该模块。你可以在项目的 GitHub 历史记录中找到一个示例实现。

在关键组件就绪后,让我们开始实现 master/detail 视图。

使用辅助路由的 master/detail 视图

路由器优先架构的真正力量在辅助路由中得以实现,我们可以通过路由配置单独影响组件的布局,从而允许我们混合现有的组件到不同的布局中。辅助路由是相互独立的路由,它们可以在标记中定义的命名出口中渲染内容,例如 <router-outlet name="master"><router-outlet name="detail">。此外,辅助路由可以有它们的参数、浏览器历史、子路由和嵌套辅助路由。

在下面的示例中,我们将实现一个基本的 master/detail 视图,使用辅助路由:

  1. 实现一个简单的组件,其中定义了两个命名的出口:

    **src/app/manager/user-management/user-management.****component****.****ts**
      template: `
        <div class="h-pad">
          <router-outlet name="master"></router-outlet>
          <div style="min-height: 10px"></div>
          <router-outlet name="detail"></router-outlet>
        </div>
      ` 
    
  2. manager 下添加一个新的 userTable 组件。

  3. 更新 manager-routing.module.ts 以定义辅助路由:

    **src/app/manager/manager-routing.****module****.****ts**
      ...
        {
          path: 'users',
          component: UserManagementComponent,
          children: [
            { 
              path: '', component: UserTableComponent, 
               outlet: 'master' 
            },
            {
              path: 'user',
              component: ViewUserComponent,
              outlet: 'detail',
              resolve: {
                user: userResolver,
              },
            },
          ],
          canActivate: [authGuard],
          canActivateChild: [authGuard],
          data: {
            expectedRole: Role.Manager,
          },
        },
    ... 
    

    这意味着当用户导航到/manager/users时,他们将看到UserTableComponent,因为它使用的是默认路径。

  4. manager.module.ts中提供UserResolve,因为viewUser依赖于它。

  5. userTable中实现一个临时按钮:

    **src/app/manager/user-table/user-table.component.html**
    <button
      mat-icon-button
      [routerLink]="[
        '../users',
        { outlets: { detail: ['user', { userId: row._id }] } }
      ]"
      [skipLocationChange]="true">
      <mat-icon>visibility</mat-icon>
    </button> 
    

    skipLocationChange指令在不将新记录推入历史记录的情况下进行导航。因此,如果用户查看多个记录并点击后退按钮,他们将被带回到上一个屏幕,而不是必须滚动查看他们首先查看的记录。

    想象一下,一个用户点击了一个类似于之前定义的查看详情按钮——然后,ViewUserComponent将为用户渲染带有给定userId的组件。在下一张截图中,你可以看到在下一节实现数据表后,查看详情按钮将看起来如何:

    手机截图  自动生成的描述

    图 9.2:查看详情按钮

    你可以尽可能多地组合,并为主视图和详情视图定义替代组件,从而允许动态布局的无限可能性。然而,设置routerLink可能是一个令人沮丧的经历。根据具体条件,你必须提供或不需要提供链接中的所有或部分出口。

    例如,在先前的场景中,考虑以下替代实现,其中主出口被明确定义:

    ['../users', { 
       outlets: { 
         master: [''], detail: ['user', {userId: row.id}] 
       } 
    }], 
    

    路由器将无法正确解析此路由,并且会静默失败加载。如果它是master: [],则可以正常工作。这取决于空路由上的模式匹配方式;虽然这在框架代码中逻辑上是合理的,但对于使用 API 的开发者来说并不直观。

    现在我们已经完成了ViewUserComponent的 resolve guard 实现,你可以使用 Chrome DevTools 来查看正确加载的数据。

    在调试之前,确保我们在第七章使用 REST 和 GraphQL API中创建的lemon-mart-server正在运行。

  6. Chrome DevTools中,在this.currentUser分配后立即设置一个断点,如图所示:计算机截图  自动生成的描述

    图 9.3:Dev Tools 调试 ViewUserComponent

你将观察到this.currentUser被正确设置,而无需在ngOnInit函数内部加载数据的任何样板代码,这显示了 resolve guard 的真正好处。"ViewUserComponent"是详情视图;现在,让我们实现主视图,作为一个带有分页的数据表。

带有分页的数据表

我们已经创建了脚手架来布局我们的主/详细视图。在主出口处,我们将有一个用户分页数据表,所以让我们实现 UserTableComponent,它将包含一个名为 dataSourceMatTableDataSource 属性。我们需要能够使用标准分页控件(如 pageSizepagesToSkip)批量获取用户数据,并使用用户提供的搜索文本进一步缩小选择范围。

让我们从向 UserService 添加必要的功能开始:

  1. 实现一个新的 IUsers 接口来描述分页数据的结构:

    **src/app/user/user/user.****service****.****ts**
    ...
    export interface IUsers {
      data: IUser[]
      total: number
    } 
    
  2. 使用 getUsers 函数更新 UserService 的接口:

    **src/app/user/user/user.****service****.****ts**
    ...
    export interface IUserService {
      getUser(id: string): Observable<IUser>
      updateUser(id: string, user: IUser): Observable<IUser>
      **getUsers****(****pageSize****:** **number****,** **searchText****:** **string****,** 
        **pagesToSkip****:** **number****):** **Observable****<****IUsers****>**
    }
    export class UserService implements IUserService {
    ... 
    
  3. getUsers 添加到 UserService

    **src/app/user/user/user.****service****.****ts**
    ...
    getUsers(
        pageSize: number,
        searchText = '',
        pagesToSkip = 0,
        sortColumn = '',
        sortDirection: '' | 'asc' | 'desc' = 'asc'
      ): Observable<IUsers> {
        const recordsToSkip = pageSize * pagesToSkip
        if (sortColumn) {
          sortColumn =
            sortDirection === 'desc' ? `-${sortColumn}` : sortColumn
        }
        return this.httpClient.get<IUsers>(
          `${environment.baseUrl}/v2/users`, { 
            params: {
              filter: searchText,
              skip: recordsToSkip.toString(),
              limit: pageSize.toString(),
              sortKey: sortColumn,
            },
          })
        }
    ... 
    

    注意,sort 方向由关键字 asc(升序)和 desc(降序)表示。在升序排序列时,我们将列名作为参数传递给服务器。为了降序排序列,我们在列名前加上负号。

  4. 使用分页、排序和过滤设置 UserTable

    **src/app/manager/user-table/user-table.****component****.****ts**
    ...
    @Component({
      selector: 'app-user-table',
      templateUrl: './user-table.component.html',
      styleUrls: ['./user-table.component.css'],
    })
    export class UserTableComponent implements AfterViewInit {
      @ViewChild(MatPaginator) paginator!: MatPaginator
      @ViewChild(MatSort) sort!: MatSort
      private skipLoading = false
      private readonly userService = inject(UserService)
      private readonly router = inject(Router)
      private readonly activatedRoute = inject(ActivatedRoute)
      private readonly destroyRef = inject(DestroyRef)
      readonly refresh$ = new Subject<void>()
      readonly demoViewDetailsColumn = signal(false)
      items$!: Observable<Iuser[]>
      displayedColumns = computed(() => [
        'name',
        'email',
        'role',
        ...(this.demoViewDetailsColumn() ? ['_id'] : []),
      ])
      isLoading = true
      resultsLength = 0
      hasError = false
      errorText = ''
      selectedRow?: Iuser
      search = new FormControl<string>('', OptionalTextValidation)
      resetPage(stayOnPage = false) {
        if (!stayOnPage) {
          this.paginator.firstPage()
        }
        **// this.outletCloser.closeOutlet('detail')**
        this.router.navigate([
          '../users',
          { outlets: { detail: null } }
        ], {
          skipLocationChange: true,
          relativeTo: this.activatedRoute,
        })
        this.selectedRow = undefined
      }
      showDetail(userId: string) {
        this.router.navigate([
          '../users',
          { outlets: { detail: ['user', { userId: userId }] }
        }],
          {
            skipLocationChange: true,
            relativeTo: this.activatedRoute,
          }
        )
      }  
      ngAfterViewInit() {
        this.sort.sortChange
          .pipe(
            tap(() => this.resetPage()),
            takeUntilDestroyed(this.destroyRef)
          )
          .subscribe()
        this.paginator.page
          .pipe(
            tap(() => this.resetPage(true)),
            takeUntilDestroyed(this.destroyRef)
          )
          .subscribe()
        if (this.skipLoading) {
          return
        }     
        setTimeout(() => {
          **this****.****items$** **=** **merge****(**
            **this****.****refresh$****,**
            **this****.****sort****.****sortChange****,**
            **this****.****paginator****.****page****,**
            **this****.****search****.****valueChanges****.****pipe****(**
              **debounceTime****(****1000****),**
              **tap****(****() =>****this****.****resetPage****())**
            **)**
          **).****pipe****(**
            **startWith****({}),**
            **switchMap****(****() =>** **{**
              **this****.****isLoading** **=** **true**
              **return****this****.****userService****.****getUsers****(**
                **this****.****paginator****.****pageSize****,**
                **this****.****search****.****value****as****string****,**
                **this****.****paginator****.****pageIndex****,**
                **this****.****sort****.****active****,**
                **this****.****sort****.****direction**
              **)**
            **}),**
            **map****(****(****results****: { total:** **number****; data: IUser[] }****) =>** **{**
              **this****.****isLoading** **=** **false**
              **this****.****hasError** **=** **false**
              **this****.****resultsLength** **= results.****total**
              **return** **results.****data**
            **}),**
            **catchError****(****(****err****) =>** **{**
              **this****.****isLoading** **=** **false**
              **this****.****hasError** **=** **true**
              **this****.****errorText** **= err**
              **return****of****([])**
            **}),**
            **takeUntilDestroyed****(****this****.****destroyRef****),**
          **)**
        **})**
      }
    } 
    

    我们定义并初始化各种属性以支持加载分页数据。items$ 存储定义在数据表上显示的数据的可观察流。displayedColumns,一个计算信号,定义了表格的列。为了动态显示或隐藏列,我们可以使用一个信号定义一个切换器,例如 demoViewDetailsColumn。由于这个信号在计算信号中被引用,当它更新时,计算信号也会更新,这将在表格上得到反映。paginatorsort 提供分页和排序首选项,.search 提供我们用于通过文本过滤结果的文本。

    resetPage 帮助将分页重置到第一页并隐藏详细视图。这在搜索、分页或排序事件之后很有用,否则将显示随机记录的详细视图。

    showDetail 使用路由器在名为 detail 的出口处显示所选记录的详细视图。在本节稍后,我们将介绍在模板中实现相同链接的版本。我故意包含了这两个选项,这样你可以看到它们是如何实现的。

    我故意在代码库中以下代码被注释掉:

    // this.outletCloser.closeOutlet('detail') 
    

    我发现,在某些情况下,路由器可能无法优雅地关闭出口。位于 common 文件夹中的 OutletCloserService 可以从任何上下文中无麻烦地关闭任何出口。

    关于安德鲁·斯科特原始版本的引用,请参阅 stackblitz.com/edit/close-outlet-from-anywhere

    魔法发生在ngAfterViewInit中。我们首先订阅sortpaginator变化事件,以便我们可以正确地重置表格。接下来,我们使用setTimeout调用内的merge方法,如前一个片段中突出显示的,来监听影响需要显示的数据的分页、排序和筛选属性的变化。如果某个属性发生变化,整个管道就会被触发。

    为什么setTimeout是必要的?因为我们使用从模板中提取的 paginator 和 sort 的引用,我们必须使用ngAfterViewInit生命周期钩子。然而,在这个时候,Angular 已经为 Material 数据表组件设置了dataSource属性。如果我们使用merge操作符重新分配它,我们将得到 NG0100 ExpressionChangedAfterItHasBeenCheckedError。使用setTimeout将重新分配推入下一个变化检测周期,从而避免错误。

    这与我们在AuthService中实现登录例程的方式类似。该管道包含对this.userService.getUsers的调用,该调用将根据传入的分页、排序和筛选偏好检索用户。然后结果被管道传输到this.items$ Observable,数据表通过async管道订阅它以显示数据。

    没有必要订阅this.items$,因为 Material 数据表已经内部订阅了它。如果你订阅,每次对服务器的调用都将执行两次。

    然而,你必须注意将takeUntilDestroyed调用放在管道中的最后一个元素。否则,你可能会在调用之后泄漏合并后的订阅。

    cartant.medium.com/rxjs-avoiding-takeuntil-leaks-fb5182d047ef了解更多相关信息。

  5. 导入以下模块:

    **src/app/manager/user-table/user-table.****component****.****ts**
    imports: [
      AsyncPipe,
      FlexModule,
      FormsModule,
      MatButtonModule,
      MatFormFieldModule,
      MatIconModule,
      MatInputModule,
      MatPaginatorModule,
      MatProgressSpinnerModule,
      MatSlideToggleModule,
      MatSortModule,
      MatTableModule,
      MatToolbarModule,
      ReactiveFormsModule,
      RouterLink,
    ], 
    
  6. 实现userTable的 CSS:

    **src****/app/manager/user-****table****/user-****table****.component.scss**
    .loading-shade {
      position: absolute;
      top: 0;
      left: 0;
      bottom: 56px;
      right: 0;
      background: rgba(0, 0, 0, 0.15);
      z-index: 1;
      display: flex;
      align-items: center;
      justify-content: center;
    }
    .filter-row {
      min-height: 64px;
      padding: 8px 24px 0;
    }
    .full-width {
      width: 100%;
    }
    .mat-mdc-paginator {
      background: transparent;
    }
    /* row selection styles */
    .mat-mdc-row .mat-mdc-cell {
      border-bottom: 1px solid transparent;
      border-top: 1px solid transparent;
      cursor: pointer;
    }
    .mat-mdc-row:hover .mat-mdc-cell {
      border-color: currentColor;
      background-color: #efefef;
    }
    .selected {
      font-weight: 500;
      background-color: #efefef;
    } 
    

    在注释/* 行选择样式 */下面的样式有助于在点击单个行时辅助材料涟漪效果。

  7. 最后,实现userTable模板:

    **src/app/manager/user-table/user-table.component.html**
    <div fxLayout="row" fxLayoutAlign="end">
      <mat-slide-toggle
        [checked]="demoViewDetailsColumn()"
        (change)="demoViewDetailsColumn.set($event.checked)">
        Demo 'View Details' Column
      </mat-slide-toggle>
    </div>
    <div class="filter-row">
      <form style="margin-bottom: 32px">
        <div fxLayout="row">
          <mat-form-field class="full-width">
            <mat-icon matPrefix>search</mat-icon>
            <input matInput placeholder="Search" 
                   aria-label="Search" [formControl]="search" />
            <mat-hint>Search by e-mail or name</mat-hint>
            @if (search.invalid) {
              <mat-error>
                Type more than one character to search
              </mat-error>
            }
          </mat-form-field>
        </div>
      </form>
    </div>
    <div class="mat-elevation-z8">
      **@if (isLoading) {**
        **<****div****class****=****"loading-shade"****>**
          **<****mat-spinner****></****mat-spinner****>**
        **</****div****>**
      **}**
      @if (hasError) {
        <div class="error">
          {{ errorText }}
        </div>
      }
      <mat-table
        class="full-width"
        **[****dataSource****]=****"items$"**
        matSort
        matSortActive="name"
        matSortDirection="asc"
        matSortDisableClear>
        <ng-container matColumnDef="name">
          <mat-header-cell *matHeaderCellDef mat-sort-header>
            Name
          </mat-header-cell>
          <mat-cell *matCellDef="let row">
            {{ row.fullName }}
          </mat-cell>
        </ng-container>
        <ng-container matColumnDef="email">
          <mat-header-cell *matHeaderCellDef mat-sort-header>
            E-mail
          </mat-header-cell>
          <mat-cell *matCellDef="let row">
            {{ row.email }}
          </mat-cell>
        </ng-container>
        <ng-container matColumnDef="role">
          <mat-header-cell *matHeaderCellDef mat-sort-header>
            Role
          </mat-header-cell>
          <mat-cell *matCellDef="let row">
            {{ row.role }}
          </mat-cell>
        </ng-container>
        <ng-container matColumnDef="_id">
          <mat-header-cell *matHeaderCellDef>
            View Details
          </mat-header-cell>
          <mat-cell *matCellDef="let row" 
                    style="margin-right: 8px">
            **<****button**
              **mat-icon-button**
              **[****routerLink****]=****"[**
                **'../users',**
                **{** 
                  **outlets: { detail: ['user', { userId: row._id }]** 
                **}** 
              **}]"**
              **[****skipLocationChange****]=****"true"****>**
              <mat-icon>visibility</mat-icon>
            </button>
          </mat-cell>
        </ng-container>
        <mat-header-row *matHeaderRowDef="displayedColumns()">
        </mat-header-row>
        <mat-row
          **matRipple**
          **(****click****)=****"selectedRow = row;** 
            **demoViewDetailsColumn() ? 'noop' : showDetail(row._id)"**
          [class.selected]="selectedRow === row"
          *matRowDef="let row; columns: displayedColumns()">
        </mat-row>
      </mat-table>
      <mat-toolbar>
        <mat-toolbar-row>
          <button mat-icon-button **(****click****)=****"refresh$.next()**">
            <mat-icon title="Refresh">refresh</mat-icon>
          </button>
          <span class="flex-spacer"></span>
          <mat-paginator [pageSizeOptions]="[5, 10, 25, 100]" 
                         [length]="resultsLength">
          </mat-paginator>
        </mat-toolbar-row>
      </mat-toolbar>
    </div> 
    

    注意loading-shade样式的实现,它在加载数据时在表格上放置一个旋转器。这是一个本地化旋转器的示例。在实现 NgRx/SignalState 的全局旋转器部分,我将介绍我们如何实现全局版本。大多数非常大的应用程序将需要一个本地化旋转器,以避免全局旋转器造成的过度全屏中断。

    我们将items$绑定到dataSource以激活 Observable。下面,带有[routerLink]="['../users', { outlets: { detail: ['user', { userId: row._id }] } }]"mat-icon-button使用上下文行变量来分配一个 URL,该 URL 将在detail出口中显示ViewUserComponentskipLocationChange确保浏览器中的 URL 不会更新为出口信息。

    注意,在routerLink中使用相对 URL'../users',如上所示,允许UserTableComponent从管理功能模块的上下文中解耦。这样,组件可以在其他上下文中重用,例如/owner/users/ceo/users,而不是硬编码为/manager/users

    在延迟加载的模块和命名出口中设置路由器可能会出错。

    您可以通过修改app.config.ts中的根提供者来启用路由器的调试模式,如下所示添加withDebugTracing函数:

    provideRouter(routes, withDebugTracing()), 
    

    进一步来说,matRipple指令在行被点击时启用 Material Design 涟漪效果。紧接着,我们实现点击处理程序。默认情况下,点击行将使用showDetail函数显示详细视图;否则,用户将在最右侧列的视图按钮上点击。

    最后,观察刷新按钮的点击,这会导致refresh$可观察对象更新。这将由我们在组件中实现的合并管道捕获。

    仅放置主视图,表格如下所示(确保您已更新到 Angular 的最新版本):

    图 9.4:用户表

    如果您点击行,ViewUserComponent将使用showDetails函数在详细出口中渲染,如下所示:

    计算机截图 自动生成描述

    图 9.5:行点击的主/详细视图

    注意行是如何被突出显示以表示选择的。如果您在右上角翻转演示“查看详情”列选项,您将取消隐藏查看详情列。

    如果您点击查看图标,ViewUserComponent将使用模板中的routerLink在详细出口中渲染,如下所示:

    图 9.6:主/详细视图图标点击

    在上一章中,我们实现了编辑按钮,由右上角的铅笔图标表示,将userId传递给UserProfile以编辑和更新数据。

  8. 点击编辑按钮,将被带到ProfileComponent,编辑用户记录,并验证您是否可以更新其他用户的记录。

  9. 确认您可以在数据表中查看更新的用户记录。

这本书中 LemonMart 的数据表分页演示完成了主要功能。在继续之前,请确保所有测试都已通过。

对于单元测试,我导入NameInputComponentViewUserComponent的具体实现,而不是使用angular-unit-test-helper中的createComponentMock函数。这是因为createComponentMock不足以将数据绑定到子组件。在进一步阅读部分,我包括了一篇由 Aiko Klostermann 撰写的博客文章,该文章涵盖了使用@Input()属性测试 Angular 组件。

实现的重任完成后,我们现在可以探索替代的架构、工具和库,以更好地理解针对各种需求构建 Angular 应用的最好方式。接下来,让我们探索 NgRx。

NgRx 存储和效果

第一章Angular 的架构和概念所述,NgRx 库基于 RxJS 将响应式状态管理引入 Angular。使用 NgRx 进行状态管理允许开发者编写原子化、自包含和可组合的代码片段,创建动作、还原器和选择器。这种响应式编程将副作用隔离在状态变化中。NgRx 是 RxJS 之上的抽象层,以适应Flux 模式

NgRx 有四个主要元素:

  • 存储:状态信息持久化的中心位置。您在存储中实现一个还原器以存储状态转换,并实现一个选择器以从存储中读取数据。这些都是原子化和可组合的代码片段。

    视图(或用户界面)通过使用选择器显示存储中的数据。

  • 动作:在整个应用中发生的独特事件。

    动作从视图触发,目的是将它们派发到存储中。

  • 派发器:这是一个向存储发送动作的方法。

    存储中的还原器监听派发的动作。

  • 效果:这是动作和派发器的组合。效果通常用于不是从视图中触发的动作。

让我们回顾以下 Flux 模式图,现在突出显示了一个效果

系统图,自动生成描述

图 9.7:通量模式图

让我们通过一个具体的例子来演示 NgRx 是如何工作的。为了保持简单,我们将利用 LocalCast 天气应用。

实现 LocalCast 天气应用的 NgRx

我们将在 LocalCast 天气应用中实现 NgRx 以执行搜索功能。考虑以下架构图:

天气预报图,自动生成描述

图 9.8:LocalCast 天气架构

我们将使用 NgRx 存储和效果库来实现我们的实现。NgRx 存储动作在图中以浅灰色表示,包括WeatherLoaded还原器和应用状态。在顶部,动作表示为各种数据对象流,要么派发动作,要么对派发的动作进行操作,使我们能够实现第一章Angular 的架构和概念中引入的Flux 模式。NgRx 效果库通过在其模型中隔离副作用来扩展 Flux 模式,而不会在存储中留下临时数据。

图 9.8中以深灰色表示的效果工作流程从步骤 1开始:

  1. CitySearchComponent派发search动作。

  2. search动作出现在@ngrx/action可观察流(或数据流)中。

  3. CurrentWeatherEffectssearch动作进行操作以执行搜索。

  4. WeatherService 执行搜索以从 OpenWeather API 获取当前天气信息。

存储动作,以浅灰色表示,以 step A(大写 A)开头:

  1. CurrentWeatherEffects 分派 weatherLoaded 动作。

  2. weatherLoaded 动作出现在 Observable 数据流上,标记为 @ngrx/action 流。

  3. weatherLoaded 约束对 weatherLoaded 动作进行操作。

  4. weatherLoaded 约束将天气信息转换为要存储的新状态。

  5. 新状态是一个持久化的 search 状态,是 appStore 状态的一部分。

注意,有一个包含子 search 状态的父级 appStore 状态。我故意保留了这种设置,以展示父级状态如何随着你在存储库中添加不同类型的数据元素而扩展。

最后,一个视图(一个 Angular 组件)从存储中读取,以 step a(小写 a)开始:

  1. CurrentWeather 组件使用 async 管道订阅 selectCurrentWeather 选择器。

  2. selectCurrentWeather 选择器监听 appStore 状态中 store.search.current 属性的变化。

  3. appStore 状态检索持久化的数据。

使用 NgRx 选择器就像编写查询来读取存储在数据库中的数据。在这种情况下,数据库是存储库。

使用 NgRx,当用户搜索城市时,检索、持久化和在 CurrentWeatherComponent 上显示该信息的动作将自动通过单个可组合和不可变元素发生。

比较 BehaviorSubject 和 NgRx

我们将实现 NgRx 与 BehaviorSubject 一起,以便你可以看到实现相同功能的差异。为此,我们需要一个滑动开关来在两种策略之间切换:

本节使用 local-weather-app 仓库。你可以在 projects/stage12 文件夹下找到本章的代码示例。

注意,位于 src 文件夹下的主应用程序使用按钮切换在 SignalsBehaviorSubjectNgRx 之间切换。

  1. 首先,在 CitySearchComponent 上实现一个 <mat-slide-toggle> 元素,如下面的截图所示:天气预报截图 自动生成的描述

    图 9.9:LocalCast 天气滑动切换

    确保字段由组件上的名为 useNgRx 的属性支持。

  2. 重构 doSearch 方法,将 BehaviorSubject 代码提取到名为 behaviorSubjectBasedSearch 的单独函数中。

  3. 创建一个名为 ngRxBasedSearch 的函数原型:

    **src/app/city-search/city-search.****component****.****ts**
    doSearch(searchValue: string) {
      const userInput = searchValue.split(',').map((s) => s.trim())
      const searchText = userInput[0]
      const country = userInput.length > 1 ? userInput[1] : undefined
      **if** **(****this****.****useNgRx****) {**
        **this****.****ngRxBasedSearch****(searchText, country)**
      **}** **else** **{**
        **this****.****behaviorSubjectBasedSearch****(searchText, country)**
      **}**
    } 
    

我们将从你刚刚创建的 ngRxBasedSearch 函数中分派一个动作。

设置 NgRx

你可以使用以下命令添加 NgRx Store 包:

$ npx ng add @ngrx/store 

这将创建一个包含 index.ts 文件的 reducers 文件夹。现在添加 NgRx 的 effects 包:

$ npx ng add @ngrx/effects --minimal 

我们在这里使用 --minimal 选项以避免创建不必要的样板代码。

接下来,安装 NgRx 规范库,这样你就可以利用生成器为你创建样板代码:

$ npm i -D @ngrx/schematics 

由于 NgRx 的高度解耦特性,实现 NgRx 可能会令人困惑,这可能需要了解库的内部工作原理。

projects/stage12下的示例项目配置了@ngrx/store-devtools进行调试。

如果你希望在运行时能够console.log NgRx 动作进行调试或监控,可以使用 NgRx 文档中描述的 MetaReducer,ngrx.io/guide/store/metareducers

定义 NgRx 动作

在我们能够实现影响或还原器之前,我们首先需要定义我们的应用程序将能够执行的动作。对于 LocalCast Weather,有两种类型的动作:

  • search:获取正在搜索的城市或邮编的当前天气

  • weatherLoaded:表示已获取新的当前天气信息

通过运行以下命令创建名为search的动作:

$ npx ng generate @ngrx/schematics:action search --group --creators 

在提示时选择默认选项。

--group选项将动作分组在名为action的文件夹下。--creators选项使用创建函数来实现动作和还原器,这是一种更熟悉且直接的方式来实现这些组件。

现在,让我们使用createAction函数实现这两个动作,提供名称和预期的输入参数列表:

**src/app/action/search.****actions****.****ts**
import { createAction, props, union } from '@ngrx/store'
import { ICurrentWeather } from '../interfaces'
export const SearchActions = {
  search: createAction(
    '[Search] Search',
    props<{ searchText: string; country?: string }>()
  ),
  weatherLoaded: createAction( 
    '[Search] CurrentWeather loaded',
    props<{ current: ICurrentWeather }>()
  ),
}
const all = union(SearchActions)
export type SearchActions = typeof all 

搜索操作名为'[Search] Search',输入参数包括searchText和一个可选的country参数。weatherLoaded操作遵循类似的模式。在文件末尾,我们创建了一个动作的联合类型,这样我们就可以将它们分组在单个父类型下,以便在应用程序的其余部分中使用。

注意,动作名称前缀为[Search]。这个约定有助于开发者在调试期间视觉上分组相关的动作。

现在我们已经定义了动作,我们可以实现影响来处理搜索动作并分发一个weatherLoaded动作。

实现 NgRx 影响

如前所述,影响允许我们更改存储的状态,而无需存储导致更改的事件数据。例如,我们希望我们的状态只有天气数据,而不是搜索文本本身。影响允许我们一步完成此操作,而不是强迫我们使用中间存储来存储searchText,以及一个更复杂的链式事件来将其转换为天气数据。

否则,我们可能需要在中间实现一个还原器。我们首先需要将此值存储在 NgRx 存储中,然后从服务中检索它,最后分发一个weatherLoaded动作。影响将使从服务中检索数据变得更加简单。

现在,让我们将CurrentWeatherEffects添加到我们的应用程序中:

$ npx ng generate @ngrx/schematics:effect currentWeather --module=app.module.ts --root --group --creators 

在提示时选择默认选项。

你将在effects文件夹下创建一个新的current-weather.effects.ts文件。

再次强调,--group用于将影响分组在同名文件夹下。--root将影响注册到app.module.ts中,我们使用带有--creators选项的创建函数。

CurrentWeatherEffects文件中,首先实现一个私有的doSearch方法:

**src/app/effects/current-weather.****effects****.****ts**
private doSearch(action: { searchText: string; country?: string }) {
  return this.weatherService.getCurrentWeather(
    action.searchText,
    action.country
  ).pipe(
    map((weather) =>
      SearchActions.weatherLoaded({ current: weather })
    ),
    catchError(() => EMPTY)
  )
} 

注意,我们选择忽略由EMPTY函数抛出的错误。您可以使用类似于为 LemonMart 实现的UiService将这些错误暴露给用户。

这个函数接受一个带有搜索参数的操作,调用getCurrentWeather,并在收到响应后,派发weatherLoaded操作,传递当前天气属性。

现在,让我们创建效果本身,这样我们就可以触发doSearch函数:

**src/app/effects/current-weather.****effects****.****ts**
getCurrentWeather$ = createEffect(() =>
  this.actions$.pipe(
    ofType(SearchActions.search), 
    exhaustMap((action) => this.doSearch(action))
  )
) 

这是我们利用 Observable 操作流this.actions$并监听SearchAction.search类型操作的地方。然后我们使用exhaustMap操作符来注册发射的事件。

由于其独特的性质,exhaustMap不会允许在doSearch函数完成派发weatherLoaded操作之前处理另一个搜索操作。

RxJS 操作符对操作的影响

在前面的例子中,我使用了exhaustMap操作符。这并不一定是这个用例的正确 RxJS 操作符,switchMap才是。我选择exhaustMap的明确目的是为了限制对免费资源生成的 API 调用数量,这样可以积极限制请求的速率。

让我们探索我们可以选择的四个 RxJS 操作符:

  1. mergeMap:允许并行处理多个操作,适用于每个操作的效果是独立且不需要同步的场景。

  2. concatMap:按顺序处理操作,只有在前一个操作完成之后才开始下一个操作,确保操作按它们被派发的顺序处理,这对于在状态更新中保持一致性很有用。

  3. switchMap:在接收到新操作时,取消之前的操作并切换到新的操作,这对于搜索栏输入等用例非常合适,在这些用例中,只有最新的操作(例如,用户输入)是相关的。

  4. exhaustMap:如果已有操作正在处理,则忽略新操作,这使得它对于避免重复或冲突请求很有用,例如,多次提交相同的表单。

使用exhaustMap,如果doSearch函数在创建操作之前快速创建,则尚未处理的操作将被丢弃。所以,如果创建了操作abcde,但doSearchcd创建之间完成,那么操作bce将永远不会被处理,但操作d将会被处理。对于bce的 API 调用永远不会发生。只有为d发出的weatherLoaded操作。虽然我们避免了为用户永远不会看到的成果进行不必要的 API 调用,但最终状态可能会让用户感到困惑。

使用mergeMap,所有搜索操作都是并行处理的,进行 API 调用,并派发weatherLoaded操作。所以,如果快速创建了操作abcde,用户可能会看到所有操作的闪烁结果,但最后显示的是e

使用concatMap,动作按顺序处理。考虑到动作abcde,对于b的 API 调用不会在为a分发了weatherLoaded动作并渲染结果之后进行。这将为每个动作发生,直到显示e的天气。

使用switchMap,每个动作都会进行 API 调用。然而,只有最后一个动作会被分发,所以用户只会看到最后一个动作显示。

因此,从 UX 的角度来看,switchMap在功能上是正确的实现。您还可以在数据处理时实现加载指示器或禁用用户输入,以防止昂贵的 API 调用。

最终,根据您的用例和 UX 需求,考虑使用不同的 RxJS 操作符。并非所有分发的动作都会导致需要渲染的屏幕。如果您想保留所有数据输入,您可以在服务工作者后台线程中处理动作,并更新应用中的通知面板或徽章计数器。

实现 reducers

当触发weatherLoaded动作时,我们需要一种方法来摄取当前天气信息并将其存储在我们的appStore状态中。Reducer 将帮助我们处理特定动作,创建一个隔离且不可变的管道以可预测地存储我们的数据。

让我们创建一个searchreducer:

$ npx ng generate @ngrx/schematics:reducer search 
    --reducers=reducers/index.ts --group --creators 

使用默认选项。在这里,我们使用--group来保持文件在reducers文件夹下组织,并使用--creators来利用创建 NgRx 代码的创建者风格。我们还使用--reducers指定我们的父appStore状态的位置在reducers/index.ts,以便我们的新 reducer 可以与之注册。

您可能会注意到reducers.index.ts已更新以注册新的search.reducer.ts。让我们一步一步实现它。

search状态中,我们将存储当前天气,因此实现接口以反映这一点:

**src/app/reducers/search.****reducer****.****ts**
export interface State {
  current: ICurrentWeather
} 

现在让我们指定initialState。这类似于我们需要定义signalBehaviorSubject的默认值。重构WeatherService以导出const defaultWeather: ICurrentWeather对象,您可以使用它来初始化BehaviorSubjectinitialState

**src/app/reducers/search.****reducer****.****ts**
export const initialState: 
  State = { 
      current: defaultWeather,
  } 

最后,使用on操作符实现searchReducer来处理weatherLoaded动作:

**src/app/reducers/search.****reducer****.****ts**
const searchReducer = createReducer(
  initialState,
  on(SearchActions.weatherLoaded, (state, action) => {
    return {
      ...state,
      current: action.current,
    }
  })
) 

我们注册了weatherLoaded动作,解包存储的数据,并将其传递到search状态。

当然,这是一个非常简单的例子。然而,很容易想象一个更复杂的场景,我们可能需要扁平化或处理接收到的数据并将其以易于消费的方式存储。以不可变的方式隔离这种逻辑是利用像 NgRx 这样的库的关键价值主张。

使用选择器在 Store 中注册

我们需要CurrentWeatherComponent注册到appStore状态以获取更新的当前天气数据。

首先,依赖注入appStore状态并注册选择器以从State对象中提取当前天气:

**src/app/current-weather/current-weather.****component****.****ts**
**import** ***** **as** **appStore** **from****'../reducers'**
export class CurrentWeatherComponent {
  current$: Observable<ICurrentWeather>
  constructor(**private****store****:** **Store****<appStore.****State****>**) {
    this.current$ =
      **this****.****store****.****pipe****(****select****(****(****state****:** **State****) =>** **state.****search****.****current****))**
  } 
  ...
} 

我们监听通过存储流动的状态变化事件。使用select函数,我们可以实现内联选择以获取所需的数据。

我们可以稍作重构,通过使用createSelectorreducers/index.ts上创建selectCurrentWeather属性来使我们的选择器可重用:

**src/app/reducers/index.****ts**
export const selectCurrentWeather = createSelector(
  (state: State) => state.search.current,
  current => current
) 

随着 TypeScript 接口和 NgRx 选择器的数量增加,您应该将它们拆分为单独的文件,并更好地组织您的代码。

此外,由于我们希望保持BehaviorSubject的持续操作,我们可以在CurrentWeatherComponent中实现一个merge运算符来监听WeatherService更新和appStore状态更新:

**src/app/current-weather/current-weather.****component****.****ts**
import * as appStore from '../reducers'
  constructor(
    private weatherService: WeatherService,
    private store: Store<appStore.State>
  ) {
    this.current$ = merge(
      **this****.****store****.****pipe****(****select****(appStore.****selectCurrentWeather****)),**
      this.weatherService.currentWeather$
    )
  } 

现在我们能够监听存储更新,让我们实现最终的拼图碎片:分发搜索动作。

分发存储动作

我们需要分发搜索动作,以便我们的搜索效果可以获取当前天气数据并更新存储。在本章的早期,您在CitySearchComponent中实现了一个名为ngRxBasedSearch的存根函数。

让我们实现ngRxBasedSearch

**src/app/city-search/city-search.****component****.****ts**
ngRxBasedSearch(searchText: string, country?: string) {
  this.store.dispatch(SearchActions.search({ searchText, country }))
} 

不要忘记将appState存储注入到组件中!

就这样!现在您应该能够运行您的代码并测试是否一切正常。

如您所见,NgRx 带来了许多复杂的技巧来创建使数据转换不可变、定义良好和可预测的方法。然而,这伴随着相当大的实现开销。

使用您的最佳判断来决定您是否需要在您的 Angular 应用程序中使用 Flux 模式。前端应用程序代码可以通过实现返回平面数据对象的 RESTful API 而变得简单得多,复杂的数据操作由服务器端处理,从而减少,如果不是消除,对像 NgRx 这样的工具的需求。

单元测试 reducer 和选择器

您可以在search.reducer.spec.ts中对weatherLoadedreducer 和selectCurrentWeather选择器实现单元测试:

**src/app/reducers/search.****reducer****.****spec****.****ts**
import { SearchActions } from '../actions/search.actions'
import { defaultWeather } from '../weather/weather.service'
import { fakeWeather } from '../weather/weather.service.fake'
import { selectCurrentWeather } from './index'
import { initialState, reducer } from './search.reducer'
describe('Search Reducer', () => {
  describe('weatherLoaded', () => {
    it('should return current weather', () => {
      const action = SearchActions.weatherLoaded({ current: fakeWeather })
      const result = reducer(initialState, action)
      expect(result).toEqual({ current: fakeWeather })
    })
  })
})
describe('Search Selectors', () => { 
  it('should selectCurrentWeather', () => {
    const expectedWeather = defaultWeather
    expect(selectCurrentWeather({
      search: { current: defaultWeather }
    })).toEqual(
      expectedWeather
    )
  })
}) 

这些单元测试很简单,将确保在存储中不会发生对数据结构的意外更改。

使用 MockStore 单元测试组件

您需要更新CurrentWeatherComponent的测试,以便我们可以将模拟的Store注入到组件中以测试current$属性的值。

让我们看看需要添加到spec文件中的 delta,以配置模拟存储:

**src/app/current-weather/current-weather.****component****.****spec****.****ts**
import { MockStore, provideMockStore } from '@ngrx/store/testing'
describe('CurrentWeatherComponent', () => {
  ...
  let store: MockStore<{ search: { current: ICurrentWeather } }>
  const initialState = { search: { current: defaultWeather } }
  beforeEach(async(() => {
    ...
    TestBed.configureTestingModule({
      imports: [AppMaterialModule],
      providers: [
        ...
        **provideMockStore****({ initialState }),**
      ],
    }).compileComponents()
    ...
    **store =** **TestBed****.****inject****(****Store****)** **as****any**
  }))
...
}) 

我们现在可以更新'should get currentWeather from weatherService'测试,以查看CurrentWeatherComponent是否与模拟存储一起工作:

**src/app/current-weather/current-weather.****component****.****spec****.****ts**
it('should get currentWeather from weatherService', (done) => {
  // Arrange
  store.setState({ search: { current: fakeWeather } })
  weatherServiceMock.currentWeather$.next(fakeWeather)
  // Act
  fixture.detectChanges() // triggers ngOnInit()
  // Assert
  expect(component.current$).toBeDefined()
  component.current$.subscribe(current => { 
    expect(current.city).toEqual('Bethesda')
    expect(current.temperature).toEqual(280.32)
    // Assert on DOM
    const debugEl = fixture.debugElement
    const titleEl: HTMLElement =
      debugEl.query(By.css('.mat-title')).nativeElement
    expect(titleEl.textContent).toContain('Bethesda')
    done()
  })
}) 

模拟存储允许我们设置存储的当前状态,这反过来又允许选择器在构造函数中调用以触发并获取提供的虚假天气数据。

TestBed 并非在 Angular 中编写单元测试的硬性要求,关于这个话题在 angular.dev/guide/testing 有很好的介绍。我的同事和第二版的审稿人布伦登·考林斯为这一章贡献了一个无床的 spec 文件,名为 current-weather.component.nobed.spec.ts。他提到在运行测试时,由于减少了导入和维护,性能有显著提升,但需要更高水平和专业知识来实现测试。如果你在一个大型项目中,考虑跳过 TestBed

GitHub 上的示例代码位于 projects/stage12 文件夹下。

继续更新你剩余的测试,直到它们全部通过后再继续。

NgRx 生态系统

现在你对 NgRx 的理解已经超越了理论层面,让我们来检查生态系统中的不同可用选项。

这里有一些社区中流行的选项,包括 NgRx 的兄弟包:

  • NgRx/Data,一个简化实体管理的 NgRx 入门

  • NgRx/ComponentStore,NgRx/Store 的组件范围版本,减少了样板代码

  • NgRx/SignalStore,Angular 中下一代状态管理

  • Akita,为 JS 应用量身定制的响应式状态管理解决方案

  • Elf,一个具有神奇力量的响应式存储

让我们探索这些选项。

NgRx/Data

如果 NgRx 是基于配置的框架,那么 NgRx Data 就是 NgRx 的基于约定的兄弟。NgRx Data 自动创建存储、效果、动作、还原器、分发和选择器。如果你的应用程序的大部分动作是 CRUD创建检索更新删除)操作,那么 NgRx Data 可以用更少的代码实现与 NgRx 相同的结果。

@ngrx/data@ngrx/entity 库协同工作。它们共同提供了一套丰富的功能,包括事务性数据管理。

NgRx Data 可能是你和你的团队更好地了解 Flux 模式的一个很好的入门,它允许轻松地过渡到完整的 NgRx 框架。不幸的是,NgRx Data 已不再推荐用于新项目。

截至 17 版本,NgRx Data 正式进入维护模式,不推荐用于新项目或添加到现有项目中。

你可以在 ngrx.io/guide/data 上了解更多相关信息。

你可以通过执行以下命令将 NgRx Data 添加到你的项目中:

$ npx ng add @ngrx/store –minimal
$ npx ng add @ngrx/effects –minimal
$ npx ng add @ngrx/entity
$ npx ng add @ngrx/data 

那么,你应在你的下一个应用中实现 NgRx Data 吗?这取决于情况,但鉴于其维护模式的状态,可能不太适合。由于这个库是 NgRx 之上的抽象层,如果你没有很好地理解 NgRx 的内部结构,你可能会感到迷茫和受限。然而,这个库在减少实体数据管理和 CRUD 操作的样板代码方面有很大的潜力。

如果你正在应用程序中执行大量 CRUD 操作,你可能可以节省时间,但请注意将实现范围限制在需要它的区域。正如 NgRx 文档所强调的,NgRx Data 缺乏许多功能齐全的实体管理系统功能,如深度实体克隆、服务器端查询、关系、键生成和非规范化服务器响应。

对于一个功能齐全的实体管理库,可以考虑 BreezeJS www.getbreezenow.com/breezejs。然而,请注意 Breeze 并不遵循 NgRx 所采用的响应式、不可变和 Redux 原则。

接下来,让我们调查 ComponentStore 在 Flux 模式更不具挑战性和更专注的应用。

NgRx/ComponentStore

NgRx ComponentStore 提供了一种轻量级、响应式的状态管理解决方案,非常适合组件或模块内的本地状态。

它旨在在不需要全局存储的情况下管理本地状态,保持关注点的清晰分离,并使组件简单易维护。这种方法对于具有许多本地状态和交互的复杂组件尤其有用,因为它允许基于推的服务管理此状态,支持可重用性和独立实例。

你可以使用ComponentStore实现分页数据表的 dataSource,类似于使用 Elf 的方式。查看 Pierre Bouillon 这篇出色的两篇博客文章:dev.to/this-is-angular/handling-pagination-with-ngrx-component-stores-1j1p

相比之下,NgRx Store 管理全局共享状态,对需要可扩展性、多个效果和 DevTools 集成的较大应用程序有益。虽然 ComponentStore 的可扩展性较低,并且有许多更新器和效果,但它确保了类型安全、性能和易于测试,从而允许更封装和组件特定的状态管理。

ComponentStoreStore之间的选择取决于应用程序的大小、组件依赖、状态持久性和业务需求等因素。

ngrx.io/guide/component-store了解更多关于 ComponentStore 的信息。

你可以通过执行以下操作将 ComponentStore 添加到你的项目中:

$ npx ng add @ngrx/component-store 

简而言之,ComponentStore是本书中提到的“带有 Subject 的服务”方法的替代方案。然而,随着 Angular 架构向信号转变,你可能想要跳过ComponentStore并实现SignalStore

NgRx/Signals

在第二章“表单、可观察者、信号和主题”的“使用 Angular 信号”部分,我向您介绍了信号。NgRx/Signals 是一个自包含的库,它提供了一个反应式状态管理解决方案,并附带了一套用于处理 Angular 信号的实用工具。它旨在简单易用,为开发者提供了一个直观的 API。其轻量级特性确保了应用程序负载最小,同时保持高性能。

该库推崇声明式编程,培养简洁的代码。它促进了自主组件的构建,这些组件易于集成,促进了可扩展和灵活的应用程序。此外,它强制执行类型安全,在开发周期的早期阶段减少错误。

该库包括以下内容:

  • SignalStore 是一个强大的状态管理系统,它从 NgRx/Store 和 NgRx/ComponentStore 中汲取了最佳之处。

  • SignalState 是一个简化的实用工具,用于在 Angular 组件和服务中管理状态,它取代了服务中任何需要自行管理的信号属性。

  • rxMethod 提供了可选的使用方式来与 Observables 交互。这对于与现有代码交互非常有用。

  • withEntities 是一个实体管理插件,提供了一种高效的方法来促进 CRUD 操作,以管理实体。

我们将在接下来的章节中深入探讨 SignalState 和 SignalStore。

您可以在 ngrx.io/guide/signals 上了解更多关于 NgRx Signals 的信息。

您可以通过执行以下命令将 SignalStore 添加到您的项目中:

$ npx ng add @ngrx/signals 

让我们通过一些流行的非 NgRx 选项,如 Akita 和 Elf,来结束我们的状态管理生态系统之旅。

Akita

Akita 是一个将 Flux、Redux 和 RxJS 概念结合到可观察数据存储模型中的状态管理解决方案,它倾向于不可变性和流式数据。它强调简单性,减少了样板代码,由于其适中的学习曲线,使得所有级别的开发者都能轻松上手。Akita 采用面向对象原则,对于那些熟悉面向对象编程的人来说,它使代码更加直观,并强制执行一致的结构来指导和标准化团队的开发实践。

Akita 围绕 RxJS 的 BehaviorSubject 构建,并为状态管理提供了专门的类,如 StoreQueryEntityStore。与 NgRx 类似,Akita 将状态变化暴露为 RxJS Observables,并使用更新方法进行状态突变,从而实现面向对象的状态管理风格。

如果您正在寻找一个具有内置实体管理、状态历史记录插件、服务器端分页、更多面向对象而非函数式,以及总体上更少的样板代码的简单解决方案,那么尝试 Akita 是值得的。

您可以在 opensource.salesforce.com/akita/ 上了解更多关于 Akita 的信息。

Elf

Elf 是众多选项中最神奇的一个。它是一个针对 Angular 的新状态管理库,旨在通过最小化 API 简化反应性和状态突变,专注于人体工程学和易用性。它使用现代 RxJS 模式进行状态管理,使您能够对状态变化和反应性进行精细控制。Elf 设计得轻量级且直观,为更全面的 NgRx 套件提供了一个更简单的替代方案。

Elf 是模块化的,完全可摇树(tree-shakable),并提供了一级支持以下功能:

  • 请求缓存,以防止冗余的 API 调用。

  • 实体,如 NgRx/Data 或 Akita。

  • 状态持久化,适用于离线优先的应用程序。

  • 状态历史,便于实现撤销/重做功能。

  • 高级分页,以优化分页数据的获取和缓存。

  • Elf 集成了构建具有状态管理和声明式 Web 应用的功能和最佳实践,并使其变得简单。虽然可以使用 NgRx/ComponentStore 实现像分页支持这样的功能,但内置的分页缓存支持令人印象深刻。此外,Elf 还有一个插件,可以同步浏览器标签页之间的状态,从而实现真正的高级状态管理。

  • 考虑到 Elf 内置的众多高质量功能,它是一个突出的解决方案,可能是你下一个项目的正确选择。你可以在 ngneat.github.io/elf/ 上了解更多关于 Elf 的信息。

我们已经涵盖了 NgRx 生态系统的细微差别。让我们学习如何使用 Angular 配置代理,以处理期望以特定方式访问服务器端数据的基于约定的状态管理库。

使用 Angular CLI 配置服务器代理

一些状态管理库,尤其是基于约定的实体存储库如 NgRx Data,对访问服务器端数据做出了假设。在 NgRx Data 的情况下,库希望通过与你 Angular 应用在同一端口上运行的 /api 路径来访问 REST API。我们必须利用 Angular CLI 的代理功能来实现这一目标,在开发过程中完成。

通常,HTTP 请求会发送到我们的 web 服务器,并且我们的 API 服务器应该有相同的 URL。然而,在开发过程中,我们通常在 http://localhost 的两个不同端口上托管这两个应用程序。某些库,包括 NgRx Data,要求 HTTP 调用在同一个端口上。这为创建无摩擦的开发体验带来了挑战。因此,Angular CLI 随带了一个代理功能,你可以用它将 /api 路径指向 localhost 上的不同端点。这样,你可以使用一个端口来服务你的 web 应用和 API 请求:

  1. src 下创建一个 proxy.conf.json 文件,如下所示:

如果你正在使用 lemon-mart-server 单一仓库,这将位于 web-app/src

**proxy.conf.json**
{
  "/api": {
    "target": "http://localhost:3000",
    "secure": false,
    "pathRewrite": {
       "^/api": ""
    }
  }
} 
  1. 使用 angular.json 注册代理:

    **angular.json**
    ...
    "serve": {
      "builder": "@angular-devkit/build-angular:dev-server",
      "options": {
        "browserTarget": "lemon-mart:build",
        "proxyConfig": "proxy.conf.json"
      },
      ...
    } 
    

现在当您运行npm startng serve时启动的服务可以重写对/api路由的任何调用 URL 为http://localhost:3000。这是lemon-mart-server默认运行的端口。

如果您的 API 在不同的端口上运行,请使用正确的端口号和子路由。

使用 NgRx/SignalState 实现全局加载指示器

在第八章的“多步骤响应式表单”部分,以及本章前面的“数据表格与分页”部分,我讨论了本地化加载指示器和全局加载指示器之间的区别。全局加载指示器是解决 UI 元素在数据加载时未准备好交互而产生的 UX 问题的 80-20 解决方案。然而,这将在具有多个屏幕组件或后台服务工作者加载数据的大型应用程序中引起过多的全屏中断。在这种情况下,大多数组件将需要本地加载指示器。

考虑到这一点,让我们追求 80-20 解决方案。我们可以使用HttpInterceptor来检测应用程序内是否进行了 API 调用。这允许我们显示或隐藏全局加载指示器。然而,如果有多个并发调用,我们必须跟踪这一点,否则全局加载指示器可能会行为异常。使用 NgRx/SignalState,我们可以跟踪调用次数,而无需在服务中引入本地状态。

NgRx/SignalState

SignalState 是@ngrx/signals提供的一个轻量级实用工具,用于以简洁和极简的方式在 Angular 组件和服务中管理基于信号的状态。它用于在组件类、服务或独立函数中直接创建和操作状态的小部分。您可以提供一个对象属性的深层嵌套信号。

SignalState 应在组件或服务中使用来管理简单的状态。该库提供了以下函数:

  • signalState是一个实用函数,它接受存储的初始状态并定义状态的结构。

  • patchState更新存储的值。

ngrx.io/guide/signals/signal-state中了解更多关于 NgRx SignalState 的信息。

我们首先将signalStatecomputed signalshowLoaderhideLoader函数添加到UiService中:

  1. 按照以下方式修改UiService

    **src/app/common/ui.****service****.****ts**
    @Injectable({ providedIn: 'root' })
    export class UiService {
      ...
      private readonly loadState = **signalState**({ 
        count: 0, 
        isLoading: false 
      })
      isLoading = **computed**(() => this.loadState.isLoading())
      showLoader() {
        if (this.loadState.count() === 0) {
          **patchState**(this.loadState, () => ({ isLoading: true }))
        }
        **patchState**(this.loadState, (state) => ({ 
          count: state.count++ 
        }))}
      hideLoader() {
        **patchState**(this.loadState, (state) => ({ 
          count: state.count—
        }))
        if (this.loadState.count() === 0) {
          **patchState**(this.loadState, () => ({ isLoading: false }))
        }
      }  
      ...
    } 
    

    我们首先定义一个私有的signalState并初始化countisLoading属性。状态应该始终封装在使用它的边界内,以避免不可控的副作用。正如我们在下一节中将要讨论的,SignalStore 是管理副作用的一个更健壮的解决方案。然而,我们希望isLoading是公开可用的,以便 UI 组件可以将其绑定以隐藏或显示加载指示器。因此,我们实现了一个computed信号,它作为一个选择器以只读方式返回isLoading的当前值。

    patchState 是一个实用函数,它提供了一种类型安全的方式来对状态片段进行不可变更新。我们使用它来更新 countisLoading 的值,每当调用 showhide 函数时。

  2. 接下来,在 src/common 下实现 LoadingHttpInterceptor 以调用 showhide 方法:

    **src/common/loading.****http****.****interceptor****.****ts**
    export function LoadingHttpInterceptor(
      req: HttpRequest<unknown>, next: HttpHandlerFn) {
      const uiService = inject(UiService)
      uiService.showLoader()
      return next(req).pipe(finalize(() => 
                            uiService.hideLoader()))
    } 
    

    我们注入 UiService 并调用 showLoader 以将计数加一。然后设置最终操作符,以便 API 调用完成后调用 hideLoader 以将计数减一。因此,每当调用加载器函数且计数等于零时,我们知道我们需要显示或隐藏旋转器。

    不要忘记在 app.config.ts 中提供新的拦截器。

  3. 现在,在 common 下创建一个 LoadingOverlayComponent 并使用 isLoading 来显示或隐藏一个旋转器:

    **src/common/loading-overlay.****component****.****ts**
    @Component({
      selector: 'app-loading-overlay',
      template: `
        **@if (uiService.isLoading()) {**
          <div class="overlay">
            <div class="center">
              <img alt="loading" class="spinner"
                        src="img/lemon.svg" />
            </div>
          </div>
        **}**
      `,
      styles: `
        .overlay {
          position: fixed;
          width: 100%;
          height: 100%;
          left: 0;
          top: 0;
          background-color: rgba(255, 255, 255, 0.65);
          z-index: 9999;
        }
        .spinner {
          display: block;
          width: 48px;
          height: 48px;
          animation-name: spin;
          animation-duration: 1.00s;
          animation-iteration-count: infinite;
          animation-timing-function: ease-in-out;
        }
        @keyframes spin {
          0% { transform: rotate(0deg); }
          100% { transform: rotate(360deg); }
        }
        .center {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
          }
      `,
      standalone: true,
      **encapsulation****:** **ViewEncapsulation****.****ShadowDom**,
    })
    export class LoadingOverlayComponent {
      readonly uiService = inject(UiService)
    } 
    

    我们注入并使用来自 UiService 的计算 isLoading 信号。通过 @if 流程控制,旋转器将根据 isLoading 是否设置为 true 或 false 来显示或隐藏。

    Angular 中的 ViewEncapsulation.ShadowDom 使用允许组件样式在 Shadow DOM 内封装。默认情况下,Angular 使用模拟模式将样式范围限定在组件内。然而,Shadow DOM 封装为动态 CSS 功能提供了更稳健的支持。

  4. 最后,更新 app.component.ts 以导入并将在模板顶部放置新组件:

    **src/app/app.****component****.****ts**
    template: `
        **<app-loading-overlay></app-loading-overlay>**
        <div class="app-container">
        ... 
    
  5. 尝试一下。每当进行 API 调用时,一个辉煌的柠檬旋转器将接管屏幕。柠檬登录截图 自动生成描述

    图 9.10:LemonMart 的柠檬旋转器

  6. 这解决了第八章,食谱 - 可重用性、表单和缓存中提到的用户资料表单中的数据弹出问题。

    你的 API 调用是否太快以至于无法欣赏旋转器?

    lemon-mart-server 中,你可以添加两秒的延迟:

    `server/src/v1/routes/authRouter.ts`
    router.get('/me', authenticate(), async (_req, res) => {
      `await setTimeout(2000)`
    ... 
    

    或者,你可以在浏览器 DevTools 的 网络 选项卡中将 无节流 下拉菜单更改为 Fast 3GSlow 3G

使用 HTML 和 CSS 预加载屏幕

如果你遵循上一节的提示,并将浏览器网络速度减慢到 Slow 3G 并禁用缓存,你会注意到任何东西在屏幕上显示都需要很长时间。在第三章,构建企业级应用中,我介绍了如何实现 服务器端渲染SSR)来克服此类问题。然而,这并不总是可行的,或者可能过于复杂。使用简单的 HTML 和 CSS,我们可以实现一个简单的方法来展示一个吸引人且动态的加载屏幕,以娱乐在慢速网络下盯着你的应用的用户。

让我们从添加 LemonMart 的 CSS 开始:

  1. src/assets/styles 下创建 spinner.css。从 LemonMart 的 GitHub 仓库复制内容至 github.com/duluca/lemon-mart/blob/main/src/assets/styles/spinner.css

  2. 更新 index.html 以导入样式表,并将必要的 HTML 放在 <app-root> 元素内部:

    **src/index.html** 
    <head>
      ...
      **<****link****href****=****"assets/styles/spinner.css"****rel****=****"stylesheet"** **/>**
    </head>
    <body class="mat-typography mat-app-background">
        <app-root>
          **<****div****class****=****"spinner-background"****>**
            **<****div****class****=****"spinner-container"****>**
              **<****svg****class****=****"spinner"****width****=****"****65px"****height****=****"65px"**
                **viewBox****=****"0 0 66 66"****>**
                **<****circle****class****=****"path"****fill****=****"none"****stroke-width****=****"6"**
                  **stroke-linecap****=****"round"****cx****=****"33"****cy****=****"33"****r****=****"30"****>**
                **</****circle****>**
              **</****svg****>**
              **<****h2****class****=****"animate-text"****>****Loading****</****h2****>**
            **</****div****>**
          **</****div****>**
        </app-root>
    </body> 
    

    当 Angular 启动时,<app-root> 的内容将被您的应用程序内容替换。

    注意,这个预加载屏幕被设计成最小化,应该会立即出现在屏幕上。然而,您会发现它仍然可能需要长达 6 秒钟才能显示。这是因为 Angular 优先加载全局的 styles.scss 文件。如果您使用 Angular Material,这将增加 165 KB 的内容,在 慢速 3G 网络中几乎需要 6 秒钟才能加载。然而,在 50 秒的总加载时间背景下,这仍然要好得多。

  3. 重新启动您的 Angular 应用程序,您应该会看到预加载屏幕:

计算机屏幕截图  描述自动生成

图 9.11:慢速网络上的预加载屏幕

现在您已经了解了如何使用 NgRx/SignalState 处理状态切片,让我们接下来深入了解出色的 NgRx/SignalStore 库。事实上,它如此之好,以至于它激发了我重写 LocalCast 天气应用程序,使其几乎完全依赖于 Observable 和 RxJS 操作符,并且没有订阅调用或异步管道。

使用 NgRx/SignalStore 重写 Angular 应用程序

使用 Observables,最好的订阅是你不需要做的。在这本书中,我们使用了 async pipetake(1)takeUntilDestroyedngOnDestroy 中的 unsubscribe 来尝试管理它们。这本书的示例代码在六年的时间里经过了各种实践者和专家的多次审查。每次审查都突出了 RxJS 代码中的一些疏忽或错误。

书的第三版提供了一个 99%无错误的实现。由于 RxJS 生态系统的疯狂复杂性,我永远无法声称 100%。

我引以为傲的是不选择容易的道路。我尽力为您提供真实和完整的示例,而不仅仅是计数器和待办事项列表。然而,与现实生活中发生的事情相比,这些项目仍然是非常受控制和规模较小的。你很少有时间回头重新评估整个项目。错误随着时间的推移而累积。这是使用 RxJS 的工作中的一个令人悲伤的现实。它在所做的事情上很棒,但 95%以上的代码并不需要这个工具带来的灵活性和反应性。大多数代码都是关于从 API 中检索一些数据一次并显示出来。由 async/await 驱动的承诺的信号使这种代码的编写变得简单。

RxJS 和信号辅助工具

几个重要的函数将帮助您从 Observables 和 RxJS 转移:

  • JavaScript 承诺是一种允许异步操作的结构,提供了一种处理最终成功值或失败原因的方法。

  • JavaScript async/await 是 JavaScript 中的语法糖,它允许您以同步的方式编写异步代码,建立在承诺之上。

  • RxJS InteroptoSignal 创建一个跟踪 Observable 值的信号,类似于模板中的异步管道,但更灵活。类似于异步管道,toSignal 也为我们管理订阅,因此不需要使用 subscribetakeUntil 或取消订阅。还有一个 toObservable,这在过渡期间非常有用。

  • ChangeDetectionStrategy.OnPush 是一个策略,告诉 Angular 仅当组件的输入属性更改时才运行变更检测,通过减少检查次数来提高性能。你需要在你的组件中将 changeDetection 属性设置为这个值,直到基于 Signal 的组件到来。

  • lastValueFrom 是一个实用函数,它将 Observable 转换为承诺,该承诺解析为 Observable 发出的最后一个值。此操作符还为我们管理订阅。还有 firstValueFrom,但你可能不需要它。这个对话将有必要进行,直到 Angular 为 HttpClientRouterFormControl 等模块实现基于承诺的 API。

    RxJS 互操作功能目前处于开发者预览阶段。

    你可以在 angular.dev/guide/signals/rxjs-interop 上了解更多相关信息。

状态管理仍然是管理大型应用程序复杂性的关键。让我们看看 NgRx SignalStore 如何帮助过渡。

NgRx/SignalStore

SignalStore 是一个围绕声明式编程构建的完整功能状态管理解决方案,确保代码干净简洁。SignalStore 用于管理具有复杂状态的较大存储,而 SignalState 则是为在单个组件或服务中包含简单状态而设计的。

更多关于 NgRx SignalStore 的信息,请参阅 ngrx.io/guide/signals/signal-store

SignalStore 可以在根级别或组件级别提供。该库提供了以下函数:

  • signalStore 是一个用于管理应用程序中更大和更复杂状态片段的实用函数。

  • withState 接收存储的初始状态并定义状态的结构。

  • withComputed 从存储中现有的状态片段推导出计算属性。

  • withMethods 包含自定义函数(存储方法),这些函数公开暴露以通过一个定义良好的 API 操作存储。withMethods 可以使用 patchState 和注入的服务来更新存储。

  • withHooks 在存储创建或销毁时被调用,允许获取数据以初始化存储或更新状态。

  • withEntities 是一个扩展,用于简化实体管理中的 CRUD 操作。它类似于 @ngrx/entity,但并不相同。

    更多关于 NgRx SignalStore 实体的信息,请参阅 ngrx.io/guide/signals/signal-store/entity-management

    此外,还可以在 ngrx.io/guide/signals/signal-store/custom-store-features 查看带有自定义存储功能的先进用例。

让我们看看如何将 SignalStore 应用于 LocalCast 天气。下面的图表是本章前面 实现 NgRx for LocalCast 天气 部分中的图表的重现。

一个图表的图表  描述自动生成

图 9.12:LocalCast 天气架构

初步检查时,SignalStore 似乎比 NgRx Store 的实现更简单。这是因为信号的内生反应性已经内置到 Angular 中。你必须记住这条看不见的线索,它使得这个实现背后的魔法得以工作。

工作流程,如 图 9.12 中用深灰色表示,从 步骤 1 开始:

  1. CitySearchComponent 触发 doSearch 方法,该方法反过来调用 store.updateWeather

  2. withMethods 激活 updateWeather 函数,该函数注入了对 WeatherService 的引用,并从中调用 getCurrentWeather

  3. updateWeather 等待 getCurrentWeather 的结果,该结果从 OpenWeather 获取当前天气信息,并使用 patchState 更新 store.current

  4. CurrentWeatherComponent 绑定到 store.current,因此当值更新时,模板会自动更新。

现在我们已经理解了 SignalStore 的概念运作方式,让我们来浏览一下新的代码库。

重构 RxJS 和 NgRx 代码

我们将回顾重构后的 LocalCast 天气应用,以检查代码是如何使用信号和 SignalStore 重写以变得更加简单和简洁。

本节源代码位于 local-weather-app 仓库的 projects/signal-store 目录下 github.com/duluca/local-weather-app/tree/main/projects/signal-store

你可以通过执行以下命令来运行项目:

$ npx ng serve --project signal-store 

使用以下命令运行 Cypress 测试:

$ npx ng run signal-store:cypress-run --spec "cypress/e2e/app.cy.ts,cypress/e2e/simple-search.cy.ts" 

NgRx Store 到 SignalStore

让我们从 projects/signal-store/src/app/store 下的 Store 实现开始:

**projects/signal-store/src/app/store/weather.****store****.****ts**
export const WeatherStore = signalStore(
  {
    providedIn: 'root',
  },
  withState({
    current: defaultWeather,
  }),
  withMethods((store, weatherService = inject(WeatherService)) => ({
    async updateWeather(searchText: string, country?: string) {
      patchState(store, {
        current: await weatherService.getCurrentWeather(
          searchText,
          country
        ),
      })
    },
  }))
) 

withState 定义并初始化了存储。withMethods 实现了 updateWeather 函数,该函数封装了更新当前天气的行为。这个函数原本在 WeatherService 中,但现在已经被移动到存储中。可以说,对于 NgRx Store 的实现来说,这应该是最佳做法;然而,由于整体架构更加简单,更容易看出任何潜在的副作用最好在存储中实现。

从 Observables 到 Signals 的服务

我们必须更新 API 调用来返回 Promise 而不是 Observable。我首先更新了 PostalCodeService

**projects/signal-store/src/app/postal-code/postal-code.****service****.****ts**
export class PostalCodeService implements IPostalCodeService {
  private readonly httpClient = inject(HttpClient)
  resolvePostalCode(postalCode: string): Promise<IPostalCode> {
    const uriParams = new HttpParams()
      .set('maxRows', '1')
      .set('username', environment.username)
      .set('postalcode', postalCode)
    const httpCall$ = this.httpClient.get<IPostalCodeData>(
      `${environment.baseUrl}${environment.geonamesApi}.geonames.org/postalCodeSearchJSON`,
      { params: uriParams }
    )
    return lastValueFrom(httpCall$).then((data) =>
      data.postalCodes?.length > 0 ? 
        data.postalCodes[0] : defaultPostalCode
    )
  }
} 

resolvePostalCode现在返回Promise<IPostalCode>。我们将httpClient.get返回的 Observable 存储为本地变量httpCall$,然后通过lastValueFrom进行包装。在这个过程中,我们还移除了实现mergeMapdefaultIfEmpty以清理接收数据的管道。我们必须在then函数中实现类似的功能。如前所述,then的行为类似于一个tap函数。

接下来,让我们看看WeatherService

**projects/signal-store/src/app/weather/weather.****service****.****ts**
export class WeatherService implements IWeatherService {
  private readonly httpClient = inject(HttpClient)
  private readonly postalCodeService = inject(PostalCodeService)
  async getCurrentWeather(
    searchText: string, country?: string): Promise<ICurrentWeather> {
    const postalCode = await   
       this.postalCodeService.resolvePostalCode(searchText)
    if (postalCode && postalCode !== defaultPostalCode) {
      return this.getCurrentWeatherByCoords({
        latitude: postalCode.lat,
        longitude: postalCode.lng,
      })
    } else {
      const uriParams = new HttpParams().set(
        'q',
        country ? `${searchText},${country}` : searchText
      )
      return this.getCurrentWeatherHelper(uriParams)
    }
  }
  private getCurrentWeatherHelper(
    uriParams: HttpParams): Promise<ICurrentWeather> {
    uriParams = uriParams.set('appid', environment.appId)
    const httpCall$ = this.httpClient.get<ICurrentWeatherData>(
      `${environment.baseUrl}api.openweathermap.org/data/2.5/weather`,
      { params: uriParams }
    )
    return lastValueFrom(httpCall$).then(
           (data) => this.transformToICurrentWeather(data))
  } 

getCurrentWeather现在是一个异步函数,用于等待postalCodeService.resolvePostalCode的结果。处理resolvePostalCode响应的逻辑现在是一个简单的 if-else 语句,嵌套较少。getCurrentWeatherHelper已经被重构,类似于我们重构resolvePostalCode的方式。

最重要的是,不再有BehaviorSubjectsignal或任何保留在服务中用于更新currentWeather值的代码。

从 Observables 到 Signals 的组件

更新服务后,我们现在可以完成重构并更新组件以使用信号。

让我们从CitySearchComponent开始:

**projects/signal-store/src/app/city-search/city-search.****component****.****ts**
@Component({
  selector: 'app-city-search',
  ...
  **changeDetection****:** **ChangeDetectionStrategy****.****OnPush****,**
})
export class CitySearchComponent {
  private readonly store = inject(WeatherStore)
  search = new FormControl(
    '', 
    [Validators.required, Validators.minLength(2)]
  )
  readonly searchSignal = **toSignal**(
    this.search.valueChanges.pipe(
      filter(() => this.search.valid),
      debounceTime(1000)
    )
  )
  constructor() {
    effect(() => {
      this.doSearch(this.searchSignal())
    })
  }
  doSearch(searchValue?: string | null) {
    if (typeof searchValue !== 'string') return
    const userInput = searchValue.split(',').map((s) => s.trim())
    const searchText = userInput[0]
    const country = userInput.length > 1 ? userInput[1] : undefined
    this.store.updateWeather(searchText, country)
  }
} 

我们首先将changeDetection策略设置为OnPush。接下来,我们将search.valueChanges包装在一个toSignal函数中,以将 Observable 转换为信号。这是应用程序中剩下的唯一管道。主要原因是因为debounceTime操作符。更多信息请见提示框。然后我们使用effect函数来响应推送到searchSignal的变化,这会触发doSearch,进而调用store.updateWeather。如我们之前所述,store.updateWeather最终会更新store.current信号。值得注意的是,我们不再从这个组件中引用WeatherService,并且不需要对模板进行任何更改。

过滤和去抖动是 LocalCast Weather 中剩下的唯一 RxJS 操作符。目前还没有信号的操作符。你可以查看 Stack Overflow 上的这个答案,了解去抖动信号函数可能的工作方式stackoverflow.com/a/76597576/178620。然而,正如作者 An Nguyen 所指出的,这是一段复杂的代码,目前最好使用经过良好测试的库。

接下来,让我们看看CurrentWeatherComponent

**projects/signal-store/src/app/current-weather/**
**current-weather.****component****.****ts**
@Component({
  selector: 'app-current-weather',
  ...
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CurrentWeatherComponent {
  readonly store = inject(WeatherStore)  
  ...
} 

最显著的变化发生在CurrentWeatherComponent内部。我们设置了changeDetection策略,但现在已经只需要注入WeatherStore。就是这样:

**projects/signal-store/src/app/current-weather/**
**current-weather.component.html**
<**div** **fxLayout**=**"row"**>
  <div fxFlex="66%" class="mat-headline-6 no-margin" data-testid="city">
    {{ store.current().city }},
    {{ store.current().country }}
  </div>
  ... 

在模板中,我们可以移除 null 保护,因为信号总是初始化的。然后我们只需绑定到store.current()信号。当然,这需要对模板进行相当繁琐的重构。我们必须更新所有对current的引用,使用store.current()。这可以通过引入一个名为current的局部变量并使用effect来监听store.current()的更新来避免。然而,使用这种配置,你将无法获得signalOnPush提供的细粒度变更检测的好处。

我预计当基于信号的组件到来时,我们将能够编写类似于异步管道工作的代码:

@if (store.current() as current) 

这将极大地帮助避免令人烦恼的模板重写。

组件更新完成后,应用重构就完成了。在应用代码周围还有一些细微的变化。你会注意到导入和提供者大大减少。

在我看来,仅使用信号的代码更容易理解和维护;远优于 RxJS 的替代方案。我希望你喜欢这个对 Angular 未来的预览。

摘要

在本章中,我们使用路由优先架构以及我们的食谱,完成了对所有主要 Angular 应用设计考虑的回顾,从而轻松实现业务线应用。我们回顾了如何编辑现有用户,利用 resolve 守卫来加载数据,以及在不同的上下文中激活和重用组件。

我们使用辅助路由实现了主/详细视图,并展示了如何构建具有分页的数据表。然后我们学习了如何使用 local-weather-app 实现 NgRx/Store 和 NgRx/SignalStore。我们涵盖了 NgRx 生态系统中的可用选项,包括 NgRx/Data、NgRx/ComponentStore、Akita 和 Elf,以及这些选项之间的差异,以便你可以在项目中做出明智的决定。

我们还实现了一个预加载动画,以便在慢速连接时你的应用看起来响应灵敏。我们还实现了应用内的全局旋转器,以处理数据弹出相关的用户体验问题。最后,我们通过参观使用 SignalStore 和开发者预览功能的 local-weather-app 的全面重构,一瞥 Angular 的基于信号的未来。

采用路由优先的设计、架构和实现方法,我们以对目标成果的高层次理解来处理应用的设计。通过展示路由出口的使用和在同一组件中重用不同上下文,我们见证了路由编排的力量。通过早期识别代码重用机会,我们优化了实现策略,提前实现可重用组件,避免了过度工程化解决方案的风险。

在下一章中,我们将学习使用 Docker 进行容器化以及将你的应用到云端部署。Docker 允许强大的工作流程,可以极大地提高开发体验,同时允许你将服务器配置作为代码实现,为开发者最喜欢的借口敲响了最后的丧钟:“但是在我的机器上它运行正常!”

练习

  1. lemon-mart 中更新 UserTableComponent 和相关服务,以利用 Elf 实体和分页功能,实现请求的优化处理。

  2. 按照以下指南操作:ngneat.github.io/elf/docs/features/pagination

  3. 使用 NgRx/SignalStore 重写你的 Angular 应用,使其几乎成为可观察的,并且没有 RxJS 操作符,无需任何订阅调用或异步管道。

  4. 如果你认为这只是一个在书末添加的滑稽练习,请在我的 GitHub 个人资料上给我留言,链接为github.com/duluca

进一步阅读

问题

尽可能地回答以下问题,以确保你已理解本章的关键概念,而无需查阅任何资料。你知道你是否答对了所有问题吗?访问 angularforenterprise.com/self-assessment 获取更多信息:

  1. 解析守卫是什么?

  2. 路由编排的好处是什么?

  3. 辅助路由是什么?

  4. NgRx 与使用 RxJS/Subject 有何不同?

  5. NgRx 数据的价值是什么?

  6. UserTableComponent 中,为什么我们使用 readonly isLoadingResults$: BehaviorSubject<Boolean> 而不是简单的 Boolean 来驱动加载指示器?

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/AngularEnterpise3e