前端渲染方案之虚拟列表

259 阅读6分钟

前端虚拟列表实现详解

嘿,各位前端大佬们!当我们需要展示大量数据列表时,传统的列表渲染方式会面临诸多痛点,内存占用过高、页面卡顿、 响应速度变慢等等。而虚拟列表则是解决这些痛点的有效方案。

整体功能概述

这段代码主要实现了一个无限滚动加载的列表。当用户滚动列表到底部时,会自动加载更多的数据,从而解决了前端在渲染数据量比较大的页面时出现的诸多问题。其核心原理是只渲染当前可见区域以及其附近的少量数据,通过监听滚动事件动态计算需要显示的数据,同时在列表上下添加空白填充,让滚动更加流畅,避免一次性渲染大量数据导致的性能问题。

代码结构剖析

1. 引入必要的依赖

<script setup>
import axios from "axios";
import { ref, onMounted, computed } from "vue";

这里引入了 axios 用于发送 HTTP 请求,获取数据。同时引入了 Vue 3 的一些响应式 API,如 refonMountedcomputedref 用于创建响应式数据,onMounted 用于在组件挂载后执行代码,computed 用于创建计算属性。

2. 定义响应式数据

// 总数据
const allList = ref([]);
// 最大容积
const contentSize = ref(0);
// 容器
const scroll = ref(null);
// 单个数据高度
const oneHeight = ref(100);
// 容器第一条数据索引
const startIndex = ref(0);

这些变量就像是我们的“小助手”,分别存储了所有数据、容器的最大容积、滚动容器的引用、单个数据项的高度以及当前显示数据的起始索引。

3. 获取数据

const getListData = async (num = 10) => {
  try {
    const res = await axios.get("http:://localhost:9527/mock", {
      params: { num },
    });
    allList.value = [...allList.value, ...res.data.list];
  } catch (error) {
    console.error("获取数据失败:", error);
  }
};

getListData 函数用于从服务器获取数据。使用 axios 发送 GET 请求,将获取到的数据追加到 allList 中。这里的 URL 是本地模拟的数据地址,实际开发中需要替换为真实的接口地址。

4. 组件挂载时的操作

onMounted(() => {
  getContentSize();
  getListData(30);
});

在组件挂载后,首先调用 getContentSize 函数计算容器的最大容积,然后调用 getListData 函数获取 30 条初始数据。这样做是为了在组件首次渲染时,先确定容器能够容纳的数据项数量,再获取足够的数据进行初始渲染。

5. 计算容器最大容积

const getContentSize = () => {
  if (scroll.value) {
    contentSize.value =
      Math.ceil(scroll.value.offsetHeight / oneHeight.value) + 1;
  }
};

getContentSize 函数通过计算滚动容器的高度和单个数据项的高度,得出容器能够容纳的数据项数量。在计算之前,先检查 scroll.value 是否存在,避免在滚动容器还未挂载时出现错误。Math.ceil 函数用于向上取整,确保容器能够完整显示数据项,最后加 1 是为了在容器底部预留一个数据项的空间,避免出现滚动到底部时数据显示不全的问题。

6. 监听滚动事件

let isRequest = false;
let isScroll = true;
const handleScroll = async () => {
  if (isScroll) {
    isScroll = false;
    const timer = setTimeout(() => {
      isScroll = true;
      clearTimeout(timer);
    }, 30);

    if (scroll.value) {
      const currentIndex = ~~(scroll.value.scrollTop / oneHeight.value);
      if (currentIndex === startIndex.value) return;
      startIndex.value = currentIndex;
      if (
        startIndex.value + contentSize.value >= allList.value.length &&
        !isRequest
      ) {
        isRequest = true;
        try {
          await getListData();
        } catch (error) {
          console.error("加载更多数据失败:", error);
        }
        isRequest = false;
      }
    }
  }
};

handleScroll 函数是实现无限滚动加载的核心。为了避免频繁触发滚动事件,使用了节流技术,通过 isScroll 变量控制。

在函数内部,先检查 scroll.value 是否存在,避免在滚动容器还未挂载时出现错误。通过 scroll.value.scrollTop 获取当前滚动的距离,除以单个数据项的高度 oneHeight.value,并使用 ~~ 进行取整操作,得到当前显示数据的起始索引 currentIndex。若 currentIndexstartIndex.value 相等,说明滚动位置没有发生变化,直接返回。

当滚动到接近列表底部时,即 startIndex.value + contentSize.value >= allList.value.length!isRequest(表示当前没有正在进行的请求),会调用 getListData 函数加载更多数据。为避免重复请求,在请求开始前将 isRequest 设为 true,请求完成后再将其恢复为 false,同时添加 try...catch 语句捕获请求过程中可能出现的错误。

7. 计算显示数据的索引和样式

// 容器数据最后一条数据索引
const endIndex = computed(() => {
  let endIndex = startIndex.value + contentSize.value * 2;
  if (!allList.value[endIndex]) {
    endIndex = allList.value.length - 1;
  }
  return endIndex;
});

// 容器展示数据
const showData = computed(() => {
  let _startIndex = 0;
  if (startIndex.value <= contentSize.value) {
    _startIndex = 0;
  } else {
    _startIndex = startIndex.value - contentSize.value;
  }
  return allList.value.slice(_startIndex, endIndex.value);
});

// 上下空白区填充
const blankFillStyle = computed(() => {
  let _startIndex = 0;
  if (startIndex.value <= contentSize.value) {
    _startIndex = 0;
  } else {
    _startIndex = startIndex.value - contentSize.value;
  }
  return {
    paddingTop: _startIndex * oneHeight.value + "px",
    paddingBottom:
      (allList.value.length - endIndex.value) * oneHeight.value + "px",
  };
});
为什么结束索引需要 startIndex + contentSize * 2

在虚拟列表的实现中,为保证滚动的流畅性,避免滚动过程中出现数据闪烁或者空白的情况,通常会多渲染一些数据,在当前可见区域的上下两侧形成缓冲区。

  • contentSize 的含义:它代表滚动容器能够容纳的数据项数量,通过 Math.ceil(scroll.value.offsetHeight / oneHeight.value) + 1 计算得出。
  • startIndex 的含义:表示当前显示数据在 allList 中的起始索引,会随用户滚动列表动态更新。
  • startIndex + contentSize * 2 的作用:计算结束索引时使用该公式,是为了在当前可见区域的上下两侧各预留 contentSize 数量的数据项作为缓冲区。这样,当用户滚动列表时,即使滚动速度较快,也能保证有足够的数据显示,避免出现数据闪烁或空白。

例如,假设 contentSize 为 10,startIndex 为 20,那么 endIndex 就会计算为 20 + 10 * 2 = 40。此时,除了当前可见区域的 10 个数据项,还会额外渲染上下两侧各 10 个数据项,总共渲染 30 个数据项。

为什么 showData 要动态调整起始索引
if (startIndex.value <= contentSize.value) {
  _startIndex = 0;
} else {
  _startIndex = startIndex.value - contentSize.value;
}

这是为了解决滚动边界问题以及优化渲染范围。当滚动到顶部(startIndex 接近 0)时,直接从 0 开始渲染,避免出现负数索引或无效计算;当用户向下滚动时,起始索引逐步增加,通过减去 contentSize 可以确保渲染的数据包含当前可见区域以及上缓冲区的数据。

8. 模板部分

<template>
  <div class="wrapper">
    <div class="scroll-container" ref="scroll" @scroll.passive="handleScroll">
      <div :style="blankFillStyle">
        <div class="list-item" v-for="(item, index) in showData" :key="index">
          <p class="title">{{ item.title }}</p>
          <p class="content">{{ item.content }}</p>
          <p class="reads">{{ item.reads }}</p>
        </div>
      </div>
    </div>
  </div>
</template>

模板部分使用了 v-for 指令遍历 showData,将每个数据项渲染成一个列表项。同时绑定了 scroll 事件,当滚动时调用 handleScroll 函数。@scroll.passive 表示使用被动事件监听器,可提高滚动性能。

9. 样式部分

<style lang="less" scoped>
.wrapper {
  width: 100%;
  height: 100%;
  text-align: center;

  .scroll-container {
    width: 100%;
    height: 100%;
    overflow-y: auto;
    .list-item {
      margin: 4px 10px;
      background-color: #eee;
      height: 100px;
      overflow: hidden;
      .title {
        font-size: 16px;
        color: #333;
      }
      .content {
        font-size: 14px;
        color: #666;
      }

      .reads {
        font-size: 12px;
        color: #999;
      }
    }
  }
}
</style>

样式部分使用了 Less 预处理器,为列表容器和列表项添加了一些基本的样式,让页面看起来更加美观。wrapper 类设置了宽度和高度为 100%,并将文本居中显示。scroll-container 类设置了宽度和高度为 100%,并添加了垂直滚动条。

总结

通过对这段代码的分析,我们了解了如何`实现无限滚动加载功能。关键在于监听滚动事件,计算当前显示数据的索引,以及在滚动到底部时加载更多数据。同时,利用缓冲区策略和动态调整起始索引的方式,保证了滚动的流畅性。还通过节流技术避免频繁触发滚动事件,提高性能。希望这篇博客能帮助你更好地理解前端开发中的无限滚动加载技术,让你在开发中更加得心应手!