🚀 从重复 CRUD 到工程化封装:我是如何设计 useTableList 统一列表逻辑的

在中后台项目中,最常见的页面不是表单,而是列表页。

真正消耗时间的,往往不是业务复杂度,而是分页、排序、loading、查询参数同步这些重复劳动。

这篇文章分享我在项目中封装的一个 Hook ------ useTableList,用于统一管理 Ant Design Table 的列表行为。


一、为什么要封装表格 Hook?

在没有封装之前,一个列表页通常要处理:

  • loading 状态
  • 分页 / 页码同步
  • 查询参数合并
  • 表格排序映射
  • rowSelection 管理
  • search / refresh / reset 行为区分

结果就是:

👉 每个列表页几乎都在复制粘贴,而且 bug 特别容易集中在这些地方。

所以我给自己定了一个目标:

列表页只关心:表单 + columns + 业务操作

其余全部交给 Hook。


二、设计目标

  • ✅ 数据请求收敛到一个入口
  • ✅ 表格行为(分页 / 排序)内聚
  • ✅ 对外暴露语义清晰的 API
  • ✅ 最大限度贴合 antd Table
  • ✅ 可作为项目级基础设施

三、对外 API 设计

ts 复制代码
const {
  queryParams,
  search,
  refresh,
  reset,
  selectedRowKeys,
  tableProps
} = useTableList(getListApi, { rowSelection: true })

用于表单搜索 / 条件变化。

refresh ------ 刷新当前页

用于新增 / 删除 / 修改之后。

reset ------ 重置条件

用于重置按钮。

selectedRowKeys ------ 批量操作能力基础

tableProps ------ 直接传给 antd Table

tsx 复制代码
<Table rowKey="id" columns={columns} {...tableProps} />

四、全局配置能力

解决不同后端字段不统一的问题:

ts 复制代码
configureTableOption({
  pageSize: 20,
  sortField: ['orderType', 'orderField'],
  sortOrder: ['ASC', 'DESC']
})

五、核心实现思路

1️⃣ 单一数据入口

所有行为最终都会走:

  • search
  • refresh
  • reset
  • 表格分页 / 排序
ts 复制代码
const fetchList = async (params) => { ... }

这是整个 Hook 稳定性的核心。


2️⃣ 查询参数是唯一真相

分页、排序、条件都收敛在 queryParams 中,避免状态割裂。


3️⃣ 表格行为完全内聚

ts 复制代码
onTableChange => fetchList()

页面层不再处理分页 / 排序细节。


4️⃣ rowSelection 统一托管

让批量操作天然可扩展。


六、完整源码(useTableList.ts)

ts 复制代码
import {  type Key, type ReactNode, useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { Empty, type TableProps as AntdTableProps } from 'antd'
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 TableListResult<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]
  })
}

export function useTableList<T extends Record<string, any> = Record<string, any>>(
  getRequestFn: (data: QueryParamsData) => Promise<TableResponse<T>>,
  initParams = {} as Record<string, any>
): TableListResult<T> {
  const { rowSelection, ...restInitParams } = initParams
  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,
      ...restInitParams
    }
  })

  const { pagination, list, queryParams } = state
  const { pageNo: currentPageNo, pageSize: currentPageSize } = queryParams

  const [isLoading, setIsLoading] = useState(true)
  const initialQuery = 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 = useCallback(
    (total: number): string => {
      return `共 ${total} 条记录 第 ${currentPageNo}/${Math.ceil(total / currentPageSize)} 页 `
    },
    [currentPageNo, currentPageSize]
  )

  const fetchList = useCallback(
    async (params: QueryParamsData): Promise<void> => {
      const { pageNo } = params
      setIsLoading(true)
      const queryParamsData = { ...restInitParams, pageSize: currentPageSize, ...params }
      if (params.pageNo === void 0) {
        queryParamsData.pageNo = 1
      }
      if (params.pageSize === void 0) {
        queryParamsData.pageSize = currentPageSize
      }
      const { data } = await getRequestFn(queryParamsData)
      const { list = [], totalCount = 0 } = data || {}
      rowSelection && setSelectedRowKeys([])
      setState({
        list,
        queryParams: queryParamsData as T & QueryParamsData,
        pagination: {
          ...pagination,
          current: pageNo,
          pageSize: queryParamsData.pageSize,
          total: totalCount
        }
      })

      setIsLoading(false)
    },
    [queryParams, restInitParams, rowSelection, currentPageSize, getRequestFn, pagination]
  )

  const search = useCallback(
    (params?: Record<string, any>) => {
      fetchList({ ...queryParams, ...params, pageNo: 1 })
    },
    [fetchList, queryParams]
  )

  const refresh = useCallback(
    (params?: Record<string, any>) => {
      fetchList({ ...queryParams, ...params})
    },
    [fetchList, queryParams]
  )

  const reset = useCallback(
    (params?: Record<string, any>) => {
      fetchList({ ...params, pageSize: currentPageSize, pageNo: 1 })
    },
    [fetchList, currentPageSize]
  )

  const onTableChange = useCallback(
    ( 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 as number,
          pageSize: pageSize as number
        }
        fetchList(params)
      }
    },
    [queryParams, fetchList]
  )

  const tableProps: TableProps<T> = useMemo(() => {
    return {
      bordered: true,
      size: 'middle',
      sticky: true,
      rowSelection: rowSelectionData,
      pagination: { ...pagination, showTotal },
      loading: isLoading,
      dataSource: list,
      onChange: onTableChange,
      locale: {
        emptyText: isLoading ? '' : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
      }
    }
  }, [rowSelectionData, pagination, showTotal, isLoading, list, onTableChange])

  useEffect(() => {
    search(initialQuery.current)
  }, [])

  return {
    queryParams,
    search,
    refresh,
    reset,
    selectedRowKeys,
    tableProps
  }
}

七、页面使用示例

ts 复制代码
const { tableProps, search, reset, selectedRowKeys } = useTableList(getUserList, {
  rowSelection: true
})
tsx 复制代码
<Form onFinish={search}>
  <Button htmlType="submit">搜索</Button>
  <Button onClick={reset}>重置</Button>
</Form>

<Table rowKey="id" columns={columns} {...tableProps} />

八、总结

useTableList 带来的不是"少写几行代码",而是:

  • 列表页工程化
  • 行为模型统一
  • Bug 集中收口
  • 可持续扩展

非常适合作为中后台项目的基础设施。


九、后续可进阶方向

  • URL 同步查询参数
  • 导出 / 批量操作能力
  • 自动轮询 / 缓存
  • 向 ProTable 形态演进

如果这篇文章对你有帮助,欢迎点赞 / 收藏 / 评论交流 👏 也欢迎分享你们项目中是如何封装列表页的。

相关推荐
pas13621 小时前
25-mini-vue fragment & Text
前端·javascript·vue.js
软件开发技术深度爱好者21 小时前
JavaScript的p5.js库使用介绍
javascript·html
冴羽21 小时前
CSS 新特性!瀑布流布局的终极解决方案
前端·javascript·css
码途潇潇21 小时前
JavaScript有哪些数据类型?如何判断一个变量的数据类型?
前端·javascript
我的写法有点潮1 天前
JS中对象是怎么运算的呢
前端·javascript·面试
悠哉摸鱼大王1 天前
NV12 转 RGB 完整指南
前端·javascript
pas1361 天前
29-mini-vue element搭建更新
前端·javascript·vue.js
IT=>小脑虎1 天前
2026版 React 零基础小白进阶知识点【衔接基础·企业级实战】
前端·react.js·前端框架
IT=>小脑虎1 天前
2026版 React 零基础小白入门知识点【基础完整版】
前端·react.js·前端框架