一、什么是虚拟滚动
虚拟滚动是一种长列表优化技术,只渲染当前可视区域内的 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' }"> <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; 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();
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-window | React | 需配合 VariableSizeList |
| react-virtuoso | React | 自动检测 |
| vue-virtual-scroller | Vue | 支持 |
| 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 对象本身也会占用大量内存。此时需要配合服务端分页,按需加载数据。
十、推荐学习路径
- 理解虚拟滚动的原理:固定容器、计算可见范围、只渲染可见项
- 用手写实现理解核心逻辑(本文的 React/Vue 实例)
- 使用成熟库(react-window / TanStack Virtual)到实际项目中
- 处理边缘情况:动态高度、滚动恢复、键盘导航