使用useDataTable:打造高效、可复用的React表格组

2025-11-06  本文已影响0人  修齐治平zzr

在现代前端开发中,数据表格几乎是每个管理后台系统的核心组件。无论是用户列表、订单管理、数据分析还是内容管理,我们都需要处理大量的表格数据展示和交互。然而,在日常开发中,我们常常会陷入这样的困境:
● 每个表格都要重复实现分页、筛选、加载状态等逻辑
● 不同后端接口的数据结构千差万别,需要大量适配代码
● 筛选和搜索功能在每个表格中都要重新实现
● 状态管理分散,难以维护和测试
● 团队成员之间的表格实现方式不统一,代码复用率低
这些问题不仅降低了开发效率,还增加了维护成本。面对这样的挑战,我们是否能够找到一种更优雅的解决方案?答案是肯定的——通过封装一个功能完善、高度可配置的 useDataTable Hooks,我们能够将表格的通用逻辑抽象出来,实现"一次封装,处处使用"的开发体验。
本文将详细介绍 useDataTable 的设计理念、核心功能以及在实际项目中的应用价值,帮助你在日常开发中提升效率,保持代码的整洁和可维护性。
直接上useDataTable代码

import { useState, useEffect, useCallback, useMemo } from 'react';
import { Table, Pagination, Input, Button, Space, message } from 'antd';
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import type { TableProps, PaginationProps, InputProps } from 'antd';

// 类型定义
interface RequestParams {
  current: number;
  pageSize: number;
  [key: string]: any;
}

interface ResponseData<T = any> {
  list: T[];
  total: number;
  current?: number;
  pageSize?: number;
}

interface FilterConfig {
  dataIndex: string;
  placeholder?: string;
  debounce?: number;
}

interface FieldMapping {
  list?: string;
  total?: string;
  current?: string;
  pageSize?: string;
}

interface UseDataTableOptions<T = any> {
  // 数据源配置
  url?: string;
  data?: T[]; // 静态数据源
  params?: Record<string, any>;

  // 表格配置
  columns: TableProps<T>['columns'];
  rowKey?: string | ((record: T) => string);
  immediate?: boolean;

  // 数据转换
  transformData?: (data: any) => ResponseData<T>;
  fieldMapping?: FieldMapping;

  // 筛选配置
  filter?: FilterConfig;

  // 分页配置
  pagination?: boolean | PaginationProps;

  // 请求配置
  request?: (params: any) => Promise<any>;
}

interface UseDataTableReturn<T = any> {
  // 核心属性
  tableProps: TableProps<T>;
  data: T[];
  loading: boolean;
  total: number;

  // 分页相关
  paginationProps: PaginationProps;
  current: number;
  pageSize: number;

  // 筛选相关
  filterProps: InputProps;
  filterValue: string;

  // 操作方法
  refresh: () => void;
  search: (params: Record<string, any>) => void;
  reset: () => void;
  setData: (data: T[]) => void;
  setLoading: (loading: boolean) => void;
}

// 工具函数:获取嵌套对象属性值
const getNestedValue = (obj: any, path: string): any => {
  if (!path) return undefined;
  return path.split('.').reduce((acc, part) => acc && acc[part], obj);
};

// 主 Hook 实现
const useDataTable = <T = any>(options: UseDataTableOptions<T>): UseDataTableReturn<T> => {
  const {
    // 数据源
    url,
    data: staticData,
    params: initialParams = {},

    // 表格配置
    columns,
    rowKey = 'id',
    immediate = true,

    // 数据转换
    transformData,
    fieldMapping = {
      list: 'list',
      total: 'total',
      current: 'current',
      pageSize: 'pageSize',
    },

    // 筛选配置
    filter,

    // 分页配置
    pagination: paginationConfig = true,

    // 自定义请求
    request,
  } = options;

  // 状态管理
  const [data, setData] = useState<T[]>([]);
  const [loading, setLoading] = useState(false);
  const [total, setTotal] = useState(0);
  const [pagination, setPagination] = useState({
    current: 1,
    pageSize: 10,
  });
  const [searchParams, setSearchParams] = useState<Record<string, any>>(initialParams);
  const [filterValue, setFilterValue] = useState('');
  const [filteredData, setFilteredData] = useState<T[]>([]);
  const [debounceTimer, setDebounceTimer] = useState<NodeJS.Timeout | null>(null);

  // 判断是否使用静态数据
  const isStaticData = !!staticData;

  // 获取表格数据
  const fetchData = useCallback(async () => {
    if (isStaticData) {
      // 使用静态数据
      setData(staticData);
      setTotal(staticData.length);
      return;
    }

    if (!url && !request) {
      console.warn('useDataTable: 请提供 url 或 request 参数');
      return;
    }

    setLoading(true);
    try {
      const requestParams: RequestParams = {
        current: pagination.current,
        pageSize: pagination.pageSize,
        ...searchParams,
      };

      let result;
      if (request) {
        // 使用自定义请求方法
        result = await request(requestParams);
      } else if (url) {
        // 使用默认 fetch
        const response = await fetch(url, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify(requestParams),
        });
        result = await response.json();
      }

      // 数据提取和转换
      let finalData: ResponseData<T>;
      if (transformData) {
        finalData = transformData(result);
      } else {
        const listData = getNestedValue(result, fieldMapping.list!) || [];
        const totalCount = getNestedValue(result, fieldMapping.total!) || 0;
        const currentPage = getNestedValue(result, fieldMapping.current!);
        const pageSizeValue = getNestedValue(result, fieldMapping.pageSize!);

        finalData = {
          list: listData,
          total: totalCount,
          current: currentPage,
          pageSize: pageSizeValue,
        };
      }

      setData(finalData.list || []);
      setTotal(finalData.total || 0);
      
      // 更新分页信息
      if (finalData.current) {
        setPagination(prev => ({
          ...prev,
          current: finalData.current!,
        }));
      }
      if (finalData.pageSize) {
        setPagination(prev => ({
          ...prev,
          pageSize: finalData.pageSize!,
        }));
      }
    } catch (error) {
      console.error('表格数据请求失败:', error);
      message.error('数据加载失败');
      setData([]);
      setTotal(0);
    } finally {
      setLoading(false);
    }
  }, [
    url,
    staticData,
    isStaticData,
    pagination.current,
    pagination.pageSize,
    searchParams,
    transformData,
    fieldMapping.list,
    fieldMapping.total,
    fieldMapping.current,
    fieldMapping.pageSize,
    request,
  ]);

  // 筛选数据
  useEffect(() => {
    if (!filterValue || !filter) {
      setFilteredData(data);
      return;
    }

    const filtered = data.filter(item => {
      const fieldValue = getNestedValue(item, filter.dataIndex);
      return String(fieldValue || '').toLowerCase().includes(filterValue.toLowerCase());
    });
    
    setFilteredData(filtered);
  }, [data, filterValue, filter]);

  // 处理筛选变化(防抖)
  const handleFilterChange = useCallback((value: string) => {
    setFilterValue(value);
    
    if (debounceTimer) {
      clearTimeout(debounceTimer);
    }

    const timer = setTimeout(() => {
      if (!isStaticData) {
        setPagination(prev => ({ ...prev, current: 1 }));
      }
    }, filter?.debounce || 300);

    setDebounceTimer(timer);
  }, [debounceTimer, filter?.debounce, isStaticData]);

  // 清除防抖定时器
  useEffect(() => {
    return () => {
      if (debounceTimer) {
        clearTimeout(debounceTimer);
      }
    };
  }, [debounceTimer]);

  // 初始化数据
  useEffect(() => {
    if (immediate && !isStaticData) {
      fetchData();
    } else if (isStaticData) {
      setData(staticData);
      setTotal(staticData.length);
    }
  }, [fetchData, immediate, isStaticData, staticData]);

  // 刷新数据
  const refresh = useCallback(() => {
    if (isStaticData) {
      setData(staticData);
      setTotal(staticData.length);
    } else {
      fetchData();
    }
  }, [fetchData, isStaticData, staticData]);

  // 搜索
  const search = useCallback((params: Record<string, any>) => {
    setSearchParams(prev => ({ ...prev, ...params }));
    if (!isStaticData) {
      setPagination(prev => ({ ...prev, current: 1 }));
    }
  }, [isStaticData]);

  // 重置
  const reset = useCallback(() => {
    setSearchParams(initialParams);
    setFilterValue('');
    if (!isStaticData) {
      setPagination({ current: 1, pageSize: 10 });
    }
  }, [initialParams, isStaticData]);

  // 分页变化处理
  const handlePaginationChange = useCallback((current: number, pageSize?: number) => {
    setPagination(prev => ({
      current,
      pageSize: pageSize || prev.pageSize,
    }));
  }, []);

  // 计算最终显示的数据
  const displayData = useMemo(() => {
    return filter ? filteredData : data;
  }, [filter, filteredData, data]);

  // 计算分页后的数据
  const paginatedData = useMemo(() => {
    if (paginationConfig === false || isStaticData) {
      return displayData;
    }
    
    const startIndex = (pagination.current - 1) * pagination.pageSize;
    const endIndex = startIndex + pagination.pageSize;
    return displayData.slice(startIndex, endIndex);
  }, [displayData, pagination.current, pagination.pageSize, paginationConfig, isStaticData]);

  // 计算显示的总数
  const displayTotal = useMemo(() => {
    if (isStaticData) {
      return filter ? filteredData.length : data.length;
    }
    return filter ? filteredData.length : total;
  }, [isStaticData, filter, filteredData.length, data.length, total]);

  // 表格配置
  const tableProps: TableProps<T> = useMemo(() => ({
    columns,
    dataSource: paginatedData,
    loading,
    rowKey,
    pagination: false,
    scroll: { x: 800 },
  }), [columns, paginatedData, loading, rowKey]);

  // 分页配置
  const paginationProps: PaginationProps = useMemo(() => {
    if (paginationConfig === false) {
      return {} as PaginationProps;
    }

    const baseProps: PaginationProps = {
      current: pagination.current,
      pageSize: pagination.pageSize,
      total: displayTotal,
      showSizeChanger: true,
      showQuickJumper: true,
      showTotal: (total, range) => 
        `第 ${range[0]}-${range[1]} 条,共 ${total} 条`,
      onChange: handlePaginationChange,
      onShowSizeChange: handlePaginationChange,
    };

    // 合并自定义分页配置
    return typeof paginationConfig === 'object' 
      ? { ...baseProps, ...paginationConfig } 
      : baseProps;
  }, [pagination.current, pagination.pageSize, displayTotal, handlePaginationChange, paginationConfig]);

  // 筛选输入框配置
  const filterProps: InputProps = useMemo(() => ({
    value: filterValue,
    onChange: (e) => handleFilterChange(e.target.value),
    placeholder: filter?.placeholder || `搜索${filter?.dataIndex}`,
    allowClear: true,
    prefix: <SearchOutlined />,
    style: { width: 250 },
  }), [filterValue, handleFilterChange, filter]);

  return {
    // 核心属性
    tableProps,
    data: paginatedData,
    loading,
    total: displayTotal,
    
    // 分页相关
    paginationProps,
    current: pagination.current,
    pageSize: pagination.pageSize,
    
    // 筛选相关
    filterProps,
    filterValue,
    
    // 操作方法
    refresh,
    search,
    reset,
    setData,
    setLoading,
  };
};

export default useDataTable;

使用示例

import React from 'react';
import useDataTable from './useDataTable';
import { Table, Input, Button, Space, Card } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';

// 示例1: 基本使用(后端数据)
const BasicTableExample: React.FC = () => {
  const { tableProps, paginationProps, filterProps, loading, refresh } = useDataTable({
    url: '/api/users',
    columns: [
      { title: 'ID', dataIndex: 'id', key: 'id' },
      { title: '姓名', dataIndex: 'name', key: 'name' },
      { title: '邮箱', dataIndex: 'email', key: 'email' },
      { title: '状态', dataIndex: 'status', key: 'status' },
    ],
    filter: {
      dataIndex: 'name',
      placeholder: '搜索用户姓名...',
    },
    fieldMapping: {
      list: 'data.list',
      total: 'data.total',
    },
  });

  return (
    <Card title="用户列表">
      <div style={{ marginBottom: 16 }}>
        <Space>
          <Input {...filterProps} />
          <Button 
            icon={<ReloadOutlined />} 
            onClick={refresh}
            loading={loading}
          >
            刷新
          </Button>
        </Space>
      </div>
      <Table {...tableProps} pagination={paginationProps} />
    </Card>
  );
};

// 示例2: 静态数据
const StaticDataTableExample: React.FC = () => {
  const staticData = [
    { id: 1, name: '张三', age: 25, department: '技术部' },
    { id: 2, name: '李四', age: 30, department: '市场部' },
    { id: 3, name: '王五', age: 28, department: '技术部' },
    // ... 更多数据
  ];

  const { tableProps, filterProps } = useDataTable({
    data: staticData,
    columns: [
      { title: 'ID', dataIndex: 'id', key: 'id' },
      { title: '姓名', dataIndex: 'name', key: 'name' },
      { title: '年龄', dataIndex: 'age', key: 'age' },
      { title: '部门', dataIndex: 'department', key: 'department' },
    ],
    filter: {
      dataIndex: 'name',
      placeholder: '搜索员工...',
    },
    pagination: false, // 禁用分页
  });

  return (
    <Card title="员工列表">
      <Input {...filterProps} style={{ marginBottom: 16, width: 200 }} />
      <Table {...tableProps} />
    </Card>
  );
};

// 示例3: 自定义请求
const CustomRequestTableExample: React.FC = () => {
  const customRequest = async (params: any) => {
    // 自定义请求逻辑
    const response = await fetch('/api/custom-endpoint', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(params),
    });
    return response.json();
  };

  const { tableProps, paginationProps } = useDataTable({
    request: customRequest,
    columns: [
      { title: '订单号', dataIndex: 'orderNo', key: 'orderNo' },
      { title: '金额', dataIndex: 'amount', key: 'amount' },
      { title: '状态', dataIndex: 'status', key: 'status' },
    ],
    transformData: (data) => ({
      list: data.orders,
      total: data.totalCount,
    }),
  });

  return (
    <Card title="订单列表">
      <Table {...tableProps} pagination={paginationProps} />
    </Card>
  );
};

export { BasicTableExample, StaticDataTableExample, CustomRequestTableExample };

在实际项目中采用 useDataTable 后,团队能够获得显著的收益:
开发效率的提升:新表格的开发时间从小时级别缩短到分钟级别,开发者只需关注业务特定的列配置,无需重复编写数据获取和状态管理代码。
代码质量的保证:统一的表格实现方式确保了代码的一致性,减少了潜在的错误。TypeScript 的全面支持提供了良好的类型安全,降低了运行时错误的风险。
维护成本的降低:当需要调整表格的通用行为时,只需修改 Hooks 本身,所有使用该 Hooks 的表格都会自动获得更新,大大减少了维护工作量。
团队协作的改善:新成员能够快速上手表格开发,不需要理解每个表格的特殊实现,降低了学习成本。
业务灵活性的保持:虽然 useDataTable 提供了丰富的默认行为,但通过各种配置选项和扩展点,它仍然能够适应各种特殊的业务需求,不会成为开发的束缚。
正如软件开发中的许多最佳实践一样,useDataTable 的核心价值在于它找到了一种平衡——在标准化和灵活性之间,在封装性和可扩展性之间,在开发效率和代码质量之间。
希望 useDataTable 的设计思路和实践经验能够为你带来启发,无论是直接使用这个 Hooks,还是基于这个理念封装适合自己团队的工具,都能够帮助你在日常开发中写出更优雅、更高效的代码。记住,好的工具不仅提高生产力,更重要的是让开发过程变得更加愉悦。

上一篇 下一篇

猜你喜欢

热点阅读