如何使用 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
- 都使用
@Injectabledecorator - 都是回傳
Observable
相異點 :
- Observable data service 內部會使用
BehaviorSubject儲存一份資料;而普通 service 會直接回傳HttpClient的Observable
- 原本只有
AppComponent,現在分成AddPostComponent、ListPostsComponent、ShowCountComponent與ReloadPostsComponent4 個 component - Component 依然注入
PostService,不過此時不是普通 service,而是 observable data service,為 4 個 component 共用的資料 - 根據
依賴反轉原則,component 不直接相依於PostService,而是兩者相依於 interface,根據介面隔離原則,component 只相依於它所需要的 interface,因此訂出IAddPost、IListPosts、IShowCount與IReloadPosts4 個以 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()或asyncpipe 才執行,所以指令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$有lengthproperty 時,才會執行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,就使用SubjectQ : 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 上找到