原生JS编写虚拟滚动
前言
在开发项目的过程中,偶尔会遇到很大的数据,然后设计图上又是列表还不分页的情况。为此研究了下虚拟滚动的方案。虚拟滚动大致的思路是当你往下滚动,但最后一个计算的元素出现的时候,替换上面不见了的 DOM 元素,将它们从渲染的 HTML 中剔除,同理往上滚动,一个计算的元素出现在最上面的时候,表明需要加载上面的元素信息,并隐藏下面的 DOM 元素。演示地址
具体思路
页面结构,当然只需要一个 DIV 元素就可以。然后将手动生成 DOM 元素添加我们需要虚拟滚动的 DOM 元素中。
代码结构,利用 JS 的 Class 来创建一个对象,然后对象里做操作方法。这样把 dom 和 JS 操作分开,有利于代码的维护。
对象上属性的考虑需要哪些呢?
- 需要虚拟滚动监听的 dom 元素 element
- 需要虚拟滚动监听的 dom 的高度 height
- 虚拟滚动列表的每一行高度 rowHeight,用来计算需要加载多少个数据
- 新加载数据的个数 pageSize
- 页面滚动加载新的 dom 元素,那么 dom 渲染需要时间,为此加一个缓存区域,提前渲染出需要的 dom 元素
- 每一行渲染的回调函数 renderItem
- 加载更多的回调函数 loadMore
那么对应的 html 编写就是
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>虚拟滚动</title>
<!-- 一个自己的样式 -->
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="virtual-scroller"></div>
<script src="./VirtualScroller.js"></script>
<script src="./index.js"></script>
</body>
</html>目前主要一个对象需要这几个属性。对应代码的编写就如下
1
2
3
4
5
6
7
8
9
10
11// VirtualScroller.js
class VirtualScroller {
constructor({
element,
height,
rowHeight,
buffer,
renderItem,
loadMore,
}) {}
}构造函数中传入需要的对象属性值,然后构造函数检验值的正确性,并赋值
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// VirtualScroller.js
class VirtualScroller {
constructor({ element, height, rowHeight, buffer, renderItem, loadMore }) {
if (typeof element === "string") {
this.scroller = document.querySelector(element);
} else if (element instanceof HTMLElement) {
this.scroller = element;
}
if (!this.scroller) {
throw new Error("Invalid element");
}
if (
!height ||
(typeof height !== "number" && typeof height !== "string")
) {
throw new Error("invalid height value");
}
if (!rowHeight || typeof rowHeight !== "number") {
throw new Error("rowHeight should be a number");
}
if (typeof renderItem !== "function") {
throw new Error("renderItem is not a function");
}
if (typeof loadMore !== "function") {
throw new Error("renderItem is not a function");
}
// set props
this.height = height;
this.rowHeight = rowHeight;
this.pageSize =
typeof pageSize === "number" && pageSize > 0 ? pageSize : 50;
this.buffer = typeof buffer === "number" && buffer >= 0 ? buffer : 10;
this.renderItem = renderItem;
this.loadMore = loadMore;
this.data = [];
}
}这个时候来看看如何生成一个对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23// index.js
let scroller = new VirtualScroller({
element: "#virtual-scroller",
height: "100vh",
rowHeight: 60, // px
pageSize: 100,
buffer: 10,
renderItem: function (dataItem) {
const div = document.createElement("div");
div.classList.add("row-content");
div.textContent = dataItem;
return div;
},
loadMore: function (pageSize) {
const data = [];
for (let i = 0; i < pageSize; i++) {
const dataItem = `当前元素下标${this.data.length + i}`;
data.push(dataItem);
}
return data;
},
});赋值操作弄好之后,还需要再每次创建新对象的时候就创建一个 dom 元素来包裹渲染 rowItem
1
2
3
4
5
6
7constructor(){
......
// 每次创建新的对象的时候就创建一个dom元素来包裹渲染rowItem
const contentBox = document.createElement('div');
this.contentBox = contentBox;
this.scroller.append(contentBox);
}还需要每次新建对象的的时候先渲染一批数据,然后还要再上面创建的包裹层中添加滚动监听。相当于虚拟 dom 的根节点。然后挂载到需要的 dom 节点上。
1
2
3
4
5constructor(){
......
this.#loadInitData();
this.scroller.addEventListener('scroll', throttle(this.#handleScroll, 150));
}这里就遇到了 2 个函数一个初始化加载函数,渲染第一次渲染的数据,第二个是防抖函数用来处理滚动的性能,第三个就是每次滚动到指定位置就渲染的数据的渲染函数
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
102class VirtualScroller{
......
#loadInitData () {
// 拿到被挂载的dom元素,获取它的显示高度,然后看最少需要渲染多少个数据,然后把个数放入到回调函数中获取数据
const scrollerRect = this.scroller.getBoundingClientRect();
const minCount = Math.ceil(scrollerRect.height / this.rowHeight);
// const page = Math.ceil(minCount / this.pageSize);
// const newData = this.loadMore(page * this.pageSize);
// const page = Math.ceil(minCount / this.pageSize);
const newData = this.loadMore(minCount);
this.data.push(...newData);
// 拿到了数据之后就是渲染挂载到指定的contentBox容器上。
this.#renderNewData(newData);
}
//渲染每一行,把它包裹在一个div中,并且这个dom元素也可以设置一些数据还不干扰到renderItem渲染的dom
#renderRow (item) {
const rowContent = this.renderItem(item);
const row = document.createElement('div');
row.dataset.index = item
row.style.height = this.rowHeight + 'px';
row.appendChild(rowContent)
return row;
}
#renderNewData (newData) {
newData.forEach(item => {
this.contentBox.append(this.#renderRow(item));
});
}
#handleScroll = (e) => {
const { clientHeight, scrollHeight, scrollTop } = e.target;
if (scrollHeight - (clientHeight + scrollHeight) < 40) {
// 到底加载更多
const newData = this.loadMore(this.pageSize);
this.data.push(...newData)
}
//记录当前的滚动距离,然后对比上一次保存的距离,知道了向上滚动还是向下滚动
const direction = scrollTop > this.#scrollTop ? 1 : -1
this.#toggleTopItems(direction)
this.#toggleBottomItems(direction)
this.#scrollTop = scrollTop;
}
//替换上面的dom
#toggleTopItems = (direction) => {
const { scrollTop } = this.scroller;
const firstVisibleItemIndex = Math.floor(scrollTop / this.rowHeight);
const firstExistingItemIndex = Math.max(0, firstVisibleItemIndex - this.buffer);
const rows = this.contentBox.children;
// 替换上面不可见的元素
if (direction === 1) {
for (let i = this.#topHiddenCount; i < firstExistingItemIndex; i++) {
if (rows[0]) rows[0].remove();
}
}
// 恢复上面隐藏的元素
if (direction === -1) {
for (let i = this.#topHiddenCount - 1; i >= firstExistingItemIndex; i--) {
const item = this.data[i];
const row = this.#renderRow(item);
this.contentBox.prepend(row);
}
}
this.#topHiddenCount = firstExistingItemIndex;
this.#paddingTop = this.#topHiddenCount * this.rowHeight;
this.contentBox.style.paddingTop = this.#paddingTop + 'px';
}
//替换下面的dom
#toggleBottomItems = (direction) => {
const { scrollTop, clientHeight } = this.scroller;
const lastVisibleItemIndex = Math.floor((scrollTop + clientHeight) / this.rowHeight);
const lastExistingItemIndex = lastVisibleItemIndex + this.buffer;
this.#lastVisibleItemIndex = lastVisibleItemIndex;
const rows = [...this.contentBox.children];
// 替换下面不可见的元素
if (direction === -1) {
for (let i = lastExistingItemIndex + 1; i <= this.data.length; i++) {
const row = rows[i - this.#topHiddenCount];
if (row) row.remove();
}
}
// 恢复下面不可见的元素
if (direction === 1) {
for (let i = this.#topHiddenCount + rows.length; i <= lastExistingItemIndex; i++) {
const item = this.data[i];
if (!item) break;
const row = this.#renderRow(item);
this.contentBox.append(row);
}
}
this.#bottomHiddenCount = Math.max(0, this.data.length - (this.#topHiddenCount + this.contentBox.children.length) - this.buffer);
this.#paddingBottom = this.#bottomHiddenCount * this.rowHeight;
this.contentBox.style.paddingBottom = this.#paddingBottom + 'px';
}
}最主要的函数操作是
this.#toggleTopItems(direction); this.#toggleBottomItems(direction)
这 2 个函数,他们主要就是替换消失在屏幕展示区域中的 dom 元素。是代码的核心。