虚拟列表
前言
在移动端的项目开发中,我们应该都有处理过类似新闻列表的数据加载,为了能够快速展现列表,我们应该尽可能的减少 DOM 节点的渲染,因此我往往会和后端接口协调一致,通过前端传递页码进行分页请求数据加载。不过这种处理方式的弊端也显而易见,因为用户每次滚动到底部都先去请求数据接口,然后再进行页面渲染,这里的 http 请求耗时会影响到用户体验,所以为了减少 http 请求,我们来学习一种优化方案:虚拟列表。
什么是虚拟列表
虚拟列表是指将可见区域的列表进行渲染,而对不可见区域的数据不渲染或部分渲染的技术,目的是为了提高渲染性能和用户体验。
实现
虚拟列表的实现,就是只加载可视区域内需要的列表项,当滚动发生时,动态通过计算获得可视区域内的列表项,并将非可视区域内存在的列表项删除。
根据虚拟列表的概念描述,我们可以提炼出几个关键点:
- 可视区域的高度,假设它是固定高度。
- 列表项的高度,假设它是固定高度。
- 总列表。
- 监听滚动偏移量。
- 删除非可视区域的列表项,那么需要知道总列表的起始位置项和结束位置项。
我是使用 vue 官方脚手架初始化的模板,下面请看第一版实现:
<template>
<div class="virtual-index-wrapper">
<div
ref="list"
class="virtual-list-container"
@scroll="onScroll($event)"
>
<div
:style="{ height: listHeight + 'px' }"
class="virtual-list-phantom"
></div>
<ul
:style="{ transform: getTransform }"
class="virtual-list-content"
>
<li
v-for="item in visibleData"
:key="item.id"
:style="{ height: itemSize + 'px', lineHeight: itemSize + 'px' }"
class="virtual-list-item"
>
{{ item.val }}
</li>
</ul>
</div>
</div>
</template>
<script>
let _arr = []
for (let i = 0; i < 1000; i++) {
_arr.push({
id: i,
val: i
})
}
export default {
name: 'VirtualIndex',
data () {
return {
listData: _arr, // 总列表
itemSize: 100, // 每项的高度
screenHeight: 0, // 可视区域高度
startOffset: 0, // 偏移量
start: 0, // 起始索引
end: 0, // 结束索引
}
},
computed: {
// 列表总高度
listHeight () {
return this.listData.length * this.itemSize
},
// 可显示的列表项数
visibleCount () {
return Math.ceil(this.screenHeight / this.itemSize)
},
// 真实列表数
visibleData () {
return this.listData.slice(this.start, Math.min(this.end, this.listData.length))
},
// 偏移量对应的 style,这个是相对 virtual-list-container 作偏移,始终显示在可视区域
getTransform () {
return `translate3d(0, ${this.startOffset}px, 0)`
}
},
mounted () {
this.screenHeight = this.$el.clientHeight
this.end = this.start + this.visibleCount
},
methods: {
onScroll () {
// 当前滚动位置
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)
}
}
}
</script>
<style lang="scss" scoped>
.virtual-index-wrapper {
position: relative;
height: 100vh;
}
.virtual-list {
&-container {
position: relative;
height: 100%;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
&-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
&-content {
position: absolute;
top: 0;
left: 0;
right: 0;
text-align: center;
}
&-item {
padding: 0 20px;
color: #333;
font-size: 32px;
box-sizing: border-box;
border-bottom: 1px solid #ccc;
}
}
</style>
效果图如下:
列表内容动态变化
在我们实际开发中,列表的内容高度可不像上面介绍那样是固定的,所以我们接下来要解决这个问题。
思路:以预估高度先行渲染,然后计算真实高度缓存起来。
- 给列表每一项预设固定高度,接着初始计算它们的位置。
- 渲染后根据它们的真实高度,更新它们缓存的位置信息。
- 滚动高度,计算可视区域显示列表的项数。
下面的代码片段,我已用注释说明了,可以试着从生命周期顺序理解:
安装下依赖 faker,为了帮助我们生成随机内容。
<template>
<div class="virtual-improve-wrapper">
<div
ref="vList"
class="virtual-list-container"
@scroll="onScroll($event)"
>
<div
ref="vPhantom"
class="virtual-list-phantom"
></div>
<ul
ref="vContent"
class="virtual-list-content"
>
<li
v-for="(item, index) in visibleData"
:id="item.id"
ref="vItem"
:key="index"
class="virtual-list-item"
>
{{ item.id }}、{{ item.val }}
</li>
</ul>
</div>
</div>
</template>
<script>
import faker from 'faker';
let _arr = []
for (let i = 0; i < 1000; i++) {
_arr.push({
id: `_${i}`,
val: faker.lorem.sentences()
})
}
export default {
name: 'VirtualIndexImprove',
data () {
return {
listData: _arr, // 总列表
itemSize: 100, // 预设每项的高度
screenHeight: 0, // 可视区域高度
positions: [], // 缓存列表位置
start: 0, // 起始索引
end: 0, // 结束索引
}
},
computed: {
// 可显示的列表项数
visibleCount () {
return Math.ceil(this.screenHeight / this.itemSize)
},
// 真实列表数
visibleData () {
return this.listData.slice(this.start, this.end)
}
},
created () {
this.initPositions()
},
mounted () {
this.screenHeight = this.$el.clientHeight // 获取可视区域高度
this.end = this.start + this.visibleCount // 计算初始结束项
},
updated () {
this.$nextTick(() => {
if (this.$refs.vItem && this.$refs.vItem.length) {
// 1、更新列表每项高度和位置
this.updateItemPosition()
// 2、更新列表的总高度
const height = this.positions[this.positions.length - 1].bottom
this.$refs.vPhantom.style.height = `${height}px`
// 3、设置偏移量
this.setStartOffset()
}
})
},
methods: {
// 初始化每项位置
initPositions () {
this.positions = this.listData.map((item, index) => ({
index,
height: this.itemSize,
top: index * this.itemSize,
bottom: (index + 1) * this.itemSize
}))
},
// 更新列表每项高度和位置
updateItemPosition () {
let nodes = this.$refs.vItem // 获取列表 DOM
nodes.forEach(elem => {
let rect = elem.getBoundingClientRect() // 返回元素的大小及其相对于视口的位置,如果是标准盒子模型,元素的尺寸等于 width/height + padding + border-width 的总和。如果 box-sizing: border-box,元素的的尺寸等于 width/height
let height = rect.height // 获取当前高度
let index = +elem.id.slice(1) // 创建当前的下标,下面更新时需要使用
let oldHeight = this.positions[index].height // 获取旧的高度
let diffVal = oldHeight - height // 对比旧的高度有无变化,有的话就更新它的位置信息,否则反之
// 判断是否更新位置信息
if (diffVal) {
this.positions[index].height = height // 更新成当前的高度
this.positions[index].bottom = this.positions[index].bottom - diffVal // 更新成当前的 bottom
// 以当前 DOM 开始,更新后面的列表位置信息
for (let k = index + 1; k < this.positions.length; k++) {
this.positions[k].top = this.positions[k - 1].bottom // 上一项 DOM 的 bottom 作为下一项的 top
this.positions[k].bottom = this.positions[k].bottom - diffVal // 更新方式同上面
}
}
})
},
// 获取列表起始索引
getStartIndex (scrollTop = 0) {
// 二分法查找 https://huitoutunao.com/guide/algorithm/%E4%BB%8B%E7%BB%8D.html#%E4%BA%8C%E5%88%86%E6%B3%95%E6%9F%A5%E6%89%BE
return this.binarySearch(this.positions, scrollTop)
},
binarySearch (list, scrollVal) {
let start = 0
let end = list.length -1
let resIndex = null
while (start <= end) {
let midIndex = parseInt((start + end) / 2) // 中间位置项
let midBottom = list[midIndex].bottom // 中间位置项的 bottom
if (midBottom === scrollVal) { // 说明滚动位置刚好和中间项的 bottom 重合
return midIndex + 1 // 返回下一项位置下标
} else if (midBottom < scrollVal) { // 说明中间项在滚动位置之间不合理
start = midIndex + 1 // 继续循环寻找符合条件的项
} else if (midBottom > scrollVal) {
if (!resIndex || resIndex > midIndex) {
resIndex = midIndex
}
end = end - 1 // 减 1 再除以 2 比上一次取的值小,所以上面判断 resIndex > midIndex 不断缩小搜索范围
}
}
return resIndex
},
// 设置列表偏移量
setStartOffset () {
const startOffset = this.start > 0
? this.positions[this.start - 1].bottom
: 0
this.$refs.vContent.style.transform = `translate3d(0, ${startOffset}px, 0)`
},
onScroll () {
// 当前滚动位置
const scrollTop = this.$refs.vList.scrollTop
// 此时的开始索引
this.start = this.getStartIndex(scrollTop)
// 此时的结束索引
this.end = this.start + this.visibleCount
this.setStartOffset()
}
}
}
</script>
<style lang="scss" scoped>
.virtual-improve-wrapper {
position: relative;
height: 100vh;
}
.virtual-list {
&-container {
position: relative;
height: 100%;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
&-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
&-content {
position: absolute;
top: 0;
left: 0;
right: 0;
}
&-item {
padding: 20px;
color: #333;
font-size: 32px;
box-sizing: border-box;
border-bottom: 1px solid #ccc;
}
}
</style>
效果图如下:
结语
在处理长列表渲染性能体验上,虚拟列表是一种比较好的方案,但是在快速滑动列表时,会出现白屏的情况,这个可以通过增量缓存真实列表来降低白屏的几率。
本文到这里就结束了,希望这篇文章对你有所帮助。