在开发中遇到长列表的时候,尤其是含有很多富媒体的列表(微博、推特等),当列表数据量变大的时候,很容易引发性能的问题,出现列表滚动时卡顿的情况。为了解决这样的问题,我们可以采用虚拟列表的方式,来渲染有限多个的列表数据,当用户滚动页面的时候再对数据进行填充。
效果预览:
Apr-29-2022 15-49-06.gif

💭思考

制作虚拟列表时,我们需要考虑几个方面:

  • 列表总高度的计算
  • 单个列表的高度
  • 实时状态下需要实际渲染的列表个数
  • 获取实时的滚动高度

列表的总高度可以通过 itemCount * itemHeight 来计算得到。单个列表的高度和列表的总数,我们可以通过参数进行传入。实时状态下需要渲染的列表个数,我们可以通过显示区域的高度除以单个列表的高度来获取。儿实时的滚动高度,我们可以通过监听列表的滚动来获取到。
image.png

⚙️实现

搭建组件的骨架

  1. <template>
  2. <div ref="scroller" class="virtual-scroller" @scroll="onScroll">
  3. <div :style="{ height: `${totalHeight}px` }">
  4. <div :style="{ transform: `translateY(${offsetY}px)` }">
  5. <template v-for="item in renderedItems">
  6. <slot :item="item" />
  7. </template>
  8. </div>
  9. </div>
  10. </div>
  11. </template>

最外层的 virtual-scroller 是我们的滚动区域,我们可以通过监听它的滚动事件来实时获取到页面的滚动距离。向内一层是我们模拟的一个页面元素,给他设定一个高度来撑开页面,从而显示滚动条并且触发滚动效果。再向内一层是实际的列表父级,我们通过为其设置竖向的偏移量来保持相对的滚动距离。
获取滚动高度

  1. const onScroll = (e: Event) =>
  2. (scrollTop.value = (e.target as HTMLElement).scrollTop)
  3. // 获取 virtual-scroller 的高度
  4. onMounted(() => {
  5. scrollerHeight.value = scroller.value!.offsetHeight
  6. })

获取列表总高,列表数量和单列表高度都由父级组件传入

  1. const totalHeight = computed(() => items.value.length * itemHeight.value)

为了在滚动中获得更好的显示效果,我们可以在单屏最大显示量的情况下增加 bufferSize,这样我们就可以获取到当前屏幕显示的第一个列表的索引

  1. const bufferSize = 5
  2. const startPosition = computed(() =>
  3. Math.max(0, Math.floor(scrollTop.value / itemHeight.value) - bufferSize)
  4. )

进而我们可以计算得到偏移的距离

  1. const offsetY = computed(() => startPosition.value * itemHeight.value)

最后,我们还需要计算得到当前渲染的列表数据

  1. const renderedItems = computed(() => {
  2. // div 可容纳的列表量加上两倍 bufferSize
  3. let count =
  4. Math.ceil(scrollerHeight.value / itemHeight.value) + 2 * bufferSize
  5. // 需要考虑剩余的数量
  6. count = Math.min(items.value.length - startPosition.value, count)
  7. return items.value.slice(startPosition.value, startPosition.value + count)
  8. })

这样,我们就实现了一个简单的定高虚拟列表。未来我们还可以做进一步的扩展,例如数据缓存和增加对动态高度的判断。

完整代码

  1. <script lang="ts" setup>
  2. import VirtualScroller from './VirtualScroller.vue'
  3. const myMassiveArray = Array.from({ length: 500 }, (_, i) => ({
  4. id: i,
  5. details: {
  6. i: {
  7. guess: Math.random() > 0.5 ? 'yes' : 'no',
  8. },
  9. },
  10. }))
  11. </script>
  12. <template>
  13. <div class="wrapper">
  14. <VirtualScroller
  15. v-slot="{ item }"
  16. :item-height="35"
  17. :items="myMassiveArray"
  18. >
  19. <div :key="item.id" :item="item" class="my-item">
  20. {{ item.id }}--{{ item.details.i.guess }}
  21. </div>
  22. </VirtualScroller>
  23. </div>
  24. </template>
  25. <style scoped>
  26. .wrapper {
  27. height: 300px;
  28. overflow: scroll;
  29. border: 1px solid #ccc;
  30. }
  31. .my-item {
  32. box-sizing: border-box;
  33. height: 35px;
  34. background-color: lightpink;
  35. border-bottom: 1px solid lightgreen;
  36. }
  37. </style>
  1. <script lang="ts" setup>
  2. import { computed, onMounted, ref, toRefs } from 'vue'
  3. const props = defineProps<{ items: any[]; itemHeight: number }>()
  4. const { items, itemHeight } = toRefs(props)
  5. const scroller = ref<HTMLElement>()
  6. const scrollerHeight = ref(0)
  7. const bufferSize = 5
  8. const scrollTop = ref(0)
  9. const totalHeight = computed(() => items.value.length * itemHeight.value)
  10. const startPosition = computed(() =>
  11. Math.max(0, Math.floor(scrollTop.value / itemHeight.value) - bufferSize)
  12. )
  13. const offsetY = computed(() => startPosition.value * itemHeight.value)
  14. const renderedItems = computed(() => {
  15. let count =
  16. Math.ceil(scrollerHeight.value / itemHeight.value) + 2 * bufferSize
  17. count = Math.min(items.value.length - startPosition.value, count)
  18. return items.value.slice(startPosition.value, startPosition.value + count)
  19. })
  20. const onScroll = (e: Event) =>
  21. (scrollTop.value = (e.target as HTMLElement).scrollTop)
  22. )
  23. onMounted(() => {
  24. scrollerHeight.value = scroller.value!.offsetHeight
  25. })
  26. </script>
  27. <template>
  28. <div ref="scroller" class="virtual-scroller" @scroll="onScroll">
  29. <div :style="{ height: `${totalHeight}px` }">
  30. <div :style="{ transform: `translateY(${offsetY}px)` }">
  31. <template v-for="item in renderedItems">
  32. <slot :item="item" />
  33. </template>
  34. </div>
  35. </div>
  36. </div>
  37. </template>
  38. <style scoped>
  39. .virtual-scroller {
  40. height: 200px;
  41. overflow: auto;
  42. will-change: transform;
  43. border: 1px solid red;
  44. }
  45. .virtual-scroller > div {
  46. overflow: hidden;
  47. will-change: transform;
  48. }
  49. .virtual-scroller > div > div {
  50. will-change: transform;
  51. }
  52. </style>