基于ahooks useTable渐进式插件化设计Table基类

1,435 阅读7分钟

ahooks useTable

我们项目和组件库计划重构,由原来class组件的方案过渡到react hooks函数组件方案。针对于table这块,我们除了UI组件做了优化,我们的项目层级也要做些实践.使用过Umi的使用者都知道,Umi最成功的解决方式是它的插件方案,我们也想使用插件式方案,我们项目已经使用了ahooks的use-request,偶然间发现useTable就是使用的插件渐进式方案,很适合我们呀,盘它... 参考文档usetable-ahooks
看下它的官方文档提供的hooks,
image.png 我们可以看到,useTable生态提供了下一代table使用模式,table扩展功能实现plugin,已经官方提供的核心库useFormTable和useTable。
我们项目的组件库也升级为基于antd二次封装的组件,看了下useTable的项目结构吧,可以看到下面packages里面有多个包,基于我们要改造的例子最近的就是antd-table了吧,我们进入目录see see... image.png
我们看下antd-table的目录结构:
image.png
我们看下antd-table的目录plugin的实现:

import { methods } from '@ahooksjs/use-table';

const filterTransformer = (filters) => {
  return Object.keys(filters).reduce((acc, key) => {
    return {
      ...acc,
      [key]: {
        selectedKeys: filters[key] || [],
      },
    };
  }, {});
};

const tableActions = {
  sort: ({ sorter }, props) => {
    props.onSort(sorter.field, sorter.order);
  },
  filter: ({ filters }, props) => {
    props.onFilter(filterTransformer(filters));
  },
};

const useAntdTablePlugin = () => {
  return {
    props: (ctx) => {
      return {
        tableProps: ({ primaryKey, ...props }) => {
          return {
            ...props,
            rowKey: primaryKey,
            pagination: false,
            onChange: (_, filters, sorter, { action }) => {
              const fn = tableActions[action] || (() => {});
              fn({ filters, sorter }, props);
            },
          };
        },
        paginationProps: ({ onChange, onPageSizeChange, ...props }) => {
          return {
            ...props,
            // Antd 不能区分【页码大小】的触发还是【页改变】的触发
            onChange: (current, pageSize) => {
              const { params } = ctx;
              // 切换页码
              if (current === params.current) {
                ctx.query(
                  { pageSize, current: ctx.options.current },
                  {
                    queryFrom: methods.ON_PAGE_SIZE_CHANGE,
                  }
                );
              }

              // 切换页
              if (params.pageSize === pageSize) {
                ctx.query(
                  { pageSize, current },
                  {
                    queryFrom: methods.ON_PAGE_SIZE_CHANGE,
                  }
                );
              }
            },
          };
        },
      };
    },
  };
};

export default useAntdTablePlugin;

再来看下index.ts的实现:

import useTable, { Obj, IResponse, Options } from '@ahooksjs/use-table';
import useAntdTablePlugin from './plugin';

const useAntdTable = (service: (params: Obj) => Promise<IResponse>, options?: Options) => {
  const antdTablePlugin = useAntdTablePlugin();
  const plugins = options?.plugins || [];

  const props = useTable(service, {
    ...options,
    plugins: [...plugins, antdTablePlugin],
  });

  return props;
};

export * from '@ahooksjs/use-table';

export default useAntdTable;

export { useAntdTablePlugin };

我们看到上面antd-table项目里面结构和参考文档可以看到,antd-table是基于useTable为核心添加plugin模式来实现。我们再细细地分析下antd-table plugin的实现吧。
可以看到,我们正常的table组件可以拆分成form表单查询,table展示区,pagination分页管理 三大模块。 table展示区和分页的操作事件分开来处理,分别是tableProps和paginationProps,思路相当清晰,tableProps主要处理filter和sort的事件,paginationProps主要处理当前页和每页展示数据多少的变化事件监听。
我们组件也是基于antd来封装的,所以我们可以参考这个自己来封装下适合我们项目的plugin功能。
下面主要是介绍下我们基于自己项目组件库封装的plugin应用已经useTable的原理。

useTable流程

看图肯定必看文字直观,我这边将我82年的流程图拉出来溜溜
毕竟流程图看起来会比较清晰些整个流程在做什么?

useTable主流程

image.png
useTable大概核心就是将请求,table操作属性,pagination操作属性传入,导出封装后的查询方法,获取当前查询条件方法,处理后的table属性,pagination属性
源码功力还是很深厚的,使用到了redux compose的概念,也使用到了洋葱模型及生命周期。
核心的use-table如果想细致了解useTablePlugin和useQueryDisplay则看下面的流程图

useTablePlugin流程图

image.png 每个生命周期的也附上:

image.png useTablePlugin可以看到主要是封装了middlewares和props属性。

  • middlewares 会根据不同的生命周期去做分类,主要分为prepare,didRender,willQuery,yourTurn, - querying, didQuery钩子。
  • props属性主要是依次执行usePaginationProps, useTableProps, useParams处理过后的props数据。

useQueryDisplay流程图

image.png useQueryDisplay主要作用:

  • 设置全局上下文ctx
  • 混合middlware和props属性
  • 把最新混合过的plugin和props暴露出来 画的比较长,各位如果看不清需要自己放大哦,如果看不清,可以私聊本人,可以把流程图原件发你。

useHuayunTablePlugin

前提:我们的组件库也是基于antd封装的业务组件,所以我们可以仿照antd-table封装符合我们业务组件的plugin。 首先还是先看下我们组件库的风格吧

image.png 设计还是比较前言吧。。。
这里就不贴码了哈,贴个图可以吧 image.png 我们还是秉承最小功能原则,将tableProps和paginationProps分开处理。
table页面上的filter过滤机查询条件,分页过滤查询我们底层组件库已经处理,我们这边还是要保持放开。
请求入参变化我们也提供了onChangeParams辅助处理一些事件, filterQueryParams是我们想针对一些请求字段做特殊处理的特殊方法。

table基类封装

源码撸完了,插件写好了,万事俱备吗,只欠东风了...
基类该怎么实现比较好呢,我们秉承的原则就是:用户使用最少的代码实现最基础的table能力,提供更多的table扩展能力,赋予它更强大的生命力。 我们设计的不一定好,但是适合我们自己项目很重要。
这边我们来看下我们基类封装入参吧

基类入参定义

/**
 * 基类入参定义
 */
export interface UseBaseListProps {
  // selectedRowKeys?: string[]; // 选中数据列表
  // pagination?: any; // 分页数据信息
  service: (params: any) => Promise<any>;
  /**
   * transformRequest 是否基类处理数据转成antd需要的数据格式 默认为true
   */
  transformRequest?: boolean;
  /**
   * defaultParams 默认请求入参
   */
  defaultParams?: Record<string, any>;
  /**
   * autoFirstQuery 是否默认查询数据 默认为true
   */
  options?: Options;
  /**
   * 是否显示searchBarSlot
   */
  isShowSearchBarSlot?: boolean;
  /**
   * searchBarSlotLeft扩展功能
   */
  searchBarSlotLeft?: () => React.ReactNode;
  /**
   * searchBarSlot扩展功能
   */
  searchBarAdvanceSlot?: () => React.ReactNode;
  /**
   * SearchBar的搜索placeholder和名称
   */
  searchOption?: any;
  /**
   * 不需要额外处理的数据,默认请求数据
   */
  untreatedParamKeys?: string[];
  /**
   * 列表是否支持滚动
   */
  getScroll?: () => {};
  /**
   * SearchBar回显查询条件展示
   */
  searchFilterParams?: Record<string, any>;
  /**
   * 查询条件别名
   */
  getParamsAlias?: (ParamsAlias|ParamsAliasBasic) | (() => (ParamsAlias|ParamsAliasBasic));
  /**
   * table列数据
   */
  getTableColumns: () => any[];
  defaultHiddenColumnKeys?: string[]; // 默认影藏的列key
  handleRefresh?: () => void; // 刷新列表数据
  onHandleColumnChange?: (hiddenColumn?: string[]) => void; // 隐藏显示列的回调函数
  /**
   * 筛选过滤处理
   */
  // filterHandler?: (key:string) => ((value: any) => void) | null;
  /**
   * params请求数据回调
   */
  onChangeParams?: (selectedRowKeys?: React.Key[], params?: any) => void;
  /**
   * 是否过滤展示查询条件
   */
  filterQueryParams?: (params: any) => any;
  /**
   * 是否支持滚动 默认不支持
   */
  supportScroll?: boolean;
  /**
   * 是否支持分页 默认支持
   */
  supportPagination?: boolean;
  /**
   * 是否支持chexkbox选中行 默认支持
   */
  supportRowSelection?: boolean;
  /**
   * 额外的rowSelection
   */
  advanceRowSelection?: Record<string, any>;
  /**
   * 空数据展示
   */
  emptyData?: React.ReactNode;
  /**
   * antd的配置
   */
  sticky?: Record<string, any>;

  /**
   * 列表按钮操作
   */
  operateButtons?: ButtonProps[];

  /**
   * 额外的选中事件操作
   */
  extraChangeSelect?: (
    selectedRowKeys: React.Key[],
    selectedRows: any[],
  ) => void;
  /**
   * 是否支持searchBar
   */
  supportSearchBar?: boolean;
  /**
   * 路由信息,用于匹配ws推送刷新
   */
  route?: Route;
}

这里只会说个主要思想,不会把源码全部贴出来,毕竟是公司项目,大家都懂得...

service请求类的封装

/**
   * 获取当前需要传入table的service方法
   */
  const requestService = transformRequest ? (params: any) => {
    const { current, pageSize, ...formData } = params;
    const queryCond = {
      pageSize,
      pageNumber: current,
      ...defaultParams,
      ...formData,
    };
    return service(queryCond).then((res) => ({
      data: {
        total: res.totalCount,
        dataSource: res.data.map((data: any, i: number) => ({
          ...data,
          key: i,
        })),
      },
    })).catch((err) => {
      // 统一捕获异常
      handleRequestError(err);
    });
  } : service;

默认设置刷新函数

/**
   * 刷新操作
   */
  const defaultHandleRefresh = () => {
    query(getParams());
  };

  const { handleRefresh = defaultHandleRefresh } = props;

过滤器别名生成器

/**
   * 别名生成器
   * @returns ParamsAlias
   */
  const paramsAliasGenerator = (): ParamsAlias => {
    if(!getParamsAlias) {
      return {};
    }
    // 请求参数
    let filterParams = !!searchFilterParams ? {...searchFilterParams} : {...queryParams};
    let result = getParamsAlias;
    if(typeof getParamsAlias === 'function') {
      result = getParamsAlias();
    } 

    let filterKeyArr:string[] = [...defaultUntreatedParamKeys, ...untreatedParamKeys?.filter((item) => !defaultUntreatedParamKeys.includes(item))];
    // 清除需要清除的数据
    if(Object.keys(filterParams).length) {
      Object.keys(filterParams).forEach((item) => {
        if(filterKeyArr.includes(item)) {
          delete filterParams[item as keyof typeof filterParams];
        }
      });
    }

    // 将剩下的别名数据整合
    if(Object.keys(filterParams).length) {
      Object.keys(filterParams).forEach((item) => {
        const column = getTableColumns()?.find((columnItem) => columnItem.key === item);
        let columnOptions = column?.options || column?.filter?.options;
        const optionValue = columnOptions?.find((option: any) => option.value === filterParams[item as keyof typeof filterParams])?.title;
        // 别名中没有对应的映射,则插入别名
        if(!result[item as keyof typeof result]) {
          (result[item as keyof typeof result] as ParamsAlias) = {
            title: column?.title,
            ...column?.filter && {
              value: optionValue,
            },
          }
        }
        // 当前别名设置只设置过string数据
        if(typeof result[item as keyof typeof result] === 'string') {
          (result[item as keyof typeof result] as ParamsAlias) = {
            title: result[item as keyof typeof result],
            ...column?.filter && {
              value: optionValue,
            },
          };
        } else if(!(result[item as keyof typeof result] as ParamsAlias).value && column?.filter) {
          // 当前有别名映射对象,有title,没有value
          (result[item as keyof typeof result] as ParamsAlias) = {
            ...(result[item as keyof typeof result] as ParamsAlias) ,
            value: optionValue,
          };
        }
      })
    }
    return result as ParamsAlias;
  }

我们不需要ref去控制service调用,是不是无缝链接,我们再看下我们的基类调用吧

image.png 直接可以拿到tableDom直接去渲染我们的table数据,不需要去管我们基类内部实现,使用起来是不是也很简单。
本章先聊到这里吧,如果大家有什么想深入了解的,可以在评论区回复。