众所周知,我是非常喜欢听歌的一个人,但是苦于喜欢的歌有一些是翻唱,网易云上没有,所以自己用Electron做了个听歌的软件,支持歌单混合网易云等多个来源的歌曲,但是有个问题,一个歌单里的歌曲可能会很多,甚至可能上千,在我初期没管这个问题,但是前一段时间注意到了,并且之前听说过虚拟列表这个东西,据说是不管多少数据,只需要加载几个十几个元素,避免了不可见的元素也大量渲染。本来应该是介绍原生Javascript实现的,但是这篇文章是以现有项目为基础,所以直接以我自己的项目为例,介绍一下我的虚拟列表实现,我最后会提到如何转成原生JS的。
效果展示
事先说明,这纯个人使用,也是通过我自己的VIP获取的歌曲数据
页面中下部分的列表,就是我实现虚拟列表之后的,可从"Total 312"看出,这歌单总共有312首歌,但是我只需要渲染9个列表元素,以及一个wrapper
元素
原理
总体原理
就是利用一个空内容的wrapper
元素撑起元素的高度,同时显示滚动条,然后利用一个绝对定位的list
元素显示应该出现在视野内的元素,并且动态更新就好了(这里的动态更新是vue提供的能力),通过计算
几个注意
- 这里实现的是固定高度的列表元素,因为需要计算渲染几个元素,所以需要提前设置每个元素的高度
wrapper
这个元素是空的,但是高度为总元素个数 × 每个元素的高度
即<div class="wrapper" :style="{height: '${itemHeight * items.length}'px}"></div>
list
<div class="list" :style="{transform: `translateY(${-transform + scrollTop}px)`}">
<div class="list-item"
v-for="(item, index) in displayingItems"
:key="startIndex + index"
:style="{ height: itemHeight + 'px' }">
<slot :item="item" :index="startIndex + index"></slot>
</div>
</div>
这里的list使用绝对定位,定位在top, bottom, left, right都是0
对于不会Vue3的人,v-for就是根据给的数组生成多个相同标签元素的的东西,自己写就是多写几个list-item元素就好了,key不用管, style就是style,slot不用管,就当成实际用的时候的具体的列表元素的内容就行了(innerHTML)
数据计算
const scrollTop = ref(0);
const displayingItems = computed(() => {
return items.slice(startIndex.value, endIndex.value)
})
const startIndex = computed(() => {
return Math.floor(scrollTop.value / itemHeight)
})
const endIndex = computed(() => {
return startIndex.value + size + 1;
});
const transform = computed(() => {
return scrollTop.value % itemHeight
})
scrollTop: 是那个容器的scrollTop
ref(0)可以直接当成0,但是用到这个变量的地方都会在这个变量更新的时候更新,computed同理
displayingItems: 需要渲染的元素 startIndex, endIndex, 需要渲染的元素在原数组中的开始索引和结束索引 tranform: 自动计算的list元素要偏移多少,因为在你滚动容器的时候,绝对定位的列表也需要偏移,才能正确显示在该显示的位置
完整实现
<script setup lang="ts">
import {computed, watch, ref, useTemplateRef, onUnmounted, nextTick} from "vue";
const {
items,
itemHeight,
className = "",
size = 10,
eventName = "virtualList-refresh"
} = defineProps<{
items: Array<any>,
itemHeight: number,
className?: string,
size?: number,
eventName ?: string,
}>()
const scrollTop = ref(0);
const displayingItems = computed(() => {
return items.slice(startIndex.value, endIndex.value)
})
const startIndex = computed(() => {
return Math.floor(scrollTop.value / itemHeight)
})
const endIndex = computed(() => {
return startIndex.value + size + 1;
});
const transform = computed(() => {
return scrollTop.value % itemHeight
})
const containerEl = useTemplateRef<HTMLDivElement>('container')
// console.log(displayingItems.value)
function handleScroll() {
if (!containerEl.value) return;
scrollTop.value = 0;
nextTick(() => scrollTop.value = containerEl.value!.scrollTop)
// console.log(scrollTop.value)
}
function refresh() {
handleScroll()
}
watch(() => items, refresh)
</script>
<template>
<div ref="container" :class="className" class="list-container" @scroll.passive="handleScroll">
<div class="list" :style="{transform: `translateY(${-transform + scrollTop}px)`}">
<div class="list-item"
v-for="(item, index) in displayingItems"
:key="startIndex + index"
:style="{ height: itemHeight + 'px' }">
<slot :item="item" :index="startIndex + index"></slot>
</div>
</div>
<div class="wrapper" :style="{height: `${itemHeight * items.length}px`}"></div>
</div>
</template>
<style scoped>
.list-container {
position: relative;
height: 100%;
width: 100%;
overflow-y: auto;
}
.list {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
}
</style>
原生JS实现方法
- 把ref, computed等去掉,并且自己实现响应式(可以通过defineProperty实现自定义setter时更新),也可以写个更新函数,在滚动事件触发时更新需要更新的数据。
- 把slot换成实际的内容,因为slot是个抽象的概念,用原生js需要换成实际的列表元素内容
- v-for改成多个元素,可以写死,也可以写个函数,判断数量和之前不同就通过js渲染
- 数据计算部分同1部分,写成手动实现的更新数据
总结
优点
- 在大数据量时候确实改善了dom渲染的负担,加快了滚动速度
缺点
- 实现有点麻烦
- 实际使用中,滚动过快可能出现刷新不及时出现空白部分(不是重度强迫症基本没什么影响)
个人感受
我本来写虚拟列表是为了优化性能,但实际上用处不是太大,主要还是拓展了自己的技术,因为都是百万级或以上的数据才会用到虚拟列表,如果是自己的项目还好说,加就加了,但是如果是现有的项目,最好还是不要主动重构列表显示部分,要不然可能吃力不讨好。总体而言,虚拟列表在处理长列表时具有重要价值,但也需要在实际应用中权衡其优缺点。