在中后台开发里,表格查询基本是最高频的场景之一。列表、分页、筛选、刷新,这些逻辑几乎每个页面都会出现,所以大多数项目最后都会走到“封装表格”这一步。
但问题也很明显:如果只提供一个统一的 Table 组件,前期会很省事,后面随着业务变复杂,总会遇到一些 UI 定制场景,通过一层组件不能完全解决。
但数据层的逻辑其实相对稳定。我觉得更合适的做法,就是把这两部分拆开处理。
提供两层能力:
- 底层提供
useTable,只处理数据请求、分页和状态管理 - 上层提供
useTableComponent,用于标准化场景下的快速开发

如果页面没有太多特殊交互,直接用 useTableComponent 就够了;如果表格样式或者结构需要深度定制,就只使用 useTable,自己控制渲染。
useTable
既然 useTable 只负责数据层,那就先把它的输入和输出定义清楚。

入参设计:
interface DataFields {
responseFields?: {
list?: string
total?: string
}
paginationFields?: {
page?: string
pageSize?: string
}
}
export interface UseTableOptions<T = any> {
/** 数据获取函数 */
request: (params: FetchParams, signal?: AbortSignal) => Promise<Record<string, any>>
/** 除分页之外的业务参数 */
queryParams?: MaybeRefOrGetter<Record<string, any>>
/** 是否立即执行 */
immediate?: boolean
/** 是否监听查询参数变化自动重新查询 */
watchQueryParams?: boolean
/** 默认分页配置 */
defaultPagination?: DefaultPagination
/** 发送请求前对参数进行处理 */
paramsHandler?: (params: FetchParams) => FetchParams
/** 数据转换函数 */
transform?: (data: T[]) => T[]
/** 请求成功回调 */
onSuccess?: (data: T[], total: number) => void
/** 请求失败回调 */
onError?: (error: unknown) => void
/** 请求和响应中的分页/列表字段映射 */
dataFields?: DataFields
}
返回值为: 返回数据、加载状态、错误信息、分页状态,以及几个常用操作方法:
export interface UseTableResult<T = any> {
data: Ref<T[]>
loading: Ref<boolean>
error: Ref<unknown>
pagination: PaginationState
refresh: () => Promise<T[] | undefined>
search: () => void
handlePageChange: (page: number) => void
handleSizeChange: (pageSize: number) => void
}
export function useTable<T = any>(options: UseTableOptions<T>): UseTableResult<T> {
}
1. 处理分页和请求参数
表格请求一定绕不开分页,所以先把分页状态和字段映射处理掉。
export function useTable<T = any>(options: UseTableOptions<T>): UseTableResult<T> {
const { defaultPagination = {}, paramsHandler, dataFields } = options
const { page: defaultCurrentPage = 1, pageSize: defaultPageSize = 10 } = defaultPagination
const listField = dataFields?.responseFields?.list ?? 'list'
const totalField = dataFields?.responseFields?.total ?? 'total'
const pageField = dataFields?.paginationFields?.page ?? 'page'
const pageSizeField = dataFields?.paginationFields?.pageSize ?? 'pageSize'
const pagination = reactive({
page: defaultCurrentPage,
pageSize: defaultPageSize,
total: 0
})
// queryParams 可能是 ref、computed 或普通对象
const getQueryParams = (): Record<string, any> | undefined => toValue(queryParams)
const buildRequestParams = (): FetchParams => {
const totalParams: FetchParams = {
...getQueryParams(),
[pageField]: pagination.page,
[pageSizeField]: pagination.pageSize
}
return paramsHandler ? paramsHandler(totalParams) : totalParams
}
}
dataFields兼容不同接口的字段命名。paramsHandler自定义参数的处理
2. 补齐请求逻辑
参数准备好之后,就可以开始处理真正的请求流程了。
我额外做了一层 AbortController 控制,避免连续触发查询时旧请求覆盖新请求。
const { request, onSuccess, onError, transform } = options
function getValueByPath<T = unknown>(source: Record<string, any>, path: string): T {
return path.split('.').reduce<any>((value, key) => value?.[key], source)
}
async function fetchData(): Promise<T[]> {
abortController?.abort()
const controller = new AbortController()
abortController = controller
try {
const response = await request(buildRequestParams(), controller.signal)
if (controller.signal.aborted) {
return latestData
}
const list = getValueByPath<T[]>(response, listField)
const total = getValueByPath<number>(response, totalField)
if (!Array.isArray(list)) {
throw new TypeError(`Expected response.${listField} to be an array`)
}
if (typeof total !== 'number') {
throw new TypeError(`Expected response.${totalField} to be a number`)
}
pagination.total = total
onSuccess?.(list, total)
const finalData = transform ? transform(list) : list
latestData = finalData
return finalData
}
catch (err) {
if (controller.signal.aborted) {
return latestData
}
onError?.(err)
throw err
}
finally {
if (abortController === controller) {
abortController = undefined
}
}
}
getValueByPath 的作用比较直接:有些接口返回的数据结构不一定是平铺的,比如 data.list、data.total,这时候通过路径读取会更灵活。
3. 通过 useAsyncState 托管异步状态
请求函数准备好之后,状态管理就交给 useAsyncState 了。
const { immediate } = options
const { state: data, isLoading: loading, error, execute } = useAsyncState(fetchData, [], {
immediate,
resetOnExecute: false
})
这样 data、loading、error 这些状态就不需要自己再手动维护一遍。
4. 补充表格常用操作
最后再把刷新、查询、翻页这些高频操作补齐,useTable 基本就成型了。
const refresh = (): Promise<T[] | undefined> => execute()
function search(): void {
pagination.page = 1
execute()
}
function handlePageChange(page: number): void {
pagination.page = page
execute()
}
function handleSizeChange(pageSize: number): void {
pagination.pageSize = pageSize
pagination.page = 1
execute()
}
// 监听查询参数变化,自动重新查询
if (queryParams && watchQueryParams) {
watch(
() => toValue(queryParams),
() => {
search()
},
{ deep: true }
)
}
完整代码
import type { FetchParams, UseTableOptions, UseTableResult } from './types'
import { useAsyncState } from '@vueuse/core'
import { reactive, toValue, watch } from 'vue'
function getValueByPath<T = unknown>(source: Record<string, any>, path: string): T {
return path.split('.').reduce<any>((value, key) => value?.[key], source)
}
export function useTable<T = any>(options: UseTableOptions<T>): UseTableResult<T> {
const {
request,
queryParams,
immediate = true,
watchQueryParams,
defaultPagination = {},
paramsHandler,
transform,
onError,
onSuccess,
dataFields
} = options
const listField = dataFields?.responseFields?.list ?? 'list'
const totalField = dataFields?.responseFields?.total ?? 'total'
const pageField = dataFields?.paginationFields?.page ?? 'page'
const pageSizeField = dataFields?.paginationFields?.pageSize ?? 'pageSize'
const { page: defaultCurrentPage = 1, pageSize: defaultPageSize = 10 } = defaultPagination
const pagination = reactive({
page: defaultCurrentPage,
pageSize: defaultPageSize,
total: 0
})
const getQueryParams = (): Record<string, any> | undefined => toValue(queryParams)
let abortController: AbortController | undefined
let latestData: T[] = []
const buildRequestParams = (): FetchParams => {
const totalParams: FetchParams = {
...getQueryParams(),
[pageField]: pagination.page,
[pageSizeField]: pagination.pageSize
}
return paramsHandler ? paramsHandler(totalParams) : totalParams
}
const fetchData = async (): Promise<T[]> => {
abortController?.abort()
const controller = new AbortController()
abortController = controller
try {
const response = await request(buildRequestParams(), controller.signal)
if (controller.signal.aborted) {
return latestData
}
const list = getValueByPath<T[]>(response, listField)
const total = getValueByPath<number>(response, totalField)
if (!Array.isArray(list)) {
throw new TypeError(`Expected response.${listField} to be an array`)
}
if (typeof total !== 'number') {
throw new TypeError(`Expected response.${totalField} to be a number`)
}
// 只保存 total,是因为 data / loading / error 交给 useAsyncState 管理
pagination.total = total
onSuccess?.(list, total)
const finalData = transform ? transform(list) : list
latestData = finalData
return finalData
}
catch (err) {
if (controller.signal.aborted) {
return latestData
}
onError?.(err)
throw err
}
finally {
if (abortController === controller) {
abortController = undefined
}
}
}
const { state: data, isLoading: loading, error, execute } = useAsyncState(fetchData, [], {
immediate,
resetOnExecute: false
})
const refresh = (): Promise<T[] | undefined> => execute()
const search = (): void => {
pagination.page = 1
execute()
}
const handlePageChange = (page: number): void => {
pagination.page = page
execute()
}
const handleSizeChange = (pageSize: number): void => {
pagination.pageSize = pageSize
pagination.page = 1
execute()
}
// 监听查询参数变化,自动重新查询
if (queryParams && watchQueryParams) {
watch(
() => toValue(queryParams),
() => {
search()
},
{ deep: true }
)
}
return {
/** 表格数据 */
data,
/** 加载状态 */
loading,
/** 错误信息 */
error,
/** 分页信息 */
pagination,
/** 刷新数据(保持当前分页) */
refresh,
/** 重新查询(重置到第一页) */
search,
/** 分页变化处理 */
handlePageChange,
/** 每页数量变化处理 */
handleSizeChange
}
}
useTableComponent
如果业务场景比较标准,其实没必要每次都自己写一遍 el-table 和 el-pagination。这时候就可以在 useTable 之上,再包一层返回组件的能力。
也就是 useTableComponent。
interface ColumnConfig {
prop: string
label: string
width?: number | string
minWidth?: number | string
align?: 'left' | 'center' | 'right'
fixed?: boolean | 'left' | 'right'
[key: string]: any
}
interface UseTableComponentOptions {
columns: ColumnConfig[]
tableProps?: Record<string, any>
paginationProps?: Record<string, any>
}
function useTableComponent(options: UseTableComponentOptions) {
const {
columns,
tableProps = {},
paginationProps = {},
...tableOptions
} = options
const tableState = useTable<T>(tableOptions)
}
这里的思路很简单:useTableComponent 不重新实现查询逻辑,只是消费 useTable 返回的数据和方法,然后拼出一个标准表格组件。
// 返回一个 Vue 组件
const TableComponent = defineComponent({
name: 'DynamicTableComponent',
setup() {
return () => {
return h('div', {class: 'table-warpper'}, [
// ... 搜索表单
h(
ElTable,
{
data: tableState.data.value,
vLoading: tableState.loading.value,
...tableProps
},
() =>
columns.map(col =>
h(ElTableColumn, {
key: col.prop,
...col
})
)
),
h(ElPagination, {
class: 'table-pagination',
currentPage: tableState.pagination.currentPage,
pageSize: tableState.pagination.pageSize,
total: tableState.pagination.total,
layout: 'total, sizes, prev, pager, next, jumper',
pageSizes: [10, 20, 50, 100],
'onUpdate:currentPage': tableState.handlePageChange,
'onUpdate:pageSize': tableState.handleSizeChange,
...paginationProps
})
])
}
}
})
return TableComponent
}
这样一来,整个表格封装就形成了两层:
useTable负责稳定的数据逻辑useTableComponent负责标准化场景下的快速渲染