原生JS实现一个虚拟列表

虚拟列表是前端解决海量数据展示的一种解决方案。
当我们需要展示万条,百万条数据时。如果使用传统的分页向下展示。随着数据量的增多,HTML节点也会增加,HTML节点越多,重绘重排的花销也会增大,慢慢地会让你的页面变得非常的慢。
那么为什么使用虚拟列表就能解决这个问题呢?

虚拟列表的原理

虚拟列表的原理其实就是:当我们查询出大量数据时,只展示当前可视区域的数据,其他的数据只在滚动到数据的页数时才展示。如图所示:

image.png

我们可以预先加载几页数据,当我们滚动到预加载的页面时,加载下(上)一页数据,并删除上(下)一页数据。这样无论我们怎么滚动,页面中展示的数据量始终保持固定的。

实现虚拟列表

了解完它的原理,接下来就是需要实现这个虚拟列表,这里可以提供两种方式解决问题~

  • 监听列表容器的滚动,通过滚动对可视列表进行控制
  • 使用ResizeObserver监听内容区域的改变,从而控制可视列表。但其方法是实验性的。

因为ResizeObserver是实验性的API,不推荐在生产环境中使用,所以我们这里第一种方法:监听滚动来实现。

具体的实现步骤为:

  1. 创建容器并监听容器滚动
  2. 获取容器高度和每个列表的高度
  3. 计算触发下(上)一页触发的滚动距离
  4. 计算留白的高度(重要)

其中,计算留白的高度是虚拟列表最重要的一环,因为当元素隐藏时,为了保证展示列表不出现塌陷,需要使用padding将容器撑高。

虚拟列表有定高不定高两种。

  • 定高就是每一个item固定高度,我们在计算滚动距离的时候就会比较轻松。
  • 但是很多情况下,列表的item高度是不固定的,这时我们就要比定高多一步:计算每一页渲染后动态计算的总高度。

定高虚拟列表

首先新建一个html文件,创建一个box容器,固定容器高度并监听滚动

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
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<title>虚拟列表</title>
<meta charset="utf8" />
<style>
* {
padding: 0;
margin: 0;
}
body {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
#box {
width: 300px;
height: 500px;
background: purple;
overflow-y: scroll;
box-sizing: border-box;
}
</style>
</head>
<body>
<div id="box">
<div id="box_container"></div>
</div>
</body>
<script lang="text/javascript" src="./script.js"></script>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// script.js
(() =>{
// 定义初始化数据
let page = 1; // 页码
let size = 20; // 每页条数
const height = 50; // 每条高度
const preLoadNum = 3; // 同时展示载页数
const boxHeight = 500; // 容器高度
let paddingBottom = 50; // 底部留白高度
let listArr = []; // 用于存放列表数据

const box = document.querySelector("#box");
// 监听滚动
box.addEventListener("scroll", (e) => {}, false)
})()

定义完一些基础的初始信息之后,接下来写一个获取数据的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// script.js

function createItem(page = 1, size = 10) {
const fragment = document.createDocumentFragment()
const box = document.createElement("div");

box.className = `page_${page}`; // 给每页容器定一个类名,后面根据类名进行容器删除
for (let i = 0; i < size; i++) {
const element = document.createElement("div");

element.style.width = "100%";
element.style.height = "50px";
element.style.color = "#fff";
element.className = `item_${page * (i + 1)}`;
element.innerText = `我是item——${((page - 1) * size) + i + 1}`;
box.appendChild(element);
}
fragment.appendChild(box);

return {fragment, box};
}

回到立即执行函数中,进入页面时立即执行一遍

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// script.js

(() =>{
// 定义初始化数据
let page = 1; // 页码
let size = 20; // 每页条数
const height = 50; // 每条高度
const preLoadNum = 3; // 同时展示载页数
const boxHeight = 500; // 容器高度
let listArr = []; // 用于存放列表数据

const box = document.querySelector("#box");
const boxContainer = document.querySelector("#box_container");

const {fragment, box: boxList} = createItem(page, size)
listArr.push(boxList); // 将数据放入列表中
boxContainer.appendChild(fragment); // 载入初始数据
// 监听滚动
box.addEventListener("scroll", (e) => {

}, false)
})()

接下来就需要将计算滚动高度去获取并展示数据

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
box.addEventListener("scroll", (e) => {
const scrollTop = e.target.scrollTop
if (scrollTop >= nextHeight) {
page++;
// 顶部留白高度
paddingTop = (page - preLoadNum) * (size * height) + paddingBottom;
// 触发下一页的高度
nextHeight = (page - 1) * (size * height) + paddingBottom + boxHeight;
let fragment;
// 判断该页数据是否已经存在列表中,存在则无须获取新的
if (!listArr[page - 1]) {
const {fragment: element, box: boxList} = createItem(page, size)
fragment = element;
listArr.push(boxList);
} else {
fragment = listArr[page - 1]
}
boxContainer.appendChild(fragment);
// 判断是否存在需要删除的列表
const hideElem = document.querySelector(`.page_${page - preLoadNum}`);
if (hideElem) {
// 如果有则删除列表并新增留白高度
boxContainer.removeChild(hideElem);
boxContainer.style.paddingTop = `${paddingTop}px`;
}
} else if (
scrollTop <= (page - preLoadNum + 1) * size * height + paddingBottom && page > preLoadNum
) {
page--;
// 顶部留白高度
paddingTop = (page - preLoadNum) * (size * height) + paddingBottom;
// 触发下一页的高度
nextHeight = (page - 1) * (size * height) + paddingBottom + boxHeight;
// 取出上一页的数据并插入
const fragment = listArr[page - preLoadNum];
boxContainer.insertBefore(fragment, boxContainer.childNodes[0]);
const hideElem = document.querySelector(`.page_${page + 1}`);
if (hideElem) {
// 删除下面的列表并减去顶部留白
boxContainer.removeChild(hideElem);
boxContainer.style.paddingTop = `${paddingTop}px`;
}
}
}, false)

大功告成!来测试一下效果
传送门

不定高虚拟列表

对于不定高的虚拟列表,我们需要计算每一页的总高度,或者将每一页的每一项高度都加上然后累加去,因为我们每一页都用了一个容器来将他们保存起来,并且容器是自动撑高的,所以,只需要获取每一页容器的高度即可。修改一下上面的代码:

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
function createItem(page = 1, size = 10) {
const fragment = document.createDocumentFragment()
const box = document.createElement("div");
box.className = `page_${page}`;
for (let i = 0; i < size; i++) {
const element = document.createElement("div");
// 高度随机
let height = Math.ceil(Math.random() * 5) * 50
element.style.width = "100%";
element.style.height = `${height}px`;
element.style.color = "#fff";
element.className = `item_${page * (i + 1)}`;
element.innerText = `我是item——${((page - 1) * size) + i + 1} \n 高度——${height}`;
box.appendChild(element);
}
fragment.appendChild(box);
return {fragment, box};
}
(() => {
let page = 1;
let size = 20;
let height = 50;
let preLoadNum = 3;
let boxHeight = 500;
let paddingBottom = 50;
const box = document.querySelector("#box");
const boxContainer = document.querySelector("#box_container");
let listArr = [];
let isGetting = false;


const {fragment, box: boxList} = createItem(page, size)
boxContainer.appendChild(fragment);
// 获取第一页的总高度
const listHeight = document.querySelector(`.page_${page}`).clientHeight;
listArr.push({boxList, height: listHeight});

let paddingTop = 0;
// 触发渲染下一页的条件
let nextHeight = paddingTop + listHeight + paddingBottom - boxHeight;

boxContainer.style.paddingBottom = `${paddingBottom}px`;
boxContainer.style.paddingTop = `${paddingTop}px`;

box.addEventListener("scroll", (e) => {

const scrollTop = e.target.scrollTop
if (scrollTop >= nextHeight) {
if (isGetting) return;
isGetting = true;
page++;
let fragment;
let pushObj;
if (!listArr[page - 1]) {
const {fragment: element, box: boxList} = createItem(page, size)
fragment = element;
pushObj = { boxList };
} else {
const { boxList, height } = listArr[page - 1]
fragment = boxList;
}

boxContainer.appendChild(fragment);
// 更新顶部留白
paddingTop = (listArr.filter((_, index) => index < page - preLoadNum)).map((val) => val.height).reduce((a,b) => (a + b), 0);
// 更新下一页触发条件
nextHeight = boxContainer.clientHeight - boxHeight - paddingBottom;
if (pushObj) {
const listHeight = document.querySelector(`.page_${page}`).clientHeight;
pushObj.height = listHeight;
listArr.push(pushObj)
}

const hideElem = document.querySelector(`.page_${page - preLoadNum}`);
if (hideElem) {
boxContainer.removeChild(hideElem);
boxContainer.style.paddingTop = `${paddingTop}px`;
}
// 渲染上一页条件
} else if (scrollTop <= nextHeight - listArr[listArr.length - 1].height && page > preLoadNum) {
page--;

// 拉取上一页数据并渲染
const { boxList } = listArr[page - preLoadNum];
boxContainer.insertBefore(boxList, boxContainer.childNodes[0]);

const hideElem = document.querySelector(`.page_${page + 1}`);
// 删掉最下面一页
if (hideElem) {
// 更新顶部留白
paddingTop = (listArr.filter((_, index) => index < page - preLoadNum)).map((val) => val.height).reduce((a,b) => (a + b), 0);
boxContainer.style.paddingTop = `${paddingTop}px`;
boxContainer.removeChild(hideElem);
// 更新下一页渲染条件
nextHeight = boxContainer.clientHeight - boxHeight - paddingBottom;
}
}
isGetting = false;
}, false);
})()

最后实现效果传送门

总结

本文主要介绍了

  • 虚拟列表的原理
  • 虚拟列表的实现步骤
  • 定高的虚拟列表实现
  • 不定高的虚拟列表实现

参考