更好用的 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 的負面影響時,切到其他的狀態管理方案(例如 ofollow,noindex">mobx 或者 mobx-state-stree) ,也不太現實,一方面是遷移成本大,一方面你也不知道新的狀態管理方案是否就是銀彈。但是,對 Redux 的負面影響無動於衷或者忍氣吞聲,也只會讓問題越滾越大,直到失控。
在開始討論如何更好地 Redux 之前,我們需要明確一點,樣板程式碼和非同步能力的缺乏, 是 Redux 自身設計的結果,而非目的 ,換句話說,Redux 設計出來,並不是要讓開發者去撰寫樣本程式碼,或者去糾結怎麼處理非同步狀態更新。
我們需要再定義一個角色,讓他來代替我們去寫樣板程式碼,讓他給予我們最優秀的非同步任務處理能力,讓他負責一切 Redux 中惡心的事兒。因此,這個角色就是一個讓 Redux 變得更加優雅的框架,至於如何建立這個角色,需要我們從單個元件開始,重新梳理下應用形態,並著眼於:
- 如何打掉 Redux 的樣板程式碼
- 如何更優雅地處理非同步任務
元件的樣子
一個元件的生態大概是這樣的:

即:資料經處理形成頁面狀態,頁面狀態決定 UI 渲染。
應用的樣子
而元件生態(UI + 狀態 + 狀態管理方式)的組合就構成了我們應用:

這裡元件生態特意只展示了 資料到狀態 這一步,因為 Redux 處理的正是這個部分。我們暫且可以定義資料到狀態的過程為 flow ,即一個業務流的意思。
應用劃分
借鑑於 Elm,我們可以按資料模型對應用進行劃分:

其中,模型具有的屬性有:
name state reducers selectors flows
這個經典的劃分模型正是 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,術業有專攻。