TanStack Table 是无头的,aria-sort 和排序箭头得你自己加
在为某后台管理系统搭设计系统组件库时,我用 @tanstack/react-table v8 封装了一个数据表格组件,交互排序逻辑跑通了,视觉上箭头也有,测试跑起来却全红——th.getAttribute("aria-sort") 返回 null。我以为是测试写错了,翻了好几遍断言,什么都没问题,就是属性不存在。
后来想通了,问题不在测试,问题在我对「无头库」的理解。
现象
- 调用
useReactTable({ getCoreRowModel, getSortedRowModel })并用flexRender渲染<table> - 点击列头排序正常工作,行顺序如期变化
- 测试断言
th.getAttribute("aria-sort")为null,aria-sort属性在 DOM 里根本不存在 - 同样地,
enableSorting={false}的表格里,列头仍然渲染成了可点击的<button>(因为皮肤代码没分支)
根因
@tanstack/react-table 是彻底的无头库(headless)——它只计算行模型、排序状态、分组信息,不渲染任何 DOM。与 antd Table 或 MUI DataGrid 这类「有头组件库」不同,TanStack 不会替你生成 <th aria-sort="ascending">,不会挂事件处理器,不会画箭头图标。这些全部是 skin 的职责。
我踩坑的根本原因是:把「状态计算」和「DOM 渲染」当成同一件事。TanStack 只做前者,把后者完全留给我,而我没意识到,以为库会帮我渲染好 ARIA 属性。
@tanstack/react-table 不产出任何 DOM 节点,包括 aria-sort、<button>、方向箭头。只要皮肤没主动写 aria-sort={...},这个属性就不存在,无论排序是否启用都一样。与此同时,enableSorting={false} 全局禁排序时,如果皮肤没读 getCanSort() 做分支,仍然会渲染 <button>——视觉上"能点"但点了没效果,是典型的无障碍和交互双重 bug。
正解
TanStack 在 column API 上提供了三个方法,是皮肤和库的完整契约:
| 方法 | 返回值 | 用途 |
|------|--------|------|
| header.column.getCanSort() | boolean | 控制是否渲染 <button> 和 aria-sort;已折叠全局 enableSorting 和 accessor 是否存在 |
| header.column.getIsSorted() | false \| "asc" \| "desc" | 决定 aria-sort 的值 + 显示哪个箭头图标 |
| header.column.getToggleSortingHandler() | onClick handler 或 undefined | 挂到 <button> 上,三态循环(无→升→降→无) |
皮肤写法如下:
const canSort = header.column.getCanSort();
const sorted = header.column.getIsSorted(); // false | "asc" | "desc"
<th
aria-sort={
!canSort ? undefined
: sorted === "asc" ? "ascending"
: sorted === "desc" ? "descending"
: "none" // 可排序但当前未排序
}
>
{canSort ? (
<button type="button" onClick={header.column.getToggleSortingHandler()}>
{flexRender(header.column.columnDef.header, header.getContext())}
{sorted === "asc" ? <ChevronUp />
: sorted === "desc" ? <ChevronDown />
: <ChevronsUpDown className="opacity-50" />}
</button>
) : (
flexRender(header.column.columnDef.header, header.getContext())
)}
</th>几个细节值得注意:
- 非排序列不加
aria-sort(undefined会让属性被省略),排序中的列写"ascending"/"descending",可排序但当前未排序的列写"none"——WAI-ARIA 规范希望none出现在「可排序但未激活」的列上,而不是省略。 - 未排序箭头(如
ChevronsUpDown加opacity-50)是可发现性信号,不要省略,否则用户不知道这列能点。 enableSorting={false}全局禁排时,getCanSort()全部返回false,皮肤自动降级为纯文本<th>,不需要额外判断。
用 getCanSort() 做分支门控,用 getIsSorted() 算 aria-sort 值和箭头图标,用 getToggleSortingHandler() 挂点击。这三行逻辑覆盖「可排序/不可排序/升序/降序」四种状态,也让 enableSorting={false} 零成本生效。
验证要点
- 单测(jsdom + Testing Library):点列头 button → 行顺序变化;断言
aria-sort从"none"→"ascending"→"descending"三态循环。 enableSorting={false}测试:断言queryByRole("button")为null,aria-sort属性不存在,点列头文字不触发重排。- 组件文件必须标
"use client"——useReactTable是 Hook,放进 RSC 会直接报错。
一句话外卖
无头库只管状态,不管 DOM——凡是你在 DOM 里找不到的东西,一定是皮肤自己还没写。