如何设计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> ); } };