Angular会管理一个组件的生命周期,包括组件的创建、渲染、子组件创建与渲染、当数据绑定属性变化时的校验、DOM移除之前毁销。

Angular提供组件生命周期钩子便于我们在某些关键点发生时添加操作。

一、组件生命周期钩子

指令和组件实例有个生命周期用于创建、更新和销毁,开发者可通过 angular2/core 库实现一个或多个这些生命周期钩子接口,每个接口都有一个单独的钩子方法,都是以接口名称加上 ng 前缀,比如:OnInit 接口有个钩子方法名为 ngOnInit,我们可以组件类实现它,比如:

import { OnInit } from 'angular2/core';

export class PeekABoo implements OnInit {
  constructor(private _logger:LoggerService) { }

  // implement OnInit's `ngOnInit` method
  ngOnInit() { this._logIt(`OnInit`); }

  protected _logIt(msg:string){
    this._logger.log(`#${nextId++} ${msg}`);
  }
}

不是所有指令或组件都能实现所有钩子方法,有的只能用在组件上。

Angular只能调用已经在指令/组件定义过钩子方法。

可选接口

从技术角度而言,对于JavaScript和TypeScript开发者来说接口是可选的,JavaScript语言并没有接口概念,Angular在运行过程中也没TypeScript接口说法,因为当TypeScript编译以后接口就消失了。

进一步来讲,其实对于Angular而言用不用钩子接口并不关心的,只要组件类存在钩子的命令规范,比如组件类有 onOnInit(),Angular自然就会调用。

尽管如此,大家还是按照接口的写法吧。

那么,刚才说过钩子接口只对指令或组件有效,而部分只对组件有效。

1、指令和组件都有效的钩子

钩子 目的
ngOnInit 发生于构造函数之后,用于初始化指令/组件,主要用于数据绑定的输入属性处理。
ngOnChanges 在数据绑定属性检查后被调用,在view和content children检查后其中之一发生改变之前。换个简单的说法,数据绑定属性的值发生变化前。
ngDoCheck 发生在每次变化检测时,主要用于在进行自定义的检查前扩展变化的检查或强制忽略检查。
ngOnDestroy 在Angular销毁组件/指令之前的清理工作,取消订阅监控对象和事件处理函数,以避免内存泄漏,仅会被调用一次。

2、组件有效的钩子

钩子 目的
ngAfterContentInit 在Angular将外部内容放到视图内之后。
ngAfterContentChecked 在Angular检测放到视图内的外部内容的绑定后。
ngAfterViewInit 在Angular创建了组件视图之后。
ngAfterViewChecked 在Angular检测了组件视图的绑定之后。

二、生命周期顺序

在Angular通过构造函数创建组件/指令后,就会按以下顺序调用这些生命周期钩子方法:

钩子 时间点
ngOnChanges 在 `ngOnInit` 之前,当数据绑定输入属性的值发生变化时。
ngOnInit 在第一次 `ngOnChanges` 之后。
ngDoCheck 每次Angular变化检测时。
ngAfterContentInit 在外部内容放到组件内之后。
ngAfterContentChecked 在放到组件内的外部内容每次检查之后。
ngAfterViewInit 在初始化组件视图和子视图之后。
ngAfterViewChecked 在组件视图和子视图检查之后。
ngOnDestroy 在Angular销毁组件/指令之前。

三、其他生命周期钩子

除上面列出的钩子外,Angular子系统也可能有自个生命周期钩子。例如,路由器,也有自己的钩子,使我们能够在路由导航的特定时刻做一些操作。

ngOnInitrouterOnActivate 都是属于钩子,为了避免发生冲突,二者前缀规范不同。当然第三方库也可能是会定义一个钩子,总之呀多注意点命令方面的问题。

四、生命周期示例简要说明

下面会通过一系列的示例,演示生命周期钩子的使用方法。所有示例都以 AppComponent 为父组件,每个子组件都会特定对某个生命周期的钩子方法进行演示。

所有示例的在线DEMO,最好是和在线DEMO一起阅读,因为后面并不会完整的去展示代码。以下是每个子组件的简要说明:

组件 说明
Peek-a-boo 演示每个生命周期钩子,每个钩子被调用时都会在屏幕上打印日志。
Spy 指令生命周期钩子,利用 `SpyDirective` 指令的 `ngOnInit` 和 `ngOnDestroy` 钩子,演示一个指令是如何被创建或销毁的。
OnChanges 看看Angular调用 `ngOnChanges` 钩子在 `changes` 对象每一次变化时的调用情况。
DoCheck 实现一个自定义变化检测 `ngDoCheck` 钩子方法,看看Angular如果调用钩子及它更改会日志的变化。
AfterView 演示Angular如何显示一个视图,包括 `ngAfterViewInit` 和 `ngAfterViewChecked` 钩子。
AfterContent 展示如何将项目外部内容放进组件内,且如何把项目内容和组件子视图区分开来,演示的钩子包括 `ngAfterContentInit` 和 `ngAfterContentChecked`。
Counter 演示一个自定义钩子示例。

五、Peek-a-boo

利用 PeekABooComponent 演示所有钩子。在实际项目中,我们很少需要实现所有钩子接口,这个示例为的是看看Angular调用钩子的执行顺序。这里有个快照,点击创建按钮再一次点击销毁的钩子调用过程。

peek-a-boo

根据日志消息的显示,钩子调用的顺序:OnChangesOnInitDoCheck(3次)、AfterContentInitAfterContentChecked(3次)、AfterViewInitAfterViewChecked(3次)、OnDestroy

如果我们点击 Update Hero 按钮,会看到另一个 OnChanges 和两对 DoCheckAfterContentCheckedAfterViewChecked

六、Spying OnInit and OnDestroy

现在来揭开这两个钩子到底是如何初始化和销毁。

创建一个 spy 指令,并实现 ngOnInitngOnDestroy 钩子,并注入 LoggerService,当钩子被调用时能在屏幕上打印日志。

// Spy on any element to which it is applied.
// Usage: <div mySpy>...</div>
@Directive({selector: '[mySpy]'})
export class Spy implements OnInit, OnDestroy {

  constructor(private _logger: LoggerService) { }

  ngOnInit()    { this._logIt(`onInit`); }

  ngOnDestroy() { this._logIt(`onDestroy`); }

  private _logIt(msg: string) {
    this._logger.log(`Spy #${nextId++} ${msg}`);
  }
}

我们可以把指令用到任何本地模板或组件元素当中,这样当这些元素初始化时或销毁时指令也会跟着初始化或销毁。

`<div *ngFor="let hero of heroes"  mySpy  class="heroes">
   {{hero}}
 </div>`

这里 spy 指令(mySpy)依附在 <div> 元素,意味者元素的初始化或销毁也会发生在指令身上。当添加一个新 hero 时,会多出一个初始化指令的动作,如果点击 reset,所有的指令也会跟着元素一同被销毁。

spy-directive

ngOnInitngOnDestroy 在实际项目应用中发挥很重要的作用,接着看看为什么我们需要他们?

1、ngOnInit

使用 ngOnInit 有两个主要理由:

  • 在构造函数之后执行复杂初始化。
  • 在设置组件之后设置输入属性。

ngOnInit 经常用来获取数据,比如之前我们介绍的 HTTP 客户端的文章。

为什么我们不把获取数据放在构造函数里呢?之前HTTP文章也谈过,试想如果在构造函数里调用一个远程服务时有异常,那实例到底应该是成功还是失败呢?一个类的构造函数应该是简单且安全的。

当一个组件必须很快的启用并开始工作时,就应该让Angular去调用 ngOnInit 方法来启用这些复杂的业务逻辑。

记住一个指令数据绑定输入属性是必须等构造函数完成以后才会开始设置,如果需要基于这些属性来初始化指令,也必须在 ngOnInit 里运行它。

2、ngOnDestroy

ngOnDestroy 主要用来清除一些逻辑,以及那些无法被GC自动回收释放资源,比如:退订observable、DOM事件、停止interval计时器等。

七、OnChanges

下面是一个监测 OnChanges 钩子的示例,每当输入属性发生变化时,Angular会调用 ngOnChanges 方法。

OnChangesComponent 组件

ngOnChanges(changes: {[propertyName: string]: SimpleChange}) {
  for (let propName in changes) {
    let prop = changes[propName];
    let cur  = JSON.stringify(prop.currentValue);
    let prev = JSON.stringify(prop.previousValue);
    this.changeLog.push(`${propName}: currentValue = ${cur}, previousValue = ${prev}`);
  }
}

ngOnChanges 钩子接收一个类型为 SimpleChange 的映射对象,包括新值和旧值。

组件里还指定两个输入属性:

@Input() hero: Hero;
@Input() power: string;

父组件绑定示例:

<on-changes [hero]="hero" [power]="power"></on-changes>

最终效果

on-changes-anim

我们看到 power 的每一次输入变化都会打印出日志,而为什么 hero 没有什么任何检测日志呢?

这是因为Angular只会对那些值会发生变化的输入属性调用 ngOnChanages,而 hero 属性是一个引用 hero 对象,Angular无法知道 hero 的 name 属性是不是已经发生变化了。所以Angular的 onChanage 钩子对于引用对象类型是无效的。

八、DoCheck

我们可以使用 DoCheck 钩子来改变变化检测结果。DoCheck 示例是在 OnChanages 示例代码基础上扩展并实现 DoCheck:

ngDoCheck() {

  if (this.hero.name !== this.oldHeroName) {
    this.changeDetected = true;
    this.changeLog.push(`DoCheck: Hero name changed to "${this.hero.name}" from "${this.oldHeroName}"`);
    this.oldHeroName = this.hero.name;
  }

  if (this.power !== this.oldPower) {
    this.changeDetected = true;
    this.changeLog.push(`DoCheck: Power changed to "${this.power}" from "${this.oldPower}"`);
    this.oldPower = this.power;
  }

  if (this.changeDetected) {
      this.noChangeCount = 0;
  } else {
      // log that hook was called when there was no relevant change.
      let count = this.noChangeCount += 1;
      let noChangeMsg = `DoCheck called ${count}x when no change to hero or power`;
      if (count === 1) {
        // add new "no change" message
        this.changeLog.push(noChangeMsg);
      } else {
        // update last "no change" message
        this.changeLog[this.changeLog.length - 1] = noChangeMsg;
      }
  }

  this.changeDetected = false;
}

我们手动检测并比较新值与旧值,当发生变化时打印日志。

do-check-anim

现在我们可以检测 hero.name 的变化了,但一定要小心。ngDoCheck 钩子调用频率非常高,不管你有没有值发生变化,每个变化都会进行一次检测。

就拿上面的示例来说,哪怕你什么值也没有改变,离开焦点时也会进行一次检测,所以 ngDoCheck 最好不要做一些很复杂的事情,这样会影响用户体验。

九、AfterView

AfterView示例探讨 AfterViewInitAfterViewChecked,Angular会在创建组件的子视图(视图可以理解为指令或组件)后调用它们。

子视图是一个文本框里显示 hero 的 name。

@Component({
  selector: 'my-child',
  template: '<input [(ngModel)]="hero">'
})
export class ChildViewComponent {
  hero = 'Magneta';
}

AfterViewComponent 组件的模板内显示子视图:

template: `
  <div>-- child view begins --</div>
    <my-child></my-child>
  <div>-- child view ends --</div>`

为了能让钩子能够检测值变化,还需要使用 @ViewChild 查询类型为 ChildViewComponent 的组件,并绑定到 viewChild 属性。

export class AfterViewComponent implements  AfterViewChecked, AfterViewInit {
  private _prevHero = '';

  // Query for a VIEW child of type `ChildViewComponent`
  @ViewChild(ChildViewComponent) viewChild: ChildViewComponent;

  ngAfterViewInit() {
    // viewChild is set after the view has been initialized
    this._logIt('AfterViewInit');
    this._doSomething();
  }

  ngAfterViewChecked() {
    // viewChild is updated after the view has been checked
    if (this._prevHero === this.viewChild.hero) {
      this._logIt('AfterViewChecked (no change)');
    } else {
      this._prevHero = this.viewChild.hero;
      this._logIt('AfterViewChecked');
      this._doSomething();
    }
  }
  // ...
}

遵守单向数据流规则

_doSomething 方法用来检测当 name 超过10个字符时显示 “That’s a long name”。

// This surrogate for real business logic sets the `comment`
private _doSomething() {
  let c = this.viewChild.hero.length > 10 ? "That's a long name" : '';
  if (c !== this.comment) {
    // Wait a tick because the component's view has already been checked
    setTimeout(() => this.comment = c, 0);
  }
}

为什么 _doSomething 方法需要使用 setTimeout 来延迟更新 comment 呢?

我们必须坚持Angular单向数据流规则,换句话说,如果我们立即更新组件数据绑定 comment 属性,而此时正在检测变化Angular会抛出异常,这一点很容易发生因为当 ngAfterViewChecked 触发前会先进行 DoCheck 这之前我们也讨论过,所以这里才用 setTimeout 推迟浏览器JavaScript循环周期。

after-view-anim

十、AfterContent

AfterContent示例探讨 AfterContentInitAfterContentChecked,Angular会在项目外部内容被放进组件内后调用它们。

1、内容投影

内容投影是将外部组件的HTML内容插入到组件模板的指定位置;跟Angular1.x的transclusion类似。

本示例同 AfterView 的示例一样,只不过这一次,我们父组件模板多套一层 AfterContentComponent 组件:

`<after-content>
   <my-child></my-child>
 </after-content>`

注意, <after-content> 标签包裹着 <my-child> 标签,除非我们打算把内容也插入到组件里,否则永远不要把其他元素放到这中间。

现在看看组件模板:

template: `
  <div>-- projected content begins --</div>
    <ng-content></ng-content>
  <div>-- projected content ends --</div>`

<ngContent> 标签是外部内容的占位符,告诉Angular要在哪插入内容。

2、AfterContent 钩子

AfterContent钩子类似于AfterView钩子,关键的区别在于如何查找子组件。

  • AfterView钩子涉及 ViewChildren,子组件通过组件模板插入。
  • AfterContent钩子涉及 ContentChildren,子组件通过映射内容插入。

接下来 AfterContent 钩子只能通过属性装饰 @ContentChild 查询组件。

export class AfterContentComponent implements AfterContentChecked, AfterContentInit {
  private prevHero = '';
  comment = '';

  // Query for a CONTENT child of type `ChildComponent`
  @ContentChild(ChildComponent) contentChild: ChildComponent;

  ngAfterContentInit() {
    // viewChild is set after the view has been initialized
    this.logIt('AfterContentInit');
    this.doSomething();
  }

  ngAfterContentChecked() {
    // viewChild is updated after the view has been checked
    if (this.prevHero === this.contentChild.hero) {
      this.logIt('AfterContentChecked (no change)');
    } else {
      this.prevHero = this.contentChild.hero;
      this.logIt('AfterContentChecked');
      this.doSomething();
    }
  }
  // ...
}