Angular 也走 Redux 風 (使用 Ngrx)
Redux 起源於 React 社群,算是一種 design pattern,適用於某些情境,也提供一些優點,Angular 也有 Redux 的實作,但 Angular 是否該使用 Redux 呢?
Version
macOS 10.12.4
Angular CLI 1.0.0
Angular 4.0.1
ngrx/store 2.2.1
為什麼會有 Redux?
Facebook 由於在界面上有多個 component 都可讀取 message,且又同時從 server 端下載 message,也就是當每個 component 的 message 被讀取後,必須更新 unread message count,但 Facebook 發現 unread message count 總是算不準,有解不完的 bug,因此提出了 Flux 架構。
Redux 靈感來自於 Flux 架構,在 Angular 目前有兩套實作,一套是 Ng-redux,另一套是 Ngrx。
Ng-redux
核心仍使用 Redux,增加對 Angular 的支援。Ngrx
只有概念使用 Redux,核心完全使用 RxJS 重新實作。
目前 Ngrx
在 GitHub 的星星數遠高於 Ng-redux
,本文將以 Ngrx
討論。
Flux 是一種概念,Redux 是 Flux 在 React 的實作,Ngrx 則是 Redux 在 Angular 的實作。
Ngrx 簡介
將 component 與 service 的資料統一放到 store,當 store 的資料有更新,將會自動更新到有 subscribe 的 component 與 service。1 1本圖片來自於 Brain Troncone, A Comprehensive Introduction to @ngrx/store
在使用 Ngrx
之前,首先必須了解一些專有名詞。2 2本圖片來自於 Building a Redux application with Anguar 2 - Part 1
View
相當於 component,主要在顯示使用者介面。
Action
當 component 有任何 event 時,會對 Ngrx
發出 action。
Middleware
負責存取對 server 端的 API,本文暫不討論此部分。
Dispatcher
負責接受 component 傳來的 action,並將 action 傳給 reducer。
Store
可視為 Ngrx
在瀏覽器端的資料庫,各 component 的資料都可統一放在這裡。
Reducer
根據 dispatcher 傳來的 action,決定該如何寫入 state。
當 state 有改變時,將通知有 subscribe 該 state 的 component 自動更新。
State
存放在 store 內的資料。
有些東西 Ngrx
已經幫我們做了,真的要我們自己實作只有 4 個部份,且資料流為單向的 : Component -> Action -> Reducer -> Store -> Component。
安裝 Ngrx
Installation
1 | ~/MyProject $ npm install @ngrx/core @ngrx/store --save |
安裝 @ngrx/core
與 @ngrx/store
。
Counter 範例
AppModule
app.module.ts3 3GitHub Commit : app.module.ts
1 | import {BrowserModule} from '@angular/platform-browser'; |
14 行
1 | imports: [ |
須在 AppModule
import StoreModule.provideStore()
,並傳入 reducer。
Component
app.component.html4 4GitHub Commit : app.component.html
1 | <p>Counter: {{ counter | async }}</p> |
在 counter
加上 async
pipe,由 async
負責將 ngrx/store
來 subscribe 與 unsubscribe。
但這有個限制,counter
必須為宣告成 Observable<number>
型別。
Component 包含了 Increment
、Decrement
與 Reset
3 個 button。
app.component.ts5 5GitHub Commit : app.component.ts
1 | import {Component} from '@angular/core'; |
15 行
1 | constructor(private store: Store<CounterState>) { |
將 ngrx/store
的 Store
依賴注入,它是個泛型,需傳入自己的 state 型別。
由 store 的 select()
傳回 store 內的 counter
field,此為 Observable
型別。
13 行
1 | counter: Observable<number>; |
宣告 counter
為 Observable
型別,其泛型為 number
。
為什麼
counter
不是number
型別,而是Observable<number>
呢?因為store.select()
回傳的型別為Observable
。
19 行
1 | increment() { |
Increment
button 的 event handler。
將 action 透過 store 的 dispatch()
傳入,action 物件包含 type
與 payload
兩個 field,type
為欲 dispatch 的 action,而 payload
則為欲透過 dispatch 傳入的資料,可自行決定其物件屬性,之後會由 reducer 根據 action 寫入 state。
可將 dispatch 概念上想成類似 event 的 emit。
Action
counter.action.ts6 6GitHub Commit : counter.action.ts
1 | export const INCREMENT = 'INCREMENT'; |
定義 action 常數,將來 component 可用 dispatch()
發布 action, 然後 reducer 再根據 action 做 switch
判斷寫入 state。
Reducer
counter.reducer.ts7 7GitHub Commit : counter.reducer.ts
1 | import {CounterState, INITIAL_COUNTER_STATE} from './counter.store'; |
一個 state 會搭配一個 reducer,由 reducer 寫入 state。
第 5 行
1 | export function counterReducer(state: CounterState = INITIAL_COUNTER_STATE, action: Action): CounterState { |
Reducer 會以 state 與 action 為參數,並寫入 state。
- 第 1 個參數為
state
,可設定 reducer 一開始的預設 state,傳入目前的 state。 - 第 2 個參數為
action
,傳入目前的 action。
第 6 行
1 | const {type, payload} = action; |
根據 ngrx 的 dispatcher.d.ts
,Action
的定義如下 :
1 | export interface Action { |
Action
的兩個 field 為 type
與 payload
,因此我們可以使用 TypeScript 2.1 的 object destruction 將 action
分解成 type
與 payload
兩個變數。
第 8 行
1 | switch (type) { |
典型的 Redux 風格,在 reducer 內會根據 action 的 type 做 switch case
。
第 10 行
1 | return {...state, counter: state.counter + payload.value}; |
Redux 為 FP (Functional Programming) 思維的產物,要求 reducer 必須為 pure function,因此必須回傳一個新的 state,而不是去修改原本的 state。
TypeScript 2.1 提供了 object spread,…state
會將整個物件的屬性加以展開,之後的參數為要修改的屬性值,{}
會將物件屬性加以合併,並回傳新的物件。
傳統會使用
Object.assign()
的寫法,但寫法並不直覺,且因為其第二個參數需傳入物件,還必須多一層{}
,建議使用 object spread 寫法可讀性較高。
Store
counter.store.ts8 8GitHub Commit : counter.store.ts
1 | export interface CounterState { |
在 store 定義自己的 state 型別。
第 1 行
1 | export interface CounterState { |
定義 CounterState
與其 field。
使用 interface 即可,因為 state 型別主要是給 TypeScript 編譯檢查與 IntelliSense 使用,而 JavaScript 沒有 interface,故編譯後 interface 會消失,若 state 使用 class,將來編譯後還會存在 class。
第 5 行
1 | export const INITIAL_COUNTER_STATE: CounterState = { |
定義 INITIAL_COUNTER_STATE
常數,為 CounterState
的初始狀態。
Todo 範例
AppModule
app.module.ts9 9GitHub Commit : app.module.ts
1 | import {BrowserModule} from '@angular/platform-browser'; |
18 行
1 | imports: [ |
須在 AppModule
import StoreModule.provideStore()
,並傳入 reducer。
Component
AppComponent
app.component.html10 10GitHub Commit : app.component.html
1 | <h1>Todo</h1> |
包含了 TodoList
與 TodoDashboard
兩個 component。
TodoList
todo-list.component.html11 11GitHub Commit : todo-list.component.html
1 | <input type="text" #title> |
todos
為 Observable
,需加上 async
將 ngrx/store
來 subscribe 與 unsubscribe。
但這有個限制,todos
必須為宣告成 Observable<Todo[]>
型別。
Component 包含了Add
與 Remove
2 個 button。
todo-list.component.ts12 12GitHub Commit : todo-list.component.ts
1 | import {Component} from '@angular/core'; |
14 行
1 | constructor(private store: Store<TodoState>) { |
將 ngrx/store
的 Store
依賴注入,它是個泛型,需傳入自己的 state 型別。
由 store 的 select()
傳回 store 內的 todos
field,此為 Observable
型別。
12 行
1 | todos: Observable<Todo[]>; |
宣告 todos
為 Observable
型別,其泛型為 Todo[]
。
為什麼
todos
不是Todo[]
型別,而是Observable<Todo[]>
呢?因為store.select()
回傳的型別為Observable
。
18 行
1 | addTodo(input: HTMLInputElement) { |
Add
button 的 event handler。
將 ADD_TODO
action 透過 store 的 dispatch()
傳入,action 物件包含 type
與 payload
兩個 field,type
為 ADD_TODO
action,而 payload
則為欲新增資料的 title
,之後會由 reducer 根據目前 action 寫入 state。
33 行
1 | removeTodo(todo: Todo) { |
Remove
button 的 event handler。
將 REMOVE_TODO
action 透過 store 的 dispatch()
傳入,action 物件包含 type
與 payload
兩個 field,type
為 REMOVE_TODO
action,而 payload
則為欲移除資料的 id
,之後會由 reducer 根據目前 action 寫入 state。
TodoDashboard
todo-dashboard.component.html13 13GitHub Commit : todo-dashboard.component.html
1 | <p> |
顯示最後更新時間與 Todo
筆數。
lastUpdate
與 todos
均為 Observable
,需加上 async
將 ngrx/store
來 subscribe 與 unsubscribe。
Component 包含了 Clear All
button。
todo-dashboard.component.ts14 14GitHub Commit : todo-dashboard.component.ts
1 | import {Component} from '@angular/core'; |
15 行
1 | constructor(private store: Store<TodoState>) { |
將 ngrx/store
的 Store
依賴注入,它是個泛型,需傳入自己的 state 型別。
由 store 的 select()
傳回 store 內的 todos
與 lastUpdate
field,皆為 Observable
型別。
12 行
1 | todos: Observable<Todo[]>; |
宣告 todos
為 Observable
型別,其泛型為 Todo[]
。
宣告 lastUpdate
為 Observable
型別,其泛型為 Date
。
20 行
1 | clearTodos() { |
Clear All
button 的 event handler。
將 CLEAR_TODOS
action 透過 store 的 dispatch()
傳入,action 物件包含 type
與 payload
兩個 field,type
為 REMOVE_TODO
action,因為沒有要傳入的資料,因此不需 payload
,之後會由 reducer 根據目前 action 寫入 state。 。
Action
todo.action.ts15 15GitHub Commit : todo.action.ts
1 | export const ADD_TODO = 'ADD_TODO'; |
在 action 定義自己的 action 常數。
實務上 action 常數會以 component 或 service 所要 dispatch 的 event 設計,以本範例而言,在 component 有 Add
、Remove
與 Delete All
3 個 button,因此會配合 3 個 button 設計出 ADD_TODO
、REMOVE_TODO
與 CLEAR_TODOS
3 個 action。
Reducer
todo.reducer.ts16 16GitHub Commit : todo.reducer.ts
1 | import {INITIAL_TODO_STATE, TodoState} from './todo.store'; |
在 reducer 定義自己的寫入 state 邏輯。
第 9 行
1 | case ADD_TODO: |
使用 object spread 方式傳回新物件。
todos
為陣列,而 ADD_TODO
主要的目的就是將新的物件加到 todos
陣列,因此可使用 array spread 方式傳回新的陣列。
19 行
1 | case REMOVE_TODO: |
一樣使用 object spread 方式傳回新的物件。
REMOVE_TODO
主要為移除 payload.id
的 Todo
,但因為 Ngrx
要求為 pure function,因此改用 filter()
過濾 todo.id
不為 payload.id
。
26 行
1 | case CLEAR_TODOS: |
一樣使用 object spread 方式傳回新的物件。
CLEAR_TODOS
為清除所有 Todo
,但因為 Ngrx
要求為 pure function,因此傳回 []
空陣列。
Store
todo.store.ts17 17GitHub Commit : todo.store.ts
1 | export interface Todo { |
在 store 定義自己的 state 型別。
第 6 行
1 | export interface TodoState { |
定義 TodoState
與其 field。
其中 todos
為 Todo
型別的陣列。
第 1 行
1 | export interface Todo { |
定義 Todo
型別。
11 行
1 | export const INITIAL_TODO_STATE: TodoState = { |
定義 INITIAL_TODO_STATE
常數,為 TodoState
的初始狀態。
Ngrx DevTools
Ngrx
所提供的開發者工具,讓我們可以針對 Ngrx
的 store 與 action 做 debug。
Installation
1 | ~/MyProject $ npm install @ngrx/store-devtools --save |
安裝 @ngrx/store-devtools
。
安裝 Redux DevTools
到 Chrome。
AppModule
app.module.ts18 18GitHub Commit : app.module.ts
1 | import {BrowserModule} from '@angular/platform-browser'; |
19 行
1 | imports: [ |
須在 AppModule
import StoreDevtoolsModule.instrumentOnlyWithExtension()
。
可在 Chrome 使用 Ngrx DevTools 對 state 與 action 做 debug,並可使用 time-traveling 的方式一個 action 一個 action 的執行。
Ngrx 的特色
- 將所有 state 統一放在單一 store 內。
- 所有寫入 state 的邏輯都統一放在單一 reducer 內,且必須為 pure function。
- Component 不寫邏輯,只負責 dispatch 適當的 action。
- 當 state 變更,component 會自動更新。
Ngrx 的優點
- 單向的資料流程,程式碼較易理解與 debug。
- 將 state 的變更邏輯統一寫在 reducer 內,而非分散在各 component,較容易維護。
- 由於 reducer 為 pure function,很容易寫單元測試。
- 資料邏輯與 framework 解耦合,action/reducer/store 獨立於 framework,將來要移植到其他 framework 很方便。
- 有 Ngrx DevTools 方便做 time-traveling 方式的 debug。
- 可將使用者行為錄製下來。
Ngrx 的缺點
- 為 FP 思維產物,若只熟 OOP 較難以掌握。
- 維護的人須事先有 Redux 觀念,否則無法維護,學習門檻較高。
- Reducer 需寫成 pure function,難度較高。
- 有點 over design 味道。
什麼時候該使用 Ngrx?
- 當多個 component 需使用共用資料,且各 component 的操作會影像其他 component 的結果。
- 資料可能同時被多個 component 修改,甚至同時被 server API 修改。
- 需要實作 undo/redo 功能。
You’ll know when you need Flux. If you aren’t sure if you need it, you don’t need it.
套句 React How-to 的名言,當你需要 Ngrx
的時候再使用 Ngrx
,若你不確定,就不要使用,避免因誤用而 over design。
Conclusion
Ngrx
有一點 over design,相當於 command 模式與 Observable 模式的合體。Ngrx
就跟所有的 design pattern 一樣,都會使設計複雜化,並不是所有應用都適合使用Ngrx
,必須看需求用在刀口上。RxJS
出來之後,Ngrx
的寫法重要性不若以往,簡單的應用直接在 service 使用RxJS
即可。
Sample Code
完整的範例可以在我的 GitHub 上找到 CounterNgrx 與 TodoNgrx。
Reference
ngrx, ngrx/store
Mosh Hamedani, Build Enterprise Applications with Angular 2
Angular University, Angular Ngrx Crash Course Part 1: Ngrx Store - Learn It By Understanding The Original Facebook Counter Bug
Angular University, Angular Service Layers: Redux, RxJs and Ngrx Store - When to Use a Store And Why ?
Hristo Georgiev, Building a Redux application with Anguar 2 - Part 1
Hristo Georgiev, Building a Redux application with Anguar 2 - Part 2
Brain Troncone, A Comprehensive Introduction to @ngrx/store
egghead.io, Build Redux Style Application with Angular 2, RxJS, and ngrx/store
Michal Majewski, What I have learned using ngrx/Redux with Angular 2
Dan Abramov, You Might Not Need Redux
Pete Hunt, React How-to