发布于 2026-01-06 0 阅读
0

你需要的最后一份 Angular 变更检测指南

你需要的最后一份 Angular 变更检测指南

Angular 的变更检测是框架的核心机制之一,但(至少就我的经验而言)它非常难理解。遗憾的是,官方网站上并没有关于这方面的官方指南。

在这篇博文中,我将提供您需要了解的关于变更检测的所有必要信息。我将通过我为这篇博文构建的一个演示项目来解释其机制。

什么是变更检测

Angular 的两大主要目标是可预测性和高性能。该框架需要通过结合状态和模板,在 UI 上复制应用程序的状态:

数据模板 DOM

如果状态发生任何变化,也需要更新视图。这种将 HTML 与数据同步的机制称为“变更检测”。每个前端框架都有自己的实现方式,例如 React 使用虚拟 DOM,Angular 使用变更检测等等。我推荐阅读《JavaScript 框架中的变更及其检测》这篇文章,它对这个主题进行了很好的概述。

变更检测:当数据发生变化时,更新视图(DOM)的过程。

作为开发者,大多数情况下我们无需关注变更检测,直到需要优化应用程序性能时才会想起它。如果处理不当,变更检测可能会降低大型应用程序的性能。

变更检测的工作原理

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

  • 开发者更新了应用程序模型
  • Angular通过重新渲染视图来同步更新后的模型。

让我们更详细地了解一下这个过程:

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

以下 GIF 动画以简化的方式演示了这一过程:

变化检测周期

图中展示了一个 Angular 组件树及其在应用程序启动过程中创建的每个组件的变更检测器 (CD)。该检测器会将属性的当前值与先前值进行比较。如果值发生了变化,则会将其设置isChanged为 true。请查看框架代码中的实现,它只是===对值进行了特殊处理,并进行了简单的比较NaN

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

Zone.js

一般来说,区域可以跟踪和拦截任何异步任务。

一个区域通常经历以下几个阶段:

  • 它开始稳定运行。
  • 如果任务在区域中运行,则会变得不稳定。
  • 如果任务完成,系统就会再次稳定下来。

Angular 在启动时会修补多个底层浏览器 API,以便检测应用程序中的变化。这是通过zone.js实现的,zone.js会修补诸如EventEmitterDOM 事件监听器、XMLHttpRequestNode.js fsAPIAPI 。

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

  • 任何浏览器事件(点击、按键抬起等)
  • setInterval()setTimeout()
  • 通过 HTTP 请求XMLHttpRequest

Angular 使用名为 `<zone>` 的区域NgZone。该区域只有一个NgZone,并且仅针对在此区域中触发的异步操作触发变更检测。

表现

默认情况下,Angular 变更检测会从上到下检查所有组件,以确定模板值是否已更改。

Angular 能够非常快速地对每个组件进行变更检测,因为它可以使用内联缓存在几毫秒内执行数千次检查,从而生成针对虚拟机优化的代码。

如果你想更深入地了解这个主题,我建议观看Victor Savkin关于“重新发明变化检测”的演讲

尽管 Angular 在后台做了很多优化,但对于大型应用程序来说,性能仍然可能会下降。在下一章中,你将学习如何通过使用不同的变更检测策略来主动提升 Angular 的性能。

变化检测策略

Angular 提供了两种运行变更检测的策略:

  • Default
  • OnPush

让我们逐一了解这些变更检测策略。

默认变更检测策略

Angular 默认使用ChangeDetectionStrategy.Default变更检测策略。这种默认策略会在每次事件触发变更检测时(例如用户事件、定时器、XHR、Promise 等),从上到下检查组件树中的每个组件。这种保守的检查方式,不依赖于组件的依赖关系,被称为“脏检查”。对于包含大量组件的大型应用程序来说,脏检查可能会对应用程序的性能产生负面影响。

变化检测周期

OnPush 变更检测策略

我们可以通过向组件装饰器元数据ChangeDetectionStrategy.OnPush添加属性来切换到变更检测策略:changeDetection

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

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

OnPush下一个 GIF 演示了如何使用变更检测策略跳过组件树的某些部分:

OnPush 变更检测周期

通过这种策略,Angular 可以知道只有在以下情况下才需要更新组件:

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

让我们仔细看看这类事件。

输入参考更改

在默认的变更检测策略中,Angular 会在@Input()数据发生任何更改或修改时运行变更检测器。而使用自定义策略,只有当传递新的引用OnPush作为值时才会触发变更检测器@Input()

基本类型(例如数字、字符串、布尔值、null 和 undefined)按值传递。对象和数组也按值传递,但修改对象属性或数组元素不会创建新的引用,因此不会触发组件的变更检测OnPush。要触发变更检测器,您需要传递一个新的对象或数组引用。

您可以使用以下简单示例测试此行为:

  1. HeroCardComponent修改年龄ChangeDetectionStrategy.Default
  2. 确认HeroCardOnPushComponent“with”字样ChangeDetectionStrategy.OnPush没有反映出年龄的变化(通过组件周围的红色边框来显示)。
  3. 在“修改英雄”面板中,单击“创建新对象引用”
  4. 验证变更检测是否已检查HeroCardOnPushComponent该项ChangeDetectionStrategy.OnPush

推送输入参考更改的变更检测

为了防止变更检测错误,在应用程序构建过程中,尽可能使用变更检测机制,仅使用不可变对象和列表,会非常有效OnPush。不可变对象只能通过创建新的对象引用来修改,因此我们可以保证:

  • OnPush每次变更都会触发变更检测
  • 我们不能忘记创建一个新的对象引用,这可能会导致错误。

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

事件处理程序已触发

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

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

  • setTimeout
  • setInterval
  • Promise.resolve().then()(当然,也适用于Promise.reject().then()
  • this.http.get('...').subscribe()(一般而言,任何 RxJS 可观察对象订阅)

您可以使用以下简单示例测试此行为:

  1. 点击“更改年龄”按钮,即可HeroCardOnPushComponent使用该按钮。ChangeDetectionStrategy.OnPush
  2. 验证变更检测是否已触发,并检查所有组件

变更检测事件触发器

手动触发变化检测

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

  • detectChanges()ChangeDetectorRef会根据变更检测策略,对该视图及其子视图运行变更检测。它可以与detach()本地变更检测检查结合使用。
  • ApplicationRef.tick()它遵循组件的变更检测策略,从而触发整个应用程序的变更检测。
  • markForCheck()此操作ChangeDetectorRef不会触发变更检测,但会将所有祖先组件标记OnPush待检查一次,检查时间可能是当前变更检测周期,也可能是下一个变更检测周期。即使组件本身使用了变更检测策略,也会对已标记的组件运行变更检测OnPush

手动运行变更检测并非权宜之计,但应仅在合理情况下使用。

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

您可以使用简单演示detectChanges()中的“DC”( )和“MFC”(markForCheck())按钮来测试其中一些操作

异步管道

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

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

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

如图所示,该功能AsyncPipe采用变更检测策略自动运行OnPush。因此,建议尽可能使用它,以便日后更轻松地从默认变更检测策略切换到该策略OnPush

您可以在异步演示中看到这种行为的实际效果

带有 OnPush 的 AsyncPipe

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

<mat-card-title>{{ (hero$ | async).name }}</mat-card-title>

  hero$: Observable<Hero>;

  ngOnInit(): void {
    this.hero$ = interval(1000).pipe(
        startWith(createHero()),
        map(() => createHero())
      );
  }
Enter fullscreen mode Exit fullscreen mode

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

<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
          );
        });
  }
Enter fullscreen mode Exit fullscreen mode

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

避免变更检测循环和 ExpressionChangedAfterCheckedError

Angular 包含一个用于检测变更检测循环的机制。在开发模式下,框架会运行两次变更检测,以检查值自第一次运行以来是否发生了变化。在生产模式下,变更检测只会运行一次,以获得更好的性能。

我在ExpressionChangedAfterCheckedError 示例中强制触发了错误,您可以通过打开浏览器控制台来查看:

ExpressionChangedAfterCheckedError

hero在这个演示中,我通过在生命周期钩子中更新属性来强制触发错误ngAfterViewInit

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

要了解为什么会出现这个错误,我们需要看一下变更检测运行期间的各个步骤:

生命周期钩子

正如我们所见,AfterViewInit生命周期钩子是在当前视图的 DOM 更新渲染完成后调用的。如果我们在这个钩子中更改值,那么在第二次变更检测运行(如上所述,在开发模式下会自动触发)中,该值将发生变化,因此 Angular 将抛出异常ExpressionChangedAfterCheckedError

我强烈推荐Max Koretskyi的文章《关于 Angular 中的变更检测,你需要知道的一切》,这篇文章更详细地探讨了著名的变更检测的底层实现和用例。ExpressionChangedAfterCheckedError

运行代码而不进行变更检测

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

  constructor(private ngZone: NgZone) {}

  runWithoutChangeDetection() {
    this.ngZone.runOutsideAngular(() => {
      // the following setTimeout will not trigger change detection
      setTimeout(() => doStuff(), 1000);
    });
  }
Enter fullscreen mode Exit fullscreen mode

这个简单的演示提供了一个按钮,用于触发 Angular 区域之外的操作:

runOutsideAngular 演示

你应该可以看到操作已记录在控制台中,但HeroCard组件未被选中,这意味着它们的边框不会变成红色。

这种机制对于使用Protractor运行的端到端测试非常有用,尤其是browser.waitForAngular在测试中使用`<head>` 标签时。每次向浏览器发送命令后,Protractor 都会等待直到区域稳定。如果您使用了setInterval`<head>` 标签,您的区域将永远不会稳定,测试很可能会超时。

RxJS observables 也可能出现同样的问题,因此您需要添加一个已修补的版本,如Zone.js 对非标准 API 的支持polyfill.ts中所述

import 'zone.js/dist/zone'; // Included with Angular CLI.
import 'zone.js/dist/zone-patch-rxjs'; // Import RxJS patch to make sure RxJS runs in the correct zone
Enter fullscreen mode Exit fullscreen mode

如果没有这个补丁,你可以在里面运行可观察代码ngZone.runOutsideAngular,但它仍然会作为任务在里面运行NgZone

停用变更检测

在某些特殊情况下,禁用变更检测是合理的。例如,如果您使用 WebSocket 将大量数据从后端推送到前端,并且相应的前端组件只需每 10 秒更新一次。在这种情况下,我们可以通过调用 `disableChanged` 来禁用变更检测,detach()并通过以下方式手动触发它detectChanges()

constructor(private ref: ChangeDetectorRef) {
    ref.detach(); // deactivate change detection
    setInterval(() => {
      this.ref.detectChanges(); // manually trigger change detection
    }, 10 * 1000);
  }
Enter fullscreen mode Exit fullscreen mode

在 Angular 应用启动过程中,也可以完全禁用 Zone.js。这意味着自动变更检测功能将被完全禁用,我们需要手动触发 UI 变更,例如通过调用 ` ChangeDetectorRef.detectChanges().`

首先,我们需要注释掉从以下位置导入 Zone.js 的代码polyfills.ts

import 'zone.js/dist/zone'; // Included with Angular CLI.
Enter fullscreen mode Exit fullscreen mode

接下来,我们需要传递空操作区域main.ts

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

有关停用 Zone.js 的更多详细信息,请参阅文章《没有 Zone.js 的 Angular Elements》

常春藤

Angular 9 将默认使用Ivy,即 Angular 的下一代编译和渲染管道。从 Angular 8 开始,您可以选择加入 Ivy 预览版的开发,并参与其持续的开发和优化。

ExpressionChangedAfterCheckedErrorAngular 团队将确保新的渲染引擎仍然能够按正确的顺序处理所有框架生命周期钩子,从而使变更检测功能与之前一样正常工作。因此,您在应用程序中仍会看到相同的效果。

马克斯·科列茨基在文章中写道

如您所见,所有熟悉的操作都还在。但操作顺序似乎有所改变。例如,Angular 现在似乎先检查子组件,然后再检查嵌入的视图。由于目前还没有编译器能够生成适合验证我假设的输出,所以我无法确定。

您可以在本博客文章末尾的“推荐文章”部分找到另外两篇与常春藤相关的有趣文章。

结论

Angular 的变更检测机制是一个强大的框架机制,它能确保我们的 UI 以可预测且高效的方式呈现数据。可以说,变更检测机制适用于大多数应用程序,尤其是在组件数量不超过 50 个的情况下。

作为开发人员,您通常需要深入了解此主题,原因有二:

  • 你收到一个问题ExpressionChangedAfterCheckedError,需要解决它。
  • 你需要提升应用程序的性能

希望这篇文章能帮助你更好地理解 Angular 的变更检测机制。你可以使用我的示例项目来体验不同的变更检测策略。

推荐文章

文章来源:https://dev.to/mokkaapps/the-last-guide-for-angular-change-detection-you-ll-ever-need-3pe4