背景:
对于长列表的渲染,一般是才采用分页或者懒加载的方式,下拉到底部又向后端请求数据,每次只加载一部分数据,但是随着加载的数据越来越多。页面的Dom在无限增加中,给浏览器带来负担,整个滑动也会出现卡顿。
解决方案:虚拟列表
虚拟列表其实是按需显示的一种体现。只对可视区进行渲染,对于非可视区数据不渲染或部分渲染,减轻浏览器负担,提升渲染性能。
对于首次渲染,可根据可视区高度 ÷ 单个列表项高度 = 一屏需要渲染的列表个数。
当滚动发生时,记录滚动距离,根据滚动距离和单个列表项高度,可知道当前可视区域开始索引。同时,为了营造出滚动效果,列表区域,设置transform属性的translate的Y值为 scrollTop - (scrollTop % itemSize) (当滚动到某数据项的中间时,transform的y值不包括该数据项)
总结:虚拟列表的实现,实际上就是在首屏加载的时候,只加载可视区域内需要的列表项,当滚动发生时,动态通过计算获得可视区域内的列表项,并将非可视区域内存在的列表项删除。Dom不变,数据改变。规避了分页和懒加载会让Dom无限增加的缺点。
两种场景的具体实现:
1. 定高场景
(1)首先是确定DOM结构:
第一层作为container,作为容器层。作用:监听滚动,记录滚动位置scrollTop
第二层分为占位层和列表层,两者是并列关系,占位层的主要作用是根据实际整体列表长度进行占位,用于形成滚动条。列表层就是可视化区域,渲染列表区域,用translate3d展示动画滚动效果,其中y值与容器层记录滚动位置有关。
(2)父组件传入所有列表数据,以及每个列表项的高度。
(3)可以计算出整个列表长度,为占位层高度赋值。数据长度 * 单个列表项高度
(4)计算可视区域高度,推算出一屏可显示列表个数。定义start、end两个变量用于控制可视区的开始索引和结束索引。通过start、end索引更新可视区列表数据。
(5)监听container滚动,记录滚动位置scrollTop,同时更新start、end,以及列表区域的偏移量 scrollTop - (scrollTop % 单个列表项高度)
<template>
<div class="container" ref="list" @scroll="handleScroll()">
<div class="phantom" :style="{ height: listHeight + 'px' }"></div>
<div class="list" :style="{ transform: getTransform }">
<div
ref="items"
class="list-item"
v-for="item in visibleData"
:key="item.id"
:style="{ height: itemSize + 'px', lineHeight: itemSize + 'px' }"
>
{{ item.value }}
</div>
</div>
</div>
</template>
<script>
// 需要接收listData以及每个列表项的高度
export default {
name: "VirtualList",
props: {
listData: {
type: Array,
default: () => [],
},
itemSize: {
type: Number,
default: 200,
},
},
data() {
// 使用return是因为一个组件可以被多次实例化,data如果是对象形式,则该组件所有实例的data都指向同一地址,一个实例对data的修改会影响所有实例。
return {
// 可视区域高度
screenHeight: 0,
// 偏移量
startOffset: 0,
// 开始索引
start: 0,
// 结束索引
end: null,
};
},
computed: {
// 列表总高度
listHeight() {
return this.listData.length * this.itemSize;
},
// 可显示的列表数目
visibleCount() {
// Math.ceil向上取整
return Math.ceil(this.screenHeight / this.itemSize);
},
// 获取渲染区数据
visibleData() {
// 兼容数据不足一屏的情况
return this.listData.slice(
this.start,
Math.min(this.end, this.listData.length)
);
},
// 偏移量对应的style
getTransform() {
return `translate3d(0,${this.startOffset}px,0)`;
},
},
mounted() {
this.screenHeight = this.$el.clientHeight;
this.start = 0;
this.end = this.start + this.visibleCount;
},
methods: {
// 监听scroll,获取滚动位置scrollTop
handleScroll() {
let scrollTop = this.$refs.list.scrollTop;
this.start = Math.floor(scrollTop / this.itemSize);
this.end = this.start + this.visibleCount;
this.startOffset = scrollTop - (scrollTop % this.itemSize);
console.log("scrollTop", scrollTop);
console.log("startOffset", this.startOffset);
},
},
};
</script>
<style scoped>
.container {
width: 100vw;
height: 100%;
overflow: auto;
position: relative;
}
.phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
.list {
left: 0;
right: 0;
top: 0;
position: absolute;
text-align: center;
}
.list-item {
padding: 10px;
box-sizing: border-box;
border-bottom: 1px solid black;
}
</style>
2.不定高场景
之前的定高场景,可以根据可视区的高度以及单个列表项的高度,精确算出需要渲染的列表数目。但是实际应用中,很多列表项的高度可能不固定。在虚拟列表中解决不定高情况的方案一般有三种:
(1)扩展组件的itemSize属性,支持的类型可以为数字、数组、函数。但是前提是需要知道每项列表的高度;
(2)将列表项渲染到可视区外,对其高度进行测量缓存,然后再将其渲染到可视区域。但渲染成本提高一倍,不可行;
(3)使用预估高度。在更新页面时,记录每个列表项的真实高度以及位置信息。
由于第一种和第二种方案可行度不高,这里采用第三种方案。
- 定义组件属性estimatedItemSize,用于接收预估高度;
- 定义position,用于列表项渲染后存储每一项的高度以及位置信息;
- 对position进行初始化;有index、height、top、bottom值;
initPositions() {
this.positions = this.listData.map((item, index) => {
return {
index,
height: this.estimatedItemSize,
top: index * this.estimatedItemSize,
bottom: (index + 1) * this.estimatedItemSize
}
})
}
- 计算占位层高度
listHeight() {
return this.position[this.positions.length - 1].bottom;
}
- 渲染完成后,在update获取每项列表的高度以及位置信息,存储到positions里面;
updated() {
let nodes = this.$refs.items;
nodes.forEach(node => {
let rect = node.getBoundingClientRect();
let height = rect.height;
let index = +node.id.slice(1);
let oldHeight = this.positions[index].height;
// 计算预估高度与实际高度的差值
let dValue = oldHeight - height;
if(dValue !== 0) {
// 更新该元素的height和bottom
this.positions[index].height = height;
this.positions[index].bottom = this.positions[index].bottom - dValue;
// 因为height改变,需要更新该元素后面的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;
}
}
})
}
- 滚动后获取开始索引,因为缓存数据是有顺序的,通过二分法获取,找到最逼近scrollTop的列表项。计算是参考每个列表项位置信息中的Bottom;
getStartIndex(scrollTop = 0) {
return this.binarySearch(this.positions, scrollTop);
}
// 二分查找
// 因为距离很少可能性找到一个完全准确的值。所以在middleValue > Value这种情况下用一个tempIndex去记录。end往左移动一位。返回tempIndex的值。
binarySearch(list, value) {
let start = 0;
let end = list.length - 1;
let tempIndex = null;
while(start <= end) {
let middle = start + Math.floor(end - start);
let middleValue = list[middle].bottom;
if (middleValue === value) {
// 因为是以bottom作为参照,返回的是列表开始索引,需要+1
return middle + 1;
} else if (middleValue < value) {
start = middle + 1
} else {
if (iempIndex === null || tempIndex > midIndex) {
tempIndex = middleIndex;
}
end = end - 1;
}
}
return tempIndex;
},
- 滚动后将偏移量的获取方式变更
scrollEvent() {
// ....
if (this.start >= 1) {
this.startOffset = this.positions[this.start - 1].bottom;
} else {
this.startOffset = 0;
}
}
其他方案:
现在的长列表优化已经有较为成熟的解决方案,在react中react-virtualized以及react-window都相对比较优秀。他们的核心方法还是虚拟列表。
react-virtualized: https://www.jianshu.com/p/fc9...
利用所提供的List组件,设置组件的宽高,渲染总数量rowCount, 每个列表卡片的高度rowHeight, 以及每个列表卡片的渲染函数rowRende。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。