在React 中處理數據流問題的一些思考

在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 ,這個actionStore接收並交給對應的reducer處理,處理完後把更新後的數據傳遞給ViewRedux不依賴於任何框架,它只是定義一種方式控制數據的流轉,可以應用於任何場景。

雖然定義了一套數據流轉的方式,但真正使用上會有不少問題,我個人總結主要是兩個問題:

  1. 定義過於繁瑣,文件多,容易造成思維跳躍。
  2. 異步流的處理沒有優雅的方案。

我們來看看寫一個數據請求的例子,這是非常典型的案例:

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的概念,很好地把actionreducersstate結合到一個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

mirrorxdva差不多,只是它使用了單例的方式,所有的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

但是我最終沒有選擇使用mirrorxdva ,因為用它們就捆綁一堆東西,我覺得不應該做成這樣子,為啥好好的解決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有個共同點,就是只是賦值,沒有任何操作,那我內置一個setStatereducer ,專門去處理這種只是賦值的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應用的新選擇》有簡單提過,歡迎大家體驗。

參考資料

React 16 新特性全解(上)

TypeScript,初次見面,請多指教