前言
虚拟列表对于大部分一线开发同学来说是一点都不陌生的东西了,有的同学是直接使用第三方组件。但是面试时如果你简历上面写了虚拟列表,却给面试官说是通过三方组件实现的,此时空气可能都凝固了。所以这篇文章欧阳将会教你2分钟内实现一个定高的虚拟列表,至于不定高的虚拟列表下一篇文章来写。
什么是虚拟列表
有的特殊场景我们不能分页,只能渲染一个长列表。这个长列表中可能有几万条数据,如果全部渲染到页面上用户的设备差点可能就会直接卡死了,这时我们就需要虚拟列表来解决问题。
一个常见的虚拟列表是下面这样的,如下图:
其中实线框的item表示在视口区域内真实渲染DOM,虚线框的item表示并没有渲染的DOM。
在定高的虚拟列表中,我们可以根据可视区域的高度
和每个item的高度
计算得出在可视区域内可以渲染多少个item。不在可视区域里面的item那么就不需要渲染了(不管有几万个还是几十万个item),这样就能解决长列表性能很差的问题啦。
实现滚动条
按照上面的图,很容易想到我们的dom结构应该是下面这样的:
<template>
<div class="container">
<div class="list-wrapper">
<!-- 只渲染可视区域列表数据 -->
</div>
</div>
</template>
<style scoped>
.container {
height: 100%;
overflow: auto;
position: relative;
}
</style>
给可视区域container
设置高度100%
,也可以是一个固定高度值。并且设置overflow: auto;
让内容在可视区域中滚动。
此时我们遇见第一个问题,滚动条是怎么来的,可视区域是靠什么撑开的?
答案很简单,我们知道每个item的高度itemSize
,并且知道有多少条数据listData.length
。那么itemSize * listData.length
不就是真实的列表高度了吗。所以我们可以在可视区域container
中新建一个名为placeholder
的空div,将他的高度设置为itemSize * listData.length
,这样可视区域就被撑开了,并且滚动条也有了。代码如下:
<template>
<div class="container">
<div class="placeholder" :style="{ height: listHeight + 'px' }"></div>
<div class="list-wrapper">
<!-- 只渲染可视区域列表数据 -->
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from "vue";
const { listData, itemSize } = defineProps({
listData: {
type: Array,
default: () => [],
},
itemSize: {
type: Number,
default: 100,
},
});
const listHeight = computed(() => listData.length * itemSize);
</script>
<style scoped>
.container {
height: 100%;
overflow: auto;
position: relative;
}
.placeholder {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
</style>
placeholder
采用绝对定位,为了不挡住可视区域内渲染的列表,所以将其设置为z-index: -1
。
接下来就是计算容器里面到底渲染多少个item,很简单,Math.ceil(可视区域的高度 / 每个item的高度)
。
为什么使用Math.ceil
向上取整呢?
只要有个item在可视区域漏了一点出来,我们也应该将其渲染。
此时我们就能得到几个变量:
start
:可视区域内渲染的第一个item的index的值,初始化为0。renderCount
:可视区域内渲染的item数量。end
:可视区域内渲染的最后一个item的index值,他的值等于start + renderCount
。注意我们这里使用start + renderCount
实际是多渲染了一个item,比如start = 0
和renderCount = 2
,我们设置的是end = 2
,实际是渲染了3个item。目的是为了预渲染下一个,后面会讲。
监听滚动事件
有了滚动条后就可以开始滚动了,我们监听container
容器的scroll事件。
可视区域中的内容应该随着滚动条的滚动而变化,也就是说在scroll事件中我们需要重新计算start
的值。
function handleScroll(e) {
const scrollTop = e.target.scrollTop;
start.value = Math.floor(scrollTop / itemSize);
offset.value = scrollTop - (scrollTop % itemSize);
}
如果当前itemSize
的值为100。
如果此时滚动的距离在0-100之间,比如下面这样:
上面这张图item1还没完全滚出可视区域,有部分在可视区域内,部分在可视区域外。此时可视区域内显示的就是item1-item7
的模块了,这就是为什么前面我们计算end时要多渲染一个item,不然这里item7就没法显示了。
滚动距离在0-100之间时,渲染的DOM没有变化,我们完全是复用浏览器的滚动,并没有进行任何处理。
当scrollTop
的值为100时,也就是刚刚把item1滚到可视区外面时。此时item1已经不需要渲染了,因为已经看不见他了。所以此时的start
的值就应该从0
更新为1
,同理如果scrollTop
的值为110
,start的值也一样是1
。所以得出start.value = Math.floor(scrollTop / itemSize);
如下图:
此时的start
从item2开始渲染,但是由于前面我们复用了浏览器的滚动,所以实际渲染的DOM第一个已经在可视区外面了。此时可视区看见的第一个是item3,很明显是不对的,应该看见的是第一个是item2。
此时应该怎么办呢?
很简单,使用translate
将列表向下偏移一个item的高度就行,也就是100px。列表偏移后就是下面这样的了:
如果当前scrollTop
的值为200,那么偏移值就是200px。所以我们得出
offset.value = scrollTop - (scrollTop % itemSize);
为什么这里要减去scrollTop % itemSize
呢?
因为在滚动时如果是在item的高度范围内滚动,我们是复用浏览器的滚动,此时无需进行偏移,所以计算偏移值时需要减去scrollTop % itemSize
。
实际上从一个item滚动到另外一个item时,比如从item0
滚动到item1
。此时会做两件事情:将start
的值从0
更新为1
和根据scrollTop
计算得到列表的偏移值100
,从而让新的start对应的item1
重新回到可视范围内。
这个是运行效果图:
下面是完整的代码:
<template>
<div ref="container" class="container" @scroll="handleScroll($event)">
<div class="placeholder" :style="{ height: listHeight + 'px' }"></div>
<div class="list-wrapper" :style="{ transform: getTransform }">
<div
class="card-item"
v-for="item in renderList"
:key="item.id"
:style="{
height: itemSize + 'px',
lineHeight: itemSize + 'px',
backgroundColor: `rgba(0,0,0,${item.value / 100})`,
}"
>
{{ item.value + 1 }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from "vue";
const { listData, itemSize } = defineProps({
listData: {
type: Array,
default: () => [],
},
itemSize: {
type: Number,
default: 100,
},
});
const container = ref(null);
const containerHeight = ref(0);
const renderCount = computed(() => Math.ceil(containerHeight.value / itemSize));
const start = ref(0);
const offset = ref(0);
const end = computed(() => start.value + renderCount.value);
const listHeight = computed(() => listData.length * itemSize);
const renderList = computed(() => listData.slice(start.value, end.value + 1));
const getTransform = computed(() =>`translate3d(0,${offset.value}px,0)`);
onMounted(() => {
containerHeight.value = container.value.clientHeight;
});
function handleScroll(e) {
const scrollTop = e.target.scrollTop;
start.value = Math.floor(scrollTop / itemSize);
offset.value = scrollTop - (scrollTop % itemSize);
}
</script>
<style scoped>
.container {
height: 100%;
overflow: auto;
position: relative;
}
.placeholder {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
.card-item {
padding: 10px;
color: #777;
box-sizing: border-box;
border-bottom: 1px solid #e1e1e1;
}
</style>
这个是父组件的代码:
<template>
<div style="height: 100vh; width: 100vw">
<VirtualList :listData="data" :itemSize="100" />
</div>
</template>
<script setup>
import VirtualList from "./common.vue";
import { ref } from "vue";
const data = ref([]);
for (let i = 0; i < 1000; i++) {
data.value.push({ id: i, value: i });
}
</script>
<style>
html {
height: 100%;
}
body {
height: 100%;
margin: 0;
}
#app {
height: 100%;
}
</style>
总结
这篇文章我们讲了如何实现一个定高的虚拟列表,首先根据可视区域的高度和item的高度计算出视口内可以渲染出来的item数量renderCount
。然后根据滚动的距离去计算start
的位置,计算end
的位置时使用start + renderCount
预渲染一个item。在每个item范围内滚动时直接复用浏览器的滚动,此时无需进行任何处理。当从一个item滚动到另外一个item时,此时会做两件事情:更新start的值和根据scrollTop
计算列表的偏移值让新的start对应的item重新回到可视范围内。
我是 ssh,工作 6 年+,阿里云、字节跳动 Web infra 一线拼杀出来的资深前端工程师 + 面试官,非常熟悉大厂的面试套路,Vue、React 以及前端工程化领域深入浅出的文章帮助无数人进入了大厂。 欢迎 长按图片加 ssh 为好友
,我会第一时间和你分享前端行业趋势,学习途径等等。2024 陪你一起度过!