本文是 《使用 RxJS + Redux 管理應用狀態》系列第三篇文章,將介紹我們在使用 Redux 時的困惑,如何重新思考 Redux 定下的正規化,以及我們能為此做出的努力。返回第一篇:使用 redux-observable 實現元件自治
本系列的文章地址彙總:
為什麼我們需要 Redux?
首先要明確的是,Redux 並不是 React 獨有的一個外掛,它是順應前端元件化開發潮流而誕生的一種狀態管理模型,你在 Vue 或者 Angular 中也可以使用這個模型。
目前,大家都比較認可的是,某一時刻的應用或者元件狀態,將對應此時應用或者元件的 UI:
UI = f(state)
複製程式碼
那麼,在前端元件化開發的時候,就需要思考兩個問題:
- 狀態來源
- 狀態管理
元件所具有的狀態,一搬來源於兩個方面:
- 自身具有的狀態:例如一個 Button 元件自身含有一個計數狀態 count,表示自己被點選的次數。
- 外部注入的狀態:例如一個 Modal 元件,就需要由外部注入一個是否顯示的狀態 visible。React 將外部注入的狀態稱為 props。
狀態源為元件輸送了其需要的狀態,進而,元件的外觀形態也得到了確認。在簡單工程和簡單元件中,我們思考了狀態來源也就行了,如果引入額外的狀態管理方案(例如我們為一個使用 Redux 管理一個按鈕元件的狀態),反而會加重每個元件的負擔,造成了多餘的抽象和依賴。
而對於大型前端工程和複雜元件來說,其往往具有如下特點:
- 資料複雜
- 元件豐富
在這種場景下,樸素的狀態管理就顯得捉襟見肘了,主要體現在下面幾個方面:
- 當元件層級過深時,如何優雅得呈遞元件需要的狀態,或者說元件如何更方便取得自己需要的狀態
- 如何回溯到某個狀態
- 如何更好的測試狀態管理
Redux 正是要去解決這些問題,從而讓大型前端工程的狀態更加可控。Redux 提出了一套約定模型,讓狀態的更新和派發都集中了:
Redux 所使用的模型是受到了 Elm 的啟發:
在 Elm 中,流動於應用中的是訊息(msg) :一個由**訊息型別(type)所標識,並且攜帶了內容(payload)**的資料結構。訊息決定了資料模型(model)怎麼更新,而資料又決定了 UI 形態。
而在 Redux 中,訊息被稱替代為動作(action),並且使用 reducer 來描述狀態隨行為的變遷。另外,與 Elm 不同的是,Redux 專注於狀態管理,而不再處理檢視(View),因此 ,Redux 也不是分型的(關於分型架構的介紹,可以看 的博文)。
在瞭解到 Redux 的利好,或者被 Redux 的流行所吸引後,我們引入 Redux 作為應用的狀態管理器,這讓整個應用的狀態變動都變得無比清晰,狀態在一條鏈路上湧動,我們甚至可以回到或者前進到某個狀態。然而,Redux 就真的完美無缺嗎?
不完美的 Redux
Redux 當然不完美,它最困擾我們的就是下面兩個方面:
- 囉嗦的樣板程式碼
- 低下的非同步任務處理能力
假定前端需要從服務端拉取一些資料並進行展示,在 Redux 的模式下,完成從資料拉取到狀態更新,就需要經歷:
(1)定義若干的 action type:
const FETCH_START = 'FETCH_START'
const FETCH_SUCCESS = 'FETCH_SUCCESSE'
const FETCH_ERROR = 'FETCH_ERROR'
複製程式碼
(2)定義若干 action creator,這裡假定我們使用 redux-thunk 驅動非同步任務:
const fetchSuccess = data => ({
type: FETCH_START,
payload: { data }
})
const fetchError = error => ({
type: FETCH_ERROR,
payload: { error }
})
const fetchData = (params) => {
return (dispatch, getState) => {
return api.fetch(params)
.then(fetchSuccess)
.catch(fetchError)
}
}
複製程式碼
(3)在 reducer 中,對不同 action type,通過 switch-case 宣告不同的狀態更新方式:
function reducer(state = initialState, action) {
const { type, payload } = action
switch(action.type){
case FETCH_START: {
return { ...state, loading: true }
}
case FETCH_SUCCESS: {
return { ...state, loading: false, data: payload.data }
}
case FETCH_ERROR: {
return { ...state, loading: false, data: null, error: payload.error}
}
}
}
複製程式碼
這個流程帶來的問題是:
- 個人開發不夠專注:工程中,我們是分散管理 action type、action 及 reducer 的,走完一套流程,需要在當中不停的跳躍,思路不夠集中。
- 多人協作不夠高效:同樣是因為 action type、action 及 reducer 的分散,多人協作時就會出現名字衝突,相似業務的流程重複等問題。這對我們的應用狀態設計提出了比較高的要求。優秀的設計是狀態易於定位,變遷流程清晰,無冗餘狀態,而低下的設計就會讓狀態膨脹難於定位,變遷流程錯綜複雜,冗餘狀態隨處可見。
怎麼用好 Redux
當我們受困於 Redux 的負面影響時,切到其他的狀態管理方案(例如 mobx 或者 mobx-state-stree),也不太現實,一方面是遷移成本大,一方面你也不知道新的狀態管理方案是否就是銀彈。但是,對 Redux 的負面影響無動於衷或者忍氣吞聲,也只會讓問題越滾越大,直到失控。
在開始討論如何更好地 Redux 之前,我們需要明確一點,樣板程式碼和非同步能力的缺乏,是 Redux 自身設計的結果,而非目的,換句話說,Redux 設計出來,並不是要讓開發者去撰寫樣本程式碼,或者去糾結怎麼處理非同步狀態更新。
我們需要再定義一個角色,讓他來代替我們去寫樣板程式碼,讓他給予我們最優秀的非同步任務處理能力,讓他負責一切 Redux 中惡心的事兒。因此,這個角色就是一個讓 Redux 變得更加優雅的框架,至於如何建立這個角色,需要我們從單個元件開始,重新梳理下應用形態,並著眼於:
- 如何打掉 Redux 的樣板程式碼
- 如何更優雅地處理非同步任務
元件的樣子
一個元件的生態大概是這樣的:
即:資料經處理形成頁面狀態,頁面狀態決定 UI 渲染。
應用的樣子
而元件生態(UI + 狀態 + 狀態管理方式)的組合就構成了我們應用:
這裡元件生態特意只展示了資料到狀態這一步,因為 Redux 處理的正是這個部分。我們暫且可以定義資料到狀態的過程為 flow,即一個業務流的意思。
應用劃分
借鑑於 Elm,我們可以按資料模型對應用進行劃分:
其中,模型具有的屬性有:
name
: 模型名稱state
:模型的初始狀態reducers
:處理當前模型狀態的 stateselectors
:服務於當前模型的 state selectorsflows
:當前模型涉及的業務流(副作用)
這個經典的劃分模型正是 Dva 的應用劃分手段,只是模型屬性略有不同。
假定我們建立了 user 模型和 post 模型,那麼框架將掛載他們的狀態到 user 和 post 狀態子樹下:
約定 —— 打掉樣板程式碼
有了模型這個概念後,框架就能定義一系列的約定去減少樣板程式碼的書寫。首先,我們回顧下以前我們是怎麼定義的一個 action type 的:
- action 名稱
- 指定一個 namespace 防止名字衝突
例如,我們這樣定義使用者資料拉取相關的 action type:
const FETCH = 'USRE/FETCH'
const FETCH_SUCCESS = 'USER/FETCH_SUCCESSE'
const FETCH_ERROR = 'USER/FETCH_ERROR'
複製程式碼
其中, FETCH
對應的是一個非同步 拉取資料的 action,FETCH_SUCCESS
和 FETCH_ERROR
則對應兩個同步修改狀態的 action。
同步 action 約定
對於同步的、不包含副作用的 action,我們直接將其呈遞到 reducer,是不會破壞 reducer 純度的。 因此,我們不妨約定: model 下 reducer 的名字對映一個直接對狀態操作的 action type:
SYNC_ACTION_TYPE = MODEL_NAME/REDUCER_NAME
複製程式碼
例如下面這個 user model:
const userModel = {
name: 'user',
state: {
list: [],
total: 0,
loading: false
},
reducers: {
fetchStart(state, payload) {
return { ...state, loading:true }
}
}
}
複製程式碼
當我們派發了一個型別為 user/fetchStart
的 action 之後,action 就帶著其 payload 進入到 user.fetchStart
這個 reducer 下,進行狀態變更。
非同步 action 約定
對於非同步的 action,我們就不能直接在 reducer 進行非同步任務處理,而 model 中的 flow 就是非同步任務的集裝箱:
ASYNC_ACTION_TYPE = MODEL_NAME/FLOW_NAME
複製程式碼
例如下面這個 model:
const user = {
name: 'user',
state: {
list: [],
total: 0,
loading: false
},
flows: {
fetch() {
// ... 處理一些非同步任務
}
}
}
複製程式碼
如果我們在 UI 裡面發出了個 user/fetch
,由於 user model 中存在一個名為 fetch 的 flow,那麼就進入到這個flow 中進行非同步任務的處理。
狀態的覆蓋與更新
如果每個狀態的更新都去撰寫一個對應的 reducer 就太累了,因此,我們可以考慮為每個模型定義一個 change reducer,用於直接更新狀態:
const userModel = {
name: 'user',
state: {
list: [],
pagination: {
page: 1,
total: 0
},
loading: false
},
reducers: {
change(state, action) {
return { ...state, ...action.payload }
}
}
}
複製程式碼
此時,當我們派發了下面的一個 action,就將能夠將 loading
狀態置為 true:
dispatch({
type: 'user/change',
payload: {
loading: true
}
})
複製程式碼
但是,這種更新是覆蓋式的,假定我們想要更新狀態中的當前頁面資訊:
dispatch({
type: 'user/change',
payload: {
pagination: { page: 1 }
}
})
複製程式碼
狀態就會變為:
{
list: [],
pagination: {
page: 1
},
loading: false
}
複製程式碼
pagination
狀態被整個覆蓋掉了,其中的總數狀態 total
就丟失了。
因此,我們還要定義一個 patch reducer,意為對狀態的補丁更新,它只會影響到 action payload 中宣告的子狀態:
import { merge } from 'lodash.merge'
const userModel = {
name: 'user',
state: {
list: [],
pagination: {
page: 1,
total: 0
},
loading: false
},
reducers: {
change(state, action) {
return {
{ ...state, ...action.payload }
}
},
patch(state, action) {
return deepMerge(state, action.payload)
}
}
}
複製程式碼
現在,我們嘗試只更新分頁:
dispatch({
type: 'user/patch',
payload: {
pagination: { page: 1 }
}
})
複製程式碼
新的狀態就是:
{
list: [],
pagination: {
page: 1,
total: 0
},
loading: false
}
複製程式碼
注意:這裡的實現不是生產環境的實現,直接使用 lodash 的 merge 是不夠的,實際專案中還要進行一定改造。
非同步任務的組織
Dva 使用了 redux-saga 進行副作用(主要是非同步任務)的組織,Rematch 則使用了 async/await 進行組織。從長期的實踐來看,我更偏向於使用 redux-observable,尤其是在其 1.0 版本的釋出之後,更是帶來了可觀察的 state$
,使得我們能更加透徹地實踐響應式程式設計。我們回顧下前文中提到的該模式的好處:
- 統一資料來源,observable 之間可組合
- 宣告式程式設計,程式碼直爽簡潔
- 優秀的競態處理能力
- 測試友好
- 便於實現元件自治
因此,對於模型非同步任務的處理,我們選擇 redux-observable:
const user:Model<UserState> = {
name: 'user',
state: {
list: [],
// ...
},
reducers: {
// ...
},
flows: {
fetch(flow$, action$, state$) {
// ....
}
}
}
複製程式碼
與 epic 的函式簽名略有不同的是,每個 flow 多了一個 flow$
引數,以上例來說,它就相當於:
action$.ofType('user/fetch')
複製程式碼
這個引數便於我們更快的取到需要的 action。
處理載入態與錯誤態
前端工程中經常會有錯誤展示和載入展示的需求,
如果我們手動管理每個模型的載入態和錯誤態就太麻煩了,因此在根狀態下,單獨劃分兩棵狀態子樹用於處理載入態與錯誤態,這樣,便於框架去治理載入與錯誤,開發者直接在狀態樹上取用即可:
- loading
- error
如圖,載入態和錯誤態還需要根據粒度進行劃分,有大粒度的 flow 級別,用於標識一個 flow 是否正在進行中;也有小粒度的 service 級別,用於標識某個非同步服務是否在進行中。
例如,若:
loading.flows['user/fetch'] === true
複製程式碼
即表示 user model 下的 fetch
flow 正在進行中。
若:
loading.services['/api/fetchUser'] === true
複製程式碼
即表示 /api/fetchUser
這個服務正在進行中。
響應式的服務治理
前端呼叫後端服務操縱資料是一個廣泛的需求,因此,我們還希望所謂的中間角色(框架)能夠在我們的業務流中注入服務,完成服務和應用狀態的互動:觀察呼叫狀況,自動捕獲呼叫異常,適時地修改應用 loading 態和 error 態,方便使用者直接在頂層狀態取用服務執行狀況。
另外,在響應式程式設計的正規化下,框架提供的服務治理,在處理服務的成功和錯誤時應該也是響應式的,即成功和錯誤將是預定義的流(observable 物件),從而讓開發者能更好的利用到響應式程式設計的能力:
const user:Model<UserState> = {
name: 'user',
state: {
list: [],
total: 0
},
reducers: {
fetchSuccess(state, payload) {
return { ...state, list: payload.list, total: payload.total }
},
fetchError(state, payload) {
return { ...state, list:[] }
}
},
flows: {
fetch(flow$, action$, state$, dependencies) {
const { service } = dependencies
return flow$.pipe(
withLatestFrom(state$, (action, state) => {
// 拼裝請求引數
return params
}),
switchMap(params => {
const [success$, error$] = service(getUsers(params))
return merge(
success$.pipe(
map(resp => ({
type: 'user/fetchSuccess',
payload: {
list: resp.list,
total: resp.total
}
}))
),
error$.pipe(
map(error => ({
type: 'user/fetchError'
}))
)
)
})
)
}
}
}
複製程式碼
reobservable
上面的種種思考,概括下來其實就是 Dva architecture + redux-observable,前者能夠打掉 Redux 冗長囉嗦的樣板程式碼,後者則負責非同步任務治理。
比較遺憾的是,Dva 沒有使用 redux-observable 進行副作用管理,也沒有相關外掛實現使用 redux-observable 或者 RxJS 進行副作用管理,並且,通過 Dva 暴露的 hook 去實現一個 redux-observable 的 Dva 中介軟體也頗為不暢,因此,筆者嘗試撰寫了一個 reobservable 來實現上面提到框架,它與 Dva 不同的是:
- 只關注應用狀態,不涉及元件路由的其他生態
- 整合 loading 和 error 處理
- 使用 redux-observable 而不是 redux-saga 處理副作用
- 響應式的服務處理,支援應用自定義服務細節
如果你的應用使用了 Redux,你苦於 Redux 種種負面影響,並且你還是一個響應式程式設計和 RxJS 的愛好者,你可以嘗試下 reobservable。但是如果你偏愛 saga,或者 async await,你還是應該選擇 Dva 或者 Rematch,術業有專攻。
參考資料
關於本系列
- 本系列將從介紹 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 的開源。
- 感謝小雨的設計支援。