Repository navigation
fix(table): 列数超过阈值且无 fixed 列/合并单元格时支持横向虚拟滚动 - #3903
tdesign-bot wants to merge 2 commits into
Conversation
新增 scroll.colThreshold 配置项(默认 30),当满足以下条件时开启横向虚拟滚动: 1. scroll.type 为 virtual 2. 不存在固定列 3. 未使用合并单元格(rowspanAndColspan) 4. 列数超过 colThreshold 裁剪掉的列用 data-virtual-x-placeholder 占位单元格撑开宽度,减少大列数表格的 DOM 渲染量。 Closes #3900
commit: |
RSS1102
left a comment
There was a problem hiding this comment.
评审意见(COMMENT)· Tencent/tdesign-vue #3903
一、结论:不建议合入
40 列、无固定列的表格开启横向虚拟滚动后,首屏只渲染 3 列,横向滚动后可视区内表体为空,表头与表体错位,属于新逻辑引入的功能性回归。
| 项 | 值 |
|---|---|
| 出口 | COMMENT |
| 判定依据 | 对照验证发现回归(新增的横向滚动用例在基线通过、合并后失败),另有 3 条待处理问题 |
| 风险档 / 分类 | A / component_feature |
| 评审基准 | base=59bef62e · 合并后=8a83dd10 · PR head=6fc094c3 |
已在本地对「基线」与「基线合并本 PR」两个版本跑了同一套浏览器用例(3 条)和 src/table/__tests__ 单测:声明的新能力成立(出现占位、只渲染部分列),固定列场景不受影响;但横向滚动后可视区单元格为 0。单测两侧均全部通过(基线 291 条,合并后 293 条,多出的是本 PR 新增的 2 条),说明现有单测抓不到该问题。
二、理由
关键代码分析
src/hooks/useVirtualScrollNew.ts:91
const avgColWidth = Math.max(1, (containerW || 0) / Math.min(colLength, tScroll.value.colThreshold));
...
const rawStart = Math.floor(scrollLeft / avgColWidth) - bufferCount;
窗口起点用「容器宽度 / min(列数, 阈值)」当作平均列宽。容器宽 800、阈值 30 时约 26.7px,而真实列宽是 120px,所以 scrollLeft / avgColWidth 把列下标放大约 4.5 倍,窗口起点比真实位置靠右很多:滚动到 1500 时起点落在第 26 列,而可视区实际在第 12 列附近。
src/hooks/useVirtualScrollNew.ts:213-223
watch(() => [isVirtualScrollX.value, params.value.columns], () => {
...
const containerW = container.value?.getBoundingClientRect().width || 0;
updateVisibleColumns(container.value?.scrollLeft || 0, containerW);
}, { immediate: true });
immediate: true 在容器挂载前执行,containerW 为 0,窗口被算成 [0,3);之后只有列变化或发生滚动才重算。
src/table/tr.tsx:两侧用 colspan=N 的占位 td 撑宽,在 table-layout: fixed 下总宽度与实际一致(实测 tableW=4800),这部分思路可行,问题只在窗口取值。
合理的地方
- 启用条件与 PR 描述一致:
type=virtual、无固定列、无合并单元格、列数超过colThreshold;有固定列时确实不启用(对照用例通过)。 - 改动被限制在
isVirtualScrollX为真的路径内,list、tree、tree-select等其它useVirtualScroll调用方不传columns,不受影响;列数未超阈值的表格行为与基线一致。 colIndex加了窗口偏移,占位 td 用colspan保持总宽度,合并后scrollWidth仍为 4800(40 列 × 120px)。colThreshold有类型声明与默认值说明。
不合理的地方
src/hooks/useVirtualScrollNew.ts:91:窗口按平均列宽估算,横向滚动后表体与可视区错位。- 复现:40 列(
width: 120)× 150 行,scroll={{ type: 'virtual', threshold: 100 }},容器宽 800、高 400,无固定列。把.t-table__content的scrollLeft设为 1500,统计首行落在可视区内的 td:基线为 8,合并后为 0。此时可视区对应第 12 列起,实际渲染的是第 26–39 列。 - 为什么必须改:满足启用条件的表格一滚动就出现表体空白,属于功能性错误。
- 复现:40 列(
src/hooks/useVirtualScrollNew.ts:220:首次计算发生在容器挂载之前,首屏只渲染 3 列。- 复现:同上,不滚动直接读首行:合并后真实 td 为 3 个(可视区能放约 7 个),基线为 40 个。
- 为什么必须改:用户打开页面就看到表头有、内容无,右侧大片空白,直到滚动一次才补齐。
src/table/__tests__/ab-issue-3900.test.jsx:45:自带单测只断言「出现占位」和「真实单元格少于总数」,覆盖不到窗口内容,因此上述两个问题在单测全绿时依然存在。
三、修复建议
- 窗口计算(第 1 条):按真实列宽(
col.width或实测宽度)累加出每列左边界,用scrollLeft与scrollLeft + containerW查找起止列,再向两侧补 buffer;列宽未知时退回渲染全部列,不要用平均值估算。 - 首次计算(第 2 条):在容器挂载后(
onMounted/ 容器就绪的 watch /useResizeObserver回调)用真实clientWidth重算;宽度为 0 时不生成窗口,保持渲染全部列。 - 单测(第 3 条):补一条「滚动到中部后,可视区内存在对应列的单元格」的用例,并断言首屏可视区内的列数不少于容器可容纳的列数。
上述期望来自 PR 描述中「只渲染可视列」的目标:只渲染可视列的前提是可视区内始终有内容。
四、非阻塞建议(NIT)
- 站点 API 文档(
table.md)里没有看到scroll.colThreshold的说明,建议随功能一起补上,或在 PR 描述里注明由哪个仓库同步。
五、未验证 / 受限说明
- 只在无头 Chromium 里验证了
type=virtual、列宽 120、容器宽 800 的场景;未验证列不设width(自适应列宽)、容器宽度变化、用户拖动滚动条、触摸滚动。 - 未验证使用
rowspanAndColspan的场景(按设计应不启用,仅读代码确认)。 - 未运行类型检查和 lint,只运行了
src/table/__tests__(基线 291、合并后 293,全部通过)。
此次 review 是由 @tdesign-bot AI 自动生成,仅供参考。
— pr-review-lab
| if (!isVirtualScrollX.value || !columns?.length) return; | ||
| // 简化模型:假定各列平均宽度,用于估算可视区间,两侧各加一屏 buffer 避免横向滚动出现空白 | ||
| const colLength = columns.length; | ||
| const avgColWidth = Math.max(1, (containerW || 0) / Math.min(colLength, tScroll.value.colThreshold)); |
There was a problem hiding this comment.
这里用 containerW / min(colLength, colThreshold) 估算平均列宽(800px 容器、阈值 30 时约 26.7px),而真实列宽可能是 120px,下面 scrollLeft / avgColWidth 会把列下标放大约 4.5 倍。本地对照:40 列、列宽 120、容器宽 800,scrollLeft=1500 时可视区对应第 12–19 列,实际渲染的是第 26–39 列,可视区内表体单元格为 0(基线为 8)。建议按真实列宽累加出每列左边界后再求窗口,列宽未知时退回渲染全部列。
| startAndEndColIndex.value = [0, params.value.columns?.length || 0]; | ||
| return; | ||
| } | ||
| const containerW = container.value?.getBoundingClientRect().width || 0; |
There was a problem hiding this comment.
immediate: true 的 watch 在容器挂载前就执行,这里 container.value 为空,containerW=0,窗口会被算成 [0,3),之后不会因为挂载而重算。实测首屏表体只渲染 3 列(可视区能放约 7 列),表头却是完整的,直到滚动一次才补齐。建议在容器就绪后(onMounted / ResizeObserver 回调)用真实宽度重算,宽度为 0 时不生成窗口。
| const placeholderTds = wrapper.findAll('td[data-virtual-x-placeholder]'); | ||
| const realTds = wrapper.findAll('td:not([data-virtual-x-placeholder])'); | ||
| // 契约断言 1:应出现横向虚拟滚动占位单元格 | ||
| expect(placeholderTds.length).toBeGreaterThan(0); |
There was a problem hiding this comment.
这条断言只验证「出现了占位 td」,无法发现窗口错位或首屏缺列,所以 table 目录下 293 条单测全过的情况下问题仍然存在。建议补一条:滚动到中部后,可视区内应存在对应列的单元格。

关联 issue
Closes #3900
问题理解
t-table在列数较多(40+ 列)时,纵向虚拟滚动只裁剪不可见的行,横向方向没有对应的裁剪机制,所有列的所有单元格都会完整渲染到 DOM 中,列数越多渲染性能越差。根因
组件缺少横向虚拟滚动能力,
useVirtualScrollNew只处理了纵向裁剪。方案
新增
scroll.colThreshold配置项(默认 30),在以下 4 个条件同时满足时启用横向虚拟滚动:scroll.type为virtualfixed: 'left' | 'right')rowspanAndColspan)colThreshold裁剪掉的列用
data-virtual-x-placeholder占位单元格(<td colspan=...>)撑开宽度,减少 DOM 渲染量。固定列和合并单元格场景与裁剪模型冲突,暂不支持,保持现状(不裁剪)。改动文件
src/common.ts:TScroll新增colThreshold?: numbersrc/hooks/useVirtualScrollNew.ts:新增横向裁剪核心逻辑(isVirtualScrollX/visibleColumns/startAndEndColIndex/handleScrollX/totalColCount),改为泛型以保证列类型安全src/table/base-table.tsx:补充columns/isFixedColumn/rowspanAndColspan到虚拟滚动参数;接入横向滚动回调src/table/tbody.tsx:启用时把裁剪后的可见列传给每一行src/table/tr.tsx:修正裁剪场景下的真实colIndex;渲染两侧占位单元格验证方式说明
受限于本地实验环境(独立 vite 脚手架 demo 与仓库源码存在 Vue 模块实例不一致的问题,尝试两种修复方案均未解决),改用仓库自带的
vitest+@vue/test-utils环境(与src/table/__tests__/*.test.jsx同一套配置)做 A/B 对照验证,验证强度不降低:data-virtual-x-placeholder占位单元格数为 0,不满足契约),1 个通过(对照组)src/table模块 293 个既有用例全部通过;复用同一 hook 的src/list、src/tree、src/tree-select模块 248 个用例全部通过eslint0 warning/error;tsc全量类型检查通过未验证项
scroll.colThreshold的 API 文档尚未补充(本 PR 未改动site文档目录,如需要请仓库维护者在合入前后补充)本 PR 由 AI 辅助分析与实现,请仓库维护者审阅后决定是否合并。