希望是最好懂的 Angular 生命週期介紹

Ray
34 min readJul 22, 2023

--

image src

Angular 總共有 8 個關於生命週期的 Hook,每個被調用的時機都不同。當然也不是每個 Hook 都必須要實現,只需要實現所需的即可。

本文內容

  1. #介紹八個生命週期 Hooks
  2. #其他資訊

#介紹八個生命週期 Hooks

ngOnChanges

觸發時機:當綁定的值變動的時候觸發,前提是要有綁定的值。可觸發多次

觸發範例:透過父元件包一個子元件,觀察子元件的ngOnChanges

// 子組件接收一個 Input 值,當這個值被改變時會觸發 ngOnChanges

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

@Component({
selector: 'app-test-child',
template: '',
styleUrls: ['./test-child.component.scss']
})
export class TestChildComponent implements OnChanges {

@Input() foo: number = 0;

ngOnChanges(): void {
console.log('onChange:', this.foo);
}

}


// 在父層傳入值給子組件,並設置一點擊時將該值 +1 的按鈕,用以判斷是否觸發子組件onChange

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

@Component({
selector: 'app-test-parent',
template: `
<app-test-child [foo]="foo"></app-test-child>
<button (click)="foo = foo + 1">值+1</button>
`,
styleUrls: ['./test-parent.component.scss']
})
export class TestParentComponent {
foo: number = 0;
}

其他參數:SimpleChanges

看完上述範例可以知道,ngOnChange 是當傳入參數發生改變時會觸發。貼心的Angular 在 ngOnChanges 有提供一個參數可以使用,這個參數可以讓你知道先前的值為何,以及這是否為第一次變更

ngOnChanges(changes: SimpleChanges): void {
console.log(changes)
}

補充說明:若子元件去更改父元件傳入的值,父元件會跟著變動嗎?

父元件有一個 foo , 並將這個 foo 傳入給子元件,子元件接收到 foo,那麼此時父元件的 foo 與 子元件的 foo 會是同一個 foo 嗎?

// 子元件

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

@Component({
selector: 'app-test-child',
template: '<button (click)="addFoo()">子元件foo+1</button>',
styleUrls: ['./test-child.component.scss']
})
export class TestChildComponent implements OnChanges {

@Input() foo: number = 0;

ngOnChanges(): void {
console.log('父元件改值(ngChange):', this.foo);
}

addFoo() {
this.foo++;
console.log('子元件改值', this.foo);
}

}

// 父元件

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

@Component({
selector: 'app-test-parent',
template: `
<app-test-child [foo]="foo"></app-test-child>
<button (click)="foo = foo + 1">父元件foo+1</button>
`,
styleUrls: ['./test-parent.component.scss']
})
export class TestParentComponent {

foo: number = 0;


}

我們通過父元件將 foo + 1,觸發子元件的 ngOnChange,再比對子元件自行將 foo + 1 時印出的 console.log 進行對比,會發現,當父元件將 foo的值改變時,子元件會與父元件的 foo 值同步,因此子元件的值會參考父元件最後一次改變的值。

那根據上面的作法,再透過父元件將值 +1 後,此時子元件 onChange 會收到的值是 7 ( 子元件更改後再+1 ) 或是 5 (父元件最後一次改動的值 +1 )?

答案是:即便子元件更改 foo 值,也不影響父元件本身的值。根據上面的結論「子元件的值會參考父元件最後一次改變的值」。此時再於子元件點擊 +1 按鈕,會發現值變成 6 ,而非先前的 6 + 1 = 7。

因此我們得到的結論是

  1. 子元件的值會參考父元件最後一次改變的值
  2. 子元件更改父元件傳入的值,但父元件不受影響

ngOnInit

觸發時機:第一次onChange執行完後觸發,若沒有傳入參數則不觸發onChange,直接觸發onInit。只觸發一次。

觸發範例:於組件實現onInit

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

@Component({
selector: 'app-test',
templateUrl: './test.component.html',
styleUrls: ['./test.component.scss']
})
export class TestComponent implements OnInit {

ngOnInit(): void {
console.log('hi');
}

}

補充說明:ngOnInit 與 constructor 實作上有何差別?

Angular 在啟動的過程有兩個主要步驟,第一個是構建組件樹,第二個是執行變更檢測。constructor 會在第一個步驟構建組件樹中(被實例時)執行。而 ngOninit 則是在執行變更檢測後執行;前者是 js 自帶的函數、後者是 Angular 提供的生命週期 Hook。(詳細可參考 "The essential difference between Constructor and ngOnInit in Angular")

需要注意的是,constructor 中是無法取得父元件傳進的值,而 ngOninit 則可以。我們將父元件將 foo = 1 的值傳入子元件,透過 console.log 觀察 constructor 與 ngOnInit 中取得的 foo 是否存在差別。

// 子元件

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

@Component({
selector: 'app-test-child',
template: '<button>子</button>',
styleUrls: ['./test-child.component.scss']
})
export class TestChildComponent {

@Input() foo: number = 0;

constructor() {
console.log('constructor 中 foo 的值:', this.foo)
}

ngOnInit() {
console.log('ngOnInit 中 foo 的值:', this.foo)
}

}

// 父元件

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

@Component({
selector: 'app-test-parent',
template: `<app-test-child [foo]="foo"></app-test-child>`,
styleUrls: ['./test-parent.component.scss']
})
export class TestParentComponent {

foo: number = 1;

}
會發現父元件傳進的值,在 constructor 無法取得

ngDoCheck

觸發時機:ngOnInit 後觸發,用於偵測 Angular 自身無法捕獲的變化項目,會被多次調用

觸發範例:父組件傳入一個 obj 給子組件,當該 obj 發生變化時觸發

當父組件點擊按鈕時,會更改傳給子元件的 obj 值,於子組件實作 doCheck 以及 onChange,並於觸發時將值透過 console.log 顯示。

// 子元件
import { Component, DoCheck, Input, OnChanges } from '@angular/core';

@Component({
selector: 'app-test-child',
template: '',
styleUrls: ['./test-child.component.scss']
})
export class TestChildComponent implements OnChanges, DoCheck {

@Input() foo: any = {};

ngDoCheck(): void {
console.log('ngDoCheck', this.foo);
}

ngOnChanges(changes: SimpleChanges): void {
console.log('ngOnChanges', this.foo);
}

}

// 父元件
import { Component } from '@angular/core';

@Component({
selector: 'app-test-parent',
template: `
<app-test-child [foo]="foo"></app-test-child>
<button (click)="modifyFoo()">變更foo值</button>
`,
styleUrls: ['./test-parent.component.scss']
})
export class TestParentComponent {

foo: any = { name: 'Ray' };

modifyFoo() {
this.foo.name = this.foo.name + '.';
}

}

可以觀察到 onChange 只有在第一次變更時有抓到 obj 的值,之後即便值變更,也只有觸發 doCheck 而非 onChange。這是因為傳入 obj 實際上是傳入該 obj 的記憶體位置,該記憶體位置並沒有因為變更值而改變,因此不會觸發 onChange,而是觸發 doCheck (用於偵測 Angular 自身無法捕獲的變化項目)。

再來注意到為什麼在父元件變更 foo 值之前,ngDoCheck 就被調用了三次?這是因為:

第一次:父元件更新子元件的Input,觸發doCheck

第二次:父元件調用子元件的doCheck

第三次:子元件自身的檢測呼叫一次doCheck

若父元件沒有傳值給子元件,則子元件也會調用兩次 doCheck 分別是 "父元件調用子元件的doCheck" 以及 "子元件自身的檢測呼叫一次doCheck"

觸發範例2:當父組件或子組件的值發生改變時觸發

分別於父組件與子組件中宣告一個 foo 變數,並個別增加一個按鈕,當點擊時會將該變數的值 + 1,並觀察值發生改變時父組件與子組件的 doCheck 是否調用。

// 父組件

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

@Component({
selector: 'app-test-parent',
template: `
<app-test-child ></app-test-child>
<button (click)="foo = foo +1">父組件變更值</button>
`,
styleUrls: ['./test-parent.component.scss']
})
export class TestParentComponent implements DoCheck {
foo: number = 0;

ngDoCheck(): void {
console.log('父觸發 ngDoCheck', this.foo);
}
}


// 子組件

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

@Component({
selector: 'app-test-child',
template: '<button (click)="foo = foo +1">子組件變更值</button>',
styleUrls: ['./test-child.component.scss']
})
export class TestChildComponent implements DoCheck {
foo: number = 0;

constructor() {
}

ngDoCheck(): void {
console.log('子觸發 ngDoCheck', this.foo);
}
}

這個案例可以觀察到不論是父組件或子組件的值發生改變,都會觸發到整顆樹的 doCheck。子元件的值發生改變,會觸發父元件與子元件的 doCheck;父元件的值發生改變,也會觸發父元件與子元件的 doCheck。

觸發範例3:在元件中使用input 並透過 ngModel 綁定值,只要使用滑鼠點擊該 input 方框內再點擊方框外,會發現 ngDoCheck 也會觸發。

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

@Component({
selector: 'app-test-child',
template: '<input [(ngModel)]="foo">',
styleUrls: ['./test-child.component.scss']
})
export class TestChildComponent implements DoCheck {

foo: string = '';

ngDoCheck(): void {
console.log('ngDoCheck', this.foo);
}


}

doCheck 是一個會被頻繁調用的 Hook ,應避免在這個 Hook 中寫過度複雜的邏輯,否則很容易引起性能問題。

ngAfterContentInit

觸發時機:<ng-content> 內容嵌入完成後呼叫,只呼叫一次

前置知識:<ng-content> 使用方法

ng-content 是提供可被嵌入的空間,舉例來說:在子組件(app-test-child)中的 html 中留下<ng-content></ng-content>,則表示父組件可將其他內容嵌入至子組件留下的 ng-content 的位置。

// 待嵌入元件

import { Component, DoCheck, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';

@Component({
selector: 'app-test-comp',
template: `<div >app-test-comp</div>`,
styleUrls: ['./test-comp.component.scss']
})
export class TestCompComponent implements OnInit, OnChanges, DoCheck {
@Input() foo = '';

constructor() {
console.log('comp - constructor');
}

ngOnChanges(): void {
console.log('comp - ngOnChanges');
}

ngOnInit(): void {
console.log('comp - ngOnInit');
}

ngDoCheck(): void {
console.log('comp - doCheck');
}
}

// 子元件

import { AfterContentInit, Component, ContentChild, ElementRef, ViewChild } from '@angular/core';

@Component({
selector: 'app-test-child',
template: `
<div #contentWrapper>
<ng-content></ng-content>
</div>
`,
styleUrls: ['./test-child.component.scss']
})
export class TestChildComponent implements AfterContentInit {

@ViewChild('contentWrapper') content: ElementRef | undefined;

ngAfterContentInit() {
console.log('ngAfterContentInit (ng-content content finish)', this.content);
}

ngAfterViewInit() {
console.log('ngAfterViewInit (ng-content view finish)', this.content);
}

}

// 父元件

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

@Component({
selector: 'app-test-parent',
template: `
<app-test-child>
<app-test-comp [foo]="foo"></app-test-comp>
</app-test-child>
`, // 於 app-test-child 中 插入其他組件
styleUrls: ['./test-parent.component.scss']
})
export class TestParentComponent {
foo = 'Ray';
}

以上述這個範例來看, app-test-child 中的 ng-content 會被置換成父組件傳入的 app-test-comp,這就是 ng-content 的使用方法。

觸發範例:具有 ng-content 的組件完成內容的嵌入時(ng-content將繫節的值綁定完成後)調用,只呼叫一次

ngAfterContentChecked

觸發時機:完成 ng-content 的變更檢測調用(每次ngDoCheck之後調用),可以調用多次。

觸發範例:既然每次 doCheck 都會調用,那麼只要有 doCheck 不就好了嗎?我們看以下範例,一共有三個元件,分別是:父元件、子元件、嵌入用元件。以下範例會利用子元件於 html 中開啟一個 ng-content 的缺口,於父元件在使用子元件時,嵌入一個其他的元件。接著觀察開放 ng-content 缺口的子元件 doCheck 以及 afterContentChecked 的差別

// 嵌入用元件

import { Component, DoCheck, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';

@Component({
selector: 'app-test-comp',
template: `<div >{{foo}}</div>`,
styleUrls: ['./test-comp.component.scss']
})
export class TestCompComponent implements OnInit, OnChanges, DoCheck {
@Input() foo = '';

constructor() {
console.log('嵌入元件 - constructor');
}

ngOnChanges(): void {
console.log('嵌入元件 - ngOnChanges');
}

ngOnInit(): void {
console.log('嵌入元件 - ngOnInit');
}

ngDoCheck(): void {
console.log('嵌入元件 - doCheck');
}
}

// 子元件

import { AfterContentChecked, AfterContentInit, Component, DoCheck, ElementRef } from '@angular/core';

@Component({
selector: 'app-test-child',
template: `
<ng-content></ng-content>
`,
styleUrls: ['./test-child.component.scss']
})
export class TestChildComponent implements AfterContentInit, DoCheck, AfterContentChecked {

ngDoCheck(): void {
console.log('子元件 - ngDoCheck');
}

ngAfterContentInit() {
console.log('子元件 ngAfterContentInit');
}

ngAfterContentChecked(): void {
console.log('子元件 ngAfterContentChecked');
}
}

// 父元件
import { Component } from '@angular/core';

@Component({
selector: 'app-test-parent',
template: `
<app-test-child>
<app-test-comp [foo]="foo"></app-test-comp>
</app-test-child>
<button (click)="changeFoo()">Change foo</button>
`, // 於 app-test-child 中 插入其他組件
styleUrls: ['./test-parent.component.scss']
})
export class TestParentComponent {
foo = 'Ray';

changeFoo() {
this.foo = this.foo + '.';
}
}

當點擊父元件觸發變更檢測時,會優先執行子元件的 doCheck,接著先進到嵌入元件的 OnChange,接著是嵌入元件的 doCheck 。這兩個 Hooks 執行完成後才回到子元件的 ngAfterContentChecked。因此當嵌入元件值被改變時,子元件的 doCheck 觸發時,嵌入元件的值是尚未改變的,子元件的 ngAfterContentChecked 觸發時嵌入元件完成值的變更之時。

doCheck 與 ngAfterContentChecked 差異在於觸發時機,兩者的差異點在於當前時機是否已經檢查完子視圖的綁定。但不論是前者或後者,在使用的時候都需特別小心,因為會被多次調用,若使用不當可能引起性能問題。

ngAfterViewInit

觸發時機:當組件的視圖被組裝完成後調用,僅調用一次

觸發範例:透過在 ngOnInit 與 ngAfterViewInit 印出當前組件視圖的元素,觀察輸出結果

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

@Component({
selector: 'app-test',
template: '<input #name>',
styleUrls: ['./test.component.scss']
})
export class TestComponent implements AfterViewInit {

@ViewChild('name') input: any;

ngOnInit() {
console.log(this.input);
}

ngAfterViewInit(): void {
console.log(this.input);
}

}

在 ngOninit 時,視圖尚未被建立 ( 這邊指 template 中的 input ),因此在未被建立前無法取得 input 的相關資訊,而到 ngAfterViewInit 時已經建立好視圖,此時就可以正常取得 input 的相關資訊。

ngAfterViewChecked

觸發時機:當組件的視圖更新後調用,可調用多次

觸發範例:透過 ngModel 於 Input 上綁定值,於 Input 上輸入值使值變更時會觸發 ngAfterViewChecked (透過變更值來觸發,只是其中一種觸發的方式,不一定只有值更新才會觸發。setTimeout、呼叫API、呼叫Service...等,也會使 ngAfterViewChecked 觸發)

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

@Component({
selector: 'app-test',
template: '<input [(ngModel)]="foo">',
styleUrls: ['./test.component.scss']
})
export class TestComponent implements AfterViewChecked{

foo: string = '';

ngAfterViewChecked(): void {
console.log("觸發 ngAfterContentChecked")
}

}

補充說明:常見錯誤 ExpressionChangedAfterItHasBeenCheckedError

ERROR Error: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: ‘.’. Current value: ‘..’. Find more at https://angular.io/errors/NG0100

會發生這個錯誤是當 Angular 已經執行完一次變更檢測時,卻又偵測到值變更,這是為了防止不穩定的資料變更行為所以才報的錯。通常問題原因是在ngAfterViewChecked 或 ngAfterViewInit 完成後更改已經檢查過的值。例如

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

@Component({
selector: 'app-test',
template: '{{foo}}',
styleUrls: ['./test.component.scss']
})
export class TestComponent implements AfterViewChecked {

foo: string = '';

ngAfterViewChecked(): void {
this.foo = this.foo + '.';
}

}

有兩種方法可以避免報這個錯

1.透過 setTimeout 將變更的操作丟到下一次循環中

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

@Component({
selector: 'app-test',
template: '{{foo}}',
styleUrls: ['./test.component.scss']
})
export class TestComponent implements AfterViewChecked {

foo: string = '';

ngAfterViewChecked(): void {
setTimeout(() => {
this.foo = this.foo + '.';
}, 0);
}

}

2.啟用 prod 模式 ( 因為這個錯只會在開發模式中顯示 )

// main.ts
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app/app.module';
import { enableProdMode } from '@angular/core';


platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.error(err));


enableProdMode();

上述兩個都不是標準的解決方法,因為這個錯的根本原因在於已經執行完變更檢測卻又再次更改已經完成變更檢測的值,實際解決方法應該是去確認是哪個值引起這個錯,而非不讓這個報錯顯示才是

ngOnDestory

觸發時機:組件被銷毀時觸發,僅觸發一次

觸發範例:父組件透過綁定變數於子組件,當變數為 False 時銷毀子組件

// 父元件

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

@Component({
selector: 'app-test-parent',
template: `
<app-test-child *ngIf="showChild"></app-test-child>
<button (click)="destroyChild()">銷毀子組件</button>
`, // 於 app-test-child 中 插入其他組件
styleUrls: ['./test-parent.component.scss']
})
export class TestParentComponent {
showChild = true;

destroyChild() {
this.showChild = false;
}
}

// 子元件

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

@Component({
selector: 'app-test-child',
template: ``,
styleUrls: ['./test-child.component.scss']
})
export class TestChildComponent implements OnDestroy {

ngOnDestroy(): void {
console.log('子組件觸發 ngOnDestroy')
}

}

補充說明:通常會在 ngOnDestory 的地方取消訂閱,避免 memory leak

// 子元件

import { Component, OnDestroy } from '@angular/core';
import { Subject, interval, sample, takeUntil } from 'rxjs';

@Component({
selector: 'app-test-child',
template: `
`,
styleUrls: ['./test-child.component.scss']
})
export class TestChildComponent implements OnDestroy {
destroy$ = new Subject();

constructor() {
const source = interval(1000);
const example = source.pipe(sample(interval(2000)));
example.pipe(takeUntil(this.destroy$)).subscribe(val => console.log(val));
}

ngOnDestroy(): void {
this.destroy$.next(null);
this.destroy$.complete();
}

}

#其他資訊

1.單個元件的生命週期執行順序為何

import { AfterContentChecked, AfterContentInit, AfterViewChecked, AfterViewInit, Component, DoCheck, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core';

@Component({
selector: 'app-ex1',
template: `
<p> value = {{ value }} </p>
<button type="button" (click)="clickHandler()">add</button>
`,
styleUrls: ['./ex1.component.scss']
})
export class Ex1Component implements OnChanges, OnInit, DoCheck, AfterContentInit, AfterContentChecked, AfterViewInit, AfterViewChecked, OnDestroy {

value = 1;
clickHandler() {
this.value++;
}

ngOnChanges(): void {
console.log('ngOnChanges')
}

ngOnInit(): void {
console.log('ngOnInit')
}

ngDoCheck(): void {
console.log('ngDoCheck')
}

ngAfterContentInit(): void {
console.log('ngAfterContentInit')
}

ngAfterContentChecked(): void {
console.log('ngAfterContentChecked')
}

ngAfterViewInit(): void {
console.log('ngAfterViewInit')
}

ngAfterViewChecked(): void {
console.log('ngAfterViewChecked')
}

ngOnDestroy(): void {
console.log('ngOnDestroy')
}

}

當點擊按鈕使值發生改變時,則會多觸發 ngDoCheck , ngAfterContentChecked , ngAfterViewChecked

2.多個元件的生命週期執行順序為何

當元件有多層時, View 與 Content 執行順序為何?父元件若調用 ViewInit,表示視圖已經完成。當父元件的視圖完成,其子元件勢必已經完成,因此子元件會比父元件優先執行 View Hook。Content Hook 則反之。

3.如果在 Hook 中加上 await , 那麼生命週期會被卡住嗎?

我們知道 ngAfterViewInit 會在 OnInit 之後才執行,那麼若是在 OnInit 增加一個 Promise 並 await 直到 resolve ,這樣 ngAfterViewInit 會等 OnInit 執行完畢才開始動作嗎?

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

@Component({
selector: 'app-test',
template: '<span>test</span>',
styleUrls: ['./test.component.scss']
})
export class TestComponent implements OnInit, AfterViewInit {

async ngOnInit(): Promise<void> {
console.log('ngOnInit start');

const rsp = await this.api();
console.log('rsp value:', rsp);

console.log('ngOnInit Finish');
}

ngAfterViewInit(): void {
console.log('ngAfterViewInit start');

console.log('ngAfterViewInit Finish');
}


api() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(true);
}, 2000);
})
}

}

答案是不會,雖然於 ngOnInit 中可以使用 await , 但即便 ngOnInit 尚未執行完畢也不會卡住使 ngAfterViewInit 無法執行。

4.不使用 Implement 時,生命週期的 Hook 還會被觸發嗎?

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

@Component({
selector: 'app-test',
template: '<span>test</span>',
styleUrls: ['./test.component.scss']
})
export class TestComponent {

ngOnInit(): void {
console.log("ngOnInit")
}

}

答案是會,差別在於若有使用 Implement 時,打錯字時會提醒 (大小寫有誤)

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

@Component({
selector: 'app-test',
template: '<span>test</span>',
styleUrls: ['./test.component.scss']
})
export class TestComponent implements OnInit {

ngonInit(): void {
console.log("ngOnInit")
}

}

Class ‘TestComponent’ incorrectly implements interface ‘OnInit’.
Property ‘ngOnInit’ is missing in type ‘TestComponent’ but required in type ‘OnInit’.ts(2420)

index.d.ts(5821, 5): ‘ngOnInit’ is declared here.

--

--