Skip to content

场景:虚拟列表

要点速览

  • 痛点:一次性渲染海量列表(1 万+)导致页面卡顿与内存膨胀
  • 三种策略:懒加载、时间分片、虚拟列表;本质和适用场景不同
  • 虚拟列表核心:仅渲染可视区域元素,随滚动替换内容,保持固定 DOM 数量
  • 关键实现:占位容器高度、索引计算、渲染位移、节流与缓冲

问题与目标

  • 目标:在海量数据场景下保持滚动流畅、首屏快速、内存稳定。
  • 挑战:全量渲染会产生成千上万的 DOM 节点,布局与重绘成本极高,交互卡顿。

方案对比

懒加载

  • 原理:仅在元素进入视口时加载资源(图片等),离开视口不移除 DOM。
  • 优点:降低初次带宽消耗,提升首屏体验;适用于图文内容延迟加载。
  • 缺点:DOM 依旧全量存在,列表项规模极大时依然卡顿与占内存。

时间分片

  • 原理:利用 requestAnimationFrame 将大量渲染任务拆分到帧之间执行,由浏览器调度。
  • 优点:避免长任务阻塞,首屏可更快出现部分内容。
  • 缺点:最终仍是全量 DOM,渲染次数多且不直观;复杂场景效果有限。

虚拟列表

  • 原理:只创建“可视区域”内的少量列表项,随滚动替换为下一段数据。滚动的高度由一个“占位容器”提供。
  • 优点:DOM 数量与视口大小线性相关,性能稳定、内存友好。
  • 适用:聊天记录、日志、排名榜、海量表格等长列表场景。

实践建议

  • 海量列表优先采用虚拟列表;配合懒加载图片与时间分片初始化可进一步优化体验。

原理与数据模型

  • 基本变量:
    • screenHeight:可视区域高度
    • itemSize:每项固定高度(定高场景)
    • listData:完整数据源(数组)
    • scrollTop:滚动偏移
  • 推导数据:
    • listHeight = listData.length * itemSize
    • visibleCount = Math.ceil(screenHeight / itemSize)
    • startIndex = Math.floor(scrollTop / itemSize)
    • endIndex = Math.min(startIndex + visibleCount, listData.length)
    • visibleData = listData.slice(startIndex, endIndex)
    • startOffset = scrollTop - (scrollTop % itemSize)(渲染位移与项高度对齐)

为什么对齐位移

对齐到 itemSize 边界可以避免出现“半个列表项”露出,确保每次渲染的首项顶部与可视区边界一致,视觉更稳定。

定高实现示例

虚拟列表组件

vue
<template>
  <!-- 虚拟列表组件外层容器 -->
  <div
    ref="listContainer"
    class="infinite-list-container"
    @scroll="scrollHandler"
  >
    <!-- 该元素高度为总列表的高度,目的是形成滚动条 -->
    <div
      class="infinite-list-phantom"
      :style="{ height: listHeight + 'px' }"
    ></div>
    <!-- 该元素为可视区域,里面存放当前可见的列表项 -->
    <div
      class="infinite-list"
      :style="{ transform: `translateY(${startOffset.value}px)` }"
    >
      <div
        v-for="item in visibleData"
        :key="item.id"
        class="infinite-list-item"
        :style="{ height: itemSize + 'px', lineHeight: itemSize + 'px' }"
      >
        {{ item.value }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from "vue";
const props = defineProps({
  listData: {
    type: Array,
    default: () => [],
    required: true,
  },
  itemSize: {
    type: Number,
    default: 150,
    required: true,
  },
});
// 可视区域的高度
const listContainer = ref(null); // 容器引用
const screenHeight = ref(0); // 可视区域的高度
onMounted(() => {
  screenHeight.value = listContainer.value.clientHeight;
});

// 可显示的列表项数 = 可视区域高度 / 列表项高度
const visibleCount = computed(() => {
  return Math.ceil(screenHeight.value / props.itemSize);
});

// 显示列表项的起始索引和结束索引[startIndex, endIndex)
const startIndex = ref(0);
const endIndex = computed(() => {
  // 结束索引不能超过列表项数量
  return Math.min(startIndex.value + visibleCount.value, props.listData.length);
});

// 列表显示数据
const visibleData = computed(() => {
  return props.listData.slice(startIndex.value, endIndex.value);
});

// 列表总高度 = 列表项数量 * 列表项高度
const listHeight = computed(() => {
  return props.listData.length * props.itemSize;
});

// 向下位移的距离
const startOffset = ref(0);

// 滚动事件处理函数
const scrollHandler = () => {
  const scrollTop = listContainer.value.scrollTop;
  // 更新各项数据
  startIndex.value = Math.floor(scrollTop / props.itemSize);
  // 向下位移的距离
  startOffset.value = scrollTop - (scrollTop % props.itemSize); // 调整为与itemSize对齐
};
</script>

<style scoped>
.infinite-list-container {
  height: 100%;
  position: relative;
  /* 形成滚动条,绝对定位的子元素虽然不参与父元素的高度计算,但是会影响到父元素的滚动条 */
  overflow: auto;
}

.infinite-list-phantom {
  position: absolute;
  top: 0; /* 是相对于“滚动内容区域”的顶部 */
  left: 0;
  right: 0;
  z-index: -1;
}

.infinite-list {
  position: absolute;
  top: 0; /* 是相对于“滚动内容区域”的顶部 */
  left: 0;
  right: 0;
  text-align: center;
  background-color: #ccc;
}

.infinite-list-item {
  padding: 10px;
  color: #555;
  box-sizing: border-box;
  border-bottom: 1px solid #999;
}
</style>

使用示例

vue
<template>
  <div class="app">
    <!-- 接受列表数组和数据项高度的Props -->
    <VirtualList :listData="listData" :itemSize="100" />
  </div>
</template>

<script setup>
import { ref } from "vue";
import VirtualList from "./components/VirtualList.vue";

const listData = ref(
  Array.from({ length: 10000 }, (_, index) => {
    return {
      id: index + 1,
      value: `第${index + 1}条数据`,
    };
  })
);
</script>

<style>
html {
  height: 100%;
}

body {
  height: 100%;
  margin: 0;
}

#app,
.app {
  height: 100%;
}
</style>

结构说明

  • phantom 负责滚动高度;list 负责实际渲染。
  • transform: translateY(startOffset) 将渲染内容对齐到可视区。

进一步优化与问题处理

动态高度(非定高)

  • 问题:列表项高度不一致时,无法用简单除法得到 startIndex
  • 思路:实时或缓存项高度,维护“前缀高度和”(累计高度),用“二分查找”定位 scrollTop 对应的首项索引。
vue
<script setup>
import { ref, reactive, computed } from "vue";

const props = defineProps({
  listData: Array,
  estimatedSize: { type: Number, default: 80 },
  overscan: { type: Number, default: 2 },
});
const listContainer = ref(null);

// 记录每项真实高度,未测量时用估算值
const heights = reactive(new Map());
const prefix = ref([0]); // 前缀和数组(第 i 项表示 0..i-1 的累计高度)

const rebuildPrefix = () => {
  const arr = [0];
  for (let i = 0; i < props.listData.length; i++) {
    const h = heights.get(i) ?? props.estimatedSize;
    arr[i + 1] = arr[i] + h;
  }
  prefix.value = arr;
};

const totalHeight = computed(() => {
  // 懒更新:当测量发生时调用 rebuildPrefix
  return prefix.value[prefix.value.length - 1];
});

const findStartIndex = (scrollTop) => {
  // 二分查找 prefix 中第一个大于等于 scrollTop 的位置 - 1
  let lo = 0,
    hi = prefix.value.length - 1;
  while (lo < hi) {
    const mid = (lo + hi) >> 1;
    if (prefix.value[mid] <= scrollTop) lo = mid + 1;
    else hi = mid;
  }
  return Math.max(0, lo - 1);
};

// 组件内的滚动与测量逻辑略,核心在于:测量可见项高度 => 更新 heights => rebuildPrefix => 重新计算索引与位移
</script>

动态高度实现提示

  • 尽量在首次渲染后测量可见项,并在滚动过程中渐进式完善高度缓存。
  • 可选用 ResizeObserver 监听项高度变化,减少手动测量复杂度。

白屏与缓冲(Overscan)

  • 现象:快速滚动时,渲染窗口未及时更新导致短暂空白。
  • 方案:增加 overscan(可视项之外的额外缓冲数量),并用 requestAnimationFrame 或节流优化滚动处理。
js
const onScroll = (e) => {
  const scrollTop = e.target.scrollTop;
  if (!scheduled) {
    scheduled = true;
    requestAnimationFrame(() => {
      startIndex.value = Math.floor(scrollTop / props.itemSize);
      startOffset.value = scrollTop - (scrollTop % props.itemSize);
      scheduled = false;
    });
  }
};

滚动性能优化

  • 使用 passive 监听提升滚动响应:addEventListener('scroll', handler, { passive: true })
  • 使用 requestAnimationFrame 或节流(如 16–33ms)降低计算频率。
  • 减少不必要的派生计算,用 computed 处理纯派生数据。

工程化与生态选择

  • 开源库:vue-virtual-scroller@tanstack/virtual-core 等,覆盖定高与动态高度,提供更丰富的特性(锚点、分组、表格)。
  • 团队实践:在可控复杂度下优先自研定高版本;复杂需求采用成熟库减少维护成本。

常见坑与注意事项

常见错误

  • key 不稳定:v-for:key 必须稳定唯一,否则会出现错位与复用错误。
  • 容器高度未设置:可视容器未给定明确高度会导致滚动与计算异常。
  • 过度 reflow:频繁测量与同步布局读写会触发回流,需批量或节流处理。
  • SEO 与可访问性:虚拟列表仅渲染部分内容,服务端渲染与爬虫场景需评估;确保可聚焦与导航无障碍。

总结

  • 虚拟列表通过“占位 + 视窗渲染 + 位移对齐”将 DOM 数量与视口绑定,显著提升海量数据场景的渲染与交互性能。
  • 定高实现简单可靠;动态高度需配合测量与前缀和 + 二分查找定位。
  • 配合 overscan 与滚动节流可进一步消除白屏与抖动,达到专业级交互体验。