当你面对一个需要渲染 10 万条数据的列表时,直接 v-for 或 .map() 生成全部 DOM 节点会让页面卡顿超过 3 秒,内存占用飙升到 500MB 以上。虚拟列表(Virtual Scrolling)是目前业界公认的最佳解决方案——它只渲染可视区域内的几十个 DOM 节点,就能让用户流畅浏览百万级数据。本文将从问题本质出发,对比三种主流方案,并给出可直接用于生产的完整代码。
📊 一、为什么大列表会卡?问题的本质
1.1 浏览器渲染流水线的瓶颈
浏览器渲染一个 DOM 节点需要经历 布局(Layout)→ 绘制(Paint)→ 合成(Composite) 三个阶段。当 DOM 节点数量超过 1000 时,布局计算的时间就开始显著增长;超过 5000 个节点后,每次滚动都会触发重新布局,帧率直接跌破 30fps。
我们用一个简单的基准测试来验证:
// 基准测试:测量不同 DOM 节点数量下的渲染时间
function benchmarkRender(count) {
const container = document.createElement('div')
document.body.appendChild(container)
const start = performance.now()
for (let i = 0; i < count; i++) {
const item = document.createElement('div')
item.className = 'list-item'
item.textContent = `Item ${i} - 这是一段用于测试的文本内容`
container.appendChild(item)
}
const layoutTime = performance.now() - start
document.body.removeChild(container)
return { count, layoutTime: layoutTime.toFixed(2) }
}
// 测试结果(Chrome 126, M2 MacBook Air):
// 1,000 条 → 12ms
// 5,000 条 → 58ms
// 10,000 条 → 142ms
// 50,000 条 → 890ms
// 100,000 条 → 2100ms(页面完全卡死约 2 秒)
1.2 三种方案的性能对比
| 方案 | 10 万条渲染时间 | 滚动帧率 | 内存占用 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|---|
| 原生 DOM 渲染 | ~2100ms | < 10fps | ~500MB | ⭐ 无 | 仅适合 < 500 条 |
| CSS content-visibility | ~2100ms(首次) | 55-60fps | ~180MB | ⭐ 简单 | 中等列表(< 5000 条) |
| Intersection Observer | ~180ms | 58-60fps | ~60MB | ⭐⭐ 中等 | 无限滚动加载 |
| 虚拟列表(Virtual Scroll) | ~8ms | 60fps | ~5MB | ⭐⭐⭐ 较高 | 超大列表(> 1 万条) |
⚡ 关键结论: 当列表超过 5000 条时,虚拟列表的渲染时间是原生方案的 1/260,内存占用是 1/100。这个差距在移动端更为显著。
1.3 什么场景需要虚拟列表?
不是所有列表都需要虚拟列表。根据我的经验,以下场景必须使用:
- ✅ 日志查看器(动辄数十万行)
- ✅ 数据表格(后台管理系统的长表格)
- ✅ 聊天消息列表(历史消息加载)
- ✅ 电商商品瀑布流(无限滚动 + 大量图片)
- ✅ 代码编辑器(VS Code 就用了虚拟滚动)
以下场景不需要:
- ❌ 导航菜单(通常 < 50 项)
- ❌ 分页表格(每页 20 条,用分页即可)
- ❌ 短列表(< 200 条,直接渲染无压力)
🔧 二、三种方案的实现与对比
2.1 方案一:CSS content-visibility(最简单)
content-visibility: auto 是 CSS 容器查询规范的一部分,它告诉浏览器跳过不在视口内元素的渲染工作。这是成本最低的优化方案,只需要一行 CSS。
/* 一行 CSS 就能让大列表变流畅 */
.list-item {
content-visibility: auto;
contain-intrinsic-size: 0 40px; /* 预估每项高度 40px */
}
// 完整示例:content-visibility 优化大列表
// 优点:零 JavaScript,兼容性好(Chrome 85+, Firefox 125+)
// 缺点:首次渲染仍需创建所有 DOM 节点,内存未优化
function renderSimpleList(items) {
const container = document.getElementById('list')
const fragment = document.createDocumentFragment()
items.forEach((item, index) => {
const div = document.createElement('div')
div.className = 'list-item'
div.textContent = `${index}: ${item.title}`
fragment.appendChild(div)
})
container.appendChild(fragment)
}
// 配合 CSS 使用:
// .list-container { height: 600px; overflow-y: auto; }
// .list-item { content-visibility: auto; contain-intrinsic-size: 0 40px; }
💡 提示:
content-visibility: auto适合 1000-5000 条的中等列表。它解决了滚动卡顿问题,但没有解决首次渲染慢和内存占用高的问题。
2.2 方案二:Intersection Observer + 懒加载
这是无限滚动(Infinite Scroll)的经典实现方式。核心思想是:先渲染前 N 条,当用户滚动到底部时再加载更多。
// 完整可运行的无限滚动实现
// 适用于:电商商品列表、社交媒体 Feed
class InfiniteScroller {
constructor(container, loadMore) {
this.container = container
this.loadMore = loadMore
this.loading = false
this.page = 1
// 创建哨兵元素(sentinel)
this.sentinel = document.createElement('div')
this.sentinel.className = 'scroll-sentinel'
this.sentinel.style.height = '1px'
container.appendChild(this.sentinel)
// 使用 Intersection Observer 监测哨兵是否进入视口
this.observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !this.loading) {
this.loadNextPage()
}
},
{ root: container, threshold: 0 }
)
this.observer.observe(this.sentinel)
}
async loadNextPage() {
this.loading = true
this.sentinel.textContent = '加载中...'
try {
const items = await this.loadMore(this.page)
items.forEach(item => {
const div = document.createElement('div')
div.className = 'list-item'
div.textContent = item.title
// 在哨兵之前插入新元素
this.container.insertBefore(div, this.sentinel)
})
this.page++
} catch (err) {
console.error('加载失败:', err)
} finally {
this.loading = false
this.sentinel.textContent = ''
}
}
destroy() {
this.observer.disconnect()
}
}
// 使用示例
const container = document.getElementById('infinite-list')
const scroller = new InfiniteScroller(container, async (page) => {
const res = await fetch(`/api/items?page=${page}&size=20`)
return res.json()
})
⚠️ 警告: 无限滚动的问题在于 DOM 节点会持续增长。当用户滚动了 100 页(每页 20 条 = 2000 个 DOM 节点)后,性能就开始下降。需要配合上方节点回收机制使用。
2.3 方案三:虚拟列表(Virtual Scrolling)—— 终极方案
虚拟列表的核心原理极其简单:只渲染可视区域内的元素,用一个撑高容器模拟完整滚动条。
┌─────────────────────┐
│ 撑高元素 (8px × 100000) │ ← 模拟完整滚动高度
│ ┌───────────────┐ │
│ │ 可视区域 │ │ ← 只渲染这 20-30 个 DOM 节点
│ │ Item 501 │ │
│ │ Item 502 │ │
│ │ Item 503 │ │
│ │ ... │ │
│ │ Item 520 │ │
│ └───────────────┘ │
└─────────────────────┘
下面是一个完整的、可直接运行的虚拟列表实现:
// 完整虚拟列表实现 - 纯 JavaScript,零依赖
// 支持:固定高度、动态高度、滚动到指定位置
class VirtualList {
constructor(options) {
this.container = options.container
this.itemCount = options.itemCount
this.estimateHeight = options.itemHeight || 40
this.renderItem = options.renderItem
this.overscan = options.overscan || 5 // 预渲染上下各 5 个
// 记录每项的实际高度(用于动态高度)
this.itemHeights = new Map()
// 缓存已渲染的 DOM 节点
this.renderedNodes = new Map()
this.init()
}
init() {
// 创建滚动容器
this.scrollContainer = document.createElement('div')
this.scrollContainer.style.cssText = `
height: ${this.container.clientHeight}px;
overflow-y: auto;
position: relative;
`
// 创建撑高元素
this.spacer = document.createElement('div')
this.spacer.style.height = `${this.itemCount * this.estimateHeight}px`
this.scrollContainer.appendChild(this.spacer)
// 创建可视区域容器
this.viewport = document.createElement('div')
this.viewport.style.cssText = 'position: sticky; top: 0;'
this.scrollContainer.appendChild(this.viewport)
this.container.appendChild(this.scrollContainer)
// 绑定滚动事件(使用 RAF 节流)
this.ticking = false
this.scrollContainer.addEventListener('scroll', () => {
if (!this.ticking) {
requestAnimationFrame(() => {
this.render()
this.ticking = false
})
this.ticking = true
}
})
this.render()
}
getItemTop(index) {
let top = 0
for (let i = 0; i < index; i++) {
top += this.itemHeights.get(i) || this.estimateHeight
}
return top
}
getVisibleRange() {
const scrollTop = this.scrollContainer.scrollTop
const containerHeight = this.scrollContainer.clientHeight
// 二分查找第一个可见元素
let start = 0
let top = 0
for (let i = 0; i < this.itemCount; i++) {
const height = this.itemHeights.get(i) || this.estimateHeight
if (top + height > scrollTop) {
start = i
break
}
top += height
}
// 找到最后一个可见元素
let end = start
let visibleTop = top
for (let i = start; i < this.itemCount; i++) {
const height = this.itemHeights.get(i) || this.estimateHeight
visibleTop += height
end = i
if (visibleTop > scrollTop + containerHeight) break
}
// 添加 overscan 缓冲区
start = Math.max(0, start - this.overscan)
end = Math.min(this.itemCount - 1, end + this.overscan)
return { start, end }
}
render() {
const { start, end } = this.getVisibleRange()
// 收集当前需要的节点
const needed = new Set()
for (let i = start; i <= end; i++) needed.add(i)
// 移除不在范围内的节点
for (const [index, node] of this.renderedNodes) {
if (!needed.has(index)) {
node.remove()
this.renderedNodes.delete(index)
}
}
// 添加新节点
const offsetTop = this.getItemTop(start)
for (let i = start; i <= end; i++) {
if (!this.renderedNodes.has(i)) {
const node = this.renderItem(i)
node.style.position = 'absolute'
node.style.top = `${this.getItemTop(i)}px`
node.style.width = '100%'
this.viewport.appendChild(node)
this.renderedNodes.set(i, node)
// 测量实际高度(用于动态高度支持)
const rect = node.getBoundingClientRect()
if (Math.abs(rect.height - this.estimateHeight) > 1) {
this.itemHeights.set(i, rect.height)
// 高度变化时更新 spacer
this.updateSpacer()
}
}
}
}
updateSpacer() {
let totalHeight = 0
for (let i = 0; i < this.itemCount; i++) {
totalHeight += this.itemHeights.get(i) || this.estimateHeight
}
this.spacer.style.height = `${totalHeight}px`
}
scrollToIndex(index, align = 'start') {
const top = this.getItemTop(index)
if (align === 'center') {
this.scrollContainer.scrollTop = top - this.scrollContainer.clientHeight / 2
} else {
this.scrollContainer.scrollTop = top
}
}
updateCount(newCount) {
this.itemCount = newCount
this.updateSpacer()
this.render()
}
destroy() {
this.container.innerHTML = ''
this.renderedNodes.clear()
this.itemHeights.clear()
}
}
// 使用示例
const list = new VirtualList({
container: document.getElementById('app'),
itemCount: 100000,
itemHeight: 40,
overscan: 10,
renderItem: (index) => {
const div = document.createElement('div')
div.className = 'virtual-item'
div.style.cssText = 'padding: 8px 16px; border-bottom: 1px solid #eee;'
div.textContent = `第 ${index} 条数据 — 虚拟列表只渲染可视区域内的元素`
return div
}
})
📌 记住: 虚拟列表的核心公式只有三个:① 撑高容器 = 总条数 × 预估高度;② 可视起始位置 = scrollTop / 单项高度;③ 偏移量 = getItemTop(start)。
🚀 三、主流虚拟列表库对比与选型
3.1 框架生态全景
在实际项目中,自己手写虚拟列表成本较高,推荐使用成熟的开源库。以下是 2026 年最主流的选择:
| 库名 | 框架 | 体积 | 动态高度 | 多列/网格 | 水平滚动 | GitHub Stars |
|---|---|---|---|---|---|---|
| TanStack Virtual | 框架无关 | ~5KB | ✅ | ✅ | ✅ | 6.5k+ |
| vue-virtual-scroller | Vue 2/3 | ~8KB | ✅ | ❌ | ❌ | 9k+ |
| react-window | React | ~6KB | ⚠️ 需 FixedSizeList | ✅ | ✅ | 15k+ |
| react-virtuoso | React | ~12KB | ✅ | ✅ | ✅ | 4k+ |
| @tanstack/react-virtual | React | ~5KB | ✅ | ✅ | ✅ | 同 TanStack |
⚡ 关键结论: 如果你在 2026 年开始新项目,TanStack Virtual 是最佳选择——它框架无关(支持 React、Vue、Svelte、Solid),体积小,功能全面,且由 TanStack 团队维护(和 TanStack Query 同一个团队)。
3.2 TanStack Virtual 实战示例
// React + TanStack Virtual 完整示例
// npm install @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
function LargeList({ items }) {
const parentRef = useRef(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // 预估每项高度
overscan: 10, // 上下各多渲染 10 个
})
return (
<div
ref={parentRef}
style={{ height: '600px', overflow: 'auto' }}
>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
ref={virtualizer.measureElement}
data-index={virtualRow.index}
>
<div className="item-card">
{items[virtualRow.index].title}
</div>
</div>
))}
</div>
</div>
)
}
3.3 Vue 3 + TanStack Virtual 示例
<!-- Vue 3 + TanStack Virtual 虚拟列表组件 -->
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
const props = defineProps({
items: { type: Array, required: true },
itemHeight: { type: Number, default: 50 }
})
const parentRef = ref(null)
const virtualizer = useVirtualizer({
count: computed(() => props.items.length),
getScrollElement: () => parentRef.value,
estimateSize: () => props.itemHeight,
overscan: 8,
})
const virtualItems = computed(() => virtualizer.value.getVirtualItems())
const totalSize = computed(() => virtualizer.value.getTotalSize())
</script>
<template>
<div ref="parentRef" class="virtual-scroll-container">
<div :style="{ height: `${totalSize}px`, position: 'relative' }">
<div
v-for="row in virtualItems"
:key="row.key"
:style="{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${row.start}px)` }"
:data-index="row.index"
:ref="(el) => el && virtualizer.measureElement(el)"
>
<slot :item="items[row.index]" :index="row.index" />
</div>
</div>
</div>
</template>
<style scoped>
.virtual-scroll-container {
height: 600px;
overflow-y: auto;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
</style>
⚠️ 四、避坑指南:生产环境的 7 个陷阱
4.1 动态高度计算不准
虚拟列表最大的坑就是动态高度。当每项高度不同时,预估高度偏移会导致滚动跳动。
// ❌ 错误写法:用固定高度处理动态内容
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // 实际有的项 200px,有的 30px
})
// ✅ 正确写法:使用 measureElement 回调动态测量
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // 初始预估值
overscan: 5,
measureElement: (element) => {
// 每次渲染后自动测量实际高度并缓存
return element.getBoundingClientRect().height
},
})
⚠️ 警告: 动态高度场景下,预估高度偏差不要超过实际平均高度的 2 倍,否则会出现滚动条长度不稳定的问题。建议用历史数据的平均值作为预估值。
4.2 图片懒加载与虚拟列表的冲突
虚拟列表会频繁销毁和重建 DOM 节点,这意味着图片的 IntersectionObserver 懒加载会失效。
// ❌ 错误写法:在虚拟列表项中使用独立的 IntersectionObserver
// 节点被回收后重新创建时,observer 可能不会重新触发
<img data-src="/photo.jpg" class="lazy" />
// ✅ 正确写法:使用 loading="lazy" 原生属性 + 预设尺寸
<img src="/photo.jpg" loading="lazy" width="200" height="150" />
// 或者在虚拟列表的 renderItem 中直接设置 src(配合 overscan 缓冲)
renderItem(index) {
const img = document.createElement('img')
img.src = items[index].imageUrl // 直接设置,因为只渲染可视区域
img.loading = 'lazy'
img.width = 200
img.height = 150
return img
}
4.3 键盘导航和无障碍访问
虚拟列表只渲染部分 DOM,这对键盘导航和屏幕阅读器提出了挑战。
// ✅ 正确做法:添加 ARIA 属性支持无障碍访问
function renderVirtualItem(index, item) {
const div = document.createElement('div')
div.setAttribute('role', 'option')
div.setAttribute('aria-setsize', totalCount)
div.setAttribute('aria-posinset', index + 1)
div.setAttribute('id', `virtual-item-${index}`)
div.setAttribute('tabindex', index === focusedIndex ? '0' : '-1')
div.textContent = item.title
return div
}
// 容器需要 role="listbox"
container.setAttribute('role', 'listbox')
container.setAttribute('aria-label', '数据列表')
container.setAttribute('aria-rowcount', totalCount)
4.4 搜索和筛选时的位置重置
当列表数据变化(搜索、筛选)时,必须重置滚动位置,否则用户会看到空白区域。
// ✅ 数据变化时重置虚拟列表
function onSearchResults(newItems) {
items = newItems
// TanStack Virtual 的重置方式
virtualizer.scrollToIndex(0, { align: 'start' })
// 自己实现的虚拟列表需要重置 scrollTop
scrollContainer.scrollTop = 0
updateCount(newItems.length)
}
4.5 回到顶部和定位到某条
虚拟列表的一个常见需求是「滚动到指定位置」,比如聊天记录中的 @ 消息。
// 滚动到指定索引,支持三种对齐方式
virtualizer.scrollToIndex(5000, { align: 'start' }) // 滚动到顶部
virtualizer.scrollToIndex(5000, { align: 'center' }) // 居中显示
virtualizer.scrollToIndex(5000, { align: 'end' }) // 滚动到底部
// 带平滑动画的滚动(需要手动实现)
function smoothScrollToIndex(index) {
const targetTop = calculateItemTop(index)
const startTop = scrollContainer.scrollTop
const distance = targetTop - startTop
const duration = 300
const startTime = performance.now()
function animate(currentTime) {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
// ease-out 缓动函数
const ease = 1 - Math.pow(1 - progress, 3)
scrollContainer.scrollTop = startTop + distance * ease
if (progress < 1) requestAnimationFrame(animate)
}
requestAnimationFrame(animate)
}
4.6 分组列表(Section Header)
分组列表需要在虚拟列表中插入固定高度的分组标题,并处理标题吸顶效果。
// 分组虚拟列表的数据结构
const sections = [
{ title: 'A', items: ['Alice', 'Amy', 'Andy'] },
{ title: 'B', items: ['Bob', 'Ben', 'Betty'] },
// ...
]
// 将分组数据扁平化为虚拟列表可用的格式
function flattenSections(sections) {
const flat = []
sections.forEach(section => {
flat.push({ type: 'header', title: section.title })
section.items.forEach(item => {
flat.push({ type: 'item', data: item, section: section.title })
})
})
return flat
}
// 渲染时根据 type 返回不同的 DOM 结构
renderItem(index) {
const entry = flatItems[index]
if (entry.type === 'header') {
const header = document.createElement('div')
header.className = 'section-header'
header.style.cssText = 'position: sticky; top: 0; background: #f5f5f5;'
header.textContent = entry.title
return header
}
// 普通列表项...
}
4.7 移动端触控优化
移动端需要特别注意触摸滚动的流畅度和惯性滚动的兼容性。
/* 移动端虚拟列表的 CSS 优化 */
.virtual-scroll-container {
/* 启用硬件加速滚动 */
-webkit-overflow-scrolling: touch;
/* 使用 overscroll-behavior 防止滚动穿透 */
overscroll-behavior: contain;
/* 减少重绘 */
will-change: scroll-position;
}
💡 五、总结与建议
虚拟列表不是银弹,但在正确的场景下,它是解决大列表渲染性能问题的唯一可靠方案。以下是我在生产环境中的建议:
- ✅ 500 条以下 — 直接渲染,不需要任何优化
- ✅ 500-5000 条 — 使用
content-visibility: auto一行 CSS 搞定 - ✅ 5000-10000 条 — Intersection Observer + 分页加载
- ✅ 10000 条以上 — 必须使用虚拟列表
- ❌ 不要在数据量小时过度优化,虚拟列表会增加代码复杂度
- ❌ 不要自己从头造轮子,除非有特殊需求,直接用 TanStack Virtual
对于 jsjson.com 这类工具型网站,虚拟列表特别适合 JSON 格式化工具中处理大型 JSON 数组的场景——当用户粘贴一个包含数万条记录的 JSON 数组时,虚拟列表可以让预览面板流畅滚动,而不是让整个页面卡死。
📌 记住: 性能优化的核心原则是「不渲染不存在的东西」。虚拟列表就是这个原则的最佳实践——用户看到多少,我们就渲染多少。