在开发中遇到长列表的时候,尤其是含有很多富媒体的列表(微博、推特等),当列表数据量变大的时候,很容易引发性能的问题,出现列表滚动时卡顿的情况。为了解决这样的问题,我们可以采用虚拟列表的方式,来渲染有限多个的列表数据,当用户滚动页面的时候再对数据进行填充。
效果预览:
💭思考
制作虚拟列表时,我们需要考虑几个方面:
- 列表总高度的计算
- 单个列表的高度
- 实时状态下需要实际渲染的列表个数
- 获取实时的滚动高度
列表的总高度可以通过 itemCount * itemHeight 来计算得到。单个列表的高度和列表的总数,我们可以通过参数进行传入。实时状态下需要渲染的列表个数,我们可以通过显示区域的高度除以单个列表的高度来获取。儿实时的滚动高度,我们可以通过监听列表的滚动来获取到。
⚙️实现
搭建组件的骨架
<template><div ref="scroller" class="virtual-scroller" @scroll="onScroll"><div :style="{ height: `${totalHeight}px` }"><div :style="{ transform: `translateY(${offsetY}px)` }"><template v-for="item in renderedItems"><slot :item="item" /></template></div></div></div></template>
最外层的 virtual-scroller 是我们的滚动区域,我们可以通过监听它的滚动事件来实时获取到页面的滚动距离。向内一层是我们模拟的一个页面元素,给他设定一个高度来撑开页面,从而显示滚动条并且触发滚动效果。再向内一层是实际的列表父级,我们通过为其设置竖向的偏移量来保持相对的滚动距离。
获取滚动高度
const onScroll = (e: Event) =>(scrollTop.value = (e.target as HTMLElement).scrollTop)// 获取 virtual-scroller 的高度onMounted(() => {scrollerHeight.value = scroller.value!.offsetHeight})
获取列表总高,列表数量和单列表高度都由父级组件传入
const totalHeight = computed(() => items.value.length * itemHeight.value)
为了在滚动中获得更好的显示效果,我们可以在单屏最大显示量的情况下增加 bufferSize,这样我们就可以获取到当前屏幕显示的第一个列表的索引
const bufferSize = 5const startPosition = computed(() =>Math.max(0, Math.floor(scrollTop.value / itemHeight.value) - bufferSize))
进而我们可以计算得到偏移的距离
const offsetY = computed(() => startPosition.value * itemHeight.value)
最后,我们还需要计算得到当前渲染的列表数据
const renderedItems = computed(() => {// div 可容纳的列表量加上两倍 bufferSizelet count =Math.ceil(scrollerHeight.value / itemHeight.value) + 2 * bufferSize// 需要考虑剩余的数量count = Math.min(items.value.length - startPosition.value, count)return items.value.slice(startPosition.value, startPosition.value + count)})
这样,我们就实现了一个简单的定高虚拟列表。未来我们还可以做进一步的扩展,例如数据缓存和增加对动态高度的判断。
完整代码
<script lang="ts" setup>import VirtualScroller from './VirtualScroller.vue'const myMassiveArray = Array.from({ length: 500 }, (_, i) => ({id: i,details: {i: {guess: Math.random() > 0.5 ? 'yes' : 'no',},},}))</script><template><div class="wrapper"><VirtualScrollerv-slot="{ item }":item-height="35":items="myMassiveArray"><div :key="item.id" :item="item" class="my-item">{{ item.id }}--{{ item.details.i.guess }}</div></VirtualScroller></div></template><style scoped>.wrapper {height: 300px;overflow: scroll;border: 1px solid #ccc;}.my-item {box-sizing: border-box;height: 35px;background-color: lightpink;border-bottom: 1px solid lightgreen;}</style>
<script lang="ts" setup>import { computed, onMounted, ref, toRefs } from 'vue'const props = defineProps<{ items: any[]; itemHeight: number }>()const { items, itemHeight } = toRefs(props)const scroller = ref<HTMLElement>()const scrollerHeight = ref(0)const bufferSize = 5const scrollTop = ref(0)const totalHeight = computed(() => items.value.length * itemHeight.value)const startPosition = computed(() =>Math.max(0, Math.floor(scrollTop.value / itemHeight.value) - bufferSize))const offsetY = computed(() => startPosition.value * itemHeight.value)const renderedItems = computed(() => {let count =Math.ceil(scrollerHeight.value / itemHeight.value) + 2 * bufferSizecount = Math.min(items.value.length - startPosition.value, count)return items.value.slice(startPosition.value, startPosition.value + count)})const onScroll = (e: Event) =>(scrollTop.value = (e.target as HTMLElement).scrollTop))onMounted(() => {scrollerHeight.value = scroller.value!.offsetHeight})</script><template><div ref="scroller" class="virtual-scroller" @scroll="onScroll"><div :style="{ height: `${totalHeight}px` }"><div :style="{ transform: `translateY(${offsetY}px)` }"><template v-for="item in renderedItems"><slot :item="item" /></template></div></div></div></template><style scoped>.virtual-scroller {height: 200px;overflow: auto;will-change: transform;border: 1px solid red;}.virtual-scroller > div {overflow: hidden;will-change: transform;}.virtual-scroller > div > div {will-change: transform;}</style>
