Redux狀態管理之痛點、分析與改良

如何設計Redux的store?

這幾乎是Redux在實踐中被問到最多的問題,或許你有自己的方式,卻總覺得哪裡不太對勁。這篇文章希望從狀態是什麼,到Elm中的狀態管理,最後與Redux分析和對比,試圖找到問題,並推導可行的改良方式。

哪些狀態需要被管理?

Domain data

Domain data非常好理解,他們直接來源於服務端對領域模型的抽象,比如user、product。它們可能被應用的多個地方用到,比如當前user包含的許可權信息所有涉及鑒權的地方都需要。

通常,前端對Domain data最大的管理需求是和服務端保持同步,不會有頻繁和複雜的變更——如果有的話請考慮合併批處理和轉移複雜度到服務端。

甚至有不少頁面僅在初始化時獲取一次Domain data,從此就再無瓜葛,直到跳轉到下一個頁面。

UI state

決定當前UI如何展示的狀態,比如一個彈窗的開閉,下拉菜單是否打開。

在我看來,UI state是前端真正開始複雜的部分——如果僅僅依靠服務端拿下來的Domain data就能做好前端,backbone的Model早就一統江湖了,沒後來者們什麼事情。

和Domain data的簡單、穩定不同,UI state是多變,不穩定的——不同的頁面有不同、甚至相似但又細微不同的展現和交互。

同時,UI state之間也是互相影響的,比如選擇列表中的元素(選中狀態是ui state),當選中數量低於N時禁用提交按鈕(按鈕是否禁用也是ui state)。這是前端工作中非常常見的需求,整個場景中沒有Domain data出現。

UI state多變、不穩定,但它仍然是需要被複用的。小到彈窗的開閉,大到表單的管理,他們的邏輯都是明顯可被抽象的。

App state *

App級的狀態,例如當前是否有請求正在載入。個人傾向將它們視為另一種抽象角度下的UI state。因為本質上它們仍然是服務於UI的:一個非同步下拉框會發請求,載入頁面主要信息也會發請求,而我們通常希望前者載入時只disable下拉框,而後者可能要用Loading mask遮罩整個頁面——場景不同,對狀態的需求就不同,單純關注當前是否有請求正在載入沒有意義,只有與UI場景結合才會產生價值,因此我傾向認為App state的本質是對UI state的再抽象。

Redux社區的主流實踐

由Redux庫貢獻者之一維護的recipes提到了

Because the store represents the core of your application, you should define your state shape in terms of your domain data and app state, not your UI component tree.

這基本代表了如今社區的主流實踐,它包含了兩個主要觀點:

  1. Store代表了應用的狀態(store represents the core of your application)
  2. 使用domain data和app state作為store的主要抽象依據

很少有人質疑過這兩點的正確性,因為第一點和Flux社區一脈相承,第二點無論看起來還是寫起代碼來都顯得順理成章。

有沒有可能這兩點才是Redux實踐的問題所在?

在往下討論之前,不妨看看Redux最重要的借鑒對象——Elm是如何管理狀態的。

Elm 中的狀態樹

Elm簡介

先用一張圖表達Elm的架構:

圖:Unidirectional User Interface Architectures

結合代碼往下看,首先在Elm中定義一個組件Counter,沒有Elm相關基礎也沒關係,可以結合注釋理解大概即可:

-- 定義數據模型type alias Model = Int-- 定義消息type Msg = Increment | Decrement-- 定義更新函數update : Msg -> Model -> Modelupdate msg model = case msg of Increment -> model + 1 Decrement -> model - 1-- 定義渲染函數view : Model -> Html Msgview model = div [] [ button [onClick Decrement] [text "-"] , text (toString model) , button [onClick Increment] [text "+"] ]-- 定義初始數據initModel : ModelinitModel = 3

有人可能要問了,」組件呢?在哪?這幾個變數哪個是組件?」。答案是:加在一起就是。

這是Elm架構的標誌:每個組件都被分成了Model/View/Update/Msg四個部分。

當它需要作為應用單獨運行時,就將這幾個部分」綁」在一起:

main = App.beginnerProgram {model = initModel, view = view, update = update}

而當它需要被上層組件使用時,則由上層組件使用這些分立的元件構建自己的對應部分,下面是使用Counter構建一個CounterList:

以下主要關注對Counter.XXX的使用

import Counter-- 使用Counter.Model組合新的Modeltype alias IndexedCounter = {id: Int, counter: Counter.Model}type alias Model = {uid: Int, counters: List IndexedCounter}-- 使用Counter.Msg 組合新的Msgtype Msg = Insert | Remove | Modify Int Counter.Msgupdate : Msg -> Model -> Modelupdate msg model = case msg of Modify id counterMsg -> let counterMapper = updateCounter id counterMsg -- 調用updateCounter函數 in {model | counters = List.map counterMapper model.counters}-- 調用Counter.updateupdateCounter : Int -> Counter.Msg -> IndexedCounter -> IndexedCounterupdateCounter id counterMsg indexedCounter = if id == indexedCounter.id then {indexedCounter | counter = Counter.update counterMsg indexedCounter.counter} else indexedCounterview : Model -> Html Msgview model = div [] [ button [onClick Insert] [text "Insert"] , button [onClick Remove] [text "Remove"] , div [] (List.map showCounter model.counters) -- 調用showCounter ]-- 調用Counter.viewshowCounter : IndexedCounter -> Html MsgshowCounter ({id, counter} as indexedCounter) = App.map (counterMsg -> Modify id counterMsg) (Counter.view counter)-- 調用Counter.initModelinitModel = {uid= 0, counters = [{id= 0, counter= Counter.initModel}]}

可以看到,上層組件同樣是分成了四個部分,而每個部分都分別調用了子組件的對應元素。

整個Elm的組件樹,就是這樣一層層組合起來,直到最頂層,仍然是分立的四部分,需要運行時,才被粘合到一起。

最終被運行的根節點組件,無論是Model、View還是Update,都是由整個組件樹上無數個小組件組合出來的,在組合的過程中,只有使用A組件的Model,而不會有使用User Model——整個架構從抽象、到組合,都是完全面向組件,而非面向領域模型的。

Redux與Elm的差異

在談論Elm的Model/Update/Msg時,熟悉Redux的讀者應該很快就聯想到了Store/Reducer/Action,然而它們間的差異也是顯而易見的:Elm中Model/Update/Msg/View是創造組件時定義的,而Redux中的Reducer/Action則是在組件樹之外定義的。

脫離具體的組件與交互場景,面向組件抽象就變得非常困難,此時領域模型成了幾乎唯一可靠的抽象依據。

領域模型與組件樹無關,加上之前flux社區的慣性,社區很自然就把store做成了App級的全局單例。

然而,管理UI state的需求仍然存在,一個Web應用可以有無數個頁面,相應地有無數的UI state需要管理,如果狀態管理框架不能有效地解決它們,也就失去了存在的意義。

在Elm中,應用的狀態樹隨著組件樹而變化,假設組件樹的根結點是頁面,那麼頁面A和B的狀態樹必然是不同的,而Redux卻需要用唯一一個狀態樹,去滿足整個應用——N個組件樹(頁面)的需求,這顯然是有問題的。

因此在Redux中有reselect, 有normalize,有mapStateToProps,這些Elm中通通不存在的東西,它們面向的其實是同一個問題:狀態樹到組件樹如何映射。然而它們都只能起緩衝作用,因為狀態樹與組件樹一對N的關係並沒有改變。

舉個例子:A頁面有個複雜的Counter組件,我們希望它被狀態管理框架管理起來——這顯然比setState更清晰更易維護。於是我們設計了counterReducer,並把它放到了store中:

const rootReducer = combineReducers({ user: userReducer, product: productReducer, //添加counterReducer counter: counterReducer,})

假設B頁面用到了同樣的組件——但是需要兩個counter,現有的狀態樹就無法滿足需要了,只能改成:

const rootReducer = combineReducers({ user: userReducer, product: productReducer, //添加counterReducer pageA: combineReducers({ counter: counterReducer, }), pageB: composeReducer({ counter1: counterReducer, counter2: counterReducer, })})const composeReducer = reducers => (state = {}, action)=> _.mapValues( reducers, (reducer, key)=> (action.id === key ? reducer(state[key], action) : state[key] ) )

(註: 此處經過修正,感謝 @SToneX 勘誤 )

這個例子既體現了Redux相對於Flux的進步(在Flux/Reflux中,要復用counter的邏輯非常困難),也體現了Redux在store設計上的尷尬:

  1. Domain data與UI state混搭
  2. 理論上頁面有無窮多個,未來rootReducer里還需要裝下page(CDEFG)
  3. rootReducer具有全局性,而頁面、組件通常是局部的,修改全局去服務局部是bad smell

「如何設計Redux的store?」這個問題的背後,便是如上所述的,Redux在設計上相對於Elm的偏離導致的。這種偏離導致Redux仍然不能非常好地駕馭UI state,最終不得不表示」You might not need Redux」和」setState is OK」。

Reducer的優勢

客觀地講,脫離組件樹定義的Reducer並非一無是處。它確實很難處理細碎、嵌套的UI狀態。但在處理某一」類」UI狀態時卻顯得得心應手——有些UI狀態是可以被脫離組件樹抽象的(類似前面提到的App state)。

一個著名的例子是redux-form,它把表單這一」類」行為進行了抽象,並且掛載在根reducer下:

import { createStore, combineReducers } from reduximport { reducer as formReducer } from redux-formconst reducers = { // ... your other reducers here ... form: formReducer // <---- Mounted at form}const reducer = combineReducers(reducers)const store = createStore(reducer)

類似的例子還有全局的錯誤處理、loading狀態管理以及模態窗的開閉管理。他們都是脫離組件樹定義Reducer帶來正麵價值的案例——對於行為高度固定的、沒有複雜嵌套關係的UI狀態,脫離組件樹幾乎不會帶來抽象上的缺失,用全局的方式進行抽象是可行的。

題外話:WebApp場景下的隱患

Store對象存在於內存中,在用戶沒有刷新的情況下是一直存在並且可訪問的,而一旦用戶刷新、分享鏈接,Store就會重新創建。由於Store是」應用」級的,開發者使用Store中的數據時,很難知道數據在刷新、分享後是否可用。

舉個我曾經在另一篇博客中提到過的例子,一個業務流程有三個頁面A/B/C,用戶通常按順序訪問它們,每步都會提交一些信息,如果把信息存在Store中,在不刷新的情況下C頁面可以直接訪問A/B頁面存進Store的數據,而一旦用戶刷新C頁面,這些數據便不復存在,使用這些數據很可能導致程序異常。

如果在設計Store時,是像上面提到的store.pageA這樣的形式,情況會稍有緩解,因為至少開發者知道這個數據屬於pageA,對數據的來源有認知,如果Store是按領域模型劃分的,情況會變得非常糟:開發者在使用store.user這樣的數據時不可能知道這個數據是否可靠,最終要麼花費額外的精力去確認,要麼給應用留下隱患——顯然後者會是更常見的情況。

Store這個名字給人以」Storage」的錯覺,面向領域模型的設計使得這種錯覺被進一步鞏固。

從辯護的角度,這個問題不是Redux獨有,它是App級Store在Web場景下的通病,從Flux/Reflux開始就已經存在。另外也可以把問題推給開發者:你不確認數據的可靠性,出了問題怪誰?

然而,好的框架、範式應該具備足夠的」防禦性」,當前Redux的主流實踐在這個問題上並沒有給出讓人滿意的答案。

例:React-Redux的Real-World example就把分頁信息存進了store導致刷新後頁碼丟失

改良版的實踐

儘管Redux有上面提到的問題,但它在單向數據流、提倡純函數、解耦輸入與響應等方面仍然有非常大的價值。對上面提到的問題,我試圖通過改良實踐去緩解:Page獨立聲明reducers並創建store

這個過程可以使用高階組件封裝起來,代碼:

const defaultConfig = { pageReducers: {}, reducers: commonReducers, // import from other files middlewares: commonMiddlewares, // import from other files};const withRedux = config => (Comp) => { const finalConfig = { ...defaultConfig, ...config, }; const { middlewares, reducers } = finalConfig; return class WithRedux extends Component { constructor(props) { super(props); const reducerFn = combineReducers({ ...finalConfig.pageReducers, ...reducers, }); this.store = applyMiddleware( ...middlewares, )(createStore)(reducerFn); } render() { return ( <Provider store={this.store}> <Comp {...this.props} /> </Provider> ); } };};

接下來,只需要在依賴Redux的頁面使用withRedux即可:

const PageA = ()=> <div>A</div>;export default withRedux({ pageReducers: { foo, // 和commonReducers合併成最終頁面的reducer },// reducers: {}, // 直接替換commonReducers})(PageA)

它可以從兩方面緩解上述問題:

  1. 抽象問題:每個Page獨立創建store,解決狀態樹的一對多問題,一個狀態樹(store)對應一個組件樹(page),page在設計store時不用考慮其它頁面,僅服務當前頁。當然,由於Reducer仍然需要獨立於組件樹聲明,抽象問題並沒有根治,面向領域數據和App state的抽象仍然比UI state更自然。它僅僅帶給你更大的自由度:不再擔心有限的狀態樹如何設計才能滿足近乎無限的UI state。
  2. 刷新、分享隱患:每個Page創建的Store都是完全不同的對象,且只存在於當前Page生命周期內,其它Page不可能訪問到,從根本上杜絕跨頁面的store訪問。這意味著能夠從store中訪問到的數據,一定是可靠的

通過commonReducers/commonMiddleware可以方便復用一些全局性的解決方案,比如redux-thunk/redux-form。頁面默認使用commonReducers/commonMiddlewares,也可以完全不用,甚至頁面可以不使用redux。復用行為,而不是共用狀態,這是Redux相對於Flux最大的進步,現在我們將這個理念繼續推進。

問題

Q:是否違反了Redux三大核心原則之一——single source of truth?

A: 沒有,它只是明確了組件樹和狀態樹一一對應的關係,一個應用會有N個頁面,但不會同時顯示兩個頁面,因此,任何時刻當前頁面對應的狀態樹都是single source of truth。

Q:和社區主流庫集成是否會有問題

A: 是的,由於和社區主流實踐有差異,遇到問題是難以避免的。

假設你正在使用ReactRouter,採用上述方案後組件樹的結構將會變成 Router > Route > Provider > PageA,而react-router-redux則需要 Provider > ConnectedRouter > Route > PageA 這樣的組件結構:ConnectedRouter是react-router-redux引入的,依賴Provider向context中注入store,這意味著Redux的Provider必須是路由的父元素,和我們將Redux下放到頁面的思路相衝突。

對此,我們的選擇是:放棄react-router-redux。

我強烈建議你回顧當初引入 react-router-redux的原因:如果是希望通過action操作history,那麼一個獨立的中間件可以輕易做到;如果是希望通過store訪問location/history,在頁面初始化時把location/history放進store也非常簡單;如果不知道為什麼,僅僅因為它是全家桶的一部分——何不幹掉他試試?

在移除react-router-redux後,我們不僅沒有受到任何功能性的影響,反而使得架構層面的耦合更低了:路由與狀態管理方案不再有耦合關係。

這種從耦合中解放的感覺就像水裡穿著衣服游泳的人終於脫掉了外套,之前是視圖(react)-路由(router)-狀態管理(redux)相互耦合,卻並沒有帶來明顯的收益,而現在我們已經開始考慮換掉react-router了。

甚至,既然是由頁面決定是否引入Redux、使用哪些reducers/middlewares,那麼一個項目中不同的頁面採用不同技術棧是完全可行的,這允許你在某些頁面上大膽嘗試新的方案而不用擔心影響全局:架構上的低耦合使我們擁有更多的選擇餘地。

Q: 談到UI state,社區有以redux-ui為代表的方案,怎麼看?

A: 它們恰恰呼應了本文提到的另一個側面:Reducer的抽象問題。redux-ui讓組件狀態、行為與組件定義重新回到了一起,從而使」讓redux管理UI state」變得更自然。當然它也帶來了一些代碼結構上的限制,是否採用取決於具體場景下的考量。它和本文最後提倡的改良實踐並不衝突,甚至,改良版實踐能更容易地在部分頁面先行嘗試這些新方案。

小結

本文從Elm的角度剖析了Redux存在的問題,也分享了我目前採用的實踐方式,這個實踐方式不是神奇藥水,僅僅是權衡問題和現狀後的小步改良。

回顧和對比主流實踐的兩個重點:

改良前

改良後

Store代表了應用的狀態

Store代表了頁面(根組件)狀態

Domain data和App state作為store的主要抽象依據

沒有本質改變,但加入UI state的影響更低

從程序設計的角度,我相信改良後的實踐又進步了一點點:更低的耦合、更準確的對應關係、更可靠的數據依賴,與Elm也更加接近。

同時我也深知這還遠遠不夠,期待能有更好的實踐方式和更好的輪子出現。

更新

======================= 2017.08.27 更新 =======================

這個方案在實踐中,仍然遇到了一些問題,其中最最重要的,則是替換store後,跨頁面action的問題

舉個例子,通過thunk在a頁面觸發一個非同步action:

const asyncAction = ()=> (dispatch)=> { setTimeout(()=> { dispatch({type: SYNC_ACT}); // dispatch 為a頁面的store.dispatch }, 5000)}

如果在這5秒內,用戶跳轉到了另一個頁面,則會重新create一個store,而回調函數中的dispatch函數仍然指向上一個頁面的store。

如果我們把頁面看成完全獨立的"小應用",這樣的行為是說得通的,但作為一個網站有時候我們也希望有"連續"的用戶體驗和交互。在實際項目中我們遇到的情況是我們使用了redux管理模態窗的開閉狀態,而需求方希望在上一個頁面離開時打開一個模態窗,同時保持打開狀態並跳到下一個頁面,兩秒後模態窗消失。

同理,如果有類似websocket的需求,相關的thunk action也會不定時地觸發dispatch,無論當前在哪個頁面。

我反思了一下Elm中的情況,得到的答案是Elm中隨著組件樹變化的"狀態"是純數據,而store並非如此,它既包含了"狀態"數據,也持有了reducer/action之間的監聽關係。這一點確實是我最初沒有考慮到的。

為了應對這個問題,我考慮了幾種方案:

  1. 回到應用單一store:pageReducer的特性通過store.replaceReducer完成。當初為每個頁面創建store是想讓狀態徹底隔離,而在replaceReducer後頁面之間如果有相同的reducer則狀態不會被重置,這是一個擔心點。同時一個副作用是犧牲掉每個page定製化middleware的能力
  2. 為這類跨頁面的action建立一個隊列,在上個頁面將action推進隊列,下個頁面取出再執行。此方案屬於頭痛醫頭,只能解決當前的case,對於websocket等類似問題比較無力。
  3. 定製thunk middleware,通過閉包獲取最新的store

在權衡方案的通用性、理解難度等方面後,目前選擇了第一種。

其實改變沒有想像中的大,只是把withRedux函數改了一下,並且有一部分功能也不再支持,比如頁面覆蓋commonReducers和定製middleware:

import commonMiddlewares from ./commonMiddlewares;import commonReducers from ./commonReducers;const defaultConfig = { pageReducers: {}, reducers: commonReducers, middlewares: commonMiddlewares,};export const createReduxStore = (config) => { const finalConfig = { ...defaultConfig, ...config, }; const { middlewares, reducers } = finalConfig; const reducerFn = combineReducers({ ...reducers, }); return applyMiddleware( ...middlewares, )(createStore)(reducerFn);};const store = createReduxStore();const withRedux = config => Comp => class WithRedux extends Component { constructor(props) { super(props); if (config && config.pageReducers) { store.replaceReducer(combineReducers({ ...commonReducers, ...config.pageReducers, })); } } render() { return ( <Provider store={store}> <Comp {...this.props} /> </Provider> ); }};

推薦閱讀:

redux 中的 state 樹太大會不會有性能問題?
react redux 某個state變化之後 如何觸發一些操作?
集成 React 和 Datatables - 並沒有宣傳的那麼難
如何規模化React應用
redux middleware 詳解

TAG:React | Redux | 前端开发 |