【翻译】支持十亿行数据的虚拟滚动 ——HighTable 的五大核心技术

0 阅读14分钟

面向数十亿行数据的虚拟滚动:来自 HighTable 的技术实践

文章头图

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 DanielHyperparam 创建,我也有机会在过去一年参与其开发。

这篇博客由 Hyperparam 赞助。感谢他们的支持,也感谢他们推动我去解决“在浏览器中渲染数十亿行”这个非常有趣的问题。

Demo

可以先试试 hightable demo

HighTable 也被用于 Parquet viewersource.coopHyperparam

滚动基础

在进入具体技术之前,我们先用标准 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 聚焦左侧方框,再用方向键 ⏶ 与 ⏷ 滚动。否则也可以用鼠标滚轮、拖动滚动条,或在触摸屏上滑动。

我们先约定一些后续会用到的定义与公式:

  1. 在本文中,我们假设 viewport.clientHeight(可见区域高度)是常量。在 hightable 里,我们会测量它并响应尺寸变化。
  2. viewport.scrollHeight(可滚动内容总高度)等于 table.clientHeight。两者都等于“行数 * 行高”:
const rowHeight = 33 // 像素
const numRows = data.numRows // 表格总行数
const height = numRows * rowHeight

在本文中,我们假设行高与行数都是常量。hightable 会响应 data.numRows(即保存表格数据的数据帧中的行数)变化,比如筛选后行数变化;但我们假设行高固定(可见 issue #395,用于支持可变行高)。

  1. viewport.scrollTop 是“已滚动后表格顶部”与“viewport 顶部”之间的像素距离。最小值 0px 表示看到表格顶部;最大值 viewport.scrollHeight - viewport.clientHeight 表示到达表格底部。
  2. 可见像素区间可由 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 = 100100.2399.89。你无法精准预测,只能保证误差在 1 像素内。

scrollTop 甚至可能超出预期范围(例如为负,或大于 scrollHeight - clientHeight)。为了规避浏览器特定的过滚动效应,hightable 在处理 scroll 事件时总会把 scrollTop 钳制在预期区间,并使用 overflow-y: clipcliphidden 的差异是: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。

首个可见行由 globalAnchorlocalOffset 共同计算:

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> 提供“跳转到某行”时,也会遇到同样问题。表格应能程序化滚动到目标行,并聚焦目标列中的单元格,而不需要上层关心局部/全局滚动模式以及横向滚动位置。

流程如下:

  1. 计算下一个状态(globalAnchorlocalOffset),让目标单元格所在行可见。
  2. globalAnchor 发生变化,则程序化滚动到新的 scrollTop。
  3. 滚动完成后渲染表格切片,让目标单元格进入 DOM。
  4. 必要时通过 cell.scrollIntoView({inline: 'nearest'}) 进行横向滚动。
  5. 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_enterm_zh说明
virtual scrolling虚拟滚动仅渲染可见区域的滚动渲染技术
lazy loading懒加载按需加载可见数据,避免一次性加载全部
viewport视口(viewport)固定尺寸的可滚动可见区域
table slice表格切片仅渲染当前可见行区间
downscale factor下采样因子将滚动条像素映射到完整表格像素
random access随机访问可直接跳转到任意行/单元格
data frame数据帧(data frame)承载表格数据并负责按需读取的数据结构