如果你像我一样想要全面了解Angular中的的变更检测机制,则必须探索资源,因为网络上没有太多可用的信息。大多数文章提到每个组件都有其自己的变更检测器,该检查器负责检查该组件,但是它们并没有深入下去,并且大多数主要聚焦于引用不可变性和变更检测策略。本文为您提供所需的信息来了解为什么用例immutables起作用和改变检测策略会如何影响检查。另外,从本文中学到的内容将使您能够自己提出各种方案来进行性能优化。
本文相当深入,有关变更检测的简介,请查看Angular变更检测的简要介绍。
本文由两部分组成。第一部分是偏技术性的,包含许多到源代码的链接。它详细说明了变更检测机制是如何在底层工作的。
第二部分显示了如何在应用程序中使用变更检测。
1. View是一个核心的概念
在整个Angular教程中都提到Angular应用程序是一棵组件树。但是,angular在底层使用了称为view的低级抽象。一个视图与一个组件之间存在直接关系——一个视图与一个组件相关联,反之亦然。视图在属性component
中包含了对关联的组件类实例的引用。所有操作(例如属性检查和DOM更新)都在视图上执行,因此从技术角度来讲,将angular表示为视图树是更合适的说法,而组件可以描述为视图的高级概念。这是你在源代码中可以阅读到的内容:
视图是应用程序用户界面的基本构建块。这是一起创建和销毁的最小元素组。
视图中元素的属性可以更改,但是视图中元素的结构(数量和顺序)不能更改。只能通过通过ViewContainerRef插入,移动或删除嵌套的View来更改Elements的结构。每个视图可以包含许多View Container。
每个视图都通过nodes属性链接到其子视图,因此可以对子视图执行操作。
2. View state
每个视图都有一个状态,该状态起着非常重要的作用,因为Angular会根据其值决定是否对视图及其所有子视图运行更改检测还是跳过它。有许多可能的状态,但以下情况与本文相关:
- FirstCheck
- ChecksEnabled
- Errored
- Destroyed
如果视图的ChecksEnabled
为false
或视图处于Errored
或Destroyed
状态,改变探测将会跳过这个视图及其子视图的检测。默认情况下,除非模式被设置为ChangeDetectionStrategy.OnPush
,否则所有的视图均初始化为ChecksEnabled
。而且状态可以组合,例如视图可以同时设置FirstCheck
和ChecksEnabled
标志。
Angular有很多高级概念来操纵视图。我在这里写了一些关于他们的文章。这样的概念之一就是ViewRef。它封装了底层组件视图,并具有一个恰当命名的方法detectChanges。发生异步事件时,Angular 会在其最顶层的ViewRef上触发更改检测,该ViewRef本身运行更改检测后,将对其子视图运行更改检测。
你可以通过ChangeDetectorRef
令牌来将viewRef
注入你的组件构造器中:
export class AppComponent {
constructor(cd: ChangeDetectorRef) { ... }
这是这个类的定义:
export declare abstract class ChangeDetectorRef {
abstract checkNoChanges(): void;
abstract detach(): void;
abstract detectChanges(): void;
abstract markForCheck(): void;
abstract reattach(): void;
}
export abstract class ViewRef extends ChangeDetectorRef {
...
}
3. 变更检测操作
负责为视图运行更改检测的主要逻辑位于checkAndUpdateView函数中。它的大多数功能都在子组件视图上执行操作。从host组件开始为每个组件递归调用此函数。这意味着在递归树展开时,子组件在下一次调用时成为父组件。
当针对特定视图触发此函数时,它将按指定顺序执行以下操作:
- 设置
ViewState.firstCheck
为true
若视图是第一次检测,否则设为false
- 检查并更新子组件/指令实例上的输入属性。
- 更新子视图更改检测状态(更改检测策略实现的一部分)
- 对内嵌视图运行更改检测(重复列表中的步骤)
- 调用子组件的
OnChanges
生命周期钩子若的它的绑定改变。 - 调用子组件的
OnInit
和ngDoCheck
生命周期钩子(OnInit
仅在第一次检测的时候调用) - 更新子视图组件实例上的
ContentChildren
查询列表 - 调用子组件实例的
AfterContentInit
和AfterContentChecked
生命周期钩子(AfterContentInit
仅在第一检测时调用一次) - 更新当前视图的dom插值若当前视图的组件实例的属性发生改变。
- 对子视图运行变更检测 (重复此列表中的步骤)
- 更新当前视图组件实例的
ViewChildren
- 调用子组件实例上的
AfterViewInit
和AfterViewChecked
生命周期钩子(AfterViewInit
仅在第一次检测的时候调用一次) - 禁用对当前视图的检查(变更检测策略实现的一部分)
根据以上列出的操作,有以下几点是需要强调的:
第一,子组件的onChanges
生命周期钩子在其视图被检测之前触发,即使是在其视图的变更检测被跳过的情况下。这是重要的信息,我们将在本文的第二部分中看到如何利用这些知识。
第二,在检查视图时,将视图的DOM作为更改检测机制的一部分进行更新。这意味着,如果未选中组件,则即使模板中使用的组件属性发生更改,DOM也不会更新。模板在第一次检查之前呈现。我所说的DOM更新实际上是插值更新。因此,如果您有<span>some {{name}}</span>
,则DOM元素span
将在第一次检查之前呈现。在检查过程中,只会{{name}}
渲染一部分。
另一个值得注意的事情是,在变更检测期间可以更改子组件视图的状态。前面我提到过,默认情况下所有组件视图都初始化为ChecksEnabled
,但对于所有使用OnPush
模式的组件来说,其变更检测在第一次检测之后被禁用(列表中第9个操作):
if (view.def.flags & ViewFlags.OnPush) {
view.state &= ~ViewState.ChecksEnabled;
}
这意味着在以后的变更检测运行期间,将跳过此组件视图及其所有子视图的检查。在有关该OnPush策略的文档指出,仅当组件的绑定已更改时,才会检查该组件。为此,必须将ChecksEnabled位设置为启用状态。这就是以下代码的所做的(操作2):
if (compView.def.flags & ViewFlags.OnPush) {
compView.state |= ViewState.ChecksEnabled;
}
这个状态只有在父视图的绑定改变并且子组件视图使用ChangeDetectionStrategy.OnPush
初始化的时候才会更新。
最后,当前视图的变更检测负责启动子视图的变更检测(操作8)。在这里检查子组件视图的状态,如果是ChecksEnabled,则对此视图执行更改检测。以下是相关代码:
viewState = view.state;
...
case ViewAction.CheckAndUpdate:
if ((viewState & ViewState.ChecksEnabled) &&
(viewState & (ViewState.Errored | ViewState.Destroyed)) === 0) {
checkAndUpdateView(view);
}
}
现在您知道了视图状态控制着是否对此视图及其子级执行更改检测。因此,问题开始了—我们可以控制该状态吗?事实证明我们可以做到,这就是本文的第二部分。
一些生命周期挂钩在DOM更新之前(3,4,5)被调用,而有些在(9)之后被调用。因此,如果您具有以下组件层次结构:A -> B -> C,这是挂钩调用和绑定更新的顺序:
A: AfterContentInit
A: AfterContentChecked
A: Update bindings
B: AfterContentInit
B: AfterContentChecked
B: Update bindings
C: AfterContentInit
C: AfterContentChecked
C: Update bindings
C: AfterViewInit
C: AfterViewChecked
B: AfterViewInit
B: AfterViewChecked
A: AfterViewInit
A: AfterViewChecked
4. 探索影响
假设我们有以下组件树:
如上所述,每个组件都与一个组件视图相关联。每个视图都用初始化为ViewState.ChecksEnabled
,这意味着当运行变更检测时,将检查树中的每个组件。
假设我们要禁用AComponent
和及其子级的更改检测。这样做很容易——我们只需要设置ViewState.ChecksEnabled
为false
即可。更改状态是一个低级操作,因此Angular为我们提供了视图上可用的一系列公共方法。每个组件都可以通过ChangeDetectorRef
令牌获取其关联的视图。对于此类,Angular文档定义了以下公共接口:
class ChangeDetectorRef {
markForCheck() : void
detach() : void
reattach() : void
detectChanges() : void
checkNoChanges() : void
}
让我们看看我们可以如何解决这个问题,以使我们受益。
4.1 detach
允许我们操作状态的第一种方法是detach
,它简单地禁用对当前视图的检查:
detach(): void { this._view.state &= ~ViewState.ChecksEnabled; }
让我们看看如何在代码中使用它:
export class AComponent {
constructor(public cd: ChangeDetectorRef) {
this.cd.detach();
}
这会确保在接下来的变更检测运行时将会跳过左边的以AComponent
开头的组件。
这里有两件事要注意——首先是尽管我们只更改了AComponent
的检测状态,但是其所有的子组件也不会被检测。第二时由于不会对左分支组件执行更改检测,因此其模板中的DOM也将不会更新。这是一个小的例子来演示它:
@Component({
selector: 'a-comp',
template: `<span>See if I change: {{changed}}</span>`
})
export class AComponent {
constructor(public cd: ChangeDetectorRef) {
this.changed = 'false';
setTimeout(() => {
this.cd.detach();
this.changed = 'true';
}, 2000);
}
第一次组件检测时span被渲染为See if I change: false
,2秒钟后changed
被更新为true
,span不会改变。然而当我们移除了this.cd.detach()
这一行,那么就会正常运行了。
4.2 reattach
如本文第一部分所述,当AComponent
的输入绑定发生变化时,它的OnChanges
生命周期方法仍然会被触发。这意味着一旦我们被通知输入属性发生变化,我们就可以激活当前组件的改变探测器以便运行变更检测,然后在下一个tick
的时候再detach
它。这里是一个演示片段:
export class AComponent {
@Input() inputAProp;
constructor(public cd: ChangeDetectorRef) {
this.cd.detach();
}
ngOnChanges(values) {
this.cd.reattach();
setTimeout(() => {
this.cd.detach();
})
}
由于reattach
仅设置ViewState.ChecksEnabled
位:
reattach(): void { this._view.state |= ViewState.ChecksEnabled; }
这几乎等同于将ChangeDetectionStrategy
设置为OnPush
:在第一次更改检测运行后禁用检查,在父组件绑定属性更改时启用它,并在运行后禁用。
注意:OnChanges 挂钩仅针对禁用分支中最顶层的组件触发,而不针对禁用分支中的每个组件触发。
4.3 markForCheck
reattach
仅对当前的组件启用检查,所以如果它的父组件的探测检测器没有激活,这将是没有效果的。这意味着reattach
仅对禁用分支的最顶层组件有用。
我们需要一种方法来检查所有父组件,直到根组件为止。这就是markForCheck
方法。
let currView: ViewData | null = view;
while (currView) {
if (currView.def.flags & ViewFlags.OnPush) {
currView.state |= ViewState.ChecksEnabled;
}
currView = currView.viewContainerParent || currView.parent;
}
正如你所看到的,它只是向上迭代并启用直到根组件的每个父组件的检查。
什么时候有用?正如设置为OnPush
的组件仍然会触发ngOnChanges
和ngDoCheck
钩子,同样这些也只是针对禁用分支的最顶层组件,而不是禁用分支的最所有组件,但是我们可以使用此挂钩执行自定义逻辑,并将我们的组件标记为可以进行一次变更检测。由于Angular仅检查对象引用,因此我们可以对某些对象属性实施脏检查:
Component({
...,
changeDetection: ChangeDetectionStrategy.OnPush
})
MyComponent {
@Input() items;
prevLength;
constructor(cd: ChangeDetectorRef) {}
ngOnInit() {
this.prevLength = this.items.length;
}
ngDoCheck() {
if (this.items.length !== this.prevLength) {
this.cd.markForCheck();
this.prevLenght = this.items.length;
}
}
4.4 detectChanges
有一种方法可以对当前组件及其所有子组件运行一次更改检测。这就是detectChanges
方法。这个方法对当前的组件运行变更检测而忽视它的状态。这意味着当运行变更检测时这个组件的状态仍然是禁用的,并且接下的常规变更检测不会对它做检查。这里是一个例子:
export class AComponent {
@Input() inputAProp;
constructor(public cd: ChangeDetectorRef) {
this.cd.detach();
}
ngOnChanges(values) {
this.cd.detectChanges();
}
即使更改检测器引用保持分离,当输入属性更改时,也会更新DOM。
4.5 checkNoChanges
更改检测器上可用的最后一种方法可确保在当前运行的更改检测中不会进行任何更改。基本上,它执行第一篇文章的列表中1,7,8操作,如果发现绑定已更改或确定DOM应该更新,则抛出异常。