优化 Angular 的更改检测

实现更快的更改检测,以便提供更好的用户体验。

Angular 会定期运行其更改检测机制,以便数据模型的更改反映在应用的视图中。更改检测可以手动触发,也可以通过异步事件(例如用户互动或 XHR 完成)触发。

更改检测是一款强大的工具,但如果运行频率过高,可能会触发大量计算并阻塞主浏览器线程。

在本文中,您将了解如何通过跳过应用的某些部分并仅在必要时运行更改检测来控制和优化更改检测机制。

Angular 的更改检测机制

如需了解 Angular 的更改检测功能的运作方式,我们来看看一个示例应用!

您可以在此 GitHub 代码库中找到该应用的代码。

该应用会列出公司两个部门(销售和研发)的员工,并包含两个组件:

  • AppComponent,即应用的根组件,以及
  • 两个 EmployeeListComponent 实例,一个用于销售,另一个用于研发。

示例应用

您可以在 AppComponent 的模板中看到两个 EmployeeListComponent 实例:

<app-employee-list
  [data]="salesList"
  department="Sales"
  (add)="add(salesList, $event)"
  (remove)="remove(salesList, $event)"
></app-employee-list>

<app-employee-list
  [data]="rndList"
  department="R&D"
  (add)="add(rndList, $event)"
  (remove)="remove(rndList, $event)"
></app-employee-list>

每位员工都有一个姓名和一个数值。应用会将员工的数字值传递给业务计算,并在屏幕上直观显示结果。

现在,请查看 EmployeeListComponent

const fibonacci = (num: number): number => {
  if (num === 1 || num === 2) {
    return 1;
  }
  return fibonacci(num - 1) + fibonacci(num - 2);
};

@Component(...)
export class EmployeeListComponent {
  @Input() data: EmployeeData[];
  @Input() department: string;
  @Output() remove = new EventEmitter<EmployeeData>();
  @Output() add = new EventEmitter<string>();

  label: string;

  handleKey(event: any) {
    if (event.keyCode === 13) {
      this.add.emit(this.label);
      this.label = '';
    }
  }

  calculate(num: number) {
    return fibonacci(num);
  }
}

EmployeeListComponent 接受员工列表和部门名称作为输入。当用户尝试移除或添加员工时,该组件会触发相应的输出。该组件还定义了 calculate 方法,用于实现业务计算。

以下是 EmployeeListComponent 的模板:

<h1 title="Department">{{ department }}</h1>
<mat-form-field>
  <input placeholder="Enter name here" matInput type="text" [(ngModel)]="label" (keydown)="handleKey($event)">
</mat-form-field>
<mat-list>
  <mat-list-item *ngFor="let item of data">
    <h3 matLine title="Name">
      {{ item.label }}
    </h3>
    <md-chip title="Score" class="mat-chip mat-primary mat-chip-selected" color="primary" selected="true">
      {{ calculate(item.num) }}
    </md-chip>
  </mat-list-item>
</mat-list>

此代码会迭代列表中的所有员工,并为每位员工渲染一个列表项。它还包含一个 ngModel 指令,用于在输入和 EmployeeListComponent 中声明的 label 属性之间建立双向数据绑定。

使用两个 EmployeeListComponent 实例,应用会形成以下组件树:

组件树

AppComponent 是应用的根组件。其子组件是两个 EmployeeListComponent 实例。每个实例都有一个项列表(E1、E2 等),用于表示部门中的各个员工。

当用户开始在 EmployeeListComponent 的输入框中输入新员工的姓名时,Angular 会从 AppComponent 开始触发对整个组件树的更改检测。这意味着,当用户在文本输入框中输入内容时,Angular 会反复重新计算与每个员工关联的数值,以验证这些数值自上次检查以来是否未发生变化。

如需了解这种情况有多慢,请打开 StackBlitz 上未优化的项目版本,然后尝试输入员工姓名。

您可以通过设置示例项目并打开 Chrome DevTools 的性能标签页,验证速度变慢是否是由 fibonacci 函数导致的。

  1. 按 `Control+Shift+J`(在 Mac 上为 `Command+Option+J`)打开 DevTools。
  2. 点击效果标签页。

现在,点击 Performance 面板左上角的 Record (“记录”),然后开始在应用中的某个文本框中输入内容。几秒钟后,再次点击 Record 即可停止录制。Chrome DevTools 处理完收集的所有性能数据后,您会看到如下内容:

性能剖析

如果列表中有许多员工,此进程可能会阻塞浏览器的界面线程并导致帧率下降,从而导致用户体验不佳。

跳过组件子树

当用户在输入 sales EmployeeListComponent 的文本输入时,您知道 R&D 部门中的数据不会发生变化,因此没有理由对其组件运行更改检测。为确保研发实例不会触发更改检测,请将 EmployeeListComponentchangeDetectionStrategy 设置为 OnPush

import { ChangeDetectionStrategy, ... } from '@angular/core';

@Component({
  selector: 'app-employee-list',
  template: `...`,
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrls: ['employee-list.component.css']
})
export class EmployeeListComponent {...}

现在,当用户输入文本时,系统只会针对相应的部门触发更改检测:

组件子树中的更改检测

您可以点击此处查看应用于原始应用的此优化。

如需详细了解 OnPush 更改检测策略,请参阅 Angular 官方文档

如需查看此优化的效果,请在 StackBlitz 上的应用中输入新员工。

使用纯管道

即使 EmployeeListComponent 的更改检测策略现在已设为 OnPush,但当用户输入相应的文本输入时,Angular 仍会重新计算部门中所有员工的数值。

如需改进此行为,您可以利用纯流水线。纯流水线和非纯流水线都接受输入并返回可在模板中使用的结果。两者的区别在于,只有在收到与上次调用不同的输入时,纯管道才会重新计算其结果。

请注意,应用会调用 EmployeeListComponent 中定义的 calculate 方法,根据员工的数值计算要显示的值。如果您将计算移至纯管道,则 Angular 仅在管道参数发生变化时才会重新计算管道表达式。该框架将通过执行引用检查来确定管道的参数是否已更改。这意味着,除非更新员工的数值,否则 Angular 不会执行任何重新计算。

下面介绍了如何将商家计算移至名为 CalculatePipe 的管道:

import { Pipe, PipeTransform } from '@angular/core';

const fibonacci = (num: number): number => {
  if (num === 1 || num === 2) {
    return 1;
  }
  return fibonacci(num - 1) + fibonacci(num - 2);
};

@Pipe({
  name: 'calculate'
})
export class CalculatePipe implements PipeTransform {
  transform(val: number) {
    return fibonacci(val);
  }
}

管道的 transform 方法会调用 fibonacci 函数。请注意,管道是纯净的。除非您另行指定,否则 Angular 会将所有管道视为纯管道。

最后,更新 EmployeeListComponent 模板中的表达式:

<mat-chip-list>
  <md-chip>
    {{ item.num | calculate }}
  </md-chip>
</mat-chip-list>

大功告成!现在,当用户在与任何部门关联的文本输入框中输入内容时,应用不会重新计算各个员工的数值。

在下面的应用中,您可以看到打字速度有多快!

如需查看上次优化的效果,请在 StackBlitz 上试用此示例

对原始应用进行纯管道优化的代码可在此处获取

总结

如果 Angular 应用出现运行时速度缓慢的问题,请执行以下操作:

  1. 使用 Chrome DevTools 对应用进行性能分析,以了解速度缓慢的原因。
  2. 引入了 OnPush 更改检测策略,以修剪组件的子树。
  3. 将繁重计算移至纯管道,以便框架对计算值执行缓存。