🔍 React 有 useAntdTable,Vue3 怎么办?自封一个 useTable!

为什么封装一个Vue3版本的useTable

之前写 React , 有 ahooks 的 提供的 useAntdTable 这样的成熟数据表封装,而在 Vue3 中并没有等价的"官方"工具。

React + Ant Design 的生态里:

  • ahooksuseAntdTable 非常常用;
  • 负责统一管理表格的数据请求、分页、搜索、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;
相关推荐
崔庆才丨静觅5 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅6 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅6 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment6 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼6 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax
吹牛不交税7 小时前
admin.net-v2 框架使用笔记-netcore8.0/10.0版
vue.js·.netcore
Cobyte7 小时前
AI全栈实战:使用 Python+LangChain+Vue3 构建一个 LLM 聊天应用
前端·后端·aigc
NEXT067 小时前
前端算法:从 O(n²) 到 O(n),列表转树的极致优化
前端·数据结构·算法