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

103 阅读4分钟

在中后台项目中,最常见的页面不是表单,而是列表页。
真正消耗时间的,往往不是业务复杂度,而是分页、排序、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 形态演进

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