虚拟列表原理
面试速答(30 秒版 TL;DR)
虚拟列表(Virtual List / Windowing)解决的不是“数据多”,而是“同时挂在 DOM 上的节点太多”。
它的核心思路可以压成三句:
- 视口里真正可见的项只是一小段,没有必要把 1 万条都渲染出来。
- 列表容器保留完整滚动高度,但真实 DOM 只维护“可见区 + 缓冲区”这几十个节点。
- 滚动时不是重建整棵列表,而是根据
scrollTop计算当前窗口,再复用节点并平移内容。
面试里一句话总结:
虚拟列表本质上是用“空间占位 + 可见窗口裁剪 + 节点复用”,把大列表的渲染成本从“总量相关”改成“视口相关”。
先澄清一个误区:虚拟列表优化的是 DOM,不是接口
很多人一提列表性能,第一反应是分页、懒加载、无限滚动。它们确实有用,但优化点并不相同:
| 手段 | 主要解决什么问题 | 不能替代什么 |
|---|---|---|
| 分页 | 一次不要请求太多数据 | 不能解决单页内 DOM 过多 |
| 无限滚动 | 按需加载更多数据 | 可能越滚 DOM 越多 |
| 虚拟列表 | 控制同时渲染的节点数 | 不会减少总数据量 |
所以当页面卡顿来自:
- 页面上挂了几千上万个节点
- 样式计算、布局、绘制时间过高
- React / Vue diff 范围太大
- 滚动时主线程长任务明显
这时虚拟列表才是更直接的手段。
核心心智模型:你看到的是“长列表”,浏览器维护的是“短窗口”
从用户视角,列表像完整渲染出来的一样;从浏览器视角,真正存在的只是一个滑动窗口。
先看固定高度场景的主链路:算窗口、渲染窗口、平移窗口。
这套方案一般包含 4 个关键量:
- 列表总数:
total - 单项高度:固定高度场景下通常是
itemHeight - 容器可视高度:
containerHeight - 滚动偏移量:
scrollTop
在固定高度列表里,推导通常很直接:
- 可见数量:
visibleCount = ceil(containerHeight / itemHeight) - 起始索引:
startIndex = floor(scrollTop / itemHeight) - 结束索引:
endIndex = startIndex + visibleCount - 1
为了避免滚动时边缘闪烁,还会再加一层 overscan(缓冲区):
- 实际渲染起点:
renderStart = max(0, startIndex - overscan) - 实际渲染终点:
renderEnd = min(total - 1, endIndex + overscan)
如果把上面这些公式翻成一条更好记的链路,可以直接记成:
固定高度场景:最容易落地,也最常被问
固定高度是虚拟列表最适合面试讲清楚的版本,因为公式简单、原理完整。
1. 为什么它能快很多?
假设:
- 总数据 10000 条
- 每项高度 50px
- 视口高度 500px
那么可见区域大概只需要 10 条,再加前后各 5 条缓冲,真实渲染节点也就 20 条左右。
也就是说:
- 不是从 10000 条渲染优化到 0 条
- 而是从 10000 个 DOM 节点,降到约 20 到 30 个 DOM 节点
这会直接降低:
- DOM 创建和销毁成本
- 样式计算成本
- 布局和绘制压力
- 框架层组件实例数量
2. 为什么滚动条还能是完整的?
因为虚拟列表并不是把总高度改小了,而是:
- 先创建一个“总高度占位层”
- 再把真实渲染出来的小段内容,平移到当前应该显示的位置
比如总高度是:
const totalHeight = total * itemHeight
真实渲染区只是一小段:
const offsetY = renderStart * itemHeight
于是浏览器仍然认为这是一个很长的滚动容器,用户拖动滚动条时不会感觉异常。
可以把它想成“滚动条来自占位层,内容来自窗口层”:
一个最小实现:固定高度虚拟列表
下面这段代码只展示核心逻辑,重点不是框架语法,而是索引和位移怎么计算。
type Item = { id: number; text: string }
const itemHeight = 48
const containerHeight = 480
const overscan = 4
function getRange(scrollTop: number, total: number) {
const visibleCount = Math.ceil(containerHeight / itemHeight)
const startIndex = Math.floor(scrollTop / itemHeight)
const endIndex = Math.min(total - 1, startIndex + visibleCount - 1)
const renderStart = Math.max(0, startIndex - overscan)
const renderEnd = Math.min(total - 1, endIndex + overscan)
return {
renderStart,
renderEnd,
offsetY: renderStart * itemHeight,
totalHeight: total * itemHeight,
}
}
渲染结构通常是:
<div class="list-container">
<div class="list-phantom"></div>
<div class="list-window" style="transform: translateY(offsetY)">
<!-- 这里只渲染 renderStart 到 renderEnd -->
</div>
</div>
关键点:
list-phantom负责撑开总高度list-window负责承载当前窗口内容- 每次滚动只更新索引区间和位移
可变高度场景:难点不在渲染,在“定位”
固定高度之所以简单,是因为索引和位置可以直接通过除法得到。可变高度场景会复杂很多,因为你无法用:
scrollTop / itemHeight
直接算出某一项索引。
可变高度为什么难?
因为你必须知道:
- 第
n项开始位置是多少 - 当前
scrollTop落在哪一项上 - 某一项高度变化后,后面所有项的偏移如何修正
常见做法是:
- 先估算一个默认高度
- 渲染后测量真实高度
- 维护每一项的高度表和前缀和
- 通过二分查找找到当前可见起点
所以面试里如果被问到“可变高度为什么更麻烦”,不要只回答“实现复杂”,而要点明:
- 固定高度是 O(1) 定位
- 可变高度通常要依赖测量和缓存
- 高度变化还会影响后续项的位移计算
一个最小实现:可变高度虚拟列表
下面这版故意不追求“最优数据结构”,而是先把核心链路讲完整:
- 先给每一项一个预估高度
- 根据高度表构建每一项的
offsetTop - 滚动时对
scrollTop做二分查找,找到起始项 - 渲染后用
ResizeObserver回填真实高度,再重新计算窗口
整个过程更像一个持续收敛的闭环,而不是“一次计算完就结束”:
如果是超大规模列表,工程里还会进一步用树状数组(Fenwick Tree)或分块结构,把“单项高度变化后更新后续偏移”的成本降下来;但面试里先把下面这版讲清楚就够了。
先看核心算法:
type VirtualRange = {
renderStart: number
renderEnd: number
offsetY: number
totalHeight: number
}
function createEstimatedHeights(total: number, estimatedItemHeight: number) {
return Array.from({ length: total }, () => estimatedItemHeight)
}
function buildOffsets(heights: number[]) {
const offsets = new Array<number>(heights.length)
let totalHeight = 0
for (let i = 0; i < heights.length; i++) {
offsets[i] = totalHeight
totalHeight += heights[i]
}
return {
offsets,
totalHeight,
}
}
function findStartIndex(
offsets: number[],
heights: number[],
scrollTop: number,
) {
let left = 0
let right = offsets.length - 1
let answer = 0
while (left <= right) {
const mid = (left + right) >> 1
const itemTop = offsets[mid]
const itemBottom = itemTop + heights[mid]
if (itemBottom <= scrollTop) {
left = mid + 1
} else {
answer = mid
right = mid - 1
}
}
return answer
}
function getVariableRange(options: {
heights: number[]
scrollTop: number
containerHeight: number
overscan: number
}): VirtualRange {
const { heights, scrollTop, containerHeight, overscan } = options
const { offsets, totalHeight } = buildOffsets(heights)
const startIndex = findStartIndex(offsets, heights, scrollTop)
const viewportBottom = scrollTop + containerHeight
let endIndex = startIndex
while (
endIndex < heights.length - 1 &&
offsets[endIndex] + heights[endIndex] < viewportBottom
) {
endIndex += 1
}
const renderStart = Math.max(0, startIndex - overscan)
const renderEnd = Math.min(heights.length - 1, endIndex + overscan)
return {
renderStart,
renderEnd,
offsetY: offsets[renderStart] ?? 0,
totalHeight,
}
}
再看一个最小渲染骨架。下面以 React 18+ / TypeScript 为例,重点是“如何测量并回填高度”,不是样式细节:
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
type Item = {
id: number
text: string
}
const containerHeight = 480
const estimatedItemHeight = 72
const overscan = 4
export function VariableHeightVirtualList({
items,
}: {
items: Item[]
}) {
const containerRef = useRef<HTMLDivElement | null>(null)
const rowRefs = useRef(new Map<number, HTMLDivElement>())
const heightsRef = useRef(
createEstimatedHeights(items.length, estimatedItemHeight),
)
const [scrollTop, setScrollTop] = useState(0)
const [range, setRange] = useState(() =>
getVariableRange({
heights: heightsRef.current,
scrollTop: 0,
containerHeight,
overscan,
}),
)
useEffect(() => {
heightsRef.current = items.map(
(_, index) => heightsRef.current[index] ?? estimatedItemHeight,
)
const nextScrollTop = containerRef.current?.scrollTop ?? 0
setRange(
getVariableRange({
heights: heightsRef.current,
scrollTop: nextScrollTop,
containerHeight,
overscan,
}),
)
}, [items])
useLayoutEffect(() => {
setRange(
getVariableRange({
heights: heightsRef.current,
scrollTop,
containerHeight,
overscan,
}),
)
}, [scrollTop])
useEffect(() => {
const observer = new ResizeObserver((entries) => {
let changed = false
for (const entry of entries) {
const index = Number(entry.target.getAttribute('data-index'))
const nextHeight = Math.ceil(entry.contentRect.height)
if (heightsRef.current[index] !== nextHeight) {
heightsRef.current[index] = nextHeight
changed = true
}
}
if (!changed) return
const nextScrollTop = containerRef.current?.scrollTop ?? 0
setRange(
getVariableRange({
heights: heightsRef.current,
scrollTop: nextScrollTop,
containerHeight,
overscan,
}),
)
})
for (const row of rowRefs.current.values()) {
observer.observe(row)
}
return () => observer.disconnect()
}, [range.renderStart, range.renderEnd])
const visibleItems = items.slice(range.renderStart, range.renderEnd + 1)
return (
<div
ref={containerRef}
style={{ height: containerHeight, overflowY: 'auto' }}
onScroll={(event) => {
setScrollTop(event.currentTarget.scrollTop)
}}
>
<div style={{ height: range.totalHeight, position: 'relative' }}>
<div style={{ transform: `translateY(${range.offsetY}px)` }}>
{visibleItems.map((item, visibleIndex) => {
const index = range.renderStart + visibleIndex
return (
<div
key={item.id}
data-index={index}
ref={(node) => {
if (node) {
rowRefs.current.set(index, node)
} else {
rowRefs.current.delete(index)
}
}}
>
{item.text}
</div>
)
})}
</div>
</div>
</div>
)
}
这段代码里最关键的不是 React API,而是下面 4 个动作:
heightsRef:缓存每一项当前已知高度,未知时先用预估值buildOffsets:把高度表转成偏移表,回答“第 n 项从哪里开始”findStartIndex:用二分查找回答“当前 scrollTop 落在哪一项上”ResizeObserver:在真实 DOM 高度变化后回填缓存并重新算窗口
这段实现还差什么,才算接近生产可用?
上面的代码已经能说明原理,但真实项目通常还会补 3 个点:
- 滚动锚点修正
如果视口上方某一项高度被回填后变大或变小,当前内容会“跳一下”。更稳的做法是记录锚点项,并在高度变化后同步修正
scrollTop。 - 偏移更新优化
这里每次都重新
buildOffsets,是为了好讲。数据量再大一些时,通常会改成树状数组、分段缓存或懒更新。 - 异步内容二次测量 图片加载、展开收起、字体切换都会让高度再次变化,所以测量不是“一次性动作”,而是持续监听。
只会“少渲染”还不够,真实项目还要处理这些细节
1. 缓冲区不能太小
如果只渲染严格可见区,快速滚动时容易出现白边或闪动。常见做法是给上下各加几项缓冲。
缓冲区太小的问题:
- 滚动过快时来不及补帧
- 视觉上会出现空白
- 低端设备更明显
缓冲区太大的问题:
- 节点数量回升
- 虚拟列表收益被稀释
2. 节点复用要稳定 key
框架层如果 key 不稳定,会导致:
- 组件频繁卸载重建
- 输入框焦点丢失
- 本地状态错位
所以应该尽量用数据主键,而不是窗口内相对索引。
3. 滚动事件不要做重计算
滚动本身频率就高,如果每次滚动都做:
- 大量同步测量
- 复杂排序或过滤
- 重型 setState / 响应式更新
那即使使用虚拟列表,也可能照样掉帧。
工程里常见做法:
- 把范围计算保持为常数级
- 避免滚动回调里做昂贵逻辑
- 必要时用
requestAnimationFrame节流渲染更新
4. 可访问性和搜索要额外考虑
虚拟列表只渲染局部节点,会带来两个常见边界:
- 屏幕阅读器未必能像普通列表那样线性读取全部内容
- 浏览器页内搜索可能找不到未挂载的项
因此在后台管理系统、长表格场景里通常收益明显,但如果页面强调全文搜索、打印、无障碍体验,就要额外设计兜底方案。
虚拟列表不适合所有列表
下面这几类场景,收益往往没想象中大:
1. 列表本来就不长
如果一屏几十项,总节点数也不高,引入虚拟列表只会增加实现复杂度。
2. 单项高度频繁变化
例如:
- 图片异步加载后高度变化
- 展开收起项很多
- 富文本内容长度差异极大
这时维护高度缓存和滚动位置校正的成本会明显上升。
3. 每项本身计算就很重
如果瓶颈不是“节点数”,而是每一项里都有复杂图表、富文本解析、同步计算,那么只做虚拟列表不够,还要继续优化单项渲染逻辑。
面试里最容易被追问的 4 个问题
Q1:虚拟列表为什么能提升性能?
因为浏览器真正吃不消的常常不是“数组长度”,而是“同时参与渲染的 DOM 数量”。虚拟列表把渲染节点数限制在可见区附近,显著降低了创建、布局、绘制和框架 diff 的成本。
Q2:它会不会影响滚动条长度?
不会。通常会用一个占位层撑出完整总高度,滚动条仍然按全量内容计算;只是实际 DOM 只渲染当前窗口。
Q3:固定高度和可变高度的本质差异是什么?
固定高度可以直接通过除法得到索引和偏移;可变高度必须维护高度缓存和偏移映射,通常需要测量与二分查找,所以实现复杂度更高。
Q4:虚拟列表和分页怎么选?
两者不是互斥关系。分页优化的是数据获取和页面信息密度,虚拟列表优化的是前端渲染成本。大数据量后台表格经常会“服务端分页 + 前端虚拟滚动”一起用。
常见误区
-
误区 1:只要数据多就一定要上虚拟列表。 真正要看的是渲染节点数、滚动体验和实现成本。
-
误区 2:用了虚拟列表就不会卡。 如果单项组件很重、滚动回调很重、图片解码很重,照样会掉帧。
-
误区 3:把
index当成 key 没问题。 窗口滑动时这会导致节点状态错乱。 -
误区 4:无限滚动等于虚拟列表。 无限滚动只是数据追加策略,不控制 DOM 数量时,滚得越久节点越多。
速记要点
- 虚拟列表优化的是“同时渲染的节点数”,不是总数据量。
- 固定高度靠“总高度占位 + 可见窗口裁剪 + translateY 位移”实现。
- 可变高度难在“测量、缓存、前缀和、二分定位、滚动校正”。
- 它适合长列表,但不适合所有列表;实现前先确认瓶颈真的是 DOM 过多。