如何使用 Observable Data Service ?
當 HTML 都 component 化之後,看似美好,但卻也導出另外一個棘手的問題 : component 間共用的資料該如何處理?
React 是以 Redux 來解決這個問題,當然在 Angular 也可使用類似 Redux 的 NgRx,但 Redux 稍嫌複雜,而 Observable Data Service 較為簡單。顧名思義,Observable
採用的是 RxJS,而 Data Service
採用是 DI。
Version
Node.js 8.9.3
Angular CLI 1.6.2
Angular 5.1.2
RxJS 5.5.2
User Story
- 一開始會顯示所有 post
- 當輸入 post 名稱,按下
Add Post
後,新增的 post 會顯示在下方 - 顯示目前 post 筆數
- 按下
Reload
會重新從 API server 下載最新資料
Task
- 原來該有的功能必須保留,但拆成 4 個 component
- 但 4 個 component 會共用一份資料,且互相影響
Architecture
Observable Data Service vs. 普通 Service
相同點 :
- 兩者本質上都是 service
- 都使用
@Injectable
decorator - 都是回傳
Observable
相異點 :
- Observable data service 內部會使用
BehaviorSubject
儲存一份資料;而普通 service 會直接回傳HttpClient
的Observable
- 原本只有
AppComponent
,現在分成AddPostComponent
、ListPostsComponent
、ShowCountComponent
與ReloadPostsComponent
4 個 component - Component 依然注入
PostService
,不過此時不是普通 service,而是 observable data service,為 4 個 component 共用的資料 - 根據
依賴反轉原則
,component 不直接相依於PostService
,而是兩者相依於 interface,根據介面隔離原則
,component 只相依於它所需要的 interface,因此訂出IAddPost
、IListPosts
、IShowCount
與IReloadPosts
4 個以 component 為導向的 interface,PostService
必須實踐此 4 個 interface - Component 只負責 subscribe
PostService
,當資料改變時,PostService
會自動更新 component
Implementation
AppComponent
app.component.html
1 | <app-add-post></app-add-post> |
原本只有一個 AppComponent
時,所有 HTML 都在 app.component.html
下,現在因為都切成 component,所以只會看到 component 的 selector 而已。
切 Component 心法
切 component 與切 class、切 function 的心法一樣,就是
單一職責原則
,讓你的 component 只有一個目的,且只有一個改變 component 的理由理想的 component 會看到很多其他 component 的 selector,而不是一個 component 有大量的 HTML
AddPostComponent
add-post.component.html
1 | <input type="text" [(ngModel)]="title"> |
只負責新增 post 的 component,因此只有 <input>
與 <button>
。
add-post.component.ts
1 | import { Component } from '@angular/core'; |
15 行
1 | onAddPostClick() { |
onAddPostClick()
負責回應 <button>
的 click
event。
因為 addPost()
傳入的型別為 Post
,因此先宣告 post
為 Post
型別,並準備 post 物件。
透過 PostService.addPost()
傳入 post
。
最後將 <input>
清空,因為 <input>
與 title
使用 ngModel
的 two-way binding,因此對 title 指定空字串,就相當於清空 <input>
。
13 行
1 | constructor(private postService: IAddPost) { } |
因為 onAddPostClick()
使用了 PostService
,因此需要 DI 注入 PostService
。
根據 依賴反轉原則
,我們不應該直接相依於 PostService
,而應該相依於 AddPostComponent
所定義出的 IAddPost
interface。
iadd-post.interface.ts
1 | import { Post } from '../model/post.model'; |
根據 介面隔離原則
,因為 AddPostComponent
只使用了 addPost()
,因此 IAddPost
interface 也應該只有 addPost()
。
ListPostsComponent
list-posts.component.html
1 | <ul> |
只負責顯示post 的 component,因此只有 <ul>
與 <li>
。
posts$
因為為 Observable
,因此要搭配 async
pipe 做 subscribe。
list-posts.component.ts
1 | import { Component, OnInit } from '@angular/core'; |
12 行
1 | posts$: Observable<Post[]> = this.postService.posts$; |
posts$
為 Observable,直接使用 PostService.post$
property。
當然也可以在
ngOnInit()
內this.posts$ = this.postService.post$
,但因為 RxJS 為 Declarative Programming,主要都在定義Observable
關係,而不重視執行順序,反正最後都是在subscribe()
或async
pipe 才執行,所以指令Observable
時一般不寫在ngOnInit()
內,直接寫在field
即可。
14 行
1 | constructor(private postService: IListPosts) { } |
因為 ListPostComponent
使用了 PostService
,因此需要 DI 注入 PostService
。
根據 依賴反轉原則
,我們不應該直接相依於 PostService
,而應該相依於 ListPostComponent
所定義出的 IListPosts
interface。
16 行
1 | ngOnInit(): void { |
ListPostsComponent
主要用於顯示 post,而需求是 一開始會顯示所有 post
,因此在 ngOnInit()
時要呼叫 PostService.reloadPosts()
將全部 post 灌入 posts$
observable。
ilist-posts.interface.ts
1 | import { Observable } from 'rxjs/Observable'; |
根據 介面隔離原則
,因為 ListPostsComponent
只使用了 post$
property 與 reloadPosts()
,因此 IListPosts
interface 也應該只有 post$
property 與 reloadPosts()
。
ShowCountComponent
show-count.component.html
1 | {{ (posts$|async)?.length }} |
只負責顯示 post 筆數的 component。
當使用
Observable
的 property 時,要加上?
,表示當posts$
有length
property 時,才會執行post$.length
因為
post$
為非同步,可能 HTML 在做 data binding 時,post$
還沒有產生目前
?
寫法只能用在 HTML template,還不能用在 TypeScript,將來會提供
show-count.component.ts
1 | import { Component } from '@angular/core'; |
12 行
1 | posts$: Observable<Post[]> = this.postService.posts$; |
posts$
為 Observable,直接使用 PostService.post$
property。
14 行
1 | constructor(private postService: IShowCount) { } |
因為 ShowCountCompone
使用了 PostService
,因此需要 DI 注入 PostService
。
根據 依賴反轉原則
,我們不應該直接相依於 PostService
,而應該相依於 ShowCountComponent
所定義出的 IShowCount
interface。
ishow-count.interface.ts
1 | import { Observable } from 'rxjs/Observable'; |
根據 介面隔離原則
,因為 ShowCountComponent
只使用了 post$
property ,因此 IShowCount
interface 也應該只有 post$
property。
ReloadPostsComponent
reload-posts.component.html
1 | <button (click)="onReloadClick()">Reload</button> |
只負責重新從 API server 抓資料的 component,因此只有 <button>
。
reload-posts.component.ts
1 | import { Component } from '@angular/core'; |
12 行
1 | onReloadClick() { |
onReloadClick()
負責回應 <button>
的 click
event。
呼叫 PostService.reloadPosts()
重新透過 GET 向 API server 抓最新資料。
10 行
1 | constructor(private postService: IReloadPosts) { } |
因為 onReloadClick()
使用了 PostService
,因此需要 DI 注入 PostService
。
根據 依賴反轉原則
,我們不應該直接相依於 PostService
,而應該相依於 ReloadPostComponent
所定義出的 IReloadPosts
interface。
ireload-posts.interface.ts
1 | export abstract class IReloadPosts { |
根據 介面隔離原則
,因為 ReloadPostComponent
只使了 reloadPosts()
,因此 IReloadPosts
interface 也應該只有 reloadPosts()
。
PostService
post.service.ts
1 | import { Injectable } from '@angular/core'; |
12 行
1 | export class PostService implements IAddPost, IListPosts, IShowCount, IReloadPosts |
根據 介面隔離原則
,我們根據 component 的需求訂出 IAddPost
、IListPosts
、IShowCount
與 IReloadPosts
interface,因此 PostService
必須概括承受實現這些 interface。
13 行
1 | private store$: BehaviorSubject<Post[]> = new BehaviorSubject<Post[]>([]); |
本文的關鍵在此,使用 BehaviorSubject
當作 PostService
內部的資料庫,儲存所有 post 資料,Observable Data Service 的關鍵就在於使用 BehaviorSubject
。
Q : 什麼是 BehaviorSubject ?
根據 RxJS 的 source code
BehaviorSubject.ts
1 | export class BehaviorSubject<T> extends Subject<T> |
BehaviorSubject
繼承於 Subject
。
Subject.ts
1 | export class Subject<T> extends Observable<T> implements ISubscription |
而 Subject
繼承於 Observable
。
因此 Observable
所有的特性,Subject
與 BehaviorSubject
都有,如被 subscribe()
。
Subject
與BehaviorSubject
算是一種特殊的Observable
,提供一些原本Observable
沒有的功能Q : Observable 與 Subject 有何差別 ?
Observable
只能從HttpClient
或 array、event 提供資料,無法自行新增Subject
可以透過next()
手動新增資料至 Observable
1 | const subject: Subject = new Subject(); |
若你要手動將資料推進
Observable
,就使用Subject
Q : Subject 與 BehaviorSubject 有何差別 ?
Subject
不能提供初始值,但BehaviorSubject
可提供初始值Subject
在被subscribe()
後,必續再next()
才能收到變動資料;BehaviorSubject
只要被subscribe()
後,就可收到之前最後一筆資料,不用再等next()
BehaviorSubject
提供了getValue()
,能獲得目前BehaviorSubject
最新一筆資料;而Subject
無法
1 | const subject: BehaviorSubject = new BehaviorSubject('Hello World'); |
Q : 為什麼 Observable Data Service 要使用 BehaviorSubject ?
因為 HTML 的 async
pipe,每個 HTML template 執行 subscribe()
的時間不一致,有快有慢,若 subscribe()
早於 next()
,則可收到變動資料;若晚於 next()
,則可能收不到資料,必須等下一次 next()
。
若使用 BehaviorSubject
,無論 subscribe()
早於 next()
或晚於 next()
,async
pipe 都會獲得最新一筆 next()
,因此 Observable Data Service 必須使用 BehaviorSubject
。
16 行
1 | constructor(private httpClient: HttpClient) { } |
因為 PostService
要使用 HttpClient
,因此 DI 注入 HttpClient
。
18 行
1 | addPost(post: Post): void { |
使用 HttpClient.Post()
寫入 API,並且馬上 subscribe()
執行。
由於我們自己有維護一份 store$
儲存所有 post,因此新增的 post 也該寫入 $store
。
this.store$.getValue()
將取得目前最新的 post 陣列資料。
...this.store$.getValue()
將會展開 post 陣列所有資料,此為 ES6 的 array spread operator。
[...this.store$.getValue(), post]
,將 post 加到原本陣列之後,此為新的陣列。
使用 next()
將新的陣列推入 BehaviorSubject
。
Q : 這種寫法不就本地
store$
不是最新資料 ?
1 | addPost(post: Post): void { |
若你很在乎前端是否為最新資料,就下 reloadPosts()
重新 GET,當然執行效率會較差一點,依你實務上的需求決定。
26 行
1 | reloadPosts(): void { |
重新對 API 下 GET 抓資料,由於是最新的一份資料,馬上下 $store.next()
將資料寫進 BehaviorSubject
。
14 行
1 | readonly posts$: Observable<Post[]> = this.store$; |
已經都自己維護一份 BehaviorSubject
的 store$
,我們希望當 BehaviorSubject()
使用 next()
改變資料時,能通知所有 subscribe()
的 component 都能自動更新,這也是我們使用 Observable Data Service 的初衷。
既然 BehaviorSubject
繼承於 Observable
,理論上我們也可直接將 $store
設定為 public
,直接被 component subscribe()
,但因為 BehaviorSubject()
提供 next()
,因此也可能被 component 誤用 next()
新增資料,因此我們改用真的 Observable
給 component 訂閱,並且宣告為 readonly
,表示不能被 component 修改。
由於 store$
也是一種 Observable
,基於 里式替換原則
: 父類別可用子類別取代
,因此不需要轉型即可。
AppModule
app.module.ts
1 | import { BrowserModule } from '@angular/platform-browser'; |
由於我們 component 與 service 都是基於 依賴反轉原則
與 介面隔離原則
所設計,因此必須藉由 DI container 幫我們相對應的 service 注入。
29 行
1 | PostService, |
一般我們使用普通 service 時,都是每個 component 注入新的 service,但使用 Observable Data Service 時不可如此,因為我們就是希望各 component 共用同一份資料,只要在 provider 使用 useExisting
,Angular DI 就會以 Singleton 方式建立 service,各 component 才能共用一份 store$
。
Conclusion
- Observable Data Service 在實務上常常使用,只要各 component 間有資料共用,且會根據共用資料有不同互動,就可以使用,不侷限在資料來自於 API,也可以用在與 API 無關的資料
- Observable Data Service 同時使用了 DI 與 RxJS,讓 component 與 service 解耦合,只要 service 的資料更新,所有 component 的資料也會隨之更新
Sample Code
完整的範例可以在我的 GitHub 上找到