[從 0 開始的 Angular 生活]No.32 介紹 ngOnChanges 生命週期 Hook

前言

之前曾經介紹到 Angular 元件生命週期的 Hook分別是 ngOnInit 與 ngOnDestroy ,在那篇文章內曾經說過元件被實體化的過程,第一個先執行的是建構式 constructor ,也提到盡量不要再建構式裡面寫程式碼。這次要介紹的式另一個生命週期的 Hook - ngOnChanges 。

ngOnChanges() 、 constructor() 、 ngOnInit()

在介紹之前,來一張元件生命週期的圖表:

ngOnChanges() 與 constructor() 、 ngOnInit() 之間的執行順序是如何,可以很容易地從這張圖表上看出來。

但秉持著實踐精神,我們還是實際寫一些程式測試看看~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { Component, OnInit, Input, OnChanges } from '@angular/core';

@Component({
selector: 'app-article-body',
templateUrl: './article-body.component.html',
styleUrls: ['./article-body.component.scss']
})
export class ArticleBodyComponent implements OnInit, OnChanges {
@Input()
item;
constructor() {
console.log('ArticleBodyComponent: constructor');
}

ngOnInit() {
console.log('ArticleBodyComponent: ngOnInit');
}

ngOnChanges() {
console.log('ArticleBodyComponent: ngOnChanges');
}
}

因為有跑 ngFor 而文章資料總共有 6 筆,因此產生 6 個 ArticleBody 元件。

  • 可以從這裡觀察出無論如何一定會先執行 constructor() ,但執行過程中元件還沒被初始化。
    • 此時元件內什麼東西都沒有,也沒有透過屬性繫結傳進來的資料。
  • 緊接著執行 ngOnChanges() 然後才是 ngOnInit()

這就是它們的執行順序。

什麼是 ngOnChanges()

ngOnChanges() 觸發的時機點

  • 元件裡面的屬性如果有套用 @input() ,只要該元件的父元件透過屬性繫結傳資料進來的話, ngOnChanges() 就會被觸發

驗證 ngOnChanges() 觸發的時機點

  • 在 ArticleBody 元件內新增一個屬性 counter
    • counter 值會從父元件透過屬性繫結傳入
    • counter 發生改變時 ngOnChanges() 就會觸發,由此驗證

ArticleBody

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export class ArticleBodyComponent implements OnInit, OnChanges {
@Input()
item;

@Input()
counter;

constructor() {
console.log('ArticleBodyComponent: constructor');
}

ngOnInit() {
console.log(`ArticleBodyComponent ${this.item.id} : ngOnInit`);
}

ngOnChanges() {
console.log(`ArticleBodyComponent ${this.item.id} : ngOnChanges`);
}
}

1
2
3
4
<article class="post" id="post{{idx}}" *ngFor="let item of atticleData; let idx = index">
<app-article-header [item]="item" (delete)="doDelete($event)" (changeTitle)="doChange($event)"></app-article-header>
<app-article-body [item]="item" [counter]="counter"></app-article-body>
</article>

ArticleList

1
2
3
4
5
6
7
8
9
10
export class ArticleListComponent implements OnInit {
atticleData: Array<any>;
counter = 0;
constructor() { }
ngOnInit() {
setTimeout(() => {
this.counter++;
}, 2000);
}
}

為了方便所以補上 item.id ,接著運行開發伺服器,觀察 console.log 變化:

重新整理

兩秒後

由此可知,在兩秒後六個不同的 ArticleBody 元件都觸發了 ngOnChanges() ,證明了只有在屬性繫結傳入資料發生改變時才會觸發 ngOnChanges() 。

ngOnChanges() 參數的作用

另外 ngOnChanges() 可以傳入一個參數,比較傳入前與傳入後的資料:

1
2
3
4
ngOnChanges(changes) {
console.log(`ArticleBodyComponent ${this.item.id} : ngOnChanges`);
console.log(changes);
}

發現這個 changes 接收到一個叫物件,點開後會發現裡面還有一層以 counter 屬性命名的物件:

  • 該物件的型別為 SimpleChange
  • 代表當前值為多少的屬性 currentValue
  • 代表前一個值為多少的屬性 previousValue
  • firstChange 是不是第一次發生改變
    • 目前已經是第二次改變了,所以是 false
    • 第一次改變發生於重新整理時那一次的 ngOnChanges()

透過這個例子得知可以利用這個參數在觸發 ngOnChanges() 時多做一些額外的判斷。

ngOnChanges() 實務應用

前面幾篇文章提到,建議不要在子元件上直接使用雙向繫結修改傳入的資料,因為這樣會直接的修改到原始資料。

但現在我們懂得使用 ngOnChanges() 這個 Hook ,因此可以針對這個部分對整個程式碼進行重構。

ActicleHeader

切回 ActicleHeader 元件觀察:

  • 目前有一個 @input() 綁定了 item 屬性
    • 所以當接到 item 資料時 ngOnChanges() 會被觸發
  • 透過 ngOnChanges(changes) ,當接收到值時建立一份與原始資料不同的新物件
    • 如此一來就可以放心地使用雙向繫結,也不怕修改到原始資料
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import { Component, OnInit, Input, Output, EventEmitter, OnChanges} from '@angular/core';

@Component({
selector: 'app-article-header',
templateUrl: './article-header.component.html',
styleUrls: ['./article-header.component.scss']
})
export class ArticleHeaderComponent implements OnInit, OnChanges {
@Input()
item;

@Output()
delete = new EventEmitter<any>();

@Output()
changeTitle = new EventEmitter<any>();

isEdit = false;
newTitle = '';
constructor() { }

ngOnInit() {
this.newTitle = this.item.title;
}
ngOnChanges(changes) {
if (changes.item) {
this.item = Object.assign({}, changes.item.currenValue);
}
}

deleteArticle() {
this.delete.emit(this.item);
}
editTitle(e) {
console.log('editTitle', e.target);
this.newTitle = e.target.value;
this.changeTitle.emit({ title: this.newTitle, id: this.item.id });
}
editExit(e) {
console.log('editExit', e.target);
e.target.value = this.item.title;
this.isEdit = false;
}
}

接著修改 Template

  • 在 Input 內補上雙向繫結
    • 因為改為使用雙向繫結 editTitle()editExit() 不再需要傳入 $event 參數
  • 調整取消編輯上的點擊事件,當點擊時觸發 editExit()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<header class="post-header">
<h2 class="post-title" >
<a *ngIf="!isEdit" [href]="item.href">{{ item.title }}</a>
<input *ngIf="isEdit" type="text" size="70" [(ngModel)]="item.title"
(keyup.enter)="editTitle()" (keyup.escape)="editExit()">
</h2>
<div class="post-info clearfix">
<span class="post-date"><i class="glyphicon glyphicon-calendar"></i>{{item.date|date:'yyyy-MM-dd HH:mm'}}</span>
<span class="post-author"><i class="glyphicon glyphicon-user"></i><a
href="http://blog.miniasp.com/author/will.aspx">{{item.author}}</a></span>
<span class="post-category"><i class="glyphicon glyphicon-folder-close"></i><a
[href]="item['category-link']">{{item.category}}</a></span>
</div>
<span>
<button (click)="deleteArticle()">刪除</button>
<button *ngIf="!isEdit" (click)="isEdit=true">編輯標題</button>
<button *ngIf="isEdit" (click)="isEdit=false">取消編輯</button>
</span>
</header>

因為調整了 Template ,所以 class 內的方法也需要跟著調整。

邏輯部分

  • 新增了一個屬性 originData 用途是保存原始傳入的資料,用於取消編輯時使用
    • 並於 ngOnChanges() 觸發時將原始資料賦值給 originData
      • 特別注意這裡不可以寫 this.originData = this.item; 因為此時還沒有 item
  • 刪除不再需要的屬性 newTitle
  • 調整 editTitle() ,此時發射的 this.item 已經不是原始資料了
  • 調整 editExit() ,實作不可變物件特性,使用原始資料重新建立新物件還原
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import { Component, OnInit, Input, Output, EventEmitter, OnChanges} from '@angular/core';

@Component({
selector: 'app-article-header',
templateUrl: './article-header.component.html',
styleUrls: ['./article-header.component.scss']
})
export class ArticleHeaderComponent implements OnInit, OnChanges {
@Input()
item;

@Output()
delete = new EventEmitter<any>();

@Output()
changeTitle = new EventEmitter<any>();
originData;
isEdit = false;
constructor() { }

ngOnInit() {
}
ngOnChanges(changes) {
if (changes.item) {
this.originData = changes.item.currentValue;
this.item = Object.assign({}, changes.item.currentValue);
}
}

deleteArticle() {
this.delete.emit(this.item);
}
editTitle() {
this.changeTitle.emit(this.item);
}
editExit() {
this.item = Object.assign({}, this.originData);
this.isEdit = false;
}
}

現在 ArticleHeader 這個元件邏輯已經完成,可以測試一下。

編輯成功

編輯中

取消編輯

  • 倘若這個步驟沒辦法完成,可能是雙向繫結出了問題
    • 應檢查是否有在 Article 功能模組內匯入 FormsModule ,需匯入才可在 input 上使用雙向繫結

至此程式的運作都如預期,唯獨刪除功能出狀況了,所以我們到 ArticleList 父元件調整一下程式碼。

ArticleList

  • 造成刪除失敗的原因是,現在的 item 物件已經不是原本的 item 物件了
    • 而任意兩個物件在 JavaScript 中相比都是 False ,因此刪除導致失敗
    • 修正方法為改採 item.id 比較即可

因此程式碼修改如下:

1
2
3
doDelete(item){
this.atticleData = this.atticleData.filter(v => v.id !== item.id);
}

測試看看是否如我們預期。

小結

透過 ngOnChanges() 的特性在屬性繫結的階段時,讓 item 屬性變成是一個「不可變的物件」,因為只是有任何新的資料進來,馬上就會重新產生新的物件,並且賦值給 item ,藉由這種方式讓 item 值與原本傳進子物件的 item 值得以脫鉤。

脫鉤後就可以在 Template 內放心地使用雙向繫結而不會影響到父物件內的原始資料,這麼做也確保了修改 item 屬性時不會一併的修改到其他元件內 item 屬性的內容。

0%