虚拟列表:简而言之,对于大量列表数据,仅渲染滚动区域内可视区域 所对应 数据源 的部分数据

介绍

举个例子:
比如我要渲染 1000 条数据,每条数据高度 60px,如果我想把全部元素渲染,那么滚动区域至少需要 6000px 高但是呢,如果我渲染 1000 条数据,那么整个 dom 树就会出现巨量 dom 元素,如下图,B,C,D 区域将全部都是 dom 元素,一旦回流重绘就会非常卡顿。

img

这种场景的优化方案就是采取虚拟列表去渲染,即我们要做的就是在滚动的时候只渲染可视区域对应的 dom 元素,即下图的 B,非可视区域内元素不去渲染(即 C,D 区域不渲染元素),这样的话可以减少大量非必要 DOM 渲染(C,D 区域用户也不需要看见,所以它的 dom 渲染是非必要的),去提高性能!

实现原理

根据视口高度(上图中的 B 区域高度),父元素的滚动距离(scrollTop)及每一个列表元素 item 的平均高度,计算出当前 scrollTop 到 scrollTop+视口高度这段可视区域 对应数据源列表所需要展示的 部分数据,再将这部分数据展示在 B 区域内,对于隐藏滚动区域 C,D 使用 paddingTop&&paddingBottom 填充,保持整个滚动区域高度不变,同时监听滚动,滚动触发时动态改变可是区域内数据及 paddingTop&&paddingBottom 等数据

  • 1,监听容器元素滚动
  • 2,获取容器元素滚动距离 scrollTop
  • 3,获取当前滚动距离(scrollTop)下可视区域首个需要渲染元素索引(in 数据源)startIndex,计算方式:startIndex = 滚动距离(scrollTop)/ 列表元素高度(itemHeight)
  • 4,获取当前滚动距离(scrollTop)下可视区域最后一个需要渲染元素索引(in 数据源)endIndex,计算方式:endIndex = startIndex + 可视区域高度(viewHeight)/ 列表元素高度(itemHeight)
  • 5,根据 startIndex 和 endIndex 获取数据源对应的 可视区域需要展示的数据 viewData,计算方式:viewData =dataSource(数据源).slice(startIndex,endIndex+1)
  • 6,计算 C 区域的高度,既 paddingTop,计算方式:paddingTop = startIndex*itemHeight(列表元素高度)
  • 7,计算 D 区域的高度,既 paddingBottom,计算方式:paddingBottom = contentHeight(整个滚动区域高度,既 A 区域)-paddingTop-可视区域高度(viewHeight)
  • 8,清除可是区域内上一次展示的数据,使用新数据 viewData 重新填充,且设置新的 paddingTop&paddingBottom

代码实现

1
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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
<style>
html,
body {
width: 100%;
height: 100%;
margin: 0;
}

#container {
width: 100%;
height: 100%;
}

#content {
background-color: pink;
}
</style>
</head>

<body>
<div id="container">
<div id="content"></div>
</div>
<script>
function createVlist(container, content, data_Source, item_Height) {
const dataSource = data_Source // 数据源
const itemHeight = item_Height // 每一行元素高度
const viewHeight = container.clientHeight // 视口高度(虚拟列表所展示部分高度)
const contentHeight = itemHeight * dataSource.length // 滚动区域高度
const itemCount = Math.ceil(viewHeight / itemHeight) // 视口元素数量
// 设置容器元素overflow: auto (生成滚动区域,不被截取)
container.setAttribute('style', `overflow:auto`)
// 设置滚动区域高度
content.setAttribute('style', `height:${contentHeight}px`)
const scrollCallback = (e) => {
const scrollTop = e.target.scrollTop // 容器元素的滚动距离
const startIndex = Math.ceil(scrollTop / itemHeight) // 视口第一个元素所在数据源中的索引
const endIndex = startIndex + itemCount // 视口最后一个元素所在数据源中的索引
// 根据startIndex endIndex找出数据源中需要展示在虚拟列表中的部分数据
const itemList = dataSource.slice(startIndex, endIndex + 1)

// 滚动区域高度contentHeight = 滚动区域paddingTop + 视口高度 + 滚动区域paddingBottom
const paddingTop = startIndex * itemHeight
const paddingBottom =
contentHeight - paddingTop - itemCount * itemHeight

// 动态调整滚动区域的paddingTop paddingBottom 保证列表部分始终展示在视口
content.setAttribute(
'style',
`padding-top:${paddingTop}px;padding-bottom:${paddingBottom}px`
)
// 展示下一批次列表前删除上一批次列表数据
content.innerHTML = ''
// 动态调整每次滚动后对应的展示数据
for (const val of itemList) {
const item = document.createElement('div')
item.innerHTML = val
item.setAttribute(
'style',
`background-color:${
val % 2 === 0 ? 'red' : 'blue'
};width:100%;height:${item_Height}px`
)
content.appendChild(item)
}
}
// 添加容器元素的滚动监听
container.addEventListener('scroll', scrollCallback)
// 初始首屏数据
scrollCallback({ target: container })
}
// 创建虚拟列表
createVlist(
container,
content,
Array.from({ length: 100 }, (v, i) => i),
60
)
</script>
</body>
</html>

Vue 使用虚拟列表

安装 vue-virtual-scroller

1
npm i vue-virtual-scroller

main.js中引入

1
2
3
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import vuevirtualscroller from 'vue-virtual-scroller'
Vue.use(vuevirtualscroller)

基础用法(固定大小)

1
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
<template>
<RecycleScroller
class="scroller"
:items="list"
:item-size="32"
key-field="id"
v-slot="{ item }">
<div class="user">
{{ item.name }}
</div>
</RecycleScroller>
</template>

<script>
export default {
name: 'Home',
components: {},
data() {
return {
items: [],
}
},
created() {
this.getData()
},
methods: {
getData() {
for (let i = 0; i < 200000; i++) {
this.items.push({ title: 'ssadqwesadwqe', id: i })
}
},
},
}
</script>

<style lang="less" scoped>
.scroller {
height: 300px;
background-color: rgba(0, 0, 0, 0.1);
}

.user {
height: 32%;
padding: 0 12px;
display: flex;
align-items: center;
}
</style>

使用<RecycleScroller>items绑定数据源,item-size设置的子元素的高度,父子元素高度都必须固定


上拉加载(固定大小)

1
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
<template>
<div>
<RecycleScroller
class="scroller"
:items="items"
:item-size="150"
:emitUpdate="true"
@update="update"
v-if="items.length">
<template slot-scope="props">
<li :key="props.itemKey">
<div>{{ props.item.title }}</div>
<img :src="props.item.img" alt="" />
</li>
</template>
</RecycleScroller>
</div>
</template>
<script>
export default {
name: 'test',
data() {
return {
items: [],
}
},
created() {
this.getData()
},
mounted() {},
methods: {
getData() {
let arr = []
for (let i = 1; i < 20; i++) {
arr.push({
id: i + 1,
img: 'http://dummyimage.com/200x100/FF6600',
time: '2003-02-02',
title: 'hahahha',
})
}
this.items = arr
},
update(start, end) {
if (end === this.items.length) {
let temp = []
let id = this.items[this.items.length - 1].id
let arr = []
for (let i = 1; i < 20; i++) {
arr.push({
id: id + i,
img: 'http://dummyimage.com/200x100/FF6600',
time: '2003-02-02',
title: 'hahahha',
})
}
let res = {
code: 200,
data: arr,
}
temp = [...this.items, ...res.data]
this.items = temp
}
},
},
}
</script>
<style lang="css" scoped>
.scroller {
height: 300px;
background-color: #ccc;
}

.user {
height: 150px;
padding: 0 12px;
display: flex;
align-items: center;
}
</style>

加入:emitUpdate=“true”@update=“update”,emitUpdate 必须设置为 true,update中定义数据更新的方法


上拉加载(可变大小)

1
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
<template>
<div>
<DynamicScroller
:items="items"
:min-item-size="54"
class="scroller"
:emitUpdate="true"
@update="update"
v-if="items.length">
<template v-slot="{ item, index, active }">
<DynamicScrollerItem
:item="item"
:active="active"
:size-dependencies="[item.message]"
:data-index="index">
<li class="single-item" :key="item.id">
<div class="right-info">
<span>标题:{{ item.title }}</span>
<span>项目数量:{{ item.id }}</span>
<span>项目时间:{{ item.time }}</span>
<span>项目描述:{{ item.des }}</span>
</div>
</li>
</DynamicScrollerItem>
</template>
</DynamicScroller>
</div>
</template>
<script>
export default {
name: 'test',
data() {
return {
items: [],
}
},
created() {
this.getData()
},
mounted() {},
methods: {
getData() {
let arr = []
for (let i = 1; i < 20; i++) {
arr.push({
id: i + 1,
img: 'http://dummyimage.com/200x100/FF6600',
time: '2003-02-02',
title: 'hahahha',
})
}
this.items = arr
},
update(start, end) {
if (end === this.items.length) {
let temp = []
let id = this.items[this.items.length - 1].id
let arr = []
for (let i = 1; i < 20; i++) {
arr.push({
id: id + i,
img: 'http://dummyimage.com/200x100/FF6600',
time: '2003-02-02',
title: 'hahahha',
})
}
let res = {
code: 200,
data: arr,
}
temp = [...this.items, ...res.data]
this.items = temp
}
},
},
}
</script>
<style lang="less" scoped>
.scroller {
height: calc(100vh - 3rem);
background-color: #ccc;
}

.user {
height: 32%;
padding: 0 12px;
display: flex;
}
.single-item {
display: flex;
justify-content: flex-start;
align-items: center;
border-bottom: 1px solid rgb(187, 167, 167);
.left-pic {
width: 200px;
img {
width: 200px;
}
}
.right-info {
padding-left: 20px;
text-align: left;
span {
display: block;
&:last-child {
word-break: break-all;
}
}
}
}
</style>

vue-virtual-scroller

参数

参数 说明 默认值
items 要在滚动条中显示的项目列表。 -
direction 滚动方向(verticalhorizontal) vertical
itemSize 以像素为单位显示项目的高度(或水平模式下的宽度),用于计算滚动大小和位置。
如果设置为(默认值),它将使用可变大小模式。
null
gridItems 在同一行上显示多个项目以创建网格。您必须输入一个值才能使用此道具(不支持动态大小)。 -
itemSecondarySize 设置时网格中项目的大小(垂直模式下的宽度,水平模式下的高度)。
如果未设置,它将使用 gridItems itemSecondarySize itemSize 的值。
-
minItemSize 如果项目的高度(或水平模式下的宽度)未知,则使用的最小大小。 -
sizeField 用于在可变大小模式下获取项目大小的字段。 size
typeField 用于区分列表中不同类型的组件的字段。对于每种不同的类型,将创建一个回收物品池。 type
keyField 用于标识项目和优化管理渲染视图的字段。 id
pageMode 启用页面模式。 false
prerender 为服务器端呈现 (SSR) 渲染固定数量的项目。 0
buffer 要添加到滚动可见区域边缘以开始渲染更远的项目的像素量。 200
emitUpdate 每次更新虚拟滚动条内容时发出一个事件(可能会影响性能)。 false
listClass 添加到项目列表包装器的自定义类。 -
itemClass 添加到每个项目的自定义类。 -
listTag 要呈现为列表包装器的元素。 div
itemTag 要呈现为列表项(默认槽内容的直接父级)的元素。 div

事件

事件名 说明 参数
resize 当滚动条的大小更改时发出。
visible 当滚动条认为自己在页面中可见时发出。
hidden 当滚动条隐藏在页面中时发出。
update 每次更新视图时发出。emitUpdate 需设置为 true startIndex, endIndex, visibleStartIndex, visibleEndIndex
scroll-start 渲染第一项时发出。
scroll-end 呈现最后一项时发出。