Files
workspace/code/g3soft-libs/docs/table-design.md
T
2026-10-12 17:32:50 +08:00

14 KiB
Raw Blame History

G3Table 设计方案(性能优先)

参考实现:D:\workspace\code\fms\fms-vue\src\components\fms-table(FmsTable.vue 954 行 + 4 个 utils) 底座:stk-table-vue@1.1.0(MIT,零运行时依赖、零 peer 依赖,纯 ESM) 状态:待评审,确认后再开工


1. 先讲清楚参考实现到底是什么

这一点决定后面所有取舍,所以放在最前面。

FmsTable.vue 不是一个表格实现,它是 stk-table-vue 的一层适配器(约 950 行)。 虚拟滚动 / 列宽拖拽 / 列序拖拽 / 区域选择 / 行拖拽排序 / 二级表头 / 固定列 / 合并单元格 全部是库的能力。该文件真正的增量只有四类:

类别 内容 是否值得搬
A. 性能门控启发式 MIN_VIRTUAL_ROW_COUNT = 100(行数少时关虚拟滚动)、MIN_VIRTUAL_X_COLUMN_COUNT = 20、editingActive 抑制编辑态下的逐格 customCell 注入 ✅ 这是真正的核心价值,必须搬
B. 防抖 / 节流 resize 150ms trailing 防抖、键盘编辑激活 100ms 合并、rAF 合并同一帧多次通知 ✅ 直接搬
C. 针对 v1.1.0 的 workaround effectiveVirtualX(库在 virtualX 下会把列宽钉死,破坏原生自适应)、--bg-border-right 局部清除、border-left: none、td:has(...) 补高等 ⚠️ 不抄注释,按我们自己的验证结论重写
D. 业务耦合 useAppStore 取暗色模式、@/components/ui/* 直接 import 内部组件 ❌ 必须替换成 @g3soft/* 对应物

C 类是最大的风险。 该文件里约 15 条注释在描述「与库内部行为作斗争」,它们修的是 v1.1.0 特定内部实现的 bug。整段照搬等于把一堆针对某个版本的补丁搬进我们的库, 之后库一升级就要重新验证每一条。正确做法:只搬 A、B 两类,C 类在实现时逐条实测 (用 E2E 锁住几何),确认仍然必要才保留,并在注释里写「为什么」而不是「库做了什么」。


2. 底座集成(已实测确认的四个硬约束)

这些都是我实际读 node_modules 确认的,不是假设:

2.1 纯 ESM,可直接用

lib/stk-table-vue.js  →  真实 import/export,无 require

与 @g3soft/ui 的 "type": "module" + preserveModules 兼容。 vue 是裸导入(由宿主解析),不会被我们打包进去。

2.2 样式是独立文件,必须单独引入

实测:lib/stk-table-vue.js 与 StkTable-6OMgH-gT.js 里没有任何 .css import。 fms 在 src/main.js 手动 import 'stk-table-vue/lib/style.css'。

对我们的影响(重要):@g3soft/ui 的产物是 dist/style.css(sideEffects: ["**/*.css"])。 组件库不能要求消费方额外引一次第三方样式。方案:

  • 在 src/table/style/ 下建一个入口,@import 库样式 + 我们的覆盖层,由 vite 打进 dist/style.css;
  • 覆盖层用 .g3-table__inner.stk-table 双类选择器(权重 (0,2,1) > 库默认 (0,1,1)), 与我们现有「不用 scoped、BEM + g3- 前缀」的约定一致。

待确认:库样式里是否有 :root 级变量可能与 --g3-* 冲突。开工第一步先做这件事。

2.3 区域选择是按需注册的全局副作用

// fms/src/main.js
import { registerFeature, useAreaSelection } from 'stk-table-vue'
registerFeature(useAreaSelection)   // 一次性,mount 之前

行内编辑依赖 area-selection-change 事件,不注册就没有该事件。

对我们的影响:组件库不拥有 app 启动流程,不能在 bootstrap 里替用户调用。 方案:在 G3Table 内部按需自注册(registerFeature 幂等,模块级一次性 flag 包住), 并额外暴露 G3Table.registerFeatures() 供显式控制。避免「组件静默不工作」这类最难查的 问题——这正好是 docs-examples.test.ts 那类隐形失败的同源风险。

2.4 完整的 TS 类型可用

lib/src/StkTable/types/index.d.ts 导出 StkTableColumn<T> / SortConfig / AreaSelectionRange / CustomCellProps<T> 等。我们的 G3TableColumn<T> 应当 Omit + 扩展它,而不是从零重写一遍(重写会随库版本漂移)。


3. 组件 API

3.1 目录结构

packages/ui/src/table/
  G3Table.vue              # 主组件(对齐 FmsTable.vue 的职责划分)
  types.ts                 # G3TableColumn / G3TableProps / emits / expose
  index.ts
  style/
    index.scss             # @import 库样式 + 我们的覆盖层
    _overrides.scss
  utils/
    column.ts              # 内部列注入 / 剥离(序号列、选择列、拖拽列)
    selection.ts           # createSelectionColumn:原生 checkbox,零组件开销
    actions.ts             # createActionsCell:操作列
    editable.ts            # createEditableCell:行内编辑
    focus.ts               # 编辑器焦点恢复 / 草稿保持
  G3Table.test.ts          # 组件契约
  utils/*.test.ts          # 纯函数

3.2 Props(对齐 fms,命名转 kebab-case,全部走 withDefaults)

prop 类型 默认 说明
columns G3TableColumn<T>[] [] 列定义,配合 v-model:columns(列宽/列序拖拽回写)
dataSource T[] [] 数据
rowKey string | (row) => key 'id' 行唯一键
virtual boolean true 纵向虚拟滚动(受 §4.1 门控)
virtualX boolean true 横向虚拟滚动(受 §4.2 门控)
rowHeight / headerRowHeight number 30 / 34 唯一真源,同时驱动虚拟计算与 CSS 变量
bordered boolean | string true 自绘外框 + 行分隔线
colResizable boolean | object true 列宽拖拽(未绑定 v-model:columns 时自动关闭)
columnDraggable boolean | object true 列序拖拽
rowDraggable boolean false 行拖拽排序
rowSelection object | null null { selectedRowKeys, onChange, getCheckboxProps }
seq boolean | object true 序号列
activeRowKey string | number | null null 当前高亮行
editable boolean true 行内编辑总开关
noDataFull / showOverflow boolean true 空态铺满 / 溢出省略
autoFitWidth boolean false 非横向虚拟时铺满容器
theme 'light' | 'dark' undefined 不传则跟随全局主题(按 §4.5 从 ConfigProvider 取,不依赖 pinia)
footerData / footerConfig array / object [] / `` 表尾合计

3.3 Events / Expose

// events
'update:columns' | 'change' (row, col, newValue) | 'row-order-change'
'update:selectedRowKeys' | 'area-selection-change'

// expose
scrollToRow(rowKey) | editRow(rowKey, focusCol?) | initVirtualScroll() | refreshLayout()
getSelectedArea() | clearSelectedArea() | setRowExpand(...)

3.4 列定义 G3TableColumn<T>

export type G3TableColumn<T> = Omit<StkTableColumn<T>, 'customCell'> & {
  dataIndex: keyof T & string
  /** 表头/单元格对齐已由 §3.2 的 headerAlign 推断补齐 */
  /** 行内编辑(首版按「全量对齐」实现) */
  editor?: {
    type: 'text' | 'number' | 'select' | 'checkbox' | 'textarea'
    options?: SelectOption[] | ((row: T) => SelectOption[])  // 选项可依赖同行其它列
    disabled?: boolean | ((row: T) => boolean)
    allowClear?: boolean
    emptyValue?: unknown
    onChange?: (value, row, col) => void
  }
  /** 操作列 */
  actions?: G3TableAction<T>[]
  /** 覆盖内置 customCell 渲染(逃生舱) */
  customCell?: (props: CustomCellProps<T>) => VNode
  resizable?: boolean   // false = 该列禁止拖宽(操作列/展开列)
}

4. 性能策略(本次的核心)

按「收益 ÷ 维护成本」排序,全部来自 A/B 两类:

4.1 虚拟滚动门控:行数 ≤ 100 时关闭虚拟滚动

const MIN_VIRTUAL_ROW_COUNT = 100
const effectiveVirtual = computed(
  () => props.virtual && props.dataSource.length > MIN_VIRTUAL_ROW_COUNT,
)

原因(照搬并保留注释):库的虚拟滚动在「行数 > 单屏行数」时开启,会导致 「容器一屏放 18 行、数据 20 行」这种情况下最后几行被虚拟化吞掉、必须滚动才可见—— 一个纯粹的正确性 bug。行数少时全量渲染没有任何性能问题。

4.2 横向虚拟门控:列数 ≤ 20 时关闭

原因:库在 virtualX = true 时会把每列宽度钉死为 minWidth ?? width ?? 100 (cellStyleMap 的 --cw + width/min-width/max-width 三重锁),未设 width 的列 会失去「按比例铺满容器」的原生自适应。列数少、总宽不超容器时用不上横向虚拟。

4.3 editingActive:无编辑行时不注入 customCell

// 有行在编辑时列才带 customCell;无编辑行时全部走库原生 td
const displayColumns = computed(() => {
  const cols = props.columns.map((c) => resolveCustomCell(c, editingCtx, editingActive.value))
  // ...
})

这是整个方案里单点收益最大的优化:纯展示 / 非编辑态的可编辑列不再包裹函数式组件, 滚动时每格省掉一个组件实例。行与行之间切换编辑不翻转 editingActive,避免整表列重建。

4.4 尺寸与键盘防抖

  • resize 150ms trailing 防抖:折叠动画 / 窗口缩放期间 ResizeObserver 每帧通知, 但中间尺寸是瞬态的;逐帧 initVirtualScroll() 会每帧强制 reflow 并重渲染可见行列。 合并到尺寸稳定后刷新一次。并区分「仅宽度变化」→ 只重算 X 方向。
  • rAF 合并同一帧内的多次通知。
  • 键盘编辑 100ms 合并:按住方向键滚动时全程保持 0 编辑器,松手后再激活; 快速离散连按(<150ms)也走防抖,避免每键挂载/卸载整行编辑器。

4.5 主题:从 ConfigProvider 取,不依赖 pinia

参考实现用 useAppStore().darkMode——那是业务 store。我们改走 ConfigProvider / documentElement.dataset(与现有组件一致), 并保留 theme prop 显式覆盖。

4.6 零组件开销的选择列

行 checkbox 用原生 <input type="checkbox"> + 自绘方框,不用 G3Checkbox 组件。 每行一个组件实例在滚动时的开销不可接受。样式模拟成 G3Checkbox 外观(CSS 层,非组件)。


5. 与现有约定的对齐(必改项)

参考实现 我们必须改成
<style scoped> + 非 scoped 全局覆盖 禁止 scoped(CODEBUDDY.md 明确),全走 g3- 前缀 + BEM
@/components/ui/button 等直接 import G3Button / G3Modal / G3Select / G3Input
var(--fms-*) 业务变量 --g3-* 令牌,每个都带 fallback
useAppStore() 取主题 ConfigProvider
dataIndex 无类型约束(JS) 手写 types.ts + 泛型 G3TableColumn<T>
无 resizable: false 语义 保留(操作列/展开列必须禁止拖宽,见 §4.1 注释原因)

RTL:全部用逻辑属性,带符号位移走 --g3-dir-sign;水平方向键走 horizontalArrowStep(key, direction)。表头拖拽 / 列序拖拽需单独验 RTL 几何。


6. 新增依赖

# packages/ui/package.json
dependencies:
  stk-table-vue: ^1.1.0
  • MIT,零运行时依赖、零 peer 依赖,纯 ESM,体积可控(主包 ~23KB + 分块 185KB)。
  • 不改 pnpm-workspace.yaml 的 allowBuilds(该包无安装脚本)。
  • 需确认发布物无 exports 字段(实测确实没有),我们按 lib/stk-table-vue.js 深路径引用时 要留意——建议用主入口 import { StkTable } from 'stk-table-vue',样式单独引。

7. 测试计划

按仓库三层测试约定:

  • L0 类型:pnpm --filter @g3soft/ui typecheck。必须单独跑——pnpm build 不 gate 类型错误。
  • L1/L2 单测(happy-dom):
    • utils/column.test.ts:内部列注入 / 剥离、headerAlign 推断、列宽回写
    • utils/selection.test.ts:全选 / 半选 / 禁用行 / 筛选后计数
    • utils/editable.test.ts:草稿保持、notifyChange 去重、焦点判定矩阵
    • G3Table.test.ts:组件契约(列渲染、门控开关、空态、只读)
  • L3 E2E(必须在真浏览器,happy-dom 无布局引擎):
    • 虚拟滚动几何:100 行以下不虚拟、101 行以上虚拟;滚动后行位置正确
    • 列宽/列序拖拽:page.mouse 前先 scrollIntoViewIfNeeded()
    • resize 防抖:容器尺寸变化后不出现中间态抖动
    • RTL 几何:LTR / RTL 各测一次(单侧通过可能是巧合)
  • playground:新增可交互元素必须挂 data-testid(E2E 只按 testid 找元素)。
  • tests/docs-examples.test.ts 会校验文档示例的 prop 契约——新增示例的 prop 名必须与 运行时 props 对账,写错会被拦(这正是它存在的意义)。

注意:publint 在 prepublishOnly 跑,新增深路径引用可能触发 exports 一致性检查失败。


8. 实施顺序(建议分 4 批,每批可独立验证)

  1. 底座打通:装依赖 → 样式合流进 dist/style.css → 最小可渲染表格 → 验证 ESM / 类型 / 主题变量不冲突
  2. 核心只读:列定义 + 类型 + 虚拟门控(§4.1/4.2)+ 序号列 + 排序 + 固定列 + 空态 + 单测
  3. 交互层:多选列(§4.6)+ resize/键盘防抖(§4.4)+ 列宽/列序拖拽 + E2E 几何
  4. 编辑与操作:editingActive(§4.3)+ 行内编辑 + 操作列 + 行拖拽 + 表尾合计 + 文档/示例

9. 待你确认的点

  1. §2.2 样式合流:接受把库样式打进我们的 dist/style.css 吗?(否则消费方要多引一次)
  2. §2.3 区域选择自注册:组件内自动注册 vs 暴露显式 API?(我倾向自动 + 显式覆盖)
  3. §4.1 门控阈值 100 / 20:沿用,还是按我们的场景调整?
  4. 命名:G3Table / table(与现有 G3Splitter / splitter 一致)
  5. C 类 workaround:确认「只搬 A/B、C 类逐条实测」这个原则(避免把版本补丁搬进库)