虚拟滚动(Virtual Scroll)详解

一、什么是虚拟滚动

虚拟滚动是一种长列表优化技术,只渲染当前可视区域内的 DOM 元素,而非渲染列表中的所有数据。无论数据总量多大(数万甚至百万条),页面上始终保持固定数量的 DOM 节点。

为什么需要虚拟滚动

1
2
3
4
5
6
7
传统渲染方式:
10000 条数据 → 10000 个 DOM 节点
→ 页面卡顿、内存占用高、滚动掉帧

虚拟滚动:
10000 条数据 → ~20 个 DOM 节点(仅可视区域)
→ 内存恒定、滚动流畅

二、核心原理

虚拟滚动的核心思路是用 JS 模拟滚动条,根据滚动位置计算出当前应该显示哪些数据项,只渲染这些项,并利用绝对定位或 transform 控制它们在容器中的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌───────────────┐  ← 容器(固定高度,overflow: auto)
│ 可视区域 │
│ ┌───────────┐│
│ │ 项 5 ││ ← 只渲染这 ~10 个
│ │ 项 6 ││
│ │ 项 7 ││
│ └───────────┘│
│ │
│ (滚动条) │
│ │
│ ┌───────────┐│
│ │ 项 100 ││ ← 未进入可视区,不渲染
│ │ 项 101 ││
│ └───────────┘│
└───────────────┘

三、手写虚拟滚动

基础实现

1
2
3
4
5
6
7
8
9
10
11
<div ref="container" class="viewport" @scroll="onScroll">
<!-- 占位元素:撑起滚动条高度 -->
<div class="scroll-bar" :style="{ height: totalHeight + 'px' }">
<!-- 可见项容器:通过 translateY 定位 -->
<div class="visible-items" :style="{ transform: `translateY(${offsetY}px)` }">
<div v-for="item in visibleItems" :key="item.id" class="item">
{{ item.name }}
</div>
</div>
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 核心逻辑
const ITEM_HEIGHT = 50; // 每项固定高度(px)
const BUFFER = 2; // 缓冲项数(上下多渲染几项,避免滚动闪白)

function VirtualScroll({ items, containerHeight }) {
const totalCount = items.length;
const totalHeight = totalCount * ITEM_HEIGHT;

// 可见项数量
const visibleCount = Math.ceil(containerHeight / ITEM_HEIGHT) + BUFFER * 2;

// 根据滚动位置计算起始索引
let startIndex = Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER;
startIndex = Math.max(0, startIndex);

const visibleItems = items.slice(startIndex, startIndex + visibleCount);
const offsetY = startIndex * ITEM_HEIGHT;

return { visibleItems, offsetY, totalHeight };
}

关键参数说明

参数含义建议值
ITEM_HEIGHT每项高度固定值(动态高度需额外计算)
BUFFER缓冲项数2-5(过大会增加 DOM 数量)
containerHeight容器可视高度动态获取 clientHeight

四、React 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { useState, useRef, useMemo, useCallback } from 'react';

function VirtualList({ items, itemHeight = 50, containerHeight = 600 }) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef(null);

const totalHeight = items.length * itemHeight;
const visibleCount = Math.ceil(containerHeight / itemHeight) + 4;
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - 2);
const offsetY = startIndex * itemHeight;

const visibleItems = useMemo(
() => items.slice(startIndex, startIndex + visibleCount),
[items, startIndex, visibleCount]
);

const onScroll = useCallback(() => {
setScrollTop(containerRef.current.scrollTop);
}, []);

return (
<div
ref={containerRef}
onScroll={onScroll}
style={{ height: containerHeight, overflow: 'auto' }}
>
<div style={{ height: totalHeight, position: 'relative' }}>
<div style={{ transform: `translateY(${offsetY}px)` }}>
{visibleItems.map((item) => (
<div key={item.id} style={{ height: itemHeight }}>
{item.name}
</div>
))}
</div>
</div>
</div>
);
}

五、Vue 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<template>
<div ref="container" class="viewport" @scroll="onScroll">
<div class="scroll-bar" :style="{ height: totalHeight + 'px' }">
<div class="visible-area" :style="{ transform: `translateY(${offsetY}px)` }">
<div
v-for="item in visibleItems"
:key="item.id"
class="item"
:style="{ height: itemHeight + 'px' }"
>
{{ item.name }}
</div>
</div>
</div>
</div>
</template>

<script setup>
import { ref, computed } from 'vue';

const props = defineProps({
items: Array,
itemHeight: { type: Number, default: 50 },
containerHeight: { type: Number, default: 600 },
});

const scrollTop = ref(0);

const totalHeight = computed(() => props.items.length * props.itemHeight);
const visibleCount = computed(() => Math.ceil(props.containerHeight / props.itemHeight) + 4);
const startIndex = computed(() => Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - 2));
const visibleItems = computed(() =>
props.items.slice(startIndex.value, startIndex.value + visibleCount.value)
);
const offsetY = computed(() => startIndex.value * props.itemHeight);

function onScroll(e) {
scrollTop.value = e.target.scrollTop;
}
</script>

六、动态高度处理

当列表项高度不固定时,需要维护一个高度数组来记录每项的真实高度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 动态高度的虚拟滚动
const heights = new Map(); // index → 实际高度

function getItemHeight(index) {
return heights.get(index) ?? DEFAULT_HEIGHT;
}

function getOffsetY(index) {
let offset = 0;
for (let i = 0; i < index; i++) {
offset += getItemHeight(i);
}
return offset;
}

function getTotalHeight() {
return items.reduce((sum, _, i) => sum + getItemHeight(i), 0);
}

// 组件渲染后更新实际高度
function onItemMounted(index, el) {
heights.set(index, el.offsetHeight);
// 触发重新计算
}

社区方案可直接使用现成库:

框架动态高度
react-windowReact需配合 VariableSizeList
react-virtuosoReact自动检测
vue-virtual-scrollerVue支持
TanStack Virtual框架无关支持

七、滚动位置恢复

在 SPA 中,用户从列表页进入详情页再返回时,需要恢复滚动位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function ListPage() {
const savedPosition = sessionStorage.getItem('listScrollTop');
const [scrollTop, setScrollTop] = useState(Number(savedPosition) || 0);

// 恢复位置
useEffect(() => {
if (containerRef.current) {
containerRef.current.scrollTop = scrollTop;
}
}, []);

// 离开前保存
useEffect(() => {
return () => {
sessionStorage.setItem('listScrollTop', scrollTop);
};
}, [scrollTop]);

return <div ref={containerRef} onScroll={...}>...</div>;
}

八、场景与选型

数据量推荐方案原因
< 500直接渲染不需要任何优化
500 - 5000分页(客户端或服务端)虚拟滚动收益不大
5000 - 100000虚拟滚动(固定高度)性能优秀,实现简单
> 100000虚拟滚动(动态高度)需配合节流和缓冲
动态高度react-virtuoso / TanStack Virtual自动测量高度

九、常见问题

Q1: 虚拟滚动会导致 SEO 问题吗

虚拟滚动渲染的内容在 DOM 中不完整,搜索引擎无法抓取全部数据。需要进行 SEO 的页面(如电商列表)应使用服务端分页替代虚拟滚动。

Q2: 虚拟滚动中如何实现 Ctrl+F 搜索

浏览器的 Ctrl+F 只能搜索 DOM 中存在的文本。虚拟滚动中大部分数据不在 DOM 中,搜索不到。解决方案:

  • 用搜索框组件替代 Ctrl+F
  • 搜索时切换为全量渲染(数据替换为搜索结果)
  • 使用 contenteditable 或独立的搜索索引

Q3: 虚拟滚动能解决内存问题吗

DOM 数量减少了,但 JS 数据仍然全部在内存中。如果数据总量上百万条,JS 对象本身也会占用大量内存。此时需要配合服务端分页,按需加载数据。

十、推荐学习路径

  1. 理解虚拟滚动的原理:固定容器、计算可见范围、只渲染可见项
  2. 用手写实现理解核心逻辑(本文的 React/Vue 实例)
  3. 使用成熟库(react-window / TanStack Virtual)到实际项目中
  4. 处理边缘情况:动态高度、滚动恢复、键盘导航