什么是虚拟滚动?
想象一下,你有一个包含10,000个项目的待办事项列表。如果一次性将所有项目都渲染到页面上,浏览器需要创建10,000个DOM元素,这会导致:
- 页面加载缓慢
- 内存占用过高
- 滚动卡顿不流畅
虚拟滚动(Virtual Scrolling)就像是一个"智能窗口",它只渲染当前可见区域的内容,而不是整个列表。当用户滚动时,这个"窗口"会动态更新显示的内容。
通俗比喻:把虚拟滚动想象成一个只能看到部分书籍内容的魔法书架。当你上下移动视线时,书架会自动更换显示的书本,但你始终只能看到有限的几本,而不是整个图书馆的所有书籍。
虚拟滚动的原理
虚拟滚动的核心思想很简单:
- 计算可见区域:确定容器的高度和滚动位置
- 计算显示的起始和结束索引:根据每个项目的高度,算出当前应该显示哪些项目
- 只渲染可见项目:仅创建当前可见区域内的DOM元素
- 使用占位元素:用一个具有正确高度的空元素来维持滚动条的准确性
基础实现示例
让我们先看一个简单的JavaScript实现,理解核心逻辑:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>虚拟滚动</title>
</head>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#container {
width: 400px;
height: 400px;
overflow: auto;
margin: 0 auto;
}
#content {
position: relative;
height: 100000px;
}
.item {
width: 100%;
border: 1px solid;
}
</style>
<body>
<div id="container">
<div id="content"></div>
</div>
</body>
<script>
const container = document.getElementById("container");
const content = document.getElementById("content");
const itemHeight = 50;
const totalItems = 2000;
// 渲染可见项目
function renderVisibleItems() {
const scrollTop = container.scrollTop;
console.log(scrollTop, "scrollTop");
// 向下取整
const startIndex = Math.floor(scrollTop / itemHeight);
// 向上取整
const visibleCount = Math.ceil(container.clientHeight / itemHeight);
console.log(visibleCount, "visibleCount");
// 取小
const endIndex = Math.min(startIndex + visibleCount + 5, totalItems); // 多渲染几个作为缓冲
// 清空内容
content.innerHTML = "";
// 创建可见项目
for (let i = startIndex; i < endIndex; i++) {
const item = document.createElement("div");
item.className = "item";
item.style.height = itemHeight + "px";
item.style.position = "absolute";
item.style.top = i * itemHeight + "px";
item.style.width = "100%";
item.textContent = `项目 ${i + 1}`;
content.appendChild(item);
}
}
container.addEventListener("scroll", renderVisibleItems);
renderVisibleItems(); // 初始渲染
</script>
</html>

Vue 3中的虚拟滚动实践
在Vue 3中,我们可以利用Composition API更优雅地实现虚拟滚动。以下是几种实践方式:
1. 使用第三方库(推荐)
对于生产环境,建议使用成熟的虚拟滚动库,如vue-virtual-scroller:
bash
npm install vue-virtual-scroller@next
xml
<template>
<RecycleScroller
class="scroller"
:items="items"
:item-size="50"
key-field="id"
v-slot="{ item }"
>
<div class="user">
{{ item.name }}
</div>
</RecycleScroller>
</template>
<script setup>
import { ref } from 'vue';
import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
// 模拟长列表数据
const items = ref(Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `用户 ${i + 1}`
})));
</script>
<style scoped>
.scroller {
height: 400px;
}
.user {
height: 50px;
display: flex;
align-items: center;
padding: 0 16px;
border-bottom: 1px solid #eee;
}
</style>
2. 自定义虚拟滚动组件
如果你想更深入地理解原理,可以自己实现一个简单的虚拟滚动组件:
我的整体思路如下:滚动区域有3个部分组成,上方占位区,显示的项目,下方占位区。每次滚动不需要去计算每一个展示项目的位置。只需要计算上下占位区的高度与显示的项目。
基于这个思路写出了基本代码,然后让AI优化了代码与处理了边界值相关的一些问题。
xml
<template>
<div class="virtual-scroll-container" ref="viewWrapRef">
<div class="scroll-wrap" :style="{ height: scrollContainerHeight + 'px' }">
<!-- 上方占位区 -->
<div
class="top-placeholder"
:style="{ height: topPlaceholderHeight + 'px' }"
></div>
<!-- 内容区 -->
<div class="content-container">
<div v-for="item in displayItems" :key="item.id" class="item">
<slot name="item" :item="item"></slot>
</div>
</div>
<!-- 下方占位区 -->
<div
class="bottom-placeholder"
:style="{ height: bottomPlaceholderHeight + 'px' }"
></div>
</div>
</div>
</template>
<script setup lang="ts">
import {
ref,
onMounted,
onUnmounted,
computed,
nextTick,
watch,
withDefaults,
} from "vue";
interface Props {
items: any[];
itemHeight: number;
overscan?: number; // 添加缓冲项目数量配置
}
const props = withDefaults(defineProps<Props>(), {
items: () => [],
itemHeight: 50,
overscan: 5, // 默认上下各缓冲5个项目
});
const viewWrapRef = ref<HTMLElement | null>(null);
const startIndex = ref(0);
const animationId = ref<number | null>(null);
// 添加容器高度和滚动位置的状态
const containerHeight = ref(0);
const scrollTop = ref(0);
const scrollContainerHeight = computed(() => {
return props.itemHeight * props.items.length;
});
// 计算可见项目数量(包含缓冲)
const visibleItemCount = computed(() => {
if (containerHeight.value > 0) {
const baseCount = Math.ceil(containerHeight.value / props.itemHeight);
return baseCount + props.overscan * 2; // 上下都添加缓冲
}
return 10; // 默认值
});
// 计算实际显示的项目(包含缓冲)
const displayItems = computed(() => {
if (!visibleItemCount.value || props.items.length === 0) {
return [];
}
// 计算起始索引,考虑缓冲
const start = Math.max(0, startIndex.value - props.overscan);
// 计算结束索引,确保不超出数组范围
const end = Math.min(
props.items.length,
startIndex.value + visibleItemCount.value + props.overscan
);
return props.items.slice(start, end);
});
// 计算上方占位高度
const topPlaceholderHeight = computed(() => {
const start = Math.max(0, startIndex.value - props.overscan);
return start * props.itemHeight;
});
// 计算下方占位高度(确保不为负数)
const bottomPlaceholderHeight = computed(() => {
if (!visibleItemCount.value || props.items.length === 0) {
return 0;
}
const end = Math.min(
props.items.length,
startIndex.value + visibleItemCount.value + props.overscan
);
const bottomHeight = (props.items.length - end) * props.itemHeight;
return Math.max(0, bottomHeight); // 确保不为负数
});
// 监听容器尺寸变化
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
containerHeight.value = entry.contentRect.height;
}
});
onMounted(() => {
nextTick(() => {
if (viewWrapRef.value) {
// 初始化容器高度
containerHeight.value = viewWrapRef.value.clientHeight;
// 监听容器尺寸变化
resizeObserver.observe(viewWrapRef.value);
// 添加滚动事件监听
viewWrapRef.value.addEventListener("scroll", handleScroll, {
passive: true,
});
// 初始计算一次
updateStartIndex();
}
});
});
onUnmounted(() => {
if (viewWrapRef.value) {
viewWrapRef.value.removeEventListener("scroll", handleScroll);
resizeObserver.unobserve(viewWrapRef.value);
}
if (animationId.value) {
cancelAnimationFrame(animationId.value);
}
resizeObserver.disconnect();
});
// 直接处理滚动,不使用防抖(为了更快速响应)
const handleScroll = () => {
if (animationId.value) {
cancelAnimationFrame(animationId.value);
}
animationId.value = requestAnimationFrame(() => {
updateStartIndex();
});
};
// 更新起始索引
const updateStartIndex = () => {
if (!viewWrapRef.value) return;
const currentScrollTop = viewWrapRef.value.scrollTop;
scrollTop.value = currentScrollTop;
// 计算新的起始索引
let newStartIndex = Math.floor(currentScrollTop / props.itemHeight);
// 确保索引在有效范围内
newStartIndex = Math.max(0, newStartIndex);
newStartIndex = Math.min(newStartIndex, Math.max(0, props.items.length - 1));
// 只有当索引真正改变时才更新
if (newStartIndex !== startIndex.value) {
startIndex.value = newStartIndex;
}
};
// 监听items变化,重置状态
watch(
() => props.items,
(newItems) => {
if (newItems.length === 0) {
startIndex.value = 0;
} else {
// 如果滚动位置超出了新数组的范围,调整到末尾
if (startIndex.value >= newItems.length) {
startIndex.value = Math.max(0, newItems.length - 1);
// 滚动到正确位置
nextTick(() => {
if (viewWrapRef.value) {
viewWrapRef.value.scrollTop = startIndex.value * props.itemHeight;
}
});
}
}
}
);
// 暴露方法给父组件
defineExpose({
scrollToIndex: (index: number) => {
if (viewWrapRef.value) {
const targetIndex = Math.max(0, Math.min(index, props.items.length - 1));
viewWrapRef.value.scrollTop = targetIndex * props.itemHeight;
startIndex.value = targetIndex;
}
},
scrollToTop: () => {
if (viewWrapRef.value) {
viewWrapRef.value.scrollTop = 0;
startIndex.value = 0;
}
},
scrollToBottom: () => {
if (viewWrapRef.value) {
const targetIndex = Math.max(0, props.items.length - 1);
viewWrapRef.value.scrollTop = targetIndex * props.itemHeight;
startIndex.value = targetIndex;
}
},
});
</script>
<style lang="scss" scoped>
.virtual-scroll-container {
position: relative;
height: 300px;
overflow: auto;
.scroll-wrap {
position: relative;
}
.top-placeholder,
.bottom-placeholder {
width: 100%;
}
.content-container {
width: 100%;
}
}
</style>