虚拟列表之无限滚动
无限滚动
某种业务场景,需要下拉展示大量的列表数据,例如10万条。假设一条数据就是一个dom, 如果使用普通的下拉加载,就会在一个页面上生成10个dom。这样会导致页面性能急剧下降。对用户体验极其不优好。针对天量数据的下拉列表,是否有更好的方案来解决呢,接下来就分析下如何通过虚拟列表来实现。
虚拟列表
虚拟列表的实现,实际上就是在首屏加载的时候,只加载可视区域内需要的列表项,当滚动发生时,动态通过计算获得可视区域内的列表项,并将非可视区域内存在的列表项删除。
如果要实现以上的思路。我们需要哪些计算哪些值呢?
- 计算当前可视区域起始数据索引(startIndex)
- 计算当前可视区域结束数据索引(endIndex)
- 计算当前可视区域的数据,并渲染到页面中
- 计算整个虚拟列表的偏移位置,并通过transformY 来实时调整虚拟列表的位置。也就是说,虚拟列表要保留在可视区范围内。当滚动发生的时候,我们需要通过scroll事件来触发并计算需要transformY的偏移量。
虚拟列表在dom表现形式上是一个高度等于可视区的绝对定位的元素。
dom结构的说明
为了实现这个无限滚动的效果,对可视区域内的列表项进行渲染,保持列表容器的高度并可正常的触发滚动, 我们需要有一个元素展示真正渲染的数据, 一个元素撑开高度保证滚动, 一个父亲容器,请看下面的代码
<div class="infinite-list-container">
<div
class="infinite-list-phantom"
>
</div>
<div class="infinite-list">
<div
class="infinite-list-item"
>
<div
class="infinite-list-item"
>
...
</div>
</div>
</div>
.infinite-list-container {
height: 100%;
overflow: auto;
position: relative;
-webkit-overflow-scrolling: touch;
}
/*绝对定位, 虽然脱离了文档流,但是受制于其父元素,当父元素设置了overflow: auto;当其高度
高于父元素时候,就会出现滚动条。在此作用是,展示列表的总高度,但是不渲染列表。*/
.infinite-list-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
/**绝对定位,层级高于infinite-list-phantom,用于可视区渲染的容器。当滚动发生时,需要通过transformY实时的将此容器移动到可视区范围内。此容器内要渲染的数据,是根据滚动的状态变化的。*/
.infinite-list {
left: 0;
right: 0;
top: 0;
position: absolute;
text-align: center;
}
总结:
- infinite-list-container 为可视区域的容器
- infinite-list-phantom 为容器内的占位,高度为总列表高度,用于形成滚动条
- infinite-list 为列表项的渲染区域,需要实时计算移动到可视区域。
监听滚动
监听infinite-list-container的scroll事件,获取滚动位置scrollTop
我们需要拿到:
- 可视区域高度:screenHeight
- 列表每项高度:itemSize
- 列表数据:listData
- 当前滚动位置:scrollTop
需要计算的变量总结
- 列表总高度listHeight = listData.length * itemSize
- 可显示的列表项数visibleCount = Math.ceil(screenHeight / itemSize)
- 数据的起始索引startIndex = Math.floor(scrollTop / itemSize)
- 数据的结束索引endIndex = startIndex + visibleCount
- 列表显示数据为visibleData = listData.slice(startIndex,endIndex)
当滚动后,由于渲染区域相对于可视区域已经发生了偏移,此时我需要获取一个偏移量startOffset,通过样式控制将渲染区域偏移至可视区域中。
偏移量
startOffset = scrollTop - (scrollTop % itemSize);
接下来,我们代码来展示下(vue来实现下,其他的类似)
<template>
<div
class="infinite-list-container"
ref="list"
@scroll="scrollEvent"
>
<div class="scrollTopBtn" @click="scrollToTop" v-show="end > 20">
回到顶部
</div>
<div
class="infinite-list-phantom"
:style="{ height: listHeight + 'px' }"
></div>
<div class="infinite-list" :style="{ transform: getTransform }">
<div
class="infinite-list-item"
v-for="item in visibleData"
:key="item.id"
:style="{ height: itemSize + 'px',lineHeight: itemSize + 'px' }"
>
<div class="left-section">
{{ item.title[0] }}
</div>
<div class="right-section">
<div class="title">{{ item.title }}</div>
<div class="desc">{{ item.content }}</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Prop, Watch, Component } from 'vue-property-decorator';
import Faker from 'faker';
interface Data {
title: string;
content: string;
id: number | string;
}
@Component
export default class VirtualList extends Vue {
public readonly itemSize: number = 100;
public listData: Data[] = [];
// 可视区域高度
public screenHeight: number = document.documentElement.clientHeight || document.body.clientHeight;
// 可显示的列表项数
public visibleCount: number = Math.ceil(this.screenHeight / this.itemSize);
// 偏移量
public startOffset: number = 0;
// 起始索引
public start: number = 0;
// 结束索引
public end: number = this.start + this.visibleCount;
public $refs: {
list: any;
};
// 列表总高度
get listHeight() {
return this.listData.length * this.itemSize;
}
// 偏移量对应的style
get getTransform() {
return `translate3d(0,${this.startOffset}px,0)`;
}
// 获取真实显示列表数据
get visibleData() {
return this.listData.slice(
this.start,
Math.min(this.end, this.listData.length)
);
}
getTenListData() {
if (this.listData.length >= 200) {
return [];
}
return new Array(10).fill({}).map(item => ({ id: Faker.random.uuid(), title: Faker.name.title(), content: Faker.random.words() }))
}
created() {
this.listData = this.getTenListData();
}
// scrollTo() 方法可以使界面滚动到给定元素的指定坐标位置。 https://developer.mozilla.org/zh-CN/docs/Web/API/Element/scrollTo
scrollToTop() {
this.$refs.list.scrollTo({
top: 0,
left: 0,
behavior: 'smooth'
});
}
public scrollEvent(e: any) {
// 当前滚动位置
const scrollTop = this.$refs.list.scrollTop;
// 此时的开始索引
this.start = Math.floor(scrollTop / this.itemSize);
// 此时的结束索引
this.end = this.start + this.visibleCount;
if (this.end > this.listData.length) {
this.listData = this.listData.concat(this.getTenListData());
}
// 此时的偏移量
this.startOffset = scrollTop - (scrollTop % this.itemSize);
}
}
</script>
<style>
.scrollTopBtn {
position: fixed;
border-radius: 50%;
font-size: 12px;
color: white;
background: goldenrod;
bottom: 101px;
right: 20px;
z-index: 10000;
width: 50px;
height: 50px;
text-align: center;
line-height: 50px;
}
.infinite-list-container {
margin-top: 10px;
height: 99%;
overflow: scroll;
position: relative;
-webkit-overflow-scrolling: touch;
}
.infinite-list-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
.infinite-list {
left: 0;
right: 0;
top: 0;
position: absolute;
text-align: center;
}
.infinite-list-item {
background: white;
box-shadow: 0 0 10px rgba(144, 144, 144, 0.15);
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
/* padding: 10px; */
margin-top: 10px;
}
.left-section {
width: 25%;
display: flex;
justify-content: center;
align-items: center;
font-size: 25px;
font-weight: bold;
color: white;
background: #6ab6fc;
border-radius: 10px;
}
.right-section {
height: 100%;
margin-left: 20px;
flex: 1;
}
.title {
font-size: 14px;
font-weight: 500;
text-align: left;
height: 14px;
}
.desc {
margin-top: 10px;
font-size: 12px;
font-weight: 400;
text-align: left;
height: 12px;
}
</style>
参考文章: