如何設計Redux的store?html
這幾乎是Redux在實踐中被問到最多的問題,或許你有本身的方式,卻總以爲哪裏不太對勁。這篇文章但願從狀態是什麼,到Elm中的狀態管理,最後與Redux分析和對比,試圖找到問題,並推導可行的改良方式。前端
Domain data很是好理解,他們直接來源於服務端對領域模型的抽象,好比user、product。它們可能被應用的多個地方用到,好比當前user包含的權限信息全部涉及鑑權的地方都須要。react
一般,前端對Domain data最大的管理需求是和服務端保持同步,不會有頻繁和複雜的變動——若是有的話請考慮合併批處理和轉移複雜度到服務端。git
甚至有很多頁面僅在初始化時獲取一次Domain data,今後就再無瓜葛,直到跳轉到下一個頁面。github
決定當前UI如何展現的狀態,好比一個彈窗的開閉,下拉菜單是否打開。web
在我看來,UI state是前端真正開始複雜的部分——若是僅僅依靠服務端拿下來的Domain data就能作好前端,backbone的Model早就一統江湖了,沒後來者們什麼事情。redux
和Domain data的簡單、穩定不一樣,UI state是多變,不穩定的——不一樣的頁面有不一樣、甚至類似但又細微不一樣的展示和交互。segmentfault
同時,UI state之間也是互相影響的,好比選擇列表中的元素(選中狀態是ui state),當選中數量低於N時禁用提交按鈕(按鈕是否禁用也是ui state)。這是前端工做中很是常見的需求,整個場景中沒有Domain data出現。websocket
UI state多變、不穩定,但它仍然是須要被複用的。小到彈窗的開閉,大到表單的管理,他們的邏輯都是明顯可被抽象的。react-router
App級的狀態,例如當前是否有請求正在加載。我的傾向將它們視爲另外一種抽象角度下的UI state。由於本質上它們仍然是服務於UI的:一個異步下拉框會發請求,加載頁面主要信息也會發請求,而咱們一般但願前者加載時只disable下拉框,然後者可能要用Loading mask遮罩整個頁面——場景不一樣,對狀態的需求就不一樣,單純關注當前是否有請求正在加載
沒有意義,只有與UI場景結合纔會產生價值,所以我傾向認爲App state的本質是對UI state的再抽象。
由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.
這基本表明了現在社區的主流實踐,它包含了兩個主要觀點:
Store表明了應用的狀態(store represents the core of your application)
使用domain data和app state做爲store的主要抽象依據
不多有人質疑過這兩點的正確性,由於第一點和Flux社區一脈相承,第二點不管看起來仍是寫起代碼來都顯得瓜熟蒂落。
有沒有可能這兩點纔是Redux實踐的問題所在?
在往下討論以前,不妨看看Redux最重要的借鑑對象——Elm是如何管理狀態的。
先用一張圖表達Elm的架構:
結合代碼往下看,首先在Elm中定義一個組件Counter,沒有Elm相關基礎也不要緊,能夠結合註釋理解大概便可:
-- 定義數據模型 type alias Model = Int -- 定義消息 type Msg = Increment | Decrement -- 定義更新函數 update : Msg -> Model -> Model update msg model = case msg of Increment -> model + 1 Decrement -> model - 1 -- 定義渲染函數 view : Model -> Html Msg view model = div [] [ button [onClick Decrement] [text "-"] , text (toString model) , button [onClick Increment] [text "+"] ] -- 定義初始數據 initModel : Model initModel = 3
有人可能要問了,"組件呢?在哪?這幾個變量哪一個是組件?"。答案是:加在一塊兒就是。
這是Elm架構的標誌:每一個組件都被分紅了Model/View/Update/Msg四個部分。
當它須要做爲應用單獨運行時,就將這幾個部分"綁"在一塊兒:
main = App.beginnerProgram {model = initModel, view = view, update = update}
而當它須要被上層組件使用時,則由上層組件使用這些分立的元件構建本身的對應部分,下面是使用Counter構建一個CounterList:
如下主要關注對Counter.XXX的使用
import Counter -- 使用Counter.Model組合新的Model type alias IndexedCounter = {id: Int, counter: Counter.Model} type alias Model = {uid: Int, counters: List IndexedCounter} -- 使用Counter.Msg 組合新的Msg type Msg = Insert | Remove | Modify Int Counter.Msg update : Msg -> Model -> Model update msg model = case msg of Modify id counterMsg -> let counterMapper = updateCounter id counterMsg -- 調用updateCounter函數 in {model | counters = List.map counterMapper model.counters} -- 調用Counter.update updateCounter : Int -> Counter.Msg -> IndexedCounter -> IndexedCounter updateCounter id counterMsg indexedCounter = if id == indexedCounter.id then {indexedCounter | counter = Counter.update counterMsg indexedCounter.counter} else indexedCounter view : Model -> Html Msg view model = div [] [ button [onClick Insert] [text "Insert"] , button [onClick Remove] [text "Remove"] , div [] (List.map showCounter model.counters) -- 調用showCounter ] -- 調用Counter.view showCounter : IndexedCounter -> Html Msg showCounter ({id, counter} as indexedCounter) = App.map (\counterMsg -> Modify id counterMsg) (Counter.view counter) -- 調用Counter.initModel initModel = {uid= 0, counters = [{id= 0, counter= Counter.initModel}]}
能夠看到,上層組件一樣是分紅了四個部分,而每一個部分都分別調用了子組件的對應元素。
整個Elm的組件樹,就是這樣一層層組合起來,直到最頂層,仍然是分立的四部分,須要運行時,才被粘合到一塊兒。
最終被運行的根節點組件,不管是Model、View仍是Update,都是由整個組件樹上無數個小組件組合出來的,在組合的過程當中,只有使用A組件的Model
,而不會有使用User Model
——整個架構從抽象、到組合,都是徹底面向組件,而非面向領域模型的。
在談論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: combineReducers({ counter1: counterReducer, counter2: counterReducer, }) })
這個例子既體現了Redux相對於Flux的進步(在Flux/Reflux中,要複用counter的邏輯很是困難),也體現了Redux在store設計上的尷尬:
Domain data與UI state混搭
理論上頁面有無窮多個,將來rootReducer裏還須要裝下page(CDEFG)
rootReducer具備全局性,而頁面、組件一般是局部的,修改全局去服務局部是bad smell
"如何設計Redux的store?"這個問題的背後,即是如上所述的,Redux在設計上相對於Elm的偏離致使的。這種偏離致使Redux仍然不能很是好地駕馭UI state,最終不得不表示"You might not need Redux"和"setState is OK"。
客觀地講,脫離組件樹定義的Reducer並不是一無可取。它確實很難處理細碎、嵌套的UI狀態。但在處理某一"類"UI狀態時卻顯得駕輕就熟——有些UI狀態是能夠被脫離組件樹抽象的(相似前面提到的App state)。
一個著名的例子是redux-form,它把表單這一"類"行爲進行了抽象,而且掛載在根reducer下:
import { createStore, combineReducers } from 'redux' import { reducer as formReducer } from 'redux-form' const reducers = { // ... your other reducers here ... form: formReducer // <---- Mounted at 'form' } const reducer = combineReducers(reducers) const store = createStore(reducer)
相似的例子還有全局的錯誤處理、loading狀態管理以及模態窗的開閉管理。他們都是脫離組件樹定義Reducer帶來正面價值的案例——對於行爲高度固定的、沒有複雜嵌套關係的UI狀態,脫離組件樹幾乎不會帶來抽象上的缺失,用全局的方式進行抽象是可行的。
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)
它能夠從兩方面緩解上述問題:
抽象問題:每一個Page獨立建立store,解決狀態樹的一對多問題,一個狀態樹(store)對應一個組件樹(page),page在設計store時不用考慮其它頁面,僅服務當前頁。固然,因爲Reducer仍然須要獨立於組件樹聲明,抽象問題並無根治,面向領域數據和App state的抽象仍然比UI state更天然。它僅僅帶給你更大的自由度:再也不擔憂有限的狀態樹如何設計才能知足近乎無限的UI state。
刷新、分享隱患:每一個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之間的監聽關係。這一點確實是我最初沒有考慮到的。
爲了應對這個問題,我考慮了幾種方案:
回到應用單一store:pageReducer的特性經過store.replaceReducer完成。當初爲每一個頁面建立store是想讓狀態完全隔離,而在replaceReducer後頁面之間若是有相同的reducer則狀態不會被重置,這是一個擔憂點。同時一個反作用是犧牲掉每一個page定製化middleware的能力
爲這類跨頁面的action創建一個隊列,在上個頁面將action推動隊列,下個頁面取出再執行。此方案屬於頭痛醫頭,只能解決當前的case,對於websocket等相似問題比較無力。
定製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> ); } };