@qfei-design/canvas-table
v1.3.0
Published
A high-performance canvas-based table library for browser web apps
Readme
@qfei-design/canvas-table
基于 Canvas 的高性能表格库,适合大数据量、复杂单元格绘制和高交互场景。
适用场景
- 数据量大,DOM 表格渲染成本高
- 单元格样式复杂,需要图形化渲染能力
- 需要固定列、汇总、选择、编辑、拖拽等交互
- 需要分组表格或二次扩展渲染能力
核心能力
- 基于 Canvas 渲染,适合高密度表格场景
- 支持虚拟滚动和分页加载
- 支持左侧固定列
- 支持表头列拖拽重排
- 支持行拖拽排序
- 支持汇总行与汇总 loading 状态
- 支持单元格编辑和自定义编辑器
- 支持自定义单元格/表头渲染
- 支持分组表格
- 提供事件总线,便于接入方监听滚动、加载、编辑、选择等行为
- 提供 Shape 与动画能力,用于复杂视觉扩展
安装
pnpm add @qfei-design/canvas-table这是一个浏览器端运行的 client-only 包,核心实例可以用于 React、Vue 或原生 DOM 场景。
默认是本地数据模式:不传 virtualOptions 时,可直接调用 setData(rows) 渲染数据。
只有显式传入 virtualOptions.enabled = true 时,才进入虚拟分页加载模式。
AI / 自动集成说明
如果这个包需要被另一个 AI 项目自动安装和接入,请优先阅读:
- docs/agent-usage.md
- recipes.json
- capabilities.json
- AI_INTEGRATION.md
- PUBLIC_API.md
- package.ai.json
- docs/features/meta-adapter.md
- React 基础示例
- React 虚拟分页示例
- React 分组表示例
- React Meta Adapter 示例
- Vue 示例
推荐读取顺序:
package.ai.jsondocs/agent-usage.mdrecipes.jsoncapabilities.jsonPUBLIC_API.md- 按场景选择最小示例
最快成功路径:
最快成功路径:先读 docs/agent-usage.md 选择接入路径,再从对应 recipe 指向的最小示例起步。
能力目录
核心能力文档:
- basic-table: 基础表格接入和本地数据渲染
- virtual-scroll: 大数据量分页虚拟滚动
- group-table: 分组表格与组内明细加载
- row-selection: 单选、多选、Shift 连续区间选择和选择态读取
- row-drag: 行拖拽排序和当前顺序读取
- cell-edit: 内置输入编辑和自定义编辑器
- summary-row: 汇总行、汇总 loading 和外部更新
- fixed-columns: 左侧固定列和宽表横向滚动
- column-drag: 表头拖拽换列和宽表列编排
- empty-state: 空状态文案、图片和自定义绘制
- event-bus:
globalEventBus事件订阅和实例级监听
进阶文档:
- ANIMATION_USAGE.md: Shape 与动画扩展
- docs/features/meta-adapter.md: 当上游字段定义来自 JSON meta 时,如何先转换为
IColumn[]再接入表格 - React Meta Adapter 示例: 展示如何把 JSON meta 转成
IColumn[]并在运行时切换列定义
核心导出
import {
CanvasTableComponent,
GroupTableComponent,
TextShape,
globalEventBus,
} from "@qfei-design/canvas-table";
import type {
IColumn,
TableCanvasProps,
GroupTableProps,
} from "@qfei-design/canvas-table";还可以直接使用库内导出的 Shape、工具函数和图标资源。
快速开始
1. 普通表格
import { CanvasTableComponent } from "@qfei-design/canvas-table";
import type { IColumn } from "@qfei-design/canvas-table";
const container = document.getElementById("table-root")!;
const columns: IColumn[] = [
{
key: "id",
title: "ID",
width: 80,
headerAlign: "center",
align: "center",
fixed: "left",
},
{
key: "name",
title: "姓名",
width: 160,
editType: "input",
headerAlign: "left",
align: "left",
},
{
key: "email",
title: "邮箱",
width: 240,
headerAlign: "right",
align: "right",
},
];
const table = new CanvasTableComponent(container, {
columns,
canvasWidth: 960,
canvasHeight: 600,
rowKey: "id",
style: {
rowHeight: 40,
headerHeight: 40,
fontSize: 14,
fontFamily: "Arial, sans-serif",
},
});
table.setData([
{ id: 1, name: "张三", email: "[email protected]" },
{ id: 2, name: "李四", email: "[email protected]" },
]);说明:这条路径默认是“本地数据直出”,不需要 data:load 事件,也不需要传 page。
2. 虚拟滚动分页加载
启用分页加载后,推荐先请求一次 count,再用总数初始化 virtualOptions.totalRowCount。随后外部监听 data:load 事件,并在回调中认领请求身份、调用 setData(data, page, request) 回填数据。
const table = new CanvasTableComponent(container, {
columns,
canvasWidth: 960,
canvasHeight: 600,
rowKey: "id",
style: {
rowHeight: 40,
headerHeight: 40,
fontSize: 14,
fontFamily: "Arial, sans-serif",
},
virtualOptions: {
enabled: true,
totalRowCount: 10000,
pageSize: 100,
},
});
const off = globalEventBus.onWithNamespace(
"data:load",
table.tableId,
async (page, request) => {
request.useRequestIdentity();
try {
const rows = await fetchRows(page, { signal: request.signal });
table.setData(rows, page, request);
} catch (error) {
table.markPageLoadFailed(page, request);
if (request.signal.aborted) return;
console.error("Failed to load virtual table page", {
page,
requestId: request.requestId,
errorName: error instanceof Error ? error.name : typeof error,
});
}
},
);推荐分页接入顺序:
- 先调用
fetchCount()获取总数 - 用返回值设置
virtualOptions.totalRowCount - 再监听
data:load - 同步调用
request.useRequestIdentity(),并在成功/失败时把同一个request传给setData/markPageLoadFailed
如果是 React / Next.js 客户端组件,优先直接参考:
这个示例明确包含:
fetchTotalCount()与totalRowCount的初始化关系- 请求上下文与
setData(rows, page, request)的回填关系 data:load的订阅方式page + 1的后端分页转换位置
3. 分组表格
import { GroupTableComponent } from "@qfei-design/canvas-table";
const groupTable = new GroupTableComponent(container, {
columns,
groups: ["department", "team"],
canvasWidth: 960,
canvasHeight: 600,
rowKey: "id",
style: {
rowHeight: 40,
headerHeight: 40,
fontSize: 14,
fontFamily: "Arial, sans-serif",
},
groupVirtualOptions: {
enabled: true,
totalRowCount: 50,
pageSize: 20,
},
virtualOptions: {
enabled: true,
totalRowCount: 0,
pageSize: 100,
},
});
globalEventBus.onWithNamespace(
"group:load",
groupTable.tableId,
async (group, page) => {
const groups = await fetchGroups(group, page);
groupTable.setGroup(groups, group, page);
},
);框架适配
React
推荐做法是封装一个 React 组件,在 useEffect 中创建和销毁 CanvasTableComponent。
可直接参考:
Vue
推荐在挂载后创建实例,在卸载前销毁实例,并通过外部状态管理数据加载和事件订阅。
可直接参考:
Vanilla DOM
如果不是 React 或 Vue,也可以直接传入真实 DOM 容器来创建 CanvasTableComponent 或 GroupTableComponent。
常见接入方式
普通表格接入
适合非分组场景。通常流程是:
- 创建
CanvasTableComponent - 传入列定义和画布尺寸
- 不传
virtualOptions,首次直接调用setData - 按需监听滚动、选择、编辑和加载事件
分页加载接入
适合大数据量场景。通常流程是:
- 开启
virtualOptions - 监听
data:load - 按页请求数据
- 认领请求身份并使用
setData(data, page, request)回填
如果是 React 接入,加载回调应直接返回或回填对应请求的数据,避免用一份共享 data/dataPage 状态承接多个并发页。
注意:分页模式下,setData 必须传 page,否则会抛错。
分组表格接入
适合分层展示场景。通常流程是:
- 创建
GroupTableComponent - 配置
groups和groupVirtualOptions - 监听
group:load - 调用
setGroup(data, group, page)回填分组 - 叶子明细分页时监听
group:data:load - 通过
setData(data, group, page, { totalRowCount, request })回填叶子页 - 请求失败或取消时调用
markGroupPageLoadFailed(group, page, request)
自定义渲染
列上可以通过 render 和 headerRender 进行扩展。
import { TextShape } from "@qfei-design/canvas-table";
const columns: IColumn[] = [
{
key: "status",
title: "状态",
width: 120,
render(cell, group) {
group.addChild(
new TextShape({
name: "status-text",
x: 12,
y: 24,
attrs: {
text: String(cell.rowData.status ?? ""),
fill: "#1f2937",
fontSize: 14,
},
}),
);
},
},
];自定义编辑器
当列设置 editType: 'custom' 时,可以通过 customEdit 自定义编辑行为。
const table = new CanvasTableComponent(container, {
columns: [{ key: "name", title: "姓名", width: 160, editType: "custom" }],
canvasWidth: 960,
canvasHeight: 600,
style: {
rowHeight: 40,
headerHeight: 40,
fontSize: 14,
fontFamily: "Arial, sans-serif",
},
customEdit({ value, updateValue, commit, cancel }) {
const input = document.createElement("input");
input.value = String(value ?? "");
input.addEventListener("input", () => {
updateValue(input.value);
});
return {
element: input,
getValue: () => input.value,
autoClose: true,
destroy: () => {
input.remove();
},
};
},
});复杂编辑器可以让 canvasTable 继续管理关闭和键盘行为,只把弹窗根节点声明给表格:
customEdit({ value, commit, cancel }) {
const editor = document.createElement('div')
const popupRoot = document.createElement('div')
document.body.appendChild(popupRoot)
return {
element: editor,
getValue: () => value,
autoClose: {
outsideClick: 'commit',
escape: 'cancel',
enter: 'commitAndMove',
tab: 'commitAndMove',
},
relatedElements: () => [popupRoot],
overlayOptions: {
overflow: 'visible',
},
destroy: () => {
popupRoot.remove()
},
}
}编辑写入默认由表格自动完成。若业务需要先走保存/草稿层,再由宿主在成功后回填数据,可设置 editApplyMode: 'controlled',此时表格只发出编辑事件,不主动改写行数据。
核心 API
IColumn
常用字段:
key: 列唯一标识title: 表头标题width: 列宽minWidth: 最小列宽align: 单元格对齐方式headerAlign: 表头对齐方式fixed: 当前支持lefteditType:input | customrender: 自定义单元格渲染headerRender: 自定义表头渲染prefixRender/suffixRender: 表头前后缀图标suffixRender返回值可设置displayMode: 'hover',让后缀图标仅在鼠标悬浮表头单元格时展示;默认常显suffixRender.onClick接收(column, cell, event),可用cell和原生点击事件定位宿主弹窗;点击后缀图标不会继续触发表头列点击stateChange/headerStateChange: 状态变化回调
对齐规则:
headerAlign只影响表头内置文本布局align影响表体与汇总行内置文本布局- 两者都支持
left | center | right - 未传时默认左对齐
TableCanvasProps
常用字段:
columns: 列配置canvasWidth/canvasHeight: 画布尺寸style: 表格样式rowKey: 行唯一标识virtualOptions: 虚拟滚动配置showSummary: 是否展示汇总行summaryRowHeight: 汇总行高度summaryData: 汇总数据summaryRenderer: 汇总渲染函数showSN: 序号列配置;showSN.width可设置内置行头/序号列最终宽度,未设置时沿用默认自动宽度rowSortable: 行拖拽排序配置selectable: 行选择配置rowStyleOptions: 行样式计算emptyStateOptions: 空状态配置showEmptyCellPlaceholder: 空值单元格是否展示默认占位符-,默认展示;传false时空值不展示横线customEdit: 自定义编辑器editApplyMode: 编辑写入模式,支持auto | controlledshowHeader/showBody: 是否渲染表头或表体
序号列说明:
showSN.width只在showSN.enabled = true时生效。- 设置后该值会作为内置固定行头列的最终宽度,不会再根据复选框、行拖拽图标或行头后缀自动扩容。
- 如果同时启用
selectable、rowSortable或bodyRowHeadSuffixOptions,宿主需要自行预留足够宽度。
空值单元格说明:
- 默认情况下,单元格值为
null、undefined或空字符串''时展示-。 - 设置
showEmptyCellPlaceholder: false后,空值单元格不展示横线。 0和false会作为有效值展示为0、false,不会被当成空值。
CanvasTableComponent
常用方法:
updateProps(newProps)updateCanvasSize(width, height, refresh?)refreshCanvas()setData(data, page?, request?)setVirtualPageData(data, page, totalRowCount, request?)markPageLoadFailed(page, request?)scrollTo(x, y)scrollToRow(rowIndex)getScrollState()setColumnOption(colKey, key, value)setRowColors(rowKeys, color?)setRowTip(rowKey, params?)setRowData(rowKey, data)setCellData(rowKey, columnKey, data)clearData(params?)getPagesInView()updateDataByRowKey(dataMap),支持普通对象和保留数字/字符串键类型的ReadonlyMapgetTableData()refreshEmptyState()updateSummaryData(newSummaryData)setSummaryLoading(loading, columnKey?)getSelectionInfo()clearSelection()destroy()
updateDataByRowKey(dataMap) 使用普通对象时遵循 JavaScript 对象键语义:优先匹配字符串键,仅在没有同名字符串行键时把规范数字字符串回退为数字键。需要严格区分 "1" 和 1 时请传入 ReadonlyMap。每条替换数据必须保留当前配置解析出的原 rowKey;方法会先校验全部命中行,任一行尝试变更身份时整批拒绝,不会产生部分更新。
setRowData()、setCellData() 和默认自动编辑同样不能修改当前配置解析出的 rowKey。写入会先基于候选行校验身份,身份变化时抛错且保留原行数据;需要更换行身份时,请重新提交数据集,而不是增量修改身份字段。
GroupTableComponent
在基础表格能力之外,额外提供:
setGroup(data, group?, page?)setData(data, groups, page?, options?),分页请求上下文通过options.request回传setDataTotal(count),显式设置分组表共享选择总数markGroupPageLoadFailed(groupValue, page, request?)scrollTo(x, y, animate?)scroll(x, y, animate?)setRowTip(groupValue, rowKey, params?)setRowData(groupValue, rowKey, data)setCellData(groupValue, rowKey, columnKey, data)extendGroup(groupValue, expand)clearData()clearSelection()getSelectionInfo()destroy()
调用 updateProps({ virtualOptions }) 会同步更新已经创建的所有叶子表。分页开关、页大小和缓存上限使用新配置,各叶子的 totalRowCount 仍以对应分组的明细总数为准。分页模式或非分页模式显式提供正数 totalRowCount 时,共享选择总数使用该外部总数;非分页且未提供正数总数时,由已加载一级分组的 count 之和统一派生。setDataTotal(count) 可显式接管总数,后续叶子回包和分组加载不会覆盖它。分组表头不会进入叶子明细虚拟分页模式。
事件与扩展
事件总线
通过 globalEventBus 可以监听实例级事件。
常见事件包括:
data:load: 请求某一页数据,payload 为page和可选认领的请求上下文group:load: 请求某个分组的子分组数据group:data:load: 请求某个叶子分组下的一页明细数据,payload 为分组路径、page和请求上下文group:expand: 分组展开/收起scroll:change: 滚动变化selection:change: 选择变化selection:range-limit: 单次 Shift 区间超过 200 行,返回source、限制值和区间索引,宿主可据此展示 toastselection:range-error: Shift 区间缺页加载或 ID 校验失败,返回source、操作 ID、区间索引、失败原因和可选失败页码edit:start: 编辑开始edit:change: 编辑值变更edit:cancel: 编辑取消edit:end: 编辑结束copy: 复制完成,返回success、rowCount、columnCount、cellCount,不返回复制文本paste: 粘贴完成row:*/cell:*/column:*: 行、单元格、列的鼠标交互事件shape:*: Shape 鼠标交互事件
示例:
const off = globalEventBus.onWithNamespace(
"selection:change",
table.tableId,
(payload) => {
console.log(payload);
if (payload.change?.source === "shift-range") {
console.log("本次 Shift 区间 ID", payload.change.rangeIds);
}
},
);
// 取消监听
off();Shape 与动画
库内导出了 Shape 系统,可用于更复杂的单元格图形绘制和动画。
进阶说明请参考:
已知约束
- 当前包是 client-only,不支持 SSR 直接实例化。
- 虽然核心库不依赖 Vue 运行时,但现有仓库 demo 主要是 Vue 页面。
- 分页虚拟滚动模式下,
setData必须传page。 - 分组模式下,外部需要分别维护分组加载和组内数据加载。
- 编辑、粘贴、选择等业务提交逻辑需要接入方基于事件自行承接。
- 当前仓库中 demo 较完整,但自动化测试仍偏少,更适合作为业务底座持续演进。
Demo 与源码参考
- 仓库说明:../../readme.md
- demo 应用:
packages/app - 动画文档:ANIMATION_USAGE.md
- feature 文档:
docs/features/*.md
