面向数十亿行数据的虚拟滚动:来自 HighTable 的技术实践
- 原文链接:rednegra.net/blog/202602…
- 原文作者:Sylvain Lesage
TL;DR:本文介绍了 <HighTable> 中与纵向滚动相关的五种技术。<HighTable> 是一个 React 组件,可以在保持良好性能与可访问性的前提下,在表格中展示数十亿行数据。
这是一篇长文,这也反映了“在表格里渲染数十亿行”这件事本身的复杂性,以及我们为构建这个 React 组件投入的工作量。
目录:
- 引言
- Demo
- 滚动基础
- 技术 1:懒加载
- 技术 2:表格切片
- 技术 3:无限像素
- 技术 4:像素级精确滚动
- 技术 5:两步式随机访问
- 总结
引言
在表格里展示数据,是你在 HTML 入门课程里最早会遇到的练习之一。
<table>
<thead>
<tr><th>Name</th><th>Age</th></tr>
</thead>
<tbody>
<tr><td>Alice</td><td>64</td></tr>
<tr><td>Bob</td><td>37</td></tr>
</tbody>
</table>
但在数据科学里常见的情况是:简单规模下可行的方法,一旦规模上去就会失效。
这篇文章会展示我们在 <HighTable> React 组件里用来解决纵向滚动挑战的五种技术,从而支撑数十亿行数据。
这个组件还提供列能力(排序、隐藏、调整宽度)、行能力(选择)、单元格能力(键盘导航、指针交互、自定义渲染)。如果你想了解更多,欢迎提问并查看代码。
<HighTable> 由 hyparam/hightable 开发。它最初由 Kenny Daniel 为 Hyperparam 创建,我也有机会在过去一年参与其开发。
这篇博客由 Hyperparam 赞助。感谢他们的支持,也感谢他们推动我去解决“在浏览器中渲染数十亿行”这个非常有趣的问题。
Demo
可以先试试 hightable demo:
HighTable 也被用于 Parquet viewer、source.coop 与 Hyperparam:
滚动基础
在进入具体技术之前,我们先用标准 HTML 表格说明滚动是如何工作的。
HTML 结构由一个可滚动容器(我们称为 viewport)和其内部的 table 元素组成:
<div class="viewport" style="overflow-y: auto;">
<table class="table">
...
</table>
</div>
在这个结构中,viewport 是一个固定高度的 div。CSS 属性 overflow-y: auto 会在 table 高度超过 viewport 时启用纵向滚动条。
下面这个小组件中,滚动左侧方框,你会看到右侧方框如何模拟滚动效果。
如果你使用键盘,可以先用 Tab 聚焦左侧方框,再用方向键 ⏶ 与 ⏷ 滚动。否则也可以用鼠标滚轮、拖动滚动条,或在触摸屏上滑动。
我们先约定一些后续会用到的定义与公式:
- 在本文中,我们假设
viewport.clientHeight(可见区域高度)是常量。在 hightable 里,我们会测量它并响应尺寸变化。 viewport.scrollHeight(可滚动内容总高度)等于table.clientHeight。两者都等于“行数 * 行高”:
const rowHeight = 33 // 像素
const numRows = data.numRows // 表格总行数
const height = numRows * rowHeight
在本文中,我们假设行高与行数都是常量。hightable 会响应 data.numRows(即保存表格数据的数据帧中的行数)变化,比如筛选后行数变化;但我们假设行高固定(可见 issue #395,用于支持可变行高)。
viewport.scrollTop是“已滚动后表格顶部”与“viewport 顶部”之间的像素距离。最小值0px表示看到表格顶部;最大值viewport.scrollHeight - viewport.clientHeight表示到达表格底部。- 可见像素区间可由 viewport 的滚动位置计算:
const firstVisiblePixel = viewport.scrollTop
const lastVisiblePixel = viewport.scrollTop + viewport.clientHeight
// firstVisiblePixel 为包含端点,lastVisiblePixel 为不包含端点
有了这些基础,我们来看看如何处理大规模数据集。
技术 1:懒加载
处理大数据集时的第一个挑战是:它装不进浏览器内存。好消息是:你也不需要在同一时刻看所有行。所以我们不会在启动时加载整个数据文件,而是只加载当前可见单元格。
注意:数据懒加载不会改变表格的 HTML 结构。
在表格中,只有可见单元格会被加载。滚动时,新进入可见区的单元格会被请求并在后台加载,加载完成后再渲染。
为此,我们先计算可见行区间,并只加载它们:
const rowStart = Math.floor(firstVisiblePixel / rowHeight)
const rowEnd = Math.ceil(lastVisiblePixel / rowHeight)
// rowStart 为包含端点,rowEnd 为不包含端点
在 hightable 中,数据加载逻辑由 data frame 处理,并通过 data prop 传入 React 组件:
<HighTable data={data} />
data frame 是一个对象,定义了如何按需加载(即 fetch + cache)数据,以及如何拿到已加载数据用于渲染。可参考 types.ts 中的 DataFrame TypeScript 定义。
下面是一个简化版 DataFrame:它为单列生成随机数据,通过延迟模拟网络请求,并把值持久保存在内存中。
const cache = new Map()
const eventTarget = new EventTarget()
const numRows = 1_000_000
const data = {
numRows,
eventTarget,
// 同步返回缓存值(如果有)
getCell({ row }) {
return cache.get(row);
},
// 加载给定行区间内缺失的值,并写入缓存
async fetch({ rowStart, rowEnd }) {
// 模拟网络延迟
await new Promise((resolve) => setTimeout(resolve, 100));
for (let row = rowStart; row < rowEnd; row++) {
// 跳过已缓存行
if (cache.has(row)) continue;
// 为单元格生成随机值并缓存
cache.set(row, {value: Math.random()});
}
// 触发事件,通知 <HighTable> 重新渲染可见单元格
eventTarget.dispatchEvent(new Event('resolve'));
},
}
data frame 通过异步 data.fetch() 从数据源加载数据。它必须缓存结果,并在有新数据可用时触发 resolve 事件。数据源可以是任何东西:本例中是随机生成;也可以来自本地文件、内存数组、远程文件(HTTP range 请求)或 REST API 等。
data frame 还必须提供同步 data.getCell():返回给定单元格的缓存数据,若尚未加载则返回 undefined。
每次滚动时,表格都会渲染:会对可见行调用 data.getCell(),必要时调用 data.fetch() 在后台加载(若数据已缓存,应由 data frame 负责快速返回)。每次抓取到新数据并通过 resolve 事件上报后,表格会再次渲染。
你可以在 hyparquet demo 中看到一个更完整的 data frame 示例:它通过 HTTP range 请求加载远程 Parquet 文件。
data frame 的结构并不偏向“按行”或“按列”,而是允许按单元格加载与访问。当前 hightable 实现里我们按整行加载,但后续也可以通过计算可见列来做列级懒加载;如果你感兴趣,欢迎加入相关讨论。
懒加载的影响
假设有 100 亿行、每行 100 字节,总数据量是 1TB。全部加载进内存不可能;而通过懒加载,我们只加载 3KB 的可见部分(一次约 30 行),依然能保持良好性能。
数据懒加载是浏览器处理大数据集的第一步。下一步是避免一次渲染过多 HTML 元素。
技术 2:表格切片
在软件工程中做优化,第一步通常是删掉“无效计算”。在这里,如果表格有一百万行、但一次只能看到 30 行,那为什么要渲染一百万个 <tr>?作为参考,Chrome 建议为获得最佳响应性,创建或更新的 HTML 元素数量应低于 300。
在 <HighTable> 中,只渲染表格的可见切片;其他行元素根本不存在。
为实现这一点,HTML 结构要调整:在 viewport 与 table 之间增加一个中间 div,我们称之为 canvas:
<div class="viewport" style="overflow-y: auto;">
<div class="canvas" style="position: relative; height: 30000px;">
<table class="table" style="position: absolute; top: 3000px;">
<!-- 表格仅渲染可见行 -->
...
</table>
</div>
</div>
后续技术 3、4、5 的结构都会延续这一套。
这里的 canvas div 与
<canvas>HTML 元素毫无关系。如果这个命名让你困惑,我也欢迎更好的命名建议。
canvas 的高度会设置成足以容纳所有行:
canvas.style.height = `${data.numRows * rowHeight}px`
它会把 viewport 的滚动条尺寸设置为预期大小。正如滚动基础里所示,viewport.scrollHeight 等于 canvas.clientHeight。
canvas 的作用是为表格切片提供绝对定位参考系。
表格切片内部结构如下:
<table>
<tbody>
<!-- 第 0 到 99 行不渲染 -->
<!-- 可见行 -->
<tr>...row 100...</tr>
<tr>...row 101...</tr>
...
<tr>...row 119...</tr>
<!-- 第 120 到 999 行不渲染 -->
</tbody>
</table>
假设数据总计 1000 行、每行 30px、viewport 高度 600px(约可见 20 行)。当用户下滚 3000px 时,<HighTable> 在真实 <table> 里只渲染第 100 到 119 行。
上述 HTML 是简化版。hightable 里我们会渲染表头,并在可见行前后增加一些 padding 行,以改善滚动体验。
表格顶部位置需要调整,以对齐其在“完整表格”中的位置(接近 viewport.scrollTop,但会减去首个可见行顶部的隐藏像素)。公式如下:
table.style.top = `${
viewport.scrollTop - (viewport.scrollTop % rowHeight)
}px`;
这些计算会在每次 scroll 事件(以及其他变化,如 viewport 高度变化、总行数变化)时执行。计算完成后,表格切片按新的可见行重渲染,表格 top 会更新,data frame 也会按需加载新的可见单元格。
有个细节值得提:sticky header。在
<HighTable>中,列名表头是<table>的<thead>一部分,而不是独立元素。这对可访问性更友好(读屏器更容易识别每个数据单元格对应的表头单元格),对列宽调整也更方便(表头与数据列由浏览器自动对齐)。借助position: sticky,滚动时表头仍固定在 viewport 顶部。我们在计算首个可见行时会考虑这一点。
注意,表格切片并不只适用于纵向滚动;同样思路也可用于横向滚动(只渲染可见列)。其优先级相对低一些,因为通常列数远少于行数。若你对“虚拟列”感兴趣,也欢迎参与讨论。
表格切片的影响
假设有 100 亿行、一次可见 30 行,那么我们渲染的是 30 个 HTML 元素,而不是 100 亿个。这让我们在任意行数下都能保持良好性能,因为渲染元素数量是常量。
到这里为止都比较常规。后面的技术会更贴近 hightable,专门解决“数十亿行”带来的新问题。
技术 3:无限像素
技术 2 在大多数时候都有效,直到它失效……正如 Eric Meyer 在《Infinite Pixels》里解释的,HTML 元素高度有上限,而且各浏览器不同。最差情况是 Firefox:大约 1700 万像素。由于 canvas 高度随行数增长,若行高是 33px(hightable 默认值),最多只能渲染约 50 万行。
hightable 的应对方式是:给 canvas 设最大高度,并在超过阈值后降低滚动条分辨率。 在 hightable 中,这个阈值是 800 万像素。
具体来说,超过阈值后,“滚动条移动 1 像素”会对应“完整表格中的多个像素”。下采样因子就是“完整表格理论高度”与“canvas 最大高度”的比值。靠这个因子,无论表格多大,滚动条走到一半都会到达完整表格中部。
低于阈值时,下采样因子是 1,行为与之前完全一致:滚动 1 像素就对应完整表格的 1 像素。
下采样因子计算如下:
const fullTableHeight = data.numRows * rowHeight
const maxCanvasHeight = 8_000_000
if (fullTableHeight <= maxCanvasHeight) {
downscaleFactor = 1
} else {
downscaleFactor =
(fullTableHeight - viewport.clientHeight) /
(maxCanvasHeight - viewport.clientHeight)
}
这时首个可见行可按下式计算:
firstVisibleRow = Math.floor(
(viewport.scrollTop * downscaleFactor) / rowHeight
)
table 顶部位置设置为与 viewport 顶部对齐首个可见行:
table.style.top = `${viewport.scrollTop}px`;
这样用户就能在数十亿行范围内导航整张表。
但它也有缺点:原生滚动条精度受限于 1 个物理像素。在“高分屏”上,视觉精度可细到 1 个 CSS 像素的分数(1 / devicePixelRatio),但为简化我们仍按 1 像素讨论。
补充个趣闻:通过编程设置滚动值并不总是可预测。它依赖 device pixel ratio,而这个值又受页面缩放等因素影响。比如
element.scrollTo({top: 100})可能得到scrollTop = 100、100.23或99.89。你无法精准预测,只能保证误差在 1 像素内。
scrollTop甚至可能超出预期范围(例如为负,或大于scrollHeight - clientHeight)。为了规避浏览器特定的过滚动效应,hightable 在处理 scroll 事件时总会把scrollTop钳制在预期区间,并使用overflow-y: clip。clip与hidden的差异是:clip仍会显示 sticky header,具体原因我也不完全确定。
因此,当下采样因子很大(比如 2,189,781,021)时,最小滚动步长(1px)会映射到完整表格中的 2,189,781,021 像素。若行高 30px,就意味着最小滚动步长约等于 72,992,701 行。于是会出现可达行空洞:
- 当
viewport.scrollTop = 0,可见行是 0 到 5 - 当
viewport.scrollTop = 1,可见行是 72,992,700 到 72,992,705 - 当
viewport.scrollTop = 2,可见行是 145,985,401 到 145,985,406 - 依此类推……
例如,行 6 到 10 将无法导航到。你无法设置 viewport.scrollTop = 0.00000000274 去到行 6 到 10,因为浏览器会把滚动位置四舍五入到最近整数像素。
无限像素的影响
若假设 100 亿行,“无限像素”技术可让你覆盖整个行跨度进行全局导航。行数本身没有上限,因为我们总能增大下采样因子,让内容适配到 canvas 最大高度。
但受滚动条精度限制,若行高 30px、canvas 为 800 万像素,每滚动 1px 会跨过 1250 行。这意味着每 1250 行里只有 1 行(及其邻近行)可达。
所以,无限像素提供了“跨数十亿行的全局导航”,但不提供细粒度滚动,一些行仍不可达。这个问题由技术 4 解决。
技术 4:像素级精确滚动
上一种技术可以全局滚动,但用户无法做局部滚动:任何滚动手势都会跳过大量不可达行。
为了解决这一点,我们实现了两种滚动模式:局部滚动与全局滚动。局部滚动是让表格切片按像素连续移动(甚至比“按行滚动”更细);全局滚动是按滚动条位置跳到目标区域。
这套逻辑需要维护三元状态:{ scrollTop, globalAnchor, localOffset }
- 记录上一次 viewport 的 scrollTop,用于在每个 scroll 事件中计算滚动增量。
globalAnchor是最近一次“全局滚动”对应的 viewport scrollTop。它只在全局滚动时更新,局部滚动时不更新。localOffset是施加在globalAnchor上的局部偏移量,用于得到当前实际滚动位置。它会在每次局部滚动时更新,在全局滚动时重置为 0。
首个可见行由 globalAnchor 与 localOffset 共同计算:
const firstVisibleRow = Math.floor((
state.globalAnchor * downscaleFactor + state.localOffset
) / rowHeight)
此时 table 的绝对定位变为:
table.style.top = `${viewport.scrollTop + state.localOffset}px`;
每次 scroll 事件里,我们都会计算滚动幅度(新 scrollTop 减去旧 scrollTop),并据此决定:
- 若滚动幅度很大(典型是拖拽滚动条),执行全局滚动:跳到新的全局位置(技术 3)。
- 若滚动幅度较小(例如鼠标滚轮),执行局部滚动:保持
globalAnchor不变(即不再与真实scrollTop同步),通过调整localOffset让视觉上表现为局部移动(比如向下 3 行)。
代码化后(简化伪代码)大致如下:
const state = getState()
const delta = viewport.scrollTop - state.scrollTop
if (Math.abs(delta) > localThreshold) {
// 全局滚动
state.localOffset = 0
state.globalAnchor = viewport.scrollTop
} else {
// 局部滚动
state.localOffset += delta
}
setState(state)
这样,用户既可以在当前位置附近细粒度移动,也可以跳转到数据任意位置。
像素级精确滚动的影响
假设有 100 亿行,双模式滚动允许用户通过原生滚动条访问完整表格中的任意像素:鼠标滚轮用于局部滚动,拖拽滚动条用于全局滚动。
当完整表格高度小于“最大 canvas 高度(hightable 中为 800 万像素)的平方”时,该方案成立,也就是约 64 万亿像素。因此在行高 30px 时,到 2 万亿行都能保证 1px 级保真度。
超过该上限后,最小步长会大于 1px,但直到 64 万亿行仍可保证每一行都可达。再往上才会出现不可达行。
最后一个挑战是:如何在不关心“局部/全局模式”的前提下,以编程方式移动到任意单元格(即随机访问表格任意区域),无论是键盘导航还是“跳转到某行”输入。这个问题需要将纵向与横向滚动解耦,下一节会讲。
技术 5:两步式随机访问
hightable 的一个需求是支持键盘导航(例如按 ↓ 到下一行)。幸运的是,Web Accessibility Initiative(WAI)通过 Grid Pattern 与 Data Grid Examples 提供了指导。我们采用 roving tabindex 处理焦点,支持预期中的全部键盘交互。
浏览器在调用
cell.focus()时有一个有用默认行为:自动滚动到该单元格并聚焦。但 hightable 没采用这个默认行为,因为它会把单元格放到 viewport 中间,体验不自然。为实现预期行为,我们先通过
cell.scrollIntoView({block: 'nearest', inline: 'nearest'})以最小移动量让下一个行/列进入可见区;然后再用cell.focus({preventScroll: true})设置焦点,避免额外滚动。
不幸的是,WAI 资源里的键盘导航方案是面向“完整表格”的。而在技术 2(表格切片)、3(无限像素)、4(像素级精确滚动)之后,需要更多步骤。尤其是为了支持键盘移动活动单元格,我们会把纵向滚动逻辑与横向滚动逻辑分开。
当用户移动活动单元格时,目标位置可能是表格任意处:↓ 去下一行,Ctrl+↓ 去最后一行。若位移很大,我们可能需要先做纵向滚动,才能让目标单元格进入 DOM。
当应用通过
<HighTable>提供“跳转到某行”时,也会遇到同样问题。表格应能程序化滚动到目标行,并聚焦目标列中的单元格,而不需要上层关心局部/全局滚动模式以及横向滚动位置。
流程如下:
- 计算下一个状态(
globalAnchor与localOffset),让目标单元格所在行可见。 - 若
globalAnchor发生变化,则程序化滚动到新的 scrollTop。 - 滚动完成后渲染表格切片,让目标单元格进入 DOM。
- 必要时通过
cell.scrollIntoView({inline: 'nearest'})进行横向滚动。 - 用
cell.focus({preventScroll: true})把焦点设到新单元格。
注意在第 1 步(计算下一个状态)中,我们遵循 block: nearest 的行为,最小化滚动量:若目标行在当前 viewport 下方,它会在下一次 viewport 中成为最后可见行;若在上方,它会成为第一可见行;若本就可见,则不做纵向滚动。
用于解耦纵横滚动的伪代码如下,需要一个 flag 在程序化纵向滚动期间禁止横向滚动与焦点操作:
/* 单元格导航逻辑内 */
const shouldScroll = state.update()
renderTableSlice()
if (shouldScroll) {
// 设置标记:程序化滚动期间
// 禁止横向滚动与聚焦
setFlag('programmaticScroll')
viewport.scrollTo({top: state.globalAnchor, behavior: 'instant'})
}
/* scroll 事件处理器内 */
if (isFlagSet('programmaticScroll')) {
// 程序化滚动结束后,
// 重新允许横向滚动与聚焦
clearFlag('programmaticScroll')
}
/* 单元格渲染逻辑内 */
if (!isFlagSet('programmaticScroll')) {
// 允许横向滚动与聚焦
cell.scrollIntoView({inline: 'nearest'})
cell.focus({preventScroll: true})
}
程序化滚动时我们使用 behavior: 'instant',确保只收到一次 scroll 事件。若用 behavior: 'smooth',会触发多次 scroll,导致 flag 过早清除,并因中间态 scrollTop 引发与内部状态冲突(可见该 open issue)。
两步式随机访问的影响
借助这项技术,用户可以通过键盘访问表格中的任意随机单元格,即使数据规模达到数十亿行。纵向与横向滚动被解耦:按 → 移到下一列不会触发纵向滚动;按 ↓ 移到下一行也不会干扰横向定位。
总结
不需要伪造滚动条,也不需要把表格画在 canvas 里。我们直接使用 Web 平台。通过这五种建立在原生 HTML 元素之上的技术,hightable 可以让你在浏览器里顺畅浏览远程数据文件中的数十亿行数据。
如果你喜欢这篇文章,欢迎给 GitHub 仓库点个 ⭐!
术语表(本篇命中)
| term_en | term_zh | 说明 |
|---|---|---|
| virtual scrolling | 虚拟滚动 | 仅渲染可见区域的滚动渲染技术 |
| lazy loading | 懒加载 | 按需加载可见数据,避免一次性加载全部 |
| viewport | 视口(viewport) | 固定尺寸的可滚动可见区域 |
| table slice | 表格切片 | 仅渲染当前可见行区间 |
| downscale factor | 下采样因子 | 将滚动条像素映射到完整表格像素 |
| random access | 随机访问 | 可直接跳转到任意行/单元格 |
| data frame | 数据帧(data frame) | 承载表格数据并负责按需读取的数据结构 |