优化 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 开发者工具的性能标签页,以验证速度是否减慢是否源于 fibonacci 函数。

  1. 按 `Control+Shift+J`(在 Mac 上,则按 `Command+Option+J`)打开开发者工具。
  2. 点击效果标签页。

现在,点击 Record(录制)图标 (位于 Performance(性能)面板的左上角),开始在该应用的某个文本框中输入内容。几秒钟后,再次点击 Record(录制)图标 以停止记录。在 Chrome 开发者工具处理其收集的所有分析数据后,您会看到如下内容:

性能剖析

如果列表中有许多员工,此过程可能会阻塞浏览器的界面线程并导致丢帧,进而导致糟糕的用户体验。

跳过组件子树

当用户输入销售 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 开发者工具对应用进行性能分析,查看运行缓慢的原因。
  2. 引入了 OnPush 更改检测策略来剪除组件的子树。
  3. 将繁重的计算移至纯管道,以允许框架对计算值执行缓存。