虚拟列表之无限滚动

无限滚动

某种业务场景,需要下拉展示大量的列表数据,例如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>

参考文章:

posted @ 2021-09-06 15:41  eastsae  阅读(830)  评论(0编辑  收藏  举报