面向 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、
AbortController、ResizeObserver、Pointer Events,以及带 IANA 时区数据的现代Intl
pnpm add @stevenleep/data-grid react react-dom antd @ant-design/iconsUI 依赖都是 optional peers:包不会悄悄选择或安装它们的版本。只用与 UI 无关的 Core 时可只安装包本身:
pnpm add @stevenleep/data-gridimport { 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"。
仓库内提供了一个完整的 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:text、longText、number、decimal、money、percent、boolean、select、multiSelect、status、date、dateTime、duration、link、email、phone、user、relation、image、file、json。
未知 value type 会在 definition 解析时直接报错,避免把协议拼写错误静默显示成文本。通过 definition.valueTypes 注册后,可提供 codec、比较、搜索、筛选、渲染与编辑行为。
Local 模式中,date 按 temporal.timeZone 的日历日比较,dateTime 按绝对时刻比较;二者统一支持 Date、Unix 毫秒和 ISO 字符串。无 offset 的 ISO 日期时间固定按 UTC,普通数值字段不会被猜成日期。完整格式与 Remote 对齐规则见字段语义和查询协议。
适合设置页和小数据列表,执行同一套筛选、搜索、排序和 offset 分页语义。
<DataGrid definition={definition} source={createLocalSource(rows)} />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 },
},
);已有 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。错误同样必须使用包含 value、requestSignature 和可选 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>;GridFilterBuilder、GridSortBuilder 和 GridColumnPanel 均支持 value/state + onChange,可放入业务自己的 Drawer、Modal 或页面区域。组件 props 类型全部公开。
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 为 query、columns、selection、data、views、editing、actions。通常只控制业务必须拥有的 slice,其余交给实例管理即可。
跨租户、项目或其他数据边界复用实例时必须给 source 设置稳定的 datasetKey。Controlled source 还要携带结果实际所属的 resultDatasetKey;通过 state 控制 query、views、data、selection、editing 或 actions 时,则要携带 stateDatasetKey。边界变化时,Core 默认清空业务查询、保存视图与实体状态,只保留列偏好和 page size;收到 dataset.controlled.reset 后应把新数据集的 slice 和 key 原子写回。
确实可以跨数据集复用的查询域必须显式开放,不能靠旧对象偶然残留:
<DataGrid
{...props}
datasetTransition={{
preserveQuery: ['context'],
preserveViews: true,
}}
/>preserveViews 只保留视图定义并解除当前 active view;分页始终回到第一页/初始 cursor。选择、编辑、动作和 rows 不会被隐式带入另一个数据集。
动作定义统一覆盖 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 后恢复,避免旧字段配置污染新版本。
服务端下发的 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:checkrelease:check 会验证格式、类型、测试、覆盖率、库与 Demo 构建/gzip 预算、包导出,以及实际 tarball 的 Core-only、React 18/19、AntD 6、ESM、CommonJS、CSS、TypeScript 和 Vite 消费。npm OIDC、Vercel 运行时与正式发版流程见维护与发布。
definition.id 必须稳定;当字段、列或运行时行为发生不兼容变更时提升 revision。
MIT