页面有十万条数据时,真正拖慢浏览器的通常不是数组长度,而是十万个 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 防止状态串行;不定高时维护累计高度、二分查找和锚点校正;数据继续增长时再配合分页和无限加载。
先从固定高度版本开始,只有在内容高度确实不可预测时,再承担不定高度虚拟列表的复杂度。
评论