虚拟滚动:优化长列表性能的利器

什么是虚拟滚动?

想象一下,你有一个包含10,000个项目的待办事项列表。如果一次性将所有项目都渲染到页面上,浏览器需要创建10,000个DOM元素,这会导致:

  • 页面加载缓慢
  • 内存占用过高
  • 滚动卡顿不流畅

虚拟滚动(Virtual Scrolling)就像是一个"智能窗口",它只渲染当前可见区域的内容,而不是整个列表。当用户滚动时,这个"窗口"会动态更新显示的内容。

通俗比喻:把虚拟滚动想象成一个只能看到部分书籍内容的魔法书架。当你上下移动视线时,书架会自动更换显示的书本,但你始终只能看到有限的几本,而不是整个图书馆的所有书籍。

虚拟滚动的原理

虚拟滚动的核心思想很简单:

  1. 计算可见区域:确定容器的高度和滚动位置
  2. 计算显示的起始和结束索引:根据每个项目的高度,算出当前应该显示哪些项目
  3. 只渲染可见项目:仅创建当前可见区域内的DOM元素
  4. 使用占位元素:用一个具有正确高度的空元素来维持滚动条的准确性

基础实现示例

让我们先看一个简单的JavaScript实现,理解核心逻辑:

html 复制代码
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>虚拟滚动</title>
  </head>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    #container {
      width: 400px;
      height: 400px;
      overflow: auto;
      margin: 0 auto;
    }
    #content {
      position: relative;
      height: 100000px;
    }
    .item {
      width: 100%;
      border: 1px solid;
    }
  </style>
  <body>
    <div id="container">
      <div id="content"></div>
    </div>
  </body>
  <script>
    const container = document.getElementById("container");
    const content = document.getElementById("content");
    const itemHeight = 50;
    const totalItems = 2000;

    // 渲染可见项目
    function renderVisibleItems() {
      const scrollTop = container.scrollTop;
      console.log(scrollTop, "scrollTop");

      // 向下取整
      const startIndex = Math.floor(scrollTop / itemHeight);

      // 向上取整
      const visibleCount = Math.ceil(container.clientHeight / itemHeight);
      console.log(visibleCount, "visibleCount");

      // 取小
      const endIndex = Math.min(startIndex + visibleCount + 5, totalItems); // 多渲染几个作为缓冲

      // 清空内容
      content.innerHTML = "";

      // 创建可见项目
      for (let i = startIndex; i < endIndex; i++) {
        const item = document.createElement("div");
        item.className = "item";
        item.style.height = itemHeight + "px";
        item.style.position = "absolute";
        item.style.top = i * itemHeight + "px";
        item.style.width = "100%";
        item.textContent = `项目 ${i + 1}`;
        content.appendChild(item);
      }
    }

    container.addEventListener("scroll", renderVisibleItems);
    renderVisibleItems(); // 初始渲染
  </script>
</html>

Vue 3中的虚拟滚动实践

在Vue 3中,我们可以利用Composition API更优雅地实现虚拟滚动。以下是几种实践方式:

1. 使用第三方库(推荐)

对于生产环境,建议使用成熟的虚拟滚动库,如vue-virtual-scroller

bash 复制代码
npm install vue-virtual-scroller@next
xml 复制代码
<template>
  <RecycleScroller
    class="scroller"
    :items="items"
    :item-size="50"
    key-field="id"
    v-slot="{ item }"
  >
    <div class="user">
      {{ item.name }}
    </div>
  </RecycleScroller>
</template>

<script setup>
import { ref } from 'vue';
import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';

// 模拟长列表数据
const items = ref(Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  name: `用户 ${i + 1}`
})));
</script>

<style scoped>
.scroller {
  height: 400px;
}

.user {
  height: 50px;
  display: flex;
  align-items: center;
  padding: 0 16px;
  border-bottom: 1px solid #eee;
}
</style>

2. 自定义虚拟滚动组件

如果你想更深入地理解原理,可以自己实现一个简单的虚拟滚动组件:

我的整体思路如下:滚动区域有3个部分组成,上方占位区,显示的项目,下方占位区。每次滚动不需要去计算每一个展示项目的位置。只需要计算上下占位区的高度与显示的项目。

基于这个思路写出了基本代码,然后让AI优化了代码与处理了边界值相关的一些问题。

xml 复制代码
<template>
  <div class="virtual-scroll-container" ref="viewWrapRef">
    <div class="scroll-wrap" :style="{ height: scrollContainerHeight + 'px' }">
      <!-- 上方占位区 -->
      <div
        class="top-placeholder"
        :style="{ height: topPlaceholderHeight + 'px' }"
      ></div>

      <!-- 内容区 -->
      <div class="content-container">
        <div v-for="item in displayItems" :key="item.id" class="item">
          <slot name="item" :item="item"></slot>
        </div>
      </div>

      <!-- 下方占位区 -->
      <div
        class="bottom-placeholder"
        :style="{ height: bottomPlaceholderHeight + 'px' }"
      ></div>
    </div>
  </div>
</template>

<script setup lang="ts">
import {
  ref,
  onMounted,
  onUnmounted,
  computed,
  nextTick,
  watch,
  withDefaults,
} from "vue";

interface Props {
  items: any[];
  itemHeight: number;
  overscan?: number; // 添加缓冲项目数量配置
}

const props = withDefaults(defineProps<Props>(), {
  items: () => [],
  itemHeight: 50,
  overscan: 5, // 默认上下各缓冲5个项目
});

const viewWrapRef = ref<HTMLElement | null>(null);
const startIndex = ref(0);
const animationId = ref<number | null>(null);

// 添加容器高度和滚动位置的状态
const containerHeight = ref(0);
const scrollTop = ref(0);

const scrollContainerHeight = computed(() => {
  return props.itemHeight * props.items.length;
});

// 计算可见项目数量(包含缓冲)
const visibleItemCount = computed(() => {
  if (containerHeight.value > 0) {
    const baseCount = Math.ceil(containerHeight.value / props.itemHeight);
    return baseCount + props.overscan * 2; // 上下都添加缓冲
  }
  return 10; // 默认值
});

// 计算实际显示的项目(包含缓冲)
const displayItems = computed(() => {
  if (!visibleItemCount.value || props.items.length === 0) {
    return [];
  }

  // 计算起始索引,考虑缓冲
  const start = Math.max(0, startIndex.value - props.overscan);
  // 计算结束索引,确保不超出数组范围
  const end = Math.min(
    props.items.length,
    startIndex.value + visibleItemCount.value + props.overscan
  );

  return props.items.slice(start, end);
});

// 计算上方占位高度
const topPlaceholderHeight = computed(() => {
  const start = Math.max(0, startIndex.value - props.overscan);
  return start * props.itemHeight;
});

// 计算下方占位高度(确保不为负数)
const bottomPlaceholderHeight = computed(() => {
  if (!visibleItemCount.value || props.items.length === 0) {
    return 0;
  }

  const end = Math.min(
    props.items.length,
    startIndex.value + visibleItemCount.value + props.overscan
  );

  const bottomHeight = (props.items.length - end) * props.itemHeight;
  return Math.max(0, bottomHeight); // 确保不为负数
});

// 监听容器尺寸变化
const resizeObserver = new ResizeObserver((entries) => {
  for (const entry of entries) {
    containerHeight.value = entry.contentRect.height;
  }
});

onMounted(() => {
  nextTick(() => {
    if (viewWrapRef.value) {
      // 初始化容器高度
      containerHeight.value = viewWrapRef.value.clientHeight;

      // 监听容器尺寸变化
      resizeObserver.observe(viewWrapRef.value);

      // 添加滚动事件监听
      viewWrapRef.value.addEventListener("scroll", handleScroll, {
        passive: true,
      });

      // 初始计算一次
      updateStartIndex();
    }
  });
});

onUnmounted(() => {
  if (viewWrapRef.value) {
    viewWrapRef.value.removeEventListener("scroll", handleScroll);
    resizeObserver.unobserve(viewWrapRef.value);
  }

  if (animationId.value) {
    cancelAnimationFrame(animationId.value);
  }

  resizeObserver.disconnect();
});

// 直接处理滚动,不使用防抖(为了更快速响应)
const handleScroll = () => {
  if (animationId.value) {
    cancelAnimationFrame(animationId.value);
  }

  animationId.value = requestAnimationFrame(() => {
    updateStartIndex();
  });
};

// 更新起始索引
const updateStartIndex = () => {
  if (!viewWrapRef.value) return;

  const currentScrollTop = viewWrapRef.value.scrollTop;
  scrollTop.value = currentScrollTop;

  // 计算新的起始索引
  let newStartIndex = Math.floor(currentScrollTop / props.itemHeight);

  // 确保索引在有效范围内
  newStartIndex = Math.max(0, newStartIndex);
  newStartIndex = Math.min(newStartIndex, Math.max(0, props.items.length - 1));

  // 只有当索引真正改变时才更新
  if (newStartIndex !== startIndex.value) {
    startIndex.value = newStartIndex;
  }
};

// 监听items变化,重置状态
watch(
  () => props.items,
  (newItems) => {
    if (newItems.length === 0) {
      startIndex.value = 0;
    } else {
      // 如果滚动位置超出了新数组的范围,调整到末尾
      if (startIndex.value >= newItems.length) {
        startIndex.value = Math.max(0, newItems.length - 1);

        // 滚动到正确位置
        nextTick(() => {
          if (viewWrapRef.value) {
            viewWrapRef.value.scrollTop = startIndex.value * props.itemHeight;
          }
        });
      }
    }
  }
);

// 暴露方法给父组件
defineExpose({
  scrollToIndex: (index: number) => {
    if (viewWrapRef.value) {
      const targetIndex = Math.max(0, Math.min(index, props.items.length - 1));
      viewWrapRef.value.scrollTop = targetIndex * props.itemHeight;
      startIndex.value = targetIndex;
    }
  },
  scrollToTop: () => {
    if (viewWrapRef.value) {
      viewWrapRef.value.scrollTop = 0;
      startIndex.value = 0;
    }
  },
  scrollToBottom: () => {
    if (viewWrapRef.value) {
      const targetIndex = Math.max(0, props.items.length - 1);
      viewWrapRef.value.scrollTop = targetIndex * props.itemHeight;
      startIndex.value = targetIndex;
    }
  },
});
</script>

<style lang="scss" scoped>
.virtual-scroll-container {
  position: relative;
  height: 300px;
  overflow: auto;

  .scroll-wrap {
    position: relative;
  }

  .top-placeholder,
  .bottom-placeholder {
    width: 100%;
  }

  .content-container {
    width: 100%;
  }
}
</style>
相关推荐
逃离疯人院1 小时前
前端性能深度解析:网络响应时间与实际渲染时间的鸿沟
前端
我是若尘1 小时前
🚀 深入理解 Claude Code:从入门到精通的能力全景图
前端
老前端的功夫1 小时前
Webpack 深度解析:从配置哲学到编译原理
前端·webpack·前端框架·node.js
重铸码农荣光1 小时前
🌟 Vibe Coding 时代:用自然语言打造你的专属 AI 单词应用
前端·vibecoding
MegatronKing1 小时前
SSL密钥协商导致抓包失败的原因分析
前端·https·测试
Kratzdisteln1 小时前
【TIDE DIARY 5】cursor; web; api-key; log
前端
Danny_FD1 小时前
使用docx库实现文档导出
前端·javascript
良木林2 小时前
webpack:快速搭建环境
前端·webpack·node.js
网络点点滴2 小时前
Vue3路由的props
前端·javascript·vue.js