← 📜 Scroll · 滚动怎么动、长什么样、装得下多少 / 虚拟滚动:在浏览器里滚动十亿行 待审核 9 / 9
virtual scroll · table slice · infinite pixels

虚拟滚动:在浏览器里滚动十亿行

前面几页讲滚动「怎么动」与滚动条「长什么样」,都还在样式与交互行为的范畴。这一页换一个问题:当数据本身大到十亿、乃至万亿行——一次性渲染会撑爆 DOM、一次性拉取会撑爆内存、甚至连一根够高的滚动条都画不出来时,浏览器还能不能滚得动?答案是能,而且不靠伪造滚动条、不退回 Canvas 画表格,全用 Web 平台原生能力。本页拆解 HighTable 叠起来的五项技术,并配一个真的能滚的视口——把行数拨到 2 万亿,看 DOM 里始终只有几十个 <tr>

1 · 先把问题摆出来

一张表占多少资源,由三个量决定。设每行高度固定 rowHeight = 32px,总行数 numRows,视口可见高度 clientHeight:总内容高 scrollHeight=numRows×rowHeightscrollHeight = numRows \times rowHeight、当前滚动位置 scrollTop,可见像素区间就是 [scrollTop, scrollTop + clientHeight)。朴素地把每行都渲成一个 <tr>、把每行数据都拉到内存,三个量都随 numRows 线性膨胀:

规模 朴素 <tr> 朴素内存(每行 ≈100 B) 朴素 scrollHeight
1 万行 10,000 ≈ 1 MB 320,000 px
100 万行 1,000,000 ≈ 100 MB 32,000,000 px
10 亿行 1,000,000,000 ≈ 100 GB 32,000,000,000 px
2 万亿行 2,000,000,000,000 ≈ 181 TB 64,000,000,000,000 px

三处都会爆:DOM 元素数(Chrome 建议单页 < ~1500 个)、内存、以及元素高度上限(Firefox 约 1700 万 px,Chrome 约 3350 万 px)。虚拟滚动要逐一拆掉这三堵墙。

2 · 核心 demo:一个真的能滚十亿行的视口

下面这个视口实现了后面要讲的前三项技术:只渲染可见的那几十行(表格切片)、只物化滚到过的行数据(懒加载)、以及当总高超过元素上限时把滚动条按比例缩放(无限像素)。切换行数,盯住读数面板里的「DOM 中 <tr> 数」与「已物化数据」——无论总行数是 1 万还是 2 万亿,它们都几乎不变。

注意「已物化数据」这一项。它统计的是 demo 运行至今真正生成过的行数——只有滚动经过的行才会被取数。哪怕总规模是 2 万亿行(朴素需要约 181 TB),实际占用的也只是你看过的那几百行、几十 KB。这正是懒加载的意义:把「数据有多大」与「要加载多少」彻底解耦。

3 · 技术拆解:五层叠起来

五项技术由浅入深、逐层补足上一层暴露出的新问题。前三项解决「渲染与加载的规模」,后两项解决「缩放滚动条后带来的精度与可达性」。

3.1 · 其一 · 懒加载:只取可见的格子

数据不在前端,而在远端文件 / 接口后面。约定一个 DataFrame 接口:同步的 getCell(row) 命中缓存就立刻返回,缺数据时调异步的 fetch(range) 在后台拉,拉到后触发 resolve 事件让视口重渲。于是「加载多少」只取决于可见区间,与总行数无关——十亿行的表,首屏也只需取约 30 行、几 KB。

const first = Math.floor(scrollTop / rowHeight)
const last  = Math.ceil((scrollTop + clientHeight) / rowHeight)

for (let r = first; r < last; r++) {
  const cell = df.getCell(r)            // 命中缓存:同步返回
  if (cell === undefined)
    df.fetch({ rowStart: first, rowEnd: last })  // 否则后台拉
}
df.on('resolve', renderSlice)           // 拉到后重渲可见区

3.2 · 其二 · 表格切片:只渲染可见的那几十行

DOM 里不放百万个 <tr>,只放可见的那几十个。外层 viewport 负责滚动;内层 canvas 是一个空的占位容器,高度撑到 scrollHeight——它唯一的作用是把滚动条撑到正确长度;真正的 <table> 绝对定位在 canvas 内,每次滚动把它的 top 挪到当前可见区。无论总行数多少,DOM 元素数都是常数。

<div class="viewport" style="overflow-y: auto">
  <div class="canvas" style="position: relative; height: 32000000000px">
    <table style="position: absolute; top: 1280064px">
      <!-- 只放可见的 ~30 行 <tr> -->
    </table>
  </div>
</div>
canvas.style.height = numRows * rowHeight + 'px'          // 撑出滚动条
const first = Math.floor(scrollTop / rowHeight)
table.style.top = scrollTop - (scrollTop % rowHeight) + 'px'  // 平移到可见区

3.3 · 其三 · 无限像素:绕过元素高度上限

切片解决了 DOM 数量,但 canvas 的高度仍受限:浏览器对单个元素高度有上限(Firefox 约 1700 万 px)。10 亿行 × 32px = 320 亿 px,远远超出。做法是缩放滚动条精度:把 canvas 钳到一个安全高度 MAX,再算一个 downscale 比例,把 scrollTop 放大回真实行坐标。代价是某些行落进了缝隙——拖动滚动条一个物理像素,会跳过 downscale 个真实像素的行。

const full = numRows * rowHeight          // 可能 320 亿 px
const MAX  = 8_000_000                     // 留足余量的安全上限
const downscale = full <= MAX
  ? 1
  : (full - clientHeight) / (MAX - clientHeight)

canvas.style.height = Math.min(full, MAX) + 'px'
firstVisibleRow = Math.floor(scrollTop * downscale / rowHeight)

**这就是上面 demo 的「拖一像素 ≈ N 行」那项读数。**1 万行时 downscale = 1,滚动条像素与行一一对应,毫无损失;到 2 万亿行时,一个物理像素对应几百万行,光靠拖动滚动条根本停不到指定行——这个缺口由下一项补上。

3.4 · 其四 · 像素精确滚动:全局跳转 + 局部微调双模式

缝隙问题的解法是把滚动分成两种语义:拖动滚动条 / 翻页是「全局滚动」,一次跨越很大范围,用 downscale 缩放映射;滚轮的小步是「局部滚动」,在锚点附近以真实像素累加偏移,从而能逐行精确停靠。靠 delta 的大小来区分二者,把全局锚点 globalAnchor 与局部偏移 localOffset 分开记账。

const delta = viewport.scrollTop - state.scrollTop
if (Math.abs(delta) > LOCAL_THRESHOLD) {   // 大跳 = 拖滚动条(全局)
  state.globalAnchor = viewport.scrollTop
  state.localOffset = 0
} else {                                    // 小步 = 滚轮(局部精确)
  state.localOffset += delta
}
state.scrollTop = viewport.scrollTop

firstVisibleRow = Math.floor(
  (state.globalAnchor * downscale + state.localOffset) / rowHeight)
table.style.top = viewport.scrollTop + state.localOffset + 'px'

3.5 · 其五 · 两步随机访问:键盘与「跳到行」

键盘上下键、Ctrl+↓、以及上面 demo 的「跳到行」,都需要程序化滚动,但要穿过前几层的复杂度。做法是把垂直水平两步解耦:第一步先更新垂直状态、重渲切片,再用 scrollTo({ behavior: 'instant' }) 把目标行滚进来——用 instant 是为了只触发一次 scroll 事件;第二步在该事件里再处理水平滚动与聚焦。如此即可在 2 万亿行里直达任意一行。

// 第一步:先更新垂直状态,把目标行滚进来
const shouldScroll = state.update(targetRow)
renderSlice()
if (shouldScroll) {
  flag.programmatic = true
  viewport.scrollTo({ top: state.globalAnchor, behavior: 'instant' })
}

// 第二步:垂直稳定后,再处理水平滚动与聚焦
viewport.addEventListener('scroll', () => {
  if (!flag.programmatic) {
    cell.scrollIntoView({ inline: 'nearest' })
    cell.focus({ preventScroll: true })
  }
  flag.programmatic = false
})

4 · 朴素表格 vs 虚拟滚动

维度 朴素:全量渲染 + 全量加载 虚拟滚动:切片 + 懒加载 + 缩放
DOM <tr> 随行数线性增长 常数(≈ 可见行数)
内存 / 网络 全量拉取 仅可见区间
可表示的最大行数 受元素高度上限 ≈ 2 万亿行仍可达
滚动手感 原生、逐像素 大表需双模式补精度
实现复杂度 几乎为零 五层叠加
是否伪造滚动条 / Canvas 表 否,全用原生

关键取舍:用一点滚动条精度(无限像素的缝隙)换来「任意规模」的可行性,再用双模式与程序化跳转把精度补回来。

它没有用到什么,同样值得记一笔。没有自绘的假滚动条,没有把整张表画进 <canvas>——单元格仍是真实 DOM,可选中、可被读屏、可命中。虚拟滚动是取舍而非替换:只在「规模」这一个维度上动手,其余交给浏览器原生能力。固定行高是当前实现的前提;动态行高需要额外的测量与累计高度索引,是另一类问题。