设计开发一个data-table

前言

在日常开发中,数据表格扮演着至关重要的角色。它以结构化的形式展现信息,使数据清晰易懂,开发者基于此类表格可以对其进行拓展和复用,本篇文章我们将循序渐进地介绍如何构建一个功能完善、易于使用的表格组件,并探讨其背后的设计理念和最佳实践

阅读之前,你可能需要了解以下技术栈:TS+React+​Antd ProComponents

是什么?

data-table(数据表格)是一种常见的用户界面组件,用于展示和操作数据集合,由于后端的数据与前端某些表格深度绑定,所以需要通过此类数据表格实现对应的诉求。

一个完整的数据功能表格通常有以下常用功能

  1. 数据展示:以表格的形式展示数据集,其中数据通常按行和列组织。

  2. 动态数据绑定:能够绑定到动态数据源,实时展示数据变化。

  3. 排序:允许用户根据一列或多列对数据进行排序。

  4. 筛选:提供筛选器,使用户可以基于特定条件筛选数据。

  5. 分页:对于大量数据,提供分页功能以便用户可以逐页浏览。

  6. 交互操作:支持用户对数据进行操作,如编辑、删除、添加等。

  7. 自定义列定义:允许开发者自定义表格的每一列,包括列的标题、数据索引、宽度、格式化等。

  8. 响应式设计:能够适应不同的屏幕尺寸和设备。

  9. 状态管理:跟踪和展示数据的状态,如是否被选中、是否处于活动状态等。

  10. 工具栏:可能包括额外的工具栏,提供导出、打印、自定义列可见性等高级功能。

为什么我们会需要如此复杂的功能表格?或者说我们如何写好一个表格?

为什么?

参考类似JavaScript中继承的概念,通过定义一个基类(或基组件)来设定通用的属性和方法,然后根据特定需求对其进行扩展,后续对其属性进行拓展

设计数据表格的一些关键因素有以下几点:

  1. 设计一个基类数据表格,使其包含通用的功能和属性,以便在不同的场景下复用

  2. 将数据表格分解为多个模块或组件,如表头、表体、分页器等,每个模块负责特定的功能

  3. 允许通过配置来定义表格的行为和外观,而不是硬编码,增加了灵活性并简化了扩展

  4. 将通用的属性和方法封装在基类中,如数据加载、行选择、单元格格式化等

  5. 集成数据验证和错误处理机制,确保数据的准确性和完整性

  6. 管理好不同版本的数据表格,确保向后兼容,减少升级的复杂性

基于实际情况和表格数据类型,使得我们系统偏向于使用数据表格的输出形式

怎么做?

说了这么多,如何规范实现一个data-table

先来看看antd提供的高级表格组件(Antd ProComponents-ProTable:https://pro-components.antdigital.dev/components/table

ProTable的诞生是为了解决项目中需要写很多table的样板代码的问题,所以在其中做了封装了很多常用的逻辑。这些封装可以简单的分类为预设行为与预设逻辑。

设计方向很简单,基于antd提供的如此便捷的组件,我们可以通过上层传递的参数来达到表格多态效果,提高表格的可用性和灵活性

下面代码中的ProTable是隶属于公司的sz-components库中,这个库包含了antd的ProTable,并对其做了修改

以近期迭代的实际场景为例:

在resource.types中定义了该模块下的tstypes类型

将后端文档中的表格结构统一存放在TableData类型中,用于限制和提示相关类型结构

将与枚举相关的类型值存放在Enum中

接着我们创建相关的data-table,来看看代码结构

export const ResourceExerciseDataTable: React.FC<DataTableProps<IResourceExercise.TableData>> = props => {
  const { AuthorityMap, ManualActivationMap, ProductSalesMap, StateMap } = IResourceExercise.Enum;
  const { hideInSearch, hideInTable, extraColumns, ...rest } = props;
  const { isUcc, dataSourceFrom } = useAppVariable();
  const { packageExerciseApi } = useAppApi();
  const columns = useMemo(() => {
    const cols: ProColumns<IResourceExercise.TableData>[] = [
      {
        dataIndex: 'packageName',
        title: '习题包名称',
        ellipsis: true,
        fixed: 'left',
        width: 140,
      },
      {
        dataIndex: 'versionDate',
        title: '资源包版本',
        hideInSearch: true,
        width: 120,
        render: (text, record) => {
          const { versionDate, versionNum } = record;
          return <div>{formatVersion({ versionDate, versionNum }).version}</div>;
        },
      },
      {
        dataIndex: 'permission',
        title: '可用权限',
        valueEnum: AuthorityMap,
        width: 80,
      },
      {
        dataIndex: 'needActive',
        title: '需手动开通后使用',
        valueEnum: ManualActivationMap,
        width: 120,
      },
      {
        dataIndex: 'asProduct',
        title: '是否作为产品售卖',
        valueEnum: ProductSalesMap,
        width: 120,
      },
      {
        dataIndex: 'exerciseCount',
        title: '总包含题目数',
        hideInSearch: true,
        width: 120,
      },
      {
        dataIndex: 'state',
        title: '状态',
        fieldProps: {
          mode: 'multiple',
        },
        valueEnum: StateMap,
        width: 80,
      },
      {
        dataIndex: 'createUserName',
        title: '版本作者',
        hideInSearch: true,
        ellipsis: true,
        width: 120,
      },
      {
        dataIndex: 'createTime',
        title: '创建时间',
        ellipsis: true,
        valueType: 'dateTime',
        searchType: 'dateRange',
        width: 120,
      },
      {
        dataIndex: 'firstShelfTime',
        title: '首次上架时间',
        ellipsis: true,
        valueType: 'dateTime',
        searchType: 'dateRange',
        width: 120,
      },
      {
        dataIndex: 'shelfTime',
        title: '最新上架时间',
        ellipsis: true,
        valueType: 'dateTime',
        searchType: 'dateRange',
        width: 120,
      },
    ];

    return DataTableUtils.resolve(cols, {
      hideInTable,
      hideInSearch,
      extraColumns,
    });
  }, [isUcc, dataSourceFrom, extraColumns, hideInSearch, hideInTable]);
  const itemValueToNum = (list: string[]) => list.map(item => Number(item));
  /**
   * 获取习题包分页列表
   * @param param
   * @returns
   */
  const handleTableSearch = async (param: any) => {
    const data = await packageExerciseApi.getExerciseList(
      Object.assign({}, omit(param, 'state', 'createTime', 'firstShelfTime', 'shelfTime', 'permission', 'needActive', 'asProduct'), {
        pageNo: param.current,
        permissionList: param.permission ? itemValueToNum([param.permission]) : [],
        needActiveList: param.needActive ? itemValueToNum([param.needActive]) : [],
        asProductList: param.asProduct ? itemValueToNum([param.asProduct]) : [],
        stateList: param.state,
        ...timeCollect(param),
      }),
    );
    return {
      data: data.records,
      total: data.total,
      success: true,
    };
  };

  return (
    <ProTable
      scroll={{ x: '100%', y: window.innerHeight * 0.6 }}
      search={{ labelWidth: 100 }}
      {...rest}
      queryKey={[IResourceExercise.key, 'table']}
      rowKey="id"
      columns={columns}
      request={rest.dataSource ? undefined : rest.request ?? handleTableSearch}
    />
  );
};

首先限制Props类型为DataTableProps<IResourceExercise.TableData>以便上层更好控制表格参数

表头部分使用固定写法是因为:数据展示及数据查询条件与后端接口强关联,如果接口层发生数据或功能模块发生变化,需要调整当前data-table

接着通过DataTableUtils中的表格函数对外层的参数做处理,来看看resolve函数的写法

static resolve<T>(
  list: ProColumns<T>[],
  options?: {
    hideInSearch?: string[];
    hideInTable?: string[];
    extraColumns?: ProColumns<T>[];
  },
): ProColumns<T>[] {
  const { hideInSearch, hideInTable, extraColumns } = options ?? {};
  let cols = list.concat();
  cols.forEach(col => {
    if (hideInSearch && hideInSearch.length > 0) {
      if (hideInSearch.includes(col.dataIndex as any)) {
        col.hideInSearch = true;
      }
    }
    if (hideInTable && hideInTable.length > 0) {
      if (hideInTable.includes(col.dataIndex as any)) {
        col.hideInTable = true;
      }
    }
  });

  if (extraColumns) {
    cols = cols.concat(extraColumns);
  }

  cols.sort((a, b) => (a.index ?? 0) - (b.index ?? 0));

  return cols.map(item => omit(item, ['index']));
}

hideInSearch,hideInTable,extraColumns分别代表上级对表格搜索栏,表格表头,列数据的控制,其中参数引入了index的概念,通过index来控制在哪一列中插入新的列,打个比方:在data-table中使用index对表格进行排序,思考下面的ProColumns代码

[{
        dataIndex: 'packageName',
        title: '习题包名称',
        ellipsis: true,
        fixed: 'left',
        width: 140,
        index: 1,
      },
      {
        dataIndex: 'versionDate',
        title: '资源包版本',
        hideInSearch: true,
        width: 120,
        index: 2,
        render: (text, record) => {
          const { versionDate, versionNum } = record;
          return <div>{formatVersion({ versionDate, versionNum }).version}</div>;
        },
      },
      {
        dataIndex: 'permission',
        title: '可用权限',
        valueEnum: AuthorityMap,
        width: 80,
        index: 3
      }]

如果我想在第二列后面插入一行,只需要通过extraColumns参数传入列的index>2&&<3的数字即可,思考以下代码

{
        dataIndex: 'line',
        title: '新插入的列',
        width: 140,
        index: 2.1,
}

以上代码可以在资源包和权限中间插入新的列

说完resolve函数,接下来就是表格自带的request函数,request是表格提供的请求函数,在第一次加载时可以通过该函数将分页,工具栏过滤等参数通过调用该函数将参数格式化,但是一般推荐在search.transform中将搜索值进行格式化

ProTable提供一个params参数,传入该参数可以拓展request函数的参数,方便在外层维护使用

最后需要分享的是queryKey的概念,这个是在公共组件封装的一个用法,其借鉴的是react-query的queryKey的概念,使用该字段可以使表格数据对请求函数(request)深度绑定,在其他地方使用时可以直接使用以下代码实现表格数据刷新的效果

const { refetch } = ProTable.useApi()
refetch([IResourceExercise.key, 'table'])

总结

以上就是文章全部内容了,本文详细介绍了如何构建一个功能完善、易于使用的data-table(数据表格)组件,并探讨了其设计理念和最佳实践。感谢看到最后,如果觉得有所帮助,还望三连支持一下,感谢

相关文章:

https://procomponents.ant.design/components/table

GitHub - TanStack/query: 🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.


设计开发一个data-table
http://website.diehunter1024.work/2024/08/19/设计开发一个data-table/
作者
阿宇的编程之旅
发布于
2024年8月19日
许可协议