若只有一個 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
-
counter
初始值為3
- 按
+
則counter
+ 1,按-
則counter
-1
Task
有以下面 3 種實作方式
-
@Input()
+@Output()
decorator -
@ViewChild()
decorator - Observable Data Service
@Input() & @Output() Decorator
Architecture
-
AppComponent
負責顯示counter
;CounterComponent
負責處理<button/>
-
AppComponent
相當於 parent component,CounterComponent
相當於 child component -
AppComponent
須向CounterComponent
傳入 counter 初始值;CounterComponent
須向AppComponent
傳出最新 counter 結果
Implementation
AppComponent
app.component.html
<app-counter (counterChange)="onCounterChange($event)" [initialCounter]="initialCounter"></app-counter> <p></p> {{ counter }}
AppComponent
透過 InitialCounter
將 counter
的初始值傳進 CounterComponent
,另外由 onCounterChange()
接受 CounterComponent
傳出的 counterChange
event。
由 AppComponent
顯示實際的 counter
。
onCounterChange($event)
的參數一定要用 $event
,Angular 底層另有處理
app.component.ts
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 行
counter: number = this.initialCounter;
counter
的初始值,除了顯示外,也傳進 CounterComponent
。
12 行
onCounterChange(counter: number) { this.counter = counter; }
由 onCounterChange()
接受 counterChange
event,雖然傳進的是 $event
,但在 method 內可使用自己容易閱讀的參數。
CounterComponent
counter.component.html
<button (click)="onAdd1Click()">+</button> <button (click)="onMinus1Click()">-</button>
將 2 個 <button/>
封裝在 CounterComponent
內。
counter.component.ts
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 行
@Input() initialCounter;
將 field 加上 @Input()
decorator 後,parent component 就可以透過 @Input()
將資料傳進 child component。
10 行
@Output() counterChange: EventEmitter<number> = new EventEmitter<number>();
將 field 加上 @Output()
decorator 後,child component 就可透過 event 方式將資料傳到 parent component。
EventEmitter
可接受泛型,將要傳出的資料型別以泛型表示。
13 行
ngOnInit(): void { this.counter = this.initialCounter; }
凡透過 @Input()
傳進的資料,必須在 ngOnInit()
lifecycle hook 才可抓到資料。
17 行
onAdd1Click() { this.counter++; this.counterChange.emit(this.counter); }
onAdd1Click()
接受 DOM 原生的 click
event。
透過 EventEmitter.emit()
將資料由 child component 傳到上層的 parent component。
Summary
- 當 parent component 要將資料傳給 child component 時,使用
@Input()
decorator - 當 child component 要將資料傳給 parent component 時,使用
@Output()
decorator
@ViewChild() Decorator
Architecture
同樣是 AppComponent
與 CounterComponent
,但這次是反過來,將 counter
包進 CounterComponent
,卻將 <button/>
留在 AppComponent
。
-
AppComponent
負責處理<button/>
;CounterComponent
負責顯示counter
-
AppComponent
相當於 parent component,CounterComponent
相當於 child component -
AppComponent
直接呼叫CounterComponent
的 method 改變counter
;AppComponent
也可直接讀取CounterComponent
的 property
Implementation
AppComponent
app.component.html
<button (click)="onAdd1Click()">+</button> <button (click)="onMinus1Click()">-</button> <p></p> <app-counter [initialCounter]="initialCounter"></app-counter>
將 2 個 <button/>
留在 AppComponent
內。
AppComponent
透過 InitialCounter
將 counter
的初始值傳進 CounterComponent
,實際的 counter
值將由 CounterComponent
顯示。
app.component.ts
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 行
@ViewChild(CounterComponent) counterComponent: CounterComponent;
Angular 允許我們在 parent component 透過 @ViewChild()
decorator 宣告 child component,藉此存取 child component 的 public field 與 method。
@ViewChild()
第一個參數傳入 child component 的型別。
第 9 行
export class AppComponent implements AfterViewInit { ngAfterViewInit(): void { console.log(this.counterComponent.counter); } }
若要透過 @ViewChild()
存取 child component 的 public field,則必須在 ngAfterViewInit()
lifecycle hook 才可抓的到,不可以在 ngOnInit()
。
17 行
onAdd1Click() { this.counterComponent.add1(); }
若要透過 @ViewChild()
存取 child component 的 public method,則無此限制。
CounterComponent
counter.component.html
{{ counter }}
負責顯示 counter
。
counter.component.ts
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 行
@Input() initialCounter;
將 field 加上 @Input()
decorator 後,parent component 就可以透過 @Input()
將資料傳進 child component。
Summary
- 當 parent component 要存取 child component 時,使用
@ViewChild()
宣告 child decorator,public field 可在ngAfterViewInit()
存取,public method 則無此限制
Observable Data Service
一樣維持 AppComponent
包含 <button/>
,而 CounterComponent
顯示 counter
,但這次將 counter
獨立放在 CounterService
內。
Architecture
-
AppComponent
負責處理<button/>
;CounterComponent
負責顯示counter
-
AppComponent
相當於 parent component,CounterComponent
相當於 child component - 將
counter
變數改放在CounterService
的BehaviorSubject
,再以Observable
型態給其他 component 訂閱
- 根據
依賴反轉原則
,component 不直接相依於CounterService
,而是兩者相依於 interface - 根據
介面隔離原則
,component 只相依於它所需要的 interface,因此AppComponent
訂出InitialCounterInterface
,而CounterComponent
訂出ChangeCounterInterface
,CounterService
必須實踐此 2 個 interface -
AppComponent
、CounterComponent
與CounterService
都只相依於InitialCounterInterface
與ChangeCounterInterface
,如此 component 與 service 將徹底解耦合 -
CounterComponent
只負責訂閱CounterService
;當資料改變時,CounterService
會自動更新CounterComponent
Implementation
AppComponent
app.component.html
<button (click)="onAdd1Click()">+</button> <button (click)="onMinus1Click()">-</button> <p></p> <app-counter [initialCounter]="initialCounter"></app-counter>
將 2 個 <button/>
留在 AppComponent
內。
AppComponent
透過 InitialCounter
將 counter
的初始值傳進 CounterComponent
,實際的 counter
值將由 CounterComponent
顯示
app.component.ts
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 行
onAdd1Click() { this.counterService.add1(); }
使用 CounterService
提供的 add1()
將 counter
+ 1。
12 行
constructor(private counterService: ChangeCounterInterface) { }
因為 onAdd1Click()
與 onMinus1Click()
都使用了 CounterService
,因此需要 DI 注入 CounterService
。
根據 依賴反轉原則
,我們不應該直接相依於 CounterService
,而應該相依於根據 ApppComponent
需求所定義出的 ChangeCounterInterface
。
change.counter.interface
export abstract class ChangeCounterInterface { abstract add1(): void; abstract minus1(): void; }
根據 介面隔離原則
,因為 AppComponent
只使用了 add1()
與 minus1()
,因此 ChangeCounterInterface
也應該只有 add1()
與 minus1()
。
CounterComponent
counter.component.html
{{ counter$ | async }}
只負責顯示 counter$
的 component。
counter.component.ts
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 行
counter$: Observable<number> = this.counterService.counter$;
counter$
為 Observable
,直接使用 CounterService.counter$
property。
17 行
ngOnInit(): void { this.counterService.setInitialCounter(this.initialCounter); }
由 CounterService.setInitialCounter()
設定 counter
的初始值。
14 行
constructor(private counterService: InitialCounterInterface) { }
因為 ngOnInit()
使用了 CounterService
,因此需要 DI 注入 CounterService
。
根據 依賴反轉原則
,我們不應該直接相依於 CounterService
,而應該相依於根據 CounterComponent
需求所定義出的 InitialCounterInterface
。
initial-counter.interface.ts
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
counter.service.ts
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 行
export class CounterService implements InitialCounterInterface, ChangeCounterInterface
根據 介面隔離原則
,我們依照 component 需求訂出 InitialCounterInterface
與 ChangeCounterInterface
,因此 CounterService
必須概括承受實現這些 interface。
第 9 行
private counterStore$: BehaviorSubject<number> = new BehaviorSubject<number>(0);
使用 BehaviorSubject
當作 CounterService
內部的資料庫,儲存共用的 counter
。
12 行
setInitialCounter(initialCounter: number) { this.counterStore$.next(initialCounter); }
使用 BehaviorSubject.next()
儲存 counter
的初始值。
16 行
add1() { this.counterStore$.next(this.counterStore$.getValue() + 1); }
使用 this.counterStore$.getValue()
獲得目前 counterStore$
最新一筆資料,也就是目前的 counter 值,然後 +1
。
最後使用 this.counterStore$.next()
將最新的 counter 寫入 BehaviorSubject
。
10 行
counter$: Observable<number> = this.counterStore$;
已經都自己維護一份 BehaviorSubject
的 counterStore$
,我們希望當 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
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 行
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
- 當 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
注:本文内容来自互联网,旨在为开发者提供分享、交流的平台。如有涉及文章版权等事宜,请你联系站长进行处理。