Skip to content

虚拟列表

前言

  • 移动端列表数据量比较大的时,滚动页面由于DOM太多,会造成页面卡顿,影响用户体验

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

  • 1、页面布局的时候,将列表拆分成三个区域、视口区域、滚动条,渲染列表区域
  • 2、首先计算视口区域的高度(列表展示的个数每一项的高度),滚动条的高度(列表总个数每一项的高度)
  • 3、计算数据截取的范围,起始索引的位置=(视口滚动的距离/每一项的高度),结束的索引的位置=起始索引+列表展示的个数
  • 4、计算渲染列表区域的偏移量,起始索引的位置*每一项的高度

补充(列表高度不固定)

  • 1、当列表的高度不固定时,首先会定义一个列表用来缓存当前元素的高度,top、bottom距离
  • 2、页面在更新的时候,得到列表真实的DOM,更新缓存的元素的高度,top、bottom距离,最后重新计算滚动条的高度(最后一个元素的bottom距离)
  • 3、视口区域在滚动的时候,通过滚动的距离,利用二分查找去缓存列表中查找算出起始索引的位置,结束的索引的位置=起始索引+列表展示的个数
  • 4、最后计算渲染列表区域的偏移量,结束起始索引元素的top距离
html
<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 && 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>