[译] 关于Angular的变更检测(Change Detection)你需要知道这些

8,428 阅读13分钟

原文地址:Everything you need to know about change detection in Angular

如果你像我一样,想对Angular的变更检测机制有一个深入的理解,由于在网上并没有多少有用的信息,你只能去看源码。大多数文章都会提到每一个组件都会有一个属于自己的变更检测器(change detector),它负责检查和这个组件,但是他们几乎都仅限于在说怎么使用immutable 数据和变更检测策略,这篇文章将会让你明白为什么使用immutable可以工作,并且脏检查机制是如何影响检查的过程的。还有,这篇文章将会引发你对性能优化方面的一些场景的思考。

这篇文章包含2部分,第一部相当的有技术含量,它包含了一些指向源码的链接,它详细的介绍了脏检查机制在Angular的底层是怎么运行的,所有内容是基·Angular的最新 版本-4.0.1(注:作者写这篇文章的时候,Angular的最新版本是4.0.1), 脏检查机制的实现在这个版本的实现和之前的2.4.1版本是不一样的,如果你对之前版本的实现感兴趣的话,你可以在这个stackoverflow的答案上学习到一些东西。

第二部分介绍了变更检测在应用程序中该怎么使用,这部分内容既适用于之前的2.4.1版本,也使用于最新的4.0.1版本,因为这部分的API并没有改变。

将视图(view)作为一个核心概念

在Angular的教程中提到过,一个Angular应用程序就是一个组件树,然而,Angular在底层用了一个低级的抽象,叫做 视图(view)。一个视图和一个组件之间有直接的关联:一个视图对应着一个组件,反之亦然。一个视图通过一个叫component的属性,保持着对与其所关联的那个组件类的实例的引用。所有的操作(比如属性检查,DOM更新等),都会表现在视图上面,因此从技术上来讲,更正确的说法是,Angular是一个视图树,一个组件可以被看做是一个视图的更高级的概念。下面是一些源码中的关于视图的介绍.

一个视图是一个应用程序UI的基本组成单位,它是能够被一起创建和销毁的最小的一个元素集合。
在一个视图中,元素的属性可以改变,但是它的结构(数量和顺序)不会被改变,只有通过一个ViewContainerRef来插入、移动或是删除内嵌的视图这些操作才可以改变元素的结构。每一个视图可以包含多个视图容器。

在本文中,我将交替使用组件视图和组件的概念。

 在这里有一点需要注意的是,网上的所有文章和StackOverflow上的一些回答将变更检测视为变更检测器对象或者`ChangeDetectorRef`,指的就是我在这里所说的视图(view)。实际上,没有一个单独的对象来进行变更检测,并且视图才是变更检测所运行的地方。

每一个视图通nodes属性对它的子视图有一个引用,因此,它可以在它的子视图中执行一些操作。

视图状态(View state)

每一个视图都有一个状态,它扮演着非常重要的角色,因为根据这个状态的值,Angular来决定是要对这个视图以及它的子视图进行变更检测还是忽略掉。有许多可能的状态,但是下面的这几个是与本文相关的几个。

  1. FirstCheck
  2. ChecksEnabled
  3. Errored
  4. Destroyed

如果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函数中的视图进行变更检测,它的大部分功能在子组件上执行,这个函数从主组件开始被每一个组件递归的调用,这就意味着随着递归树的展开,子组件在下一个调用中成为父组件。

当为特定视图触发此函数时,它按照指定的顺序执行以下操作:

  1. 如果一个视图是第一次被检查,则将ViewState.firstCheck设置为true,如果是已经被检查过了,则设置为false.

  2. 检查并更新在子组件/指令实例上的输入属性。

  3. 更新子视图变更检测状态(一部分是变更检测策略的实现)。

  4. 对内嵌的视图执行变更检测(重复列出的这些步骤)。

  5. 如果绑定的值改变的话,在子组件中调用 OnChanges生命周期钩子。

  6. 调用子组件的OnInitngDoCheck生命周期钩子(OnInit只有在第一次检查的时候才会被调用)。

  7. 在子视图组件实例中更新ContentChildren queryList

  8. 在子组件实例中调用AfterContentInitAfterContentChecked生命周期钩子(AfterContentInit只有在第一次检查的时候才会被调用)。

  9. 如果当前视图组件实例上的属性变化的话,更新DOM插值表达式。

  10. 对子视图执行变更检查(重复这个列表里的步骤)。

  11. 更新当前视图组件实例中的ViewChildren查询列表。

  12. 在当前组件实例中调用AfterViewInitAfterViewChecked生命周期钩子(AfterViewInit只有在第一次检查的时候才会被调用)。

  13. 禁用当前视图的检查(一部分是变更检测策略的实现)。

基于上面的执行列表,有几个需要强调的事情。

第一个事情就是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)。这是检查子组件视图状态的地方,如果ChecksEnabledtrue,那么执行变更检测,下面是相关的代码:

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

探索含义(Exploring the implications)

我们假设有下面的一个组件树:

正如我们上面所学到的,每一个组件都有一个与之相关联的组件视图,每一个视图初始化时的ViewState.ChecksEnabled都为true,这就意味着当Angular执行变更检测时,组件树上的每一个组件杜辉被检查。

假设我们想禁用掉AComponent及它的子组件的变更检测,我们只需要很简单的把它的ViewState.ChecksEnabled设置为false就可以的。直接改变状态是一个低级的操作,因此Angular为我们提供了一些在视图上可用的公共方法。每一个组件都可以通过ChangeDetectorRef来获得与其关联的视图的引用,Angular文档中为这个类定义了如下的公共接口:

class ChangeDetectorRef {
  markForCheck() : void
  detach() : void
  reattach() : void
  
  detectChanges() : void
  checkNoChanges() : void
}

让我们看看我们看以从中收获点什么吧。

deatch

第一个我们可以操作视图的方法是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()的时候,一切都会如期执行。

reattach

像本文中第一部分中所说的那样,如果绑定的输入属性aPropAppComponent中改变了,AComponentOnChanges生命周期钩子仍旧会触发。这就意味着一旦我们输入属性改变了,我们就可以激活当前视图的变更检测器去执行变更检测,然后在下个事件循环中再把它从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钩子才会被触发,而不是禁用分支的所有组件。

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应该要被更行的时候,将会抛出一个异常。