Skip to content

虚拟列表优化

遗留问题:

  • 动态高度
  • 白屏问题
  • 滚动事件触发频率过高

动态高度

在实际应用中,列表项目里面可能包含一些可变内容,导致列表项高度并不相同。例如新浪微博:

image-20240702084546314

不固定的高度就会导致:

  • 列表总高度:listHeight = listData.length * itemSize
  • 偏移量的计算:startOffset = scrollTop - (scrollTop % itemSize)
  • 数据的起始索引 startIndex = Math.floor(scrollTop / itemSize)

这些属性的计算不能再通过上面的方式来计算。因此我们会遇到这样的一些问题:

  1. 如何获取真实高度?
  2. 相关属性该如何计算?
  3. 列表渲染的项目有何改变?

解决思路:

  1. 如何获取真实高度?

    • 如果能获得列表项高度数组,真实高度问题就很好解决。但在实际渲染之前是很难拿到每一项的真实高度的,所以我们采用预估一个高度渲染出真实 DOM,再根据 DOM 的实际情况去更新真实高度。
    • 创建一个缓存列表,其中列表项字段为 索引、高度与定位,并预估列表项高度用于初始化缓存列表。在渲染后根据 DOM 实际情况更新缓存列表
  2. 相关的属性该如何计算?

    • 显然以前的计算方式都无法使用了,因为那都是针对固定值设计的。
    • 于是我们需要 根据缓存列表重写计算属性、滚动回调函数,例如列表总高度的计算可以使用缓存列表最后一项的定位字段的值。
  3. 列表渲染的项目有何改变?

    • 因为用于渲染页面元素的数据是根据 开始/结束索引数据列表 中筛选出来的,所以只要保证索引的正确计算,那么渲染方式是无需变化的。
    • 对于开始索引,我们将原先的计算公式改为:在 缓存列表 中搜索第一个底部定位大于 列表垂直偏移量 的项并返回它的索引
    • 对于结束索引,它是根据开始索引生成的,无需修改。

代码

vue
<template>
  <!-- 外层容器 -->
  <div ref="list" class="infinite-list-container" @scroll="scrollHandler">
    <!-- 该元素高度为总列表的高度,目的是为了形成滚动 -->
    <div ref="listHeight" class="infinite-list-phantom"></div>
    <!-- 该元素为可视区域,里面就是一个一个列表项 -->
    <div ref="content" class="infinite-list">
      <div
        class="infinite-list-item"
        ref="items"
        v-for="item in visibleData"
        :key="item.id"
      >
        {{ item.value }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, watch, onUpdated, nextTick } from "vue";
const props = defineProps({
  listData: {
    type: Array,
    default: () => [],
  },
  itemSize: {
    type: Number,
    default: 150,
  },
  // 预估高度
  estimatedItemSize: {
    type: Number,
    required: true,
  },
});

// 引用container元素
const list = ref(null);
// 可视区域高度
const screenHeight = ref(0);
// 开始索引
const startIndex = ref(0);
// 结束索引
const endIndex = ref(0);
// 初始的偏移量
// const startOffset = ref(0)

// 用于创建列表项元素的引用
const items = ref([]);
// 用于引用phantom元素
const listHeight = ref(null);
// 用于引用list元素
const content = ref(null);

// 缓存列表,用于存储列表项的位置信息
let positions = [];
// 用于初始化每个列表项的位置信息
const initPostions = () => {
  positions = props.listData.map((_, index) => ({
    index, // 列表项的下标
    height: props.estimatedItemSize, // 列表项的高度,这里采用预估的高度
    top: index * props.estimatedItemSize, // 列表项的顶部位置,根据下标和预估高度计算
    bottom: (index + 1) * props.estimatedItemSize, // 列表项的底部位置,也是根据下标和预估高度计算
  }));
};

// 列表总高度
// 现在因为列表项不定高,所以不能再采用这样的计算方式
// const listHeight = computed(() => props.listData.length * props.itemSize)
// 可显示的列表项数
const visibleCount = computed(() =>
  Math.ceil(screenHeight.value / props.itemSize)
);
// 列表显示数据
const visibleData = computed(() =>
  props.listData.slice(
    startIndex.value,
    Math.min(endIndex.value, props.listData.length)
  )
);

// 向下位移的距离
// const getTransform = computed(() => `translate3d(0, ${startOffset.value}px, 0)`)

// const getStartIndex = (scrollTop) => {
//   // 找到第一个底部位置大于滚动高度的列表项
//   let item = positions.find((i) => i && i.bottom > scrollTop)
//   return item.index
// }

// 关于查找 startIndex 的方法,可以使用二分查找法来进行优化
const binarySearch = (list, value) => {
  let start = 0;
  let end = list.length - 1;
  let tempIndex = null;
  while (start <= end) {
    let midIndex = parseInt((start + end) / 2);
    let midValue = list[midIndex].bottom;
    if (midValue === value) {
      return midIndex + 1;
    } else if (midValue < value) {
      start = midIndex + 1;
    } else if (midValue > value) {
      if (tempIndex === null || tempIndex > midIndex) {
        tempIndex = midIndex;
      }
      end = end - 1;
    }
  }
  return tempIndex;
};
const getStartIndex = (scrollTop) => {
  return binarySearch(positions, scrollTop);
};

// 滚动对应的处理函数
const scrollHandler = () => {
  // 这里要做的事情主要就是更新各项数据
  let scrollTop = list.value.scrollTop;
  // startIndex.value = Math.floor(scrollTop / props.itemSize)
  startIndex.value = getStartIndex(scrollTop);
  endIndex.value = startIndex.value + visibleCount.value;
  // startOffset.value = scrollTop - (scrollTop % props.itemSize)
  setStartOffset();
};

onMounted(() => {
  // 获取可视区域高度
  screenHeight.value = list.value.clientHeight;
  startIndex.value = 0;
  endIndex.value = startIndex.value + visibleCount.value;
  // 在组件挂载的时候,初始化列表项的位置信息
  initPostions();
});

const updateItemsSize = () => {
  items.value.forEach((node, index) => {
    // 获取列表项实际的高度
    let height = node.getBoundingClientRect().height;
    // 计算预估高度和真实高度的差值
    let oldHeight = positions[index].height; // 拿到该项的预估高度
    let dValue = oldHeight - height;
    if (dValue) {
      // 如果存在差值,那么就需要更新位置信息
      positions[index].bottom -= dValue;
      positions[index].height = height;
      // 接下来需要更新后续所有列表项的位置
      for (let i = index + 1; i < positions.length; i++) {
        positions[i].top = positions[i - 1].bottom;
        positions[i].bottom -= dValue;
      }
    }
  });
};

// 更新偏移量
const setStartOffset = () => {
  let startOffset =
    startIndex.value >= 1 ? positions[startIndex.value - 1].bottom : 0;
  content.value.style.transform = `translate3d(0, ${startOffset}px, 0)`;
};

onUpdated(() => {
  // 这里之所以使用 nextTick,是为了确保DOM更新完毕后再去获取列表项的位置信息
  nextTick(() => {
    if (!items.value || !items.value.length) return;
    // 1. 更新列表项的高度
    updateItemsSize();
    // 2. 更新虚拟列表的高度
    listHeight.value.style.height =
      positions[positions.length - 1].bottom + "px";
    // 3. 更新列表的偏移量
    setStartOffset();
  });
});

watch(() => props.listData, initPostions);
</script>

<style scoped>
.infinite-list-container {
  height: 100%;
  overflow: auto;
  position: relative;
  -webkit-overflow-scrolling: touch;
}

.infinite-list-phantom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}

.infinite-list {
  left: 0;
  right: 0;
  top: 0;
  position: absolute;
  text-align: center;
}

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

-EOF-

MIT License