[從 0 開始的 Angular 生活]No.31 實作單向資料流與不可變的物件

前言

上一篇解釋了什麼是單向資料流以及不可變的物件,這一篇要透過實作來了解。

需求描述

我想要實作一個修改文章標題的功能

  • 在 ArticleHeader 內新增一個按鈕叫做編輯標題
    • 按下後標題顯示輸入框供使用者輸入新標題,按下 Enter 確定修改;按下 ESC 則退出

了解需求後立馬動手吧~

先從 UI 開始

因為這個需求所以要調整 ArticleHeader 元件下的 Template 以及 class ,順便移除掉一些不相干的東西讓這個範例看起來更單純。

ArticleHeader Template 的調整

  • 新增編輯標題按鈕、取消編輯按鈕並加上點擊事件
  • 把之前的 PipeComponent 給取消
  • 重新調整一下文章的資料格式,讓範例更簡單易懂
    • subject 那一層拿掉、刪除 subtitle
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<header class="post-header">
<h2 class="post-title" >
<a [href]="item.href">{{ item.title}}</a>
<input type="text" size="70" [value]="item.title">
</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 (click)="">編輯標題</button>
<button (click)="">取消編輯</button>
</span>
</header>

現在網頁呈現的畫面是這樣的:

邏輯是這樣的:

  • 使用結構型指令 ngIf 控制標題與輸入框以及編輯與取消按鈕的顯示
  • 當使用者點擊編輯標題按鈕時修改 isEdit 的值為 True 、點擊取消編輯按鈕時 isEdit 的值為 False

為了做到這些事情,所以必須先到 ArticleHeader class 進行調整

ArticleHeader class 的調整

這裡需要做的調整如下:

  • 新增屬性 isEdit 用來判斷是不是處於編輯模式,預設為 False
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Component, OnInit, Input, Output, EventEmitter} from '@angular/core';

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

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

isEdit = false;
constructor() { }
ngOnInit() {
}
}

回到 Template 補上 ngIf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<header class="post-header">
<h2 class="post-title" >
<a *ngIf="!isEdit" [href]="item.href">{{ item.title}}</a>
<input *ngIf="isEdit" type="text" size="70" [value]="item.title">
</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>

測試一下目前的邏輯是否正確

非編輯模式

編輯模式

實作編輯文章標題

這邊有個一個簡單但不太建議的做法:

  • 直接在 input 上使用雙向繫結綁定 item.title

這麼做會直接把修改的內容直接寫回去,而需求瞬間就完成了。

但伴隨而來的缺點是

  • 會讓子元件與父元件間相依性太重
  • 因為是雙向繫結,一但修改後是沒辦法透過取消編輯取消的,會直接修改到原始的資料。

所以這個做法肯定是不太行的,得換個方式。

比較好的方式 - 透過單向資料流與不可變的物件方式

  • 在 ArticleHeader Template 的 input 上建立二個 Keyup 事件,並傳入參數 $event
    • 當使用者按下 Enter 時觸發 editTitle 方法
    • 當使用者按下 ESC 時觸發 exitEdit 方法
  • 在 ArticleHeader class 內建立一個屬性值 newTitle
  • 進行 ngOnInit() 時將 newTitle 賦值為 item.title
  • input 上使用屬性繫結 value 的值改用 newTitle
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" [value]="newTitle"
(keyup.enter)="editTitle($event)" (keyup.escape)="editExit($event)">
</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>
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
import { Component, OnInit, Input, Output, EventEmitter} from '@angular/core';

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

@Output()
delete = new EventEmitter<any>();
isEdit = false;
newTitle = '';
constructor() { }

ngOnInit() {
this.newTitle = this.item.title;
}
deleteArticle() {
this.delete.emit(this.item);
}
editTitle(e) {
console.log('editTitle', e.target);
this.newTitle = e.target.value;
}
editExit(e) {
console.log('editExit', e.target);
e.target.value = this.item.title;
this.isEdit = false;
}
}
  • 當觸發 editTitle 方法時,會將 input 內的 value 值取出並賦值給 newTitle ,等待傳送給父元件
  • 當觸發 editExit 方法時,將原始資料塞回 input 內的 value

前置作業都做完了,剩下就是通知父元件 ArticleList 囉!

  • 建立一個新的 @Output() 並宣告一個 titleChange 的事件
  • 當觸發 editTitle 方法時,使用 emit 發射要變更的資料給父元件
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
import { Component, OnInit, Input, Output, EventEmitter} from '@angular/core';

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

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

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

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

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

接下來就是到父元件上設定接收並做出實際的改變啦~

  • 事件繫結 changeTitle 並且當這個事件被觸發時執行 doChange() 方法,使用 $event 參數接收子元件送來的資料

ArticleList Template

1
2
3
4
5
6
<!-- Article START-->
<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"></app-article-body>
</article>
<!-- Article END-->

到了最後這個步驟了,為了讓底下的所有子元件都知道資料物件發生改變,所以 atticleData 屬性指向的陣列要重新建立,而陣列裡面的物件如果裡面的屬性有發生修改,也要重新建立。

ArticleList class

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 } from '@angular/core';

@Component({
selector: 'app-article-list',
templateUrl: './article-list.component.html',
styleUrls: ['./article-list.component.scss']
})
export class ArticleListComponent implements OnInit {
atticleData: Array<any>;
constructor() { }
ngOnInit() {
/* tslint:disable */
this.atticleData = [
{
"id": 1,
"href": "http://blog.miniasp.com/post/2016/04/30/Visual-Studio-Code-from-Command-Prompt-notes.aspx",
"title": "從命令提示字元中開啟 Visual Studio Code 如何避免顯示惱人的偵錯訊息",
"date": "2016/04/30 18:05",
"author": "GHJKL",
"category": "Visual Studio",
"category-link": "http://blog.miniasp.com/category/Visual-Studio.aspx",
"summary": "<p>由於我的 Visual Studio Code 大部分時候都是在命令提示字元下啟動,所以只要用 <strong><font color='#ff0000' face='Consolas'>code .</font></strong>就可以快速啟動 Visual Studio Code 並自動開啟目前所在資料夾。不過不知道從哪個版本開始,我在啟動 Visual Studio Code 之後,卻開始在原本所在的命令提示字元視窗中出現一堆惱人的偵錯訊息,本篇文章試圖解析這個現象,並提出解決辦法。</p><p>... <a class='more' href='http://blog.miniasp.com/post/2016/04/30/Visual-Studio-Code-from-Command-Prompt-notes.aspx#continue'>繼續閱讀</a>...</p>"
},
...以下省略
];
}
doDelete(item){
this.atticleData = this.atticleData.filter(v => v !== item);
}
doChange($event: any){
this.atticleData = this.atticleData.map((item)=>{
if($event.id === item.id) {
// 不要這樣寫 item.title = $event.title;
// 當屬性被改動時,要建立新的物件
return Object.assign({}, item, $event);
}
return item;
});
}
}

  • 為了讓陣列重新建立,使用 ES6 的陣列方法 map ,它會回傳一個新的陣列
  • 值得注意的是,當進入 if 判斷時,不可以直接寫 item.title = $event.title 這樣就落入之前說的陷阱了
    • 正確應該使用 Object.assign 方法,回傳一個新的物件並且合併 item 以及 $event

根據 MDN 的解釋,使用 Object.assign 方法時,如果在目標物件裡的屬性名稱 (key) 和來源物件的屬性名稱相同,將會被覆寫。若來源物件之間又有相同的屬性名稱,則後者會將前者覆寫。

都完成了,來測試看看吧!

編輯中

無法順利變更標題

糟糕程式好像出了點問題,加上幾行 console.log 觀察看看吧。

原來問題是沒有正確合併,因位子元件傳送給父元件的資料物件內的屬性名稱要與標題的屬性名稱一致才會覆蓋,因此必須回去修改。

1
2
3
4
5
editTitle(e) {
console.log('editTitle', e.target);
this.newTitle = e.target.value;
this.changeTitle.emit({ title: this.newTitle, id: this.item.id });
}

重新測試!

修改成功

小結

這個範例主要是練習實作一個稍微複雜一點的單向資料流與不可變物件特性的開發技巧,透過這個練習未來在開發多個元件時,可以有效降低元件與元件間的相依性也提升可維護性。

看來有空必須要常常回來這篇文章複習哩~

0%