更新时间 - 2017-03-20 16:15;
更新内容 - 我有话说模块

Angular 2 Change Detection - 1 文章中,我们介绍了浏览器渲染、Zone、NgZone 的概念,本文将详细介绍 Angular 2 组件中的变化检测器。

组件和变化检测器

如你所知,Angular 2 应用程序是一颗组件树,而每个组件都有自己的变化检测器,这意味着应用程序也是一颗变化检测器树。顺便说一句,你可能会想。是由谁来生成变化检测器?这是个好问题,它们是由代码生成。 Angular 2 编译器为每个组件自动创建变化检测器,而且最终生成的这些代码 JavaScript VM友好代码。这也是为什么新的变化检测是快速的 (相比于 Angular 1.x 的 $digest)。基本上,每个组件可以在几毫秒内执行数万次检测。因此你的应用程序可以快速执行,而无需调整性能。

另外在 Angular 2 中,任何数据都是从顶部往底部流动,即单向数据流。下图是 Angular 1.x 与 Angular 2 变化检测的对比图:

让我们来看一下具体例子:

child.component.ts

import { Component, Input } from '@angular/core';

@Component({
    selector: 'exe-child',
    template: `
     <p>{{ text }}</p>
    `
})
export class ChildComponent {
    @Input() text: string;
}

parent.component.ts

import { Component, Input } from '@angular/core';

@Component({
    selector: 'exe-parent',
    template: `
     <exe-child [text]="name"></exe-child> 
    `
})
export class ParentComponent {
    name: string = 'Semlinker';
}

app.component.ts

import { Component } from '@angular/core';

@Component({
  selector: 'exe-app',
  template: `
    <exe-parent></exe-parent>
  `
})
export class AppComponent{ }

变化检测总是从根组件开始。上面的例子中,ParentComponent 组件会比 ChildComponent 组件更早执行变化检测。因此在执行变化检测时 ParentComponent 组件中的 name 属性,会传递到 ChildComponent 组件的输入属性 text 中。此时 ChildComponent 组件检测到 text 属性发生变化,因此组件内的 p 元素内的文本值从空字符串 变成 'Semlinker' 。这虽然很简单,但很重要。另外对于单次变化检测,每个组件只检查一次。

OnChanges

当组件的任何输入属性发生变化的时候,我们可以通过组件生命周期提供的钩子 ngOnChanges 来捕获变化的内容。具体示例如下:

import { Component, Input, OnChanges, SimpleChange } from '@angular/core';

@Component({
    selector: 'exe-child',
    template: `
     <p>{{ text }}</p>
    `
})
export class ChildComponent implements OnChanges{
    @Input() text: string;

    ngOnChanges(changes: {[propName: string]: SimpleChange}) {
        console.dir(changes['text']);    
    }
}

以上代码运行后,控制台的输出结果:

我们看到当输入属性变化的时候,我们可以通过组件提供的生命周期钩子 ngOnChanges 捕获到变化的内容,即 changes 对象,该对象的内部结构是 key-value 键值对的形式,其中 key 是输入属性的值,value 是一个 SimpleChange 对象,该对象内包含了 previousValue (之前的值) 和 currentValue (当前值)。

需要注意的是,如果在组件内手动改变输入属性的值,ngOnChanges 钩子是不会触发的。具体示例如下:

import { Component, Input, OnChanges, SimpleChange } from '@angular/core';

@Component({
    selector: 'exe-child',
    template: `
     <p>{{ text }}</p>
     <button (click)="changeTextProp()">改变Text属性</button>
    `
})
export class ChildComponent implements OnChanges {
    @Input() text: string;

    ngOnChanges(changes: { [propName: string]: SimpleChange }) {
        console.dir(changes['text']);
    }

    changeTextProp() {
        this.text = 'Text属性已改变';
    }
}

当你点击 '改变Text属性' 的按钮时,发现页面中 p 元素的内容会从 'Semlinker' 更新为 'Text属性已改变' ,但控制台却没有输出任何信息,这验证了我们刚才给出的结论,即在组件内手动改变输入属性的值,ngOnChanges 钩子是不会触发的。

变化检测性能优化

在介绍如何优化变化检测的性能前,我们先来看几张图:

变化检测前:

变化检测时:

我们发现每次变化检测都是从根组件开始,从上往下执行。虽然 Angular 2 优化后的变化检测执行的速度很快,但我们能否只针对那些有变化的组件才执行变化检测或灵活地控制变化检测的时机呢 ? 答案是有的,接下来我们看一下具体怎么进行优化。

变化检测策略

在 Angular 2 中我们可以在定义组件的 metadata 信息时,设定每个组件的变化检测策略。接下来我们来看一下具体示例:

profile-name.component.ts

import { Component, Input} from '@angular/core';

@Component({
    selector: 'profile-name',
    template: `
        <p>Name: {{name}}</p>
    `
})
export class ProfileNameComponent {
   @Input() name: string;
}

profile-age.component.ts

import { Component, Input } from '@angular/core';

@Component({
    selector: 'profile-age',
    template: `
        <p>Age: {{age}}</p>
    `
})
export class ProfileAgeComponent {
    @Input() age: number;
}

profile-card.component.ts

import { Component, Input } from '@angular/core';

@Component({
    selector: 'profile-card',
    template: `
       <div>
         <profile-name [name]='profile.name'></profile-name>
         <profile-age [age]='profile.age'></profile-age>
       </div>
    `
})
export class ProfileCardComponent {
    @Input() profile: { name: string; age: number };
}

app.component.ts

import { Component } from '@angular/core';

@Component({
  selector: 'exe-app',
  template: `
    <profile-card [profile]='profile'></profile-card>
  `
})
export class AppComponent {
  profile: { name: string; age: number } = {
    name: 'Semlinker',
    age: 31
  };
}

上面代码中 ProfileCardComponent 组件,有一个 profile 输入属性,而且它的模板视图只依赖于该属性。如果使用默认的检测策略,每当发生变化时,都会从根组件开始,从上往下在每个组件上执行变化检测。但如果 ProfileCardComponent 中的 profile 输入属性没有发生变化,是没有必要再执行变化检测。针对这种情况,Angular 2 为我们提供了 OnPush 的检测策略。

OnPush策略

import { Component, Input, ChangeDetectionStrategy } from '@angular/core';

@Component({
    selector: 'profile-card',
    template: `
       <div>
         <profile-name [name]='profile.name'></profile-name>
         <profile-age [age]='profile.age'></profile-age>
       </div>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProfileCardComponent {
    @Input() profile: { name: string; age: number };
}

当使用 OnPush 策略的时候,若输入属性没有发生变化,组件的变化检测将会被跳过,如下图所示:

实践是检验真理的唯一标准,我们马上来个例子:

app.component.ts

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'exe-app',
  template: `
    <profile-card [profile]='profile'></profile-card>
  `
})
export class AppComponent implements OnInit{

  profile: { name: string; age: number } = {
    name: 'Semlinker',
    age: 31
  };

 ngOnInit() {
   setTimeout(() => {
    this.profile.name = 'Fer';
   }, 2000);
 }
}

以上代码运行后,浏览器的输出结果:

我们发现虽然在 AppComponent 组件中 profile 对象中的 name 属性已经被改变了,但页面中名字的内容却未同步刷新。在进一步分析之前,我们先来介绍一下 Mutable 和 Immutable 的概念。

Mutable(可变) and Immutable(不可变)

在 JavaScript 中默认所有的对象都是可变的,即我们可以任意修改对象内的属性:

var person = {
    name: 'semlinker',
    age: 31
};
person.name = 'fer'; 
console.log(person.name); // Ouput: 'fer'

上面代码中我们先创建一个 person 对象,然后修改 person 对象的 name 属性,最终输出修改后的 name 属性。接下来我们调整一下上面的代码,调整后的代码如下:

var person = {
    name: 'semlinker',
    age: 31
};

var aliasPerson = person;
person.name = 'fer';
console.log(aliasPerson === person); // Output:  true

在修改 person 对象前,我们先把 person 对象赋值给 aliasPerson 变量,在修改完 person 对象的属性之后,我们使用 === 比较 aliasPerson 与 person,发现输出的结果是 true。也许你已经知道了,我们刚才在 AppComponent 中模型更新了,但视图却未同步更新的原因。

接下来我们来介绍一下 Immutable

Immutable 即不可变,表示当数据模型发生变化的时候,我们不会修改原有的数据模型,而是创建一个新的数据模型。具体示例如下:

var person = {
    name: 'semlinker',
    age: 31
};

var newPerson = Object.assign({}, person, {name: 'fer'});

console.log(person.name, newPerson.name); // Output: 'semliker' 'fer'
console.log(newPerson === person); // Output: false

这次要修改 person 对象中的 name 属性,我们不是直接修改原有对象,而是使用 Object.assign 方法创建一个新的对象。介绍完 Mutable 和 Immutable 的概念 ,我们回过头来分析一下 OnPush 策略,该策略内部使用 looseIdentical 函数来进行对象的比较,looseIdentical 的实现如下:

export function looseIdentical(a: any, b: any): boolean {
  return a === b || typeof a === 'number' && typeof b === 'number' && isNaN(a) && isNaN(b);
}

因此当我们使用 OnPush 策略时,需要使用的 Immutable 的数据结构,才能保证程序正常运行。为了提高变化检测的性能,我们应该尽可能在组件中使用 OnPush 策略,为此我们组件中所需的数据,应仅依赖于输入属性。

OnPush 策略是提高应用程序性能的一个简单而好用的方法。不过,我们还有其他方法来获得更好的性能。 即使用 Observable 与 ChangeDetectorRef 对象提供的 API,来手动控制组件的变化检测行为。

ChangeDetectorRef

ChangeDetectorRef 是组件的变化检测器的引用,我们可以在组件中的通过依赖注入的方式来获取该对象:

import { ChangeDetectorRef } from '@angular/core';

@Component({}) class MyComponent {
    constructor(private cdRef: ChangeDetectorRef) {}
}

ChangeDetectorRef 变化检测类中主要方法有以下几个:

export abstract class ChangeDetectorRef {
  abstract markForCheck(): void;
  abstract detach(): void;
  abstract detectChanges(): void;
  abstract reattach(): void;
}

其中各个方法的功能介绍如下:

  • markForCheck() - 在组件的 metadata 中如果设置了 changeDetection: ChangeDetectionStrategy.OnPush 条件,那么变化检测不会再次执行,除非手动调用该方法。

  • detach() - 从变化检测树中分离变化检测器,该组件的变化检测器将不再执行变化检测,除非手动调用 reattach() 方法。

  • reattach() - 重新添加已分离的变化检测器,使得该组件及其子组件都能执行变化检测

  • detectChanges() - 从该组件到各个子组件执行一次变化检测

接下来我们先来看一下 markForCheck() 方法的使用示例:

child.component.ts

import { Component, Input, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';

@Component({
    selector: 'exe-child',
    template: `
     <p>当前值: {{ counter }}</p>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChildComponent implements OnInit {
    @Input() counter: number = 0;

    constructor(private cdRef: ChangeDetectorRef) {}
    
    ngOnInit() {
        setInterval(() => {
            this.counter++;
            this.cdRef.markForCheck();
        }, 1000);
    }
}

parent.component.ts

import { Component, ChangeDetectionStrategy } from '@angular/core';

@Component({
    selector: 'exe-parent',
    template: `
     <exe-child></exe-child> 
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class ParentComponent { }

ChildComponent 组件设置的变化检测策略是 OnPush 策略,此外该组件也没有任何输入属性。那么我们应该怎么执行变化检测呢 ?我们看到在 ngOnInit 钩子中,我们通过 setInterval 定时器,每隔一秒钟更新计数值同时调用 ChangeDetectorRef 对象上的 markForCheck() 方法,来标识该组件在下一个变化检测周期,需执行变化检测,从而更新视图。

接下来我们来讲一下 detach() 和 reattach() 方法,它们用来开启/关闭组件的变化检测。让我们看下面的例子:

child.component.ts

import { Component, Input, OnInit, ChangeDetectorRef } from '@angular/core';

@Component({
    selector: 'exe-child',
    template: `
     Detach: <input type="checkbox" 
        (change)="detachCD($event.target.checked)">
     <p>当前值: {{ counter }}</p>
    `
})
export class ChildComponent implements OnInit {
    counter: number = 0;
  
    constructor(private cdRef: ChangeDetectorRef) { }

    ngOnInit() {
        setInterval(() => {
            this.counter++;
        }, 1000);
    }

    detachCD(checked: boolean) {
        if (checked) {
            this.cdRef.detach();
        } else {
            this.cdRef.reattach();
        }
    }
}

该组件有一个用于移除或添加变化检测器的复选框。 当复选框被选中时,detach() 方法将被调用,之后组件及其子组件将不会被检查。当取消选择时,reattach() 方法会被调用,该组件将会被重新添加到变化检测器树上。

Observables

使用 Observables 机制提升性能和不可变的对象类似,但当发生变化的时候,Observables 不会创建新的模型,但我们可以通过订阅 Observables 对象,在变化发生之后,进行视图更新。使用 Observables 机制的时候,我们同样需要设置组件的变化检测策略为 OnPush。我们马上看个例子:

counter.component.ts

import { Component, Input, OnInit, ChangeDetectionStrategy, 
         ChangeDetectorRef } from '@angular/core';
import { Observable } from 'rxjs/Rx';

@Component({
    selector: 'exe-counter',
    template: `
      <p>当前值: {{ counter }}</p>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class CounterComponent implements OnInit {
    counter: number = 0;

    @Input() addStream: Observable<any>;

    constructor(private cdRef: ChangeDetectorRef) { }

    ngOnInit() {
        this.addStream.subscribe(() => {
            this.counter++;
            this.cdRef.markForCheck();
        });
    }
}

app.component.ts

import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs/Rx';

@Component({
  selector: 'exe-app',
  template: `
   <exe-counter [addStream]='counterStream'></exe-counter>
  `
})
export class AppComponent implements OnInit {
  counterStream: Observable<any>;
  
  ngOnInit() {
     this.counterStream = Observable.timer(0, 1000); 
  }
}

现在我们来总结一下变化检测的原理:Angular 应用是一个响应系统,变化检测总是从根组件到子组件这样一个从上到下的顺序开始执行,它是一棵线性的有向树,默认情况下,变化检测系统将会走遍整棵树,但我们可以使用 OnPush 变化检测策略,在结合 Observables 对象,进而利用 ChangeDetectorRef 实例提供的方法,来实现局部的变化检测,最终提高系统的整体性能。

我有话说

1.ChangeDetectionStrategy 变化检测策略总共有几种 ?

export declare enum ChangeDetectionStrategy {
    OnPush = 0, // 变化检测器的状态值是 CheckOnce
    Default = 1, // 组件默认值 - 变化检测器的状态值是 CheckAlways,即始终执行变化检测
}

2.变化检测器的状态有哪几种 ?

export declare enum ChangeDetectorStatus {
    CheckOnce = 0, // 表示在执行detectChanges之后,变化检测器的状态将会变成Checked
    Checked = 1, // 表示变化检测将被跳过,直到变化检测器的状态恢复成CheckOnce
    CheckAlways = 2, // 表示在执行detectChanges之后,变化检测器的状态始终为CheckAlways
    Detached = 3, // 表示该变化检测器树已从根变化检测器树中移除,变化检测将会被跳过
    Errored = 4, // 表示在执行变化检测时出现异常
    Destroyed = 5, // 表示变化检测器已被销毁
}

3.markForCheck()、detectChanges()、detach()、reattach() (@angular/core version: 2.2.4)

markForCheck()

ViewRef_.prototype.markForCheck = function () { 
  this._view.markPathToRootAsCheckOnce(); 
};

AppView.prototype.markPathToRootAsCheckOnce = function () {
    var c = this;
    while (isPresent(c) && c.cdMode !== ChangeDetectorStatus.Detached) {
       if (c.cdMode === ChangeDetectorStatus.Checked) {
              c.cdMode = ChangeDetectorStatus.CheckOnce;
           }
           if (c.type === ViewType.COMPONENT) {
                    c = c.parentView;
           }
           else {
                    c = c.viewContainer ? 
                       c.viewContainer.parentView : null;
           }
        }
};

detectChanges()

 ViewRef_.prototype.detectChanges = function () {
   this._view.detectChanges(false);
   triggerQueuedAnimations();
};

AppView.prototype.detectChanges = function (throwOnChange) {
   var s = _scope_check(this.clazz);
   if (this.cdMode === ChangeDetectorStatus.Checked ||
         this.cdMode === ChangeDetectorStatus.Errored ||
         this.cdMode === ChangeDetectorStatus.Detached)
         return;
         
   if (this.cdMode === ChangeDetectorStatus.Destroyed) {
         this.throwDestroyedError('detectChanges');
   }
   
        this.detectChangesInternal(throwOnChange);
   if (this.cdMode === ChangeDetectorStatus.CheckOnce)
         this.cdMode = ChangeDetectorStatus.Checked;
         this.numberOfChecks++;
         wtfLeave(s);
};

detach()

ViewRef_.prototype.detach = function () {
  this._view.cdMode = ChangeDetectorStatus.Detached; 
};

reattach()

ViewRef_.prototype.reattach = function () {
  this._view.cdMode = this._originalMode;
  this.markForCheck();
};

总结

通过两篇文章,我们详细介绍了 Angular 2 变化检测的内容,此外在了解变化检测的基础上,我们还介绍了如何基于 OnPush 变化检测策略,进行变化检测的优化,从而提高应用程序的性能。

如果觉得我的文章对你有用,请随意赞赏
已赞赏

你可能感兴趣的文章

semlinker 作者 · 3月21日

呵呵,还在修仙中,还没到神。使用 Immutable 数据结构,是为了避免对象深度比较,特别是对象内部结构非常复杂的情况下,是很影响性能的,最终的目的是改变 looseIdentical 函数的比较结果。ChangeDetectorRef 使得我们能够更灵活的控制检测器的行为,特别是当数据源如 Observable 在短时间内会频繁变化的时候,你不想频繁地刷新视图,你就可以手动的控制,页面的刷新频率。最终结论:Immutable 能解决大部分问题,而 ChangeDetectorRef 能让你更灵活地控制检测器的行为,具体根据实际业务场景。回复发不了代码,我把代码补充到 "我有话说" 部分,@angular/core 的版本是 2.2.4。

+1 回复

xaclincoln · 3月20日

大神,你是推荐使用Immutable还是手动ChangeDetectorRef?Typescript2.2对Spread Operator有了很不错的支持.

回复

载入中...
semlinker semlinker

2.4k 声望

发布于专栏

Angular 4.x 修仙之路

生活在混合应用的世界中,迷失在 Angular 4.x 和 Ionic 3.x 的道路上...

514 人关注

系列文章