在中后台项目中,最常见的页面不是表单,而是列表页。
真正消耗时间的,往往不是业务复杂度,而是分页、排序、loading、查询参数同步这些重复劳动。
这篇文章分享我在项目中封装的一个 Hook —— useTableList,用于统一管理 Ant Design Table 的列表行为。
一、为什么要封装表格 Hook?
在没有封装之前,一个列表页通常要处理:
- loading 状态
- 分页 / 页码同步
- 查询参数合并
- 表格排序映射
- rowSelection 管理
- search / refresh / reset 行为区分
结果就是:
👉 每个列表页几乎都在复制粘贴,而且 bug 特别容易集中在这些地方。
所以我给自己定了一个目标:
列表页只关心:表单 + columns + 业务操作
其余全部交给 Hook。
二、设计目标
- ✅ 数据请求收敛到一个入口
- ✅ 表格行为(分页 / 排序)内聚
- ✅ 对外暴露语义清晰的 API
- ✅ 最大限度贴合 antd Table
- ✅ 可作为项目级基础设施
三、对外 API 设计
const {
queryParams,
search,
refresh,
reset,
selectedRowKeys,
tableProps
} = useTableList({ queryFn: getListApi, rowSelection: true, params: { } })
search —— 查询(回到第一页)
用于表单搜索 / 条件变化。
refresh —— 刷新当前页
用于新增 / 删除 / 修改之后。
reset —— 重置条件
用于重置按钮。
selectedRowKeys —— 批量操作能力基础
tableProps —— 直接传给 antd Table
<Table rowKey="id" columns={columns} {...tableProps} />
四、全局配置能力
解决不同后端字段不统一的问题:
configureTableOption({
pageSize: 20,
sortField: ['orderType', 'orderField'],
sortOrder: ['ASC', 'DESC']
})
五、核心实现思路
1️⃣ 单一数据入口
所有行为最终都会走:
- search
- refresh
- reset
- 表格分页 / 排序
const fetchList = async (params) => { ... }
这是整个 Hook 稳定性的核心。
2️⃣ 查询参数是唯一真相
分页、排序、条件都收敛在 queryParams 中,避免状态割裂。
3️⃣ 表格行为完全内聚
onTableChange => fetchList()
页面层不再处理分页 / 排序细节。
4️⃣ rowSelection 统一托管
让批量操作天然可扩展。
六、完整源码(useTableList.ts)
import { useMemo, useRef } from 'react'
type noop = (this: any, ...args: any[]) => any
type PickFunction<T extends noop> = (
this: ThisParameterType<T>,
...args: Parameters<T>
) => ReturnType<T>
export function useMemoizedFn<T extends noop>(fn: T) {
const fnRef = useRef<T>(fn)
fnRef.current = useMemo<T>(() => fn, [fn])
const memoizedFn = useRef<PickFunction<T>>(void 0)
if (!memoizedFn.current) {
memoizedFn.current = function (this, ...args) {
return fnRef.current.apply(this, args)
}
}
return memoizedFn.current
}
import { type Key, type ReactNode, useState, useEffect, useRef, useMemo } from 'react'
import { Empty, type TableProps as AntdTableProps } from 'antd'
import { useMemoizedFn } from './memoized'
import type {
TablePaginationConfig,
TableCurrentDataSource
} from 'antd/es/table/interface'
export interface QueryParamsData {
pageNo: number
pageSize: number
orderType?: string
orderField?: string
[key: string]: any
}
interface TableResponse<T> {
status: string
data: {
list: T[]
totalCount: number
}
}
interface TableState<T> {
pagination: TablePaginationConfig
list: T[]
queryParams: QueryParamsData
}
export interface TableProps<T> {
bordered: boolean
size: 'middle'
sticky: boolean
rowSelection: AntdTableProps<T>['rowSelection'] | undefined
pagination: TablePaginationConfig
loading: boolean
dataSource: T[]
onChange: AntdTableProps<T>['onChange']
locale: {
emptyText: string | ReactNode
}
}
interface TableResult<T> {
/** 查询参数 */
queryParams: QueryParamsData
/** 执行查询方法 */
search: (params?: Record<string, any>) => void
refresh: (params?: Record<string, any>) => void
reset: (params?: Record<string, any>) => void
/** 选中的行 keys */
selectedRowKeys: Key[]
/** 表格属性 */
tableProps: TableProps<T>
}
interface GlobalTableConfig {
sortField: string[]
sortOrder: string[]
pageSize: number
}
// 默认配置
const globalTableConfig: GlobalTableConfig = {
sortField: ['orderType', 'orderField'],
sortOrder: ['ASC', 'DESC'],
pageSize: 10
}
/**
* 配置全局表格参数
* @param config
*/
export function configureTableOption(config: GlobalTableConfig): void {
Object.keys(config).forEach((key: string) => {
globalTableConfig[key] = config[key]
})
}
interface TableOptions<T, P = Record<string, any>> {
queryFn: (data: QueryParamsData) => Promise<TableResponse<T>>
params?: P
rowSelection?: boolean
}
export function useTableList<T extends Record<string, any> = Record<string, any>>({
queryFn,
params: initParams,
rowSelection
}: TableOptions<T>): TableResult<T> {
const PAGE_SIZE = globalTableConfig.pageSize
const [state, setState] = useState<TableState<T>>({
pagination: {
showSizeChanger: true,
showQuickJumper: true,
total: 0,
pageSize: PAGE_SIZE,
current: 1
},
list: [],
queryParams: {
pageNo: 1,
pageSize: PAGE_SIZE,
...initParams
}
})
const { pagination, list, queryParams } = state
const { pageNo: currentPageNo, pageSize: currentPageSize } = queryParams
const [loading, setLoading] = useState(true)
const initialParams = useRef(queryParams)
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([])
const rowSelectionData: AntdTableProps<T>['rowSelection'] | undefined = useMemo(() => {
if (!rowSelection) return void 0
return {
selectedRowKeys,
onChange: (keys: Key[]) => setSelectedRowKeys(keys)
}
}, [rowSelection, selectedRowKeys])
const showTotal = useMemoizedFn(
(total: number): string => {
return `共 ${total} 条记录 第 ${currentPageNo}/${Math.ceil(total / currentPageSize)} 页 `
}
)
const fetchList = async (params: QueryParamsData): Promise<void> => {
const { pageNo } = params
setLoading(true)
const queryParamsData = { ...initParams, pageSize: currentPageSize, ...params }
if (params.pageNo === void 0) {
queryParamsData.pageNo = 1
}
if (params.pageSize === void 0) {
queryParamsData.pageSize = currentPageSize
}
const { data } = await queryFn(queryParamsData)
const { list = [], totalCount = 0 } = data || {}
rowSelection && setSelectedRowKeys([])
setState({
list,
queryParams: queryParamsData,
pagination: {
...pagination,
current: pageNo,
pageSize: queryParamsData.pageSize,
total: totalCount
}
})
setLoading(false)
}
const search = (params?: Record<string, any>) => {
void fetchList({ ...queryParams, ...params, pageNo: 1 })
}
const refresh = (params?: Record<string, any>) => {
void fetchList({ ...queryParams, ...params})
}
const reset = (params?: Record<string, any>) => {
void fetchList({ ...params, pageSize: currentPageSize, pageNo: 1 })
}
const onTableChange = (
pagination: TablePaginationConfig,
_filters: Record<string, any>,
sorter: Record<string, any>,
extra: TableCurrentDataSource<any>
) => {
const { action } = extra
if (['paginate', 'sort'].includes(action)) {
const { current, pageSize } = pagination
const { field, order } = sorter
const [orderTypeField, orderFieldName] = globalTableConfig.sortField
const [ascValue, descValue] = globalTableConfig.sortOrder
const params = {
...queryParams,
[orderTypeField]: order ? (order === 'ascend' ? ascValue : descValue) : void 0,
[orderFieldName]: field,
pageNo: current,
pageSize: pageSize
}
void fetchList(params)
}
}
useEffect(() => {
search(initialParams.current)
}, [])
return {
queryParams,
search: useMemoizedFn(search),
refresh: useMemoizedFn(refresh),
reset: useMemoizedFn(reset),
selectedRowKeys,
tableProps: {
bordered: true,
size: 'middle',
sticky: true,
rowSelection: rowSelectionData,
loading,
dataSource: list,
pagination: { ...pagination, showTotal },
onChange: useMemoizedFn(onTableChange),
locale: {
emptyText: loading ? '' : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
}
}
}
}
---
## 七、页面使用示例
```ts
const { tableProps, search, reset, selectedRowKeys } = useTableList({
queryFn: getUserList,
params: {
search: 'hello'
}
})
<Form onFinish={search}>
<Button htmlType="submit">搜索</Button>
<Button onClick={reset}>重置</Button>
</Form>
<Table rowKey="id" columns={columns} {...tableProps} />
八、总结
useTableList 带来的不是“少写几行代码”,而是:
- 列表页工程化
- 行为模型统一
- Bug 集中收口
- 可持续扩展
非常适合作为中后台项目的基础设施。
九、后续可进阶方向
- URL 同步查询参数
- 导出 / 批量操作能力
- 自动轮询 / 缓存
- 向 ProTable 形态演进
如果这篇文章对你有帮助,欢迎点赞 / 收藏 / 评论交流 👏 也欢迎分享你们项目中是如何封装列表页的。