@tkit/model-React全局和局部状态管理管理方案
备注
@tkit/*
是内部版本,替换成 tkit-*
则是对外版本
繁复 + 的 Redux
18 年 9 月的时候,我们团队开始全面推广 TypeScript + React + Redux 的方案,其中 Redux 这块方案选型:
- 以
redux-actions
创建 action 和 reducer - 以
redux-saga
处理副作用
一个类型化后的 redux action 代码示例如下:
// store
interface State { ... }
const initialState: State = { ... };
// action
const DOSOMETHING = 'DOSOMETHING';
const doSomething = createAtion(DOSOMETHING, (id: number) =>({ id }));
function* doSomethingEffect(action: Action<{ id: number }>) { ... };
function* doSomethingSaga() {
yield takeEvery(DOSOMETHING, doSomethingEffect);
}
// reducer
const applySomething = handleActions({
[DOSOMETHING]: (state: IInitialState, action: Action<{ id: number }>) => { ... }
});
以上的方案,随后就经历第一个大型项目的拷打,上百个的这样的 redux action ——即便引入了 redux-actions
来简化创建 action 和 reducer 的编码,依旧痛点满满:
代码结构过于繁复,再加上类型注解,开发体验指数级降低
dvajs
后来者的优势是总有别人的经验能够参考,大型实践之后,我们引入了 dvajs 类似的数据结构来管理 react redux:
interface Model<S extends any> {
state: S;
reducers: {
[doSomething: string]: (state: S, action: Action<any>) => S;
};
effects: {
[doSomethingAsync: string]: (action: Action<any>, utils: SagasUtils) => Iterator<{}, any, any>;
};
}
同时,总结了这个结构的不足:
- reducers 和 effects 结构不对称
- effects 仅支持 generator,不支持 async 函数
- 单个 effect 里对 reducer 的调用是基于字符串,无法做到类型化
- reducers、effects 里每个方法的 action 参数的类型不能和
bindActionCreators
贯通
对于不足 1——调整 effect 参数顺序即可:
interface Model<S extends any> {
...
effects: {
[doSomethingAsync: string]: (utils: SagasUtils, action: Action<any>) => Iterator<{}, any, any>;
};
...
}
而其他不足,则需要另辟蹊径
Model
单个 effect 内部的类型化以及 action 参数的贯通,设计到类型计算,仅仅通过 interface 泛型是做不到,我们需要一个工厂函数——来实现参数类型对返回类型的复杂逻辑关系。
这个工厂函数的基本结构:
interface CreateModel {
<S, R, E>
(model: {
state: S;
reducers: R;
effects?: E;
}
): {
state: S;
reducers: ApplySomething<S, R>;
actions: DoSomethings<R, E>
}
}
实现 reducers 和 state 类型贯通
export interface Reducers<S> {
[doSomething: string]: <P extends AbstractAction>(state: Readonly<S>>, action: P) => S;
}
interface CreateModel {
<S, R extends Reducers<S>, ...>(model: {
state: S;
reducers: R;
...
}): any
}
实现 effect 内触发 reducer 的类型化
在 effect 逻辑里直接调用 model.actions.doSomething——需要显式指定 effect 返回值类型,否则将陷入类型推断循环引用。
const model = createModel({
...
effects: {
*doSometingAsync(...): Iterator<{}, any, any> {
model.actions.doSomething(action)
}
}
...
})
实现和 bindActionCreators
类型贯通
思路在于将 reducers 和 effects 内各方法 的 action 参数类型提取出来,用以推断工厂函数返回 model actions 属性的类型:
interface EffectWithPayload<Utils extends BaseEffectsUtils> {
<P extends AbstractAction>(
saga: Utils,
action: P
) => Iterator<{}>
}
interface Effects<Utils extends BaseEffectsUtils> {
[doSomethingAsync: string]: EffectWithPayload<Utils>
}
interface CreateModel {
<S, R extends Reducers<S>, E extends Effects<SagaUtils>>(model: {
state: S;
reducers: R;
effects: E;
...
}): {
...
actions: {
[doSomething in keyof R]: <A extends Parameters<R[doSomething]>>[1]>(payload: A['payload']) = > A;
[doSomethingAsync in keyof E]: <A extends Parameters<E[doSomethingAsync]>>[1]>(payload: A['payload']) = > XXX;
}
}
}
实现了 model.actions 自动类型推断,通过 bindActionCreators
connect 到组件时,各个 action 也是类型化的:
function Demo(props: { actions: typeof model.actions }) {
// ok
props.actions.doSomething(paramsMathed);
// TS check error
props.actions.doSomething(paramsMismatched);
...
}
实现 effects 支持 async 函数
首先,类型定义上扩充 effect 泛型:
interface HooksModelEffectWithPayload<Utils extends BaseEffectsUtils> {
<P extends AbstractAction>(saga: Utils, action: P): Promise<any>;
}
interface ReduxModelEffects {
[doSomethingAsync: string]:
| EffectWithPayload<ReduxModelEffectsUtils>
| HooksModelEffectWithPayload<ReduxModelEffectsUtils>;
}
interface CreateModel {
<... E extends ReduxModelEffects>(model: {
...
effects?: E;
}): { ... }
}
然后,需要在逻辑实现上区分 async & generator:
{
...
const mayBePromise = yield effect(effects, action);
mayBePromise['then']
}
Demo
最终一个全局的 Redux Model 的真实示例:
import createModel from '@tkit/model';
export interface State {
groups: Group[];
scopes: Scope[];
}
const model = createModel({
state: groupModelState,
namespace: 'groupModel',
reducers: {
setGroups: (state, action: Tction<Group[]>): typeof state => {
return {
...state,
groups: action.payload
}
}
},
effects: {
async clearGroupByPromise({ asyncPut }, action: Tction<Group>) {
await asyncPut(model.actions.setGroups, []);
},
*clearGroupByGenerator({ tPut }, action: Tction<Group>): Iterator<{}, any, any> {
yield tPut(model.actions.setGroups, []);
},
}
})
【加强】reducer 支持 immer
类型扩充:
interface CMReducers<S> {
[doSomethingCM: string]: <P extends AbstractAction>(state: S, action: P) => void | S;
}
逻辑处理,由于代码逻辑上是区分不了基于 immer 的 reducer 和普通的 reducer,所以必须创建一个新的工厂函数 CM 来做这个事情——由于 reducer 类型的不兼容,所以需要做一个类型转换伪装:
interface CM {
<S, R extends CMReducers<S>, E extends ReduxModelEffects>(model: {
state: S;
reducers: R;
effects: E;
}): CreateModel<S, {
[doSomething in keyof R]: (
state: S,
action: Parameters<R[doSomething]>[1]
) => M;
}, E>
}
然后就可以欢快的:
import { CM } from '@tkit/model';
const model = CM({
reducers: {
setGroups: (state, action: Tction<Group[]>) => {
state.groups = action.payload;
}
},
...
})
源自 Redux 的痛苦
不可否认,即便充分的结构化和类型化,很多时候,还是会感受到来自 Redux 本身的痛苦——比如,很多状态,并不适合放在全局 Redux ,但是其复杂程度又不能通过 局部的 setState
来管理——
19年2月发布稳定版的 React Hooks useReducer
似乎能够解决这个问题——依就是个局部 Redux 的模板:
const [state, dispatch] = useReducer((state, action) => {
switch(action.type) { ... }
})
有 reducer 有 dispatch,我们很容易把 Redux Model 复用到 Hooks 上来
Hooks Model
hooks effect 只能 async & await,同样也需要一个单独的工厂 M 函数来创建 Hooks Model:
interface M {
<M, R extends Reducers<M>, E extends HooksModelEffects>model: {
state: M;
reducers: R;
effects: E;
}): CreateModel(M, R, E)
}
支持 immer 的版本 MM——同样需要类型伪装:
interface MM {
<M, R extends CMReducers<M>, E extends HooksModelEffects>(model: {
state: M;
reducers: R;
effects: E;
}): MM<
M,
{
[doSomething in keyof R]: (
state: M,
action: Parameters<R[doSomething]>[1]
) => M;
},
E
>
}
useModel
useModel 的接口,接收一个 hooks model 和初始状态作为参数:
interface useModel {
<M extends { reducers: any; ... }>(model: M, initialState: M['state'] = model['state']): [
M,
M['actions']
]
}
实现 bindDispatchToAction
,将 dispatch 和 model actions 绑定:
interface bindDispatchToAction {
<A, E, M extends { actions: A; effects: E; TYPES: any }>(
actions: A,
dispatch: ReturnType<typeof useReducer>[1],
model: M
): A
}
Demo
import { MM, useModel } from '@tkit/model';
const UserMMModel = MM({
namespace: 'test',
state: UserMMModelState,
reducers: {
doRename: (state, action: Tction<{ username: string }>) => {
state.username = action.payload.username;
}
},
effects: {
doFetchName: async ({ tPut }, action: Tction<{ time: number }>): Promise<{}> => {
return tPut(UserMMModel.actions.doRename, { username: `${action.payload.time}` });
}
}
});
function Demo() {
const [state, actions] = useModel(UserMMModel);
return (
<>
<h5>{state.username}</h5>
<button onClick={() => actions.doRename('ok')}>1</button>
<button onClick={() => actions.doFetchName(1)}>1</button>
</>
);
}