- 原文地址:Everything you need to know about change detection in Angular
- 原文做者:Max, Wizard of the Web
- 译文出自:掘金翻译计划
- 本文永久连接:github.com/xitu/gold-m…
- 译者:tian-li
- 校对者:nanjingboy, Mcskiller
若是你想跟我同样对 Angular 的变化检测机制有全面的了解,你就不得不去查看源码,由于网上几乎没有这方面的文章。大部分文章只提到每一个组件都有本身的变化检测器,且重点在使用不可变变量(immutable)和变化检测策略(change detection strategy)上,却没有进行更深刻的探讨。这篇文章会带你一块儿了解为何不可变变量能够触发变化检测及变化监测策略如何 影响检测。另外,你能够将本文中学到的知识运用到各类须要提高性能的场景中。前端
本文包括两部分。第一部分比较偏技术,会有不少源码的连接。主要讲解变化检测机制是如何运做的。本文的内容是基于(当时的)最新版本 —— Angular 4.0.1。该版本中的变化检测机制和 2.4.1 的有一点不一样。若是你有兴趣,能够参考 Stack Overflow 上的这个回答。node
第二部分展现了如何应用变化检测。因为 2.4.1 和 4.0.1 的 API 没有发生变化,因此这一部分对于两个版本都适用。android
Angular 的教程上一直在说,一个 Angular 应用是一颗组件树。然而,在 Angular 内部使用的是一种叫作视图(view)的低阶抽象。视图和组件之间是有直接联系的 —— 每一个视图都有与之关联的组件,反之亦然。视图经过 component
属性将其与对应的组件类关联起来。全部的操做都在视图中执行,好比属性检查和更新 DOM。因此,从技术上来讲,更正确的说法是:一个 Angular 应用是一颗视图树。组件能够描述为视图的更高阶的概念。关于视图,源码中有这样一段描述:ios
视图是构成应用 UI 的基本元素。它是一组一块儿被创造和销毁的最小合集。git
视图的属性能够更改,而视图中元素的结构(数量和顺序)不能更改。想要改变元素的结构,只能经过用
ViewContainerRef
来插入、移动或者移除嵌入的视图。每一个视图能够包含多个视图容器(View Container)。github
在这篇文章中,我会交替使用组件视图和组件的概念。后端
值得一提的是,网上有关变化检测文章和 StackOverflow 中的回答中,都把本文中的视图称为变化检测器对象(Change Detector Object)或者 ChangeDetectorRef。实际上,变化检测并无单独的对象,它实际上是在视图上运行的。bash
每一个视图都经过 nodes
属性将其与子视图相关联,这样就能对子视图进行操做。app
每一个视图都有一个 state
属性。这是一个很是重要的属性,由于 Angular 会根绝这个属性的值来肯定是否要对此视图和全部的子视图执行变化检测。state
属性有不少可能的值,与本文相关的有如下几种:dom
若是 CheckesEnabled
是 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
只会在第一次检测时调用)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
的文档中说,只有在它的绑定发生变化时,才会执行检测。因此要设置 CheckesEnabled
位来启用检测。下面这段代码就是这个做用(第 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);
}
复制代码
当组件第一次被检测时,span
就会被渲染成 See if I change: false
。两秒以后,changed
属性变成了 true
,span
中的文字并不会更新。然而,若是去掉 this.cd.detach()
,就会按照预想的样子更新了。
如第一部分所说,若是 AComponent
的输入绑定 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
钩子只会在禁用检测的子树的最顶端组件触发,并不会对整个子树的全部组件都触发。
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
方法。这个方法会对当前组件视图执行变化检测,无论组件的状态是什么。也就是说,视图仍会禁用检测,而且在接下来常规的变化检测中,不会检测此组件。好比:
export class AComponent {
@Input() inputAProp;
constructor(public cd: ChangeDetectorRef) {
this.cd.detach();
}
ngOnChanges(values) {
this.cd.detectChanges();
}
复制代码
尽管变化检测器引用仍保持分离,但 DOM 元素仍会随着输入绑定的变化而变化。
这是变化检测器的最后一个方法,其主要做用是保证当前执行的变化检测中,不会有变化发生。简单来讲,它执行本文第一部分提到的列表中的第 一、七、8 步。若是发现绑定发生了变化或者 DOM 须要更新,就抛出异常。
对于本文若是你有任何问题,请到 Stack Overflow 提问,而后在本文评论区贴上连接。这样整个社区都能受益。谢谢。
若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。