在开发中遇到长列表的时候,尤其是含有很多富媒体的列表(微博、推特等),当列表数据量变大的时候,很容易引发性能的问题,出现列表滚动时卡顿的情况。为了解决这样的问题,我们可以采用虚拟列表的方式,来渲染有限多个的列表数据,当用户滚动页面的时候再对数据进行填充。
效果预览:
💭思考
制作虚拟列表时,我们需要考虑几个方面:
- 列表总高度的计算
- 单个列表的高度
- 实时状态下需要实际渲染的列表个数
- 获取实时的滚动高度
列表的总高度可以通过 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 = 5
const startPosition = computed(() =>
Math.max(0, Math.floor(scrollTop.value / itemHeight.value) - bufferSize)
)
进而我们可以计算得到偏移的距离
const offsetY = computed(() => startPosition.value * itemHeight.value)
最后,我们还需要计算得到当前渲染的列表数据
const renderedItems = computed(() => {
// div 可容纳的列表量加上两倍 bufferSize
let 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">
<VirtualScroller
v-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 = 5
const 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 * bufferSize
count = 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>