# 定高虚拟列表
虚拟列表是上述问题的一种解决方案,是按需显示的一种实现,只对可见区域渲染,对非可见区域不渲染或部分渲染,从而减少性能消耗。
虚拟列表将完整的列表分为三个区域:虚拟区 / 缓冲区 / 可视区
- 虚拟区为非可见区域不进行渲染
- 缓冲区为后续优化滚动白屏使用,暂不渲染
- 可视区为用户视窗内的数据,需要渲染对应的列表项
# 实现
假设列表 可见区域 的高度为 500px,列表项高度为 50px。 初始化时列表里有1w条数据本来需要同时渲染, 但列表区域中最多只能显示 500 / 50 = 10 条数据,那么在首次渲染列表时只需要加载前10条。
当列表发生滚动,计算 视窗偏移量 获得 开始索引,再根据索引获得此时可见区域内用于渲染的列表数据范围。 例如当前滚动条距离顶部150px,那么可见区域内的列表项为第 4(1 + 150 / 50) 项 至 第 13(10 + 3) 项。
无论滚动到什么位置,浏览器只需要渲染可见区域内的节点。
本文代码基于Vue,实现虚拟列表的关键点主要分为 1.模拟完整列表的页面结构调整 2.总结过程中的参数和计算公式 3.添加滚动回调时的操作
# 页面结构
container:列表容器,监听phantom元素的滚动条,判断当前用于渲染的列表数据范围。
phantom:占位元素,为了保持列表容器的 真正高度 并使滚动能够正常触发,我们专门使用一个div来占位生成滚动条。
content:渲染区域,用户真正看到的页面内容,一般由 缓冲区 + 可视区 组成。
<!-- 可视区域的容器 -->
<div class="container" ref="virtualList">
<!-- 占位,用于形成滚动条 -->
<div class="phantom"></div>
<!-- 列表项的渲染区域 -->
<div class="content">
<!-- item-1 -->
<!-- item-2 -->
<!-- ...... -->
<!-- item-n -->
</div>
</div>
2
3
4
5
6
7
8
9
10
11
12
# 参数&计算
已知数据: ● 假定可视区域高度固定,称为 screenHeight ● 假定列表每项高度固定,称为 itemSize ● 假定列表数据称为 listData ● 假定当前距离顶部偏移量称为 scrollTop
可推算出: ● 列表总高度 listHeight = listData.length * itemSize ● 可见列表项数 visibleCount = Math.ceil(screenHeight / itemSize) ● 数据的起始索引 start = Math.ceil(scrollTop / itemSize) ● 数据的结束索引 end = startIndex + visibleCount ● 列表显示数据为 visibleData = listData.slice(start, end)
export default {
......
props: {
listData:{
type:Array,
default: () => []
},
itemSize: {
type: Number,
default: 200
}
},
computed:{
// 列表总高度
listHeight() {
return this.listData.length * this.itemSize;
},
// 可显示的列表项数
visibleCount() {
return Math.ceil(this.screenHeight / this.itemSize)
},
// 获取真实显示列表数据
visibleData() {
return this.listData.slice(this.start, this.end);
}
},
mounted() {
// 初始化数据
this.screenHeight = this.$el.clientHeight;
this.start = 0;
this.end = this.start + this.visibleCount;
},
data() {
return {
screenHeight:0, // 可视区域高度
start:0, // 起始索引
end:null, // 结束索引
};
},
};
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
# 窗口滚动
容器滚动绑定监听事件,当滚动后,我们要获取 距离顶部的高度scrollTop ,然后计算 开始索引start 和 结束索引end ,根据他们截取数据,并计算 当前偏移量currentOffset 用于将渲染区域偏移至可见区域中 。
export default {
...
mounted() {
...
this.$refs.virtualList.addEventListener('scroll', event => this.scrollEvent(event.target))
},
data() {
return {
...
curretnOffset: 0, // 当前偏移量
};
},
...
methods: {
scrollEvent(target) {
//当前滚动位置
let scrollTop = target.scrollTop;
//此时的开始索引
this.start = ~~(scrollTop / this.itemSize);
//此时的结束索引
this.end = this.start + this.visibleCount;
//此时的偏移量
this.currentOffset = scrollTop - (scrollTop % this.itemSize);
}
}
...
}
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
currentOffset 为什么不是 =scrollTop?
为了正确实现滚动效果。
每次滚动生成的偏移量都只能是itemSize的倍数,你可以理解为this.currentOffset = this.start * this.itemSize
一次滚动可能不会让元素完全离开虚拟列表(比如一半在列表内一半在列表外)。
如果=scrollTop的话 刚滚下去一半 偏移量马上就跟上来了,在用户视角就是列表没有滚动。
直到偏移量够了才发生索引变化 刷新数据 才产生类似滚动的效果,你可以试一下把itemSize设置的比较大,再currentOffset = scrollTop
# 遗留的问题
- 动态高度 多行文本、图片之类的可变内容,会导致列表项的高度并不相同。 解决方法: 以预估高度先行渲染,然后获取真实高度并缓存。
- 白屏闪烁 回调执行也有执行耗时,如果滑动过快会出现白屏/闪烁的情况。为了使页面平滑滚动,我们还需要在可见区域的上方和下方渲染额外的项目,给滚动回调一些缓冲时间。
- 响应耗时 一次性请求大量数据可能会使后端处理时间增加,过大的响应体也会导致请求中Content Download 耗时增加,建议通过请求接口分片获取渲染数据。。
点击查看
<template>
<div class="container" ref="virtualList">
<div class="phantom" :style="{ height: listHeight + 'px' }"></div>
<div
class="content"
:style="{ transform: `translate3d(0, ${currentOffset}px, 0)` }"
>
<div
v-for="item in visibleData"
:key="item.id"
:style="{ height: itemSize + 'px', lineHeight: itemSize + 'px' }"
class="list-item"
>
{{ item.value }}
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
listData: [],
itemSize: 50,
screenHeight: 0,
currentOffset: 0,
start: 0,
end: 0,
};
},
mounted() {
for (let i = 1; i <= 1000; i++) {
this.listData.push({id: i, value: '字符内容' + i})
}
this.screenHeight = this.$el.clientHeight;
this.start = 0;
this.end = this.start + this.visibleCount;
this.$refs.virtualList.addEventListener("scroll", (event) =>
this.scrollEvent(event.target)
);
},
computed: {
listHeight() {
return this.listData.length * this.itemSize;
},
visibleCount() {
return Math.ceil(this.screenHeight / this.itemSize);
},
visibleData() {
return this.listData.slice(this.start, this.end);
},
},
methods: {
scrollEvent(target) {
const scrollTop = target.scrollTop;
this.start = ~~(scrollTop / this.itemSize);
this.end = this.start + this.visibleCount;
this.currentOffset = scrollTop - (scrollTop % this.itemSize);
},
},
};
</script>
<style scoped>
.container {
position: relative;
height: 90vh;
overflow: auto;
}
.phantom {
position: absolute;
top: 0;
right: 0;
left: 0;
}
.content {
position: absolute;
top: 0;
right: 0;
left: 0;
text-align: center;
}
.list-item {
padding: 10px;
border: 1px solid #999;
}
</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