el-table 虚拟滚动完整实现方案
方案概述
本方案通过自定义指令 v-infinite-scroll 实现 el-table 组件的虚拟滚动功能,优化大数据量下的表格性能。核心原理是只渲染可视区域及缓冲区域的数据,通过 padding 撑开滚动条来模拟完整数据的高度。
核心特性
- 性能优化:只渲染必要数据,避免大量 DOM 节点创建
- 正确滚动:通过 padding 技术维持滚动条行为正确
- 易于集成:通过指令方式简单使用
- 动态数据:支持异步数据加载和动态更新
实现代码
自定义指令实现
ini
/**
* el-table 虚拟滚动指令
* 通过只渲染可视区域+缓冲区域的数据来优化大数据量下的性能
*/
export const infiniteScroll = {
/**
* 指令挂载时初始化虚拟滚动功能
* @param {HTMLElement} el - 绑定指令的元素
* @param {Object} binding - 指令绑定值
*/
mounted(el, binding) {
// 解构指令参数
const { rowHeight, bufferCount = 5, dataArray, onScroll } = binding.value;
let ticking = false; // 节流锁,确保每帧只执行一次更新
// 获取真实的滚动容器和表格元素
const container = el.querySelector('.el-scrollbar__wrap');
const table = container.querySelector('table');
/**
* 获取当前数据总量
* 通过传递的数组引用动态获取长度,确保数据更新时能正确响应
*/
const getTotalCount = () => dataArray ? dataArray.length : 0;
/**
* 更新可视区域数据
* 核心函数:计算当前可视区域索引,设置padding维持滚动条,通知父组件更新数据
*/
function update() {
// 获取滚动位置和容器高度
const scrollTop = container.scrollTop;
const containerHeight = container.clientHeight;
const totalCount = getTotalCount();
// 计算可视区域索引
// startIndex: 可视区域顶部所在的行索引
let startIndex = Math.floor(scrollTop / rowHeight);
// endIndex: 可视区域底部所在的行索引
let endIndex = Math.ceil((scrollTop + containerHeight) / rowHeight) - 1;
// 添加缓冲区域,提高滚动体验
startIndex -= bufferCount;
endIndex += bufferCount;
// 基本边界处理
startIndex = Math.max(0, startIndex);
endIndex = Math.max(0, Math.min(totalCount - 1, endIndex));
// 计算 padding 值来维持正确的滚动条高度
// topPadding: 隐藏在上方的数据所占高度
const topPadding = Math.max(0, startIndex * rowHeight);
// bottomPadding: 隐藏在下方的数据所占高度
// 减1是因为要从索引转换为剩余行数
const bottomPadding = totalCount > 0 && endIndex >= 0 ?
(totalCount - endIndex - 1) * rowHeight : 0;
// 通过 padding 撑开表格,模拟完整数据的高度
table.style.paddingTop = `${topPadding}px`;
table.style.paddingBottom = `${bottomPadding}px`;
// 通知父组件更新显示数据
if (typeof onScroll === 'function') {
onScroll(startIndex, endIndex);
}
// 释放节流锁
ticking = false;
}
/**
* 使用 requestAnimationFrame 进行帧级节流
* 确保每帧只执行一次更新,避免频繁计算影响性能
*/
function requestTick() {
if (!ticking) {
requestAnimationFrame(update);
ticking = true;
}
}
// 监听滚动事件
container.addEventListener('scroll', requestTick);
// 初始化时直接通知父组件显示前20条数据
// 这是一种实用的默认策略,虽然可能不完全准确但能保证基本显示效果
if (typeof onScroll === 'function') {
onScroll(0, 20);
}
}
};
使用示例
xml
<template>
<div class="virtual-table-demo">
<h2>el-table 虚拟滚动演示</h2>
<div class="controls">
<el-button @click="loadData" type="primary">加载数据</el-button>
<el-button @click="clearData" type="danger">清空数据</el-button>
<el-button @click="generateLargeData" type="success">生成10万条数据</el-button>
</div>
<!--
使用 v-infinite-scroll 指令实现虚拟滚动
参数说明:
- rowHeight: 每行高度(需固定)
- bufferCount: 缓冲区域行数
- dataArray: 数据数组引用(用于动态获取长度)
- onScroll: 滚动时的回调函数
-->
<el-table
v-infinite-scroll="{
rowHeight: 40,
bufferCount: 5,
dataArray: totalData, // 传递数组引用而不是固定长度
onScroll: handleScroll
}"
:data="displayData"
height="400px"
border
v-loading="loading">
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="name" label="姓名" width="150"></el-table-column>
<el-table-column prop="email" label="邮箱"></el-table-column>
<el-table-column prop="address" label="地址"></el-table-column>
<el-table-column prop="date" label="日期" width="180"></el-table-column>
</el-table>
<div class="info">
<p>总数据量: {{ totalData.length }}</p>
<p>当前显示: {{ displayData.length }} 条</p>
<p>显示范围: {{ visibleStartIndex }} - {{ visibleEndIndex }}</p>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue';
import { infiniteScroll } from './directives/infiniteScroll';
// 注册自定义指令
const vInfiniteScroll = infiniteScroll;
// 完整数据源
const totalData = reactive([]);
// 显示的数据(通过计算属性动态截取可视区域数据)
const displayData = computed(() => {
return totalData.slice(visibleStartIndex.value, visibleEndIndex.value + 1);
});
// 当前显示范围索引
const visibleStartIndex = ref(0);
const visibleEndIndex = ref(0);
const loading = ref(false);
/**
* 处理滚动事件回调
* @param {number} startIndex - 可视区域起始索引
* @param {number} endIndex - 可视区域结束索引
*/
const handleScroll = (startIndex, endIndex) => {
visibleStartIndex.value = startIndex;
visibleEndIndex.value = endIndex;
};
/**
* 生成测试数据
* @param {number} count - 数据量
*/
const generateData = (count) => {
totalData.length = 0;
for (let i = 0; i < count; i++) {
totalData.push({
id: i + 1,
name: `用户${i + 1}`,
email: `user${i + 1}@example.com`,
address: `北京市朝阳区某某街道${i + 1}号`,
date: new Date(Date.now() - Math.random() * 365 * 24 * 60 * 60 * 1000).toLocaleDateString()
});
}
};
/**
* 加载数据
*/
const loadData = async () => {
loading.value = true;
try {
// 模拟API请求延迟
await new Promise(resolve => setTimeout(resolve, 800));
// 生成1万条测试数据
generateData(10000);
// 重置显示范围
handleScroll(0, Math.min(20, totalData.length - 1));
} finally {
loading.value = false;
}
};
/**
* 生成大量数据测试性能
*/
const generateLargeData = async () => {
loading.value = true;
try {
// 模拟API请求延迟
await new Promise(resolve => setTimeout(resolve, 800));
// 生成10万条测试数据
generateData(100000);
// 重置显示范围
handleScroll(0, Math.min(20, totalData.length - 1));
} finally {
loading.value = false;
}
};
/**
* 清空数据
*/
const clearData = () => {
totalData.length = 0;
handleScroll(0, 0);
};
// 初始化少量数据用于测试
generateData(50);
handleScroll(0, 20);
</script>
<style scoped>
.virtual-table-demo {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.controls {
margin-bottom: 20px;
}
.controls .el-button {
margin-right: 10px;
}
.info {
margin: 20px 0;
padding: 15px;
background-color: #f5f5f5;
border-radius: 4px;
font-size: 14px;
}
.info p {
margin: 8px 0;
}
</style>
使用说明
核心参数
rowHeight: 每行固定高度(必须指定)bufferCount: 缓冲区域行数(默认5行)dataArray: 数据数组引用(用于动态获取长度)onScroll: 滚动回调函数
注意事项
- 行高固定:为保证计算准确性,需要固定每行高度
- 初始化策略:指令初始化时会默认显示前20条数据,这是一种实用但可能不完全准确的策略