虚拟列表渲染大数量级列表

公司的大屏智慧工地项目中,接入了一些设备,页面上需要显示设备的报警信息,报警信息日积月累后的数量过大,渲染到页面上时间过长,造成页面卡顿的现响。由于对接的是别人的接口,没有做分页处理,同时后端同事暂时也没有时间处理,所以解决问题的任务就落到了前端打工仔的肩膀上了。

各项固定高度的虚拟列表

其实在项目本身里的场景还是比较简单的,每一项也没有图片信息,文字过长后也是省略处理,所以每一行的高度是固定的。

实现思路

  1. 根据项数和高度计算出总高度,生成一个滚动元素,此元素用于初始化滚动条,同时也是实际列表元素的背景
  2. 实际列表的显示区域随着scroll事件进行偏移transform: tranlate()
  3. 显示的项数是固定的,显示具体哪些项的内容随着滚动替换

页面结构

<template>
    <div class="container" ref="virtualList">
        <!-- 占位, 用于形成滚动条 -->
        <div class="phantom" :style="{ height: listHeight + 'px' }"></div>
        <!-- 实际展示的内容列表 -->
        <div class="content" :style="{ transform: `translate3d(0, ${currentOffset}px, 0)` }">
            <div v-for="item in visibleData" :key="item.id" :style="{ height: itemSize + 'px', '--height': itemSize }"
                class="list-item">
                {{ item.value }}
            </div>
        </div>
    </div>
</template>

具体的结构示意图如下,在滚动时通过transform: tranlate()偏移显示列表,让显示列表能够一直在父元素视图内:

虚拟列表渲染大数量级列表

逻辑实现

数据初始化

export default {
    name: 'BaseVisualList',
    components: {
    },
    data() {
        return {
            listData: [],
            itemSize: 50, // 每项高度
            screenHeight: 0, // 可视区域高度
            start: 0,        // 起始索引
            end: null,       // 结束索引
            currentOffset: 0, // 当前偏移量
        }
    },
    mounted() {
        for (let i = 1; i <= 1000; i++) {
            this.listData.push({ id: i, value: '字符内容' + i })
        }
        this.screenHeight = this.$el.clientHeight
        this.start = 0
        this.end = this.start + this.visibleCount

        this.$refs.virtualList.addEventListener('scroll', event => this.scrollEvent(event.target))
    },
    computed: {
        // 总高度
        listHeight() {
            return this.listData.length * this.itemSize
        },
        // 可以看到的项数
        visibleCount() {
            return Math.ceil(this.screenHeight / this.itemSize)
        },
        visibleData() {
            return this.listData.slice(this.start, this.end)
        }
    }
}

滚动事件

最后的currentOffset的计算方式要减去(scrollTop % this.itemSize)我的理解为了防止滚动到最后的时候发生抖动,例如我滚一次是滚动100px,此时已经显示到1000中的999项了,再滚一次,那么此时只需要偏移60px,如果还是偏移100px,那么就会往下挤40px,这样就会导致可以一直往下滚:

scrollEvent(target) {
    // 当前滚动位置
    let scrollTop = target.scrollTop;
    // 此时的开始索引
    // 双波浪线对结果进行取整操作,得到最接近且小于等于该结果的整数值
    this.start = ~~(scrollTop / this.itemSize); 
    // 此时的结束索引
    this.end = this.start + this.visibleCount;
    // 此时的偏移量
    this.currentOffset = scrollTop - (scrollTop % this.itemSize);
}

各项不固定高度的虚拟列表

复杂一点的列表可能内容长度不确定,同时还可能存在图片等其他信息,这时候列表项的高度就不是固定的了。

实现思路

  1. 给定预定的高度、显示项数,每一项记录上边缘top下边缘bottom的距离顶部差
  2. 滚动时用下边缘距离比较scrollTop,找到最近的一个下边缘大于scrollTop的即为第一个需要显示的
  3. 每次滚动后缓存每一项的真实高度
  4. 减轻滚动过快页面白屏的现象加入缓冲区,即上下多渲染一部分内容

逻辑实现

根据预计高度初始化内容

created() {
    for (let i = 1; i <= 10000; i++) {
        this.listData.push({ id: i, value: i + '字符内容'.repeat(Math.random() * 20), url: 'https://th.bing.com/th?u=https%3a%2f%2fth.bing.com%2fth%3fid%3dORMS.134b6f51000ec2aa66b3dc62cb309792%26pid%3dWdp&ehk=wqdvjD0ExLTidICzbTGgd844N62aZ1gRDMVk4idmJMw%3d&w=186&h=88&c=8&rs=2&o=6&pid=WP0' })
    }
    this.initPositions(this.listData, this.preItemSize)
},

computed: {
    // 总高度
    listHeight() {
        return this.positions[this.positions.length - 1].bottom;
    }
}

滚动触发更新

  1. 更新起始项的索引,更新可视列表,从而触发Vue生命周期中的updated生命周期,从而更新每一项缓存的top和bottom数据
  2. 更新y轴的偏移量:用下边缘距离比较scrollTop,找到最近的一个下边缘bottom大于scrollTop的即为第一个需要显示的,因为还有缓存区的存在,所以偏移距离需要减去缓存区的高度
 scrollEvent(target) {
const { scrollTop } = target;
this.start = this.getStartIndex(scrollTop);
this.end = this.start + this.visibleCount;
this.currentOffset = this.getCurrentOffset()
}
// 初始化列表
initPositions(listData, itemSize) {
this.positions = listData.map((item, index) => {
return {
index,
top: index * itemSize,
bottom: (index + 1) * itemSize,
height: itemSize,
}
})
}
getStartIndex(scrollTop = 0) {
return binarySearch(this.positions, scrollTop)
let binarySearch = function (list, target) {
const len = list.length
let left = 0, right = len - 1
let tempIndex = null
while (left <= right) {
let midIndex = (left + right) >> 1
let midVal = list[midIndex].bottom
if (midVal === target) {
return midIndex
} else if (midVal < target) {
left = midIndex + 1
} else {
// list不一定存在与target相等的项,不断收缩右区间,寻找最匹配的项
if (tempIndex === null || tempIndex > midIndex) {
tempIndex = midIndex
}
right--
}
}
// 如果没有搜索到完全匹配的项 就返回最匹配的项
return tempIndex
};
}
updated() {
this.$nextTick(() => {
if (!this.$refs.items || !this.$refs.items.length) {
return;
}
// 根据真实元素大小,修改对应的缓存列表
this.updatePositions()
// 更新完缓存列表后,重新赋值偏移量
this.currentOffset = this.getCurrentOffset()
})
}
// 该方法用于更新每一项的top、bottom、heigiht值
updatePositions() {
let nodes = this.$refs.items;
nodes.forEach((node) => {
// 获取 真实DOM高度
const { height } = node.getBoundingClientRect();
// 根据 元素索引 获取 缓存列表对应的列表项
const index = node.id - 1
let oldHeight = this.positions[index].height;
// dValue:真实高度与预估高度的差值 决定该列表项是否要更新
let dValue = oldHeight - height;
// 如果有高度差 !!dValue === true
if (dValue) {
// 更新对应列表项的 bottom 和 height
this.positions[index].bottom = this.positions[index].bottom - dValue;
this.positions[index].height = height;
// 依次更新positions中后续元素的 top bottom
for (let k = index + 1; k < this.positions.length; k++) {
this.positions[k].top = this.positions[k - 1].bottom;
this.positions[k].bottom = this.positions[k].bottom - dValue;
}
}
})
}
getCurrentOffset() {
if (this.start >= 1) {
// 计算偏移量时减去上缓冲区的列表项的高度
let size = this.positions[this.start].top - (this.positions[this.start - this.aboveCount] ?
this.positions[this.start - this.aboveCount].top : 0);
return this.positions[this.start - 1].bottom - size;
} else {
return 0;
}
}

参考文章

本文的思路都是基于大佬的这两篇文章的,更详细的思路可以看以下两篇文章

基于【虚拟列表】高性能渲染海量数据

深入【虚拟列表】动态高度、缓冲、异步加载… Vue实现

原文链接:https://juejin.cn/post/7340204792810815497 作者:acui

(0)
上一篇 2024年2月28日 下午4:00
下一篇 2024年2月28日 下午4:11

相关推荐

发表回复

登录后才能评论