超详细的虚拟列表实现过程
# 前言
在日常开发过程种经常会遇到渲染长列表的情况,一旦数据量太大页面渲染的内容过多就会导致性能问题,尤其是在移动端。这个时候提到性能优化很多小伙伴会想到做分页,但其实分页只是治标不治本。一旦用户加载的页数过多了那数据依然会渲染的越来越多导致卡顿。这个时候就需要引入本文的主角
虚拟列表
了!
# 什么是虚拟列表
简介
所谓虚拟列表,顾名思义就是假的列表。这里的假值得是数据并不是完全渲染的,而是只渲染用户能看见的部分。这样一来就会大大减小浏览器的渲染压力,然后通过虚拟滚动来定位渲染内容。从用户的感知上就和滚动一个长列表无差,实则只有可视区域渲染了东西。
# 实现
虚拟列表也分为定高
和不定高
两种,具体是什么意思呢下面我们通过实现一个定高
的虚拟列表来加深一下对他的认知。
# 定高虚拟列表
首先来看一张图(网上找的,我画的太丑了)
通过这张图,我们可以得知一个虚拟列表大概分为三个区域
虚拟区
: 不渲染任何内容的区域缓冲区
: 为防止滑动过快造成空白,所以我们通常会渲染超出可视区域一点的内容让滑动有一个更好的衔接视图区
: 用户能看到的区域,也是我们要渲染内容的区域。
在整个过程中我们要做的其实就一件事,根据当前滚动条的高度去计算出对应的需要渲染的数据的索引,然后将他渲染到屏幕上。
首先因为我们不是渲染的真实的列表,所以我们的div
肯定是滚动不起来的。那如何做到让他滚动起来呢?答案非常简单就是给他一个超出屏幕的高度
。
当然这个高度也不是随便乱给的哦,我们需要根据实际内容来计算
出整个列表的真实高度
(例如列表里总共有50
条数据,那我们的列表总高度就是50*item.height
)。
ok,到这里引出了一个新的变量了:item.height(列表每一项的高度)
。注意这个非常重要!最后我们需要根据滚动条的高度来计算出索引区间
,也就是我们要真实渲染的数据。
下面总结一下实现虚拟列表我们需要用到哪些计算量:
列表的总高度
:列表每一项的高度之和当前渲染数据的起始索引
:当前滚动条高度/列表项高度后取整当前渲染数据的结束索引
:从起始索引开始逐个递增高度直到超出可视区域高度为止就是结束索引
可以看到列表项的高度其实贯穿了整个虚拟列表始终,每一个地方都需要用到它去参与计算。所以它非常重要。
下面我们来实现一下,定高虚拟列表。
/**
* 计算可视区域的列表 固定高度
*/
const handleComputedViewList = (scrollTop : number) => {
// 当前滚动条的高度/列表项的高度向下取整
const startIndex = scrollTop === 0 ? 0 : Math.floor(scrollTop / itemHeight.value)
const height = ChatBox.value.offsetHeight //可视区域的高度
const pushNum = Math.ceil(height / itemHeight.value) // 可视区域高度/列表项高度计算出可视区域一共可以渲染多少项
// 用起始索引+上最大渲染数得出结束索引
const endIndex = (startIndex + pushNum) > props.list.length - 1 ? props.list.length - 1 : startIndex + pushNum
const list = []
// 遍历起始索引到结束索引这个区间,添加真实渲染的数据
for (let i = startIndex; i <= endIndex; i++) {
list.push({
...props.list[i] as Object,
top: i * itemHeight.value, // 通过索引*列表项的高度可以得到当前列表项所处于长列表的位置
idx: i,
})
}
viewList.value = list
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
可以看到方法还是比较简单的,这里要提一点,由于我们是模拟了一个空的长列表,所以我们的列表项都是通过定位放置
到空列表中的,位置计算就是通过索引*列表项高度
得到的。
# 不定高虚拟列表
经过上面的例子可以看出,列表项的高度承载了整个虚拟列表的计算过程,当我们在得知列表项的高度的时候,实现一个虚拟列表还是比较简单的。但是大多数场景下列表项的高度都是由内容区域撑开的,并不是固定的。这个时候我们该如何实现一个虚拟列表呢?
既然不知道列表项的高度,那我们就自己预估
一个高度,然后根据这个高度先渲染出一个预估的虚拟列表,当元素渲染完毕后我们就可以拿到元素的真实高度了。这个时候我们再去替换掉之前的预估高度并且重新计算列表项的位置,就可以实现了。
首先我们创建一个虚拟的list
填充数据
const itemHeight = ref(50) //预测每一项的高度
const viewList = ref([]) //视图中可见的列表
const containerHeight = ref(0) // 内容区域的总高度,模拟滚动行为
let measuredData = reactive([])
const initData = () => {
measuredData = props.list.map((item, index) => ({
id: index,
height: itemHeight.value,
top: index * itemHeight.value,
bottom: (index + 1) * itemHeight.value,
dHeight: 0,// 真实高度和预测高度的差值
}))
containerHeight.value = measuredData[measuredData.length - 1].bottom
}
initData()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
如上述代码,我们事先拟定列表项高度为50
,并且生成了一个已经装载好位置信息的数组。这里头引入了两个新变量:bottom
和dHeight
。
bottom
是列表项位于列表中bottom
的位置,计算也很简单就是列表项的top值+列表项
的高度。他的作用主要是方便我们计算后续真实列表项的高度。因为整个长列表的高度其实就等于最后一个列表项的bottom
值。
而dHeight
的作用是真实高度和我们拟定高度的差值
,他可以用于计算列表项的偏移量
。计算逻辑如下:
我们以第一项的差值为0
开始计算,那后续的列表项的偏移量其实就等于当前项top
+上一项的dHeight
。可能文字描述比较难理解,下面我们用一组数据来解释一下。
假设现在我们渲染了十项,并且拟定高度是50
。这时候我们的渲染数据是如下结构
[
{
id:0,
height:50,
top:0,
bottom:50,
},
{
id:1,
height:50,
top:50,
bottom:100
},
{
id:2,
height:50,
top:100,
bottom:150
},
...
]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
这时我们观测到数据项的真实高度其实是80
,100
,40...
,那第二项数据的真实top
就不应该是50
了,而是80
。换一个角度来说也就是上一项的top值
+上一项的高度
。这样计算的话依赖了两个变量
,其实并不是非常好用。
如果可以只依赖自身
变化去计算就好了,这里就体现出拟定高度
和真实高度
差值的作用了,
我们先把真实高度
带入进去看看和拟定数据
之间有什么规律
[
{
id:0,
height:80,
top:0,
bottom:80,
},
{
id:1,
height:100,
top:80,
bottom:180 // 100 - (50-100) - (50-80) = 180
},
{
id:2,
height:40,
top:180,
bottom:220 // 150 - (50-40) - (50-100) - (50-80)
},
...
]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
看上面的数据,可以总结出修正后的bottom
值等于自身高度差
+所有前置项的高度差
。
这里花了很大篇幅来解释dHeight
,是因为他在我们计算真实高度和修正偏移量中起到至关重要的作用。好了下面就开始编码吧。
步骤和定高的虚拟列表其实是一样的,只是多了一步修正高度的步骤
# 查找起始坐标
/**
* 获取当前可视区域内的起始坐标
*/
const handleGetStartIndex = (scrollTop : number) => {
// 采用二分查找
let star = 0
let end = props.list.length - 1
while (star <= end) {
const mid = Math.floor((star + end) / 2);
const currentOffset = measuredData[mid].top;
if (currentOffset === scrollTop) {
return mid;
} else if (currentOffset < scrollTop) {
star = mid + 1;
} else {
end = mid - 1;
}
}
return star
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
因为我们的列表是有序递增
的,所以这里可以采用二分查找
去优化一下查找速度,起始坐标的查找逻辑就是找到最接近当前scrollTop
值的那一项。
# 查找结束坐标
/**
* 获取当前可视区域内的结束坐标
*/
const handleGetEndIndex = (startIndex : number) => {
const listHeight = ChatBox.value.offsetHeight // 可视区域的高度
const startItem = measuredData[startIndex]
const maxOffset = startItem.top + listHeight
let offset = startItem.top + startItem.height;
let endIndex = startIndex;
while (offset <= maxOffset && endIndex < props.list.length - 1) {
endIndex++;
const currentItem = measuredData[endIndex];
offset += currentItem.height;
}
return endIndex;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
查找结束坐标就用起始坐标的高度不停地叠加下一个列表项的高度,直到大于等于可视区域的高度
为止。
# 获取当前可视范围的数据
// 获取当前可视的范围
const getChildShowRange = (scrollOffset : number) => {
const itemSumCount = props.list.length
const bufferNum = 2 // 缓冲区的数量
const startIndex = handleGetStartIndex(scrollOffset);
const endIndex = handleGetEndIndex(startIndex);
const bufferStartIndex = Math.max(0, startIndex - bufferNum) // 上缓冲区
const bufferEndIndex = Math.min(itemSumCount - 1, endIndex + bufferNum) // 下缓冲区
const items = []
for (let i = bufferStartIndex; i <= bufferEndIndex; i++) {
const item = measuredData[i];
items.push({
top: item.top,
...props.list[i] as Object,
idx: i
});
}
viewList.value = items
nextTick(() => {
handleObserveDomToUpdate()
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
这个函数没啥好说的,就是获取起始结束坐标然后push
真实渲染的数据
# 修正偏移量
// 观察实际高度 更新数据
const handleObserveDomToUpdate = () => {
const doms = document.getElementsByClassName('item')
for (let i = 0; i < doms.length; i++) {
const index = doms[i].id;
const nodeHeight = (doms[i] as HTMLDivElement).offsetHeight;
const oldHeight = measuredData[index].height // 旧的高度
const dHeight = oldHeight - nodeHeight // 比较高度差
if (dHeight) {
// 如果高度差存在就进行替换真实高度
measuredData[index].height = nodeHeight
measuredData[index].dHeight = dHeight //将差值保留
measuredData[index].bottom = measuredData[index].bottom - dHeight // 用原先的bottom减去高度差得到真实的bottom位置
}
}
// 重新计算整体高度
// 从当前渲染的第一项开始重新计算后面所有的偏移量
const startId = +doms[0].id
let startHeight = measuredData[startId].dHeight
measuredData[startId].dHeight = 0;
for (let i = startId + 1; i < measuredData.length; ++i) {
const item = measuredData[i];
measuredData[i].top = measuredData[i - 1].bottom;
measuredData[i].bottom = measuredData[i].bottom - startHeight;
if (item.dHeight !== 0) {
startHeight += item.dHeight;
item.dHeight = 0;
}
}
// 重新计算子列表的高度
containerHeight.value = measuredData[measuredData.length - 1].bottom;
// 替换掉当前展示的列表中的数据
viewList.value.forEach(d => {
d.top = measuredData[d.idx].top
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
整体的思路就是先获取到真实渲染的高度
,然后通过真实高度
去和拟定高度
做个比较,这个时候可以得到关键信息自身的高度差
。
有了自身的高度差
之后,我们就可以去重新计算偏移量以及整个列表的最新高度
。所以下面又进行了一次循环,在这个循环里首先我们取出当前渲染项第一项的dHeight
,
记录下来之后将他置为0
(ps:因为当前项的高度已经修正,我们已经不需要高度差了,这里将他单独取出来是为了去计算下一样的偏移量。)
在循环体种我们将高度差逐个累加并用于计算列表项的偏移量。这就是我们上文中总结出来的:当前列表项的偏移量等于自身拟定偏移量减去自身高度差和前面所有列表项的高度差。
这样就完成了整个列表的修正。最后我们将长列表的高度改成修正过后的最后一项的bottom
,也就是真实的长列表高度。然后再把当前渲染的数据的top
值修改一下就可以啦!
下面是完整代码
<template>
<!-- 用view标签无法正确获取到scrollTop,所以这里改为div标签 -->
<div class="chat" ref="ChatBox" @scroll="handleScroll">
<view class="list" :style="{'height':containerHeight + 'px'}">
<view class="item" v-for="(item,index) in viewList" :id="item.idx"
:style="{'top':+item.top + (20*(index+1)) + 'px'}">
<view :class="['msg-box',item.role==='user'?'msg-user':'msg-sys']">
<view :class="['icon flex-box',item.role==='user'?'icon-user':'icon-sys']">
{{item.role==='user'?'User':'Tumi'}}
</view>
<view class="msg border-box" :style="{'background':item.role==='user'?'#1F429C':'var(--fu-color)'}">
{{item.content}}
</view>
</view>
</view>
</view>
</div>
</template>
<script setup lang="ts">
import { nextTick, onMounted, reactive, ref } from 'vue';
const props = defineProps({
list: {
type: Array,
default: () => []
}
})
const itemHeight = ref(50) //预测每一项的高度
const viewList = ref([]) //视图中可见的列表
const ChatBox = ref(null)
const containerHeight = ref(0) // 内容区域的总高度,模拟滚动行为
let measuredData = reactive([])
const initData = () => {
measuredData = props.list.map((item, index) => ({
id: index,
height: itemHeight.value,
top: index * itemHeight.value,
bottom: (index + 1) * itemHeight.value,
dHeight: 0,// 真实高度
}))
containerHeight.value = measuredData[measuredData.length - 1].bottom
}
initData()
/**
* 获取当前可视区域内的起始坐标
*/
const handleGetStartIndex = (scrollTop : number) => {
// 采用二分查找
let star = 0
let end = props.list.length - 1
while (star <= end) {
const mid = Math.floor((star + end) / 2);
const currentOffset = measuredData[mid].top;
if (currentOffset === scrollTop) {
return mid;
} else if (currentOffset < scrollTop) {
star = mid + 1;
} else {
end = mid - 1;
}
}
return star
}
/**
* 获取当前可视区域内的结束坐标
*/
const handleGetEndIndex = (startIndex : number) => {
const listHeight = ChatBox.value.offsetHeight // 可视区域的高度
const startItem = measuredData[startIndex]
const maxOffset = startItem.top + listHeight
let offset = startItem.top + startItem.height;
let endIndex = startIndex;
while (offset <= maxOffset && endIndex < props.list.length - 1) {
endIndex++;
const currentItem = measuredData[endIndex];
offset += currentItem.height;
}
return endIndex;
}
// 获取当前可视的范围
const getChildShowRange = (scrollOffset : number) => {
const itemSumCount = props.list.length
const bufferNum = 2 // 缓冲区的数量
const startIndex = handleGetStartIndex(scrollOffset);
const endIndex = handleGetEndIndex(startIndex);
const bufferStartIndex = Math.max(0, startIndex - bufferNum)
const bufferEndIndex = Math.min(itemSumCount - 1, endIndex + bufferNum)
const items = []
for (let i = bufferStartIndex; i <= bufferEndIndex; i++) {
const item = measuredData[i];
items.push({
top: item.top,
...props.list[i] as Object,
idx: i
});
}
viewList.value = items
nextTick(() => {
handleObserveDomToUpdate()
})
}
// 观察实际高度 更新数据
const handleObserveDomToUpdate = () => {
const doms = document.getElementsByClassName('item')
for (let i = 0; i < doms.length; i++) {
const index = doms[i].id;
const nodeHeight = (doms[i] as HTMLDivElement).offsetHeight;
const oldHeight = measuredData[index].height // 旧的高度
const dHeight = oldHeight - nodeHeight // 比较高度差
if (dHeight) {
// 如果高度差存在就进行替换真实高度
measuredData[index].height = nodeHeight
measuredData[index].dHeight = dHeight //将差值保留
measuredData[index].bottom = measuredData[index].bottom - dHeight // 用原先的bottom减去高度差得到真实的bottom位置
}
}
// 重新计算整体高度
// 从当前渲染的第一项开始重新计算后面所有的偏移量
const startId = +doms[0].id
let startHeight = measuredData[startId].dHeight
measuredData[startId].dHeight = 0;
for (let i = startId + 1; i < measuredData.length; ++i) {
const item = measuredData[i];
measuredData[i].top = measuredData[i - 1].bottom;
measuredData[i].bottom = measuredData[i].bottom - startHeight;
if (item.dHeight !== 0) {
startHeight += item.dHeight;
item.dHeight = 0;
}
}
// 重新计算子列表的高度
containerHeight.value = measuredData[measuredData.length - 1].bottom;
// 替换掉当前展示的列表中的数据
viewList.value.forEach(d => {
d.top = measuredData[d.idx].top
})
}
/**
* 监听滚动条滚动
*/
const handleScroll = (e : any) => {
getChildShowRange(e.target.scrollTop)
}
/**
* 计算可视区域的列表 固定高度
*/
const handleComputedViewList = (scrollTop : number) => {
const startIndex = scrollTop === 0 ? 0 : Math.floor(scrollTop / itemHeight.value)
const height = ChatBox.value.offsetHeight
const pushNum = Math.ceil(height / itemHeight.value)
const endIndex = (startIndex + pushNum) > props.list.length - 1 ? props.list.length - 1 : startIndex + pushNum
const list = []
for (let i = startIndex; i <= endIndex; i++) {
list.push({
...props.list[i] as Object,
top: i * itemHeight.value,
idx: i,
})
}
viewList.value = list
}
onMounted(() => {
getChildShowRange(0)
})
</script>
<style lang="scss" scoped>
.chat {
height: calc(100vh - 278rpx);
overflow-y: auto;
.list {
padding: 20rpx 0;
position: relative;
.item {
// margin-bottom: 40rpx;
position: absolute;
padding: 0 20rpx;
width: 100%;
box-sizing: border-box;
.msg-box {
display: flex;
}
.icon {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
font-size: 24rpx;
color: #fff;
}
.icon-user {
background: #1F429C;
margin-left: 10rpx;
}
.icon-sys {
background: #6F7899;
margin-right: 10rpx;
}
.msg {
padding: 20rpx;
border-radius: 10rpx;
max-width: calc(100% - 190rpx);
font-size: 24rpx;
color: #fff;
}
}
.msg-user {
flex-direction: row-reverse;
}
.msg-sys {}
}
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# 结语
定高的虚拟列表比较容易实现,不定高的主要是采用了拟定高度的方式,先渲染出数据然后再根据真实高度去更新我们拟定的数据,难点在于更新数据的计算。不过只要理解了利用高度差来计算偏移量的逻辑,那整体还是比较好实现的。重点再理解那段修正偏移量的逻辑!