使用useDataTable:打造高效、可复用的React表格组
在现代前端开发中,数据表格几乎是每个管理后台系统的核心组件。无论是用户列表、订单管理、数据分析还是内容管理,我们都需要处理大量的表格数据展示和交互。然而,在日常开发中,我们常常会陷入这样的困境:
● 每个表格都要重复实现分页、筛选、加载状态等逻辑
● 不同后端接口的数据结构千差万别,需要大量适配代码
● 筛选和搜索功能在每个表格中都要重新实现
● 状态管理分散,难以维护和测试
● 团队成员之间的表格实现方式不统一,代码复用率低
这些问题不仅降低了开发效率,还增加了维护成本。面对这样的挑战,我们是否能够找到一种更优雅的解决方案?答案是肯定的——通过封装一个功能完善、高度可配置的 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,还是基于这个理念封装适合自己团队的工具,都能够帮助你在日常开发中写出更优雅、更高效的代码。记住,好的工具不仅提高生产力,更重要的是让开发过程变得更加愉悦。