返回博客

TanStack Table 是无头的,aria-sort 和排序箭头得你自己加

在为某后台管理系统搭设计系统组件库时,我用 @tanstack/react-table v8 封装了一个数据表格组件,交互排序逻辑跑通了,视觉上箭头也有,测试跑起来却全红——th.getAttribute("aria-sort") 返回 null。我以为是测试写错了,翻了好几遍断言,什么都没问题,就是属性不存在。

后来想通了,问题不在测试,问题在我对「无头库」的理解。

现象

  • 调用 useReactTable({ getCoreRowModel, getSortedRowModel }) 并用 flexRender 渲染 <table>
  • 点击列头排序正常工作,行顺序如期变化
  • 测试断言 th.getAttribute("aria-sort")nullaria-sort 属性在 DOM 里根本不存在
  • 同样地,enableSorting={false} 的表格里,列头仍然渲染成了可点击的 <button>(因为皮肤代码没分支)

根因

@tanstack/react-table 是彻底的无头库(headless)——它只计算行模型、排序状态、分组信息,不渲染任何 DOM。与 antd Table 或 MUI DataGrid 这类「有头组件库」不同,TanStack 不会替你生成 <th aria-sort="ascending">,不会挂事件处理器,不会画箭头图标。这些全部是 skin 的职责。

我踩坑的根本原因是:把「状态计算」和「DOM 渲染」当成同一件事。TanStack 只做前者,把后者完全留给我,而我没意识到,以为库会帮我渲染好 ARIA 属性。

坑:无头库不渲染 ARIA,测试里 aria-sort 永远是 null

@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-sortundefined 会让属性被省略),排序中的列写 "ascending"/"descending",可排序但当前未排序的列写 "none"——WAI-ARIA 规范希望 none 出现在「可排序但未激活」的列上,而不是省略。
  • 未排序箭头(如 ChevronsUpDownopacity-50)是可发现性信号,不要省略,否则用户不知道这列能点。
  • enableSorting={false} 全局禁排时,getCanSort() 全部返回 false,皮肤自动降级为纯文本 <th>,不需要额外判断。
正解:三个 column 方法,皮肤自己写 ARIA 和箭头

getCanSort() 做分支门控,用 getIsSorted()aria-sort 值和箭头图标,用 getToggleSortingHandler() 挂点击。这三行逻辑覆盖「可排序/不可排序/升序/降序」四种状态,也让 enableSorting={false} 零成本生效。

验证要点

  • 单测(jsdom + Testing Library):点列头 button → 行顺序变化;断言 aria-sort"none""ascending""descending" 三态循环。
  • enableSorting={false} 测试:断言 queryByRole("button")nullaria-sort 属性不存在,点列头文字不触发重排。
  • 组件文件必须标 "use client"——useReactTable 是 Hook,放进 RSC 会直接报错。

一句话外卖

无头库只管状态,不管 DOM——凡是你在 DOM 里找不到的东西,一定是皮肤自己还没写。