React 状态管理器这样封装,开发效率提升30%

1,136 阅读6分钟

本文主要讲述的内容是基于 React + Redux 的项目,应该如何设计状态管理器。用过 Redux 的同学应该都知道,状态管理器可以同时处理多个数据模型 Model, 并且每个 Model 中都会包含 ActionReducerInitialStateEffect。其中:

  • Effect 表示异步请求的方法,一般都是封装的 http 请求;
  • Action 表示在 Effect 异步请求完成的回调任务中应该执行的具体动作;
  • Reducer 表示状态更新函数,它将根据 Action.type 去更新相应的数据,
  • InitialState 表示该数据模型的初始化状态;

Model 层的封装过程大致如下:

// src/models/main.js
const effects = {
    queryUserInfo(query) {
        return function(dispatch, getState) {
            return axois.post('/v1.0/query/user-info', query)
                .then(resp => {
                    const data = resp.data ?? {};
                    // 触发 Action
                    dispatch({ type: 'UserInfo', payload: data });
                });
        }
    },
    queryCount(query) {
        return function(dispatch, getState) {
        return axois.get('/v1.0/query/count', query)
            .then(resp => {
                const data = resp.data ?? {};
                // 触发 Action
                dispatch({ type: 'Count', payload: data });
            });
        }
    }
};

const initState = {
    userInfo: {
        name: '',
        sex: '',
        age: 0,
    },
    count: 9999999,
}

const reducer1 = function(prevState = {}, action) {
    switch (action.type) {
        case 'UserInfo':
            return { ...prevState, userInfo: action.payload };
        case 'Count':
            return { ...prevState, count: action.payload };
        default:
            return prevState;
    }
}

创建 store

// src/store.js
const reducer = combineReducers({ main: reducer1, systemUi: reducer2 });

const store = configureStore({
  reducer,
  preloadedState,
});

以上便是我早前使用 Redux 时的封装过程,其中 initialStatereducereffect 逻辑都是分开的,当一个 Model 中管理的状态特别多时,就逻辑就比较混乱。记不住哪个 effect 对应的 action.type 是什么。并且在使用时也很不方便:

// src/pages/home.jsx
import { effects } from '@/models/main';

export default function Page(props:any) {
    const model = useSelector((state) => state['main']);
    
    const dispatch = useDispatch();
    
    const actions = useMemo(() => {
        return bindActionCreators(effects, dispatch);
    }, []);
    
    // ...
}

优化

优化策略一

封装一个超类 Model,可自动生成 reducer 函数,每个数据模型在实现时继承 Model; 生成 reducer 函数时,根据每个 effect 方法的名称 + model.name 来生成 action.type

优化策略二

封装两个钩子函数,分别对 useSelectoruseDispatch 进行封装,简化使用方式,减少代码量。

策略一的实现

import type { Action, Dispatch } from '@reduxjs/toolkit';

export type ReduxAction = { payload: Function | object | null } & Action;

export type Effect = (query?: any) => (dispatch: Dispatch, getState?: any) => Promise<any>;

export default class Model {
    // 生成 reducer 函数
    reducer<S>(): Reducer<S, ReduxAction, S> {
        // 子类的名称
        const prefix = this.constructor.name;
        // 子类原型对象上定义的方法
        const keys = Object.getOwnPropertyNames(Object.getPrototypeOf(this));
        keys.splice(keys.indexOf('constructor'), 1);

        // prettier-ignore
        const initialState = Object.assign({}, this) as S;
        const finalTypes = keys.map((key) => `${prefix}/${key}`);

        // 当 finalTypes 集合中包含 action.type 时才会触发状态更新;
        // 另外,如果 action.payload 为 null、undefined 时,将不会更新;
        return function (initState: S = initialState, action: ReduxAction): S {
            // eslint-disable-next-line
            if (finalTypes.includes(`${prefix}/${action.type}`) && action.payload != null) {
                const payload = action.payload;
                return {
                    ...initState, 
                    ...(typeof payload === 'function' ? payload(initState) : payload) 
                };
            } else {
                return initState;
            }
        };
    }

    // 从子类的 prototype 中提取 effects,要排除 constructor 属性。
    effects<A>() {
        const prototype = Object.getPrototypeOf(this);
        const keys = Object.getOwnPropertyNames(prototype);
        keys.shift();

        const effects = {} as any;
        keys.forEach((key) => effects[key] = prototype[key]);

        return effects as A;
    }
}

通过超类 Model 你会发现,所有的 Effect 方法只能定义在子类的原型对象上。 那么具体的数据模型应该如何定义:

import * as api from '@/api/main';
import Model from '@/common/model';
import { getLocalStorage } from '@/utils';
import type { Dispatch } from '@reduxjs/toolkit';

export default class MainModel extends Model {
    regionList: Array<any> = [];
    userInfo: { [key: string]: any } = getLocalStorage('USER_INFO') || {};
    
    queryRegionList(query: any) {
        return (dispatch: Dispatch) => {
            // api 表示接口
            return api.queryRegionList(query).then((response: any) => {
                dispatch({ 
                    type: 'queryRegionList', 
                    payload: { regionList: response?.data ?? [] } 
                });

                return response;
            });
        };
    }

    queryUserInfo() {
        return (dispatch: Dispatch) => {
            return api.queryUserInfo().then((response: any) => {
                // 如果你不希望更新 model,请将 payload 设置为 null。
                dispatch({ 
                    type: 'queryUserInfo', 
                    payload: { userInfo: response?.data ?? {} } 
                });
                
                // 也可以不返回任何内容,返回的内容可以在 .then() 的回调函数中获取。
                return response;
            });
        };
    }
}

所有的 InitialState 都是定义在子类的实例对象上的,获取时直接通过实例对象进行访问即可。这样设计的目的是因为,开发中需要知道每个 Model 层中对应数据类型,这个时候直接借助 TypeScript 类型检查就可以知道这个Model 的数据结构。

如果将一个 Class 作为接口使用时,TS 只会检查它的实例属性和方法,不会检查它的静态属性/方法,以及原型对象上的属性和方法。

开发中还应该将所有的 models 全部导入 src/models/index.ts 中:

import MainModel from './main';
import WorkInfoModel from './workInfo';
import SystemRoleModel from './systemRole';

import MainModel from './main';
import WorkInfoModel from './workInfo';
import SystemRoleModel from './systemRole';

export const allModelState = {
  mainModel: new MainModel(),
  workInfoModel: new WorkInfoModel(),
  systemRoleModel: new SystemRoleModel(),
};
export type AllModelStateType = typeof allModelState;

/**
 * 注意
 * 以下的内容可以定义,也可以不定义。
 * 个人认为还是统一在这里定义并导出最好。业务逻辑中可直接导入使用 
 */
 
// 全局共享的数据
export const mainReducer = allModelState.mainModel.reducer<typeof MainModel>();
export const mainActions = allModelState.mainModel.actions<typeof MainModel.prototype>();

// 作业信息
export const workInfoReducer = allModelState.workInfoModel.reducer<typeof WorkInfoModel>();
export const workInfoActions = allModelState.workInfoModel.actions<typeof WorkInfoModel.prototype>();

// 系统管理
export const systemRoleReducer = allModelState.systemRoleModel.reducer<typeof SystemRoleModel>();
export const systemRoleActions = allModelState.systemRoleModel.actions<typeof SystemRoleModel.prototype>();

策略二实现

import { useMemo } from 'react';
import type { AllModelStateType } from '@/models';
import { bindActionCreators } from '@reduxjs/toolkit';
import { useSelector, useDispatch } from 'react-redux';

// 获取函数的参数类型
type PT<T> = T extends (param: infer P) => any ? P : never;

// 获取函数的返回值类型
type RT<T> = T extends (...args: any) => infer R ? R : never;

// 如果函数的返回值仍然是一个函数,则返回内部函数的返回值类型
type RT2<T> = RT<RT<T>>;


/**
 * 注意,
 * 如果函数的参数是一个可选参数,那么 PT<T> 得到的结果中包含 undefined 类型
 * 如果函数的参数为空,那么 PT<T> 得到的结果为 unknown。
 * 并且 undefined extends unknown
 */
type Actions<A> = {
  [K in keyof A]: undefined extends PT<A[K]>
    ? unknown extends PT<A[K]>
      ? () => RT2<A[K]>
      : (query?: PT<A[K]>) => RT2<A[K]>
    : (query: PT<A[K]>) => RT2<A[K]>;
};

export function useActions<A>(actions: A) {
  const dispatch = useDispatch();
  // 避免每次函数组件渲染时重新计算。
  return useMemo(() => {
    return bindActionCreators(actions as any, dispatch);
  }, [dispatch, actions]) as Actions<A>;
}

export function useModel<T extends keyof AllModelStateType>(name: T) {
  return useSelector<AllModelStateType>((state) => state[name]) as AllModelStateType[T];
}

上述 useActions 钩子将根据传入的 actions 参数通过 bindActionCreators 进行合成,并通过 useMemo() 避免函数组件每次渲染时都重新计算。另外 Actions<A> 将重新定义每个 action 方法的类型。否则,开发者拿到的 action 都被定义为 any

useModel 钩子只需要传入一个 name 参数。就可以得到相应的数据和正确的类型。

使用

import React, { memo, useCallback, useMemo } from 'react';
import { useModel, useActions } from '@/redux';
import { workInfoActions } from '@/models';

function Page() {
    const { regionList } = useModel('mainModel');
    const { queryTableList } = useActions(workInfoActions);
    //...
}

根据页面加载情况,按需导入

import { useParams, useNavigate, useLocation } from 'react-router-dom';
import React, { useState, useEffect, memo } from 'react';
import { mainActions, mainReducer } from '@/models';
import type { AllModelStateType } from '@/models';
import type { Reducer, ReducersMapObject } from '@reduxjs/toolkit';
import { useModel, useActions } from '@/redux';
import { ReduxAction } from '@/common/model';
import { replaceReducer } from '@/redux';
import { Spin } from 'antd';

type Models = { [propName: string]: Reducer<any, ReduxAction> };

type ChildrenType = React.ReactElement | React.MemoExoticComponent<any>;

type Loader = () => Promise<{ default: ChildrenType }>;

export default function LazyLoader(loader: Loader, models?: Models): React.FunctionComponent {
  return memo(() => {
    const params = useParams();
    const location = useLocation();
    const navigate = useNavigate();
    const mainModel = useModel('mainModel');
    const actions = useActions(mainActions);
    const [children, setChildren] = useState<ChildrenType | null>(null);

    useEffect(() => {
      loader().then((res) => {
        // mainReducer 作为公共的数据模型,应保证在各个页面中都有。
        // 封装的 store.replaceReducer();
        replaceReducer({ mainModel: mainReducer, ...models } as any);
        setChildren(() => res.default);
      });
    }, []);

    return (
      <Spin delay={500} spinning={!children} size="large">
        <div style={{ height: 'calc(100vh - 131px)', display: !children ? 'block' : 'none' }} />
        {children
          ? React.createElement(children.type, {
              ...mainModel,
              ...actions,
              location,
              navigate,
              params,
            })
          : null}
      </Spin>
    );
  });
}

上述代码中,定义了一个惰性加载的高阶组件,从而实现页面的按需加载。并且,在页面加载时,通过 replaceReducer() 函数完成状态管理器中的 model 的动态更新。

总结

读者如果有更好的想法、或建议都可以在下方给我留言,本人将在第一时间回复,多多交流学习,多多点赞。