Skip to content

长列表优化 (虚拟滚动 Virtual Scrolling)

1. 问题的根源

当一个页面需要渲染一个非常长的列表时(例如,通过无限滚动加载了成千上万条数据),会引发严重的性能问题。

  • 渲染开销: 浏览器需要渲染大量的 DOM 元素,这个过程本身非常耗时。任何数据的增删都可能触发大规模的重排 (Reflow) 和重绘 (Repaint)。
  • 内存占用: 每个 DOM 元素和关联的组件实例都会占用内存,当元素数量巨大时,内存消耗会急剧上升,可能导致页面崩溃。
  • GPU 负担: 大量的元素渲染给 GPU 带来沉重负担,导致页面滚动时出现明显的卡顿和掉帧。

性能检测示例

通过 Chrome DevTools 的 Performance 面板录制长列表的渲染过程,可以清晰地看到:

  • 脚本执行 (Scripting) 时间过长(例如超过 1000ms)。
  • 渲染 (Rendering) 时间过长(例如接近 1000ms)。
  • 内存 (JS Heap) 占用从几十兆飙升到上百兆。

2. 核心解决思路:虚拟滚动

虚拟滚动的核心思想是 只渲染用户当前可视区域 (Viewport) 内的列表项。对于那些在可视区域之外(上方或下方)的列表项,我们不创建真实的 DOM 元素,而是用一片空白区域来代替它们,从而在视觉上“欺骗”用户,让他们以为整个列表都已加载。

3. 实现原理详解

实现虚拟滚动需要三个关键部分:一个固定高度的滚动容器、一个用于撑开总高度的占位元素,以及一个只承载可视区域 DOM 的渲染池。

3.1. 结构布局

  1. 滚动容器 (Container)

    • 一个外层 div
    • 设置一个固定的高度(如 height: 100%height: 500px)。
    • 设置 overflow: auto;overflow-y: scroll;,使其成为一个可滚动的区域。
  2. 内容撑开区 (Wrapper/Placeholder)

    • 滚动容器内部的一个 div
    • 不渲染任何真实列表项。它的唯一作用是通过设置一个巨大的高度来“撑开”滚动容器,从而产生一个长度正确的滚动条。
    • 它的高度通过公式计算:总高度 = 总数据量 × 单个列表项的高度
    • 需要设置 position: relative;,为内部绝对定位的元素提供定位上下文。
  3. 可视区域渲染项 (Rendered Items Pool)

    • 实际渲染的列表项 DOM 元素。
    • 它们的数量非常有限(例如,仅渲染 20-30 个)。
    • 每个列表项都使用 position: absolute; 进行定位。
    • 通过 transform: translateY(Ypx) 来精确控制每个列表项在滚动容器内的垂直位置。

3.2. 关键计算逻辑

当用户滚动时,我们需要动态计算并更新需要显示的内容和它们的位置。

  1. 监听滚动事件: 在滚动容器上监听 scroll 事件。

  2. 获取滚动位置: 在事件回调中,通过 container.scrollTop 获取当前垂直滚动的距离。

  3. 计算可视范围的起止索引:

    • 起始索引 (startIndex): 表示可视区域顶部的第一个元素在完整数据列表中的索引。
    javascript
    // 向下取整,因为即使一个元素只露出一部分,它也算可见
    const startIndex = Math.floor(scrollTop / itemSize);
    • 结束索引 (endIndex): 表示可视区域底部的最后一个元素在完整数据列表中的索引。
    javascript
    const containerHeight = container.clientHeight;
    // 向上取整,确保覆盖到所有部分可见的元素
    const endIndex = Math.ceil((scrollTop + containerHeight) / itemSize);
  4. 建立渲染池 (Pool) 与缓冲区 (Buffer):

    • 截取数据: 从完整的原始数据数组中,使用 items.slice(startIndex, endIndex) 截取出当前需要渲染的数据子集。
    • 增加缓冲区: 为了防止用户快速滚动时出现短暂的白屏,可以在 startIndex 前和 endIndex 后额外多渲染几个列表项。这被称为缓冲区。
    javascript
    const buffer = 10; // 上下各多渲染10个
    const finalStartIndex = Math.max(0, startIndex - buffer); // 防止索引为负
    const finalEndIndex = endIndex + buffer;
    const visibleItems = allItems.slice(finalStartIndex, finalEndIndex);
  5. 计算每个元素的位置:

    • 渲染池中的每一个元素,都需要计算它精确的 translateY 值。
    • 这个值等于该元素在 完整数据列表中的索引 乘以 单个列表项的高度
    javascript
    // item.index 是该项在 allItems 中的原始索引
    const position = item.index * itemSize;
    // 在模板中应用样式
    // style="{ transform: `translateY(${position}px)` }"

3.3. 重要前提

这种基础的虚拟滚动实现有一个非常重要的前提:所有列表项的高度必须是固定的、已知的。如果列表项高度动态变化,计算 startIndex 和每个元素的位置会变得极其复杂,性能开销也会剧增。

4. 组件化实现 (以 Vue 为例)

为了复用,我们可以将上述逻辑封装成一个通用的 RecycleScroller 组件。

4.1. 组件设计

  • Props:

    • items: (Array, required) 完整的原始数据数组。
    • itemSize: (Number, required) 每个列表项的固定高度。
    • keyField: (String, default: 'id') 用于 :key 绑定的唯一标识符字段名。
  • Scoped Slot (作用域插槽):

    • 组件内部负责循环和定位逻辑,但它不应该关心每个列表项具体长什么样。
    • 通过作用域插槽,组件可以将当前要渲染的单项数据(item)回传给父组件,由父组件来决定如何渲染。

4.2. 使用示例

vue
<template>
  <RecycleScroller
    class="my-scroller"
    :items="longListData"
    :item-size="54"
    key-field="id"
  >
    <template v-slot="{ item }">
      <ListItem :data="item" />
    </template>
  </RecycleScroller>
</template>

<script>
import RecycleScroller from './RecycleScroller.vue';
import ListItem from './ListItem.vue';

export default {
  components: { RecycleScroller, ListItem },
  data() {
    return {
      // 假设 longListData 是一个包含 10000 个对象的数组
      longListData: [...]
    };
  }
}
</script>

<style>
.my-scroller {
  height: 100vh; /* 让滚动器撑满整个视口高度 */
}
</style>

4.3. 核心代码片段 (简化版)

vue
<template>
  <div class="recycle-scroller-container" @scroll="handleScroll" ref="container">
    <div class="recycle-scroller-wrapper" :style="{ height: `${totalSize}px` }">
      <div
        v-for="poolItem in visibleItemsPool"
        :key="poolItem.item[keyField]"
        class="recycle-scroller-item"
        :style="{ transform: `translateY(${poolItem.position}px)` }"
      >
        <slot :item="poolItem.item"></slot>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    items: { type: Array, required: true },
    itemSize: { type: Number, required: true },
    keyField: { type: String, default: 'id' }
  },
  data() {
    return {
      visibleItemsPool: [], // 渲染池
      scrollTop: 0
    };
  },
  computed: {
    // 计算总高度
    totalSize() {
      return this.items.length * this.itemSize;
    }
  },
  methods: {
    handleScroll() {
      // 使用 requestAnimationFrame 优化滚动事件处理
      window.requestAnimationFrame(() => {
        this.scrollTop = this.$refs.container.scrollTop;
        this.updateVisibleItems();
      });
    },
    updateVisibleItems() {
      const startIndex = Math.floor(this.scrollTop / this.itemSize);
      const endIndex = Math.ceil((this.scrollTop + this.$refs.container.clientHeight) / this.itemSize);
      
      // 添加缓冲区
      const buffer = 10;
      const finalStartIndex = Math.max(0, startIndex - buffer);
      const finalEndIndex = Math.min(this.items.length, endIndex + buffer);
      
      const newPool = [];
      for (let i = finalStartIndex; i < finalEndIndex; i++) {
        newPool.push({
          item: this.items[i],        // 原始数据
          position: i * this.itemSize // 垂直偏移量
        });
      }
      this.visibleItemsPool = newPool;
    }
  },
  mounted() {
    this.updateVisibleItems(); // 初始加载
  }
};
</script>

<style scoped>
.recycle-scroller-container {
  overflow-y: auto;
  position: relative;
}
.recycle-scroller-wrapper {
  position: relative;
  width: 100%;
}
.recycle-scroller-item {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}
</style>

5. 使用现成的库

在实际项目中,从零开始实现虚拟滚动是复杂的,需要处理很多边界情况。推荐使用经过社区检验的成熟库。

推荐库: vue-virtual-scroller

这是一个功能强大且性能优异的 Vue 虚拟滚动库。

  1. 安装:

    bash
    npm install vue-virtual-scroller
  2. 基本用法: 它的用法与我们自己封装的组件非常相似,遵循了相同的模式。

    javascript
    // main.js
    import Vue from 'vue'
    import VueVirtualScroller from 'vue-virtual-scroller'
    import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' // 引入CSS
    
    Vue.use(VueVirtualScroller)
    vue
    <template>
      <RecycleScroller
        class="scroller"
        :items="list"
        :item-size="54"
        key-field="id"
        v-slot="{ item }"
      >
        <div class="user">
          {{ item.name }}
        </div>
      </RecycleScroller>
    </template>

该库还提供了处理 动态高度 列表项的 DynamicScroller 组件,但需要注意,动态高度场景的性能开销会比固定高度场景更大。在绝大多数情况下,应优先考虑将列表项设计为固定高度。