备案 控制台
学习
实践
活动
专区
工具
TVP
写文章

Angular更改检测终极指南

更改检测是Angular的核心机制,一些开发者认为它很难理解。而且,官网也没有提供有关它的官方指南。在这篇博文中,作者提供了和更改检测相关的所有必要信息,还构建了一个演示项目,来解释更改检测背后的具体机制。

什么是更改检测

Angular的两大宗旨是可预测和高效。框架需要组合状态和模板,以在UI上复制应用程序的状态:

如果状态发生任何更改,就必须更新视图。将HTML与我们的数据同步的机制被称为“更改检测”。每个前端框架都有对应的实现,例如React使用虚拟DOM,Angular使用更改检测等。我推荐大家阅读 《JavaScript框架中的更改及其检测》 ,这篇文章提供了关于这一主题的很不错的概述。

更改检测:数据更改后更新视图(DOM)的过程。

作为开发人员,大多数时候我们不需要关心更改检测,除非我们需要优化应用程序的性能。如果处理不当,更改检测会降低大型应用程序的性能。

更改检测的工作机制

一个更改检测周期可以分为两个部分:

  • 开发人员 更新应用程序模型;
  • Angular 通过重新渲染视图来同步视图中更新的模型。

我们来具体看一下这个过程:

  1. 开发人员更新数据模型,例如更新组件绑定;
  2. Angular检测到了更改;
  3. 更改检测从上到下检查组件树中的 每个 组件,以查看对应的模型是否已更改;
  4. 如果有新值,它将更新组件的视图(DOM)。

以下GIF以简化的形式演示了这一过程:

这张图显示了一个Angular组件树及其在应用程序引导过程中为每个组件创建的更改检测器(CD)。检测器会对比属性的当前值与先前值,如果值已更改,它会将isChanged设置为true。可以看一下 框架代码中的实现 ,实质上就是一个===对比,对NaN有特殊处理。

更改检测不执行深度对象比较,它只对比模板使用属性的先前值和当前值。

Zone.js

一般来说,一个区域(zone)可以一直跟踪并拦截任何异步任务。一个区域通常具有以下阶段:

  • 它在开始时是稳定的;
  • 任务在区域中运行时,它会变得不稳定;
  • 任务完成后,它会再次稳定下来。

Angular在启动时修补了几个浏览器的底层API,以便检测应用程序中的更改。这是使用zone.js完成的,其修补了EventEmitter、DOM事件侦听器、XMLHttpRequest和Node.js中的fs等API。

简而言之,如果发生以下事件之一,框架将触发更改检测:

  • 任何浏览器事件(单击、键入等);
  • setInterval()和setTimeout();
  • 通过XMLHttpRequest的HTTP请求。

Angular将自己的区域称为NgZone。仅存在一个NgZone,并且仅针对此区域中触发的异步操作触发更改检测。

性能

默认情况下,如果模板值已更改,Angular更改检测将 从上至下检查所有组件。

Angular对每个组件进行更改检测的速度非常快,因为它可以使用内联缓存在几毫秒内执行数千次检查,其中内联缓存可生成对VM优化的代码。

如果你想了解有关这个主题的更深入的说明,建议你观看Victor Savkin的演讲: 重塑更改检测

尽管Angular在后台进行了大量优化,但在大型应用程序上性能可能仍会下降。在下一章节中,你将学习如何使用不同的更改检测策略来主动改善Angular性能。

更改检测策略

Angular提供了两种策略来运行更改检测:

  • Default
  • OnPush

我们来具体研究一下这两种策略。

默认更改检测策略

默认情况下,Angular使用ChangeDetectionStrategy.Default更改检测策略。每当事件触发更改检测(例如用户事件、计时器、XHR、promise等)时,这个默认策略都会从上到下检查组件树中的每一个组件。这种不对组件依赖项做任何假设的保守检查方法被称为 脏检查 。它可能会对包含许多组件的大型应用程序的性能产生负面影响。

OnPush更改检测策略

我们将changeDetection属性添加到组件装饰器元数据,就能切换到ChangeDetectionStrategy.OnPush更改检测策略:

@Component({
    selector: 'hero-card',
    changeDetection: ChangeDetectionStrategy.OnPush,
    template: ...
export class HeroCard {
}

这种更改检测策略可以跳过对这个组件及其所有子组件的非必要检查。

下面这张GIF演示了使用OnPush更改检测策略跳过组件树的某些部分:

使用这一策略时,Angular知道组件仅在以下情况下才需要更新:

  • 输入引用已更改;
  • 该组件或其子组件之一触发了一个事件处理程序;
  • 更改检测是手动触发的;
  • 通过异步管道链接到模板的一个可观察对象发出了一个新值。

我们来仔细看看这些事件。

输入引用更改

在默认的更改检测策略中,每当@Input()数据被更改或修改时,Angular都会运行更改检测器。使用OnPush策略时,只有当一个 新引用 被作为@Input()值传递时,才会触发更改检测器。

数值、字符串、布尔值、null和undefined之类的原始类型按值传递。对象和数组也按值传递,但是修改对象属性或数组条目不会创建新的引用,因此不会触发OnPush组件的更改检测。要触发更改检测器,你需要传递一个新的对象或数组引用。

你可以使用这个简单的 演示 来测试这一行为。

  1. 使用ChangeDetectionStrategy.Default修改HeroCardComponent的age;
  2. 带有ChangeDetectionStrategy.OnPush的HeroCardOnPushComponent不能反映更改的age(组件周围会显示红色边框);
  3. 在“Modify Heroes”面板中单击“Create new object reference”;
  4. 现在更改检测会检查带有ChangeDetectionStrategy.OnPush的HeroCardOnPushComponent。

为防止更改检测错误,一个小技巧是在构建应用程序时只使用不可变的对象和列表,然后在所有地方都使用OnPush更改检测。不可变对象只能通过创建新的对象引用来修改,因此我们可以保证:

  • 每次更改都会触发OnPush更改检测;
  • 我们不会忘记创建新的对象引用,否则会导致一些错误。

Immutable.js是一个不错的选择,这个库为对象(Map)和列表(List)提供了持久的不可变数据结构。通过npm安装这个 后,我们就有了类型定义,这样就可以在IDE中使用类型泛型、错误检测和自动完成功能。

触发事件处理程序

如果OnPush组件或其子组件之一触发了一个事件处理程序(如单击按钮),将触发更改检测(针对组件树中的所有组件)。

请注意,以下操作不会触发使用OnPush策略的更改检测:

  • setTimeout
  • setInterval
  • Promise.resolve().then()(当然Promise.reject().then()也是一样)
  • this.http.get(’…’).subscribe()(也就是任何RxJS可观察的订阅)

你可以使用这个简单的 演示 测试此行为。

  1. 在使用ChangeDetectionStrategy.OnPush的HeroCardOnPushComponent中单击“Change Age”按钮;
  2. 可以看到更改检测被触发,并检查所有组件。

手动触发更改检测

有三种手动触发更改检测的方法:

  • ChangeDetectorRef上的detectChanges(),它会在这个视图及其子级上运行更改检测,并遵循已有的更改检测策略。它可以与detach()结合使用,以实现本地更改检测检查。
  • ApplicationRef.tick(),它会依照组件的更改检测策略,触发整个应用程序的更改检测。
  • ChangeDetectorRef上的markForCheck() 不会 触发更改检测,但会将所有OnPush祖先标记为要检查一次,在当前或下一个更改检测周期中检查。即使已标记的组件正在使用OnPush策略,也将运行更改检测。

手动运行更改检测不是什么hack手段,但你只能在合理的情况下使用它。

下图以可视形式展示了不同的ChangeDetectorRef方法:

你可以在这个简单的 演示 中使用“DC”(detectChanges())和“MFC”(markForCheck())按钮来测试其中一些动作。

异步管道

内置的AsyncPipe订阅一个可观察对象,并返回它发出的最新值。

每次发出新值时,AsyncPipe内部都会调用markForCheck,请参见其 源代码

private _updateLatestValue(async: any, value: Object): void {
  if (async === this._obj) {
    this._latestValue = value;
    this._ref.markForCheck();
}

如图所示,AsyncPipe使用OnPush更改检测策略自动运行。因此建议尽量多用它,以便将来从默认更改检测策略切换到OnPush上。

你可以在异步 演示 中看到这种行为。

第一个组件通过AsyncPipe将一个可观察对象直接绑定到模板:

<mat-card-title>{{ (hero$ | async).name }}</mat-card-title>
  hero$: Observable<Hero>;
  ngOnInit(): void {
    this.hero$ = interval(1000).pipe(
        startWith(createHero()),
        map(() => createHero())
  }

而第二个组件订阅这个可观察对象并更新数据绑定值:

<mat-card-title>{{ hero.name }}</mat-card-title>
  hero: Hero = createHero();
  ngOnInit(): void {
    interval(1000)
      .pipe(map(() => createHero()))
        .subscribe(() => {
          this.hero = createHero();
          console.log(
            'HeroCardAsyncPipeComponent new hero without AsyncPipe: ',
            this.hero
  }

如你所见,没有AsyncPipe的实现不会触发更改检测,因此我们需要为可观察对象发出的每个新事件手动调用detectChanges()。

避免更改检测循环

Angular有一种检测更改检测循环的机制。在开发模式下,框架运行两次更改检测,以检查自首次运行以来该值是否已更改。在生产模式下,更改检测仅运行一次以获得更好的性能。

我在ExpressionChangedAfterCheckedError演示中强加了这个 错误 ,打开浏览器控制台就能看到:

在这个演示中,我通过更新ngAfterViewInit生命周期hook中的hero属性来强制执行错误:

  ngAfterViewInit(): void {
    this.hero.name = 'Another name which triggers ExpressionChangedAfterItHasBeenCheckedError';
  }

要搞清楚为什么会导致错误,我们需要查看更改检测运行期间的各个步骤:

如你所见,在渲染了当前视图的DOM更新之后,将调用AfterViewInit生命周期hook。如果我们更改这个hook中的值,它在第二次更改检测中将具有不同的值(如上所述,第二次检测在开发模式下是自动触发的),因此Angular将抛出ExpressionChangedAfterCheckedError。

我强烈建议你阅读Max Koretskyi撰写的 《Angular更改检测全面解析》 ,它详细探讨了著名的ExpressionChangedAfterCheckedError的底层实现和用例。

运行代码时不进行更改检测

可以在NgZone外部运行某些代码块,这样就不会触发更改检测。

  constructor(private ngZone: NgZone) {}
  runWithoutChangeDetection() {
    this.ngZone.runOutsideAngular(() => {
      // 后面的setTimeout不会触发更改检测
      setTimeout(() => doStuff(), 1000);
  }

这个简单的演示提供了一个按钮,可以触发一个Angular区域之外的动作:

你能看到这个动作已在控制台中记录了下来,但是HeroCard组件没有被检查,意味着它们的边框不会变成红色。

这个机制对由Protractor运行的端到端测试很有用,尤其是在测试中使用browser.waitForAngular的情况下。将每个命令发送到浏览器后,Protractor将等待到区域变得稳定为止。如果使用setInterval,区域将永远不会稳定,并且测试可能会超时。

RxJS可观察对象可能会遇到相同的问题,但你需要按照Zone.js对非标准API的支持 文档 所述,将修补版本添加到polyfill.ts中:

import 'zone.js/dist/zone';  // 用Angular CLI加入进来.
import 'zone.js/dist/zone-patch-rxjs'; // 导入RxJS补丁来确保RxJS运行在正确的区域中

如果没有这个修补程序,你可以在ngZone.runOutsideAngular内部运行可观察对象的代码,但它仍会作为在NgZone内部的任务来运行。

停用更改检测

在一些特殊的情况下有必要停用更改检测。例如,如果你使用WebSocket将大量数据从后端推送到前端,则相应的前端组件应该每10秒才更新一次。在这种情况下,我们可以调用detach()来停用更改检测,并使用detectChanges()手动触发它:

constructor(private ref: ChangeDetectorRef) {
    ref.detach(); // 停用更改检测
    setInterval(() => {
      this.ref.detectChanges(); // 手动触发更改检测