Tips & Tricks (更新: 2026/6/2)

用Claude Code构建React表格组件:排序、筛选、分页与可访问性

用Claude Code实现React表格组件,覆盖语义化table、排序、筛选、分页、移动端、TanStack Table和Playwright检查。

用Claude Code构建React表格组件:排序、筛选、分页与可访问性

先定义表格契约,而不是先画样式

后台系统、客户管理、账单历史、商品目录、文章数据看板都会用到表格。表格的第一版通常只是“把数据列出来”,但真实项目很快会出现更多要求:按金额排序、按状态筛选、数据太多时分页、手机上能读、键盘能操作、修改后能用自动化测试确认没有坏掉。

Claude Code很适合处理这些重复而细碎的工作,但前提是你给它清晰的约束。只说“做一个好看的表格”,很容易得到一个视觉上像表格、语义上却只是div网格的实现。那样的代码在屏幕阅读器、键盘操作、排序状态、移动端布局和测试上都容易留下问题。

本文用一个客户表格作为例子,构建可直接复制的React/TypeScript组件。内容包括语义化table基础、列排序、全局筛选、分页、响应式移动端、可访问性、何时选择TanStack Table,以及用Playwright做回归检查。React整体开发可以参考Claude Code React开发,可访问性细节可以继续看Claude Code可访问性实践

官方资料建议同时打开:HTML表格语义看MDN <table>,排序状态看MDN aria-sort,复杂表格状态看TanStack Table文档,端到端测试看Playwright Writing tests,Claude Code工作流看Claude Code overview

语义化table基础

当行和列的交叉关系本身有意义时,就应该使用表格。客户名、套餐、月收入、状态、注册日期这类数据,必须通过列标题才能理解,所以它们适合放进table。如果只是几个独立卡片,则不一定要使用表格。

基础结构包含captiontheadtbodyth scope="col"caption说明表格目的,列标题使用th,行的主语可以用th scope="row"。这些写法不会让样式自动变好,却能让浏览器和辅助技术理解数据关系。

<table>
  <caption>客户月度经常性收入</caption>
  <thead>
    <tr>
      <th scope="col">客户</th>
      <th scope="col">套餐</th>
      <th scope="col">MRR</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Northwind</th>
      <td>Pro</td>
      <td>$1,200</td>
    </tr>
  </tbody>
</table>

给Claude Code的要求也要写到这个级别。例如“使用原生table、添加caption、列标题加scope="col"、第一列作为行标题”。这样比“做一个表格”稳定得多。

flowchart TD
  A["需求"] --> B["语义化 table"]
  B --> C["排序、筛选、分页状态"]
  C --> D["移动端呈现"]
  D --> E["可访问性检查"]
  E --> F["Playwright 验证"]

给Claude Code的提示词

表格组件横跨数据、状态、CSS和测试,所以提示词要限制修改范围,也要说明禁止项。下面的模板适合直接改成自己的项目路径。

请用 React + TypeScript 实现客户表格组件。

要求:
- 只修改 src/components/DataTable.tsx 和 src/components/data-table.css
- 使用 table、caption、thead、tbody、th scope
- 数据字段为 id, name, plan, mrr, status, signedUpAt
- 支持全文筛选、列排序、每页5条分页
- 只有当前排序列能设置 aria-sort="ascending|descending"
- 列标题内部使用 button 触发排序
- 移动端通过 data-label 显示每个单元格的列名
- 增加 Playwright 测试覆盖筛选、排序、分页和移动端标签

不要:
- 新增项目没有使用的UI库
- 随意添加 role="grid"
- 用伪代码代替可运行代码

重点是把Claude Code当作“保持约束并生成差分的助手”,而不是只让它输出一段组件。表格越接近业务核心,越需要把验收条件写清楚。

可复制的React/TypeScript实现

下面的实现不依赖表格库,适合中小型后台列表。它包含搜索、排序、分页、aria-sort和移动端需要的data-label

// src/components/DataTable.tsx
"use client";

import { useMemo, useState, type ReactNode } from "react";
import "./data-table.css";

type SortDirection = "asc" | "desc";
type SortState<T> = { key: keyof T; direction: SortDirection } | null;

type Customer = {
  id: string;
  name: string;
  plan: "Free" | "Pro" | "Enterprise";
  mrr: number;
  status: "active" | "trial" | "paused";
  signedUpAt: string;
};

type Column<T> = {
  key: keyof T;
  label: string;
  numeric?: boolean;
  render?: (value: T[keyof T], row: T) => ReactNode;
};

const pageSize = 5;
const rows: Customer[] = [
  { id: "cus_001", name: "Northwind", plan: "Pro", mrr: 1200, status: "active", signedUpAt: "2026-01-15" },
  { id: "cus_002", name: "Blue Bottle", plan: "Free", mrr: 0, status: "trial", signedUpAt: "2026-02-02" },
  { id: "cus_003", name: "Kobayashi Studio", plan: "Enterprise", mrr: 8400, status: "active", signedUpAt: "2025-11-20" },
  { id: "cus_004", name: "Atlas Foods", plan: "Pro", mrr: 980, status: "paused", signedUpAt: "2025-12-09" },
  { id: "cus_005", name: "Green Lab", plan: "Pro", mrr: 1600, status: "active", signedUpAt: "2026-03-01" },
  { id: "cus_006", name: "Sakura Dental", plan: "Free", mrr: 0, status: "trial", signedUpAt: "2026-03-18" },
];

const money = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
});

const columns: Column<Customer>[] = [
  { key: "name", label: "Customer" },
  { key: "plan", label: "Plan" },
  { key: "mrr", label: "MRR", numeric: true, render: (_, row) => money.format(row.mrr) },
  { key: "status", label: "Status" },
  { key: "signedUpAt", label: "Signed up", render: (_, row) => new Date(row.signedUpAt).toLocaleDateString("en-US") },
];

function compare<T>(a: T, b: T, key: keyof T) {
  const left = a[key];
  const right = b[key];
  if (typeof left === "number" && typeof right === "number") return left - right;
  return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: "base" });
}

export function DataTable() {
  const [query, setQuery] = useState("");
  const [page, setPage] = useState(1);
  const [sort, setSort] = useState<SortState<Customer>>({ key: "name", direction: "asc" });

  const filtered = useMemo(() => {
    const keyword = query.trim().toLowerCase();
    if (!keyword) return rows;
    return rows.filter((row) =>
      columns.some((column) => String(row[column.key]).toLowerCase().includes(keyword)),
    );
  }, [query]);

  const sorted = useMemo(() => {
    if (!sort) return filtered;
    return [...filtered].sort((a, b) => {
      const result = compare(a, b, sort.key);
      return sort.direction === "asc" ? result : -result;
    });
  }, [filtered, sort]);

  const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
  const currentPage = Math.min(page, totalPages);
  const pageRows = sorted.slice((currentPage - 1) * pageSize, currentPage * pageSize);

  function updateQuery(value: string) {
    setQuery(value);
    setPage(1);
  }

  function toggleSort(key: keyof Customer) {
    setSort((current) => {
      if (!current || current.key !== key) return { key, direction: "asc" };
      return { key, direction: current.direction === "asc" ? "desc" : "asc" };
    });
  }

  return (
    <section className="table-shell" aria-labelledby="customers-title">
      <label>
        <span>Filter customers</span>
        <input value={query} onChange={(event) => updateQuery(event.target.value)} type="search" />
      </label>
      <div className="table-scroll" tabIndex={0}>
        <table className="data-table">
          <caption id="customers-title">Monthly recurring revenue by customer</caption>
          <thead>
            <tr>
              {columns.map((column) => {
                const isSorted = sort?.key === column.key;
                const ariaSort = isSorted ? (sort.direction === "asc" ? "ascending" : "descending") : undefined;
                return (
                  <th key={String(column.key)} scope="col" aria-sort={ariaSort} className={column.numeric ? "numeric" : undefined}>
                    <button type="button" onClick={() => toggleSort(column.key)}>{column.label}</button>
                  </th>
                );
              })}
            </tr>
          </thead>
          <tbody>
            {pageRows.map((row) => (
              <tr key={row.id}>
                {columns.map((column, index) => {
                  const content = column.render ? column.render(row[column.key], row) : String(row[column.key]);
                  return index === 0 ? (
                    <th key={String(column.key)} scope="row" data-label={column.label}>{content}</th>
                  ) : (
                    <td key={String(column.key)} data-label={column.label} className={column.numeric ? "numeric" : undefined}>{content}</td>
                  );
                })}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
      <nav className="pagination" aria-label="Table pagination">
        <button type="button" disabled={currentPage === 1} onClick={() => setPage((value) => value - 1)}>Previous</button>
        <span aria-live="polite">Page {currentPage} of {totalPages}</span>
        <button type="button" disabled={currentPage === totalPages} onClick={() => setPage((value) => value + 1)}>Next</button>
      </nav>
    </section>
  );
}

移动端CSS和可访问性

移动端可以横向滚动,也可以在窄屏下把每一行堆叠成卡片。客户列表、文章列表这类操作型表格通常更适合堆叠显示,但DOM仍然保持表格结构。

.table-scroll {
  overflow-x: auto;
}

.data-table {
  width: 100%;
  border-collapse: collapse;
}

.data-table th,
.data-table td {
  border-top: 1px solid #e5e7eb;
  padding: 0.75rem;
  text-align: left;
}

.data-table .numeric {
  text-align: right;
}

.pagination {
  display: flex;
  gap: 0.75rem;
  justify-content: flex-end;
}

@media (max-width: 640px) {
  .data-table thead {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
  }

  .data-table,
  .data-table tbody,
  .data-table tr,
  .data-table th,
  .data-table td {
    display: block;
    width: 100%;
  }

  .data-table tr {
    border: 1px solid #d8dee8;
    border-radius: 0.5rem;
    margin-bottom: 0.75rem;
  }

  .data-table th,
  .data-table td {
    display: grid;
    grid-template-columns: 8rem 1fr;
    gap: 0.75rem;
  }

  .data-table th::before,
  .data-table td::before {
    content: attr(data-label);
    font-weight: 700;
  }
}

可访问性检查重点包括:caption是否说明表格目的,列标题是否为th,排序按钮是否可以用键盘操作,筛选框是否有标签,分页状态是否通过aria-live通知。不要随意添加role="grid",除非你真的实现了网格组件需要的键盘交互。

何时选择TanStack Table

如果只是少量列、全局搜索和单列排序,自写组件就足够。若需要列显示控制、列级筛选、行选择、服务端分页、固定列或虚拟滚动,就应该考虑TanStack Table。它是headless库,负责表格状态,不强制你使用某套样式。

方案适合场景注意点
自写组件小型列表、简单排序和筛选后续功能变多时维护成本会上升
TanStack Table多列状态、服务端数据、行选择需要理解API,UI仍要自己做
企业级Grid类Excel编辑、超大数据依赖、体积、授权都要确认

Claude Code引入TanStack Table时,可以要求它使用官方React适配器、保留现有CSS,并把列定义写成类型安全的ColumnDef<Customer>[]

Playwright检查

表格测试不能只确认页面打开了,还要确认用户行为:排序改变状态,筛选能找到目标行,分页能翻到下一页,移动端仍能看到列名。

// tests/customer-table.spec.ts
import { expect, test } from "@playwright/test";

test("customer table works", async ({ page }) => {
  await page.goto("/customers");
  await expect(page.getByRole("table", { name: /monthly recurring revenue/i })).toBeVisible();

  await page.getByRole("button", { name: /MRR/ }).click();
  await expect(page.getByRole("columnheader", { name: /MRR/ })).toHaveAttribute("aria-sort", "ascending");

  await page.getByLabel("Filter customers").fill("north");
  await expect(page.getByRole("row", { name: /Northwind/ })).toBeVisible();

  await page.getByLabel("Filter customers").fill("");
  await page.getByRole("button", { name: "Next" }).click();
  await expect(page.getByText("Page 2 of 2")).toBeVisible();

  await page.setViewportSize({ width: 390, height: 844 });
  await expect(page.locator("td[data-label='Plan']").first()).toBeVisible();
});

让Claude Code修复表格时,把这个测试一起交给它,并要求“先复现失败,再修复”。这样比只看截图可靠得多。

用例、坑和收益导线

用例表格功能业务价值
SaaS客户列表套餐、MRR、状态、续约日发现流失风险和升级机会
电商商品管理库存、价格、分类、发布状态更快发现缺货和价格错误
内容运营看板PV、读完率、CTA点击、更新时间决定重写顺序和广告收益优化
账单历史支付状态、金额、到期日降低客服查账成本

常见坑有五个:用div伪装表格、只显示排序箭头却不更新aria-sort、筛选后忘记回到第1页、手机端只靠横向滚动、让Claude Code随意加入新UI库。把这些写进提示词,最后再让Claude Code做一次可访问性审查。

表格也是收益工具。内容站可以把PV、CTA点击、广告收入和重写日期放在一起,判断下一篇要改什么。SaaS可以把MRR、使用下降和续约日放在一起,安排客户成功动作。团队想把这类后台表格做成稳定流程,可以看Claude Code培训与导入咨询;个人学习可以从教材与模板开始。

实际试用结果

Masa在一个小型客户列表中试过这个结构,最明显的收益是筛选时自动回到第1页。之前用户在第2页筛选时会看到空表,以为没有结果。加入Playwright后,这类问题很容易被发现。第二个收益是从一开始就给单元格写data-label,移动端不再需要临时补CSS。把语义、状态、移动端、可访问性和测试一起交给Claude Code,比单独让它“美化表格”可靠得多。

#Claude Code #表格 #React #TanStack Table #UI
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。