首页
学习
活动
专区
工具
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(); // 手动触发更改检测
    }, 10 * 1000);
  }

在Angular应用程序的引导过程中,也可以完全停用Zone.js。这意味着自动更改检测已完全停用,我们需要手动触发用户界面更改,例如调用ChangeDetectorRef.detectChanges()。

首先,我们需要注释掉从polyfills.ts导入的Zone.js:

import 'zone.js/dist/zone';  // Included with Angular CLI.

接下来,我们需要在main.ts中传递noop区域:

platformBrowserDynamic().bootstrapModule(AppModule, {
      ngZone: 'noop';
}).catch(err => console.log(err));

有关停用Zone.js的更多细节,请参见文章《没有Zone.Js的Angular Elements》

Ivy

默认情况下,Angular 9将使用Angular的下一代编译和渲染管道Ivy。从Angular 8开始,你可以选择使用Ivy的预览版本,并帮助其开发和改进。

Angular团队将确保新的渲染引擎仍以正确的顺序处理所有框架的生命周期hooks,以便更改检测能正常工作。因此,你还是会在应用程序中看到相同的ExpressionChangedAfterCheckedError。

Max Koretskyi在这篇文章中写道:

如你所见,所有熟悉的操作都在。但是操作顺序似乎已经改变了。例如,现在Angular会先检查子组件,然后才检查嵌入式视图。由于目前没有编译器可以生成合适的输出来验证我的假设,因此我还不确定。

你可以在本文末尾的“推荐文章”部分中找到另外两篇与Ivy相关的有趣文章。

总结

Angular更改检测是一种强大的框架机制,可确保我们的UI以可预测和高效的方式表示我们的数据。可以肯定地说,更改检测适用于大多数应用程序,尤其是包含的组件少于50个的应用。

作为开发人员,当你需要深入研究这一主题时,往往出于以下两个原因:

  • 你收到一个ExpressionChangedAfterCheckedError,并需要解决它。
  • 你需要提高应用程序性能。

希望本文能帮助你更好地了解Angular的更改检测。请随意使用我的演示项目来尝试不同的更改检测策略。

https://github.com/Mokkapps/angular-change-detection-demo

原文链接https://www.mokkapps.de/blog/the-last-guide-for-angular-change-detection-you-will-ever-need/

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/5LErr3XJBfMjKXIK7MGB
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券