Files
workspace/code/g3soft-libs/docs/content/ui/data/table.md
T
2026-10-13 22:32:50 +08:00

25 KiB
Raw Blame History

title, description
title description
Table 表格 高性能虚拟滚动表格,支持排序、固定列、多选、行内编辑、列宽 / 列序拖拽与行拖拽排序

Table 表格

Table 用于展示结构化数据,底层是 stk-table-vue,虚拟滚动 / 列宽拖拽 / 列序拖拽 / 区域选择 / 行拖拽排序都由它提供;组件负责性能门控、内部列管理与行内编辑态。

何时使用

  • 数据量大(成百上千行)且需要滚动流畅时;
  • 需要行内编辑、多选批量操作、列宽 / 列序可调时;
  • 数据量很小、只要一张静态表时,原生 <table> 或 Grid 更轻。

基础用法

给 columns 与 dataSource 两份数据即可。序号列(seq)默认开启,边框(bordered)默认有。

固定列

列上写 fixed: 'left' | 'right' 即可固定(底座用 position: sticky 实现,非双表格叠加)。三件需要知道的事:

  • 序号列与选择列已经固定在左侧,不需要业务再配;业务列的 fixed: 'left' 会自动接在它们后面,fixed: 'right' 的列要排在 columns 最后。
  • 固定列必须写 width:左固定列的吸附位移由「它左侧那些固定列的列宽累加」算出,缺宽度就没有位移(右固定列若不需要吸附效果可以不写,但统一写上最省心)。
  • 固定列被滚动内容遮住时才画阴影(fixedColShadow,默认关)。它不影响功能,但冻结列与滚过去的列底色同为白、又紧贴在一起,没有这层渐变就看不出边界在哪,业务侧建议打开。

固定列只有在总列宽超过容器宽时才看得出效果 —— 整表都看得见时,固定与不固定没有任何区别。

固定列的底色是不透明的表格基色(否则横滚时下方内容会透上来),行 hover / 当前行高亮改用内阴影叠色;这层叠色与单元格选中描边并存(选中框不会被行色替换掉),正在编辑的那一格则与普通列一致 —— 纯基色 + 焦点环,不叠行色。

同理还有斑马纹:开了 stripe 时,固定列(含序号列)也会跟着条纹 —— 冻结列的底色必须不透明,组件在它上面补了一层同色内阴影把条纹接上(否则就是「整表隔行灰、只有序号列没有」)。这层处理对宿主页自己的行底色样式(例如文档站的正文斑马纹)同样有效。

⚠ 固定列 + 横向虚拟的已知限制

同时开固定列与横向虚拟(列数 > 20 且显式要 virtual-x)时,快速横向拖动(拖滚动条、触控板甩)会出现一帧位移:被虚拟掉的列在表体左侧留了一个宽度可变的占位,而它是异步更新的(库内部用 rAF)。滚动已经到位、占位还是旧宽度的那一帧里,固定列的静态位置被占位推着走 —— 而 position: sticky 只能把格子往右收、不能把它往左拉回来,于是左固定列先偏一下再吸回去(实测偏 258 → 1628,等于占位全长);右固定列偏差为 0,所以现象只出现在左侧。

这是「占位 + sticky」这套布局的固有代价,组件这层无法在绘制前把它抹平(试过在 scroll 里提前重算窗口:无效,且会让表体多出中间态)。两条出路:

  • 要绝对稳:别让二者同时存在 —— :virtual-x="false"(列数不多时没有任何代价);
  • 要根治:需要把左占位挪到固定列之后(上游 stk-table-vue 的行布局改动)。
<!-- 左冻结首列、右冻结末列 -->
<G3Table :columns="columns" :data-source="data" fixed-col-shadow />

虚拟滚动与性能

组件内置两条门控,自动决定是否启用虚拟滚动,不需要业务手动切换:

方向 阈值 低于阈值时 原因
纵向 行数 > 100 全量渲染 库的虚拟滚动在「行数略多于单屏行数」时会把最后几行算出视口外,必须滚动才可见;行数少时全量渲染没有性能代价,却换来确定性正确的渲染
横向 列数 > 20 关掉横向虚拟 库在横向虚拟开启时会把每列宽度钉死为 minWidth ?? width ?? 100,未设 width 的列会失去「按比例铺满容器」的原生自适应

可以用 virtual / virtualX 关掉,但不能把它们强制打开到阈值以下。

除了门控,还有三处性能相关设计:

  • 无编辑行时不注入 customCell:纯展示列与非编辑态的可编辑列走库的原生 td 渲染路径,滚动时每格少一个函数式组件实例;
  • 尺寸变化 150ms 防抖:折叠动画 / 窗口缩放期间 ResizeObserver 每帧都会通知,逐帧重算会强制 reflow 并重渲染可见行列;组件合并到尺寸稳定后只刷新一次,并区分「仅宽度变化」只重算横向;
  • 选择列用原生 <input type="checkbox">:视觉由 CSS 模拟成 Checkbox 的外观,但每行不创建组件实例。

多选与批量操作

传 rowSelection 即在最前注入选择列;getCheckboxProps 可禁用个别行(禁用行不计入「全选」的分母)。

排序

列上写 sorter: true 即可点表头排序(组件内排序,不请求接口)。sortType 决定按数字还是字符串比较,默认字符串;需要自定义规则时 sorter 也可以传函数。

行内编辑

列上加 editor 即可编辑:单击单元格进入。

按键 行为
Enter 全选当前内容(便于直接覆写),不提交
Esc 取消编辑:把该行恢复到进入编辑前的值,然后退出编辑
Tab / Shift+Tab 提交并跳到下一个 / 上一个可编辑格(见下方「跳格」)
失焦(点别处) 提交并抛出 change

Enter 不做提交是刻意的:单元格里的值通常要整体替换,全选后直接输入即可,不必先手动清空。提交一律靠失焦。

跳格

Tab / Shift+Tab 只在可编辑格之间走动:没有 editor 的列、以及被 disabled 按行禁用的格都会被跳过;行尾自动接到下一行的第一个可编辑格,到表格两头则循环(Excel / AG Grid 的手感)。全表没有任何可编辑格时不接管,Tab 保持浏览器默认行为。

为什么不直接交给浏览器的 Tab 序:

  • 编辑态的编辑器只挂在当前编辑行上(其它行是纯文本,不可聚焦),浏览器按文档顺序在表格里找不到「下一格」,会把焦点送到页面其它元素上 —— 表现出来就是「按了 Tab 但没选中下一个」;
  • select 更明显:它展开时会把 DOM 焦点移进下拉浮层,而浮层是 Teleport 到 body 的,浮层里的按键事件到不了表格。所以表格在 document 的捕获阶段接管 Tab,并用 ARIA 关联(浮层 id ↔ 触发器 aria-controls)把浮层追回它所属的单元格。

还有两种「焦点此刻不在编辑器上,但 Tab 仍属于表格」的情况,同样会被接管:

  • 编辑行 / 编辑列被虚拟滚动滚出渲染窗口(编辑器随之卸载、焦点掉到 body):这时按 Tab 会把表格滚回编辑行并继续前进一格,而不是把焦点送出表格(放行的话浏览器会按文档顺序落到页面上其它元素上)。判据是「编辑格此刻不在 DOM 里」——编辑格还在渲染时(例如点了页面空白)不接管,页面 / 表单的 Tab 序不受影响。
  • 长按 Tab / 连按 Tab(keydown 带 repeat,或间隔小于底座 100ms 的连按防抖):底座的导航优化在长按期间会把编辑器全卸载(连「当前编辑行」都置空,等松手再挂回来),焦点因此落在 body。这时 Tab 仍然被认作表格的,一格一格继续走,松手后编辑器落到最后一格。从只读列起步(如「订舱号」,没有编辑器)也走这条路径。
编辑器类型 说明
text / number / password / textarea 用 Input 渲染
select 用 Select 渲染;options 可以是数组,也可以是接收当前行的函数
checkbox 点击即切换,无需先激活整行;约定 1 = 选中、0 = 未选中(与后端 tinyint 对齐)。切换后该格同时成为当前格(行高亮 + 选中框 + 进入该行编辑),与点击其它单元格行为一致

编辑器的输入值先写进内部草稿,失焦才落库并抛出 change。这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。用 editable={false} 可整体禁用编辑。

编辑器形态:单元格内无外间距、无常驻边框

默认(editorSeamless)下编辑器的外间距为 0、自身不画常驻边框,整格铺满、融进表格:

  • 适合整行编辑、键盘连续录入、固定列宽、专业用户(Excel / AG Grid / Handsontable 的表格态);
  • 单元格几何在进入 / 离开编辑时完全不变——编辑器的盒高就等于 rowHeight,同一行的纯文本列不会跟着跳;
  • 水平内边距由单元格统一提供(编辑器和纯文本格用同一套网格),进出编辑态零位移;
  • 视觉提示只在焦点、hover 与校验时出现,且都不占用布局空间:
    • 焦点 → 单元格内嵌的主色焦点环(内阴影)+ 输入框自身的焦点光晕;正在编辑的那一格始终有环(判据是编辑器上的标记类,而不是 :focus-within——select 展开时焦点会被移进浮层,用 :focus-within 会时有时无);
    • hover → 格内极淡填充;
    • 校验(status = error / warning)→ 文字色 + 1px 内描边。
<!-- 回到「格内留一圈间距 + 自身描边」的悬浮卡片观感 -->
<G3Table :editor-seamless="false" :columns="columns" :data-source="data" />

两种形态的编辑器盒高都等于 rowHeight,所以切换真值不会改变行高。textarea 编辑器在默认的 30px 行里放不下正文,需要显式给足 row-height。

⚠ 数据源必须是响应式的

行对象必须能被 Vue 追踪,否则编辑回写不会触发重渲染。典型的踩坑写法是用 computed 派生数据:

// ❌ 错:computed 返回的是普通对象数组,行字段不可追踪
const rows = computed(() => Array.from({ length: n.value }, (_, i) => makeRow(i)))

// ✅ 对:ref 会递归代理内部对象
const rows = ref<Row[]>([])
watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) }, { immediate: true })

用错时的症状极具迷惑性,不容易联想到数据源:

  • change 事件照常抛出(数据其实已经改了,父级能拿到)
  • 但界面上仍显示旧值
  • 点一下别处(触发无关重渲染)才「突然」更新
  • 尤其是 select 编辑器:选完选项下拉关了、值却没变,很像组件的事件时序 bug

开发模式下,进入编辑时会检测并打印一条警告指明改法(每个组件实例只警告一次)。

不可改的格子(灰底,常驻)

在支持行内编辑的表里:凡是「现在改不了」的格子,都铺一层灰底 + 文字降一档亮度, 不需要业务额外配任何东西、也不加锁图标。三种情形统一走这套视觉:

情形 怎么配 何时变灰
整列只读 列上没有 editor 始终
整列禁用 editor.disabled: true 始终
按行禁用 editor.disabled: (row) => boolean 该行判定为 true 时(动态)

前提:这张表里得先有人能编辑。 灰底的语义是「这一格现在改不了」,它靠「旁边有可改的格子」传达意义;所以下面两种情况不铺灰:

  • 表格里一个可编辑列都没有(纯展示表:列上都没写 editor)—— 每格都改不了时,灰底不指向任何东西,铺满反而让整张表看上去像「禁用状态」;
  • editable="false"(编辑功能整体关掉)—— 同理,此时每一格都不可编辑。

按行禁用是最常用的那种:「已放舱之后状态不许再改」这类条件随行数据变化:

{
  dataIndex: 'status',
  title: '状态',
  editor: { type: 'select', options: STATUS_OPTIONS, disabled: (row) => row.status === 2 },
}

几件需要知道的事:

  • 判定是行数据的派生值,每次渲染重算 —— 数据变了灰底自己消失 / 出现,业务不用管;
  • 整列只读 / 整列禁用的列不会被注入 customCell(走 td 上的类名),零运行时开销; 只有「按行禁用」的列会常驻注入,代价落在这些列上;
  • 「既禁用、又校验不过」的格子显示为灰:既然改不了,「这里错了」提示了也没用;
  • 灰底用 --td-bgc 叠中性色得到,亮 / 暗主题自动跟随。

下面这张 demo 里,第 2 行(状态=停用)的「备注」列就是按行禁用的灰格 —— 不点它也能看出来。

操作列

列上加 actions 渲染成一排小按钮;type: 'more' 的项会收进「更多」下拉。带 confirm 的操作先弹确认框,确认后才执行 onClick。

操作列建议显式写 resizable: false:它的宽度由内容决定,拖了不会落库,列一重建就丢。

列宽、列序与行拖拽

列宽 / 列序拖拽要求用 v-model:columns 绑定列定义——否则组件拖完无处回写(松手回弹),会直接关掉这两个功能。行拖拽排序用 row-draggable,顺序变化通过 row-order-change 回传。

API

Props

名称 类型 默认值 说明
columns G3TableColumn<T>[] [] 列定义,配合 v-model:columns 接收拖拽后的回写
dataSource T[] [] 数据源
rowKey string | (row) => string | number 'id' 行唯一键
virtual boolean true 纵向虚拟滚动总开关(实际是否开启受行数门控)
virtualX boolean true 横向虚拟滚动总开关(实际是否开启受列数门控)
rowHeight number 30 行高,同时驱动虚拟滚动计算
headerRowHeight number 34 表头行高
bordered boolean true 是否显示边框(透传给底座的 bordered: true)。边框由底座绘制,不再是自绘外框 + 圆角
stripe boolean false 斑马纹(隔行底色)。固定列会一起上条纹(见「固定列」):库只把条纹画在 tr 上,冻结列的底色必须不透明,组件在它上面补一层同色内阴影接上
fixedColShadow boolean false 固定列被滚动内容遮挡时画 10px 渐变阴影(见「固定列」)
colResizable boolean | object true 列宽拖拽;未绑定 v-model:columns 时自动关闭。可用 { disabled: (col) => boolean } 按列禁用
columnDraggable boolean | object true 列序拖拽;同上要求绑定 v-model:columns
rowDraggable boolean false 行拖拽排序
rowSelection TableRowSelection | null null 多选配置,见下
seq boolean | { title?, width? } true 序号列,固定左侧(见「固定列」)
activeRowKey string | number | null null 当前高亮行
editable boolean true 行内编辑总开关
editorSeamless boolean true 编辑器无缝嵌入单元格:格内无外间距、无常驻边框,只有焦点 / hover / 校验态才给视觉提示。传 false 回到留间距 + 自身描边的形态
noDataFull boolean true 空态铺满容器高度
showOverflow boolean true 内容溢出省略
autoFitWidth boolean false 非横向虚拟时铺满容器宽度(开启后关闭列宽拖拽)
theme 'light' | 'dark' — 不传则跟随全局主题(ConfigProvider / html[data-g3-theme])
footerData T[] [] 表尾数据
footerConfig object `` 表尾配置

列定义 G3TableColumn<T>

在 stk-table-vue 的列类型基础上扩展;下面只列自有字段,其余(width / minWidth / fixed / align / headerAlign / sorter / sortType / children / mergeCells 等)原样透传。

字段 类型 说明
dataIndex keyof T & string 取值字段
title string 表头文字
resizable boolean 该列是否可拖宽;操作列 / 展开列建议设 false
editor TableEditor<T> 行内编辑器,见下
actions TableAction<T>[] 操作列按钮
customCell (props) => VNode | string 自定义单元格渲染(逃生舱),传了就不再自动包装

editor

字段 类型 说明
type 'text' | 'number' | 'password' | 'textarea' | 'select' | 'checkbox' 编辑器类型
options TableSelectOption[] | ((row) => TableSelectOption[]) select 选项,可依赖当前行其它字段
disabled boolean | ((row) => boolean) 是否禁用该格编辑
allowClear boolean select 是否可清空,默认 true
emptyValue unknown select 清空后落库的值,默认 null
onChange (value, row, col) => void 本列值变化回调(早于组件的 change)
validate (value, row, col) => boolean | string 校验规则:返回 falsy 表示通过,返回非空 string 表示不通过(该字符串即错误原因)。
errorStyle 'cell' | 'inline' 校验失败的展示形态,默认 'cell'。见下

校验(validate)

给列配 validate 即可获得实时校验:

{
  dataIndex: 'qty',
  title: '采购数量',
  editor: {
    type: 'number',
    // 返回 falsy = 通过;返回 string = 不通过,字符串即错误原因
    validate: (value) => Number(value) > 0 || '采购数量必须大于 0',
  },
}

三件需要知道的事:

  • 返回值的语义是「falsy 通过 / string 不通过」,不是「true 通过」。所以 validate: (v) => Number(v) > 0 || '必须大于 0' 这种最自然的写法是对的; 但若写成 (v) => Number(v) > 0(只返回布尔),则永远不会报错—— 布尔 true 被当作「通过」、false 也被当作「通过」。要让布尔参与判错, 必须返回字符串。这一点容易踩,故单独说明。
  • 校验是派生值,不是一次性状态:每次渲染都按当前行数据重算, 所以用户把空值改成合法值后,红底在下次渲染就自动消失, 不需要业务手动清状态,组件内部也不维护任何 error 注册表。
  • 校验函数抛错会被吞掉并降级为「通过」(仅 DEV 打印警告)。 因为它在渲染期被调用,抛出去会让整格渲染失败,比「没有红底」严重得多。

展示形态 errorStyle

值 效果
'cell'(默认) 单元格铺浅红底 / 浅琥珀底;正在编辑的那一格例外(见下)
'inline' 只在编辑器自身画文字色与 1px 内描边,不铺底

浅红底取自设计原型,适合 ERP 明细这类以单元格为视觉单位的场景; 若表格本身已经很密,用 'inline' 更克制。

编辑态让位(默认形态下的行为):正在编辑的那一格不铺校验底色,底色回到基色,校验信号只留语义色焦点环 + 红字(铺着底色时输入区被染色、对比度下降,录入时「看不清」)。离开该格后底色自动回来(校验本身是按行数据派生的,值改对了自然消失)。

早先这里还有一个右上角的 ! 角标,已整体去掉(实际录入反馈):它画的 ! 会压到文字上 —— 右对齐的数字列最明显(数字正好顶在角标下面),而为了给它让位留出的 14px 右边距又实打实缩窄了录入区。

actions

字段 类型 说明
key string | number 唯一键;type: 'more' 的子项用它做下拉 key
label string 文案
type 'default' | 'danger' | 'delete' | 'more' 语义类型
icon Component 图标组件
visible boolean | ((row) => boolean) 是否显示
disabled boolean | ((row) => boolean) 是否禁用
confirm string | object 二次确认;字符串为标题,对象支持 title / description / okText / cancelText / okType / width / maskClosable
onClick (row) => void 点击回调(带 confirm 时在确认后执行)
items TableAction<T>[] type: 'more' 时的子项

Events

名称 参数 说明
update:columns (columns) 列宽 / 列序拖拽后回写(内部列已剥离)
update:selectedRowKeys (keys) 勾选变化
change (row, col, value) 行内编辑的值变化
row-order-change (rows, sourceIndex, targetIndex) 行拖拽排序完成
area-selection-change (ranges) 单元格选区变化(行内编辑依赖它)

Exposed

名称 类型 说明
scrollToRow(rowKey) Promise<void> 滚动到指定行
editRow(rowKey, focusCol?) Promise<void> 滚动到指定行并进入编辑
initVirtualScroll() / refreshLayout() — 手动重算虚拟滚动布局
getSelectedArea() / clearSelectedArea() — 读取 / 清空单元格选区
setRowExpand(...) — 展开 / 收起行(需配 expand 列)

Slots

名称 说明
empty 空态内容,默认显示「暂无数据」
expand 展开行内容(需列配置 type: 'expand')

实现说明

  • 底层是 stk-table-vue:虚拟滚动、列宽 / 列序拖拽、区域选择、行拖拽、二级表头、固定列、合并单元格都由它实现。组件不重复造这些轮子,只做门控与适配。
  • 库样式已并入 dist/style.css:库的 CSS 是独立文件(其 JS 里不含任何 .css import),组件 @use 了它,消费方不需要额外引一次第三方样式。
    • 注意 SCSS 里不能写 @use 'stk-table-vue/lib/style.css':带 .css 后缀时 Sass 不会内联,只原样输出一条 CSS @import 语句,产物里会留下一条浏览器解析不了的裸包路径。去掉后缀才会真正内联。
  • 主题通过覆盖库的 CSS 变量实现:库把 41 个主题值都做成了 .stk-table 作用域内的变量,组件把它们映射到 --g3-* 令牌(覆盖层在 src/table/style/_overrides.scss)。改变量而不是逐个选择器改样式,库升级时不容易失效。
  • 区域选择按需自注册:库的单元格区域选择是全局注册的(registerFeature(useAreaSelection)),不注册则 area-selection-change 永不触发、行内编辑失效。组件库不拥有 app 启动流程,因此首次挂载时自动注册一次;需要显式控制时可调用导出的 registerTableFeatures()。
  • data-testid 透传到内层:组件 inheritAttrs: false,$attrs 落在内层 StkTable 上,所以写 E2E 时按类名 .g3-table 定位根节点。
  • 边框完全交给底座:组件把 bordered 直接透传为底座的 bordered: true,不再自绘外框与行分隔线。因此:
    • 底座用逐格 background-image 渐变画线(不是 border),横竖线都来自 --bg-border-* 变量;
    • 受滚动条影响,表格右侧与底部的边框会消失——这是底座文档(table/basic/bordered)明确说明的行为,需要时自行补 CSS;
    • 没有圆角;bordered: false 时四边与格线全部消失。
  • --th-bgc 等表头/表尾底色由覆盖层的变量映射提供,不通过行内 style 注入。
  • 行编辑态存在 WeakMap 里:按行对象存,不给业务数据打标记;行被回收时状态自动释放。行与行之间切换编辑不翻转 editingActive,避免整表列重建。
  • 编辑器无缝嵌入单元格(editorSeamless):把 G3Input / G3Select 自带的 padding / border / border-radius / background 置零,只保留焦点、hover 与校验提示;编辑器的高度由组件显式钉成 var(--row-height)(不能被控件的 min-height: 32px 顶高,也不能靠 height: 100% —— td 的内容盒不等于行高),因此编辑前后的行高完全一致。焦点环画在外层 td 的 inset 阴影上:不参与盒模型计算,几何零影响。
  • RTL:布局用逻辑属性,带符号位移走 --g3-dir-sign;分隔条拖拽与键盘方向由库的 useDirection 处理。