286 lines
14 KiB
Markdown
286 lines
14 KiB
Markdown
# 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 类逐条实测」这个原则(避免把版本补丁搬进库)
|