前端性能优化之虚拟滚动

62 阅读5分钟

Vue 3 虚拟滚动实现原理与代码详解

前言

在处理大数据量的表格或列表渲染时,传统的渲染方式会导致页面卡顿、内存占用过高。虚拟滚动(Virtual Scrolling)技术通过只渲染可视区域内的元素,有效解决了这一性能问题。本文将详细介绍虚拟滚动的实现原理,并提供完整的 Vue 3 + Element Plus 实现。

什么是虚拟滚动?

虚拟滚动是一种优化长列表渲染性能的技术。它的核心思想是:只渲染用户当前能看到的元素,而不是渲染整个列表。

传统渲染的问题

假设我们要渲染 5000 条数据的表格:

  • 传统方式:创建 5000 个 DOM 元素
  • 内存占用:5000 × DOM 节点大小 ≈ 很大
  • 性能影响:浏览器需要管理大量 DOM,导致滚动卡顿

虚拟滚动解决方案

  • 只渲染可视区域内的元素(比如 30 条)
  • 内存占用:30 × DOM 节点大小 ≈ 很小
  • 性能提升:浏览器只需管理少量 DOM,滚动流畅

虚拟滚动实现原理

1. 核心概念

虚拟滚动包含以下几个关键要素:

  1. 滚动容器:具有固定高度和滚动条的容器
  2. 高度撑开元素:一个不可见但具有完整列表高度的 div,用于产生滚动条
  3. 可视内容容器:绝对定位的容器,只显示当前可视区域的数据
  4. 偏移量控制:根据滚动位置计算内容容器的 translateY 值

2. 工作原理图解

┌─────────────────────────────────┐
│           滚动容器              │  ← 固定高度,overflow: auto
├─────────────────────────────────┤
│                                 │
│   ┌─────────────────────────┐   │
│   │     高度撑开元素        │   │  ← height: totalHeight
│   │    (不可见)            │   │
│   └─────────────────────────┘   │
│                                 │
│   ┌─────────────────────────┐   │
│   │   可视内容容器          │   │  ← position: absolute
│   │   ┌─────────────────┐   │   │  ├── translateY(offset)
│   │   │  可见项目 1    │   │   │  └── 显示 visibleData
│   │   ├─────────────────┤   │   │
│   │   │  可见项目 2    │   │   │
│   │   ├─────────────────┤   │   │
│   │   │  ... (30项)    │   │   │
│   │   └─────────────────┘   │   │
│   └─────────────────────────┘   │
│                                 │
└─────────────────────────────────┘

3. 滚动计算过程

当用户滚动时:

  1. 获取滚动位置scrollTop = e.target.scrollTop
  2. 计算开始索引startIndex = Math.floor(scrollTop / itemHeight)
  3. 计算结束索引endIndex = startIndex + visibleCount
  4. 更新可见数据visibleData = originalData.slice(startIndex, endIndex)
  5. 设置偏移量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;
};