原文地址:Everything you need to know about change detection in Angularnode
若是你像我同样,想对Angular
的变动检测机制有一个深刻的理解,因为在网上并无多少有用的信息,你只能去看源码。大多数文章都会提到每个组件都会有一个属于本身的变动检测器(change detector
),它负责检查和这个组件,可是他们几乎都仅限于在说怎么使用immutable
数据和变动检测策略,这篇文章将会让你明白为何使用immutable
能够工做,而且脏检查机制是如何影响检查的过程的。还有,这篇文章将会引起你对性能优化方面的一些场景的思考。git
这篇文章包含2部分,第一部至关的有技术含量,它包含了一些指向源码的连接,它详细的介绍了脏检查机制在Angular
的底层是怎么运行的,全部内容是基·Angular的
最新 版本-4.0.1
(注:做者写这篇文章的时候,Angular
的最新版本是4.0.1
), 脏检查机制的实如今这个版本的实现和以前的2.4.1
版本是不同的,若是你对以前版本的实现感兴趣的话,你能够在这个stackoverflow的答案上学习到一些东西。github
第二部分介绍了变动检测在应用程序中该怎么使用,这部份内容既适用于以前的2.4.1
版本,也使用于最新的4.0.1
版本,由于这部分的API并无改变。性能优化
在Angular的教程中提到过,一个Angular应用程序就是一个组件树,然而,Angular在底层用了一个低级的抽象,叫作 视图(view)。一个视图和一个组件之间有直接的关联:一个视图对应着一个组件,反之亦然。一个视图经过一个叫component
的属性,保持着对与其所关联的那个组件类的实例的引用。全部的操做(好比属性检查,DOM更新等),都会表如今视图上面,所以从技术上来说,更正确的说法是,Angular
是一个视图树,一个组件能够被看作是一个视图的更高级的概念。下面是一些源码中的关于视图的介绍.bash
一个视图是一个应用程序UI的基本组成单位,它是可以被一块儿建立和销毁的最小的一个元素集合。
在一个视图中,元素的属性能够改变,可是它的结构(数量和顺序)不会被改变,只有经过一个ViewContainerRef
来插入、移动或是删除内嵌的视图这些操做才能够改变元素的结构。每个视图能够包含多个视图容器。markdown
在本文中,我将交替使用组件视图和组件的概念。dom
在这里有一点须要注意的是,网上的全部文章和StackOverflow上的一些回答将变动检测视为变动检测器对象或者`ChangeDetectorRef`,指的就是我在这里所说的视图(view)。实际上,没有一个单独的对象来进行变动检测,而且视图才是变动检测所运行的地方。
复制代码
每个视图通nodes属性对它的子视图有一个引用,所以,它能够在它的子视图中执行一些操做。异步
每个视图都有一个状态,它扮演着很是重要的角色,由于根据这个状态的值,Angular来决定是要对这个视图以及它的子视图进行变动检测仍是忽略掉。有许多可能的状态,可是下面的这几个是与本文相关的几个。ide
若是ChecksEnabled
是false或者视图是Errored
或者Destroyed
的状态,变动检测将会跳过这个视图以及它的子视图。默认的,全部的视图都被初始化为ChecksEnabled
的状态,除非你设置了ChangeDetectionStrategy.OnPush
。稍后将会详细介绍。视图的状态也能够合并,例如,一个视图既能够有FirstCheck
的状态,也能够由ChecksEnabled
的状态。函数
Angular
有许多高级的概念来操做视图,我在这里写了一些,其中一个就是viewRef,它封装了基本的组件视图,还有一个指定的方法detectChanges,当一个异步事件发生的时候,Angular
将会在它的顶级viewRef
触发变动检测,它会在对它本身进行变动检测后对它的子视图进行变动检测。
你能够经过ChangeDetectorRef
标记将这个viewRef
注入到一个组件的constructor
中:
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
queryList
。
在子组件实例中调用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
是true
,那么执行变动检测,下面是相关的代码:
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
都为true
,这就意味着当Angular执行变动检测时,组件树上的每个组件杜辉被检查。
假设咱们想禁用掉AComponent
及它的子组件的变动检测,咱们只须要很简单的把它的ViewState.ChecksEnabled
设置为false
就能够的。直接改变状态是一个低级的操做,所以Angular为咱们提供了一些在视图上可用的公共方法。每个组件均可以经过ChangeDetectorRef
来得到与其关联的视图的引用,Angular文档中为这个类定义了以下的公共接口:
class ChangeDetectorRef {
markForCheck() : void
detach() : void
reattach() : void
detectChanges() : void
checkNoChanges() : void
}
复制代码
让咱们看看咱们看以从中收获点什么吧。
第一个咱们能够操做视图的方法是deatch
,它仅仅是可以禁用掉对当前视图的检查:
detach(): void { this._view.state &= ~ViewState.ChecksEnabled; }
复制代码
让咱们看看怎么在代码中使用它:
export class AComponent { constructor(public cd: ChangeDetectorRef) { this.cd.detach(); } 复制代码
它确保了在接下来的变动检测中,以AComponent
为开始的左侧部分将会被忽略掉(橘黄色的组件将不会被检查):
在这里有两个地方须要注意--第一个就是就是咱们改变了AComponent
的检测状态,全部它的子组件也不会被检查。第二个就是因为左侧的组件们北邮执行变动检测,全部他们呢的模板视图也不会被更新,下面是一个小例子来证实这一点:
@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()
的时候,一切都会如期执行。
像本文中第一部分中所说的那样,若是绑定的输入属性aProp
在AppComponent
中改变了,AComponent
的OnChanges
生命周期钩子仍旧会触发。这就意味着一旦咱们输入属性改变了,咱们就能够激活当前视图的变动检测器去执行变动检测,而后在下个事件循环中再把它从deatch
(变动检测树中分离)掉,下面的代码片断证实了这一点:
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也会更新。
变动检测器上最后一个有用的方法是在运行当前的变动检测时,确保没有变化发生。基本上,它执行了本文第一部分那个步骤中的1,7,8的操做,而且当它发现一个绑定值变化了或是决定DOM应该要被更行的时候,将会抛出一个异常。