这篇有关Angular中变化检测的文章最初发布于Angular In Depth博客中 ,并经许可在此处重新发布。
如果您像我一样,并且希望全面了解Angular中的变更检测机制,则由于网络上没有太多可用信息,因此您基本上必须探究资源。
大多数文章提到每个组件都有自己的变更检测器,该变更检测器负责检查该组件,但它们并没有超出范围,而是主要关注不可变的用例和变更检测策略。
本文为您提供所需的信息理解为什么用例immutables工作和如何变化检测策略会影响检查。 另外,您将从本文中学到的知识将使您能够自己提出各种方案来进行性能优化。
本文的第一部分是非常技术性的,并且包含许多到源的链接。 它详细说明了更改检测机制在引擎盖下如何工作。 其内容基于最新的Angular版本(撰写本文时为4.0.1)。 在此版本中,更改检测机制在内部实现的方式与早期的2.4.1不同。 如果有兴趣,您可以在Stack Overflow答案中阅读一些有关它如何工作的信息。
本文的后半部分展示了如何在应用程序中使用更改检测,并且其内容适用于Angular的2.4.1和最新的4.0.1版本,因为公共API尚未更改。
查看为核心概念
Angular应用程序是组件树。 但是,在幕后,Angular使用了一种称为view的低级抽象。 视图和组件之间存在直接关系:一个视图与一个组件相关联,反之亦然。 视图在component
属性中包含对关联的组件类实例的引用 。 所有操作(例如属性检查和DOM更新)都在视图上执行。 因此,从技术上讲,Angular是视图树是正确的,而组件可以描述为视图的高级概念。 这是您可以在源代码中阅读的有关视图的内容 :
视图是应用程序用户界面的基本构建块。 这是一起创建和销毁的最小元素组。
视图中元素的属性可以更改,但是视图中元素的结构(数量和顺序)不能更改。 只能通过通过ViewContainerRef插入,移动或删除嵌套的View来更改Elements的结构。 每个视图可以包含许多视图容器。
在本文中,我将互换使用组件视图和组件的概念。
在这里需要特别注意的是,网络上的所有文章以及有关更改检测的Stack Overflow的答案均指的是我在此处描述为“更改检测器对象”或“ ChangeDetectorRef”的视图。 实际上,没有单独的对象用于变更检测,而View是运行变更检测的对象。
每个视图都有一个通过nodes属性链接到其子视图的链接,因此可以对子视图执行操作。
查看状态
每个视图都有一个状态 ,该状态起着非常重要的作用,因为Angular会根据其值决定是对视图及其所有子级运行更改检测,还是跳过它。 有许多可能的状态 ,但以下情况与本文相关:
- FirstCheck
- ChecksEnabled
- 错误的
- 被毁
变化检测跳过了视图及其子视图如果ChecksEnabled
是false
或观点是在Errored
或Destroyed
状态。 默认情况下,所有的意见都初始化ChecksEnabled
除非ChangeDetectionStrategy.OnPush
使用。 以后再说。 状态可以组合:例如,一个视图可以同时设置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 {
...
}
变更检测操作
负责为视图运行更改检测的主要逻辑位于checkAndUpdateView函数中。 它的大多数功能都在子组件视图上执行操作。 从主机组件开始,为每个组件递归调用此函数。 这意味着在递归树展开时,子组件在下一次调用时成为父组件。
当为特定视图触发此功能时,它将按指定顺序执行以下操作:
- 如果第一次检查视图,则将
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
位置1来启用检查。 这就是以下代码的作用(操作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
探索含义
假设我们有以下组件树:
如上所述,每个组件都与一个组件视图相关联。 每个视图都使用ViewState.ChecksEnabled
初始化,这意味着当Angular运行更改检测时,将检查树中的每个组件。
假设我们要禁用AComponent
及其子项的更改检测。 这样做很容易-我们只需要将ViewState.ChecksEnabled
设置为false
。 更改状态是一个低级操作,因此Angular为我们提供了视图上可用的一系列公共方法。 每个组件都可以通过ChangeDetectorRef
令牌保留其关联视图。 对于此类,Angular文档定义了以下公共接口:
class ChangeDetectorRef {
markForCheck() : void
detach() : void
reattach() : void
detectChanges() : void
checkNoChanges() : void
}
让我们来看看如何纠缠它以使我们受益。
分离
允许我们操作状态的第一种方法是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);
}
第一次检查组件时,跨度将显示为文本, See if I change: false
。 在两秒钟之内,将changed
属性更新为true
,跨度中的文本将不会更改。 但是,如果我们删除此行this.cd.detach()
,那么一切都会按预期进行。
重新连接
正如在本文的第一部分所示, OnChanges
生命周期钩仍然会触发AComponent
如果输入结合aProp
上改变AppComponent
。 这意味着,一旦通知我们输入属性已更改,就可以激活当前组件的更改检测器以运行更改检测,并在下一个刻度线将其分离。 以下是片段说明:
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
挂钩仅针对禁用分支中最顶层的组件触发,而不针对禁用分支中的每个组件触发。
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;
}
从实现中可以看到,它只是向上迭代并启用对直到根目录的每个父组件的检查。
什么时候有用? 与ngOnChanges
,即使组件使用OnPush
策略,也会触发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;
}
}
detectChanges
有一种方法可以对当前组件及其所有子组件运行一次更改检测。 这是使用detectChanges
方法完成的。 此方法不管当前组件视图的状态如何都运行更改检测,这意味着对于当前视图,检查可能保持禁用状态,并且在随后的常规更改检测运行期间不会检查组件。 这是一个例子:
export class AComponent {
@Input() inputAProp;
constructor(public cd: ChangeDetectorRef) {
this.cd.detach();
}
ngOnChanges(values) {
this.cd.detectChanges();
}
即使更改检测器引用保持分离,当输入属性更改时,也会更新DOM。
checkNoChanges
变更检测器上可用的最后一种方法可确保在当前运行的变更检测中不会进行任何更改。 基本上,它从上面的列表中执行操作1,7和8,如果发现绑定更改或确定DOM应该更新,则抛出异常。