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

286 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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
```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<T>`
```ts
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 时**关闭**虚拟滚动
```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 用**原生 `<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. 新增依赖
```yaml
# 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 类逐条实测」这个原则(避免把版本补丁搬进库)