React 封装的一些总结

最近刚刚把重构推进一个段落,所以想整理一下这个项目里面用过的一些封装思路

API 的封装

这里主要指的是 axios,因为 axios 支持的比较好,所以一般项目利用的都是 axios 而不是 fetch,主要实现内容就是之前笔记里提过的:axios 的简易封装封装一个 axios url encoding serialize util

我基本上就是沿用这个思路封装了 5 个函数:

  • 基础函数

    基础函数是 CRUD 调用的,大致实现方法如下:

    export const request = async ({
      apiMethod,
      uri,
      params = {},
      data,
    }: ApiProps): Promise<AxiosResponse<any, any>> => {
      // 这里也会负责一些其他的操作,主要是对 headers 和 params 进行处理
      // 这个对于所有的 CRUD 操作都是一样的,因此集中封装了一个函数进行处理
      const headers = {};
    
      // 封装的另一个原因也是在于 paramsSerializer 这一段不是很想重复好几遍
      return await axios({
        method: apiMethod,
        url: uri,
        data,
        params,
        headers,
        paramsSerializer: (params) => {
          return toQuery(params);
        },
      });
    };
    

    主要抽出来的原因就是三个:

    • header 的集中处理
    • params 的集中处理
    • paramsSerializer 的集中处理
  • Create

    export const createSingleEntity = async (
      state: RootState,
      uri: string,
      model: any,
      params: any = {}
    ) => {
      // 这里也是一个对 model 操作的地方
      const data = processModel(model);
    
      return await request({
        apiMethod: ApiCallTypes.POST,
        uri,
        data,
        params,
      });
    };
    

    这里抽出来的主要原因是需要对 model 进行另外的处理,将 {attr1: sth, attr2: str} 这样一个 object 的数据转成后台需要的类型,以我们项目来说,大概是 {type: uri, data: model, id: 0} 这样的操作

    建于所有的 entity 都需要同样的处理,与其放在调用的地方,不如集中到 createSingleEntity 中进行处理

  • Retrieve

    这里主要处理是需要对数据进行一个 pagination 的操作,这同样也是所有 entity 共通的,因此放在这里集中操作

  • Update

    Update 的处理更加麻烦一些,主要是我们的 Update 用的是 jsonpatch,并且操作可以包含 CUD 的操作,因此需要调用不同的 wrapper 以生成后端需要的数据格式

  • Delete

    Delete 也是操作 deleteSingleEntity,如果是 batch 操作的话,会转到 update 去操作

    至于为什么不直接调用 update 呢……?

    这个同样涉及到数据的处理操作,与其在每一个需要删除对象的地方,都额外包装一次数据,不如将其统一放到 delete 种操作,这样在处理每一个 endpoint 能够少写几行代码

模型的封装

TS 比较常用的是新建一个个性化对象,不过 Redux 要求的是一个可序列化对象——也就是基础的对象、数组类型,考虑过后这最终还是决定用一个 Type Definition+default object 的操作去做,如:

type ISomeObject = {
  // type definition
};

const defaultSomeObject: ISomeObject = {
  // initialize value
};

但是,如果没有 redux 这个 rehydrate 的需求,也可以直接创建一个 custom object:

export class SomeObject {
  constructor(
    public readonly x: number,
    protected y: number,
    private z: number
  ) {
    // No body necessary
  }
}

这样去进行操作,关于 parameter properties 的缩写部分,在 TypeScript 构造函数中解构参数属性 有提,包括找到的另一个操作方法。

另外关于模型,这里也做了一些其他的优化:

  • 以前的数据会使用数据扁平化

    也就是对象中包含对象的处理,之前遇到这种情况,会将对象中一些需要的数据抠出来进行单独存放,这一点因为 UI 方面也做了同步的升级,可以获取嵌套对象的数据,所以这一步就不需要了

  • 移除了复合模型

    这里同样也是 UI 方面做的优化,假设说当前页面的数据来源于 AB,现在直接使用 useMemo,在 dependency array 中监听 A[]B[] 的变化,然后自动完成数据拼接

    之前的操作则是先调用 API 获得 A[]B[],随后拼成组合模型 C[],在页面中直接渲染 C[]。这样一些操作就非常的麻烦,因为需要同时更新 A[]+C[]/B[]+C[]/ A[]+B[]+C[] 这样的情况。这也说明 state 中出现了重复数据,才会出现既要更新单个模型(A/B),又要更新复合模型©的情况

Redux 的封装

React 项目结构小结 也提了一下 Redux 的基本实现,这里再细化一下 CRUD 相对的操作逻辑:

  • 过滤方法

    按照上面提到的,如果是用第一种,也就是用 type+default object 的方法,可以使用一个 lodash 的函数:pick,语法为 _.pick(object, [paths]),它会返回一个新的过滤好的对象:

    var object = { a: 1, b: '2', c: 3 };
    
    _.pick(object, ['a', 'c']);
    // => { 'a': 1, 'c': 3 }
    

    [paths] 可以使用 Object.keys(defaultObject) 获得

    如果使用 custom class 的话,则可以用 spread operator 进行操作:

    const newObj = new SomeObject(...data);
    
  • Create

    这里主要是处理一下数据,将一些 UI 中多余的数据进行过滤——比如说应对一些验证可能会出现 isValid 这样的属性,这是后端不需要的。之后将处理过的对象还原成 API 所需要的格式,这点就交给封装好了的 createSingleEntity 去做

  • Retrieve

    Retrieve 的操作基本上是一样的,在数据类型定义的非常明确的情况下,这一块甚至可以单独的抽成一个函数去调用

  • Update

    操作与 Create 的 case 相似,不过这里需要将数据稍微整理一下成 UpdateEntities 需要的数据格式,我主要将数据格式定义成下面这样:

    type IUpdatedEntities<T> = {
      updateEntities: {
        addEntity: T[];
        updateEntity: Partial<T>[];
        deleteEntity: T[];
      };
      uri: string;
    };
    

    delete 主要后端只需要接受 id,但是 UI 的选项是不会做到只返回选择的 id,而是返回选择的对象。在考虑了一下之后,建于所有的对象都需要从 T[] 转成 string[] 的操作,因此将其放到了 UpdatedEntities,而非每一次的删除操作中

  • Delete

    操作基本一致,如果只选中一个对象,那么直接调用 delete/entityName/id

    如果选中多个数据,则就接收到的 T[] 传到对应的 deleteEntities

UI 的封装

UI 这一块基本上跟业务有关,这里只是简单地提供一下思路和与我们项目相关的一些实现。

这里还是以我们的项目为例,每一个页面都有对应的 CRUD 的操作,R 已经实现完毕了,对于 CUD 的操作主要通过 表格(table)表单(form) 去实现。表格的东西其实主要通过 UI 库实现的,主要就是写一些 custom validator、custom filter 之类的,onChangeHandler 也是表单内部实现的,我们主要就是拿到修改过的数据,因此这里没什么好说的

表单则是重复比较多的重灾区,分析一下原因主要有这么几个问题:

  • 每个页面都需要 form
  • 每个页面都在重复 render form(modal 方式现实)
  • form 的组成构造是一样的,所以方法的调用也是一致的,但是在具体实现的时候,重复会比较多

class based component 的案例如下:

// class based component
export class ExampleClass extends Component {
  constructor(props) {
    this.state = {
      modalManager: {
        isModalOpen: false,
        modalID: '',
        modalContent: emptyDiv,
        headerText: emptyDiv,
        toggleFn: emptyFunc,
        submitFn: emptyFunc,
        isDeleteModal: false,
      },
    };
  }

  // setModal & closeModal 是 class based component 重复的重灾区
  setModal = ({
    headerText,
    modalContent,
    toggleFn,
    submitFn,
    modalID = '',
    isDeleteModal = false,
  }) => {
    this.setState({
      modalManager: {
        isModalOpen: true,
        modalID,
        modalContent,
        headerText,
        toggleFn,
        submitFn,
        isDeleteModal,
      },
    });
  };

  closeModal = () => {
    this.setState({
      modalManager: {
        isModalOpen: false,
        modalID: '',
        modalContent: emptyDiv,
        headerText: emptyDiv,
        toggleFn: emptyFunc,
        submitFn: emptyFunc,
        isDeleteModal: false,
      },
    });
  };

  // CUD 这里就需要调用 setModal 3 次

  render() {
    return (
      <>
        {/* 省略其他的 render */}
        <ModalWrapper
          modalID={modalID}
          size={'md'}
          isOpen={isModalOpen}
          headerText={headerText}
          toggleFunc={toggleFn}
          submitFunc={submitFn}
          isDeleteModal={isDeleteModal}
        >
          {modalContent}
        </ModalWrapper>
      </>
    );
  }
}

functional component 的案例如下:

// 省略 import

export const Example = () => {
  const {
    modalManager: {
      modalID,
      isModalOpen,
      modalContent,
      headerText,
      toggleFn,
      submitFn,
      isDeleteModal,
    },
    setModal,
    closeModal,
  } = useModalManager();

  const setAddModal = () => {
    setModal({
      headerText,
      modalID: 'add',
      toggleFn: closeModal,
      modalContent: <>some component here</>,
      submitFn: addFunc,
      isDeleteModal: false,
      modalSize: 'md',
    });
  };

  const setEditModal = () => {
    setModal({
      headerText,
      modalID: 'edit',
      toggleFn: closeModal,
      modalContent: <>some component here</>,
      submitFn: editFunc,
      isDeleteModal: false,
      modalSize: 'md',
    });
  };

  const setDeleteModal = () => {
    setModal({
      headerText,
      modalID: 'delete',
      toggleFn: closeModal,
      modalContent: <>some component here</>,
      submitFn: deleteFunc,
      isDeleteModal: true,
      modalSize: 'md',
    });
  };

  return (
    <>
      {/* 省略其他的 render */}
      <ModalWrapper
        modalID={modalID}
        size={modalSize}
        isOpen={isModalOpen}
        headerText={headerText}
        toggleFunc={toggleFn}
        submitFunc={submitFn}
        isDeleteModal={isDeleteModal}
      >
        {modalContent}
      </ModalWrapper>
    </>
  );
};

functional component 虽然好了一些,但是重复次调用还是比较厉害的

这个实现有另外一个问题,是刚开始使用 hooks 没发现,但是挪到 redux 的时候就 gg 的问题——将 react component 存储到了 state 中去。之前使用 RTK 的时候就直接报错,说 mutate #object 之类的,后面一排查发现是 modalContent 的问题

最后在了解了一下整个项目后,做出了这样的修改:

  1. 创建一个新的 redux modal slice

  2. 重新定义了一个新的 hooks 去管理 CUD 的操作

    type IUseSetModal<T> = {
      currentEntityName: string;
      readOnly?: boolean;
      addStructure?: Record<string, Sth>;
      addSubmitFn?: (args: T) => Promise<any>;
      editStructure?: Record<string, Sth>;
      editSubmitFn?: (args: T) => Promise<any>;
      selectedEntities?: Record<string, Partial<T>>;
      deleteSubmitFn?: (args: Record<string, Partial<T>>) => Promise<any>;
    };
    
    const useSetModal = <T,>({}: // 所有上面定义的属性,并且为可选属性提供默认值
    IUseSetModal<T>) => {
      const dispatch = useAppDispatch();
    
      const handleAdd = () => {
        dispatch(
          setModal({
            readOnly,
            submitFn: addSubmitFn,
            structure: addStructure,
            currentEntityName,
            modalOp: 'add',
          })
        );
      };
    
      const handleDelete = () => {
        dispatch(
          setModal({
            readOnly,
            submitFn: deleteSubmitFn,
            currentEntityName,
            modalOp: 'delete',
            selectedEntities,
            deselectAll,
          })
        );
      };
    
      const handleEdit = () => {
        dispatch(
          setModal({
            readOnly,
            submitFn: editSubmitFn,
            structure: editStructure,
            currentEntityName,
            modalOp: 'edit',
            selectedEntities: Object.values(selectedEntities!)[0],
          })
        );
      };
    
      return { handleAdd, handleDelete, handleEdit } as const;
    };
    
    export default useSetModal;
    
  3. 将 modal wrapper 的东西放到 App.js 下面去进行全局渲染

    大致代码如下:

    const GlobalModalWrapper = () => {
      const {
        isOpen,
        submitFn,
        modalOp,
        deselectAll,
        currentEntityName,
        selectedEntities,
        readOnly,
        customValidator,
        structure,
        isLoading,
      } = useAppSelector<IModalSlice<any>>((state: RootState) => state.modal);
    
      const dispatch = useAppDispatch();
    
      const [entity, setEntity] = useState<any | Record<string, any>>({});
    
      const onChangeHandler = () => {
        // do sth
      };
    
      useEffect(() => {
        if (modalOp === 'add' || modalOp === 'edit') {
          // do sth
          return;
        }
    
        if (modalOp === 'delete') {
          // do sth
        }
      }, [selectedEntities, modalOp]);
    
      let headerText = '',
        content = null;
    
      const entityName = startCase(currentEntityName),
        op = startCase(modalOp);
    
      switch (modalOp) {
        case 'add':
        case 'edit':
          headerText = `${op} a ${entityName}`;
          content = sth;
          break;
        case 'delete':
          headerText = `${op} ${entityName}(s)`;
          content = sth;
      }
    
      return (
        <ModalWrapper
          headerText={headerText}
          modalId={`${snakeCase(currentEntityName)}-${modalOp}`}
          readOnly={readOnly}
          isLoading={isLoading}
          isOpen={isOpen}
          isDelete={modalOp === 'delete'}
          closeModal={() => {
            dispatch(closeModal());
          }}
          submitFn={async () => submitFn!(entity)}
          deselectAll={deselectAll}
        >
          {content}
        </ModalWrapper>
      );
    };
    
    export default GlobalModalWrapper;
    

所以最终在组件里面的调用就是这样的:

export const Example = () => {
  const { handleAdd, handleEdit, handleDelete } = useSetModal<Sth>({
    currentEntityName: '',
    readOnly: some_bool,
    addStructure: {},
    addSubmitFn: add_function,
    editStructure: {},
    editSubmitFn: edit_func,
    selectedEntities,
    deleteSubmitFn: edit_func,
    customValidator: some_func,
  });
};

这样每个页面上的 modal 操作从 30+行 省略到了 10+ 行,需要写的瘦身差不多一半,class based component 的话省的要稍微多一些,不过主要也是因为重复声明两个函数造成的

总结

总体上来说这次重构的效果还是挺好的,完成了:

  • 测试代码的添加

    虽然是从 redux 开始,不过我们终于开始写测试了……

  • 代码更加的模块化

  • 使用 redux 集中化数据

    这一部分其实也减少了一些重复调用的代码,毕竟数据和页面也存在 1-to-many 的情况

  • 单个页面开发速度提升 50%

    之前简单的页面实现周期大概是一周左右(5-6 天),现在因为代码已经封装好了,所以实现周期提速到了 2-3 天

  • 现有的代码量大概优化了 50%+

    每个页面渲染本身优化了大概 40%左右

    数据处理是个重灾区,之前每个 entity 都是独立处理的,现在可以通过通过调用封装后函数进行处理。平均上来说大概优化了 60-70%

总体平均一下,代码量对半砍是肯定有的

11-12 10:36