# 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 区域选择是**按需注册**的全局副作用 ```js // 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` / `SortConfig` / `AreaSelectionRange` / `CustomCellProps` 等。我们的 `G3TableColumn` 应当 **`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[]` | `[]` | 列定义,配合 `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` | 行内编辑总开关 | | `editorSeamless` | `boolean` | `true` | 单元格内无外间距 + 无常驻边框(§5.3) | | `noDataFull` / `showOverflow` | `boolean` | `true` | 空态铺满 / 溢出省略 | | `autoFitWidth` | `boolean` | `false` | 非横向虚拟时铺满容器 | | `theme` | `'light' \| 'dark'` | `undefined` | 不传则跟随全局主题(**按 §4.5 从 ConfigProvider 取,不依赖 pinia**) | | `footerData` / `footerConfig` | `array` / `object` | `[]` / `` | 表尾合计 | ### 3.3 Events / Expose ```ts // 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` ```ts export type G3TableColumn = Omit, '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[] /** 覆盖内置 customCell 渲染(逃生舱) */ customCell?: (props: CustomCellProps) => VNode resizable?: boolean // false = 该列禁止拖宽(操作列/展开列) } ``` --- ## 4. 性能策略(本次的核心) 按「收益 ÷ 维护成本」排序,全部来自 A/B 两类: ### 4.1 虚拟滚动门控:行数 ≤ 100 时**关闭**虚拟滚动 ```ts 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` ```ts // 有行在编辑时列才带 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 用**原生 `` + 自绘方框**,不用 `G3Checkbox` 组件。 每行一个组件实例在滚动时的开销不可接受。样式模拟成 `G3Checkbox` 外观(CSS 层,非组件)。 --- ## 4.7 编辑器无缝嵌入单元格(`editorSeamless`,默认开启) **形态**:单元格内部**无外间距** + 编辑器**无常驻边框**。适合整行编辑、键盘连续 录入、固定列宽以及专业用户(Excel / AG Grid / Handsontable 的表格态)。 **目标**:单元格保持稳定的几何结构,编辑器融进表格,**只有焦点、校验和特殊 交互状态才增强视觉提示**。 三条硬约束(改动这块前必读): 1. **编辑器盒高 = `var(--row-height)`**。td 是 border-box,指定 `height: 30px` 时 内容盒只有 `30 - padding`,而子元素 `height: 100%` 解析的基准是**内容盒**。 所以 td 垂直 padding 必须为 0,且编辑器不能靠 `height: 100%` 撑—— `G3Select` 的触发器自带 `min-height: 32px`,在 30px 的行里会直接把行顶高 2px。 显式 `height: var(--row-height)` + `min-height: 0` 把下限压死。 2. **水平内边距只能由 td 提供**(库的 `--cell-padding-x`)。若改成给编辑器加 透明边框,编辑器与纯文本格会各用一套逻辑像素取整,窄列下出现「编辑前文字 偏移 1px」的漂移;td padding 对两者一视同仁,**进出编辑态零位移**。 3. **无边框 ≠ 无提示**。焦点环画在**外层 td 的 `inset` 阴影**上(`:focus-within`): 内阴影在 padding box 内侧绘制,不参与 td 自身的盒模型计算,几何绝对稳定, 且天然铺满整格;hover 换成格内极淡填充,校验用文字色 + 1px 内描边。 三者都不占用布局空间。 去零的是 `padding` / `border` / `border-radius` / `background`, **绝不动 height**;被移除的内边距由编辑器内层控件补回一次网格间距 (`space-1` / `space-2`,与纯文本格同一档),因此「无间距」的收益是 **格内不留空白**,而不是让文字失去网格对齐。 --- ## 5. 与现有约定的对齐(必改项) | 参考实现 | 我们必须改成 | |---|---| | `