在React 中處理數據流問題的一些思考
背景
相信大家在項目開發中,在頁面較複雜的情況下,往往會遇到一個問題,就是在頁面組件之間通信會非常困難。
比如說一個商品列表和一個已添加商品列表:

假如這兩個列表是獨立的兩個組件,它們會共享一個數據“被選中的商品”,在商品列表
選中一個商品,會影響已添加商品列表
,在已添加列表
中刪除一個商品,同樣會影響商品列表
的選中狀態。
它們兩個是兄弟組件,在沒有數據流框架的幫助下,在組件內數據有變化的時候,只能通過父組件傳輸數據,往往會有onSelectedDataChange
這種函數出現,在這種情況下,還尚且能忍受,如果組件嵌套較深的話,那痛苦可以想像一下,所以才有解決數據流的各種框架的出現。
本質分析
我們知道React是MVC
裡的V
,並且是數據驅動視圖的,簡單來說,就是数据=> 视图
,視圖是基於數據的渲染結果:
V = f(M)
數據有更新的時候,在進入渲染之前,會先生成Virtual DOM ,前後進行對比,有變化才進行真正的渲染。
V + ΔV = f(M + ΔM)
數據驅動視圖變化有兩種方式,一種是setState
,改變頁面的state
,一種是觸發props
的變化。
我們知道數據是不會自己改變,那麼肯定是有“外力”去推動,往往是遠程請求數據回來或者是UI
上的交互行為,我們統稱這些行為叫action
:
ΔM = perform(action)
每一個action
都會去改變數據,那麼視圖得到的数据(state)
就是所有action
疊加起來的變更,
state = actions.reduce(reducer, initState)
所以真實的場景會出現如下或更複雜的情況:

問題就出在,更新數據比較麻煩,混亂,每次要更新數據,都要一層層傳遞,在頁面交互複雜的情況下,無法對數據進行管控。
有沒有一種方式,有個集中的地方去管理數據,集中處理數據的接收,修改和分發?答案顯然是有的,數據流框架就是做這個事情,熟悉Redux
的話,就知道其實上面講的就是Redux
的核心理念,它和React
的數據驅動原理是相匹配的。
數據流框架
Redux
數據流框架目前占主要地位的還是Redux ,它提供一個全局Store
處理應用數據的接收,修改和分發。

它的原理比較簡單, View
裡面有任何交互行為需要改變數據,首先要發一個action
,這個action
被Store
接收並交給對應的reducer
處理,處理完後把更新後的數據傳遞給View
。 Redux
不依賴於任何框架,它只是定義一種方式控制數據的流轉,可以應用於任何場景。
雖然定義了一套數據流轉的方式,但真正使用上會有不少問題,我個人總結主要是兩個問題:
- 定義過於繁瑣,文件多,容易造成思維跳躍。
- 異步流的處理沒有優雅的方案。
我們來看看寫一個數據請求的例子,這是非常典型的案例:
actions.js
exportconstFETCH_DATA_START='FETCH_DATA_START';exportconstFETCH_DATA_SUCCESS='FETCH_DATA_SUCCESS';exportconstFETCH_DATA_ERROR='FETCH_DATA_ERROR';exportfunctionfetchData(){returndispatch=>{dispatch(fetchDataStart());axios.get('xxx').then((data)=>{dispatch(fetchDataSuccess(data));}).catch((error)=>{dispatch(fetchDataError(error));});};}exportfunctionfetchDataStart(){return{type:FETCH_DATA_START,}}...FETCH_DATA_SUCCESS...FETCH_DATA_ERROR
reducer.js
import{FETCH_DATA_START,FETCH_DATA_SUCCESS,FETCH_DATA_ERROR}from'actions.js';exportdefault(state={data:null},action)=>{switch(action.type){caseFETCH_DATA_START:...caseFETCH_DATA_SUCCESS:...caseFETCH_DATA_ERROR:...default:returnstate}}
view.js
import { createStore, applyMiddleware } from 'redux'; import thunk from 'redux-thunk'; import reducer from 'reducer.js'; import { fetchData } from 'actions.js'; const store = createStore(reducer, applyMiddleware(thunk)); store.dispatch(fetchData());
第一個問題,發一個請求,因為需要託管請求的所有狀態,所以需要定義很多的action
,這時很容易會繞暈,就算有人嘗試把這些狀態再封裝抽象,也會充斥著一堆模板代碼。有人會挑戰說,雖然一開始是比較麻煩,繁瑣,但對項目可維護性,擴展性都比較友好,我不太認同這樣的說法,目前還算簡單,真正業務邏輯複雜的情況下,會顯得更噁心,效率低且閱讀體驗差,相信大家也寫過或看過這樣的代碼,後面自己看回來,需要在actions
文件搜索一下action
的名稱, reducer
文件查詢一下,繞一圈才慢慢看懂。
第二個問題,按照官方推薦使用redux-thunk實現異步action
的方法,只要在action
裡返回一個函數即可,這對有強迫症的人來說,簡直受不了, actions
文件顯得它很不純,本來它只是來定義action
,卻竟然要夾雜著數據請求,甚至UI
上的交互!
我覺得Redux
設計上沒有問題,思路非常簡潔,是我非常喜歡的一個庫,它提供的數據的流動方式,目前也是得到社區的廣泛認可。然而在使用上有它的缺陷,雖然是可以克服,但是它本身難道沒有可以優化的地方?
dva
dva的出來就是為了解決redux
的開發體驗問題,它首次提出了model
的概念,很好地把action
、 reducers
、 state
結合到一個model
裡面。
model.js
exportdefault{namespace:'products',state:[],reducers:{'delete'(state,{payload:id}){returnstate.filter(item=>item.id!==id);},},};
它的核心思想就是一個action
對應一個reducer
,通過約定,省略了對action
的定義,默認reducers
裡面的函數名稱即為action
的名稱。
在異步action
的處理上,定義了effects(副作用)
的概念,與同步action
區分起來,內部借助了redux-saga來實現。
model.js
exportdefault{namespace:'counter',state:[],reducers:{},effects:{*add(action,{call,put}){yieldcall(delay,1000);yieldput({type:'minus'});},},};
通過這樣子的封裝,基本保持Redux
的用法,我們可以沉浸式地在model
編寫我們的數據邏輯,我覺得已經很好地解決問題了。
不過我個人喜好問題,不太喜歡使用redux-saga
這個庫來解決異步流,雖然它的設計很巧妙,利用了generator
的特性,不侵入action
,而是通過中間件的方式進行攔截,很好地將異步處理隔離出獨立的一層,並且以此聲稱對實現單元測試是最友好的。是的,我覺得設計上真的非常棒,那時候還特意閱讀了它的源碼,讚歎作者真的牛,這樣的方案都能想出來,但是後來我看到還有更好的解決方案(後面會介紹),就放棄使用它了。
mirrorx
mirrorx和dva
差不多,只是它使用了單例的方式,所有的action
都保存了actions
對像中,訪問action
有了另一種方式。還有就是處理異步action
的時候可以使用async/await
的方式。
importmirror,{actions}from'mirrorx'mirror.model({name:'app',initialState:0,reducers:{increment(state){returnstate+1},decrement(state){returnstate-1}},effects:{asyncincrementAsync(){awaitnewPromise((resolve,reject)=>{setTimeout(()=>{resolve()},1000)})actions.app.increment()}}});
它內部處理異步流的問題,類似redux-thunk
的處理方式,通過注入一個中間件,這個中間件裡判斷當前action
是不是異步action
(只要判斷是不是effects
裡定義的action
即可),如果是的話,就直接中斷了中間件的鍊式調用,可以看看這段代碼。
這樣的話,我們effects
裡的函數就可以使用async/await
的方式調用異步請求了,其實不是一定要使用async/await
,函數里的實現沒有限制,因為中間件只是調用函數執行而已。
我是比較喜歡使用async/await
這種方式處理異步流,這是我不用redux-saga
的原因。
xredux
但是我最終沒有選擇使用mirrorx
或dva
,因為用它們就捆綁一堆東西,我覺得不應該做成這樣子,為啥好好的解決Redux
問題,最後變成都做一個腳手架出來?這不是強制消費嗎?讓人用起來就會有限制。了解它們的原理後,我自己參照寫了個xredux出來,只是單純解決Reudx
的問題,不依賴於任何框架,可以看作只是Redux
的升級版。
使用上和mirrorx
差不多,但它和Redux
是一樣的,不綁定任何框架,可以獨立使用。
importxreduxfrom"xredux";conststore=xredux.createStore();constactions=xredux.actions;// This is a model, a pure object with namespace, initialState, reducers, effects.xredux.model({namespace:"counter",initialState:0,reducers:{add(state,action){returnstate+1;},plus(state,action){returnstate-1;},},effects:{asyncaddAsync(action,dispatch,getState){awaitnewPromise(resolve=>{setTimeout(()=>{resolve();},1000);});actions.counter.add();}}});// Dispatch action with xredux.actionsactions.counter.add();
在異步處理上,其實也存在問題,可能大家也遇到過,就是數據請求有三種狀態的問題,我們來看看,寫一個數據請求的effects
:
importxreduxfrom'xredux';import{fetchUserInfo}from'services/api';const{actions}=xredux;xredux.model({namespace:'user',initialState:{getUserInfoStart:false,getUserInfoError:null,userInfo:null,},reducers:{// fetch startgetUserInfoStart(state,action){return{...state,getUserInfoStart:true,};},// fetch errorgetUserInfoError(state,action){return{...state,getUserInfoStart:false,getUserInfoError:action.payload,};},// fetch successsetUserInfo(state,action){return{...state,userInfo:action.payload,getUserInfoStart:false,};}},effects:{asyncgetUserInfo(action,dispatch,getState){letuserInfo=null;actions.user.getUserInfoStart();try{userInfo=awaitfetchUserInfo();actions.user.setUserInfo(userInfo);}catch(e){actions.user.setUserInfoError(e);}}},});
可以看到,還是存在很多感覺沒用的代碼,一個請求需要3個reducer
和1個effect
,當時想著怎麼優化,但沒有很好的辦法,後來我想到這3個reducer
有個共同點,就是只是賦值,沒有任何操作,那我內置一個setState
的reducer
,專門去處理這種只是賦值的action
就好了。
最後變成這樣:
importxreduxfrom'xredux';import{fetchUserInfo}from'services/api';const{actions}=xredux;xredux.model({namespace:'user',initialState:{getUserInfoStart:false,getUserInfoError:null,userInfo:null,},reducers:{},effects:{asyncgetUserInfo(action,dispatch,getState){letuserInfo=null;// fetch startactions.user.setState({getUserInfoStart:true,});try{userInfo=awaitfetchUserInfo();// fetch successactions.user.setState({getUserInfoStart:false,userInfo,});}catch(e){// fetch erroractions.user.setState({getUserInfoError:e,});}}},});
這個目前是自己比較滿意的方案,在項目中也有實踐過,寫起來確實比較簡潔易懂,不知大家有沒有更好的辦法。
貧血組件/充血組件
使用了Redux
,按道理應用中的狀態數據應該都放到Store
中,那組件是否能有自己的狀態呢?目前就會有兩種看法:
- 所有狀態都應該在
Store
中託管,所有組件都是純展示組件。 - 組件可擁有自己的部分狀態,另外一些由
Store
託管。
這兩種就是分別對應貧血組件和充血組件,區別就是組件是否有自己的邏輯,還是說只是純展示。我覺得這個問題不用去爭論,沒有對錯。
理論上當然是說貧血組件好,因為這樣保證數據是在一個地方管理的,但是付出的代價可能是沉重的,使用了這種方式,往往到後面會有想死的感覺,一種想回頭又不想放棄的感覺,其實沒必要這麼執著。
相信大家幾乎都是充血組件,有一些狀態只與組件相關的,由組件去託管,有些狀態需要共享的,交給Store
去託管,甚至有人所有狀態都有組件託管,也是存在的,因為頁面太簡單,根本就不需要用到數據流框架。
總結
在React
開發中不可避免會遇到數據流的問題,如何優雅地處理目前也沒有最完美的方案,社區也存在各種各樣的方法,可以多思考為什麼是這樣做,了解底層原理比盲目使用別人的方案更重要。
如果想詳細了解xredux如何在React
中運用,可以使用RIS初始化一個Standard應用看看,之前的文章《RIS,創建React應用的新選擇》有簡單提過,歡迎大家體驗。