前言
前端生涯中遇到无数攸关资料呈现的需求需要解决,而 TanStack Table 是一款第一眼看起来很多余复杂但实际上能省下无数时间的一个优质套件,如果有任何「大量表单、中大型表单应用」我会强烈推荐。
在实际工作中我通过导入 TanStack Table 在几天时间内就能把现有表单大多复杂功能重现 ,并开始大量制作可重用、扩展性高且简洁的数据表单,因为它:
- Headless UI 库,意味着它不绑定任何样式,只提供状态与逻辑,可以与任何现有界面进行整合。
- 提供各大主流框架(React、Vue、Angular)适配器。
- 免费开源且热门,范例众多且文档完善清晰,支持 TypeScript。
数据表单背后的复杂
显示数据到表格上没什么困难的对吧?最简单的表单就是拿到数据 → 渲染上画面 → 保持数据更新,但实际上还有很多问题需要考虑,像是兼顾维护客户端与服务器端状态与逻辑(加载数据、分页、搜索、排序……)就足够头痛。
由于没有事先规划,导致有一次一项微小的功能改动(分页笔数数量调整)都要耗费 3 个工作天,对上百个数据表单进行散弹手术式的修改,既没效率又不可靠,当专案越做越大、功能越加越多,会发现自己正在重刻一套「小型的表格框架」,而且代码相互耦合 Bug 不断。
TanStack Table 核心概念
初次见到以下概念与 API 可能会觉得很复杂,因为它的定位偏向是一款需要自己配置的工具箱,接上自己的 UI 会需要付出额外的时间和经验,初入门可以参考像是 Shadcn 有不错的文档与逐步范例 TanStack Table 整合。
- 核心概念:
- 数据 Data - 提供给表格的核心数据
- 列定义 Column Defs:用于配置列及其数据模型、显示范本等的对象
- 表单实例 Table Instance:包含状态和 API 的核心表单对象
- 行模型 Row Models:data 数组如何根据你正在使用的功能转换为有用的行
- 行 Rows:每一行都镜像其各自的行数据并提供特定于行的 API
- 单元格 Cells:每个单元格都镜像其各自的行-列交叉点并提供特定于单元格的 API
- 表头分组 Header Groups:表头分组是嵌套表头层级的计算切片,每个切片包含一组表头
- 表头 Header:每个表头要相互关联或其列定义派生而来,并提供特定于表头的 API
- 列 Columns:每一列都镜像其各自的列定义,并提供特定于列的 API
- 功能:
- 列排序 Column Ordering - 动态更改列的顺序
- 列固定 Column Pinning - 将列(冻结)固定在表格的左侧或右侧
- 列调整大小 Column Sizing - 动态更改列的宽度(通过调整大小控制点)
- 列可见性 Column Visibility - 控制列的隐藏或显示
- 展开 Expanding - 展开或折叠行(支持子行/层级数据)
- 列分面 Column Faceting - 列出列值的唯一列表或列的最小值/最大值
- 列筛选 Column Filtering - 根据列的搜索值筛选列中的行
- 分组 Grouping - 将多个列分组在一起并可执行聚合运算
- 全局分面 Global Faceting - 列出整个表格所有列的唯一值或最小值/最大值
- 全局筛选 Global Filtering - 根据整个表格的搜索值筛选所有行
- 分页 Pagination - 将行数据进行分页显示
- 行固定 Row Pinning - 将行(冻结)固定在表格的顶部或底部
- 行选择 Row Selection - 选择或取消选择行(常用于复选框操作)
- 行排序 Sorting - 根据列值对行进行排序
- 虚拟滚动 Virtualization - 仅渲染可见范围的行以提升性能
实际范例
以建立一个客户端表单 Vue ClientDataTable.vue
为案例,最重要可以看到它接收 columns
(列定义)与 data
(表单数据),并且在内部定义完善数据状态与更新流程。
<ClientSideDataTable:columns="thingsColumns":data="thingsData":is-loading="isThingsFetching"> <template #header="{ rowSelection }" > <Button @click="() => handleActionA(rowSelection)" >Action A</Button> <Button>Action B</Button> <Button>Action C</Button> </template>
<template #filter="{ table }"> <DataTableViewOptions :table="table"/> <DataTableSearch :table="table" placeholder="搜索栏位⋯⋯" filtering-column="email" /> </template></ClientSideDataTable>
客户端表单实践起来大致会像底下这样,预留了一个 header slot
暴露选取状态与 filter slot
暴露表单状态:
<template> <div> <div v-if="$slots.header"> <!-- 功能选单 --> <slot name="header" :row-selection="rowSelection" /> </div> <div v-if="$slots.filter"> <!-- 过滤选单 --> <slot name="filter" :table="table" /> </div> <!-- 数据表渲染 --> <DataTable :table="table" :columns="columns" :is-loading="isLoading" /> </div> <!-- 数据表分页 --> <DataTablePagination :table="table" /></template>
<script setup lang="ts" generic="TData, TValue">import DataTable from '@/components/ui/datatables/DataTable.vue'import { useVueTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel, getExpandedRowModel, type SortingState, type ColumnFiltersState, type ColumnDef, type VisibilityState, type ExpandedState, type RowSelectionState,} from '@tanstack/vue-table'
import { valueUpdater } from '@/utils/dataTable'
const { data, columns, isLoading } = defineProps<{ columns: ColumnDef<TData, TValue>[]; data?: TData[]; isLoading: boolean;}>()
const sorting = ref<SortingState>([])const columnFilters = ref<ColumnFiltersState>([])const columnVisibility = ref<VisibilityState>({})const rowSelection = ref<RowSelectionState>({})const expanded = ref<ExpandedState>({})
const table = useVueTable({ get data() { return data ?? [] }, get columns() { return columns }, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), getExpandedRowModel: getExpandedRowModel(), onSortingChange: (updaterOrValue) => valueUpdater(updaterOrValue, sorting), onColumnFiltersChange: (updaterOrValue) => valueUpdater(updaterOrValue, columnFilters), onColumnVisibilityChange: (updaterOrValue) => valueUpdater(updaterOrValue, columnVisibility), onRowSelectionChange: (updaterOrValue) => valueUpdater(updaterOrValue, rowSelection), onExpandedChange: (updaterOrValue) => valueUpdater(updaterOrValue, expanded), state: { get sorting() { return sorting.value }, get columnFilters() { return columnFilters.value }, get columnVisibility() { return columnVisibility.value }, get rowSelection() { return rowSelection.value }, get expanded() { return expanded.value }, },})</script>
更麻烦的服务器表单问题在于状态都来自表单外部,这时候如果通过先前提到的「如何通过 TanStack Query 构建更好的前端数据获取体验」整合 TanStack Query 把拿到的状态与表单分页、排序与搜索……等计算逻辑封装出一个 useServerSideDataTable
Composable 如下:
interface UseServerSideDataTableOptions<TResult> { queryKey: QueryKey; queryFn: (params: { internalTableFilter: { limit: number; page: number; sortBy: string; sortDesc: boolean; search: string; }; }) => Promise<{ totalRows: number; results: TResult[];}>;}
export function useServerSideDataTable<TResult>( options: UseServerSideDataTableOptions<TResult>,) { const pagination = ref<PaginationState>({ pageIndex: 0, pageSize: 10 }) const sorting = ref<SortingState>([]) const columnFilters = ref<ColumnFiltersState>([]) const tableFilter = computed(() => { // 分页系统索引起点差异需要转换 const zeroToOneBasedIndexOffset = 1 const firstSortingId = sorting.value[0]?.id const firstSortDesc = sorting.value[0]?.desc // TanStack Table column 字段不一定是 string,可能是数字、日期、选单,但我们应该只会用到 string 搜索,所以先断言为 string const firstSearch = columnFilters.value[0]?.value as string return { limit: pagination.value.pageSize, page: pagination.value.pageIndex + zeroToOneBasedIndexOffset, sortBy: firstSortingId, sortDesc: firstSortDesc, search: firstSearch, } })
const query = useQuery({ queryKey: [...options.queryKey, pagination, sorting, columnFilters], queryFn: () => options.queryFn({ internalTableFilter: tableFilter.value, }), placeholderData: keepPreviousData, })
const pageCount = computed(() => { const queryData = query.data.value if (!queryData) return 0 const totalRows = queryData.totalRows const pageSize = pagination.value.pageSize const hasResults = queryData?.results?.length > 0 return calcPageCount(totalRows, pageSize, hasResults) })
return { // 供 ServerSideDataTable 可用的表格状态 pagination, sorting, columnFilters, pageCount,
// TanStack Query 响应 ...query, }}
就能把大多服务器表单需要的逻辑包装起来,灌入 ServerSideDataTable.vue
。
const customSearchs = ref({ foo: 'abc' bar: '123'})
const { data: thingsData, isFetching: isThingsFetching, pageCount: thingsPageCount, pagination: thingsPagination, sorting: thingsSorting, columnFilters: thingsColumnFilters, refetch: thingsRefetch,} = useServerSideDataTable<Things>({ queryKey: ['things', customSearchs], queryFn: async ({ internalTableFilter, }) => {
const requestParams = { // 已经构建好的 TableFilter,组合最终请求 ...internalTableFilter.value, ...customSearchs } satisfies ThingsFilter
return await fetchThings(requestParams) ; },})
<ServerSideDataTable :columns="thingsColumns" :data="thingsData" :is-loading="isThingsFetching" :page-count="thingsPageCount" v-model:pagination="thingsPagination" v-model:sorting="thingsSorting" v-model:column-filters="thingsColumnFilters"/>
总结
对于 TanStack Table 这类大型成熟的套件 AI 有较多的知识储备,很多问题其实询问 AI 起个头再翻下文档都会明朗很多。
我很满意现在创建数据表单超轻松,且后续维护和扩充都非常方便!