VUE3实现虚拟列表

322 阅读4分钟

虚拟列表:按需显示的一种实现,即只对可视区域进行渲染,对非可见区域中的数据不渲染或者部分渲染。

虚拟列表原理:

编辑

       仅加载可视区域内的列表项,当发生滚动时计算应展示列表项的startIndex,endIndex,并计算对应的数据在整个列表中的偏移量(为了让列表项展示在可视区域内)

编辑

获取元素的scrollTop,然后依此计算在可视区域内展示的列表数据项的前后索引。

<template>
    <div class="main" @scroll="throttles" ref="list">
        <div :style="{ height: totalHeight + 'px' }"></div>
        <div class="list" :style="{ transform }" ref="itemList">
            <el-card v-for="item in visibleData" :key="item.index" class="card">
                XXX
            </el-card>
        </div>
    </div>
</template>

定高


    const scrollTop: number = list.value.scrollTop 
    //起始索引  向下取整  将仅一部分可见的列表项也渲染出来
    start = Math.floor(scrollTop / itemSize)
    //结束索引
    end = start + visibleCount > data.length ? data.length : start + visibleCount  //防止end越界

    const data: any = {
    list: [],  //请求的数据
    length: 0  //总数据长度
}
    
    //可视区域展示条数 向上取整,将仅一部分可见的列表项也包含进去
    const visibleCount: number = Math.ceil(visibleHeight / itemSize) 

    //每一项的高度
    const itemSize: number = 220  
     
    //可视区域高度
    const visibleHeight: number = 700  

<template>
    <div class="main" @scroll="throttles" ref="list">
        <!-- 防止滚动条样式变化,当滚动条没有被隐藏时 -->
        <div :style="{ height: totalHeight + 'px' }"></div>
        <div class="list" :style="{ transform }" ref="itemList">
            <el-card v-for="item in visibleData" :key="item.index" class="card">
                <el-descriptions :title="'User Info' + item.index">
                    <el-descriptions-item label="Username">kooriookami</el-descriptions-item>
                    <el-descriptions-item label="Telephone">18100000000</el-descriptions-item>
                    <el-descriptions-item label="Place">Suzhou</el-descriptions-item>
                    <el-descriptions-item label="Remarks">
                        <el-tag size="small">School</el-tag>
                    </el-descriptions-item>
                    <el-descriptions-item label="Address">No.1188, Wuzhong Avenue, Wuzhong District, Suzhou, Jiangsu
                        Province</el-descriptions-item>
                </el-descriptions>
            </el-card>
        </div>
    </div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
const list = ref()
const itemList = ref()
const itemSize: number = 220  //每一项的高度
const visibleHeight: number = 700  //可视区域高度
const visibleCount: number = Math.ceil(visibleHeight / itemSize) //可视区域展示条数
let startOffset: number = 0 //偏移量
let start: number = 0   //开始索引
let end: number = visibleCount - 1 //结束索引
let data: any = {
    list: [],//请求的数据
    length: 0
}
let totalHeight: number = 0   //总高度
let transform: string = `translate3d(0,${start * itemSize}px,0)` //偏移量
const visibleData: any = ref()   //列表展示数据



//模拟请求
const getData = (start: number, end: number) => {
    start = start - 10 < 0 ? 0 : start - 10;
    if (data.length == 0)
        end = end + 10;
    else
        end = end + 10 > data.length ? data.length : end + 10;
    return new Promise((resolve) => {
        setTimeout(() => {
            let list = []
            for (let i = start; i <= end; i++) {
                list.push({
                    index: i,
                })
            }
            resolve({
                list,
                length: 100
            })
        }, 30)
    })
}


onMounted(async () => {
    data = await getData(start, end)  //请求的接口
    totalHeight = data.length * itemSize  //设置真实列表总高度
    visibleData.value = data.list    //为展示的列表项赋值
})




const scrollEvent = async () => {
    const scrollTop: number = list.value.scrollTop
    start = Math.floor(scrollTop / itemSize)
    end = start + visibleCount > data.length ? data.length : start + visibleCount 
    data = await getData(start, end)
    visibleData.value = data.list
    startOffset = start * itemSize
    transform = `translate3d(0,${startOffset}px,0)`
}

const throttles = throttle(scrollEvent)

//设置节流
const throttle=(fn: Function, delay: number = 30)=> {
    let oldTime = Date.now()
    return function () {
        const _this: any = this
        let newTime = Date.now()
        if (newTime - oldTime > delay) {
            fn.apply(_this, ...arguments)
            oldTime = newTime
        }
    }
}
</script>
<style lang="scss" scoped>
.main {
    overflow-y: scroll;
    height: 75vh;

    //隐藏滚动条
    /* Firefox */
    scrollbar-width: none;
    /* IE 10+ */
    -ms-overflow-style: none;

    ::-webkit-scrollbar {
        /* Chrome Safari */
        display: none;
    }

    .list {
        width: 100%;

        .card {
            margin-bottom: 20px;

        }
    }
}
</style>

不定高

不定高初始展示时和定高思路一致,均需要初始化设置一个列表项初始高度,以及真实列表的总高度。

然后在将数据渲染到列表上后获取列表项的真实高度,然后重新设置列表项的高度,偏移量以及真实列表总高度。

<template>
    <div class="main" @scroll="throttles" ref="list">
        <div :style="{ height: totalHeight + 'px' }"></div>
        <div class="list" :style="{ transform }" ref="itemList">
            <el-card v-for="item in visibleData" :key="item.index" class="card">
                <el-descriptions :title="'User Info' + item.index">
                    <el-descriptions-item label="Username">kooriookami</el-descriptions-item>
                    <el-descriptions-item label="Telephone">18100000000</el-descriptions-item>
                    <el-descriptions-item label="Place">Suzhou</el-descriptions-item>
                    <el-descriptions-item label="Remarks">
                        <el-tag size="small">School</el-tag>
                    </el-descriptions-item>
                    <el-descriptions-item label="Address">No.1188, Wuzhong Avenue, Wuzhong District, Suzhou, Jiangsu
                        Province</el-descriptions-item>
                </el-descriptions>
                //用来模拟不定高的情况
                <div style="height: 50px;background: skyblue" v-if="item.hidden">
                    <span>123</span>
                </div>
            </el-card>
        </div>
    </div>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted, computed, nextTick } from 'vue';
const list = ref()
const itemList = ref()
const itemSize: number = 220  //每一项的高度
const visibleHeight: number = 700  //可视区域高度
const visibleCount: number = Math.ceil(visibleHeight / itemSize) //可视区域展示条数
let startOffset: number = 0 //偏移量
let start: number = 0   //开始索引
let end: number = visibleCount - 1 //结束索引


//模拟请求
const getData = (start: number, end: number) => {
    start = start - 10 < 0 ? 0 : start - 10;
    if (data.length == 0)
        end = end + 10;
    else
        end = end + 10 > data.length ? data.length : end + 10;
    return new Promise((resolve) => {
        setTimeout(() => {
            let list = []
            for (let i = start; i <= end; i++) {
                list.push({
                    index: i,
                    hidden: i % 2 == 0 ? true : false  //控制是否显示与隐藏多的一部分
                })
            }
            resolve({
                list,
                length: 100
            })
        }, 30)
    })
}

//对数据进行缓存
const getFinalData = (list: any) => {
    return list.map((item: any) => {
        return {
            index: item.index,
            hidden: item.hidden,
            height: 220,
            top: 220 * item.index,
        }

    })
}

//存储加工好了的数据
const finalData: any = reactive([])

//finalData数组的endIndex
const footer = computed(() => finalData.length - 1)

//更改缓存
const adjustDataList = (list: any) => {
    //获取列表项
    const documentList: any = itemList.value.children
    for (let index = 0; index < list.length; index++) {
        //获取列表项的真实高度  +20是因为设置了margin-bottom为20
        list[index].height = documentList[index].offsetHeight + 20
    }
    //列表展示的数据全都没有更新
    if (footer.value < start) {
        finalData.push(...list)
        for (let index = start; index <= end; index++) {
            //第一个数据的偏移量为0
            if (finalData[index].index == 0)
                finalData[index].top = 0
            else
            //后续数据的偏移量为前一个数据的偏移量加上前一个数据的真实高度
                finalData[index].top = finalData[index - 1].top + finalData[index - 1].height

        }
    }
    //列表展示的数据仅一部分更新过,将没更新过的数据过滤出来进行更新后再加入缓存(finalData)
    if (footer.value >= start && footer.value < end) {
        const data = list.filter((item: any) => item.index > footer.value)
        finalData.push(...data)
        for (let index = data[0].index; index <= data[data.length - 1].index; index++) {
            finalData[index].top = finalData[index - 1].top + finalData[index - 1].height
        }
    }
    
    //列表项高度发生变化后,真实列表总高度也需要重新计算
    totalHeight = finalData[finalData.length - 1].top + finalData[finalData.length - 1].height + (data.length - finalData.length) * itemSize
}


let data: any = {
    list: [],//请求的数据
    length: 0
}

onMounted(async () => {
    data = await getData(start, end)
    data.list = getFinalData(data.list.filter((item: any) => item.index >= start && item.index <= end))
    totalHeight = data.length * itemSize
    visibleData.value = data.list
    //vue是异步渲染,需要nextTick在将初始化的列表项渲染完成后再拿到列表项的真实数据然后进行重新渲染
    nextTick(() => {
        adjustDataList(data.list)
        visibleData.value = finalData.slice(start, end + 1)
    })
})

let totalHeight: number = 0   //总高度
let transform: string = `translate3d(0,${start * itemSize}px,0)`
const visibleData: any = ref()

const getIndex = (scrollTop: number) => {
    //定高
    // start = Math.floor(scrollTop / itemSize)
    // end = start + visibleCount > data.length ? data.length : start + visibleCount  //防止end越界
    //不定高 不定高后可视区域展示的数据条数会浮动
    start = finalData.findIndex((item: any) => item.top >= scrollTop) - 1
    end = finalData.findLastIndex((item: any) => item.top <= scrollTop + visibleHeight) + 1
}

const scrollEvent = async () => {
    const scrollTop: number = list.value.scrollTop
    getIndex(scrollTop)
    data = await getData(start, end)
    //前后各多渲染10条数据进行缓冲,防止滑动过快出现空白情况
    data.list = getFinalData(data.list.filter((item: any) => item.index >= (start - 10) && item.index <= (end + 10)))

    //设置新的偏移量
    startOffset = finalData[start].top
    transform = `translate3d(0,${startOffset}px,0)`
    //如果向上滚动则不需要重新渲染,数据已经加工过并缓存了起来
    if (end > footer.value)
        visibleData.value = data.list
    nextTick(() => {
        //有一部分数据没进行过加工才执行
        if (end > footer.value)
            adjustDataList(data.list)
        visibleData.value = finalData.slice(start - 10 < 0 ? 0 : start - 10, end + 10 > footer.value ? footer.value : end + 10)

    })
}

const throttles = throttle(scrollEvent)


</script>