本文是 《使用 RxJS + Redux 管理應用狀態》系列第二篇文章,將會介紹 redux-observable 的設計哲學和實現思路。返回第一篇:使用 redux-observable 實現元件自治
本系列的文章地址彙總:
Redux
Redux 脫胎於 Elm 架構,其狀態管理視角和流程非常清晰和明確:
- dispatch 了一個 action
- reducer 俘獲 action,並根據 action 型別進行不同的狀態更新邏輯
- 周而復始地進行這個過程
這個過程是同步的,Redux 為了保護 reducer 的純度是不推薦在 reducer 中處理副作用的(如 HTTP 請求)。因此,就出現了 redux-thunk、redux-saga 這樣的 Redux 中介軟體去處理副作用。
這些中介軟體本質都是俘獲 dispatch 的內容,並在這個過程中進行副作用處理,最終 dispatch 一個新的 action 給 reducer,讓 reducer 專心做一個純的狀態機。
用 observable 管理副作用
假定我們在 UI 層能派發出一個資料拉取的 FETCH
action,拉取資料後,將派發拉取成功的 FETCH_SUCCESS
action 或者是資料拉取失敗的 FETCH_ERROR
action 到 reducer。
FETCH
|
fetching data...
|
/ \
/ \
FETCH_SUCCESS FETCH_ERROR
複製程式碼
如果我們用 FRP 模式來思考這個過程,FETCH 就不是一個獨立的個體,而是存在於一條會派發 FETCH action 的流上(observable):
---- FETCH ---- FETCH ----
---- FETCH_SUCCESS ---- FETCH_SUCCESS ----
---- FETCH_ERROR ---- FETCH_ERROR ----
複製程式碼
若我們將 FETCH 流定義為 fetch$
,則 FETCH_SUCCESS 和 FETCH_ERROR 都將來自於 fetch$
:
const fetch$: Observable<FetchAction> = //....
fetch$.pipe(
switchMap(() => from(api.fetch).pipe(
// 拉取資料成功
switchMap(resp => ({
type: FETCH_SUCCESS,
payload: {
// ...
}
}),
// 拉取資料失敗
catchError(error => of({
type: FETCH_ERROR,
payload: {
// ....
}
}))
))
)
複製程式碼
除此之外,我們可以用一個流來承載頁面所有的 action:
const action$: Observable<Action>
複製程式碼
那麼, fetch$
亦可以由 action$
流轉得到:
const fetch$ = action$.pipe(
filter(({type}) => type === FETCH)
)
複製程式碼
這樣,我們就形成了使用 observable 流轉 action 的模式:
接下來,我們嘗試講這個模式整合到 Redux 中,讓 observable 來負責應用的 action 流轉和副作用處理。
構建中介軟體
Redux 提供的中介軟體機制能讓我們干預每個到來的 action, 藉此處理一些業務邏輯,然後再返還一個 action 給 reducer:
中介軟體的函式構成如下:
const middleware: Middleware = store => {
// 初始化中介軟體
return next => action => {
// do something
}
}
const store = createStore(
rootReducer,
applyMiddleware(middleware)
)
複製程式碼
現在,當中介軟體初始化時,我們進行 action$
。當新的 action 到來時:
- 將 action 交給 reducer 處理
- 想
action$
中放入 action action$
可以轉化另一個的 action 流
因此,action$
既是觀察者又是可觀察物件,是一個 Subject 物件:
const createMiddleware = (): Middleware => {
const action$ = new Subject()
const middleware: Middleware = store => next => action => {
// 將 action 交給 reducer 處理
const result = next(action)
// 將 action 放到 action$ 中進行流轉
action$.next(action)
return result
}
return middleware
}
複製程式碼
流的轉換器
現在,在中介軟體中,我們初始化了 action$
,但是如何得到 fetch$
這些由 action$
派生的流呢?因此,我們還需要告知中介軟體如果通過 action$
生成更多的流,不妨定義一個轉換器,由它負責 action$
的流轉,並在當中處理副作用:
interface Transformer {
(action$: Observable<Action>): Observable<Action>
}
const fetchTransformer: Transformer = (action$) => {
action$.pipe(
filter(({type}) => type === FETCH),
switchMap(() => from(api.fetch).pipe(
switchMap(resp => ({
type: FETCH_SUCCESS,
payload: {
// ...
}
}),
catchError(error => of({
type: FETCH_ERROR,
payload: {
// ....
}
}))
))
)
}
複製程式碼
應用中,我們可能定義不同的轉換器,從而得到派發不同 action 的流:
const newActionsStreams: Observable<Action>[] = transformers.map(transformer => transformer(action$))
複製程式碼
由於這些 action 還具有一致的資料結構,因此我們可以將這些流進行合併,由合併後的流負責派發 action 到 reducer:
const newAction$ = merge(newActionStreams)
複製程式碼
那麼,修改我們的中介軟體實現:
const createMiddleware = (...transformers): Middleware => {
const action$ = new Subject()
// 執行各個 transformer,並將轉換的流進行合併
const newAction$ = merge(tramsformer.map(transformer => transformer(action$)))
const middleware: Middleware = store => {
// 訂閱 newAction$
newAction$.subscribe(action => store.dispatch(action))
return next => action => {
// 將 action 交給 reducer 處理
const result = next(action)
// 將 action 放到 action$ 中進行流轉
action$.next(action)
return result
}
}
return middleware
}
複製程式碼
優化:ofType
operator
由於我們總是需要 filter(action => action.type === SOME_TYPE)
來過濾 action,因此可以封裝一個 operator 來優化這個過程:
const ofType: OperatorFunction<Observable<Action>, Observable<Action>> = (type: String) => pipe(
filter(action => action.type === type)
)
複製程式碼
const fetchTransformer: Transformer = (action$) {
return action$.pipe(
filter(({type}) => type === FETCH),
switchMap(() => from(api.fetch)),
// ...
)
}
複製程式碼
再考慮到我們可能不只過濾一個 action type,因此可以優化我們的 ofType
operator 為:
const ofType: OperatorFunction<Observable<Action>, Observable<Action>> =
(...types: String[]) => pipe(
filter((action: Action) => types.indexOf(action.type) > -1)
)
複製程式碼
const counterTransformer: Transformer = (action$) {
return action$.pipe(
ofType(INCREMENT, DECREMENT),
// ...
)
}
複製程式碼
下面這個測試用例將用來測試我們的中介軟體是否能夠工作了:
it('should transform action', () => {
const reducer: Reducer = (state = 0, action) => {
switch(action.type) {
case 'PONG':
return state + 1
default:
return state
}
}
const transformer: Transformer = (action$) => {
return action$.pipe(
ofType('PING'),
mapTo({type: 'PONG'})
)
)
}
const middleware = createMiddleware(transformer)
const store = createStore(reducer, applyMiddleware(middleware))
store.dispatch({type: 'PING'})
expect(store.getState()).to.be.equal(1)
})
複製程式碼
優化:獲得 state
在 action 的流轉過程可能還需要獲得應用狀態,例如,fetch$
中獲取資料前,需要封裝請求引數,部分引數可能來自於應用狀態。因此,我們可以考慮為每個 transformer 再傳遞當前的 store 物件,使它能拿到當前的應用狀態:
interface Transformer {
(action$: Observable<Action>, store: Store): Observable<Action>
}
// ...
const createMiddleware = (...transformers): Middleware => {
const action$ = new Subject()
const middleware: Middleware = store => {
// 將 store 也傳遞給 transformer
const newAction$ = merge(tramsformer.map(transformer => transformer(action$, store)))
newAction$.subscribe(action => store.dispatch(action))
return next => action => {
const result = next(action)
action$.next(action)
return result
}
}
return middleware
}
複製程式碼
現在,當需要取用狀態的時候,就通過 store.getState()
拿取:
const fetchTransformer: Transformer = (action$, store) {
return action$.pipe(
filter(({type}) => type === FETCH),
switchMap(() => {
const { query, page, pageSize } = store.getState()
const params = { query, page, pageSize }
return from(api.fetch, params)
}),
// ...
)
}
複製程式碼
優化:觀察狀態
在響應式程式設計體系下,一切資料來源都應當是可被觀察的,而上面我們對狀態的取值確是主動的(proactive)的,正確的方式是應當觀察狀態的變化,並在變化時作出決策:
為此,類似 action$
,我們也將 state 流化,使得應用狀態成為一個可觀察物件,並將 state$
傳遞給 transformer:
interface Transformer {
(action$: Observable<Action>, state$: Observable<State>): Observable<Action>
}
// ...
const createMiddleware = (...transformers): Middleware => {
const action$ = new Subject()
const state$ = new Subject()
const middleware: Middleware = store => {
// 由各個 transformer 獲得應用的 action$
const newAction$ = merge(tramsformer.map(transformer => transformer(action$, state$)))
// 新的 action 到來時,將其又 dispatch 到 Redux 生態
newAction$.subscribe(action => store.dispatch(action))
return next => action => {
// 將 action 交給 reducer
const result = next(action)
// 獲得 reducer 處理後的新狀態
state$.next(state)
// 將 action 放入 action$
action$.next(action)
return result
}
}
return middleware
}
複製程式碼
當業務流程需要狀態時,就可以自由組合 state$
得到:
const fetchTransformer: Transformer = (action$, state$) {
return action$.pipe(
filter(({type}) => type === FETCH),
withLatestFrom(state$),
switchMap(([action, state]) => {
const { query, page, pageSize } = state
const params = { query, page, pageSize }
return from(api.fetch, params)
}),
// ...
)
}
複製程式碼
乍看之下,似乎不如 store.getState()
來的方便,為了獲得當前狀態,我們還額外引入了一個 operator withLatestFrom
。但是,要注意到,我們引入 state$
不只為了獲得狀態和統一模式,更重要是為了觀察狀態。
舉個例子,我們有一個備忘錄元件,每次內容變動時,我們就儲存一下草稿。如果我們能觀察狀態變動,通過響應式程式設計模式,當狀態變動時,自動形成草稿儲存的業務:
const saveDraft$: Observable<Action> = state$.pipe(
// 選出當前
pluck('content'),
// 只有當內容變動時才考慮儲存草稿
distinctUntilChanged(),
// 只在 1 s 內儲存一次
throttleTime(1000),
// 呼叫服務儲存草稿
switchMap(content => from(api.saveDraft(content)))
// ....
)
複製程式碼
大家也可以在回顧系列第一篇所介紹的內容,正是由於 redux-observable 在 1.0 版本引入了 state$
,我們才得以解耦元件的業務關係,實現單個元件的自治。
優化:響應初始狀態
現在,我們可以測試一下現在的中介軟體,看能否觀察應用狀態了:
it('should observe state', () => {
const reducer: Reducer = (state = {step: 10, counter: 0}, action) => {
switch(action.type) {
case 'PONG':
return {
...state,
counter: action.counter
}
default:
return state
}
}
const transformer: Transformer = (action$, state$) => {
return action$.pipe(
ofType('PING'),
withLatestFrom(state$, (action, state) => state.step + state.counter),
map(counter => ({type: 'PONG', counter}))
)
)
}
const middleware = createMiddleware(transformer)
const store = createStore(reducer, applyMiddleware(middleware))
store.dispatch({type: 'PING'})
expect(store.getState().counter).to.be.equal(10)
})
複製程式碼
遺憾的是,這個測試用例將不會通過,通過除錯發現,當我們 dispatch 了 PING action 後,withLatestFrom
沒有拿到最近一次的 state。這是為什麼呢?原來是因為 Redux 的 init action 並沒有暴露給中介軟體進行攔截,因此,應用的初始狀態沒能被送入 state$
中,觀察者無法觀察到初始狀態。
為了解決這個問題,在建立了 store 後,我們可以嘗試 dispatch 一個無意義的 action 給中介軟體,強制將初始狀態先送入 state$
中:
const middleware = createMiddleware(transformer)
const store = createStore(reducer, applyMiddleware(middleware))
// 派發一個 action 去獲得初始狀態
store.dispatch({type: '@@INIT_STATE'})
複製程式碼
這個方式雖然能讓測試通過,但缺不是很優雅,我們讓使用者手動去派發一個無意義的 action,這會讓使用者感覺很困惑。因此,我們考慮為中介軟體單獨設定一個 API,用以在 store 建立後,完成一些任務:
// 設定一個 store 副本
let cachedStore: Store
const createMiddleware = (...transformers): Middleware => {
const action$ = new Subject()
const state$ = new Subject()
const newAction$ = merge(transformers.map(transformer => transformer(action$, state$)))
const middleware: Middleware = store => {
cachedStore = store
return next => action => {
// 將 action 交給 reducer
const result = next(action)
// 獲得 reducer 處理後的新狀態
state$.next(state)
// 將 action 放入 action$
action$.next(action)
return result
}
}
middleware.run = function() {
// 1. 開始對 action 的訂閱
newAction$.subscribe(cachedStore.dispatch)
// 2. 將初始狀態傳遞給 state$
state$.next(cachedStore.getState())
}
return middleware
}
複製程式碼
現在,我們為中介軟體提供了一個 run
方法,來讓中介軟體在 store 建立以後完成一些工作。當我們建立好 store 後,執行 run
方法來執行中介軟體:
const middleware = createMiddleware(transformer)
const store = createStore(reducer, applyMiddleware(middleware))
// 執行我們的中介軟體
middleware.run()
複製程式碼
優化:相互關聯的 transformer
再考慮一個更加場景,各個 transformer 之間可能存在關聯,各個 trasformer 也可能直接發出 action,而不需要依賴於 action$
:
it('should queue synchronous actions', () => {
const reducer = (state = [], action) => state.concat(action)
const transformer1 = (action$, state$) => action$.pipe(
ofType('FIRST'),
mergeMap(() => of({ type: 'SECOND' }, { type: 'THIRD'} ))
)
const transformer2 = (action$, state$) => action$.pipe(
ofType('SECOND'),
mapTo({type: 'FORTH'})
)
const middleware = createMiddleware(transformer1, transformer2)
const store = createStore(reducer, applyMiddleware(middleware))
middleware.run()
const actions = store.getState()
actions.shift() // remove redux init action
expect(actions).to.deep.equal([
{ type: 'FIRST' },
{ type: 'SECOND' },
{ type: 'THIRD' },
{ type: 'FORTH' }
])
})
複製程式碼
在這個測試用例中,我們看到的 action 序列是:
FIRST
SECOND
THIRD
FORTH
複製程式碼
但是,在當前的實現中,你將得到:
FIRST
SECOND
FORTH
THIRD
複製程式碼
這並不符合預期。但是,問題又出在哪裡呢?我們分析下程式執行過程:
- 發出 first action
- 排程 first action,派生出 second action 及 third action 的 observable
- 排程 second action,派生出 forth action 的 observable
- 排程 forth action
- 排程 third action
問題顯然就出在第 2、3 步,如果第 2 步中,我們控制 observable 吐出值的速度,將同時到來的 second 和 third action 快取到佇列,並依次執行,就能得到我們期望的輸出。
幸運的是,RxJS 中提供了 observeOn
這個 operator 來控制資料來源發出值的節奏。其第一個引數接收一個排程器,用於告知資料來源以怎樣的速錄排程任務,這裡我們將使用 Queue Scheduler 將各個 action 快取到佇列,當此時再無 action 時,各個 action 出隊並被排程:
export const createEpicMiddleware = (...epics) => {
const action$ = new Subject().pipe(observeOn(queueScheduler)) as Subject<Action>
// ...
return middleware
}
複製程式碼
現在,再次執行測試用例,你講看到符合期望的 action 序列:
FIRST
SECOND
THIRD
FORTH
複製程式碼
這是因為:
- 發出 first action
- 排程 first action,入隊
- 此時沒有 action,first action 出隊,
store.dispatch(first)
,派生出 second action 及 third action 的 observable - second action 入隊,third action 入隊
- 此時沒有等待的 action,則 second action 出隊,
store.dispatch(second)
,派生出 forth action 的 observable - forth action 入隊
- 此時沒有等待的 action,隊首元素 third action 出隊,
store.dispatch(third)
- forth action 出隊,
store.dispatch(forth)
總結
截止目前,我們的中介軟體已經允許我們通過 FRP 模式梳理應用狀態了,這個中介軟體的實現已經非常類似於 redux-observable 的實現了。當然,大家生產環境還是用更流行,更穩定的 redux-observable,本文旨在幫助大家更好的理解如何在 Redux 中整合 RxJS 更好的管理狀態,通過一步一步對中介軟體的優化,也讓大家理解了了 redux-observable 的設計哲學和實現原理。本文實現的 mini redux-observable 我也放到了我的 github 上,包含了一些測試用例和一個小的 demo。
接下來,我們將探索將 redux-observable 以及 FRP 這套模式整合到 dva 架構的前端框架中,dva 架構幫助砍掉 Redux 冗長的樣板程式碼,而 redux-observable 則專注於副作用處理。
參考資料
- RxJS API document
- PRIMER ON RXJS SCHEDULERS
- redux-observable #493 pull request
- Gerard Sans — Bending time with Schedulers and RxJS 5
關於本系列
- 本系列將從介紹 redux-observable 1.0 開始,闡述自己在結合 RxJS 到 Redux 中的心得體會。涉及內容會有 redux-observable 實踐介紹,redux-observable 實現原理探究,最後會介紹下自己當前基於 redux-observble + dva architecture 的一個 state 管理框架 reobservable。
- 本系列不是 RxJS 或者 Redux 入門,不再講述他們的基礎概念,宣揚他們的核心優勢。如果你搜尋 RxJS 不小心進到了這個系列,對 RxJS 和 FRP 程式設計產生了興趣,那麼入門我會推薦:
- learnrxjs.io
- Andre Staltz 在 egghead.io 上的一系列課程
- 程墨的 《深入淺出 RxJS》
- 本系列更不是教程,只是介紹自己在 Redux 中應用 RxJS 的一些思路,希望更多人能指出當中存在的誤區,或者交流更優雅的實踐。
- 由衷的感謝實踐路上一些師兄的幫助,尤其感謝騰訊雲的 questguo 學長在模式上的指導。reobservable 脫胎於騰訊雲 questguo 主導的 React 框架 —— TCFF,期待未來 TCFF 的開源。
- 感謝小雨的設計支援。