公司的大屏智慧工地项目中,接入了一些设备,页面上需要显示设备的报警信息,报警信息日积月累后的数量过大,渲染到页面上时间过长,造成页面卡顿的现响。由于对接的是别人的接口,没有做分页处理,同时后端同事暂时也没有时间处理,所以解决问题的任务就落到了前端打工仔的肩膀上了。
各项固定高度的虚拟列表
其实在项目本身里的场景还是比较简单的,每一项也没有图片信息,文字过长后也是省略处理,所以每一行的高度是固定的。
实现思路
- 根据项数和高度计算出总高度,生成一个滚动元素,此元素用于初始化滚动条,同时也是实际列表元素的背景
- 实际列表的显示区域随着scroll事件进行偏移
transform: tranlate()
- 显示的项数是固定的,显示具体哪些项的内容随着滚动替换
页面结构
<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);
}
各项不固定高度的虚拟列表
复杂一点的列表可能内容长度不确定,同时还可能存在图片等其他信息,这时候列表项的高度就不是固定的了。
实现思路
- 给定预定的高度、显示项数,每一项记录上边缘top和下边缘bottom的距离顶部差
- 滚动时用下边缘距离比较scrollTop,找到最近的一个下边缘大于scrollTop的即为第一个需要显示的
- 每次滚动后缓存每一项的真实高度
- 减轻滚动过快页面白屏的现象加入缓冲区,即上下多渲染一部分内容
逻辑实现
根据预计高度初始化内容
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;
}
}
滚动触发更新
- 更新起始项的索引,更新可视列表,从而触发Vue生命周期中的updated生命周期,从而更新每一项缓存的top和bottom数据
- 更新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;
}
}
参考文章
本文的思路都是基于大佬的这两篇文章的,更详细的思路可以看以下两篇文章
原文链接:https://juejin.cn/post/7340204792810815497 作者:acui