Vue 3 虚拟滚动实现原理与代码详解
前言
在处理大数据量的表格或列表渲染时,传统的渲染方式会导致页面卡顿、内存占用过高。虚拟滚动(Virtual Scrolling)技术通过只渲染可视区域内的元素,有效解决了这一性能问题。本文将详细介绍虚拟滚动的实现原理,并提供完整的 Vue 3 + Element Plus 实现。
什么是虚拟滚动?
虚拟滚动是一种优化长列表渲染性能的技术。它的核心思想是:只渲染用户当前能看到的元素,而不是渲染整个列表。
传统渲染的问题
假设我们要渲染 5000 条数据的表格:
- 传统方式:创建 5000 个 DOM 元素
- 内存占用:5000 × DOM 节点大小 ≈ 很大
- 性能影响:浏览器需要管理大量 DOM,导致滚动卡顿
虚拟滚动解决方案
- 只渲染可视区域内的元素(比如 30 条)
- 内存占用:30 × DOM 节点大小 ≈ 很小
- 性能提升:浏览器只需管理少量 DOM,滚动流畅
虚拟滚动实现原理
1. 核心概念
虚拟滚动包含以下几个关键要素:
- 滚动容器:具有固定高度和滚动条的容器
- 高度撑开元素:一个不可见但具有完整列表高度的 div,用于产生滚动条
- 可视内容容器:绝对定位的容器,只显示当前可视区域的数据
- 偏移量控制:根据滚动位置计算内容容器的 translateY 值
2. 工作原理图解
┌─────────────────────────────────┐
│ 滚动容器 │ ← 固定高度,overflow: auto
├─────────────────────────────────┤
│ │
│ ┌─────────────────────────┐ │
│ │ 高度撑开元素 │ │ ← height: totalHeight
│ │ (不可见) │ │
│ └─────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ │
│ │ 可视内容容器 │ │ ← position: absolute
│ │ ┌─────────────────┐ │ │ ├── translateY(offset)
│ │ │ 可见项目 1 │ │ │ └── 显示 visibleData
│ │ ├─────────────────┤ │ │
│ │ │ 可见项目 2 │ │ │
│ │ ├─────────────────┤ │ │
│ │ │ ... (30项) │ │ │
│ │ └─────────────────┘ │ │
│ └─────────────────────────┘ │
│ │
└─────────────────────────────────┘
3. 滚动计算过程
当用户滚动时:
- 获取滚动位置:
scrollTop = e.target.scrollTop - 计算开始索引:
startIndex = Math.floor(scrollTop / itemHeight) - 计算结束索引:
endIndex = startIndex + visibleCount - 更新可见数据:
visibleData = originalData.slice(startIndex, endIndex) - 设置偏移量:
transform = translateY(startIndex * itemHeight)
完整代码实现
1. 组件代码 (DataTable.vue)
<script setup>
import { ref } from "vue";
const props = defineProps({
arr: {
type: Array,
required: true,
},
});
// 响应式数据
let arr = props.arr;
const containerHeight = arr.length * 40; // 总高度 = 数据量 × 单项高度
let startIndex = 0; // 开始索引
let endIndex = 30; // 结束索引(显示30项)
const visibleData = ref([]); // 可见数据
const transformLength = ref(0); // Y轴偏移量 这个偏移举例一定要是响应式 否则当滚动条事件触发 偏移举例重新计算如果不是响应式的数据不会重新渲染 则就不会位移
// 初始化可见数据
visibleData.value = arr.slice(startIndex, endIndex);
// 滚动事件处理
const handleScroll = (e) => {
const scrollTop = e.target.scrollTop;
console.log("scrollTop", scrollTop);
const itemHeight = 40; // 单项高度
// 计算当前显示的数据范围
startIndex = Math.floor(scrollTop / itemHeight);
endIndex = startIndex + 30; // 显示30项数据
// 更新可见数据
visibleData.value = arr.slice(startIndex, endIndex);
console.log("visibleData", visibleData);
// 更新偏移量,实现虚拟滚动效果
transformLength.value = scrollTop;
};
</script>
<template>
<!-- 滚动容器:固定高度,可滚动 -->
<div
@scroll="handleScroll"
style="
height: 100vh;
width: 80%;
overflow-y: scroll;
position: relative;
"
>
<!-- 高度撑开元素:产生滚动条 -->
<div class="container" :style="{ height: containerHeight + 'px' }"></div>
<!-- 可视内容容器:绝对定位,只显示可见数据 -->
<div
class="scroll-container"
:style="{
position: 'absolute',
top: '0',
left: '0',
width: '100%',
transform: `translateY(${transformLength}px)`,
}"
>
<!-- Element Plus 表格 -->
<el-table :data="visibleData" border>
<el-table-column prop="key" label="key" width="100" />
<el-table-column prop="name" label="name" width="150" />
<el-table-column prop="phone" label="phone" width="120" />
<el-table-column prop="lable" label="lable" width="150" />
<el-table-column prop="text" label="text" />
</el-table>
</div>
</div>
</template>
<style scoped>
/* 基础样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* 高度撑开元素 */
.container {
z-index: 10;
}
/* 可视内容容器 */
.scroll-container {
position: absolute;
z-index: 99999;
}
/* 表格边框样式 */
.scroll-container :deep(.el-table) {
border: 1px solid #ebeef5;
}
.scroll-container :deep(.el-table td),
.scroll-container :deep(.el-table th) {
border: 1px solid #ebeef5;
text-align: center;
padding: 12px 0;
}
.scroll-container :deep(.el-table th) {
background-color: #f5f7fa;
font-weight: bold;
}
/* 滚动条样式优化 */
div::-webkit-scrollbar {
width: 8px;
}
div::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
div::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
div::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>
2. 使用组件 (App.vue)
<script setup>
import DataTable from "./components/DataTable.vue";
// 生成测试数据
let str = 1;
let arr = Array.from({ length: 5000 }).map((_, index) => {
return {
key: index,
name: "zzr" + index++,
phone: str++,
lable: "标签" + index,
text: "文本" + index,
};
});
</script>
<template>
<DataTable :arr="arr" />
</template>
<style scoped>
/* 全局样式 */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f7fafc;
margin: 0;
padding: 0;
}
</style>
关键技术点解析
1. 高度计算
const containerHeight = arr.length * 40; // 总高度
const itemHeight = 40; // 单行高度
const visibleCount = 30; // 可见行数
containerHeight:撑开元素的总高度,确保滚动条正确显示itemHeight:每行数据的固定高度(可根据实际情况调整)visibleCount:同时显示的数据条数(影响用户体验)
2. 索引计算
const handleScroll = (e) => {
const scrollTop = e.target.scrollTop;
// 计算开始显示的索引
startIndex = Math.floor(scrollTop / itemHeight);
// 计算结束显示的索引
endIndex = startIndex + visibleCount;
// 获取当前显示的数据
visibleData.value = arr.slice(startIndex, endIndex);
// 更新偏移量
transformLength.value = scrollTop;
};
Math.floor(scrollTop / itemHeight):根据滚动位置计算应该从哪条数据开始显示arr.slice(startIndex, endIndex):截取需要显示的数据片段transformLength.value = scrollTop:同步内容容器的偏移量
3. 定位布局
<!-- 外层容器:相对定位 -->
<div style="position: relative; height: 100vh; overflow-y: auto">
<!-- 撑开元素:占据总高度 -->
<div :style="{ height: containerHeight + 'px' }"></div>
<!-- 内容容器:绝对定位 -->
<div style="position: absolute; top: 0; left: 0">
<!-- 表格内容 -->
</div>
</div>
- 外层容器:
position: relative作为定位参考 - 撑开元素:确保滚动条长度正确
- 内容容器:
position: absolute实现悬浮效果
性能优化建议
1. 动态可见数量
根据屏幕高度动态计算可见数据量:
const calculateVisibleCount = () => {
const viewportHeight = window.innerHeight;
return Math.ceil(viewportHeight / itemHeight) + 5; // 额外渲染几项,避免空白
};
2. 防抖处理
为滚动事件添加防抖,减少计算频率:
import { debounce } from "lodash-es";
const handleScroll = debounce((e) => {
// 滚动处理逻辑
}, 16); // 约60fps
3. 缓冲区优化
在可视区域上下增加缓冲区,提升滚动体验:
const bufferSize = 5; // 缓冲区大小
startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - bufferSize);
endIndex = Math.min(arr.length, startIndex + visibleCount + bufferSize * 2);
4. 内存管理
及时清理不需要的数据引用:
// 在组件卸载时清理
onUnmounted(() => {
visibleData.value = [];
arr = null;
});
扩展功能
1. 支持动态高度
对于不同高度的列表项,需要记录每项的高度:
const itemHeights = ref([]);
const totalHeight = computed(() => {
return itemHeights.value.reduce((sum, height) => sum + height, 0);
});
const handleScroll = (e) => {
// 根据累积高度计算当前索引
let accumulatedHeight = 0;
let currentIndex = 0;
for (let i = 0; i < itemHeights.value.length; i++) {
accumulatedHeight += itemHeights.value[i];
if (accumulatedHeight > scrollTop) {
currentIndex = i;
break;
}
}
startIndex = currentIndex;
// ... 其他逻辑
};
2. 横向虚拟滚动
对于列数很多的表格,也可以实现横向虚拟滚动:
const handleHorizontalScroll = (e) => {
const scrollLeft = e.target.scrollLeft;
const columnWidth = 120; // 列宽
const startColumn = Math.floor(scrollLeft / columnWidth);
const endColumn = startColumn + visibleColumns;
visibleColumnsData.value = allColumns.slice(startColumn, endColumn);
horizontalTransform.value = scrollLeft;
};