无限滚动虚拟table表格大数据量优化解决方案【可运行源码】

46 阅读7分钟

设计思路

  1. 虚拟滚动原理:只渲染可见区域的数据行,通过计算滚动位置动态更新显示内容

  2. 性能优化:使用固定高度的行和高效的DOM操作,避免大量DOM节点造成的性能问题

  3. 用户体验:保持平滑滚动和准确滚动条位置

UI页面预览

在这里插入图片描述

完整的实现代码: (代码供参考,源码可直接运行)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>虚拟滚动表格 - 大数据量优化</title>
    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
            font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
        }
        
        body {
            padding: 20px;
            background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
            min-height: 100vh;
        }
        
        .container {
            max-width: 1200px;
            margin: 0 auto;
            padding: 20px;
        }
        
        .header {
            text-align: center;
            margin-bottom: 30px;
        }
        
        .header h1 {
            color: #2c3e50;
            font-size: 2.5rem;
            margin-bottom: 10px;
            text-shadow: 1px 1px 3px rgba(0,0,0,0.1);
        }
        
        .header p {
            color: #7f8c8d;
            font-size: 1.1rem;
            max-width: 800px;
            margin: 0 auto;
            line-height: 1.6;
        }
        
        .controls {
            background-color: white;
            border-radius: 10px;
            padding: 20px;
            margin-bottom: 25px;
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
            display: flex;
            flex-wrap: wrap;
            gap: 20px;
            align-items: center;
        }
        
        .control-group {
            display: flex;
            align-items: center;
            gap: 10px;
        }
        
        label {
            font-weight: 600;
            color: #2c3e50;
        }
        
        input, select, button {
            padding: 10px 15px;
            border: 1px solid #ddd;
            border-radius: 6px;
            font-size: 1rem;
        }
        
        button {
            background-color: #3498db;
            color: white;
            border: none;
            cursor: pointer;
            transition: background-color 0.3s;
            font-weight: 600;
        }
        
        button:hover {
            background-color: #2980b9;
        }
        
        .stats {
            display: flex;
            gap: 20px;
            flex-wrap: wrap;
        }
        
        .stat-item {
            background-color: white;
            padding: 15px;
            border-radius: 8px;
            min-width: 180px;
            box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08);
        }
        
        .stat-value {
            font-size: 1.8rem;
            font-weight: 700;
            color: #3498db;
        }
        
        .stat-label {
            font-size: 0.9rem;
            color: #7f8c8d;
            margin-top: 5px;
        }
        
        .virtual-table-container {
            background-color: white;
            border-radius: 10px;
            overflow: hidden;
            box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
            margin-top: 30px;
        }
        
        .table-header {
            display: flex;
            background-color: #2c3e50;
            color: white;
            font-weight: 600;
            position: sticky;
            top: 0;
            z-index: 10;
        }
        
        .table-header-cell {
            padding: 16px 12px;
            text-align: left;
            flex: 1;
            min-width: 120px;
            border-right: 1px solid rgba(255, 255, 255, 0.1);
        }
        
        .table-header-cell:last-child {
            border-right: none;
        }
        
        .table-body {
            height: 500px;
            overflow-y: auto;
            position: relative;
        }
        
        .table-row {
            display: flex;
            border-bottom: 1px solid #eee;
            position: absolute;
            left: 0;
            right: 0;
            transition: background-color 0.2s;
        }
        
        .table-row:hover {
            background-color: #f8f9fa;
        }
        
        .table-cell {
            padding: 14px 12px;
            flex: 1;
            min-width: 120px;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }
        
        .odd-row {
            background-color: #f9f9f9;
        }
        
        .status-active {
            color: #27ae60;
            font-weight: 600;
        }
        
        .status-inactive {
            color: #e74c3c;
            font-weight: 600;
        }
        
        .empty-state {
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100%;
            color: #7f8c8d;
            font-size: 1.2rem;
        }
        
        .scroll-info {
            padding: 15px;
            background-color: #f8f9fa;
            border-top: 1px solid #eee;
            font-size: 0.9rem;
            color: #7f8c8d;
            text-align: center;
        }
        
        .highlight {
            background-color: #fffacd;
        }
        
        .performance-note {
            margin-top: 30px;
            padding: 20px;
            background-color: white;
            border-radius: 10px;
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
        }
        
        .performance-note h3 {
            color: #2c3e50;
            margin-bottom: 10px;
        }
        
        .performance-note ul {
            padding-left: 20px;
            line-height: 1.8;
            color: #555;
        }
        
        .performance-note li {
            margin-bottom: 8px;
        }
        
        @media (max-width: 768px) {
            .controls {
                flex-direction: column;
                align-items: flex-start;
            }
            
            .table-header-cell, .table-cell {
                min-width: 90px;
                padding: 12px 8px;
            }
            
            .stat-item {
                min-width: 140px;
            }
        }
    </style>
</head>
<body>
    <div id="app" class="container">
        <div class="header">
            <h1>虚拟滚动表格演示</h1>
            <p>处理大规模数据时,通过虚拟滚动技术仅渲染可视区域的内容,避免因DOM节点过多导致的页面卡顿问题。</p>
        </div>
        
        <div class="controls">
            <div class="control-group">
                <label for="totalRows">数据总量:</label>
                <input type="number" id="totalRows" v-model.number="totalRows" min="100" max="1000000" step="100">
            </div>
            
            <div class="control-group">
                <label for="rowHeight">行高度(px):</label>
                <input type="number" id="rowHeight" v-model.number="rowHeight" min="30" max="100" step="5">
            </div>
            
            <div class="control-group">
                <label for="bufferRows">缓冲行数:</label>
                <input type="number" id="bufferRows" v-model.number="bufferRows" min="0" max="20" step="1">
            </div>
            
            <div class="control-group">
                <label for="filterStatus">状态筛选:</label>
                <select id="filterStatus" v-model="filterStatus">
                    <option value="all">全部</option>
                    <option value="active">活跃</option>
                    <option value="inactive">非活跃</option>
                </select>
            </div>
            
            <button @click="generateData">生成新数据</button>
            <button @click="scrollToMiddle">滚动到中间</button>
            <button @click="scrollToTop">回到顶部</button>
        </div>
        
        <div class="stats">
            <div class="stat-item">
                <div class="stat-value">{{ visibleRowCount }}</div>
                <div class="stat-label">当前渲染行数</div>
            </div>
            <div class="stat-item">
                <div class="stat-value">{{ totalRowsFormatted }}</div>
                <div class="stat-label">数据总量</div>
            </div>
            <div class="stat-item">
                <div class="stat-value">{{ renderTime.toFixed(2) }}ms</div>
                <div class="stat-label">渲染时间</div>
            </div>
            <div class="stat-item">
                <div class="stat-value">{{ memoryUsage.toFixed(1) }}MB</div>
                <div class="stat-label">内存占用</div>
            </div>
        </div>
        
        <div class="virtual-table-container">
            <div class="table-header">
                <div class="table-header-cell">ID</div>
                <div class="table-header-cell">姓名</div>
                <div class="table-header-cell">邮箱</div>
                <div class="table-header-cell">职位</div>
                <div class="table-header-cell">部门</div>
                <div class="table-header-cell">状态</div>
                <div class="table-header-cell">入职日期</div>
            </div>
            
            <div class="table-body" ref="tableBody" @scroll="handleScroll">
                <div :style="tableBodyStyle">
                    <div 
                        v-for="row in visibleRows" 
                        :key="row.id"
                        class="table-row"
                        :class="{ 'odd-row': row.index % 2 === 0, 'highlight': row.index === highlightIndex }"
                        :style="rowStyle(row.index)"
                    >
                        <div class="table-cell">{{ row.id }}</div>
                        <div class="table-cell">{{ row.name }}</div>
                        <div class="table-cell">{{ row.email }}</div>
                        <div class="table-cell">{{ row.position }}</div>
                        <div class="table-cell">{{ row.department }}</div>
                        <div class="table-cell" :class="row.status === 'active' ? 'status-active' : 'status-inactive'">
                            {{ row.status === 'active' ? '活跃' : '非活跃' }}
                        </div>
                        <div class="table-cell">{{ row.joinDate }}</div>
                    </div>
                </div>
                
                <div v-if="visibleRows.length === 0" class="empty-state">
                    暂无数据,请生成数据或调整筛选条件
                </div>
            </div>
            
            <div class="scroll-info">
                显示 {{ startIndex + 1 }} - {{ Math.min(endIndex, filteredData.length) }} 条,共 {{ filteredData.length }} 条数据
                <span v-if="filteredData.length < totalRows"> (已筛选掉 {{ totalRows - filteredData.length }} 条)</span>
            </div>
        </div>
        
        <div class="performance-note">
            <h3>虚拟滚动技术优势:</h3>
            <ul>
                <li><strong>高性能渲染</strong>:仅渲染可视区域的数据行,无论总数据量多大,DOM节点数量保持恒定</li>
                <li><strong>低内存占用</strong>:避免创建大量DOM节点,显著减少内存使用</li>
                <li><strong>平滑滚动</strong>:滚动时仅更新已渲染行的数据,保持流畅的用户体验</li>
                <li><strong>快速初始化</strong>:首次加载时不需要创建所有DOM节点,加快页面加载速度</li>
                <li><strong>适用于移动端</strong>:在性能有限的移动设备上也能流畅运行</li>
            </ul>
        </div>
    </div>

    <script>
        const { createApp, ref, computed, onMounted, watch } = Vue;
        
        createApp({
            setup() {
                // 数据总量
                const totalRows = ref(10000);
                // 行高度
                const rowHeight = ref(50);
                // 缓冲行数(可视区域上下额外渲染的行数)
                const bufferRows = ref(5);
                // 表格容器引用
                const tableBody = ref(null);
                // 滚动位置
                const scrollTop = ref(0);
                // 容器高度
                const containerHeight = ref(500);
                // 渲染开始时间
                const renderStartTime = ref(0);
                // 渲染时间
                const renderTime = ref(0);
                // 高亮显示的行索引
                const highlightIndex = ref(-1);
                // 状态筛选
                const filterStatus = ref('all');
                // 生成的数据
                const allData = ref([]);
                
                // 生成模拟数据
                const generateMockData = () => {
                    console.log(`生成 ${totalRows.value} 条模拟数据...`);
                    const startTime = performance.now();
                    
                    const departments = ['技术部', '市场部', '人力资源部', '财务部', '产品部', '销售部', '运营部'];
                    const positions = ['工程师', '经理', '总监', '专员', '主管', '助理', '分析师'];
                    const firstNames = ['张', '王', '李', '赵', '刘', '陈', '杨', '黄', '周', '吴'];
                    const lastNames = ['伟', '芳', '娜', '秀英', '敏', '静', '丽', '强', '磊', '洋'];
                    
                    const data = [];
                    for (let i = 1; i <= totalRows.value; i++) {
                        const firstName = firstNames[Math.floor(Math.random() * firstNames.length)];
                        const lastName = lastNames[Math.floor(Math.random() * lastNames.length)];
                        const name = firstName + lastName;
                        
                        data.push({
                            id: i,
                            name: name,
                            email: `${name.toLowerCase()}@company.com`,
                            position: positions[Math.floor(Math.random() * positions.length)],
                            department: departments[Math.floor(Math.random() * departments.length)],
                            status: Math.random() > 0.3 ? 'active' : 'inactive',
                            joinDate: `${2020 + Math.floor(Math.random() * 4)}-${String(Math.floor(Math.random() * 12) + 1).padStart(2, '0')}-${String(Math.floor(Math.random() * 28) + 1).padStart(2, '0')}`,
                            index: i - 1 // 添加索引用于定位
                        });
                    }
                    
                    const endTime = performance.now();
                    console.log(`数据生成完成,耗时 ${(endTime - startTime).toFixed(2)}ms`);
                    
                    return data;
                };
                
                // 生成数据
                const generateData = () => {
                    renderStartTime.value = performance.now();
                    allData.value = generateMockData();
                    highlightIndex.value = -1;
                };
                
                // 根据筛选条件过滤数据
                const filteredData = computed(() => {
                    if (filterStatus.value === 'all') {
                        return allData.value;
                    } else {
                        return allData.value.filter(item => item.status === filterStatus.value);
                    }
                });
                
                // 格式化显示数据总量
                const totalRowsFormatted = computed(() => {
                    if (totalRows.value >= 1000000) {
                        return (totalRows.value / 1000000).toFixed(1) + 'M';
                    } else if (totalRows.value >= 1000) {
                        return (totalRows.value / 1000).toFixed(1) + 'K';
                    }
                    return totalRows.value.toString();
                });
                
                // 计算可见行数
                const visibleRowCount = computed(() => {
                    return Math.min(containerHeight.value / rowHeight.value + 2 * bufferRows.value, filteredData.value.length);
                });
                
                // 计算开始索引
                const startIndex = computed(() => {
                    return Math.max(0, Math.floor(scrollTop.value / rowHeight.value) - bufferRows.value);
                });
                
                // 计算结束索引
                const endIndex = computed(() => {
                    return Math.min(
                        filteredData.value.length,
                        startIndex.value + Math.ceil(containerHeight.value / rowHeight.value) + 2 * bufferRows.value
                    );
                });
                
                // 获取可见行数据
                const visibleRows = computed(() => {
                    return filteredData.value.slice(startIndex.value, endIndex.value);
                });
                
                // 表格内容区域样式
                const tableBodyStyle = computed(() => {
                    return {
                        height: `${filteredData.value.length * rowHeight.value}px`,
                        position: 'relative'
                    };
                });
                
                // 行样式
                const rowStyle = (index) => {
                    return {
                        height: `${rowHeight.value}px`,
                        top: `${index * rowHeight.value}px`
                    };
                };
                
                // 处理滚动事件
                const handleScroll = () => {
                    if (!tableBody.value) return;
                    scrollTop.value = tableBody.value.scrollTop;
                    
                    // 计算当前可视区域中间的行索引
                    const middleIndex = Math.floor((scrollTop.value + containerHeight.value / 2) / rowHeight.value);
                    
                    // 高亮显示中间行
                    if (middleIndex >= 0 && middleIndex < filteredData.value.length) {
                        highlightIndex.value = middleIndex;
                    }
                };
                
                // 滚动到中间位置
                const scrollToMiddle = () => {
                    if (!tableBody.value) return;
                    const middlePosition = (filteredData.value.length * rowHeight.value - containerHeight.value) / 2;
                    tableBody.value.scrollTo({
                        top: middlePosition,
                        behavior: 'smooth'
                    });
                };
                
                // 滚动到顶部
                const scrollToTop = () => {
                    if (!tableBody.value) return;
                    tableBody.value.scrollTo({
                        top: 0,
                        behavior: 'smooth'
                    });
                };
                
                // 模拟内存占用(粗略估算)
                const memoryUsage = computed(() => {
                    // 每条数据大约占用200字节,转换为MB
                    return (allData.value.length * 200) / (1024 * 1024);
                });
                
                // 监听数据变化,更新渲染时间
                watch([allData, filteredData], () => {
                    if (renderStartTime.value > 0) {
                        renderTime.value = performance.now() - renderStartTime.value;
                    }
                }, { flush: 'post' });
                
                // 监听筛选条件变化,重置滚动位置
                watch(filterStatus, () => {
                    if (tableBody.value) {
                        tableBody.value.scrollTop = 0;
                        scrollTop.value = 0;
                    }
                });
                
                // 组件挂载时生成初始数据
                onMounted(() => {
                    generateData();
                    
                    // 监听窗口大小变化,更新容器高度
                    const updateContainerHeight = () => {
                        if (tableBody.value) {
                            containerHeight.value = tableBody.value.clientHeight;
                        }
                    };
                    
                    updateContainerHeight();
                    window.addEventListener('resize', updateContainerHeight);
                });
                
                return {
                    totalRows,
                    rowHeight,
                    bufferRows,
                    tableBody,
                    scrollTop,
                    containerHeight,
                    renderTime,
                    highlightIndex,
                    filterStatus,
                    allData,
                    filteredData,
                    generateData,
                    totalRowsFormatted,
                    visibleRowCount,
                    startIndex,
                    endIndex,
                    visibleRows,
                    tableBodyStyle,
                    rowStyle,
                    handleScroll,
                    scrollToMiddle,
                    scrollToTop,
                    memoryUsage
                };
            }
        }).mount('#app');
    </script>
</body>
</html>

功能说明

1. 虚拟滚动实现:

只渲染可视区域及缓冲区的数据行

通过绝对定位和transform技术实现行位置定位

动态计算滚动位置,更新显示内容

2. 性能指标监控:

显示当前渲染的行数(通常只有几十行)

显示总数据量(可高达百万条)

显示渲染时间和内存占用估算

3. 交互功能:

可调整数据总量、行高、缓冲行数

可按状态筛选数据

支持滚动到中间位置或回到顶部

高亮显示当前可视区域中间的行

4. 性能优化说明:

虚拟滚动技术避免创建大量DOM节点

使用缓冲行减少滚动时的空白闪烁

高效的滚动事件处理

使用方法

  1. 将代码保存为HTML文件

  2. 直接在浏览器中打开文件

  3. 调整参数观察虚拟滚动的效果

这个实现可以处理高达百万级别的数据量,而不会造成页面卡顿,因为无论总数据量多少,实际渲染的DOM节点数量始终保持恒定,本示例是基于VUE3实现,其他框架原理相同

在这里插入图片描述