虚拟列表主要解决大数据量数据一次渲染性能差的问题。
之前写过一篇关于虚拟列表实现的文章:造轮子之不同场景下虚拟列表实现,主要讲了定高(高度统一和高度不统一两种情况)虚拟列表的实现,本文着重研究不定高虚拟列表的实现。在vue环境单页面项目下研究实现。
前文讲过虚拟列表的要做的事是确保性能的前提下,利用一定的技术模拟全数据一次性渲染后效果。
定高虚拟列表原理
绿色部分为containter,也就是父容器,它会有固定的高度。黄色部分为content,它是父容器的子元素。
当content的高度超过父容器的高度,就可以滚动内容区了,这就是一般滚动原理。
虚拟列表需要使用这个滚动原理。虚拟列表使用占位div
,设置占位div
的高度为所有列表数据的高度进而撑开containter,形成滚动条。
然后虚拟列表具体渲染过程中,只是渲染可视区也就是父容器区域
至于可视区域的内容滚动通过监听滚动条scroll
事件,获取到滚动距离scrllTop
,转换为可视区域的偏移位置,同时获取渲染数据的起始和结束索引,渲染指定段数据形成假象的滚动。
不定高内容数渲染
上一篇文章造轮子之不同场景下虚拟列表实现已经给出了定高虚拟列表的实现。不定高相对定高的难点在于数据没有渲染之前根本不知道数据的实际高度,解决方案理论上有
- 在屏幕外渲染,但消耗性能
- 以
预估高度
先行渲染,然后获取真实高度并缓存
采用第一种方案显然是不完美的,所以采用第二个方案,这也是之前有人实现过的。
不定高假数据
为了更接近业务,这里使用vue-codemirror方式渲染数据,为vue-codemirror造假数据
js
function generateRandomNumber () {
const min = 100
const max = 1000
// 生成随机整数
const randomNumber = Math.floor(Math.random() * (max - min + 1)) + min
return randomNumber
}
function getRandomLetter () {
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
const randomIndex = Math.floor(Math.random() * letters.length)
const randomLetter = letters.charAt(randomIndex)
return randomLetter
}
function generateString (length) {
const minLength = 100
const maxLength = 1000
// 确保长度在最小和最大范围内
if (length < minLength) {
length = minLength
} else if (length > maxLength) {
length = maxLength
}
// 生成字符串
const string = getRandomLetter().repeat(length)
return string
}
const d = []
for (let i = 0; i < 500; i++) {
const length = generateRandomNumber()
d.push({
data: generateString(length),
index: i
})
}
这里造了500条,具体是随机生成的字符串,字符串长度100-1000,字符从A-Z中选取。
温故定高虚拟列表
因为不定高虚拟列表有和定高虚拟列表相似之处,再来回顾一下之前定高(统一高度和不统一高度)的解决方案。这里只展示一下统一高度的,不统一高度的可以查看造轮子之不同场景下虚拟列表实现。统一高度组件代码
js
<template>
<div ref="list" class="render-list-container" @scroll="scrollEvent($event)">
<!-- 占位div -->
<div class="render-list-phantom" :style="{ height: listHeight + 'px' }"></div>
<div class="render-list" :style="{ transform: getTransform }">
<template
v-for="item in visibleData"
>
<slot :value="item.value" :height="itemSize + 'px'" :index="item.id"></slot>
</template>
</div>
</div>
</template>
<script>
export default {
name: 'VirtualList',
props: {
// 所有列表数据
listData: {
type: Array,
default: () => []
},
// 每项高度
itemSize: {
type: Number,
default: 100
}
},
computed: {
// 列表总高度
listHeight () {
return this.listData.length * this.itemSize
},
// 可显示的列表项数
visibleCount () {
return Math.ceil(this.screenHeight / this.itemSize)
},
// 偏移量对应的style
getTransform () {
return `translate3d(0,${this.startOffset}px,0)`
},
// 获取真实显示列表数据
visibleData () {
return this.listData.slice(this.start, Math.min(this.end, this.listData.length))
}
},
mounted () {
this.screenHeight = this.$el.clientHeight
this.end = this.start + this.visibleCount
},
data () {
return {
// 可视区域高度
screenHeight: 0,
// 偏移量
startOffset: 0,
// 起始索引
start: 0,
// 结束索引
end: null
}
},
methods: {
scrollEvent () {
// 当前滚动位置
const 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 scoped>
.render-list-container {
overflow: auto;
position: relative;
-webkit-overflow-scrolling: touch;
height: 200px;
}
.render-list-phantom {
position: absolute;
left: 0;
right: 0;
z-index: -1;
}
.render-list {
text-align: center;
}
</style>
研究不定高虚拟列表组件
按照统一高度方式渲染
正如上面所说为了解决不定高内容高度不定的问题,采用
以
预估高度
先行渲染,然后获取真实高度并缓存方案
所以给每条假数据一条预估高度,然后使用定高虚拟列表渲染数据,渲染数据代码
js
<template>
<div class="render-show">
<div>
<NoHasVirtualList :listData="data">
<template slot-scope="{ item, height }">
<codemirror
class="unit"
:style="{height: height}"
v-model="item.data"
:options="cmOptions"
></codemirror>
</template>
</NoHasVirtualList>
</div>
</div>
</template>
设置codemirror
组件高度固定。查看一下效果
问题很明显,由于codemirror
组件设置固定高度,导致渲染内容挤到一起了。所以预估高度不是这样用的,预估高度的意义:它是一种高度占位
,是一种占位是务必要修正的。
修正高度
为了修正这个高度,需要等待数据渲染后拿到真实高度,这个需求可以在vue生命周期函数updated实现,也可以通过IntersectionObserver实现。本文采用updated实现。
修正高度不仅修正每一条数据的高度,因为用来撑起可视区域的占位div
高度也是根据预估高度计算的,所以占位div
高度也需要更新,然后还需要更新偏移量。
具体在updated
里获取真实元素
大小,修改对应的尺寸缓存
;更新占位div高度
(使用计算属性实现);更新真实偏移量
。
js
updated () {
this.$nextTick(() => {
// 获取真实元素大小,修改对应的尺寸缓存
this.updateItemsSize()
// 更新真实偏移量
this.setStartOffset()
})
},
获取数据实际高度,修改对应尺寸缓存
创建计算属性_listData
拷贝列表数据。目的尽量不修改传进来的listData
列表数据,同时给渲染列表数据添加索引,实际是给渲染用的visibleCount
添加唯一索引
js
computed: {
_listData () {
return this.listData.reduce((init, cur, index) => {
init.push({
// _转换后的索引
_key: index,
value: cur
})
return init
}, [])
},
...
}
缓存每条数据的高度、以及数据坐标:用top
和bottom
标记
js
// 初始化缓存
initPositions () {
this.positions = this._listData.map((d, index) => ({
index,
height: this.itemSize,
top: index * this.itemSize,
bottom: (index + 1) * this.itemSize
}))
},
上面计算属性_listData
以及缓存每条数据均是服务于这一步:获取渲染数据实际高度,修改对应数据缓存尺寸
js
// 获取实际高度,修正内容高度
updateItemsSize () {
const nodes = this.$refs.items
nodes.forEach((node) => {
// 获取元素自身的属性
const rect = node.getBoundingClientRect()
const height = rect.height
const index = +node.id // id就是_listData上的唯一索引
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 // TODO
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
}
}
})
},
更新列表总高度
获取数据实际高度,修改对应尺寸缓存目的之一是为了更新列表总高度
js
computed: {
...
// 列表总高度
listHeight () {
return this.positions[this.positions.length - 1].bottom
},
...
},
上述代码中this.listHeight
是一个计算属性,是占位div
的高度。
js
<template>
<div
ref="list"
class="infinite-list-container"
@scroll="scrollEvent($event)"
>
<!-- 占位div -->
<div ref="phantom" class="infinite-list-phantom" :style="{ height: listHeight + 'px' }"></div>
...
</div>
</template>
更新真实偏移量
获取数据实际高度,修改对应尺寸缓存目的之二是为了更新真实偏移量。
借助this.positions
数组数据,通过设置this.startOffset
,在传导到计算属性this.contentTransform
更新偏移量
js
<template>
<div ref="list" class="infinite-list-container" @scroll="scrollEvent($event)">
<!-- 占位div -->
<div
class="infinite-list-phantom"
:style="{ height: listHeight + 'px' }"
></div>
<div
ref="content"
:style="{ transform: contentTransform }"
class="infinite-list"
>
....
</div>
</div>
</template>
...
computed: {
...
// 偏移量对应的style
contentTransform () {
return `translateY(${this.startOffset}px)`
},
...
},
...
// 更新偏移量
setStartOffset () {
if (this.start >= 1) {
const size =
this.positions[this.start].top -
(this.positions[this.start - this.aboveCount]
? this.positions[this.start - this.aboveCount].top
: 0)
this.startOffset = this.positions[this.start - 1].bottom - size
} else {
this.startOffset = 0
}
}
滚动事件
滚动事件用以触发更新
js
// 滚动事件
scrollEvent () {
// 当前滚动位置
const scrollTop = this.$refs.list.scrollTop
// 更新滚动状态
// 排除不需要计算的情况
if (
scrollTop > this.anchorPoint.bottom ||
scrollTop < this.anchorPoint.top
) {
// 此时的开始索引
this.start = this.getStartIndex(scrollTop)
// 此时的结束索引
this.end = this.start + this.visibleCount
// 更新偏移量
this.setStartOffset()
}
}
其中this.anchorPoint
是计算属性
js
computed: {
...
anchorPoint () {
return this.positions.length ? this.positions[this.start] : null
}
...
},
上述代码中之所以排除不需要计算的情况,需要解释一下。
真实的滚动就是滚动条滚动了多少,可视区就向上移动多少。但虚拟滚动不是。当起始索引发生变化时,渲染数据发生变化了,但渲染数据的高度不是连续的,所以需要动态的设置偏移量。当滚动时起始索引不发生变化时,因为数据变化是连续的,此时可以什么也不做,滚动显示的内容由浏览器控制。排除的部分就是索引没发生变化的情况
。
根据滚动高度获取起始索引方法this.getStartIndex
js
methods: {
...
// 获取列表起始索引
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
},
...
}
效果查看以及优化
给滚动增加缓冲
,缓冲就是多渲染几条,上方和下方渲染额外的数据,比如前后多渲染2条。增加计算属性aboveCount
和belowCount
,同时修改visibleData
js
computed: {
...
aboveCount () {
return Math.min(this.start, 2)
},
belowCount () {
return Math.min(this.listData.length - this.end, 2)
},
visibleData () {
const start = this.start - this.aboveCount
const end = this.end + this.belowCount
return this._listData.slice(start, end)
}
},
存在问题
即便是给滚动增加缓冲
,过快滑动时依然会出现白屏现象,究其本质是滚动过快而真实dom更新赶不上它
总结
本文主要研究了不定高虚拟列表的一种实现。基本原理依然是原生滚动触发,渲染首先是预估高度,之后数据渲染后更新预估高度、更新占位div高度、更新偏移量。
另外就是对于滚动事件做限制,如果滚动高度恰好位于当前元素范围内不做处理。
另外对于数据更新除了可以使用vue的生命周期函数updated还可以使用IntersectionObserver实现。
后期计划:为了解决过快滑动导致的白屏现象,会将不定高虚拟列表与虚拟滚动结合。虚拟滚动前几天写过一篇实现方案:虚拟滚动实现
本项目代码地址:github.com/zhensg123/r...
本文完。