04月21, 2022

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

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

内容导航

为什么不用 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 文件示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
  • 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
  • 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
  • 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
  • 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 的方法来验证我们编写的代码是否可用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  • 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 的全局状态的组件,主要用在类组件中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
  • // @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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  • 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
  • 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 并集成对应的操作函数的高阶组件,主要用在类组件中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
  • 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  • 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  • 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;

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  • 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 组件的质量也就有了保证

参考资料

本文链接:https://www.chenliqiang.cn/post/how-to-build-react-state-management-tool.html

-- EOF --