vue 工程化实践,封装一个 useTable
2026年04月07日

vue 工程化实践,封装一个 useTable

基于 Vue 3、TypeScript 和 ant-design-vue 封装通用表格 Hook,统一处理请求、分页、查询、重置和 loading 状态。

在后台管理系统里,表格几乎是最高频的页面形态。

每写一个列表页,我们都要重复处理这些逻辑:

  • 定义 dataSourceloading
  • 调接口,拿列表和总数
  • 维护分页参数
  • 查询时回到第一页
  • 重置筛选条件
  • 页码、页容量变化时重新请求
  • 适配不同后端接口返回结构

这些代码本身并不复杂,但散落在每个页面里会非常啰嗦。更糟的是,不同页面各写各的,分页字段、loading 处理、异常处理很容易不一致。

所以我封装了一个 useTable,目标很简单:页面只关心“表格配置”和“接口”,其他重复逻辑交给 Hook。

最终使用效果

页面里只需要这样写:

page.vue
<script setup lang="ts">
import type { TableColumnsType } from 'ant-design-vue'
import { useTable } from '@/hooks/useTable'

type UserRecord = {
  id: number
  name: string
  department: string
  status: string
}

type UserSearchParams = {
  keyword: string
  page: number
  pageSize: number
}

const columns: TableColumnsType<UserRecord> = [
  { title: 'ID', dataIndex: 'id' },
  { title: '姓名', dataIndex: 'name' },
  { title: '部门', dataIndex: 'department' },
  { title: '状态', dataIndex: 'status' },
]

const { tableProps, search, reset } = useTable<UserRecord, UserSearchParams>({
  api: getUserList,
  defaultParams: {
    keyword: '',
  },
  tableConfig: {
    rowKey: 'id',
    columns,
    bordered: true,
    pagination: {
      pageSize: 10,
      showQuickJumper: true,
    },
  },
})

const handleSearch = () => {
  void search({ keyword: '张三' })
}
</script>

<template>
  <a-button type="primary" @click="handleSearch">查询</a-button>
  <a-button @click="reset">重置</a-button>

  <a-table v-bind="tableProps" />
</template>

API 设计

这个 Hook 的入参核心只有两个:apitableConfig

const table = useTable({
  api,
  tableConfig,
})

api 是真正的数据请求函数,Hook 会自动把分页参数和查询参数合并后传进去。

tableConfig 则复用 ant-design-vueTableProps 类型,只排除掉 Hook 内部接管的几个字段:

export type UseTableConfig<RecordType> = Omit<
  TableProps<RecordType>,
  'dataSource' | 'loading' | 'pagination'
> & {
  pagination?: false | TablePaginationConfig
}

这样做的好处是,我们不用重新设计一套表格配置类型。columnsrowKeyscrollbordered 这些能力都沿用组件库原本的类型,页面使用时也有完整的 TypeScript 提示。

完整参数如下:

参数说明
api请求列表数据的接口函数
tableConfig传给 a-table 的配置
defaultParams默认查询参数
immediate是否初始化后立即请求,默认 true
pageField页码字段名,默认 page
pageSizeField页容量字段名,默认 pageSize
beforeRequest请求前转换参数
transformResponse自定义响应解析
afterRequest请求成功后的回调
onError请求失败后的回调

返回值则面向页面使用:

返回值说明
tableProps可直接绑定给 a-table 的 props
dataSource表格数据
loading请求状态
error请求错误
pagination当前分页配置
params当前查询参数
run手动请求,可选择是否重置页码
reload使用当前参数刷新
search合并查询参数并回到第一页
reset重置查询参数并回到第一页
setParams只更新参数,不立即请求

核心实现拆解

维护基础状态

Hook 内部维护表格最常见的几个状态:

const dataSource = ref<RecordType[]>([]) as Ref<RecordType[]>
const loading = ref(false)
const error = ref<unknown>(null)
const params = ref({ ...defaultParams }) as Ref<Partial<SearchParams>>
const pagination = ref<TablePaginationConfig>({
  ...defaultPagination,
  ...tableConfig?.pagination,
})

分页默认值可以统一放在 Hook 内:

const defaultPagination: TablePaginationConfig = {
  current: 1,
  pageSize: 10,
  showSizeChanger: true,
  showTotal: total => `共 ${total} 条`,
}

这样每个页面不用重复写 currentpageSizeshowTotal 这类配置。如果某个页面有特殊需求,再通过 tableConfig.pagination 覆盖即可。

组装请求参数

请求前需要把查询参数和分页参数合并:

const buildRequestParams = () => {
  const requestParams: Record<string, unknown> = {
    ...params.value,
  }

  if (tableConfig?.pagination !== false) {
    requestParams[pageField] = pagination.value.current ?? defaultPagination.current
    requestParams[pageSizeField] = pagination.value.pageSize ?? defaultPagination.pageSize
  }

  const typedRequestParams = requestParams as SearchParams & Record<string, unknown>

  return options.beforeRequest?.(typedRequestParams) ?? typedRequestParams
}

这里保留了 pageFieldpageSizeField,是为了适配不同后端接口。

有的接口叫:

{
  page: 1,
  pageSize: 10,
}

有的接口可能叫:

{
  current: 1,
  size: 10,
}

使用时只需要这样配置:

useTable({
  api,
  pageField: 'current',
  pageSizeField: 'size',
})

如果参数结构更特殊,还可以用 beforeRequest 做最后一次转换。

统一请求流程

run 是整个 Hook 的请求核心:

const run = async (nextParams?: Partial<SearchParams>, resetPage = false) => {
  if (nextParams) {
    setParams(nextParams)
  }

  if (resetPage && tableConfig?.pagination !== false) {
    pagination.value.current = 1
  }

  const currentRequestId = ++requestId
  loading.value = true
  error.value = null

  try {
    const response = await api(buildRequestParams())

    if (currentRequestId !== requestId) {
      return
    }

    const result = options.transformResponse
      ? options.transformResponse(response)
      : defaultTransformResponse<RecordType>(response)

    dataSource.value = result.list

    if (tableConfig?.pagination !== false) {
      pagination.value.total = result.total
    }

    options.afterRequest?.(result, response)
  } catch (currentError) {
    if (currentRequestId === requestId) {
      error.value = currentError
      options.onError?.(currentError)
    }
  } finally {
    if (currentRequestId === requestId) {
      loading.value = false
    }
  }
}

关于 requestId

如果用户连续快速查询,可能会出现后发请求先返回、先发请求后返回的情况。如果不处理,旧请求可能覆盖新请求的数据。

所以每次请求都会递增 requestId。响应回来时,只有当前最新请求才能更新页面状态。

兼容常见接口返回结构

不同项目里的列表接口返回格式经常不一样。为了减少页面样板代码,Hook 里内置了一个默认解析函数:

function defaultTransformResponse<RecordType>(response: unknown): TableListResult<RecordType> {
  if (Array.isArray(response)) {
    return {
      list: response as RecordType[],
      total: response.length,
    }
  }

  const responseRecord = isRecord(response) ? response : undefined
  const dataRecord = isRecord(responseRecord?.data) ? responseRecord.data : undefined
  const pageRecord = pickFirstRecord(dataRecord, responseRecord)
  const list = pickArray(pageRecord) ?? []
  const total = pickTotal(pageRecord) ?? list.length

  return {
    list: list as RecordType[],
    total,
  }
}

这样可以兼容几类常见结构:

// 直接返回数组
[]

// 返回 list / total
{
  list: [],
  total: 100,
}

// 返回 data.records / data.total
{
  data: {
    records: [],
    total: 100,
  },
}

如果真实接口更复杂,可以传入 transformResponse

useTable<UserRecord, UserSearchParams, ApiResponse>({
  api: getUserList,
  transformResponse: response => ({
    list: response.result.items,
    total: response.result.page.totalCount,
  }),
})

接管 Table 的分页变化

ant-design-vue 的表格在分页、筛选、排序变化时会触发 onChange

Hook 内部接管这个事件,更新当前页码和页容量,然后重新请求:

const handleTableChange: NonNullable<TableProps<RecordType>['onChange']> = (
  nextPagination,
  filters,
  sorter,
  extra,
) => {
  if (tableConfig?.pagination !== false) {
    pagination.value.current = nextPagination.current ?? 1
    pagination.value.pageSize = nextPagination.pageSize ?? pagination.value.pageSize
  }

  tableConfig?.onChange?.(nextPagination, filters, sorter, extra)
  void run()
}

这里仍然会调用 tableConfig.onChange,所以页面如果需要处理排序、筛选等额外逻辑,也不会被 Hook 限制。

生成 tableProps

最后,用 computed 生成真正传给表格的 props:

const tableProps = computed<TableProps<RecordType>>(() => {
  const { pagination: tablePagination, ...restTableConfig } = tableConfig ?? {}

  return {
    ...restTableConfig,
    dataSource: dataSource.value,
    loading: loading.value,
    pagination: tablePagination === false ? false : pagination.value,
    onChange: handleTableChange,
  }
})

页面只需要:

<a-table v-bind="tableProps" />

这也是设计这个 Hook 的核心目的:把重复的表格状态收敛到一个对象里。

查询与重置

Hook 里封装了三个常用动作:

const reload = () => run()
const search = (nextParams?: Partial<SearchParams>) => run(nextParams, true)
const reset = () => {
  params.value = { ...defaultParams }
  return run(undefined, true)
}

它们的区别是:

  • reload:使用当前参数刷新当前页
  • search:合并新查询参数,并回到第一页
  • reset:恢复默认参数,并回到第一页

这符合大多数后台列表页的使用习惯。查询条件变化后,一般应该重新从第一页开始看结果。

完整代码

useTable.ts
import { computed, ref } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import type { TablePaginationConfig, TableProps } from 'ant-design-vue'

export type TableListResult<RecordType> = {
  list: RecordType[]
  total: number
}

export type TableApi<SearchParams, ApiResponse> = (
  params: SearchParams & Record<string, unknown>,
) => Promise<ApiResponse>

export type UseTableConfig<RecordType> = Omit<
  TableProps<RecordType>,
  'dataSource' | 'loading' | 'pagination'
> & {
  pagination?: false | TablePaginationConfig
}

export type UseTableOptions<
  RecordType,
  SearchParams extends Record<string, unknown> = Record<string, unknown>,
  ApiResponse = unknown,
> = {
  api: TableApi<SearchParams, ApiResponse>
  tableConfig?: UseTableConfig<RecordType>
  defaultParams?: Partial<SearchParams>
  immediate?: boolean
  pageField?: string
  pageSizeField?: string
  beforeRequest?: (
    params: SearchParams & Record<string, unknown>,
  ) => SearchParams & Record<string, unknown>
  transformResponse?: (response: ApiResponse) => TableListResult<RecordType>
  afterRequest?: (result: TableListResult<RecordType>, response: ApiResponse) => void
  onError?: (error: unknown) => void
}

export type UseTableReturn<
  RecordType,
  SearchParams extends Record<string, unknown> = Record<string, unknown>,
> = {
  tableProps: ComputedRef<TableProps<RecordType>>
  dataSource: Ref<RecordType[]>
  loading: Ref<boolean>
  error: Ref<unknown>
  pagination: Ref<TablePaginationConfig>
  params: Ref<Partial<SearchParams>>
  run: (nextParams?: Partial<SearchParams>, resetPage?: boolean) => Promise<void>
  reload: () => Promise<void>
  search: (nextParams?: Partial<SearchParams>) => Promise<void>
  reset: () => Promise<void>
  setParams: (nextParams: Partial<SearchParams>) => void
}

const defaultPagination: TablePaginationConfig = {
  current: 1,
  pageSize: 10,
  showSizeChanger: true,
  showTotal: total => `共 ${total} 条`,
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value)
}

function pickFirstRecord(...values: unknown[]) {
  return values.find(isRecord)
}

function pickArray(source: Record<string, unknown> | undefined) {
  if (!source) return undefined

  const keys = ['list', 'records', 'rows', 'items', 'data']
  const value = keys.map(key => source[key]).find(Array.isArray)

  return value as unknown[] | undefined
}

function pickTotal(source: Record<string, unknown> | undefined) {
  if (!source) return undefined

  const keys = ['total', 'totalCount', 'count']
  const value = keys.map(key => source[key]).find(item => typeof item === 'number')

  return value as number | undefined
}

function defaultTransformResponse<RecordType>(response: unknown): TableListResult<RecordType> {
  if (Array.isArray(response)) {
    return {
      list: response as RecordType[],
      total: response.length,
    }
  }

  const responseRecord = isRecord(response) ? response : undefined
  const dataRecord = isRecord(responseRecord?.data) ? responseRecord.data : undefined
  const pageRecord = pickFirstRecord(dataRecord, responseRecord)
  const list = pickArray(pageRecord) ?? []
  const total = pickTotal(pageRecord) ?? list.length

  return {
    list: list as RecordType[],
    total,
  }
}

export function useTable<
  RecordType,
  SearchParams extends Record<string, unknown> = Record<string, unknown>,
  ApiResponse = unknown,
>(
  options: UseTableOptions<RecordType, SearchParams, ApiResponse>,
): UseTableReturn<RecordType, SearchParams> {
  const {
    api,
    tableConfig,
    defaultParams = {},
    immediate = true,
    pageField = 'page',
    pageSizeField = 'pageSize',
  } = options

  const dataSource = ref<RecordType[]>([]) as Ref<RecordType[]>
  const loading = ref(false)
  const error = ref<unknown>(null)
  const params = ref({ ...defaultParams }) as Ref<Partial<SearchParams>>
  const pagination = ref<TablePaginationConfig>({
    ...defaultPagination,
    ...tableConfig?.pagination,
  })
  let requestId = 0

  const setParams = (nextParams: Partial<SearchParams>) => {
    params.value = {
      ...params.value,
      ...nextParams,
    }
  }

  const buildRequestParams = () => {
    const requestParams: Record<string, unknown> = {
      ...params.value,
    }

    if (tableConfig?.pagination !== false) {
      requestParams[pageField] = pagination.value.current ?? defaultPagination.current
      requestParams[pageSizeField] = pagination.value.pageSize ?? defaultPagination.pageSize
    }

    const typedRequestParams = requestParams as SearchParams & Record<string, unknown>

    return options.beforeRequest?.(typedRequestParams) ?? typedRequestParams
  }

  const run = async (nextParams?: Partial<SearchParams>, resetPage = false) => {
    if (nextParams) {
      setParams(nextParams)
    }

    if (resetPage && tableConfig?.pagination !== false) {
      pagination.value.current = 1
    }

    const currentRequestId = ++requestId
    loading.value = true
    error.value = null

    try {
      const response = await api(buildRequestParams())

      if (currentRequestId !== requestId) {
        return
      }

      const result = options.transformResponse
        ? options.transformResponse(response)
        : defaultTransformResponse<RecordType>(response)

      dataSource.value = result.list

      if (tableConfig?.pagination !== false) {
        pagination.value.total = result.total
      }

      options.afterRequest?.(result, response)
    } catch (currentError) {
      if (currentRequestId === requestId) {
        error.value = currentError
        options.onError?.(currentError)
      }
    } finally {
      if (currentRequestId === requestId) {
        loading.value = false
      }
    }
  }

  const reload = () => run()
  const search = (nextParams?: Partial<SearchParams>) => run(nextParams, true)
  const reset = () => {
    params.value = { ...defaultParams }
    return run(undefined, true)
  }

  const handleTableChange: NonNullable<TableProps<RecordType>['onChange']> = (
    nextPagination,
    filters,
    sorter,
    extra,
  ) => {
    if (tableConfig?.pagination !== false) {
      pagination.value.current = nextPagination.current ?? 1
      pagination.value.pageSize = nextPagination.pageSize ?? pagination.value.pageSize
    }

    tableConfig?.onChange?.(nextPagination, filters, sorter, extra)
    void run()
  }

  const tableProps = computed<TableProps<RecordType>>(() => {
    const { pagination: tablePagination, ...restTableConfig } = tableConfig ?? {}

    return {
      ...restTableConfig,
      dataSource: dataSource.value,
      loading: loading.value,
      pagination: tablePagination === false ? false : pagination.value,
      onChange: handleTableChange,
    }
  })

  if (immediate) {
    void run()
  }

  return {
    tableProps,
    dataSource,
    loading,
    error,
    pagination,
    params,
    run,
    reload,
    search,
    reset,
    setParams,
  }
}

总结

useTable 大致设计思路:

  • 用 TypeScript 泛型约束表格行数据、查询参数和接口响应
  • 复用 ant-design-vueTableProps,减少重复类型设计
  • 自动维护 dataSourceloadingerrorpagination
  • 统一处理查询、重置、刷新和分页变化
  • 内置常见响应结构解析,也支持 transformResponse 自定义
  • requestId 避免旧请求覆盖新请求

封装完成后,列表页的代码会更聚焦:页面负责业务字段和交互,Hook 负责请求和表格状态。

这类封装不一定要一步到位。可以先从最常见的分页表格开始,等项目里出现更多真实场景,再逐步补充排序、筛选、缓存、选择行等能力。好的工具,通常都是被业务慢慢打磨出来的。