Angular 更改检测终极指南

阅读数:1143 2019 年 12 月 5 日 18:26

Angular更改检测终极指南

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

什么是更改检测

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

Angular更改检测终极指南

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

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

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

更改检测的工作机制

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

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

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

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

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

Angular更改检测终极指南

这张图显示了一个 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 等)时,这个默认策略都会从上到下检查组件树中的每一个组件。这种不对组件依赖项做任何假设的保守检查方法被称为脏检查。它可能会对包含许多组件的大型应用程序的性能产生负面影响。

Angular更改检测终极指南

OnPush 更改检测策略

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

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

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

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

Angular更改检测终极指南

使用这一策略时,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。

Angular更改检测终极指南

为防止更改检测错误,一个小技巧是在构建应用程序时只使用不可变的对象和列表,然后在所有地方都使用 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. 可以看到更改检测被触发,并检查所有组件。

Angular更改检测终极指南

手动触发更改检测

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

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

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

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

Angular更改检测终极指南

你可以在这个简单的演示中使用“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 上。

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

Angular更改检测终极指南

第一个组件通过 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 演示中强加了这个错误,打开浏览器控制台就能看到:

Angular更改检测终极指南

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

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

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

Angular更改检测终极指南

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

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

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

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

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

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

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/

评论

发布
用户头像
涨知识 谢谢 😁
2019 年 12 月 06 日 13:58
回复
没有更多了