🔍 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;
相关推荐
O***p6041 小时前
TypeScript类型守卫
前端·javascript·typescript
小希smallxi2 小时前
在 Spring Boot 项目中,如何在非 Web 层(如 AOP)中获取 Session 信息
前端·spring boot·后端
申阳2 小时前
Day 14:个人开发者的 Cloudflare 通关指南-将域名托管到 Cloudflare
前端·后端·程序员
申阳2 小时前
Day 13:个人开发者的 Cloudflare 通关指南-R2对象存储搭建高速免费图床
前端·后端·程序员
nvd112 小时前
niri 音频图形界面工具
前端·chrome·音视频
凯哥19702 小时前
彻底解决 Windsurf 在 Vue DevTools 无法精准定位的问题
前端
凡人程序员2 小时前
微前端qiankun接入的问题
前端·javascript
CharlieWang2 小时前
AI Elements Vue,帮助你更快的构建 AI 应用程序
前端·人工智能·chatgpt
新晨4372 小时前
JavaScript map() 方法:从工具到编程哲学的升华
前端·javascript