React
的状态管理已经是一个老生常谈的问题了。从 React
内置的 Context API
和 Hooks API
,到第三方库如 Redux
、Mobx
和 Recoil
,再到二次封装的库如 Rematch
和国内用户熟知的 dvajs
等等。可见社区对状态管理是如此的纠结,如果你也面对同样的纠结而无从下手,下面我将为你介绍如何基于 Redux
二次封装一个轻量级但是简单易用的状态管理工具。
内容导航
为什么不用 Rematch
或 dvajs
?
这两个工具都是基于 Redux
进行了二次封装。它们有一个共同点,就是将状态管理逻辑以声明式的方式全部抽象到一个 model
文件里,减少了很多 Redux
的各种范式代码(其实 Redux
也意识到了这一点,所以现在官方极力推荐他们的 redux-toolkit 方案),后面会详细描述 model
是个什么玩意儿。
dvajs
是一个很优秀的解决方案,功能齐全,还支持热更新。本人在早期使用 umijs
脚手架的时候是使用了 dva
插件来做为我的状态管理工具。它引入了 redux-saga
来管理副作用的部分,总的来说很好用,只是需要额外了解一下 redux-saga
。
Rematch
考虑得很周到,它将 reducers
的调用直接注入到 dispatch
的属性里,省去了不少 dispatch
的范式代码,这一点是我非常喜欢的,不过它做得不彻底,应该连 effects
也一起做到就比较好了。另外一点是,它的副作用部分是基于 Redux
的中间件来实现的,所以不必依赖 redus-saga
或者 redux-thunk
等第三方库,上手成本要稍低一点。
那为什么还要自己去实现一个类似的工具呢?目的只有一个,就是要让状态管理更简单些:
- 将
reducers
和 effects
的调用函数自动注入到 dispatchers
里
- 使全局状态逻辑使用更容易,同时也可以接管组件的内部状态管理逻辑
接下来将详细介绍如何一步步实现我所需要的功能。
本文所介绍的实现方案在 github
上有源码(@olajs/modx),本文的示例代码也基于这个工具。
声明式状态管理文件 model.ts
dvajs
和 Rematch
都采用了声明式的 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({ namespace: "modelA", state: { counter: 0, }, reducers: { plus: (state) => ({ counter: state.counter + 1 }), minus: (state) => ({ counter: state.counter - 1 }), }, effects: { lazyPlus({ timeout }: { timeout: number }) { const { prevState } = this; console.log(prevState); setTimeout(() => { this.plus(); }, timeout); }, lazyMinus({ timeout }: { timeout: number }) { const { prevState } = this; console.log(prevState); setTimeout(() => { this.minus(); }, timeout); }, }, });
|
解析 model
文件并创建 Redux Store
实例
接下来是要将 model
文件的内容转换成创建 Redux Store
需要的代码:
- namespace:为不同的
model
在 state
中分配不同的命名空间
- state:作为当前
model
的状态描述,初始值会被并入 Store
的 initialState
- reducers: 没有副作用的
reducer
函数
- effects:有副作用的函数,自动转换成
Redux
的 middleware
@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";
export function createStore( initialState: any, modelConfigs: ModelConfig[], extra?: { devTools?: boolean } ): Store { 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 }); }
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";
const EFFECT_THIS_KEYS = [ "namespace", "store", "next", "prevState", "dispatcher", ];
export default function parseModel(modelConfig: ModelConfig): { reducer: Reducer; middleware: Middleware; } { const { namespace, reducers = {}, effects = {} } = modelConfig; 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), }; }
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; }; }
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)) { const thisType = { namespace, store, next, prevState: store.getState()[namespace], dispatcher(actionType: string, payload?: any) { store.dispatch({ type: actionType, payload }); }, }; Object.keys(reducers).forEach((key) => { thisType[key] = (payload: any) => { store.dispatch({ type: `${namespace}/${key}`, payload }); }; }); Object.keys(effects).forEach((key) => { thisType[key] = (payload: any) => { store.dispatch({ type: `${namespace}/${key}`, payload }); }; }); converted[action.type].call(thisType, action.payload); } }; }
|
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";
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]);
store.dispatch({ type: `${namespace}/plus` }); console.log(store.getState()[namespace]);
store.dispatch({ type: `${namespace}/plus` }); console.log(store.getState()[namespace]);
store.dispatch({ type: `${namespace}/minus` }); console.log(store.getState()[namespace]);
|
全局状态的使用方法
为了能在组件中更简单的使用全局 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
| 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} />; }); }
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
组件的质量也就有了保证
参考资料
作者
Richard Chen
发表于
2022-04-21 15:12:00
,添加在分类
React
下
,最后修改于
2022-05-11 11:09:00