页面有十万条数据时,真正拖慢浏览器的通常不是数组长度,而是十万个 DOM(Document Object Model,文档对象模型)节点带来的布局、绘制、内存和框架更新成本。虚拟滚动(Virtual Scrolling,也常称为虚拟列表)的目标不是一次加载全部内容,而是让页面看起来拥有完整列表,实际只保留视口附近的一小段节点。

本文先实现最适合落地的固定高度列表,再说明不定高度列表为什么困难、怎样测量高度、如何防止内容加载后滚动位置跳动,以及什么时候应当直接使用成熟组件库。

一、先建立正确模型:不是隐藏,而是不创建

很多“优化”写成下面这样:

.offscreen-row {
  display: none;
}

这并不是真正的虚拟滚动。不可见行依旧在 DOM 中,框架仍需管理它们,浏览器也仍需维护对应的样式和节点关系。

虚拟滚动只保留三个部分:

滚动容器(固定高度,overflow: auto)
└── 占位层(总高度,负责制造完整滚动条)
    └── 可见行容器(只渲染视口附近的一小段行)

例如有 100,000 条数据、每行 40px、容器高度为 400px:

  • 页面视觉上的总高度是 100000 × 40 = 4,000,000px;
  • 视口一次只能完整显示约 10 行;
  • 加上上下缓冲区,实际渲染 20~40 个节点通常足够。

占位层不包含十万个真实节点,它只用一个高度维持正确的滚动范围。可见行容器再通过 translateY() 移动到它在完整列表中本应出现的位置。

二、固定高度列表:先解决 80% 的场景

固定高度是虚拟滚动的理想起点,例如日志、表格行、后台菜单、固定规格的消息卡片。设定:

const itemHeight = 40
const overscan = 5

其中 overscan 是上下缓冲的行数。缓冲区的意义是:用户连续滚动时,下一批内容已经在 DOM 中,减少边缘出现空白或频繁挂载的概率。

1. 计算可见区

设:

  • scrollTop:容器距离顶部的滚动距离;
  • viewportHeight:容器可见高度;
  • visibleStart:真正位于视口顶部的行索引;
  • start、end:加上缓冲区后需要渲染的数据范围,end 为开区间。

计算公式如下:

function getRange({ scrollTop, viewportHeight, total, itemHeight, overscan }) {
  const visibleStart = Math.floor(scrollTop / itemHeight)
  const visibleCount = Math.ceil(viewportHeight / itemHeight)

  const start = Math.max(0, visibleStart - overscan)
  const end = Math.min(total, visibleStart + visibleCount + overscan)

  return { start, end }
}

若 scrollTop = 4000 且每行高 40px:

visibleStart = floor(4000 / 40) = 100

这意味着第 100 项正位于视口顶部。假设缓冲区为 5 行,真实渲染范围就是第 95 到第 114 项。

2. 让滚动条保持完整高度

滚动条大小取决于滚动内容总高度,所以占位层必须反映完整数据:

spacer.style.height = `${items.length * itemHeight}px`

这个元素很高,但内部的可见行容器只放当前切片,不会随着数据规模变成大量 DOM 节点。

3. 把切片移动到正确位置

若当前从第 95 项开始渲染,则第一个实际节点应当显示在 95 × 40 = 3800px 的位置:

const offsetY = start * itemHeight
rows.style.transform = `translateY(${offsetY}px)`

这就是“虚拟”的关键:DOM 内第一项可能只是当前切片的第 0 项,但视觉上位于完整列表的第 95 项位置。transform: translateY() 用于视觉位移,避免通过频繁修改布局位置来移动整块内容。MDN:translateY

4. 一个可运行的最小实现

<div id="list" class="virtual-list">
  <div id="spacer">
    <div id="rows" class="virtual-rows"></div>
  </div>
</div>
.virtual-list {
  height: 400px;
  overflow-y: auto;
  position: relative;
  border: 1px solid #ddd;
}

#spacer {
  position: relative;
  width: 100%;
}

.virtual-rows {
  position: absolute;
  inset: 0 auto auto 0;
  width: 100%;
  will-change: transform;
}

.row {
  height: 40px;
  box-sizing: border-box;
  padding: 10px 12px;
  border-bottom: 1px solid #eee;
}
const items = Array.from(
  { length: 100_000 },
  (_, index) => ({ id: index, title: `第 ${index + 1} 条数据` }),
)

const itemHeight = 40
const overscan = 5

const list = document.querySelector('#list')
const spacer = document.querySelector('#spacer')
const rows = document.querySelector('#rows')

spacer.style.height = `${items.length * itemHeight}px`

let frameId = 0

function render() {
  const { scrollTop, clientHeight: viewportHeight } = list
  const visibleStart = Math.floor(scrollTop / itemHeight)
  const visibleCount = Math.ceil(viewportHeight / itemHeight)

  const start = Math.max(0, visibleStart - overscan)
  const end = Math.min(items.length, visibleStart + visibleCount + overscan)

  rows.style.transform = `translateY(${start * itemHeight}px)`
  rows.innerHTML = items
    .slice(start, end)
    .map((item) => `<div class="row">${item.title}</div>`)
    .join('')
}

list.addEventListener(
  'scroll',
  () => {
    if (frameId) return

    frameId = requestAnimationFrame(() => {
      frameId = 0
      render()
    })
  },
  { passive: true },
)

window.addEventListener('resize', render)
render()

滚动事件触发频率很高。通过 requestAnimationFrame() 合并同一帧中的多次事件,可以避免重复计算和重复渲染。正式项目中不要每次都用 innerHTML 重建,React、Vue 等框架应由稳定的 key 做局部更新。

三、节点如何“回收”:切片、卸载与稳定 key

在框架中,最常见的做法是直接渲染当前区间:

{items.slice(start, end).map((item) => (
  <Row key={item.id} item={item} />
))}

滚出缓冲区的组件会被卸载;重新进入时按数据重新创建。这对大多数场景已经足够。

关键是 key 必须使用稳定的业务 ID:

// 正确:同一条数据始终对应同一身份
<Row key={item.id} item={item} />

// 不推荐:切片后的索引会随着滚动改变身份
<Row key={index} item={item} />

如果使用切片索引,原本展示第 95 项的行节点滚动后可能被“误认”为第 96 项,输入框内容、展开状态、焦点或局部状态都有机会错位。

更激进的原生 DOM 实现可以维护一个固定大小的节点池,让节点从列表顶部移出后重新填充底部数据。不过要额外清理事件、表单状态和 ARIA 属性。对 React/Vue 项目而言,优先使用切片与稳定 key,通常更安全、更容易维护。

四、滚动到指定项:顶部、居中与确保可见

固定高度列表中,索引与像素位置可以直接换算。

滚到顶部

function scrollToIndex(index) {
  list.scrollTop = index * itemHeight
}

滚到视口中间

function scrollToIndexCenter(index) {
  const targetTop = index * itemHeight
  const centerOffset = (list.clientHeight - itemHeight) / 2

  list.scrollTop = Math.max(0, targetTop - centerOffset)
}

只保证可见,不强制改变位置

键盘导航、表格选中项跳转通常更适合这种方式:

function ensureVisible(index) {
  const itemTop = index * itemHeight
  const itemBottom = itemTop + itemHeight
  const viewportTop = list.scrollTop
  const viewportBottom = viewportTop + list.clientHeight

  if (itemTop < viewportTop) {
    list.scrollTop = itemTop
  } else if (itemBottom > viewportBottom) {
    list.scrollTop = itemBottom - list.clientHeight
  }
}

五、不定高度列表:为什么难得多

评论、聊天、富文本、图片瀑布流等场景中,每一项高度都不同。这时 index × itemHeight 不再成立。

需要维护两组数据:

heights[i]:第 i 项的真实高度
offsets[i]:第 i 项顶部距离整个列表顶部的像素值

若四项高度为:

heights = [40, 60, 50, 80]
offsets = [0, 40, 100, 150]

第 3 项顶部位于 150px。要根据 scrollTop 找到起始项,必须在 offsets 中做二分查找,而不是做除法。

1. 初始高度估计

在真实节点挂载、图片加载和字体排版完成前,项目并不知道每行高度。通常先提供一个估计值:

const estimatedHeight = 64
const heights = new Map()

function getHeight(index) {
  return heights.get(index) ?? estimatedHeight
}

估计不要求绝对准确,但越接近真实平均高度,首次滚动时的跳动越少。

2. 在累计偏移量中二分查找

offsets 按顺序递增。寻找“顶部不超过 scrollTop 的最后一项”:

function findStartIndex(offsets, scrollTop) {
  let left = 0
  let right = offsets.length - 1

  while (left <= right) {
    const middle = Math.floor((left + right) / 2)

    if (offsets[middle] <= scrollTop) {
      left = middle + 1
    } else {
      right = middle - 1
    }
  }

  return Math.max(0, right)
}

找到 start 后,从 offsets[start] 开始累加行高,直到覆盖视口高度和缓冲区高度,即可得到 end。

3. 用 ResizeObserver 测量真实高度

节点实际渲染后,通过 ResizeObserver 获取高度:

const observer = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const index = Number(entry.target.dataset.index)
    const nextHeight = entry.contentRect.height
    const previousHeight = getHeight(index)

    if (nextHeight === previousHeight) continue

    heights.set(index, nextHeight)
    scheduleLayoutUpdate()
  }
})

ResizeObserver 适合观察内容、图片或容器变化引起的尺寸变化。若回调中立即修改被观察元素的尺寸,可能触发观察循环;复杂更新可合并到下一帧。MDN:ResizeObserver

4. 锚点校正:避免内容加载后滚动跳动

这是不定高列表的关键。

假设用户正在阅读第 500 项,此时第 100 项的一张图片加载完成,高度增加 200px。若只重算总高度,那么当前屏幕内容会整体向下跳 200px,阅读体验很差。

正确做法是在更新前记录当前视口锚点:

const anchorIndex = findStartIndex(offsets, list.scrollTop)
const anchorOffset = list.scrollTop - offsets[anchorIndex]

重新测量并得到新的 offsets 后,恢复同一个锚点及其在视口内的位置:

list.scrollTop = newOffsets[anchorIndex] + anchorOffset

也就是说,前面任意项高度变化后,用户眼前正在阅读的那一项仍保持相同视觉位置。

5. 大数据量下使用树状数组

如果有 100,000 项,每次测得一个高度变化后都从当前位置重算后续全部 offsets,复杂度接近 O(n)。图片陆续加载时会造成明显开销。

树状数组(Fenwick Tree)可将关键操作降低为 O(log n):

更新某项高度差值          update(index, delta)
查询前 n 项累计高度       prefixSum(index)
根据 scrollTop 反查索引   findIndexByOffset(scrollTop)

固定高度列表没有必要引入它;只有不定高度、数据量很大、且高度会持续变化时才值得增加这层复杂度。

六、无限加载与虚拟滚动不是一回事

两者经常一起出现,但职责不同:

能力解决的问题
无限加载何时向服务端请求下一页数据
虚拟滚动已加载数据中,哪些 DOM 节点应该存在

可以在列表底部设置一个“哨兵”元素,用 IntersectionObserver 接近可见时请求下一页。它避免手写大量可见性检查;rootMargin 还能让请求在用户真正滚到底部前提前发起。MDN:Intersection Observer

不过,拿到新数据后仍然要靠虚拟列表限制 DOM 数量。只做无限加载、却一直保留所有旧节点,页面最终仍会变慢。

七、生产环境容易忽略的细节

1. 缓冲区不是越大越好

缓冲太小会在快速滚动时出现白屏或频繁挂载;缓冲太大则重新接近普通长列表。应按行渲染成本、滚动速度和设备性能测量,一般从上下各 3~10 行或 1 个屏幕高度开始调整。

2. 焦点与表单状态

获得焦点的输入框一旦滚出缓冲区,会被卸载并丢失焦点。编辑型表格通常需要:

  • 将编辑值提升到列表外部状态;
  • 在编辑期间扩大该行的保留范围;
  • 或在卸载前保存草稿,重新进入时恢复。

3. 无障碍信息

屏幕阅读器只会感知当前 DOM。列表项可补充:

<div role="listitem" aria-posinset="501" aria-setsize="100000">
  第 501 条数据
</div>

键盘选中项变化时,也应调用 ensureVisible(),避免焦点逻辑上移动了、视觉上却不在视口中。

4. 图片、字体与富文本

这些内容高度往往会延后变化。应预留合理尺寸,图片可设置 width、height 或 aspect-ratio,再用 ResizeObserver 兜底测量。不要假定首次挂载的高度就是最终高度。

5. 优先选择成熟方案的时机

固定高度、展示型列表适合自己实现;出现以下需求时,更建议评估成熟组件库:

  • 不定高度与图片异步加载;
  • 分组、吸顶标题、反向聊天列表;
  • 可拖拽排序、动态插入删除;
  • 复杂表格、列虚拟化、树形数据;
  • 完整的焦点管理和无障碍支持。

自己写虚拟列表并不难,难的是长期维护这些边界条件。

总结

虚拟滚动的基本公式很简单:

固定高度:start = floor(scrollTop / itemHeight)
视觉偏移:offsetY = start × itemHeight

真正需要设计的是其余部分:用占位层维持总高度;只保留视口和缓冲区的节点;使用稳定 key 防止状态串行;不定高时维护累计高度、二分查找和锚点校正;数据继续增长时再配合分页和无限加载。

先从固定高度版本开始,只有在内容高度确实不可预测时,再承担不定高度虚拟列表的复杂度。

参考资料

虚拟滚动:从固定高度到不定高度列表

作者

panxiao

发布日期

2026 - 10 - 10

许可证

Unlicensed

评论