跳到主要内容

虚拟列表原理

面试速答(30 秒版 TL;DR)

虚拟列表(Virtual List / Windowing)解决的不是“数据多”,而是“同时挂在 DOM 上的节点太多”。

它的核心思路可以压成三句:

  1. 视口里真正可见的项只是一小段,没有必要把 1 万条都渲染出来。
  2. 列表容器保留完整滚动高度,但真实 DOM 只维护“可见区 + 缓冲区”这几十个节点。
  3. 滚动时不是重建整棵列表,而是根据 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. 为什么滚动条还能是完整的?

因为虚拟列表并不是把总高度改小了,而是:

  1. 先创建一个“总高度占位层”
  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 落在哪一项上
  • 某一项高度变化后,后面所有项的偏移如何修正

常见做法是:

  1. 先估算一个默认高度
  2. 渲染后测量真实高度
  3. 维护每一项的高度表和前缀和
  4. 通过二分查找找到当前可见起点

所以面试里如果被问到“可变高度为什么更麻烦”,不要只回答“实现复杂”,而要点明:

  • 固定高度是 O(1) 定位
  • 可变高度通常要依赖测量和缓存
  • 高度变化还会影响后续项的位移计算

一个最小实现:可变高度虚拟列表

下面这版故意不追求“最优数据结构”,而是先把核心链路讲完整:

  1. 先给每一项一个预估高度
  2. 根据高度表构建每一项的 offsetTop
  3. 滚动时对 scrollTop二分查找,找到起始项
  4. 渲染后用 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 个点:

  1. 滚动锚点修正 如果视口上方某一项高度被回填后变大或变小,当前内容会“跳一下”。更稳的做法是记录锚点项,并在高度变化后同步修正 scrollTop
  2. 偏移更新优化 这里每次都重新 buildOffsets,是为了好讲。数据量再大一些时,通常会改成树状数组、分段缓存或懒更新。
  3. 异步内容二次测量 图片加载、展开收起、字体切换都会让高度再次变化,所以测量不是“一次性动作”,而是持续监听。

只会“少渲染”还不够,真实项目还要处理这些细节

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 数量时,滚得越久节点越多。


速记要点

  1. 虚拟列表优化的是“同时渲染的节点数”,不是总数据量。
  2. 固定高度靠“总高度占位 + 可见窗口裁剪 + translateY 位移”实现。
  3. 可变高度难在“测量、缓存、前缀和、二分定位、滚动校正”。
  4. 它适合长列表,但不适合所有列表;实现前先确认瓶颈真的是 DOM 过多。