3 種 component 交換資料的方法

若只有一個 component,就沒有交換資料的問題,反正資料都在 class 的 field;但一旦切成多 component 之後,parent component 與 child component 要怎麼交換資料就成為不可避免的課題,本文介紹 3 種方法。

Version


Node.js 8.9.3
Angular CLI 1.6.2
Angular 5.2

User Story


counter000

  • counter 初始值為 3
  • +counter + 1,按 -counter -1

Task


有以下面 3 種實作方式

  • @Input() + @Output() decorator
  • @ViewChild() decorator
  • Observable Data Service

@Input() & @Output() Decorator


Architecture

counter001

  • AppComponent 負責顯示 counterCounterComponent 負責處理 <button/>
  • AppComponent 相當於 parent component,CounterComponent 相當於 child component
  • AppComponent 須向 CounterComponent 傳入 counter 初始值;CounterComponent 須向 AppComponent 傳出最新 counter 結果

Implementation

AppComponent

counter008

app.component.html

1
2
3
<app-counter (counterChange)="onCounterChange($event)" [initialCounter]="initialCounter"></app-counter>
<p></p>
{{ counter }}

AppComponent 透過 InitialCountercounter 的初始值傳進 CounterComponent,另外由 onCounterChange() 接受 CounterComponent 傳出的 counterChange event。

AppComponent 顯示實際的 counter

onCounterChange($event) 的參數一定要用 $event,Angular 底層另有處理

app.component.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
initialCounter = 3;
counter: number = this.initialCounter;

onCounterChange(counter: number) {
this.counter = counter;
}
}

10 行

1
counter: number = this.initialCounter;

counter 的初始值,除了顯示外,也傳進 CounterComponent

12 行

1
2
3
onCounterChange(counter: number) {
this.counter = counter;
}

onCounterChange() 接受 counterChange event,雖然傳進的是 $event,但在 method 內可使用自己容易閱讀的參數。

CounterComponent

counter009

counter.component.html

1
2
<button (click)="onAdd1Click()">+</button>
<button (click)="onMinus1Click()">-</button>

將 2 個 <button/> 封裝在 CounterComponent 內。

counter.component.ts

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
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';

@Component({
selector: 'app-counter',
templateUrl: './counter.component.html',
styleUrls: ['./counter.component.css']
})
export class CounterComponent implements OnInit {
@Input() initialCounter;
@Output() counterChange: EventEmitter<number> = new EventEmitter<number>();
private counter: number;

ngOnInit(): void {
this.counter = this.initialCounter;
}

onAdd1Click() {
this.counter++;
this.counterChange.emit(this.counter);
}

onMinus1Click() {
this.counter--;
this.counterChange.emit(this.counter);
}
}

第 9 行

1
@Input() initialCounter;

將 field 加上 @Input() decorator 後,parent component 就可以透過 @Input() 將資料傳進 child component。

10 行

1
@Output() counterChange: EventEmitter<number> = new EventEmitter<number>();

將 field 加上 @Output() decorator 後,child component 就可透過 event 方式將資料傳到 parent component。

EventEmitter 可接受泛型,將要傳出的資料型別以泛型表示。

13 行

1
2
3
ngOnInit(): void {
this.counter = this.initialCounter;
}

凡透過 @Input() 傳進的資料,必須在 ngOnInit() lifecycle hook 才可抓到資料。

17 行

1
2
3
4
onAdd1Click() {
this.counter++;
this.counterChange.emit(this.counter);
}

onAdd1Click() 接受 DOM 原生的 click event。

透過 EventEmitter.emit() 將資料由 child component 傳到上層的 parent component。

Summary

counter005

  • 當 parent component 要將資料傳給 child component 時,使用 @Input() decorator
  • 當 child component 要將資料傳給 parent component 時,使用 @Output() decorator

@ViewChild() Decorator


Architecture

同樣是 AppComponentCounterComponent,但這次是反過來,將 counter 包進 CounterComponent,卻將 <button/> 留在 AppComponent

counter002

  • AppComponent 負責處理 <button/>CounterComponent 負責顯示 counter
  • AppComponent 相當於 parent component,CounterComponent 相當於 child component
  • AppComponent 直接呼叫 CounterComponent 的 method 改變 counterAppComponent 也可直接讀取 CounterComponent 的 property

Implementation

AppComponent

counter010

app.component.html

1
2
3
4
<button (click)="onAdd1Click()">+</button>
<button (click)="onMinus1Click()">-</button>
<p></p>
<app-counter [initialCounter]="initialCounter"></app-counter>

將 2 個 <button/> 留在 AppComponent 內。

AppComponent 透過 InitialCountercounter 的初始值傳進 CounterComponent,實際的 counter 值將由 CounterComponent 顯示。

app.component.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { AfterViewInit, Component, ViewChild } from '@angular/core';
import { CounterComponent } from './component/counter/counter.component';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements AfterViewInit {
@ViewChild(CounterComponent) counterComponent: CounterComponent;
initialCounter = 3;

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

onAdd1Click() {
this.counterComponent.add1();
}

onMinus1Click() {
this.counterComponent.minus1();
}
}

10 行

1
@ViewChild(CounterComponent) counterComponent: CounterComponent;

Angular 允許我們在 parent component 透過 @ViewChild() decorator 宣告 child component,藉此存取 child component 的 public field 與 method。

@ViewChild() 第一個參數傳入 child component 的型別。

第 9 行

1
2
3
4
5
export class AppComponent implements AfterViewInit {
ngAfterViewInit(): void {
console.log(this.counterComponent.counter);
}
}

若要透過 @ViewChild() 存取 child component 的 public field,則必須在 ngAfterViewInit() lifecycle hook 才可抓的到,不可以在 ngOnInit()

17 行

1
2
3
onAdd1Click() {
this.counterComponent.add1();
}

若要透過 @ViewChild() 存取 child component 的 public method,則無此限制。

CounterComponent

counter011

counter.component.html

1
{{ counter }}

負責顯示 counter

counter.component.ts

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

@Component({
selector: 'app-counter',
templateUrl: './counter.component.html',
styleUrls: ['./counter.component.css']
})
export class CounterComponent implements OnInit {
@Input() initialCounter;
counter: number;

ngOnInit(): void {
this.counter = this.initialCounter;
}

add1() {
this.counter++;
}

minus1() {
this.counter--;
}
}

第 9 行

1
@Input() initialCounter;

將 field 加上 @Input() decorator 後,parent component 就可以透過 @Input() 將資料傳進 child component。

Summary

counter006

  • 當 parent component 要存取 child component 時,使用 @ViewChild() 宣告 child decorator,public field 可在 ngAfterViewInit() 存取,public method 則無此限制

Observable Data Service


一樣維持 AppComponent 包含 <button/>,而 CounterComponent 顯示 counter,但這次將 counter 獨立放在 CounterService 內。

Architecture

counter003

  • AppComponent 負責處理 <button/>CounterComponent 負責顯示 counter
  • AppComponent 相當於 parent component,CounterComponent 相當於 child component
  • counter 變數改放在 CounterServiceBehaviorSubject,再以 Observable 型態給其他 component 訂閱
    counter004
  • 根據 依賴反轉原則,component 不直接相依於 CounterService,而是兩者相依於 interface
  • 根據 介面隔離原則,component 只相依於它所需要的 interface,因此 AppComponent 訂出 InitialCounterInterface,而 CounterComponent 訂出 ChangeCounterInterfaceCounterService 必須實踐此 2 個 interface
  • AppComponentCounterComponentCounterService 都只相依於 InitialCounterInterfaceChangeCounterInterface,如此 component 與 service 將徹底解耦合
  • CounterComponent 只負責訂閱 CounterService;當資料改變時,CounterService 會自動更新 CounterComponent

Implementation

AppComponent

counter012

app.component.html

1
2
3
4
<button (click)="onAdd1Click()">+</button>
<button (click)="onMinus1Click()">-</button>
<p></p>
<app-counter [initialCounter]="initialCounter"></app-counter>

將 2 個 <button/> 留在 AppComponent 內。

AppComponent 透過 InitialCountercounter 的初始值傳進 CounterComponent,實際的 counter 值將由 CounterComponent 顯示

app.component.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { Component } from '@angular/core';
import { ChangeCounterInterface } from './interface/change-counter.interface';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
initialCounter = 3;

constructor(private counterService: ChangeCounterInterface) {
}

onAdd1Click() {
this.counterService.add1();
}

onMinus1Click() {
this.counterService.minus1();
}
}

15 行

1
2
3
onAdd1Click() {
this.counterService.add1();
}

使用 CounterService 提供的 add1()counter + 1。

12 行

1
2
constructor(private counterService: ChangeCounterInterface) {
}

因為 onAdd1Click()onMinus1Click() 都使用了 CounterService,因此需要 DI 注入 CounterService

根據 依賴反轉原則,我們不應該直接相依於 CounterService,而應該相依於根據 ApppComponent 需求所定義出的 ChangeCounterInterface

change.counter.interface

1
2
3
4
export abstract class ChangeCounterInterface {
abstract add1(): void;
abstract minus1(): void;
}

根據 介面隔離原則,因為 AppComponent 只使用了 add1()minus1(),因此 ChangeCounterInterface 也應該只有 add1()minus1()

CounterComponent

counter013

counter.component.html

1
{{ counter$ | async }}

只負責顯示 counter$ 的 component。

counter.component.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { Component, Input, OnInit } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { InitialCounterInterface } from '../../interface/initial-counter.interface';

@Component({
selector: 'app-counter',
templateUrl: './counter.component.html',
styleUrls: ['./counter.component.css']
})
export class CounterComponent implements OnInit {
@Input() initialCounter;
counter$: Observable<number> = this.counterService.counter$;

constructor(private counterService: InitialCounterInterface) {
}

ngOnInit(): void {
this.counterService.setInitialCounter(this.initialCounter);
}
}

12 行

1
counter$: Observable<number> = this.counterService.counter$;

counter$Observable,直接使用 CounterService.counter$ property。

17 行

1
2
3
ngOnInit(): void {
this.counterService.setInitialCounter(this.initialCounter);
}

CounterService.setInitialCounter() 設定 counter 的初始值。

14 行

1
2
constructor(private counterService: InitialCounterInterface) {
}

因為 ngOnInit() 使用了 CounterService,因此需要 DI 注入 CounterService

根據 依賴反轉原則,我們不應該直接相依於 CounterService,而應該相依於根據 CounterComponent 需求所定義出的 InitialCounterInterface

initial-counter.interface.ts

1
2
3
4
5
6
import { Observable } from 'rxjs/Observable';

export abstract class InitialCounterInterface {
abstract counter$: Observable<number>;
abstract setInitialCounter(initialCounter: any): void;
}

根據 介面隔離原則,因為 CounterComponent 只使用了 counter$setInitialCounter(),因此 ShowCounterInterface 也應該只有 counter$setInitialCounter()

CounterService

counter014

counter.service.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { Observable } from 'rxjs/Observable';
import { InitialCounterInterface } from '../interface/initial-counter.interface';
import { ChangeCounterInterface } from '../interface/change-counter.interface';

@Injectable()
export class CounterService implements InitialCounterInterface, ChangeCounterInterface {
private counterStore$: BehaviorSubject<number> = new BehaviorSubject<number>(0);
counter$: Observable<number> = this.counterStore$;

setInitialCounter(initialCounter: number) {
this.counterStore$.next(initialCounter);
}

add1() {
this.counterStore$.next(this.counterStore$.getValue() + 1);
}

minus1() {
this.counterStore$.next(this.counterStore$.getValue() - 1);
}
}

第 8 行

1
export class CounterService implements InitialCounterInterface, ChangeCounterInterface

根據 介面隔離原則,我們依照 component 需求訂出 InitialCounterInterfaceChangeCounterInterface,因此 CounterService 必須概括承受實現這些 interface。

第 9 行

1
private counterStore$: BehaviorSubject<number> = new BehaviorSubject<number>(0);

使用 BehaviorSubject 當作 CounterService 內部的資料庫,儲存共用的 counter

12 行

1
2
3
setInitialCounter(initialCounter: number) {
this.counterStore$.next(initialCounter);
}

使用 BehaviorSubject.next() 儲存 counter 的初始值。

16 行

1
2
3
add1() {
this.counterStore$.next(this.counterStore$.getValue() + 1);
}

使用 this.counterStore$.getValue() 獲得目前 counterStore$ 最新一筆資料,也就是目前的 counter 值,然後 +1

最後使用 this.counterStore$.next() 將最新的 counter 寫入 BehaviorSubject

10 行

1
counter$: Observable<number> = this.counterStore$;

已經都自己維護一份 BehaviorSubjectcounterStore$,我們希望當 BehaviorSubject 使用 next() 改變資料時,能通知所有 subscribe() 的 component 都能自動更新,這也是我們使用 Observable Data Service 的初衷。

既然 BehaviorSubject 繼承於 Observable,理論上我們也可直接將 store$ 設定為 public,直接被 component subscribe(),但因為 BehaviorSubject 提供 next(),因此也可能被 component 誤用 next() 而新增資料,因此我們改用真的 Observable 給 component 訂閱,確保 store$ 不會被 component 新增資料。

由於 store$ 也是一種 Observable,根據 里式替換原則,父類別可被子類別取代,因此不需要特別傳型。

AppModule

app.module.ts

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
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { CounterComponent } from './component/counter/counter.component';
import { CounterService } from './service/counter.service';
import { InitialCounterInterface } from './interface/initial-counter.interface';
import { ChangeCounterInterface } from './interface/change-counter.interface';

@NgModule({
declarations: [
AppComponent,
CounterComponent
],
imports: [
BrowserModule
],
providers: [
CounterService,
{provide: InitialCounterInterface, useExisting: CounterService},
{provide: ChangeCounterInterface, useExisting: CounterService}
],
bootstrap: [AppComponent]
})
export class AppModule {
}

由於我們 component 與 service 都是基於 依賴反轉原則介面隔離原則 所設計,因此必須藉由 DI container 幫我們將相對應的 service 注入。

18 行

1
2
3
CounterService,
{provide: InitialCounterInterface, useExisting: CounterService},
{provide: ChangeCounterInterface, useExisting: CounterService}

一般我們使用普通 service 時,都是每個 component 注入新的 service,但使用 Observable Data Service 時不可如此,因為我們就是希望各 component 共用一份資料,只要在 provider 使用 useExisting,Angular DI container 就會以 Singleton 方式建立 service,各 component 才能共用同一份 counterStore$

Summary

counter007

  • 當 component 之間有 parent / child 架構時,尚有 @Input()@Output()@ViewChild() 等方法可用;但當 component 之間為 sibling 架構時,就只剩下 Observable Data Service 可用
  • Parent / child 架構算 sibling 架構的特例,當然也可以使用 Observable Data Service
  • 當 sibling component 越多,越適合 Observable Data Service

Conclusion


  • @Input() & @Output() : component 之間的資料是靠傳遞的,透過 @Input() 傳入,透過 @Output() 傳出;若 child component 的改變必須及時通知 parent component,則適合 @Output()
  • @ViewChild() : 由 parent component 直接存取 child component 的資料;若 child component 不會改變,則適合 @ViewChild()
  • Observable Data Service : 將 component 相關的資料獨立成 service,DI 注入到 component,透過 RxJS,只要訂閱 component,將來資料若異動會自動更新;若 component 之間的互動與資料關係複雜,則適合 Observable Data Service

Sample Code


完整的範例可以在我的 GitHub上找到

Reference


Angular, Component Interaction

2018-01-14