如何打造一款简单易用的 React 状态管理工具

297 阅读7分钟

React 的状态管理已经是一个老生常谈的问题了。从 React 内置的 Context APIHooks API,到第三方库如 ReduxMobxRecoil,再到二次封装的库如 Rematch 和国内用户熟知的 dvajs 等等。可见社区对状态管理是如此的纠结,如果你也面对同样的纠结而无从下手,下面我将为你介绍如何基于 Redux 二次封装一个轻量级但是简单易用的状态管理工具。

主要内容

  • 为什么不用 Rematch 或 dvajs ?
  • 声明式状态管理文件 model.ts
  • 解析 model 文件并创建 Redux Store 实例
    • index.ts
    • parseModel.ts
    • configureStore.ts
  • 如何使用?
    • 最简单的用法
    • 全局状态的使用方法
    • 组件内部状态的使用方法
    • 有副作用的异步逻辑的用法
  • 有哪些优势?
  • 参考资料

为什么不用 Rematchdvajs ?

这两个工具都是基于 Redux 进行了二次封装。它们有一个共同点,就是将状态管理逻辑以声明式的方式全部抽象到一个 model 文件里,减少了很多 Redux 的各种范式代码(其实 Redux 也意识到了这一点,所以现在官方极力推荐他们的 redux-toolkit 方案),后面会详细描述 model 是个什么玩意儿。

dvajs 是一个很优秀的解决方案,功能齐全,还支持热更新。本人在早期使用 umijs 脚手架的时候是使用了 dva 插件来做为我的状态管理工具。它引入了 redux-saga 来管理副作用的部分,总的来说很好用,只是需要额外了解一下 redux-saga

Rematch 考虑得很周到,它将 reducers 的调用直接注入到 dispatch 的属性里,省去了不少 dispatch 的范式代码,这一点是我非常喜欢的,不过它做得不彻底,应该连 effects 也一起做到就比较好了。另外一点是,它的副作用部分是基于 Redux 的中间件来实现的,所以不必依赖 redus-saga 或者 redux-thunk 等第三方库,上手成本要稍低一点。

那为什么还要自己去实现一个类似的工具呢?目的只有一个,就是要让状态管理更简单些:

  • reducerseffects 的调用函数自动注入到 dispatchers
  • 使全局状态逻辑使用更容易,同时也可以接管组件的内部状态管理逻辑

接下来将详细介绍如何一步步实现我所需要的功能。

本文所介绍的实现方案在 github 上有源码(@olajs/modx),本文的示例代码也基于这个工具。

声明式状态管理文件 model.ts

dvajsRematch 都采用了声明式的 model 文件来集中管理状态的流转逻辑,再通过代码将文件内容拆分成 Redux 需要的各个部分。下面是一个简单的 model 文件示例:

import { createModel } from "@olajs/modx";

export default createModel({
  // 为不同的 model 在 state 中分配不同的命名空间
  namespace: "modelA",
  // 将会合并入 store 的 initialState
  state: {
    counter: 0,
  },
  // 必要的 reducer
  reducers: {
    plus: (state) => ({ counter: state.counter + 1 }),
    minus: (state) => ({ counter: state.counter - 1 }),
  },
  // 有副作用的逻辑,将自动转换为 redux 的 middleware
  effects: {
    lazyPlus({ timeout }: { timeout: number }) {
      const { prevState } = this;
      console.log(prevState); // { counter: xxx }
      setTimeout(() => {
        this.plus();
      }, timeout);
    },
    lazyMinus({ timeout }: { timeout: number }) {
      const { prevState } = this;
      console.log(prevState); // { counter: xxx }
      setTimeout(() => {
        this.minus();
      }, timeout);
    },
  },
});

解析 model 文件并创建 Redux Store 实例

接下来是要将 model 文件的内容转换成创建 Redux Store 需要的代码:

  • namespace:为不同的 modelstate 中分配不同的命名空间
  • state:作为当前 model 的状态描述,初始值会被并入 StoreinitialState
  • reducers: 没有副作用的 reducer 函数
  • effects:有副作用的函数,自动转换成 Reduxmiddleware

@olajs/modx/index.ts

import {
  Store,
  ModelConfig,
  ModelAction,
  Reducer,
  Dispatch,
  CreateModelOptions,
} from "./types";
import parseModel from "./parseModel";
import configureStore from "./configureStore";

/**
 * 创建一个 redux store 实例
 * 注意这里 modelConfigs 参数是一个数组,一个应用可能有多个 model
 */
export function createStore(
  initialState: any,
  modelConfigs: ModelConfig[],
  extra?: { devTools?: boolean }
): Store {
  // 是否要关联 redux 的 devTool
  // 一般在全局使用时开启,作为组件状态管理时不开启
  const { devTools } = extra || {};
  const reducers = {};
  const middlewares: any[] = [];

  modelConfigs.forEach((modelConfig) => {
    const { namespace } = modelConfig;
    const model = parseModel(modelConfig);
    if (reducers[namespace]) {
      throw new Error("Duplicated namespace: " + namespace);
    }
    if (model.reducer) {
      reducers[namespace] = model.reducer;
    }
    if (model.middleware) {
      middlewares.push(model.middleware);
    }
  });

  return configureStore({ initialState, reducers, middlewares, devTools });
}

/**
 * 包裹 model 声明配置,主要是为了类型推断
 **/
export function createModel<Namespace, State, Reducers, Effects>(
  modelConfig: CreateModelOptions<Namespace, State, Reducers, Effects>
): {
  namespace: Namespace;
  state: State;
  reducers?: Reducers;
  effects?: Effects;
} {
  return modelConfig as any;
}

export { Store, ModelConfig, ModelAction, Reducer, Dispatch };

@olajs/modx/parseModel.ts

import { ModelConfig } from "./types";
import { Middleware, Reducer } from "redux";

// 要注入的 effects 的 this 属性
const EFFECT_THIS_KEYS = [
  "namespace",
  "store",
  "next",
  "prevState",
  "dispatcher",
];

/**
 * 解析 model 数据
 */
export default function parseModel(modelConfig: ModelConfig): {
  reducer: Reducer;
  middleware: Middleware;
} {
  const { namespace, reducers = {}, effects = {} } = modelConfig;
  // reducers 和 effects 的方法名不允许重复,因为 reducers 的方法后面会自动注入到 effects 里
  Object.keys(reducers).forEach((key) => {
    if (effects.hasOwnProperty(key)) {
      throw new Error(
        `[modx: ${namespace}] method "${key}" defined in both reducers and effects`
      );
    }
  });
  return {
    reducer: createReducer(modelConfig),
    middleware: createMiddleware(modelConfig),
  };
}

/**
 * 创建一个 reducer,将其 action 与 subject 相关联
 */
function createReducer({
  namespace,
  reducers = {},
  state: initialState,
}): Reducer {
  const converted = {};
  Object.keys(reducers).forEach((actionType: string) => {
    if (EFFECT_THIS_KEYS.includes(actionType)) {
      throw new Error(
        `[modx: ${namespace}] reducers can not have method named "${actionType}"`
      );
    }
    converted[`${namespace}/${actionType}`] = reducers[actionType];
  });
  return function (state = initialState, action) {
    if (converted.hasOwnProperty(action.type)) {
      return converted[action.type](state, action);
    }
    return state;
  };
}

/**
 * 将 effects 解析成 redux middleware
 */
function createMiddleware({
  namespace,
  reducers = {},
  effects = {},
}: ModelConfig): Middleware {
  const converted = {};
  Object.keys(effects).forEach((actionType) => {
    if (EFFECT_THIS_KEYS.includes(actionType)) {
      throw new Error(
        `[modx: ${namespace}] effects can not have method named "${actionType}"`
      );
    }
    converted[`${namespace}/${actionType}`] = effects[actionType];
  });
  return (store) => (next) => (action) => {
    next(action);
    if (converted.hasOwnProperty(action.type)) {
      // 为 effects 的 this 变量注入额外的内容
      const thisType = {
        namespace,
        store,
        next,
        // 将当前 model 的 state 直接获取了传参,方便开发人员获取
        prevState: store.getState()[namespace],
        // 简化 store.dispatch() 方法的调用
        dispatcher(actionType: string, payload?: any) {
          store.dispatch({ type: actionType, payload });
        },
      };
      // reducers 的快捷方法
      Object.keys(reducers).forEach((key) => {
        thisType[key] = (payload: any) => {
          store.dispatch({ type: `${namespace}/${key}`, payload });
        };
      });
      // effects 的快捷方法
      Object.keys(effects).forEach((key) => {
        thisType[key] = (payload: any) => {
          store.dispatch({ type: `${namespace}/${key}`, payload });
        };
      });
      converted[action.type].call(thisType, action.payload);
    }
  };
}

@olajs/modx/configureStore.ts

import {
  createStore,
  compose,
  applyMiddleware,
  combineReducers,
  Middleware,
  Reducer,
} from "redux";
import { Store } from "./types";

/**
 * 创建一个 Redux Store 实例
 */
export default function configureStore({
  initialState,
  reducers,
  middlewares = [],
}: {
  initialState: any;
  reducers: { [key: string]: Reducer };
  middlewares: Middleware[];
}): Store {
  return createStore(
    combineReducers(reducers),
    initialState,
    compose(applyMiddleware(...middlewares))
  );
}

如何使用?

下面我们来尝试着使用我们开发的这个工具

最简单的用法

我们先从最简单的直接操作 store 的方法来验证我们编写的代码是否可用:

import { createStore } from "@olajs/modx";
import model from "./model";

const store = createStore({}, [model]);
const { namespace } = model;
console.log(store.getState()[namespace]);
// { counter: 0 }
store.dispatch({ type: `${namespace}/plus` });
console.log(store.getState()[namespace]);
// { counter: 1 }
store.dispatch({ type: `${namespace}/plus` });
console.log(store.getState()[namespace]);
// { counter: 2 }
store.dispatch({ type: `${namespace}/minus` });
console.log(store.getState()[namespace]);
// { counter: 1 }

全局状态的使用方法

为了能在组件中更简单的使用全局 State 及其流转逻辑,我们要在 @olajs/modx/index.ts 中增加两个辅助的方法:

  • useGlobalModel:获取指定 model 的全局状态的 React Hooks,主要用在函数组件中
  • withGlobalModel:包装一个拥有指定 model 的全局状态的组件,主要用在类组件中
// @olajs/modx/index.ts
export function useGlobalModel<T extends ModelConfig>(
  modelConfig: T
): UseModelResult<T> {
  const { namespace } = modelConfig;
  const store = useStore();
  const [state, setState] = useState<T["state"]>(store.getState()[namespace]);
  const [dispatchers] = useState(() => getDispatchers<T>(store, modelConfig));
  useEffect(() => {
    return store.subscribe(() => setState(store.getState()[namespace]));
  }, []);
  return { store, state, dispatchers };
}

export function withGlobalModel<T extends ModelConfig>(modelConfig: T) {
  return (
    SubComponent: React.ComponentType<{
      globalModel: UseModelResult<T>;
      [key: string]: any;
    }>
  ) =>
    React.memo(function withGlobalModelContainer(props: unknown) {
      const globalModel = useGlobalModel<T>(modelConfig);
      return <SubComponent {...props} globalModel={globalModel} />;
    });
}

/**
 * 获取指定 model 的 dispatchers 方法
 */
function getDispatchers<T extends ModelConfig>(
  store: Store,
  modelConfig: T
): GetDispatchers<T> {
  const { namespace } = modelConfig;
  const result = {};
  [
    ...Object.keys(modelConfig.reducers || {}),
    ...Object.keys(modelConfig.effects || {}),
  ].forEach((key: string) => {
    result[key] = function (payload: any) {
      store.dispatch({
        type: `${namespace}/${key}`,
        payload,
      });
    };
  });
  return result as GetDispatchers<T>;
}

export type GetDispatchers<T extends ModelConfig> = T["reducers"] &
  T["effects"] & {
    [P in keyof T["reducers"]]: (payload?: Partial<T["state"]>) => void;
  };

export type UseModelResult<T extends ModelConfig> = {
  store: Store;
  state: T["state"];
  dispatchers: GetDispatchers<T>;
};

现在我们利用这两个方法来操作全局状态:

App.tsx

import React from "react";
import { useGlobalModel } from "@olajs/modx";
import model from "./model";

function App() {
  const { state, dispatchers } = useGlobalModel(model);
  return (
    <div>
      {state.counter}
      <br />
      <button onClick={() => dispatchers.plus()}>plus</button>
      <br />
      <button onClick={() => dispatchers.minus()}>minus</button>
    </div>
  );
}
export default App;

main.tsx

import React from "react";
import ReactDom from "react-dom";
import { Provider } from "react-redux";
import { createStore } from "@olajs/modx";
import model from "./model";
import App from "./App";

const store = createStore({}, [model]);
ReactDom.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementByid("app")
);

组件内部状态的使用方法

为了能在组件的内部状态管理中使用本工具,同样需要在 @olajs/modx/index.ts 中增加两个辅助方法:

  • useSinglelModel:包装一个指定的 model 并集成对应的操作函数的 React hooks,主要用在函数组件中
  • withSinglelModel:包装一个指定的 model 并集成对应的操作函数的高阶组件,主要用在类组件中
export function useSingleModel<T extends ModelConfig>(
  modelConfig: T
): UseModelResult<T> {
  const [store] = useState(createSingleStore(modelConfig));
  const [state, setState] = useState(store.getState()[modelConfig.namespace]);
  const [dispatchers] = useState(() => {
    return getDispatchers<T>(store, modelConfig);
  });
  useEffect(() => {
    return store.subscribe(() =>
      setState(store.getState()[modelConfig.namespace])
    );
  }, []);
  return { store, state, dispatchers };
}

export function withSingleModel<T extends ModelConfig>(modelConfig: T) {
  return (
    SubComponent: React.ComponentType<{
      singleModel: UseModelResult<T>;
      [key: string]: any;
    }>
  ) => {
    return React.memo(function WithSingleModelContainer(props: unknown) {
      const singleModel = useSingleModel<T>(modelConfig);
      return <SubComponent {...props} singleModel={singleModel} />;
    });
  };
}

现在我们利用这两个方法来操作组件的内部状态:

类组件的使用方法:withSingleModel.tsx

import React from "react";
import { withSingleModel, UseModelResult } from "@olajs/modx";
import model from "./model";

type Props = {
  singleModel: UseModelResult<typeof model>,
};

@withSingleModel(model)
class WithSingleModel extends React.PureComponent<Props, any> {
  render() {
    const { state, dispatchers } = this.props.singleModel;
    return (
      <div>
        {state.counter}
        <br />
        <button onClick={() => dispatchers.plus()}>plus</button>
        <br />
        <button onClick={() => dispatchers.minus()}>minus</button>
      </div>
    );
  }
}
export default WithSingleModel;

函数组件的使用方法:useSingleModel.tsx

import React from "react";
import { useSingleModel } from "@olajs/modx";
import model from "./model";

function UseSingleModel() {
  const { state, dispatchers } = useSingleModel(model);
  return (
    <div>
      {state.counter}
      <br />
      <button onClick={() => dispatchers.plus()}>plus</button>
      <br />
      <button onClick={() => dispatchers.minus()}>minus</button>
    </div>
  );
}
export default UseSingleModel;

有副作用的异步逻辑的用法

import React from "react";
import { useSingleModel } from "@olajs/modx";
import model from "./model";

function useSingleModelLazy() {
  const { state, dispatchers } = useSingleModel(model);
  return (
    <div>
      {state.counter}
      <br />
      <button onClick={() => dispatchers.lazyPlus({ timeout: 3000 })}>
        plus
      </button>
      <br />
      <button onClick={() => dispatchers.lazyMinus({ timeout: 3000 })}>
        minus
      </button>
    </div>
  );
}
export default useSingleModelLazy;

有哪些优势?

  • 使用更简单:除了省去了 Redux 烦琐的范式代码以外,通过高阶组件和自定义 hooks 进一步简化了代码的编写
  • 统一了全局和组件内部的状态管理:这一点是我比较喜欢的,即使不想把组件的状态放到全局,也可以享受到工具带来的便捷;并且你可以随时把你的状态管理迁到全局或者从全局状态撤回内部,不需要做太多的改动
  • 使组件的单测更容易:众所周知,对 UI 组件编写单测(特别是交互比较复杂的组件)是比较麻烦的事情,如果使用本工具,就可以将对 UI 组件的单测转到对组件状态流转逻辑的单测编写,只要状态流转逻辑的正确性得到保证,整个 UI 组件的质量也就有了保证

参考资料