前言
前端生涯中遇到無數攸關資料呈現的需求需要解決,而 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 起個頭再翻下文件都會明朗很多。
我很滿意現在創建資料表單超輕鬆,且後續維護和擴充都非常方便!