原文传送门
翻译说明: 本文翻译采用意译并对原文进行适当排版以方便阅读。
术语采用加粗斜体表示, 术语第一次出现时其后括号内标注英文术语。
如果你像我一样,想对 Angular 中的 变更检测 (Change Detection) 机制有个全面的了解,那么你将不得不探究源码,因为网络上获取不到足够的信息。大多数文章所提到的是:每个组件 (Component) 都有自己的 变更检测器 (change detector) 来负责检查组件, 但是这些文章不会超过这个范围而且大多数会关注于 不可变对象 (immutables) 以及 变更检测策略 (change detection strategy) 的用例 (use cases)。本文会提供给你需要的信息让你明白:
- 为什么 使用 不可变对象 的用例 会有效?
- 变更检测策略 是如何影响检测(check)的?
不仅如此, 你从本文学到的东西也让你能够靠自己就能针对不同的场景提出性能优化方案。
本文包含两部分。第一部分技术性较强而且包含大量的源码链接, 这部分详细讲述了变更检测机制工作的底层细节, 基于最新的 Angular版本—— 4.0.1。这个版本的变更检测机制的实现方式不同于之前的 2.4.1。 如果你对此感兴趣,可以阅读下这篇在 stackoverflow 上的回答。第二部分讲述了如何在应用中使用变更检测。因为开放API并没有改变, 这部分内容同时适用于之前的 2.4.1 和 最新的 4.0.1版本。
视图 (View) 是核心概念
在很多教程中已经提到:Angular 应用是组件树。然而,在底层上 Angular 使用了叫做 view 的低级抽象。视图和组件之间有直接的联系 —— 每个视图都和组件相对应,反之亦然。每个视图在 component 属性中都保存了与之相关联的组件类实例的引用。所有的操作, 像 属性 (property) 检查和 DOM 更新,都是在视图上进行的, 因此, 从技术上来讲, angular 应用是视图树更准确些, 而组件可以描述为视图的更高级概念。 在这里你可以读到有关视图的描述:
A View is a fundamental building block of the application UI.
It is the smallest grouping of Elements which are created and destroyed together.
Properties of elements in a View can change, but the structure
(number and order) of elements in a View cannot.
Changing the structure of Elements can only be done by inserting,
moving or removing nested Views via a ViewContainerRef.
Each View can contain many View Containers.
(译者注: 上述引用翻译
视图是应用程序 UI 的基本构件。视图是最小的元素组,这些元素被一起创建和销毁。视图中元素的属性会改变, 但是视图中元素的结构(数量和排序)却不会改变。 要更改元素的结构,只能通过使用 ViewContainerRef 进行插入、移动或者删除嵌套视图来完成。每个视图可以包含很多视图容器。)
本文中, 我会交替使用 组件视图 和 组件 这2个概念。
这里需要注意的是: 网络上与变更检测相关的所有文章以及 StackOverflow 上的回答都把我在这里描述的视图看做变更检测器对象 或 ChangeDetectorRef。事实上并没有单独的对象来进行变更检测,变更检测就是在视图上运行。
每个视图都通过 nodes 属性链接到它的子视图, 因此可以对子视图执行操作。
视图状态
每个视图都有个 状态, 状态扮演了很重要的角色。因为基于 状态的值, angular 决定对视图以及该视图 所有的子视图 执行变更检测还是跳过。视图有很多 可能的状态, 但是下面这几个状态是和本文相关的:
- FirstCheck
- ChecksEnabled
- Errored
- Destroyed
如果
-
ChecksEnabled
是false
或者
- 视图处于
Errored
或者Destroyed
状态
那么, 对于视图以及该视图所有子视图的变更检测就会跳过。默认所有的视图会被初始化为 ChecksEnabled
, 除非使用了 ChangeDetectionStrategy.OnPush
。稍后将详细介绍。状态可以被组合, 举个例子, 视图可以同时设置 FirstCheck
和 ChecksEnabled
标志位。
Angular 有一堆高级概念来操作视图。我已经在 这里 写了一些。其中一个概念是 ViewRef , 它封装了 底层的组件视图 并且有个恰当命名的方法 detectChanges 。 当异步事件发生时, Angular 会在最顶层的 ViewRef 触发变更检测 , 对最顶层的 ViewRef 执行完变更检测后,对它的子视图执行变更检测。
这个 viewRef
就是你可以使用 ChangeDetectorRef
token 注入到组件构造器中的东西:
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
。 - 检查并更新子组件或子指令的输入属性。
- 更新 子视图变更检测状态(变更检测策略实现的一部分)
- 对嵌套视图 (embedded views) 执行变更检测 (重复列表中的步骤)
- 如果绑定改变, 调用 子组件的
OnChanges
生命周期钩子 -
调用 子组件的
OnInit
和ngDoCheck
(OnInit
只会在第一次检测期间调用) -
更新 子视图组件实例的
ContentChildren
查询表 -
调用 子组件实例的
AfterContentInit
和AfterContentChecked
生命周期钩子 (AfterContentInit
只会在第一次检测期间调用) - 如果当前视图 (current view) 组件实例的属性发生改变, 为 当前视图 更新DOM插值
- 对子视图 执行变更检测 (重复列表中的步骤)
-
更新 当前视图组件实例的
ViewChildren
查询表 -
调用 子组件实例的
AfterViewInit
和AfterViewChecked
生命周期钩子(AfterViewInit
只会在一次检测期间调用) - 禁止 对当前视图的检查 (变更检测策略实现的一部分)
基于上面的操作列表, 有几项需要强调。
第一项: 在子视图被检查前, onChanges
生命周期钩子会在子组件上触发, 即使对子视图的变更检测跳过了。 这是个很重要的信息, 我们将会在本文的第二部分中了解到如何利用这知识。
第二项: 当视图被检查的时候, 视图 DOM 的更新 是变更检测机制的一部分。这意味着如果组件没有被检查, 那么 DOM 就不会更新, 即使用在模板中的属性发生改变。模板在第一次检测前渲染。我所指的 DOM更新事实上是 插值更新。所以, 如果你有
<span>some {{name}}</span>
那么, DOM 元素 span
会在第一次检查前渲染。 在检查期间, 只有 {{name}}
部分会被渲染。
另一个有趣的现象是: 子组件视图的状态会在变更检测期间改变。 我之前提到过, 所有组件视图默认会用ChecksEnabled
初始化 , 但是对于那些使用 OnPush
策略的组件来说, ChecksEnabled
在第一次检测过后会被禁用。(列表中的操作 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
探索含义
假设我们有下面的组件树:
如同我们上面了解的, 每个组件都和一个组件视图相关联。 每个视图都会用 ViewState.ChecksEnabled
初始化, 也就意味着当 angular 执行变更检测时, 组件树中的所有组件都会被检查。
假如我们想禁止对 AComponent
和它的子组件进行变更检测。 这很容易做到 —— 我们仅仅需要将 ViewState.ChecksEnabled
设置为 false
。 改变状态是个很低级的操作。所以 angular 提供了一些在视图上可以使用的公共方法。 每个组件可以通过 ChangeDetectorRef
token 获取到相关联的视图。 对于这个类, Angular 文档定义了下面的开放(public)接口:
class ChangeDetectorRef {
markForCheck() : void
detach() : void
reattach() : void
detectChanges() : void
checkNoChanges() : void
}
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.detech()
一切如常。
reattach
正如本文第一部分提到的, 如果 AppComponent
的输入绑定 aProp
改变了, AComponent
的 OnChanges
生命周期钩子仍然会被触发。这意味着, 一旦我们知道输入属性发生了改变, 我们可以激活当前组件的变更检测器来执行变更检测, 然后在下一个节拍中 将它分离。 演示片段:
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 应该被更新的情况, 就会抛出异常。