TypeScript + React最佳实践-第二节:@tkit/model - React 全局和局部状态管理方案

1,668 阅读5分钟

@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>;
    };
}

同时,总结了这个结构的不足:

  1. reducers 和 effects 结构不对称
  2. effects 仅支持 generator,不支持 async 函数
  3. 单个 effect 里对 reducer 的调用是基于字符串,无法做到类型化
  4. 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>
        </>
    );
}