Skip to main content

万行数据表格的设计

万行表格不是「加个虚拟滚动」就完事。列宽拖拽、排序筛选、列固定、导出、可访问性,每一项都会和虚拟化打架。

一、先定住性能底线​

做之前先立基线,否则「卡不卡」永远说不清。至少三个:

  • 滚动与筛选时的帧率 —— 是否掉帧,掉到多少;
  • 从操作到结果可见的耗时 —— 点排序之后多久能看到新顺序;
  • 内存占用 —— 数据全放客户端会持续涨,长时间开着页面尤其明显。

有了基线才能判断某个功能是不是在透支性能。一个很实用的经验:拖慢表格的通常不是一个大功能,而是五六个「看起来不重」的小特性叠加——每行加一个 tooltip、加一个状态徽标、加一个可选中的复选框,单看都不贵,加起来就超预算了。

二、列宽、固定列与虚拟化的冲突​

虚拟化的前提是「只有可见行存在」。而下面这些功能全都要遍历或持有全部行:

排序与筛选。 客户端做意味着要遍历全部一万行。交给服务端是更好的选择:前端只传条件,服务端返回当前页。代价是每次操作一次请求,需要处理好竞态与加载态。若确实要在客户端做,把计算挪到 Web Worker,别阻塞主线程:

// 主线程:把原始数据交给 Worker,拿到排好序的下标回传
const worker = new Worker('./sort-worker.js')
worker.postMessage({ type: 'sort', rows, field: 'createdAt', order: 'desc' })
worker.onmessage = ({ data }) => render(data.sortedIndexes)
// sort-worker.js:在 Worker 里排序,主线程只负责渲染
self.onmessage = ({ data: { rows, field, order } }) => {
const sorted = [...rows].sort((a, b) => order === 'desc' ? b[field] - a[field] : a[field] - b[field])
self.postMessage({ sortedIndexes: sorted.map(r => r.id) })
}

多选。 不要存行对象,只存 id 集合。存对象会让内存占用翻倍,还会在数据刷新后拿到过期副本。「全选」要用「全选当前筛选条件」的语义,而不是真的展开一万个 id。

// 选中态是一个 Set,容量与行内容无关;渲染时再按需取
const selected = new Set()
function toggle(id) { selected.has(id) ? selected.delete(id) : selected.add(id) }
// 全选 = 对「当前筛选结果」的 id 集合求并,而不是展开全部数据
function selectAll(visibleIds) { visibleIds.forEach(id => selected.add(id)) }

分组与合并单元格。 这两项与虚拟滚动天然冲突——合并单元格依赖相邻行的存在,而虚拟化恰恰让相邻行不存在。要么限定在小数据量下使用,要么在开启这些功能时关掉虚拟化。

列宽记忆与拖拽。 它本身不贵,但拖拽过程中若触发整表重排就会掉帧。做法是拖拽时只改被拖的那一列,松手后再统一应用。

导出。 一万行在前端生成文件会长时间占用主线程。走服务端异步任务:前端发起任务、轮询进度、完成后下载。顺带解决了刷新即中断的问题(详见《前端导出 Excel 与大数据量》)。

// 排序筛选交给服务端:前端只描述条件
const { rows, total } = await fetchRows({
page: 3,
sort: { field: 'createdAt', order: 'desc' },
filters: { status: ['paid', 'shipped'] },
})
表格功能和虚拟化的冲突常见解法排序 / 筛选冲突小客户端可解,数据量大时移交服务端多选与跨页全选冲突中把选中状态提到数据层,不依赖 DOM分组 / 合并单元格冲突大分组头单独渲染,或改分页列宽拖拽冲突小列状态独立于行虚拟化整表导出冲突大走服务端导出,别在浏览器拼全量先看这张表再动手:凡是「冲突大」的功能,都要在方案设计阶段就决定放服务端还是放弃虚拟化。
图:虚拟化与表格功能的冲突强度——越晚发现,返工越贵

三、虚拟化后的可访问性​

虚拟列表的 DOM 里没有全部内容,这会让读屏器误以为表格只有十几行。补救办法:

  • 让容器带表格语义(role="grid",行带 role="row"),再加 aria-rowcount(总行数)与每行的 aria-rowindex(当前行序号);属性单独加在普通 div 上不会生效;
  • 表头要用真正的表格语义(<table> + <th> + scope),不要用一堆 div 拼;
  • 打印要另做视图 —— 打印天然是全量场景,虚拟视图打出来只有一屏,这是用户投诉的高发点。

四、数据怎么取与怎么放​

两条路线:

服务端分页 + 服务端筛选排序 —— 万行场景的默认选择。首屏快、内存稳,代价是每次操作有网络往返。

客户端全量 —— 只在数据量小(几百到一两千行)且需要离线、需要极快二次筛选时才合理。超过这个量级,首屏与内存的代价会超过它带来的便利。

一个折中且常见的做法:分页取数 + 已加载部分虚拟渲染。既控制了单次传输量,滚动体验又接近全量。

五、什么时候别做前端表格​

明确该用分页的几种情况:

  • 需要完整打印或导出全量;
  • 用户需要浏览器原生查找(Ctrl+F)定位内容;
  • 表格内容需要被搜索引擎收录;
  • 数据量其实只有几百行——这时候虚拟化带来的复杂度大于收益。

分页不是「落后方案」,它在深链、分享定位、可查找性上都优于虚拟列表。选哪个取决于用户怎么用这份数据,而不是哪个听起来更高级。

内存是万行表格最容易失控的一项,值得单独量:

// Chrome 下可用(performance.memory 非标准)
const mb = performance.memory.usedJSHeapSize / 1048576
console.log('当前堆占用', mb.toFixed(0), 'MB')

// 十列 × 五万行的原始对象轻松上百 MB;只保留渲染需要的字段
const rows = raw.map((r) => ({ id: r.id, name: r.name, amount: r.amount }))

六、上线前的检查清单​

动手前把下面几条过一遍,能省掉大部分返工:

  • 先定数据获取方式 —— 服务端分页还是客户端全量,这一条决定了后面所有功能的实现方式,中途改代价最大;
  • 列数要收敛 —— 二十列的表格再怎么优化也难流畅,默认隐藏次要列、让用户自己选,往往比优化渲染更有效;
  • 行内交互要克制 —— 每行一个下拉菜单、一个 tooltip、一个可编辑单元格,都会在滚动时反复创建销毁;能移到详情里就移走;
  • 固定列与表头用 position: sticky —— 比用两套 DOM 或一个 canvas 模拟要简单得多,且能保持表格语义;
  • 空态、加载态、错误态要单独设计 —— 万行表格的加载失败比小表格更难受,用户等了几秒却只看到一个空白区;
  • 给用户一个「到底有多少条」的明确反馈 —— 虚拟渲染容易让人误以为已经看完了全部数据。

七、加个虚拟滚动远远不够​

虚拟化只解决渲染数量,数据获取与功能特性才是主要矛盾。

全量拉到前端筛选也不见得更快——数据量上万后,首屏与内存代价远超一次请求往返。

虚拟表格还会影响可访问性:DOM 不完整会影响读屏与页内查找,需要属性补偿,打印要另做视图。

多选也不是把选中的行存起来就完事——只存 id 集合,「全选」要用条件语义而不是展开成全部 id。

万行表格的难点不在渲染一万行,而在这一万行还要支持排序、多选、分组、导出之后依然不卡。

跳过离屏渲染最直接的一招是 content-visibility,注意它对可搜索性有副作用。