前言
在前端的业务中,存在以下几种情况需要用到虚拟列表,旨在解决数据量庞大时浏览器渲染性能瓶颈。比如:
- 1、导出报表数据不分页,数据量庞大
- 2、移动端使用下拉加载分页列表,一直加载下一页时,数据量越来越大,无数的真实dom生成,对浏览器的渲染造成过大压力
- 3、前端业务中许多树形数据处理,一般全部给到前端,如果一次性渲染出来,便会造成浏览器卡顿,如果把树数据结构看做一维的list来处理,结合虚拟列表就能解决性能瓶颈
以上便是我在工作中常见的场景,还有很多情况,就不一一列举了。
原理
虚拟列表也是可视化加载的一种,即只渲染可视容器中的内容。
假设有1万条记录需要同时渲染,我们屏幕的
可见区域
的高度为
500px
,而列表项的高度为
50px
,则此时我们在屏幕中最多只能看到10个列表项,那么在首次渲染的时候,我们只需加载10条即可。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-psMg5uP4-1658805024111)(https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1a51304d6b6c42e785c5e3ae3f21d99b~tplv-k3u1fbpfcp-watermark.image?)]
实现
虚拟列表的实现,实际上就是在首屏加载的时候,只加载
可视区域
内需要的列表项,当滚动发生时,动态通过计算获得
可视区域
内的列表项,并将
非可视区域
内存在的列表项删除。
- 计算当前
可视区域
起始数据索引(startIndex
) - 计算当前
可视区域
结束数据索引(endIndex
) - 计算当前
可视区域的
数据,并渲染到页面中 - 计算
startIndex
对应的数据在整个列表中的偏移位置startOffset
并设置到列表上
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nUhn7xMB-1658805024112)(https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5fca76f0393d468bbd530dbbecdf6b9f~tplv-k3u1fbpfcp-watermark.image?)]
<divclass="virtual-list-container"><divclass="virtual-list-place"></div><divclass="virtual-list"><!-- item-1 --><!-- item-2 --><!-- ...... --><!-- item-n --></div></div>
virtual-list-container
为可视区域
的容器virtual-list-place
为容器内的占位,高度为总列表高度,用于形成滚动条virtual-list
为列表项的渲染区域
接着,监听
virtual-list-container
的
scroll
事件,获取滚动位置
scrollTop
- 假定
可视区域
高度固定,称之为screenHeight
- 假定
列表每项
高度固定,称之为itemSize
- 假定
列表数据
称之为listData
- 假定
当前滚动位置
称之为scrollTop
则可推算出:
- 列表总高度
listHeight
= listData.length * itemSize - 可显示的列表项数
visibleCount
= Math.ceil(screenHeight / itemSize) - 数据的起始索引
startIndex
= Math.floor(scrollTop / itemSize) - 数据的结束索引
endIndex
= startIndex + visibleCount - 列表显示数据为
visibleData
= listData.slice(startIndex,endIndex)
当滚动后,由于
渲染区域
相对于
可视区域
已经发生了偏移,此时我需要获取一个偏移量
startOffset
,通过样式控制将
渲染区域
偏移至
可视区域
中。
- 偏移量
startOffset
= scrollTop - (scrollTop % itemSize);
列表项高度不固定
在之前的实现中,列表项的高度是固定的,因为高度固定,所以可以很轻易的获取列表项的整体高度以及滚动时的显示数据与对应的偏移量。而实际应用的时候,当列表中包含文本之类的可变内容,会导致列表项的高度并不相同。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rZH5QFIz-1658805024112)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e96e61b783d046e8a5d758088e275be1~tplv-k3u1fbpfcp-watermark.image?)]
在虚拟列表中应用动态高度的解决方案一般有如下三种:
1.对组件属性
itemSize
进行扩展,支持传递类型为
数字
、
数组
、
函数
- 可以是一个固定值,如 100,此时列表项是固高的
- 可以是一个包含所有列表项高度的数据,如 [50, 20, 100, 80, …]
- 可以是一个根据列表项索引返回其高度的函数:(index: number): number
这种方式虽然有比较好的灵活度,但仅适用于可以预先知道或可以通过计算得知列表项高度的情况,依然无法解决列表项高度由内容撑开的情况。
2.将列表项
渲染到屏幕外
,对其高度进行测量并缓存,然后再将其渲染至可视区域内。
由于预先渲染至屏幕外,再渲染至屏幕内,这导致渲染成本增加一倍,这对于数百万用户在低端移动设备上使用的产品来说是不切实际的。
3.以
预估高度
先行渲染,然后获取真实高度并缓存。
这是我选择的实现方式,可以避免前两种方案的不足。
接下来,来看如何简易的实现:
定义组件属性
itemHeight
,用于接收
预估高度
props:{//预估高度 itemHeight:{type:Number
}}
定义
positions
,用于列表项渲染后存储
每一项的高度以及位置
信息,
this.positions = [
// {
// top:0,
// bottom:100,
// height:100
// }
];
由于需要在
渲染完成
后,获取列表每项的位置信息并缓存,所以使用钩子函数
updated
来实现:
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){this.positions[index].bottom =this.positions[index].bottom - dValue;this.positions[index].height = height;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;}}})}
vue版本的代码实现
组件VirtualList:
<template>
<div ref="listContainerRef" class="virtual-list-container" :style="{height}" @scroll="onScroll">
<div ref="listPlaceRef" class="virtual-list-place"></div>
<div ref="listRef" class="virtual-list">
<div
ref="itemsRef"
class="virtual-list-item"
v-for="(item, itemIndex) in visibleList"
:key="item._key"
:id="item._key"
>
<slot name="default" :item="item" :index="itemIndex"></slot>
</div>
</div>
<slot name="footer"></slot>
</div>
</template>
<script>
export default {
props: {
// 首尾缓存比例 = (首或者尾高度 + 滚动视窗高度) / 滚动视窗高度
// 自动高度开启时需要调大缓存区比例(视窗内能容纳的越多该值应该越大)
bufferScale: {
type: Number,
default: 0.4,
requred: false
},
// 数据
dataSource: {
type: Array,
requred: true
},
// 滚动视窗高度
height: {
type: String,
default: '100%',
requred: false
},
// (非必填)列表每一项高度(不填时需开启itemAutoHeight)
itemHeight: {
type: Number,
default: 60,
requred: false
},
// 是否自动计算每一项高度
itemAutoHeight: {
type: Boolean,
default: false
},
// 到达底部回调
onReachBottom: Function
},
data () {
return {
screenHeight: 0,
startIndex: 0,
endIndex: 0,
positions: []
}
},
computed: {
_listData () {
return this.dataSource.map((item, index) => ({
...item,
_key: `${index}`
}))
},
anchorPoint () {
return this.positions.length ? this.positions[this.startIndex] : null
},
visibleCount () {
return Math.ceil(this.screenHeight / this.itemHeight)
},
aboveCount () {
return Math.min(this.startIndex, Math.ceil(this.bufferScale * this.visibleCount))
},
belowCount () {
return Math.min(this._listData.length - this.endIndex, Math.ceil(this.bufferScale * this.visibleCount))
},
visibleList () {
const startIndex = this.startIndex - this.aboveCount
const endIndex = this.endIndex + this.belowCount
return this._listData.slice(startIndex, endIndex)
}
},
mounted () {
this.init()
},
updated () {
// 列表数据长度不等于缓存长度
if (this._listData.length !== this.positions.length) {
this.initPositions()
}
this.$nextTick(function () {
if (!this.$refs.itemsRef || !this.$refs.itemsRef.length) {
return
}
// 获取真实元素大小,修改对应的尺寸缓存
if (this.itemAutoHeight) {
this.updateItemsSize()
}
// 更新列表总高度
const height = this.positions[this.positions.length - 1].bottom
this.$refs.listPlaceRef.style.height = height + 'px'
// 更新真实偏移量
this.setStartOffset()
})
},
methods: {
init () {
this.initPositions()
this.screenHeight = this.$refs.listContainerRef.clientHeight
this.startIndex = 0
this.endIndex = this.visibleCount
this.setStartOffset()
},
initPositions () {
this.positions = this._listData.map((_, index) => ({
index,
height: this.itemHeight,
top: index * this.itemHeight,
bottom: (index + 1) * this.itemHeight
})
)
},
updateItemsSize () {
const nodes = this.$refs.itemsRef
nodes.forEach((node) => {
const rect = node.getBoundingClientRect()
const height = rect.height
const index = +node.id.split('_')[0]
const oldHeight = this.positions[index].height
const dValue = oldHeight - height
if (dValue) {
this.positions[index].bottom = this.positions[index].bottom - dValue
this.positions[index].height = height
this.positions[index].over = true
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
}
}
})
},
setStartOffset () {
let startOffset
if (this.startIndex >= 1) {
const size = this.positions[this.startIndex].top - (this.positions[this.startIndex - this.aboveCount] ? this.positions[this.startIndex - this.aboveCount].top : 0)
startOffset = this.positions[this.startIndex - 1].bottom - size
} else {
startOffset = 0
}
this.startOffset = startOffset
this.$refs.listRef.style.transform = `translate3d(0,${startOffset}px,0)`
},
getStartIndex (scrollTop = 0) {
return this.binarySearch(this.positions, scrollTop)
},
binarySearch (list, value) {
let start = 0
let end = list.length - 1
let tempIndex = null
while (start <= end) {
const midIndex = parseInt((start + end) / 2)
const midValue = list[midIndex].bottom
if (midValue === value) {
return midIndex + 1
} else if (midValue < value) {
start = midIndex + 1
} else if (midValue > value) {
if (tempIndex === null || tempIndex > midIndex) {
tempIndex = midIndex
}
end = end - 1
}
}
return tempIndex
},
onScroll () {
const scrollTop = this.$refs.listContainerRef.scrollTop
const scrollHeight = this.$refs.listContainerRef.scrollHeight
if (scrollTop > this.anchorPoint.bottom || scrollTop < this.anchorPoint.top) {
this.startIndex = this.getStartIndex(scrollTop)
this.endIndex = this.startIndex + this.visibleCount
this.setStartOffset()
}
if (scrollTop + this.screenHeight > scrollHeight - 50) {
this.onReachBottom && this.onReachBottom()
}
}
}
}
</script>
<style>
.virtual-list-container {
overflow: auto;
position: relative;
}
.virtual-list-place {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
.virtual-list {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: 1;
}
</style>
固定列表项高度使用组件
<template>
<VirtualList :dataSource="list" :itemHeight="22">
<template v-slot="{item}">
<div>{{item.value}}</div>
</template>
</VirtualList>
</template>
<script>
import VirtualList from '@/components/VirtualList'
export default {
components: { VirtualList },
data () {
return {
list: new Array(100).fill(0).map((_, index) => ({ value: index + 1 }))
}
}
}
</script>
动态列表项高度使用组件
开启
itemAutoHeight
,并且设置
bufferScale
比例来增加上下缓冲区大小保证加载的内容能撑满可视区域
<template>
<VirtualList :dataSource="list" :itemHeight="22" :itemAutoHeight="true" :bufferScale="1.2">
<template v-slot="{item}">
<div>{{item.value}}</div>
</template>
</VirtualList>
</template>
<script>
import VirtualList from '@/components/VirtualList'
export default {
components: { VirtualList },
data () {
return {
list: new Array(100).fill(0).map((_, index) => ({ value: index + 1 }))
}
}
}
</script>
与分页结合时使用
在onReachBottom中做接口请求,这样的话既能通过分页优化,又能在加载的数据量大的时候采用虚拟列表方案
<template>
<VirtualList
:dataSource="list"
:itemHeight="22"
:itemAutoHeight="true"
:bufferScale="1.2"
:onReachBottom="fetchList"
>
<template v-slot="{item}">
<div>{{item.value}}</div>
</template>
</VirtualList>
</template>
<script>
import VirtualList from '@/components/VirtualList'
let page = 1
export default {
components: { VirtualList },
data () {
return {
list: new Array(100).fill(0).map((_, index) => ({ value: `${page}_${index + 1}` }))
}
},
methods: {
fetchList () {
page += 1
const list = new Array(100).fill(0).map((_, index) => ({ value: `${page}_${index + 1}` }))
this.list = [...this.list, ...list]
}
}
}
</script>
稳定npm推荐
- react相关npm包
// 虚拟列表
npm i -S react-window
// 虚拟表格
npm i -S virtuallist-antd
- react相关npm包
// 虚拟列表
npm i -S vue-virtual-scroll-list
// 虚拟表格
npm i -S vxe-table
版权归原作者 qq_42036203 所有, 如有侵权,请联系我们删除。