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 --