背景
也许你会问,Redux多实例与Redux Sub-Apps是什么关系。这里首先需要解释一下,下文中即将要阐述的内容与Sub-Apps不同, Sub-Apps 主要是将大型的BigApp组件拆分成多个较小的SubApp组件,它们之间是完全独立的,不会共享数据和逻辑状态。Sub-Apps一般用于比较大型的项目,且不同SubApp之间是没有数据通信的。而本文讲的Redux多实例方案主要侧重于组件级别,目的是为用户构建可复用的Redux实例,并达到轻量级的目的。
如何更好地实现状态逻辑复用一直都是应用程序中重要的一部分,这直接关系到应用程序的质量以及维护的难易程度。在Redux应用中,我们往往将组件划分为展示型组件(Presentational Components)与容器型组件(Container Components),这种设计模式的划分方式虽然比较教条主义,但确实能够将数据逻辑与组件的其他复杂的交互形式分离开来,通用的架构如下图所示。
Redux 架构图
在Hook出来之前,这样的模式一直是业界比较认可的方式,理解起来很自然,使用起来也很方便。除了展示型组件的状态逻辑复用技术外,在平时的业务开发中,数据流的业务逻辑复用也是非常重要的部分。你可能发现了自己经常需要复制粘贴一些重复的Redux代码,尽管有譬如iron-redux这样的库来帮助用户去除任何冗余的、形式化的代码,但是依然会有一些重复代码的累积。当然,你可以通过复用已经写好的Redux文件来减少代码的重复率,但有可能会出现意想不到的结果,比如 Container Components Demo。
示例中,两个表单的功能基本一致,如果只用一个Redux文件,程序会更加简单,维护也会更加容易。因此我们直接复用了reducer文件。这样确实能够简化程序,而结果是reducer会处理相同的action,导致触发一致的行为,出现意想不到的结果。
import { reducer as addTodoReducer } from "./AddTodo/indexRedux"; import { reducer as addPushReducer } from "./AddTodo/indexRedux"; const reducer = combineReducers({ addTodo: addTodoReducer, addPush: addPushReducer });
iron-connector就是构造轻量级的Redux多实例来解决此类问题的。同时iron-connector也针对上文中讨论的 Hooks 与 Presentational and Container Components 的设计模式问题,为用户提供了两种不同的轻量级的处理方案。
方案一:ironReducer 强化 Redux 中的 reducer ,构造多实例Redux。
见ironReducer Demo,关键步骤如下:
const reducer = { addTodo: ironReducer(originReducer, "addTodo"), addPush: ironReducer(originReducer, "addPush") }; function TodoApp() { return ( <div> <AddTodo as="addTodo" /> <AddPush as="addPush" /> </div> ); }
方案二:使用 Connector 高阶组件,动态添加 Redux 实例。
见 Connector Demo,关键步骤如下:
// 根目录store.tsx import { createStore } from "redux"; import { ironStore, Connecotr } from "iron-connector"; // 对redux的createStore增强 const store = ironStore(createStore)(rootReducer); // 使用Connecor对组件进行封装,绑定动态redux状态 export default () => { return ( <Connector as="addPush" reducer={reducer} actions={actions}> <AddPush /> </Connector> ); };
ironReducer方案:自助办理
多实例相对于单实例最核心的区别是在action中新增了譬如key的元数据来区分不同的reducer,通过ironReducer注入,并通过自定义的connect方法绑定视图层组件来构建Redux多实例。
使用高阶函数进行属性代理
方案一:ironReducer架构图
如上图所示,我们对Redux提供的一系列原生方法做了封装,通过高阶函数的属性代理,我们把元数据key注入到数据流中,构造了可复用的数据逻辑状态。
通过对比通用架构图以及ironReducer架构图,我们用一种更加通俗的方法来解释它们的不同:有一个富人,家里有一家面馆,只负责服务这个富人,面馆制作流程分为两条业务线,一条是制作不同的面条,一条是负责不同的浇头。不同的业务线有不同的步骤,两条业务线有条不紊,井井有序。有一天,这个人家里来了很多客人都要吃面。同样的两条流水线如何保证依然高效呢?这个富人想出了一个办法,每个客人进门先领一个编号,然后每个客人只需要把自己的需求跟编号告诉面馆。面馆的两条业务线根据编号按照要求做自己的本职工作就好,最后两条业务线做好面后,再分发给对应编号的客人。上面例子中的编号,就是架构图中的key,也就是软件工程里俗称的命名空间。
ironReducer的原理就是这样,我们通过一系列的高阶函数,来把命名空间注入到数据流中,以此达到Redux的复用。因此我们俗称这样的方案为自助办理。下面简单介绍一下改造的API。
- wrapAction: 接受外部注入的key,对action进行封装,添加元数据
- wrapDispatch: 对dispatch进行封装,根据传入的key分发对应的action
- bindActionCreators: 对bindActionCreator进行封装,根据传入的key构造不同的action creator
- connect: 注入原组件传入的key,将mapStateToProps 和mapDispatchToProps分别解构后传给原组件,这样我们在原组件内就可以直接用props 获取state 以及dispatch 函数
- ironReducer: 用户注入key的入口,对原来的reducer函数进行改造,返回新的reducer函数
Connector方案:中介服务
方案一中,我们通过一些高阶函数的改写来构建action元数据以及复用reducer,最终构造了轻量级的Redux的多实例方案。这样的方案个人认为是比较传统的,原理容易理解,大部分用户也是接受这样的改造。但使用起来还是比较麻烦的,如果刚开始使用,很可能会忘记使用ironReducer去绑定reducer。
上文中,我们简单提到过React Hook,这是一种让函数组件支持状态和其他React特性的全新方式,官方解读为这是下一个5年React与时俱进的开端。因此,在这么一个重要的节点上,我们的Redux多实例方案是否也能借鉴React Hook的思想和理念呢?其实,React Hook的产生其中一个重要的目的就是为了解决HOC和render props带来的问题。那么,我们应该去尝试使用React Hook的相关思路来解决Redux多实例问题。
HOC与React Hook的双剑合璧
React Hook 为状态逻辑层面的复用带来了一种全新的能力,当然它与HOC并不排斥,我们仍然可以使用熟悉的方法来提供更高阶的能力,但是现在我们的手中拥有了另外一种武器。Connector就是采用HOC+React Hook的方式实现,通过HOC的属性代理,我们可以拿到用户传来的reducer、action、以及key,再通过React Hook的状态逻辑层面的复用构造Redux的多实例。
那么我们是否要基于React-Redux写一个Hook版本呢?其实在React Hook发布后,社区异常的活跃,React-Redux就是其中一员。Hooks · React Redux V7版本在2019年6月底发布,正式推出了Hook版本的API。简单介绍一下新出API:
- useSelector:就是从redux的store对象中提取数据(state)。这个selector方法类似于之前的connect的mapStateToProps参数的概念。
- useDispatch:这个Hook返回Redux store中对dispatch函数的引用
- useStore:这个Hook返回 redux Provider组件的store对象的引用
下图为整个Connector架构图,核心就是利用HOC属性代理与React Hook的状态逻辑复用的能力。
方案二:Connector架构图
这里我们依然用面馆来举例子。上面的例子需要每个客人自己做一个行为,那就是确定编号。我们是否可以取消这个过程呢?富人又想出了一个方法,雇一个管家。管家自己把客人们编好号并告知面馆,待面馆烹饪好面后,再送给对应的客人。编号在面馆的流水线中依然存在,但客人在这个过程中对于编号是无感知的。
在这个例子中,React-Redux Hook就代表着这样的管家,只要雇佣了这个管家,管家就能帮助我们处理动态的Redux实例。我们俗称这样的方案为中介服务。下文主要介绍Connector方案的两个关键步骤。
ironStore:增强createStore,动态添加reducer
在ironStore中我们依然用ironReducer来注册新的reducer,通过采用store.replaceReducer进行动态添加。核心代码如下:
const injectAsyncReducers = (store, as, reducer) => { // 增加动态的多实例reducer store.asyncReducers[as] = ironReducer(reducer, as); // 动态插入reducer store.replaceReducer( combineReducers({ ...reducerMap, ...store.asyncReducers }) ); }; // 添加动态实例注册方法 (store as any).registerDynamicModule = ({ as, reducer }) => { injectAsyncReducers(store, as, reducer); };
Connector:HOC + React Hook
- 利用useStore拿到整个store,调用store.registerDynamicModule方法。此时进行动态Redux的注册。
2. 利用useDispatch以及自定义bindActionCreators方法生成新的action。
3. 利用useSelector,从store对象中提取数据。同时,我们通过shallowEqual以及React.memo对整个Connector进行了性能优化。
最终,我们只需要添加如下两行代码,就能动态创建Redux实例。
export default () => { return ( <Connector as="addTodo" reducer={reducer} actions={actions}> <AddTodo /> </Connector> ); };
小结
从设计模式的角度探讨React系技术栈的开发方式,都是围绕着如何优雅地复用来做的。从Mixins到HOC和Render Prop,再到现在的Hook,本质上都是在讲组件(函数)如何优雅地复用。对于数据流来说,亦是如此。不管是推崇单一数据流的Redux,还是基于Redux在分形(Fractal)架构的各类实践,如Dva,Refect等等。随着产品或者项目的不断发展,我们对于“复用”的探索也在不断深入。在我们团队19年年初沉淀的《给2019前端的5个建议》一文中,我们依然首选的还是基于Redux作为状态管理,这是我们在多个超过30万行代码的平台级产品中总结的最佳实践。
面对Redux数据流相同状态逻辑复用的问题,我们也积极思考,构建了iron-connector这样的工具来构建轻量级的Redux多实例方案。目前方案处于萌芽阶段,还有大量且重要的工作需要去做。比如兼容不支持React Hook的情况,Typescript类型自动推导,适配不同类型的项目等等。也欢迎小伙伴们为iron-connector贡献方案与代码。