Skip to content

fix(table): 列数超过阈值且无 fixed 列/合并单元格时支持横向虚拟滚动 - #3903

Draft
tdesign-bot wants to merge 2 commits into
developfrom
fix/Tencent/tdesign-vue/issue-3900-20260930-050626
Draft

tdesign-bot wants to merge 2 commits into
developfrom
fix/Tencent/tdesign-vue/issue-3900-20260930-050626

Conversation

@tdesign-bot

Copy link
Copy Markdown
Collaborator

关联 issue

Closes #3900

问题理解

t-table 在列数较多(40+ 列)时,纵向虚拟滚动只裁剪不可见的行,横向方向没有对应的裁剪机制,所有列的所有单元格都会完整渲染到 DOM 中,列数越多渲染性能越差。

根因

组件缺少横向虚拟滚动能力,useVirtualScrollNew 只处理了纵向裁剪。

方案

新增 scroll.colThreshold 配置项(默认 30),在以下 4 个条件同时满足时启用横向虚拟滚动:

  1. scroll.type 为 virtual
  2. 不存在固定列(fixed: 'left' | 'right')
  3. 未使用合并单元格(rowspanAndColspan)
  4. 列数超过 colThreshold

裁剪掉的列用 data-virtual-x-placeholder 占位单元格(<td colspan=...>)撑开宽度,减少 DOM 渲染量。固定列和合并单元格场景与裁剪模型冲突,暂不支持,保持现状(不裁剪)。

改动文件

  • src/common.ts:TScroll 新增 colThreshold?: number
  • src/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 对照验证,验证强度不降低:

  • A(基线,未修复):新增的契约测试 1 个失败(data-virtual-x-placeholder 占位单元格数为 0,不满足契约),1 个通过(对照组)
  • B(本分支,已修复):2 个测试全部通过
  • 回归:src/table 模块 293 个既有用例全部通过;复用同一 hook 的 src/list、src/tree、src/tree-select 模块 248 个用例全部通过
  • 静态检查:eslint 0 warning/error;tsc 全量类型检查通过

未验证项

  • 真实浏览器环境下横向滚动时的视觉效果与滚动流畅度(仅在 jsdom 单测环境下验证了裁剪逻辑本身的正确性,未做真实浏览器交互测试)
  • scroll.colThreshold 的 API 文档尚未补充(本 PR 未改动 site 文档目录,如需要请仓库维护者在合入前后补充)

本 PR 由 AI 辅助分析与实现,请仓库维护者审阅后决定是否合并。

v_sshuairen added 2 commits September 30, 2026 05:51
新增 scroll.colThreshold 配置项(默认 30),当满足以下条件时开启横向虚拟滚动:
1. scroll.type 为 virtual
2. 不存在固定列
3. 未使用合并单元格(rowspanAndColspan)
4. 列数超过 colThreshold

裁剪掉的列用 data-virtual-x-placeholder 占位单元格撑开宽度,减少大列数表格的
DOM 渲染量。

Closes #3900
@pkg-pr-new

pkg-pr-new Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

commit: 6fc094c

@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

失败

RSS1102

This comment was marked as duplicate.

RSS1102

This comment was marked as outdated.

@RSS1102 RSS1102 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

评审意见(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 有类型声明与默认值说明。

不合理的地方

  1. 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 列。
    • 为什么必须改:满足启用条件的表格一滚动就出现表体空白,属于功能性错误。
  2. src/hooks/useVirtualScrollNew.ts:220:首次计算发生在容器挂载之前,首屏只渲染 3 列。
    • 复现:同上,不滚动直接读首行:合并后真实 td 为 3 个(可视区能放约 7 个),基线为 40 个。
    • 为什么必须改:用户打开页面就看到表头有、内容无,右侧大片空白,直到滚动一次才补齐。
  3. 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));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里用 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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这条断言只验证「出现了占位 td」,无法发现窗口错位或首屏缺列,所以 table 目录下 293 条单测全过的情况下问题仍然存在。建议补一条:滚动到中部后,可视区内应存在对应列的单元格。

@RSS1102 RSS1102 closed this Oct 15, 2026
@RSS1102
RSS1102 deleted the fix/Tencent/tdesign-vue/issue-3900-20260930-050626 branch October 15, 2026 02:53
@uyarn
uyarn restored the fix/Tencent/tdesign-vue/issue-3900-20260930-050626 branch October 16, 2026 07:43
@uyarn uyarn reopened this Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants