虚拟列表原理
为什么需要虚拟列表?
在前端开发中,我们经常需要展示大量数据列表,比如通讯录、商品列表、日志表格等。当数据量达到数千甚至数万条时,如果一次性将所有数据渲染为真实 DOM 节点,会带来严重的性能问题:
来看一个直观的对比:
| 列表项数量 | DOM 节点数 | 首次渲染时间 | 滚动帧率(FPS) | 内存占用 |
|---|---|---|---|---|
| 100 条 | ~100 | < 50ms | 60 FPS | ~5 MB |
| 1,000 条 | ~1,000 | ~200ms | 50-60 FPS | ~20 MB |
| 10,000 条 | ~10,000 | ~2s | 20-30 FPS | ~150 MB |
| 100,000 条 | ~100,000 | > 10s | < 10 FPS | > 1 GB |
浏览器渲染的性能瓶颈在于 DOM 节点数量,而不是数据量本身。用户在某一时刻只能看到屏幕可视区域内的几十条数据,但传统方案却渲染了全部数据对应的 DOM。
虚拟列表的核心思想就是 —— 只渲染用户看得见的部分,用少量 DOM 模拟海量数据的展示效果。
虚拟列表的核心原理
基本思路
虚拟列表(Virtual List / Virtual Scroll)通过以下策略实现高性能渲染:
- 只渲染可视区域内的列表项(通常 10~20 个 DOM 节点)
- 监听滚动事件,动态计算当前应该展示哪些数据
- 通过 CSS 偏移,让可视区域的列表项"看起来"处于正确的滚动位置
关键概念
理解虚拟列表需要掌握以下几个核心概念:
| 概念 | 说明 |
|---|---|
| 可视区域(Viewport) | 用户屏幕上能看到的区域,高度固定 |
| 列表总高度(Total Height) | 所有列表项高度之和,用于产生真实的滚动条 |
| 起始索引(startIndex) | 可视区域内第一个列表项在数据中的索引 |
| 结束索引(endIndex) | 可视区域内最后一个列表项在数据中的索引 |
| 偏移量(offset) | 可视区域相对于列表顶部的滚动距离 |
| 缓冲区(Buffer) | 在可视区域上下额外多渲染几条数据,减少滚动时的白屏闪烁 |
计算流程
每次滚动时,虚拟列表的计算流程如下:
固定高度虚拟列表实现
固定高度是最简单的情况 —— 每个列表项的高度都相同,计算逻辑非常直观。
核心计算公式
// 已知条件
const itemHeight = 50; // 每项高度(固定)
const viewportHeight = 500; // 可视区域高度
const totalCount = 10000; // 数据总量
const bufferCount = 5; // 上下缓冲区条数
// ✅ 核心计算
const visibleCount = Math.ceil(viewportHeight / itemHeight); // 可视区域能显示的条数
const totalHeight = totalCount * itemHeight; // 列表总高度(撑开滚动条)
// 滚动时动态计算
const scrollTop = container.scrollTop; // 当前滚动距离
const startIndex = Math.floor(scrollTop / itemHeight); // 起始索引
const endIndex = Math.min(startIndex + visibleCount + bufferCount, totalCount); // 结束索引
const offset = startIndex * itemHeight; // 偏移量
完整实现
下面用原生 JavaScript 实现一个固定高度的虚拟列表:
<div id="virtual-list-container" style="height: 500px; overflow-y: auto; position: relative;">
<!-- 占位元素:撑开容器,产生真实滚动条 -->
<div id="virtual-list-phantom"></div>
<!-- 实际渲染区域 -->
<div id="virtual-list-content" style="position: absolute; top: 0; left: 0; right: 0;"></div>
</div>
class FixedVirtualList {
constructor({ container, itemHeight, totalCount, renderItem }) {
this.container = container;
this.itemHeight = itemHeight;
this.totalCount = totalCount;
this.renderItem = renderItem;
this.bufferCount = 5; // 缓冲条数
// 获取子元素
this.phantom = container.querySelector('#virtual-list-phantom');
this.content = container.querySelector('#virtual-list-content');
// 可视区域高度
this.viewportHeight = container.clientHeight;
// 可视区域可显示的条数
this.visibleCount = Math.ceil(this.viewportHeight / this.itemHeight);
this.init();
}
init() {
// 设置占位元素高度 —— 产生真实的滚动条
this.phantom.style.height = `${this.totalCount * this.itemHeight}px`;
// 监听滚动事件
this.container.addEventListener('scroll', () => this.handleScroll());
// 首次渲染
this.handleScroll();
}
handleScroll() {
const scrollTop = this.container.scrollTop;
// 计算起始索引(含缓冲区)
const startIndex = Math.max(0, Math.floor(scrollTop / this.itemHeight) - this.bufferCount);
// 计算结束索引(含缓冲区)
const endIndex = Math.min(
this.totalCount,
startIndex + this.visibleCount + this.bufferCount * 2
);
// 计算偏移量
const offset = startIndex * this.itemHeight;
this.content.style.transform = `translateY(${offset}px)`;
// 渲染可见列表项
this.content.innerHTML = '';
for (let i = startIndex; i < endIndex; i++) {
const item = this.renderItem(i);
item.style.height = `${this.itemHeight}px`;
item.style.boxSizing = 'border-box';
this.content.appendChild(item);
}
}
}
// ✅ 使用示例
const container = document.getElementById('virtual-list-container');
new FixedVirtualList({
container,
itemHeight: 50,
totalCount: 100000,
renderItem(index) {
const div = document.createElement('div');
div.className = 'list-item';
div.textContent = `第 ${index + 1} 条数据`;
return div;
},
});
占位元素 phantom 的高度等于所有列表项的总高度,它本身不渲染任何内容,唯一的作用是撑开容器产生滚动条,让用户产生"所有数据都已经渲染"的视觉错觉。
动态高度虚拟列表实现
实际业务中,列表项的高度往往不固定 —— 比如聊天消息、评论列表、文章摘要等,每条内容长度不同,导致每项高度也不同。
难点分析
实现思路
核心策略是先预估,后修正:
- 初始化:给每个列表项一个预估高度(
estimatedItemHeight),据此计算位置信息 - 渲染后测量:当列表项实际渲染到 DOM 后,获取真实高度并更新缓存
- 动态修正:根据真实高度重新计算总高度和偏移量
class DynamicVirtualList {
constructor({ container, estimatedItemHeight, totalCount, renderItem }) {
this.container = container;
this.estimatedItemHeight = estimatedItemHeight;
this.totalCount = totalCount;
this.renderItem = renderItem;
this.bufferCount = 5;
this.phantom = container.querySelector('#virtual-list-phantom');
this.content = container.querySelector('#virtual-list-content');
this.viewportHeight = container.clientHeight;
// ✅ 核心:维护每项的位置信息缓存
this.positions = this.initPositions();
this.init();
}
// 初始化位置信息(基于预估高度)
initPositions() {
return Array.from({ length: this.totalCount }, (_, index) => ({
index,
height: this.estimatedItemHeight,
top: index * this.estimatedItemHeight,
bottom: (index + 1) * this.estimatedItemHeight,
}));
}
init() {
// 设置总高度
this.phantom.style.height = `${this.getTotalHeight()}px`;
this.container.addEventListener('scroll', () => this.handleScroll());
this.handleScroll();
}
// 获取列表总高度
getTotalHeight() {
const last = this.positions[this.positions.length - 1];
return last ? last.bottom : 0;
}
// ✅ 使用二分查找定位 startIndex
getStartIndex(scrollTop) {
let low = 0;
let high = this.positions.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const midBottom = this.positions[mid].bottom;
if (midBottom === scrollTop) {
return mid + 1;
} else if (midBottom < scrollTop) {
low = mid + 1;
} else {
if (mid === 0 || this.positions[mid - 1].bottom <= scrollTop) {
return mid;
}
high = mid - 1;
}
}
return low;
}
handleScroll() {
const scrollTop = this.container.scrollTop;
// 二分查找确定起始索引
const startIndex = Math.max(0, this.getStartIndex(scrollTop) - this.bufferCount);
const visibleCount = Math.ceil(this.viewportHeight / this.estimatedItemHeight);
const endIndex = Math.min(this.totalCount, startIndex + visibleCount + this.bufferCount * 2);
// 偏移量
const offset = this.positions[startIndex].top;
this.content.style.transform = `translateY(${offset}px)`;
// 渲染
this.content.innerHTML = '';
for (let i = startIndex; i < endIndex; i++) {
const item = this.renderItem(i);
item.dataset.index = i;
this.content.appendChild(item);
}
// ✅ 渲染后测量真实高度并更新缓存
this.updatePositions(startIndex);
}
// 渲染后更新位置信息
updatePositions(startIndex) {
const nodes = this.content.children;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const index = Number(node.dataset.index);
const realHeight = node.getBoundingClientRect().height;
const oldHeight = this.positions[index].height;
const diff = realHeight - oldHeight;
if (diff !== 0) {
// 更新当前项
this.positions[index].height = realHeight;
this.positions[index].bottom += diff;
// 更新后续所有项的位置
for (let j = index + 1; j < this.positions.length; j++) {
this.positions[j].top = this.positions[j - 1].bottom;
this.positions[j].bottom = this.positions[j].top + this.positions[j].height;
}
}
}
// 更新总高度
this.phantom.style.height = `${this.getTotalHeight()}px`;
}
}
上面 updatePositions 中的循环更新后续所有项的位置在数据量极大时可能有性能问题。生产环境中常见的优化手段包括:延迟更新(只更新当前可视区域附近的位置)、使用前缀和数组加速偏移量计算等。
缓冲区的作用
缓冲区(Buffer Zone)是虚拟列表不可或缺的优化手段。如果只渲染可视区域内的项,快速滚动时用户会看到白屏闪烁,因为新项还来不及渲染。
// ✅ 加入缓冲区的索引计算
const bufferCount = 5;
const startIndex = Math.max(0, rawStartIndex - bufferCount);
const endIndex = Math.min(totalCount, rawEndIndex + bufferCount);
缓冲区通常设置为 可视区域可见条数的 1/3 到 1/2。太小起不到效果,太大会增加不必要的 DOM 节点。例如可视区域展示 10 条,缓冲区设置为 3~5 条即可。
滚动事件优化
虚拟列表依赖滚动事件驱动更新,高频触发的 scroll 事件需要做性能优化。
requestAnimationFrame 节流
// ❌ 直接监听 scroll —— 每像素都触发计算
container.addEventListener('scroll', handleScroll);
// ✅ 使用 requestAnimationFrame 节流
let ticking = false;
container.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
handleScroll();
ticking = false;
});
ticking = true;
}
});
IntersectionObserver 方案
另一种现代方案是使用 IntersectionObserver 来代替滚动事件监听:
// ✅ 在列表顶部和底部放置哨兵元素
const topSentinel = document.createElement('div');
const bottomSentinel = document.createElement('div');
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.target === topSentinel && entry.isIntersecting) {
// 哨兵进入视口 → 向上加载更多
loadPrevItems();
}
if (entry.target === bottomSentinel && entry.isIntersecting) {
// 哨兵进入视口 → 向下加载更多
loadNextItems();
}
});
},
{ root: container }
);
observer.observe(topSentinel);
observer.observe(bottomSentinel);
方案对比
面对大数据列表,常见的处理方式有三种:
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 分页加载 | 每次只请求和渲染一页数据 | 实现简单,服务端压力可控 | 用户需手动翻页,体验割裂 | 后台管理系统、搜索结果 |
| 无限滚动 | 滚动到底部时追加加载下一批 | 交互自然,用户无感 | DOM 持续增长,越滚越卡 | 社交媒体信息流(数据量有限) |
| 虚拟列表 | 只渲染可视区域 DOM | DOM 数量恒定,性能稳定 | 实现复杂,滚动条可能跳动 | 超大数据列表(万级以上) |
主流虚拟列表库
在实际项目中,推荐使用成熟的开源库而非从零实现:
React 生态
| 库名 | 特点 | 适用场景 |
|---|---|---|
| react-window | 轻量(~6KB),API 简洁 | 大部分虚拟列表场景(推荐首选) |
| react-virtuoso | 功能丰富,自动检测高度 | 动态高度、分组列表、聊天列表 |
| react-virtualized | 功能全面但体积大 | 需要虚拟表格、多方向滚动等复杂场景 |
| @tanstack/react-virtual | Headless,框架无关核心 | 需要高度自定义渲染的场景 |
// ✅ react-window 使用示例
import { FixedSizeList } from 'react-window';
function VirtualList({ data }) {
return (
<FixedSizeList
height={500} // 可视区域高度
width="100%" // 宽度
itemCount={data.length} // 数据总量
itemSize={50} // 每项高度(固定)
>
{({ index, style }) => (
<div style={style} className="list-item">
{data[index].name}
</div>
)}
</FixedSizeList>
);
}
// ✅ 动态高度:使用 VariableSizeList
import { VariableSizeList } from 'react-window';
function DynamicVirtualList({ data }) {
const getItemSize = (index) => {
// 根据内容动态返回高度
return data[index].content.length > 100 ? 120 : 60;
};
return (
<VariableSizeList
height={500}
width="100%"
itemCount={data.length}
itemSize={getItemSize}
>
{({ index, style }) => (
<div style={style} className="list-item">
{data[index].content}
</div>
)}
</VariableSizeList>
);
}
Vue 生态
<!-- ✅ vue-virtual-scroller 使用示例 -->
<template>
<RecycleScroller
class="scroller"
:items="items"
:item-size="50"
key-field="id"
>
<template #default="{ item }">
<div class="list-item">
{{ item.name }}
</div>
</template>
</RecycleScroller>
</template>
<script setup>
import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
const items = Array.from({ length: 100000 }, (_, i) => ({
id: i,
name: `第 ${i + 1} 条数据`,
}));
</script>
<style scoped>
.scroller {
height: 500px;
}
</style>
虚拟列表的局限性
虚拟列表虽然强大,但并非完美方案,使用时需要注意以下局限:
| 局限性 | 说明 | 应对策略 |
|---|---|---|
| 滚动条跳动 | 动态高度场景下,预估高度与真实高度不一致会导致滚动条位置跳动 | 尽量精确的预估高度,渲染后即时修正 |
| 搜索/定位困难 | 浏览器原生 Ctrl+F 无法搜索未渲染的内容 | 自行实现搜索功能,搜索后滚动到目标位置 |
| 无障碍访问 | 屏幕阅读器可能无法正确读取动态替换的内容 | 添加合适的 ARIA 属性,保持语义化 |
| SEO 不友好 | 搜索引擎爬虫无法抓取未渲染的内容 | 结合 SSR 或提供替代方案 |
| 实现复杂度 | 相比普通列表渲染,增加了不少开发和维护成本 | 使用成熟的开源库,避免重复造轮子 |
如果列表总数在 200 条以内,直接渲染所有 DOM 通常不会有明显性能问题,无需引入虚拟列表的额外复杂度。
面试高频问答
Q1:什么是虚拟列表?它解决了什么问题?
答:虚拟列表是一种按需渲染的长列表优化方案。它的核心思想是:不管数据有多少条,只渲染用户当前可视区域内的 DOM 节点(通常 10~20 个),通过监听滚动事件动态替换渲染内容。
它主要解决大数据量列表(数千~数万条)的渲染性能问题:
- 大量 DOM 节点导致首屏渲染慢
- 内存占用过高导致页面卡顿甚至崩溃
- 滚动时帧率下降,用户体验差
Q2:虚拟列表的核心计算逻辑是什么?
答:核心是根据滚动位置计算应该渲染哪些数据:
- 获取滚动距离:
scrollTop = container.scrollTop - 计算起始索引:
startIndex = Math.floor(scrollTop / itemHeight) - 计算结束索引:
endIndex = startIndex + visibleCount - 截取可见数据:
visibleData = data.slice(startIndex, endIndex) - 计算偏移量:
offset = startIndex * itemHeight,通过transform: translateY(offset)将内容定位到正确位置
同时需要一个与总数据高度等高的占位元素来产生真实的滚动条效果。
Q3:固定高度和动态高度的虚拟列表有什么区别?
答:
| 对比项 | 固定高度 | 动态高度 |
|---|---|---|
| 高度计算 | 每项高度已知,直接用 index × itemHeight | 需要维护高度缓存数组,渲染后测量真实高度 |
| 索引定位 | 直接除法计算:Math.floor(scrollTop / itemHeight) | 需要二分查找在缓存数组中定位 |
| 总高度 | totalCount × itemHeight,一次计算 | 初始用预估高度,渲染后逐步修正 |
| 实现难度 | 简单 | 较复杂 |
| 适用场景 | 列表项结构统一,如表格行 | 列表项内容不等长,如聊天消息、评论 |
Q4:虚拟列表中为什么需要缓冲区(Buffer)?
答:如果只渲染可视区域内的列表项,当用户快速滚动时,新的列表项需要先计算再渲染,这个过程中用户会看到白屏闪烁。
缓冲区的做法是在可视区域的上方和下方各多渲染几条数据(通常 3~5 条),这样在滚动时,下一批要出现在视口的内容已经提前准备好了,从而实现更平滑的滚动体验。
缓冲区大小一般为可视条数的 1/3 到 1/2,例如可视区域展示 10 条,缓冲区设为 3~5 条。
Q5:虚拟列表如何保持滚动条的真实行为?
答:通过一个占位元素(Phantom Element) 实现。
这个占位元素的高度等于所有列表项的总高度(totalCount × itemHeight),它本身不渲染任何内容,唯一的作用是撑开容器的可滚动区域,让浏览器产生对应长度的滚动条。用户拖动滚动条时,感觉和滚动一个真实的长列表完全一样。
实际渲染的内容区域使用 position: absolute 定位,通过 transform: translateY(offset) 将其放置在正确的滚动位置。
Q6:虚拟列表的滚动事件如何做性能优化?
答:常见的优化手段有:
- requestAnimationFrame 节流:确保每帧最多计算一次,避免同一帧内重复触发
- IntersectionObserver 哨兵方案:在列表顶部和底部放置哨兵元素,通过观察哨兵的可见性触发更新,替代 scroll 事件
- 减少 DOM 操作:复用已有 DOM 节点(对象池),而非每次销毁重建
- 避免强制同步布局:在读取 DOM 尺寸(
getBoundingClientRect)前,先批量完成所有写操作
Q7:虚拟列表、无限滚动、分页加载有什么区别?
答:
| 方案 | DOM 数量 | 数据加载方式 | 滚动性能 | 适用场景 |
|---|---|---|---|---|
| 分页加载 | 当前页的少量 DOM | 每次请求一页 | 好 | 后台管理、搜索结果 |
| 无限滚动 | 持续增长 | 滚到底部追加请求 | 越滚越差 | 社交信息流(数据量有限) |
| 虚拟列表 | 恒定少量 DOM | 数据全部在内存中 | 始终流畅 | 万级以上大数据列表 |
关键区别在于 DOM 节点数量:分页和虚拟列表都能控制 DOM 数量,但虚拟列表提供了连续滚动的体验;无限滚动的 DOM 会随数据增长而膨胀。
Q8:为什么动态高度虚拟列表使用二分查找来定位索引?
答:在动态高度场景下,每个列表项的高度不同,无法通过简单的 Math.floor(scrollTop / itemHeight) 计算 startIndex。
我们维护了一个位置缓存数组 positions,其中每项记录了 { top, bottom, height }。要根据 scrollTop 找到第一个 bottom > scrollTop 的项,如果用线性查找,时间复杂度为 O(n);而这个数组的 bottom 值是单调递增的,因此可以使用二分查找将时间复杂度降低到 O(log n),在 10 万条数据中也只需约 17 次比较即可定位。
Q9:在 React 中使用虚拟列表推荐哪个库?
答:推荐选择策略:
- 大部分场景首选
react-window:轻量(~6KB gzip),API 简洁,支持固定高度(FixedSizeList)和动态高度(VariableSizeList),是react-virtualized作者的精简重写版 - 动态高度/聊天列表选
react-virtuoso:自动测量高度,内置分组、倒序(聊天)、置顶等功能,开箱即用 - 需要最大灵活性选
@tanstack/react-virtual:Headless 设计,只提供计算逻辑不包含 UI,适合需要高度自定义渲染的场景
Q10:虚拟列表有哪些局限性?
答:主要有五个方面的局限:
- 浏览器搜索失效:Ctrl+F 无法搜索到未渲染的 DOM 内容,需要自行实现搜索定位功能
- 滚动条跳动:动态高度场景下,预估高度与真实高度的差异会导致滚动条位置抖动
- 无障碍受限:屏幕阅读器可能无法正确朗读动态替换的内容,需要额外的 ARIA 属性支持
- SEO 不友好:搜索引擎爬虫无法抓取未渲染的内容,需要结合 SSR
- 实现复杂度高:特别是动态高度场景,建议使用成熟的开源库来降低开发和维护成本