Skip to content

Repository files navigation

@stevenleep/data-grid

面向 React Admin 的协议驱动 Data Grid。核心状态机不依赖 React、Ant Design 和 CSS;默认渲染层使用 Ant Design 6,并提供开箱即用的工具栏、表格、统计与分页布局。

它不是 Canvas 表格,也不试图处理百万行前端数据。它以服务端分页为默认路径,重点解决后台列表中反复出现的字段、查询、权限、选择、动作、编辑、视图与数据源接入问题。

在线演示:huiyun-data-grid-demo.vercel.app

特性

  • 语义字段 GridField 与展示列 GridColumn 分离,支持嵌套字段、关系/公式/lookup/rollup 元数据、分组表头和纯展示列。
  • local、remote、controlled 三种显式数据源;remote 请求支持取消、latest-wins、去重、缓存、旧数据保留和页码纠正。
  • UI 查询自动编译为不含临时 id 的传输协议;字段可分别声明 filter、sort、select key。
  • offset 分页优先,同时支持服务端 cursor 分页、未知总数和估算总数。
  • 嵌套筛选、多字段排序、搜索、投影、汇总、facets、跨页选择和 all-matching 选择。
  • 集中管理异步动作、单元格编辑、校验、乐观更新、失败回滚和加载错误。
  • 命名视图、列宽/顺序/显隐/固定、密度与可替换的持久化协议。
  • 完整受控或按 slice 受控;核心实例稳定,React 订阅按 selector 更新。
  • 默认组件只是官方配方;DataGridView、稳定 slots、组件注册表及所有工具栏、面板、表格、页脚和单元格均可独立组合。
  • ESM、CommonJS、TypeScript declarations 与独立样式文件;支持按层级导入。

完整文档

README 只保留概览和核心示例。完整接入说明位于 docs/README.md

环境与安装

  • React 18/19(使用 React 或 AntD 层时)
  • React DOM 18/19,与 React 主版本一致
  • Ant Design 6 与 Ant Design Icons 6(使用 AntD 层时)
  • TypeScript 5.4–7.x(类型消费时)
  • Node.js >=20.19.0(仓库 CI 与 Vercel Demo 固定使用 Node.js 24)
  • 浏览器需支持 ES2020、AbortControllerResizeObserver、Pointer Events,以及带 IANA 时区数据的现代 Intl
pnpm add @stevenleep/data-grid react react-dom antd @ant-design/icons

UI 依赖都是 optional peers:包不会悄悄选择或安装它们的版本。只用与 UI 无关的 Core 时可只安装包本身:

pnpm add @stevenleep/data-grid
import { compileGridQuery, createGrid } from '@stevenleep/data-grid/core';

在应用入口导入一次样式:

import '@stevenleep/data-grid/style.css';

根入口、/react/antd 已发布为 React Client Component 边界;/core 保持 server-safe。Next.js App Router 页面如果还直接使用业务 hooks、浏览器 API 或交互状态,页面自己的组合组件仍应声明 "use client"

交互 Demo

仓库内提供了一个完整的 Vite Demo,包含订单 CRUD 工作台、Local / Remote / Controlled 数据源与异常状态实验室、自由组合配方和内嵌开发文档。新增、详情、表单编辑、删除、复制、筛选、排序、搜索、分页、字段设置、命名视图、行/值点击、跨页选择、批量操作、汇总、单元格快捷编辑、持久化、真实 CSV 导出及远程请求协议都可以直接操作。详细验收路径见 Demo README

pnpm install
pnpm demo

生产构建可用 pnpm demo:build 单独验证。

行点击与字段值点击使用 Data Grid 自己的语义上下文,不需要业务反查列配置。按钮、链接、复选框和输入控件不会误触发行点击:

<DataGrid<Order>
  definition={definition}
  source={source}
  rowActions={{ maxVisible: 3, width: 240 }}
  onRowClick={({ row }) => openOrder(row)}
  isCellClickable={({ field }) => field?.id === 'orderNo'}
  onCellClick={({ row, field, value, event }) => {
    event.stopPropagation();
    openFieldValue({ row, field, value });
  }}
/>

快速开始

import { DataGrid, createFieldHelper, createRemoteSource, defineGrid } from '@stevenleep/data-grid';

interface Order {
  id: string;
  orderNo: string;
  customer: { id: string; name: string };
  amount: string;
  status: 'pending' | 'completed';
  createdAt: string;
}

const field = createFieldHelper<Order>();
const fields = [
  field.property('orderNo', {
    title: '订单号',
    filter: true,
    sort: true,
    column: { width: 160, fixed: 'left' },
  }),
  field.property('customer', {
    title: '客户',
    valueType: 'relation',
    transport: { filterKey: 'customer_id', selectKey: 'customer' },
    filter: true,
    options: {
      dependsOn: ['status'],
      cacheTime: 60_000,
      load: async ({ search, signal }) => {
        const items = await searchCustomers(search, signal);
        return items.map((item) => ({ label: item.name, value: item.id }));
      },
    },
  }),
  field.property('amount', {
    title: '金额',
    valueType: 'money',
    meta: { currency: 'CNY' },
    filter: true,
    sort: true,
  }),
  field.property('status', {
    title: '状态',
    valueType: 'status',
    filter: true,
    options: [
      { label: '待处理', value: 'pending', color: 'gold' },
      { label: '已完成', value: 'completed', color: 'green' },
    ],
  }),
  field.property('createdAt', {
    title: '创建时间',
    valueType: 'dateTime',
    filter: true,
    sort: true,
  }),
];

const definition = defineGrid<Order>({
  id: 'orders',
  revision: 1,
  rowKey: 'id',
  defaults: {
    pageSize: 20,
    density: 'compact',
    selection: true,
    views: true,
  },
  fields,
});

const source = createRemoteSource<Order>({
  datasetKey: 'orders',
  driverKey: 'orders-api:v1',
  capabilities: {
    pagination: 'offset',
    search: true,
    filter: { logic: 'nested', negation: true, maxDepth: 3, maxConditions: 20 },
    sort: { max: 3, nulls: true },
    projection: true,
    summary: true,
    facets: true,
    selectAllMatching: true,
  },
  policy: {
    keepPreviousData: true,
    cacheTime: 60_000,
    staleTime: 5_000,
    maxCacheEntries: 20,
  },
  read: async ({ request, signal }) => {
    const response = await queryOrders(request, { signal });
    return {
      rows: response.items,
      total: { value: response.total, accuracy: 'exact' },
      summary: response.summary,
      facets: response.facets,
    };
  },
});

export function OrderList() {
  return <DataGrid definition={definition} source={source} />;
}

request 已经是传输层查询,不需要业务再次遍历字段进行转换。它包含 pagination、keyword、递归 filter、sort、select 和可选 context,字段名均已按 transport 转换。

字段与列

字段描述数据语义,列描述表格布局。一个字段可以没有列;一个列可以是分组、计算展示或平台专用列。

const field = createFieldHelper<Order>();
const definition = defineGrid<Order>({
  id: 'grouped-orders',
  rowKey: 'id',
  fields: [
    field.property('orderNo', { title: '订单号', sort: true }),
    field.accessor('customerName', (row) => row.customer.name, {
      title: '客户名称',
      transport: { filterKey: 'customer_name' },
      filter: true,
    }),
  ],
  columns: [
    {
      id: 'baseInfo',
      title: '基本信息',
      children: [
        { id: 'numberColumn', fieldId: 'orderNo', width: 160 },
        { id: 'customerColumn', fieldId: 'customerName', width: 220 },
      ],
    },
    {
      id: 'operationsHint',
      title: '说明',
      render: ({ row }) => <span>{row.status === 'completed' ? '已归档' : '处理中'}</span>,
    },
  ],
});

内置 value type:textlongTextnumberdecimalmoneypercentbooleanselectmultiSelectstatusdatedateTimedurationlinkemailphoneuserrelationimagefilejson

未知 value type 会在 definition 解析时直接报错,避免把协议拼写错误静默显示成文本。通过 definition.valueTypes 注册后,可提供 codec、比较、搜索、筛选、渲染与编辑行为。

Local 模式中,datetemporal.timeZone 的日历日比较,dateTime 按绝对时刻比较;二者统一支持 Date、Unix 毫秒和 ISO 字符串。无 offset 的 ISO 日期时间固定按 UTC,普通数值字段不会被猜成日期。完整格式与 Remote 对齐规则见字段语义查询协议

数据源

Local

适合设置页和小数据列表,执行同一套筛选、搜索、排序和 offset 分页语义。

<DataGrid definition={definition} source={createLocalSource(rows)} />

Remote

read 获得 UI query、编译后的 request、字段、AbortSignal、请求 id 和请求原因。查询变化会取消旧请求,迟到结果不会覆盖新结果。

const source = createRemoteSource<Order>(
  async ({ request, signal, reason }) => api.orders.list(request, { signal, reason }),
  {
    capabilities: { sort: { max: 2 }, filter: { logic: 'and' } },
    policy: { keepPreviousData: true },
  },
);

Controlled

已有 React Query、SWR、路由 loader 或业务数据层时,由外部提供结果和加载状态:

const datasetKey = `${tenantId}:orders`;
const requestSignature = getRequestSignature(request);
const envelope = query.data ?? {
  datasetKey,
  requestSignature,
  result: { rows: [] as Order[] },
};

const source = createControlledSource<Order>({
  datasetKey,
  resultDatasetKey: envelope.datasetKey,
  result: envelope.result,
  resultRequestSignature: envelope.requestSignature,
  loading: query.isLoading,
  refreshing: query.isFetching && !query.isLoading,
  error: query.error ? { value: query.error, datasetKey, requestSignature } : undefined,
  capabilities,
  onQueryChange: (_uiQuery, request, event) => {
    setRequest(request);
    audit(event);
  },
});

<DataGrid definition={definition} source={source} />;

动态 Controlled source 第一次查询握手前已有的 bootstrap result 可以不带请求签名;握手后,每个新 result(包括旧数据的浅拷贝)都必须回传产生它的 resultRequestSignature。错误同样必须使用包含 valuerequestSignature 和可选 datasetKey 的来源 envelope;Core 会彻底忽略不属于当前请求或数据集的错误。

数据源只声明真实支持的能力。组件会同步限制筛选器和排序器,并在发出请求前再次校验,避免 UI 构造出后端无法执行的查询。

自由组合

DataGrid 的默认布局只是由公共原子组件组成的配方。传入 children 即完全接管布局,同时复用同一个实例和所有状态能力:

<DataGrid definition={definition} source={source}>
  {(instance) => (
    <GridShell aria-label="订单列表">
      <MyHeader instance={instance} />
      <GridToolbar
        start={
          <>
            <GridViewTrigger<Order> />
            <GridFilterTrigger<Order> />
            <GridSortTrigger<Order> />
            <GridSearch<Order> />
          </>
        }
        end={<GridActions<Order> placement="toolbar" />}
      />
      <GridActiveFilters<Order> />
      <GridStatus<Order> />
      <GridTable<Order> rowActions={{ maxVisible: 2 }} />
      <GridFooter
        start={<GridSummary<Order> />}
        end={<GridPagination<Order> showQuickJumper={false} />}
      />
    </GridShell>
  )}
</DataGrid>

更底层的集成可直接使用:

const instance = useGrid({ definition, source });

<GridProvider value={instance}>
  <GridUiProvider value={{ language: 'zh-CN' }}>
    <GridTable<Order> />
  </GridUiProvider>
</GridProvider>;

GridFilterBuilderGridSortBuilderGridColumnPanel 均支持 value/state + onChange,可放入业务自己的 Drawer、Modal 或页面区域。组件 props 类型全部公开。

Slice 受控

state 可以只控制某些 slice。调用实例 API 时,组件发出期望状态,但受控值在父组件接受前保持不变。

const datasetKey = `${tenantId}:orders`;
const [query, setQuery] = useState<GridQuery>(initialQuery);

<DataGrid
  definition={definition}
  source={source}
  state={{ query }}
  stateDatasetKey={datasetKey}
  onStateChange={(next, event) => {
    if (event.type.startsWith('query.')) setQuery(next.query);
  }}
/>;

可控制的 slice 为 querycolumnsselectiondataviewseditingactions。通常只控制业务必须拥有的 slice,其余交给实例管理即可。

跨租户、项目或其他数据边界复用实例时必须给 source 设置稳定的 datasetKey。Controlled source 还要携带结果实际所属的 resultDatasetKey;通过 state 控制 queryviewsdataselectioneditingactions 时,则要携带 stateDatasetKey。边界变化时,Core 默认清空业务查询、保存视图与实体状态,只保留列偏好和 page size;收到 dataset.controlled.reset 后应把新数据集的 slice 和 key 原子写回。

确实可以跨数据集复用的查询域必须显式开放,不能靠旧对象偶然残留:

<DataGrid
  {...props}
  datasetTransition={{
    preserveQuery: ['context'],
    preserveViews: true,
  }}
/>

preserveViews 只保留视图定义并解除当前 active view;分页始终回到第一页/初始 cursor。选择、编辑、动作和 rows 不会被隐式带入另一个数据集。

Actions、选择与编辑

动作定义统一覆盖 toolbar、row、bulk 和 cell,自动处理 visible、disabled、确认框、并发保护、错误与刷新:

const definition = defineGrid<Order>({
  // fields, rowKey...
  actions: [
    {
      id: 'create',
      label: '新建订单',
      intent: 'primary',
      placement: 'toolbar',
      run: () => openCreateOrder(),
    },
    {
      id: 'archive',
      label: '归档',
      placement: 'row',
      getConfirmation: ({ row }) => (row ? `确认归档 ${row.orderNo}?` : undefined),
      disabled: ({ row }) => row?.status === 'completed',
      refresh: true,
      run: ({ row, signal }) => archiveOrder(row!.id, signal),
    },
  ],
});

显式选择会保留跨页 key 和已知行;all-matching 使用“查询签名 + 排除 key”表达,不会把所有 id 拉到浏览器。

编辑由 definition 统一保存,字段只声明是否可编辑:

const field = createFieldHelper<Order>();
const definition = defineGrid<Order>({
  // ...
  fields: [
    field.property('status', {
      title: '状态',
      valueType: 'status',
      edit: { enabled: true, required: true },
      options: statusOptions,
    }),
  ],
  editing: {
    optimistic: true,
    apply: (row, field, value) => ({ ...row, [field.id]: value }),
    save: async ({ rowKey, field, value, signal }) => {
      return updateOrder(rowKey, { [field.transport.selectKey]: value }, signal);
    },
  },
});

返回更新后的 row 会直接替换当前页;返回 { type: 'grid-edit-result', row, reload } 可精确控制;返回空值默认刷新。取消或失败时乐观更新会回滚。

视图与持久化

<DataGrid
  definition={definition}
  source={source}
  persistence={createLocalGridPersistence({ scope: currentUser.id })}
/>

GridPersistence 是异步协议,可替换为服务端存储。持久化内容包含 protocol、grid id、definition revision、列状态、视图和 active view。revision 改变时仅在提供 migrate 后恢复,避免旧字段配置污染新版本。

JSON 协议与运行时绑定

服务端下发的 schema 只包含 JSON-safe 数据;函数、React 节点和权限逻辑通过稳定名称绑定:

const schema = defineGridSchema({
  protocol: 'huiyun.data-grid/v1',
  id: 'users',
  revision: 3,
  fields: [
    {
      id: 'owner',
      title: '负责人',
      valueType: 'user',
      path: ['owner'],
      filter: true,
      options: { type: 'runtime', loader: 'users', cacheTime: 60_000 },
      renderer: 'ownerCell',
    },
  ],
  actions: [{ id: 'export', label: '导出', handler: 'exportUsers', placement: 'toolbar' }],
});

const runtime = defineGridRuntime<User>({
  optionLoaders: {
    users: ({ search, signal }) => loadUserOptions(search, signal),
  },
  renderers: {
    ownerCell: ({ value }) => <UserCell value={value} />,
  },
  actionHandlers: {
    exportUsers: ({ request }) => exportUsers(request),
  },
});

const definition = bindGridSchema(schema, runtime, 'id');

<DataGrid definition={definition} source={source} />;

引用了不存在的 renderer、editor、option loader、column header 或 action handler 时会立即报错,而不是在用户打开面板后静默失败。

分层导入

import { createGrid, compileGridQuery } from '@stevenleep/data-grid/core';
import { GridProvider, useGrid, useGridSelector } from '@stevenleep/data-grid/react';
import { DataGrid, GridTable } from '@stevenleep/data-grid/antd';
import '@stevenleep/data-grid/style.css';
  • @stevenleep/data-grid/core:纯 TypeScript,无 React、Ant Design、CSS 或 DOM 运行时依赖;类型协议使用标准 AbortSignal 与最小 GridStorageLike,不要求 DOM lib。
  • @stevenleep/data-grid/react:实例生命周期、Provider 和 selector 订阅。
  • @stevenleep/data-grid/antd:Ant Design 6 原子组件与默认配方。
  • 根入口:便捷导出以上公共 API。

明确边界

当前包有意不包含 Canvas 渲染、百万行前端模式、公式引擎、透视表、实时协同和 Excel 级区域选择。大量数据应由后端查询与分页处理;如需极端行数渲染,可在同一个 core instance 上实现独立 renderer,而不改变查询和业务协议。

开发与发布检查

pnpm install
pnpm release:check

release:check 会验证格式、类型、测试、覆盖率、库与 Demo 构建/gzip 预算、包导出,以及实际 tarball 的 Core-only、React 18/19、AntD 6、ESM、CommonJS、CSS、TypeScript 和 Vite 消费。npm OIDC、Vercel 运行时与正式发版流程见维护与发布

definition.id 必须稳定;当字段、列或运行时行为发生不兼容变更时提升 revision

License

MIT

About

Protocol-driven React Data Grid with a headless core and Ant Design 6 renderer for admin applications.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages