8

轻量级Redux多实例方案

 3 years ago
source link: https://zhuanlan.zhihu.com/p/80105476
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

轻量级Redux多实例方案

阿里巴巴集团 前端工程师

也许你会问,Redux多实例与Redux Sub-Apps是什么关系。这里首先需要解释一下,下文中即将要阐述的内容与Sub-Apps不同, Sub-Apps 主要是将大型的BigApp组件拆分成多个较小的SubApp组件,它们之间是完全独立的,不会共享数据和逻辑状态。Sub-Apps一般用于比较大型的项目,且不同SubApp之间是没有数据通信的。而本文讲的Redux多实例方案主要侧重于组件级别,目的是为用户构建可复用的Redux实例,并达到轻量级的目的。

如何更好地实现状态逻辑复用一直都是应用程序中重要的一部分,这直接关系到应用程序的质量以及维护的难易程度。在Redux应用中,我们往往将组件划分为展示型组件(Presentational Components)与容器型组件(Container Components),这种设计模式的划分方式虽然比较教条主义,但确实能够将数据逻辑与组件的其他复杂的交互形式分离开来,通用的架构如下图所示。

v2-9d8e2d396ac86ec395b7aefe72248686_720w.jpgRedux 架构图

在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

  1. 利用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贡献方案与代码。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK