为什么封装一个Vue3版本的useTable
之前写 React , 有 ahooks 的 提供的 useAntdTable 这样的成熟数据表封装,而在 Vue3 中并没有等价的"官方"工具。
在 React + Ant Design 的生态里:
ahooks的useAntdTable非常常用;- 负责统一管理表格的数据请求、分页、搜索、loading、刷新等逻辑。
例如:
ts
const { tableProps, search } = useAntdTable(getTableData)
这会自动帮你实现:
- 请求数据;
- 分页变化自动触发请求;
- 搜索参数归一化;
- Loading 管理。
👉 组件只负责展示,逻辑都集中在 hook 中。
在vue3项目中,也有不少表格业务,他们的功能都大差不差,有很多相似的逻辑,然后我们的封装一个useTable,其作用就是提取相同的逻辑到hooks中,而UI组件只负责使用hooks暴露的数据与方法。这极大的提高了代码的逻辑复用,统一行为,UI 与业务逻辑完全分离。
介绍 useTable
一个专为 Vue3 + Element Plus 设计的表格数据管理 Hook,参考 ahooks 的 useAntdTable 设计,提供部分的表格功能支持。
特性
- 🚀 开箱即用 - 零配置即可使用,自动处理分页、搜索、排序
- 📦 类型安全 - 完整的 TypeScript 支持
- 🎯 语义化 API - 方法名清晰易懂,符合开发习惯
- 🔧 高度可定制 - 支持数据转换、错误处理、回调函数等
- 📱 响应式 - 基于 Vue3 Composition API,完全响应式
- 🎨 Element Plus 友好 - 专为 Element Plus 表格组件设计
📋 核心功能
1. 数据管理
- ✅ 自动加载数据
- ✅ 加载状态管理
- ✅ 错误处理
2. 分页功能
- ✅ 页码切换
- ✅ 每页条数调整
- ✅ 分页状态同步
3. 搜索功能
- ✅ 多字段搜索
- ✅ 搜索条件组合
- ✅ 搜索重置
4. 排序功能
- ✅ 多字段排序
- ✅ 排序方向切换
- ✅ 排序状态管理
5. 行选择
- ✅ 单选/多选
- ✅ 批量操作
- ✅ 选中状态管理
基础用法
typescript
import { useTable } from '@/hooks/useTable'
// 定义数据类型
interface User {
id: number
name: string
email: string
status: 'active' | 'inactive'
}
// 定义请求参数类型
interface UserListParams {
current: number
pageSize: number
name?: string
status?: string
}
// API 请求函数
const getUserList = async (params: UserListParams) => {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(params)
})
return response.json()
}
// 使用 Hook
const { data, loading, pagination, search, refresh } = useTable<User, UserListParams>({
request: getUserList,
defaultPagination: { current: 1, pageSize: 10 },
defaultSearchParams: { status: 'active' }
})
API
配置选项
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
request |
(params: P) => Promise<HttpResponse<TableData<T>>> |
- | 数据请求函数 |
defaultPagination |
Partial<PaginationParams> |
{ current: 1, pageSize: 10 } |
默认分页参数 |
defaultSearchParams |
Record<string, any> |
{} |
默认搜索参数 |
defaultSortParams |
SortParams |
{} |
默认排序参数 |
immediate |
boolean |
true |
是否立即加载数据 |
paginationKey |
PaginationKey |
{ current: 'pageNum', size: 'pageSize' } |
自定义分页字段映射 如接口的分页字段不是pageNum和pageSize,可使用该参数快速配置分页字段 |
rowKey |
`string | ((record: T) => string | number)` |
transformResponse |
(response: HttpResponse<TableData<T>>) => TableData<T> |
- | 数据转换函数 |
onError |
(error: any) => void |
- | 错误处理函数 |
onSuccess |
(data: TableData<T>) => void |
- | 成功回调函数 |
beforeRequest |
`(params: P) => P | Promise ` |
- |
返回值
| 属性 | 类型 | 说明 |
|---|---|---|
data |
ComputedRef<T[]> |
表格数据列表 |
hasData |
ComputedRef<boolean> |
是否有表格数据 |
loading |
ComputedRef<boolean> |
加载状态 |
pagination |
ComputedRef<PaginationParams> |
分页信息 |
searchParams |
Ref<Record<string, any>> |
搜索参数 |
sortParams |
Ref<SortParams> |
排序参数 |
selectedRowKeys |
`Ref<(string | number)[]>` |
selectedRows |
Ref<T[]> |
选中的行数据 |
refresh |
() => Promise<void> |
刷新数据(保持当前条件) |
reload |
() => Promise<void> |
重新加载数据(重置到第一页) |
search |
(params?: Record<string, any>) => Promise<void> |
搜索 |
reset |
() => Promise<void> |
重置搜索 |
setPagination |
(pagination: Partial<PaginationParams>) => void |
设置分页 |
setSortParams |
(sortParams: SortParams) => void |
设置排序 |
setSelectedRowKeys |
`(keys: (string | number)[]) => void` |
clearSelection |
() => void |
清空选中 |
getRowKey |
`(record: T) => string | number` |
onSortChange |
`(payload: { prop?: string; order?: 'ascending' | 'descending' |
onSelectionChange |
(selection: T[]) => void |
处理 Element Plus 表格选择事件, @param selection - 选中的行数据 |
onCurrentChange |
(current: number) => void |
处理 Element Plus 分页页码变更事件, @param current - 当前页 |
onSizeChange |
(size: number) => void |
处理 Element Plus 分页每页条数变更事件, @param size - 每页条数 |
使用示例
基础表格
ts
<template>
<div>
<!-- 搜索表单 -->
<el-form :model="searchForm" inline>
<el-form-item label="用户名">
<el-input v-model="searchForm.name" placeholder="请输入用户名" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 表格 -->
<el-table :data="data" :loading="loading" row-key="id">
<el-table-column prop="id" label="ID" />
<el-table-column prop="name" label="用户名" />
<el-table-column prop="email" label="邮箱" />
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useTable } from '@/hooks/useTable'
const searchForm = ref({ name: '' })
const { data, loading, pagination, search, reset } = useTable({
request: getUserList,
defaultPagination: { current: 1, pageSize: 10 }
})
const handleSearch = () => {
search(searchForm.value)
}
const handleReset = () => {
searchForm.value = { name: '' }
reset()
}
const handleCurrentChange = (current: number) => {
pagination.value.current = current
}
const handleSizeChange = (size: number) => {
pagination.value.pageSize = size
}
</script>
带选择的表格
ts
<template>
<div>
<el-table
:data="data"
:loading="loading"
@selection-change="handleSelectionChange"
row-key="id"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="name" label="用户名" />
<el-table-column prop="email" label="邮箱" />
</el-table>
<el-button
type="danger"
@click="handleBatchDelete"
:disabled="!selectedRowKeys.length"
>
批量删除 ({{ selectedRowKeys.length }})
</el-button>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/useTable'
const {
data,
loading,
selectedRowKeys,
selectedRows,
setSelectedRowKeys
} = useTable({
request: getUserList
})
const handleSelectionChange = (selection: User[]) => {
const keys = selection.map(item => item.id)
setSelectedRowKeys(keys)
}
const handleBatchDelete = () => {
console.log('选中的用户:', selectedRows.value)
}
</script>
带排序的表格
ts
<template>
<el-table
:data="data"
:loading="loading"
@sort-change="handleSortChange"
>
<el-table-column prop="name" label="用户名" sortable="custom" />
<el-table-column prop="createTime" label="创建时间" sortable="custom" />
</el-table>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/useTable'
const { data, loading, setSortParams } = useTable({
request: getUserList
})
const handleSortChange = ({ prop, order }: any) => {
setSortParams({
field: prop,
order: order === 'ascending' ? 'ascend' : order === 'descending' ? 'descend' : undefined
})
}
</script>
数据转换
ts
const { data, loading } = useTable({
request: getUserList,
transformResponse: (response) => {
// 自定义数据转换逻辑
const { result } = response
return {
list: result?.list?.map(item => ({
...item,
statusText: item.status === 'active' ? '激活' : '禁用'
})) || [],
total: result?.total || 0,
current: result?.current || 1,
pageSize: result?.pageSize || 10
}
}
})
错误处理
ts
const { data, loading } = useTable({
request: getUserList,
onError: (error) => {
console.error('数据加载失败:', error)
// 自定义错误处理逻辑
},
onSuccess: (data) => {
console.log('数据加载成功:', data)
// 自定义成功处理逻辑
}
})
请求前处理
typescript
const { data, loading } = useTable({
request: getUserList,
beforeRequest: (params) => {
// 在请求前添加额外参数
return {
...params,
timestamp: Date.now(),
token: localStorage.getItem('token')
}
}
})
最佳实践
1. 类型安全
typescript
// 定义明确的类型
interface User {
id: number
name: string
email: string
}
interface UserListParams {
current: number
pageSize: number
name?: string
}
// 使用泛型确保类型安全
const { data, loading } = useTable<User, UserListParams>({
request: getUserList
})
2. 错误处理
typescript
const { data, loading } = useTable({
request: getUserList,
onError: (error) => {
// 统一错误处理
ElMessage.error('数据加载失败,请稍后重试')
console.error('API Error:', error)
}
})
3. 性能优化
typescript
// 使用防抖搜索
import { debounce } from 'lodash-es'
const debouncedSearch = debounce((params) => {
search(params)
}, 300)
const handleSearch = () => {
debouncedSearch(searchForm.value)
}
代码实现
ts
import { ref, computed, type Ref, onMounted } from 'vue';
import { ElMessage } from 'element-plus';
import { PaginationParams, SortParams, TableData, UseTableOptions, UseTableReturn } from './type';
export * from './type';
/**
* 表格 Hook
*
* 提供表格数据管理、分页、搜索、排序、选择等功能
*
* @template T 表格数据类型
* @template P 请求参数类型
* @param options 配置选项
* @returns 表格操作对象
*
* @example
* ```typescript
* const { data, loading, pagination, search, refresh } = useTable({
* request: getUserList,
* defaultPagination: { current: 1, pageSize: 10 },
* defaultSearchParams: { status: 'active' }
* })
*
* // 搜索
* search({ name: 'test' })
*
* // 刷新
* refresh()
*
* // 重置
* reset()
* ```
*/
export function useTable<T = any, P = any>(options: UseTableOptions<T, P>): UseTableReturn<T> {
const {
request,
defaultPagination = { pageNum: 1, pageSize: 10 },
defaultSearchParams = {},
defaultSortParams = {},
immediate = true,
paginationKey = { current: 'pageNum', size: 'pageSize' },
rowKey = 'id',
transformResponse,
onError,
onSuccess,
beforeRequest
} = options;
// 分页字段名配置
const pageKey = paginationKey?.current || 'pageNum';
const sizeKey = paginationKey?.size || 'pageSize';
const loading = ref(false);
const tableData = ref<TableData<T>>({
list: [],
total: 0
});
const searchParams = ref<Record<string, any>>({ ...defaultSearchParams });
const pagination = ref<PaginationParams>({
pageNum: defaultPagination.pageNum || 1,
pageSize: defaultPagination.pageSize || 10,
total: defaultPagination.total
});
const sortParams = ref<SortParams>({ ...defaultSortParams });
const selectedRowKeys = ref<(string | number)[]>([]);
const selectedRows = ref<T[]>([]);
// table数据
const data = computed<T[]>(() => tableData.value.list as unknown as T[]);
// 是否有数据
const hasData = computed(() => data.value.length > 0);
/**
* 获取行键
*/
const getRowKey = (record: T): string | number => {
if (typeof rowKey === 'function') {
return rowKey(record);
}
return (record as any)[rowKey];
};
/**
* 构建请求参数
*/
const buildRequestParams = (): P => {
const params = {
...searchParams.value,
[pageKey]: pagination.value.pageNum,
[sizeKey]: pagination.value.pageSize
} as P;
if (sortParams.value.field && sortParams.value.order) {
(params as any).sortField = sortParams.value.field;
(params as any).sortOrder = sortParams.value.order;
}
return params;
};
/**
* 更新table数据 如:删除一行数据后,不想触发获取列表的接口,可通过此方法手动更新数据
*/
const updateData = (data: T[]) => {
tableData.value = {
...tableData.value,
list: data
};
};
/**
* 执行数据请求
*/
const fetchData = async (): Promise<void> => {
try {
loading.value = true;
let params = buildRequestParams();
// 请求前处理
if (beforeRequest) {
params = await beforeRequest(params);
}
const response = await request(params);
let data = response;
if (transformResponse) {
data = transformResponse(response);
}
tableData.value = {
list: data.list || [],
total: data.total || 0
};
pagination.value.total = tableData.value.total;
onSuccess?.(tableData.value as TableData<T>);
} catch (error) {
console.error('useTable fetchData error:', error);
ElMessage.error('请求失败,请稍后重试');
onError?.(error);
} finally {
loading.value = false;
}
};
/**
* 刷新数据(保持当前分页和搜索条件)
*/
const refresh = async (): Promise<void> => {
await fetchData();
};
/**
* 重新加载数据(重置到第一页)
*/
const reload = async (): Promise<void> => {
pagination.value.pageNum = 1;
await fetchData();
};
/**
* 搜索
*/
const search = async (params?: Record<string, any>): Promise<void> => {
if (params) {
searchParams.value = { ...searchParams.value, ...params };
}
pagination.value.pageNum = 1;
await fetchData();
};
/**
* 重置搜索
*/
const reset = async (): Promise<void> => {
searchParams.value = { ...defaultSearchParams };
sortParams.value = { ...defaultSortParams };
pagination.value.pageNum = defaultPagination.pageNum!;
pagination.value.pageSize = defaultPagination.pageSize!;
selectedRowKeys.value = [];
selectedRows.value = [];
await fetchData();
};
/**
* 设置分页
*/
const setPagination = (paginationParams: Partial<PaginationParams>): void => {
Object.assign(pagination.value, paginationParams);
fetchData();
};
/**
* 设置排序
*/
const setSortParams = (sortParamsData: SortParams): void => {
sortParams.value = { ...sortParamsData };
fetchData();
};
/**
* 设置选中行
*/
const setSelectedRowKeys = (keys: (string | number)[]): void => {
selectedRowKeys.value = keys;
selectedRows.value = (tableData.value.list as unknown as T[]).filter(item =>
keys.includes(getRowKey(item))
);
};
/**
* 清空选中
*/
const clearSelection = (): void => {
selectedRowKeys.value = [];
selectedRows.value = [];
};
/**
* Element Plus: 表格排序变更事件处理
*/
const onSortChange = (
payload: { prop?: string; order?: 'ascending' | 'descending' | null } | any
): void => {
const prop: string | undefined = payload?.prop;
const order: 'ascending' | 'descending' | null | undefined = payload?.order;
if (prop && order) {
setSortParams({ field: prop, order: order === 'ascending' ? 'ascend' : 'descend' });
} else {
setSortParams({});
}
};
/**
* Element Plus: 表格选择变更事件处理
*/
const onSelectionChange = (selection: T[]): void => {
const keys = selection.map(item => getRowKey(item));
setSelectedRowKeys(keys);
};
/**
* Element Plus: 分页当前页变更事件处理
*/
const onCurrentChange = (current: number): void => {
setPagination({ pageNum: current });
};
/**
* Element Plus: 分页每页条数变更事件处理
*/
const onSizeChange = (size: number): void => {
setPagination({ pageSize: size, pageNum: 1 });
};
onMounted(() => {
if (immediate) {
fetchData();
}
});
return {
data,
hasData,
loading,
pagination,
searchParams,
sortParams,
selectedRowKeys,
selectedRows: selectedRows as unknown as Ref<T[]>,
refresh,
reload,
search,
reset,
updateData,
setPagination,
setSortParams,
setSelectedRowKeys,
clearSelection,
getRowKey,
onSortChange,
onSelectionChange,
onCurrentChange,
onSizeChange
};
}
export default useTable;