Skip to content

虚拟列表

导航目录

前言

虚拟列表是一种优化长列表性能的技术,主要解决以下问题:

  • 页面卡顿:当列表数据量较大时,过多的 DOM 元素会导致页面滚动卡顿
  • 内存占用:大量 DOM 元素会增加浏览器内存占用
  • 渲染性能:减少 DOM 操作次数,提高渲染效率
  • 用户体验:保持流畅的滚动体验,即使在数据量大的情况下

实现思路(列表高度固定)

核心步骤

  1. 页面布局:将列表拆分为三个区域:视口区域滚动条渲染列表区域
  2. 高度计算
    • 视口区域高度 = 列表展示个数 × 每一项高度
    • 滚动条高度 = 列表总个数 × 每一项高度
  3. 数据截取
    • 起始索引 = 视口滚动距离 / 每一项高度
    • 结束索引 = 起始索引 + 列表展示个数
  4. 偏移计算:渲染列表区域的偏移量 = 起始索引 × 每一项高度

补充(列表高度不固定)

核心步骤

  1. 缓存机制:定义列表缓存当前元素的高度top 距离bottom 距离
  2. 高度更新:页面更新时,获取真实 DOM 高度,更新缓存数据,并重新计算滚动条高度(最后一个元素的 bottom 距离)
  3. 索引计算:视口滚动时,通过二分查找在缓存列表中计算起始索引,结束索引 = 起始索引 + 列表展示个数
  4. 偏移计算:渲染列表区域的偏移量 = 起始索引元素的 top 距离

核心实现代码

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

interface IDatas {
  id: number | string;
  [propname: string]: any;
}

type TypePosition = {
  index: number;
  height: number;
  top: number;
  bottom: number;
};

const props = defineProps<{
  datas: IDatas[]; // 列表元素
  size: number; // 一个列表元素高度
  remain: number; // 展示几个列表元素
  variable?: boolean; // 列表元素高度不固定
}>();

const startIndex = ref<number>(0);
const endIndex = ref<number>(props.remain);
const offset = ref<number>(0);
const viewport = ref<HTMLElement>();
const scrollBar = ref<HTMLElement>();
const scrollElements = ref<HTMLElement[]>([]);

let positions: TypePosition[] = [];

// 缓存当前元素的高度,top、bottom
// DOM渲染后,更新实际的高度,top、bottom
const cacheList = () => {
  positions = props.datas.map((_, index) => ({
    index,
    height: props.size,
    top: index * props.size,
    bottom: (index + 1) * props.size,
  }));
};

const init = () => {
  if (props.variable) {
    cacheList();
  }
};

init();

// 二分查找计算起始索引
const getStartIndex = (value: number): number => {
  let start = 0;
  let end = positions.length;
  let temp = 0;
  while (start < end) {
    let middleIndex = parseInt(String((start + end) / 2));
    let middleValue = positions[middleIndex].bottom;
    if (value == middleValue) {
      return middleIndex + 1;
    } else if (middleValue < value) {
      start = middleIndex + 1;
    } else if (middleValue > value) {
      if (temp == 0 || temp > middleIndex) {
        temp = middleIndex;
      }
      end = middleIndex - 1;
    }
  }
  return temp;
};

// 滚动处理函数(防抖)
const handleScroll = (time: number) => {
  let timer: any;
  return () => {
    clearTimeout(timer);
    timer = setTimeout(() => {
      const scrollTop = viewport.value?.scrollTop || 0;
      if (props.variable) {
        startIndex.value = getStartIndex(scrollTop);
        endIndex.value = startIndex.value + props.remain;
        // 超出预留区域才需要偏移
        offset.value = positions[startIndex.value - preIndex.value].top;
      } else {
        startIndex.value = Math.floor(scrollTop / props.size);
        endIndex.value = startIndex.value + props.remain;
        // 超出预留区域才需要偏移
        offset.value =
          startIndex.value * props.size - preIndex.value * props.size;
      }
    }, time);
  };
};
const debounce = handleScroll(0);

// 组件挂载时初始化
onMounted(() => {
  nextTick(() => {
    if (viewport.value && scrollBar.value) {
      viewport.value.style.height = `${props.size * props.remain}px`; // 视图高度
      scrollBar.value.style.height = `${props.size * props.datas.length}px`; // 滚动条的高度
    }
  });
});

// 组件更新时更新缓存数据
onUpdated(() => {
  nextTick(() => {
    scrollElements.value.forEach((element) => {
      const { height } = element.getBoundingClientRect();
      const index = Number(element.getAttribute("vid"));
      let oldHeight = positions[index].height;
      let gapHeight = oldHeight - height;
      if (gapHeight) {
        positions[index].height = height;
        positions[index].bottom = positions[index].bottom - gapHeight;
        for (let i = index + 1; i < positions.length; i++) {
          positions[i].top = positions[i - 1].bottom;
          positions[i].bottom = positions[i].bottom - gapHeight;
        }
      }
    });
    // 动态计算滚动条高度
    if (scrollBar.value) {
      scrollBar.value.style.height =
        positions[positions.length - 1].bottom + "px";
    }
  });
});

// 预渲染区域防止白屏
const preIndex = computed(() => {
  return Math.min(startIndex.value, props.remain);
});

// 预渲染区域
const nextIndex = computed(() => {
  return Math.min(props.datas.length - endIndex.value, props.remain);
});

// 格式化数据,用于元素不定高时,查找更新真实的DOM信息
const formatData = computed(() => {
  return props.datas.map((item, index) => ({ ...item, index }));
});

// 实例渲染的列表
const visibleList = computed(() => {
  return formatData.value.slice(
    startIndex.value - preIndex.value,
    endIndex.value + nextIndex.value
  );
});
</script>

<template>
  <div class="viewport" ref="viewport" @scroll="debounce">
    <div class="scroll-bar" ref="scrollBar"></div>
    <div
      class="scroll-list"
      :style="{ transform: `translate3d(0,${offset}px,0)` }"
    >
      <div
        v-for="_ in visibleList"
        :key="_.id"
        :vid="_.id"
        ref="scrollElements"
      >
        <slot :_="(_ as IDatas)"></slot>
      </div>
    </div>
  </div>
</template>

<style scoped>
.viewport {
  overflow-y: scroll;
  position: relative;
}

.scroll-list {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}
</style>

优势与应用场景

优势

  1. 性能优化:只渲染可视区域内的元素,大大减少 DOM 数量,提高页面性能
  2. 内存节省:减少 DOM 元素的创建和管理,降低内存占用
  3. 流畅滚动:避免因 DOM 过多导致的滚动卡顿,提供流畅的用户体验
  4. 灵活性:支持固定高度和动态高度的列表
  5. 可扩展性:通过插槽机制,支持自定义列表项内容

应用场景

  1. 长列表展示:如聊天记录、商品列表、新闻列表等
  2. 大数据量表格:需要展示大量数据的表格场景
  3. 无限滚动:结合分页加载,实现无限滚动效果
  4. 移动端应用:移动端设备性能有限,虚拟列表能显著提升体验
  5. 时间线:如日志、活动记录等时间线展示

注意事项

  1. 性能权衡:对于数据量较小的列表,虚拟列表可能带来不必要的复杂性
  2. 初始化成本:首次渲染时需要计算初始状态,可能有轻微的性能开销
  3. 滚动精度:在动态高度列表中,滚动位置的计算可能存在轻微误差
  4. 浏览器兼容性:依赖现代浏览器的 DOM API,如 getBoundingClientRect()
  5. 样式处理:需要确保列表项的样式不会影响虚拟列表的布局计算