当表格需要展示十万级数据时,最明显的问题是 DOM 数量。一次创建数十万个节点会占用大量内存,样式计算与布局也会阻塞主线程。于是虚拟列表成为标准答案:只渲染视口附近的行。
但在真实 BI 页面里,虚拟列表只解决了最后一段。数据下载、解析、排序、聚合、格式化和图表转换都可能先把主线程占满。页面没有生成很多 DOM,用户仍然会感觉冻结。
优化之前必须把完整管线拆开。
图 1:虚拟列表只解决最后一段。数据规模、解析、计算、布局和绘制都需要独立预算。
建立数据预算
第一步不是写代码,而是确认产品真的需要在浏览器中持有十万条明细。用户要完成的是浏览、搜索、比较还是导出?多数场景只需要聚合结果或当前窗口数据,完整数据应留在服务端。
我们为不同组件定义数据预算:图表有最大点数,表格有直接渲染、虚拟渲染和服务端分页的分界,导出走独立任务。超过预算时,不静默抽样,而是告诉用户当前展示范围和获得完整结果的方式。
预算需要按项目基线测量,下面是一份可落地的起始配置,而不是通用真理:
| 场景 | 默认策略 | 切换条件 |
|---|---|---|
| 表格浏览 | 客户端分页 | 返回数据超过 5,000 行改服务端分页 |
| 固定行表格 | 虚拟列表 | 可见 DOM 保持在 100 行以内 |
| 时间序列 | 按像素宽度聚合 | 点数超过绘图区宽度的 2-4 倍 |
| 导出 | 服务端异步任务 | 不在页面内生成十万行文件 |
阈值要和设备分布、字段宽度及交互一起压测。配置的价值是让运行时知道何时拒绝危险路径,不是证明浏览器最多能塞多少数据。
预算迫使产品和技术共同回答需求。没有边界的“支持大数据”,最终会变成浏览器承担不适合它的工作。
测量四个阶段
一份大数据从网络到屏幕,至少经过获取、计算、布局和绘制。我们分别记录:响应体大小与下载时间;JSON 解析、字段转换、排序聚合耗时;DOM 创建与布局耗时;Canvas 或 SVG 绘制耗时。
浏览器 Performance 面板可以看到长任务,但还需要业务标记把长任务对应到具体阶段。否则一次 800 毫秒阻塞只会显示为脚本执行,无法知道是排序还是图表配置转换。
内存也要测量。大数组复制、为每行创建新对象、保留旧筛选结果,都会让堆持续增长。一次操作完成后内存没有回落,往往说明引用仍被组件、缓存或监听器持有。
虚拟列表的真实复杂度
固定行高的虚拟列表相对简单:根据滚动位置计算起止索引,容器保留总高度,实际节点通过偏移放到正确位置。渲染节点数量从十万降到几十,布局成本显著下降。
function visibleRange(scrollTop: number, viewportHeight: number, rowHeight: number, total: number) {
const overscan = 8;
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
const end = Math.min(total, Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan);
return { start, end, offsetY: start * rowHeight, totalHeight: total * rowHeight };
}overscan 避免快速滚动时露出空白,但不是越大越好。每行包含复杂单元格时,多渲染几十行就可能抵消虚拟化收益。滚动容器、焦点和行 key 也必须稳定,否则节点复用会把输入状态带到错误行。
可变行高会复杂得多。文本换行、展开详情和动态内容都会改变高度,需要测量并维护位置索引。高度估算不准会造成滚动跳动,频繁测量又会触发布局抖动。
我们尽量在大数据模式下约束行高和内容展示,复杂详情通过独立区域打开。不是所有桌面表格能力都应该与虚拟化同时存在。
虚拟化还会影响键盘导航、搜索、复制和可访问性。不存在于 DOM 的行无法被浏览器原生查找,焦点在节点回收时可能丢失。这些不是实现细节,而是功能契约的一部分。
把计算移出主线程
排序、聚合和复杂转换可以放进 Web Worker,让主线程保持交互响应。Worker 并不会让计算本身变快,它只是改变计算位置,还增加了数据传输成本。
如果把巨大对象频繁 postMessage,结构化克隆同样昂贵。我们减少传输字段,使用更紧凑的数据结构,在适合时使用 Transferable 转移二进制缓冲区。任务消息包含版本,用户改变条件后,旧任务结果会被丢弃。
// main thread
const values = new Float64Array(rows.map((row) => row.value));
worker.postMessage(
{ type: "aggregate", version, values: values.buffer },
[values.buffer],
);
// worker
self.onmessage = ({ data }) => {
const values = new Float64Array(data.values);
const result = aggregateByBucket(values);
self.postMessage({ version: data.version, result: result.buffer }, [result.buffer]);
};Buffer 转移后主线程的 values 会失效,这是零拷贝的代价。只有数据所有权明确时才使用 Transferable;若主线程仍要读取同一数组,就需要重新设计边界,而不是偷偷复制回来。
Worker 逻辑保持纯粹:接收输入,执行确定转换,返回结果。不访问 UI 状态,也不承担无法恢复的副作用。这样可以单独测试,并在不支持 Worker 的环境中保留降级实现。
避免无意义的数据复制
前端代码为了“不可变”经常创建新数组和新对象。在小数据里成本可以忽略,十万行下,一次展开、映射和排序就可能制造多个完整副本。
我们区分领域状态和计算缓冲。对需要触发框架更新的边界保持不可变,对内部算法则允许受控地复用内存。排序前确认是否可以使用索引数组,而不是移动完整对象;格式化尽量发生在可见行,而不是预先处理全部数据。
缓存也不能无上限。查询结果按内存成本与访问频率淘汰,大结果的缓存时间更短。缓存命中提升速度,缓存失控则会把页面变成隐形数据仓库。
图表不等于全部数据点
屏幕宽度只有一两千像素,绘制十万个时间点无法提供十万个可区分的信息。对趋势图,服务端或 Worker 可以按像素范围聚合;对异常数据,采样必须保留峰值和转折,不能简单每隔 N 个取一点。
一个保守策略是在每个像素桶保留首值、末值、最小值和最大值。它不如专门的 LTTB 算法紧凑,但能保留突发峰谷,也容易解释。用户放大时间范围后再查询更细粒度数据,避免一次把所有原始点送进浏览器。
SVG 适合需要独立节点和交互的中小规模图形,Canvas 更适合大量绘制,但交互命中、可访问性和文本布局需要额外处理。选择渲染技术要根据图形数量与交互需求,不是根据库的流行程度。
动画在大数据更新中通常价值很低。过渡会同时保留前后状态,增加计算与绘制成本。对监控和分析工具,稳定、快速地呈现新结果比华丽过渡更重要。
更新应该与变化规模匹配
很多组件每次数据变化都会重新转换完整配置并重绘。即使只更新一个单元格,也触发全部行重新计算。优化需要让更新粒度接近实际变化。
表格可以用稳定行标识定位变化,图表可以根据系列与数据版本判断是否复用。React 或 Vue 的响应式机制只能减少框架层更新,无法替代底层图表库的增量策略。
但差量更新会增加状态复杂度。必须定义何时安全增量,何时完整重建,并用一致结果测试两条路径。追求每次都最小更新,可能得到无法证明正确的缓存系统。
把性能变成运行时能力
最终我们将这些经验沉淀为组件契约和监控:数据超过阈值时自动切换策略;长任务被记录并关联组件;页面退出时验证 Worker、监听器与图表实例被释放;关键数据量拥有固定基准测试。
十万级渲染不是某个神奇算法解决的问题。它要求从产品需求开始限制规模,在网络、计算、布局和绘制之间分配工作,再对每层建立可观察的边界。
基准测试必须使用真实分布,不只使用随机数。我们的数据集包含长字符串、高基数字段、大量空值、倾斜分组和用户真实排序操作。每个方案记录解析、传输、计算、首次可见、滚动帧率和内存峰值。验收不是“十万条能打开”,而是规定设备与浏览器上,首屏在预算内出现,交互不产生超过阈值的长任务,取消旧计算后内存能回落——真实分布通常比行数更决定性能。
浏览器可以处理很多数据,但用户需要的从来不是“数据已经进入内存”。用户需要的是在合理时间内看见答案,并且界面始终能够响应下一步操作。
两条相邻的实践在这篇里没有展开:让更新粒度匹配变化规模,属于差量更新的收益与代价;把计算真正搬离主线程的传输与取消细节,属于把十万行计算移进 Web Worker。数据管线的每一段,都在这两篇里找到了它该有的边界。