Misaka12648
虚拟长列表的实现

虚拟长列表的实现

虚拟滚动普通版本

虚拟滚动加载原理是什么

原理

虚拟滚动(Virtual Scrolling)是⼀种性能优化的⼿段,通常⽤于处理⻓列表的显⽰问题。

在传统的滚动加载中,当⾯对成千上万项的⻓列表时,直接在 DOM 中创建并展⽰所有项会导致严重的性能问题,

因为浏览器需要渲染所有的列表项。

核⼼原理:是仅渲染⽤⼾可视范围内的列表项,以此减少 DOM 操作的数量和提⾼性能。

实现虚拟滚动,我们需要:

  1. 监听滚动事件,了解当前滚动位置。

  2. 根据滚动位置计算当前应该渲染哪些列表项⽬(即在视⼝内的项⽬)。

  3. 只渲染那些项⽬,并⽤占位符(⽐如⼀个空的 div)占据其它项⽬应有的位置,保持滚动条⼤⼩不

变。

  1. 当用户滚动时,重新计算并渲染新的项⽬。

简单实现⼀个虚拟滚动加载

基础版本实现

以下是⼀个简单的虚拟滚动实现的 JavaScript 代码⽰例:

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
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.scroll-container {
position: relative;
height: 200px;
width: 200px;
background-color: antiquewhite;
overflow: scroll;
}
</style>
</head>

<body>
<div class="scroll-container">
</div>
<script>
class VirtualScroll {
constructor(container, itemHeight, totalItems, renderCallback) {
this.container = container; // 容器元素
this.itemHeight = itemHeight; // 每个项的⾼度
this.totalItems = totalItems; // 总列表项数
this.renderCallback = renderCallback; // 渲染每⼀项的回调函数
this.viewportHeight = container.clientHeight; // 视⼝⾼度
this.bufferSize = Math.ceil(this.viewportHeight / itemHeight) * 3; // 缓冲⼤⼩
this.renderedItems = []; // 已渲染项的数组
this.startIndex = 0; // 当前渲染的开始索引
this.endIndex = this.bufferSize; // 当前渲染的结束索引
container.addEventListener("scroll", () => this.onScroll());
this.update();
}

onScroll() {
const scrollTop = this.container.scrollTop;
console.log(scrollTop, 'scrollTop');
const newStartIndex = Math.floor(scrollTop / this.itemHeight) - Math.ceil(this.bufferSize / 2);
const newEndIndex = newStartIndex + this.bufferSize;
if (newStartIndex !== this.startIndex || newEndIndex !== this.endIndex) {
this.startIndex = Math.max(0, newStartIndex);
this.endIndex = Math.min(this.totalItems, newEndIndex);
this.update();
}
}

update() {
this.container.innerHTML = ""; // 清空已有内容
const totalHeight = this.totalItems * this.itemHeight; // 计算容器的总⾼度
this.container.style.height = `${totalHeight} px`; // 设置容器的总⾼度
// 渲染视⼝内的项
const fragment = document.createDocumentFragment();
for (let i = this.startIndex; i < this.endIndex; i++) {
const item = this.renderCallback(i);
item.style.top = `${i * this.itemHeight}px`;
fragment.appendChild(item);
}
this.container.appendChild(fragment);
}
}

// 创建⼀个列表项的函数
function createItem(index) {
const item = document.createElement("div");
item.className = "list-item";
item.innerText = `Item${index}`;
item.style.position = "absolute";
item.style.width = "100%";
return item;
}

// 初始化虚拟滚动
const container = document.querySelector(".scroll-container");
// 容器元素需要预先在HTML中定义
const virtualScroll = new VirtualScroll(container, 30, 100, createItem);
</script>
</body>

</html>

这个例⼦中,我们创建了⼀个 VirtualScroll 类,通过传⼊容器、项⾼度、总项数和渲染回调函数来进⾏初始化。

该类的 update ⽅法⽤于渲染出当前可视范围内部分的项⽬,并将它们放到⽂档碎⽚中,然后⼀次性添加到容器中。

这样可以避免多次直接操作 DOM,减少性能消耗。

当滚动时,onScroll ⽅法将计算新的 startIndex 和 endIndex ,然后调⽤ update ⽅法进⾏更新。

请注意,实际应⽤可能需要根据具体情况调整缓冲区⼤⼩等参数。

如何计算开始索引startIndex和endIndex

1
2
3
4
5
6
7
//constructor()初始化时候方法
this.startIndex = 0; // 当前渲染的开始索引
this.bufferSize = Math.ceil(this.viewportHeight / itemHeight) * 3; // 缓冲⼤⼩
this.endIndex = this.bufferSize; // 当前渲染的结束索引
//onScroll() 方法计算 startIndex
const newStartIndex = Math.floor(scrollTop / this.itemHeight) - this.bufferSize / 2;
const newEndIndex = newStartIndex + this.bufferSize;

计算步骤分析

1. Math.floor(scrollTop / this.itemHeight)

  • scrollTop 表示容器元素垂直滚动的距离,即用户当前滚动到的位置。
  • this.itemHeight 是每个列表项的高度。
  • Math.floor(scrollTop / this.itemHeight) 计算出当前滚动位置对应的列表项的索引。例如,如果每个列表项高度为 30px,用户滚动了 60px,那么 scrollTop / this.itemHeight 结果为 2,意味着用户滚动到了第 2 个列表项的位置。

2. - this.bufferSize / 2

  • this.bufferSize缓冲大小,它定义了在当前视口上下额外渲染的列表项数量。缓冲的作用是为了在用户滚动时,提前渲染一些即将进入视口的元素,使得滚动过程更加流畅,避免出现空白或闪烁的情况
  • **减去 this.bufferSize / 2 是为了在当前视口上方也渲染一部分列表项,作为预加载的缓冲区域。**例如,如果缓冲大小为 9,那么会在视口上方额外渲染 4 个(9 / 2 向下取整)列表项。

总结

1
const newStartIndex = Math.floor(scrollTop / this.itemHeight) - this.bufferSize / 2;

通过上边代码这样的计算方式,startIndex 不仅考虑了当前视口内的第一个列表项的索引,还考虑了视口上方的缓冲区域,从而确保在用户滚动时,能够平滑地过渡到新的列表项,而不会出现明显的卡顿或空白。

虚拟滚动进阶版本

使⽤ IntersectionObserver 来实现

使⽤ IntersectionObserver 实现虚拟滚动就意味着我们会依赖于浏览器的 API 来观察哪些元素进⼊或离开视⼝(viewport),

⽽⾮直接监听滚动事件。这样我们只需在需要时渲染或回收元素。

以下是⼀个简化版使⽤ IntersectionObserver 来实现虚拟滚动的例⼦:

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
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
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>虚拟长列表</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

#app {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}

.virtual-list-container {
height: 80vh;
overflow-y: auto;
border: 1px solid #eee;
position: relative;
background: #fff;
}

.list-item {
padding: 10px 15px;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
transition: background-color 0.2s;
}

.list-item:hover {
background-color: #f5f5f5;
}

.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #0066ff;
margin-right: 15px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
}

.content {
flex: 1;
}

.title {
font-size: 16px;
color: #333;
margin-bottom: 4px;
}

.description {
font-size: 14px;
color: #666;
}

.placeholder {
height: 70px;
}
</style>
</head>
<body>
<div id="app">
<h2 style="margin-bottom: 20px;">虚拟长列表示例</h2>
<div class="virtual-list-container" id="container"></div>
</div>

<script>
class VirtualScroll {
constructor(container, itemHeight, totalItems, renderItem) {
this.container = container;
this.itemHeight = itemHeight;
this.totalItems = totalItems;
this.renderItem = renderItem;
this.observer = null;
this.init();
}

init() {
// 创建IntersectionObserver
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
const target = entry.target;
const index = parseInt(target.dataset.index);

if (entry.isIntersecting) {
// 当占位元素进入视口时,渲染实际内容
target.innerHTML = this.renderItem(index);
target.classList.remove('placeholder');
} else {
// 当元素离开视口时,恢复为占位元素
target.innerHTML = '';
target.classList.add('placeholder');
}
});
},
{
root: this.container,
rootMargin: '100px 0px',
threshold: 0
}
);

// 创建所有占位元素
for (let i = 0; i < this.totalItems; i++) {
const placeholder = document.createElement('div');
placeholder.className = 'list-item placeholder';
placeholder.dataset.index = i;
this.container.appendChild(placeholder);
this.observer.observe(placeholder);
}
}

destroy() {
if (this.observer) {
this.observer.disconnect();
}
this.container.innerHTML = '';
}
}

// 定义渲染函数
const renderListItem = (index) => {
return `
<div class="avatar">${index}</div>
<div class="content">
<div class="title">项目 ${index + 1}</div>
<div class="description">这是第 ${index + 1} 个项目的详细描述信息</div>
</div>
`;
};

// 初始化虚拟列表
const container = document.getElementById('container');
const itemHeight = 70;
const totalItems = 10000;

new VirtualScroll(container, itemHeight, totalItems, renderListItem);
</script>
</body>
</html>

在这⾥我们创建了⼀个 VirtualScroll 类,构造函数接收容器元素、每个项的⾼度、总项⽬数和

⽤于渲染每个项⽬的函数。我们在初始化⽅法中,为每个项⽬创建了⼀个占位符元素,并且向

IntersectionObserver 注册了这些占位元素。

当⼀个占位元素进⼊到视⼝中时,我们就会渲染对应的项,并且将它替换这个占位符。当⼀个项离开

视⼝,我们⼜会将它替换回原来的占位符并取消它的注册。

这种⽅法的优势包括:

不需要绑定滚动事件,防⽌滚动性能问题。

浏览器会⾃动优化观察者的回调。

不需要⼿动计算当前应该渲染的项⽬,当用户快速滚动时也不会遇到空⽩内容。

虚拟滚动不定高实现

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
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
<!DOCTYPE html>  
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>不定高虚拟长列表</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

#app {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}

.virtual-list-container {
height: 80vh;
overflow-y: auto;
border: 1px solid #eee;
position: relative;
background: #fff;
}

.list-item {
padding: 10px 15px;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
transition: background-color 0.2s;
}

.list-item:hover {
background-color: #f5f5f5;
}

.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #0066ff;
margin-right: 15px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
}

.content {
flex: 1;
}

.title {
font-size: 16px;
color: #333;
margin-bottom: 4px;
}

.description {
font-size: 14px;
color: #666;
}

.placeholder {
min-height: 70px; /* 最小高度,避免内容未渲染时布局塌陷 */
}
</style>
</head>
<body>
<div id="app">
<h2 style="margin-bottom: 20px;">不定高虚拟长列表示例</h2>
<div class="virtual-list-container" id="container"></div>
</div>

<script>
class VirtualScroll {
constructor(container, totalItems, renderItem) {
this.container = container;
this.totalItems = totalItems;
this.renderItem = renderItem;
this.observer = null;
this.init();
}

init() {
// 创建IntersectionObserver
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
const target = entry.target;
const index = parseInt(target.dataset.index);

if (entry.isIntersecting) {
// 当占位元素进入视口时,渲染实际内容
const content = this.renderItem(index);
target.innerHTML = content;
target.classList.remove('placeholder');

// 动态调整占位元素的高度
const height = target.offsetHeight;
target.style.height = `${height}px`;
} else {
// 当元素离开视口时,恢复为占位元素
target.innerHTML = '';
target.classList.add('placeholder');
target.style.height = 'auto'; // 恢复为自适应高度
}
});
},
{
root: this.container,
rootMargin: '100px 0px',
threshold: 0
}
);

// 创建所有占位元素
for (let i = 0; i < this.totalItems; i++) {
const placeholder = document.createElement('div');
placeholder.className = 'list-item placeholder';
placeholder.dataset.index = i;
this.container.appendChild(placeholder);
this.observer.observe(placeholder);
}
}

destroy() {
if (this.observer) {
this.observer.disconnect();
}
this.container.innerHTML = '';
}
}

// 定义渲染函数
const renderListItem = (index) => {
const randomLines = Math.floor(Math.random() * 5) + 1; // 随机行数
const descriptionText = Array(randomLines).fill(`这是第 ${index + 1} 个项目的详细描述信息。`).join('<br>');
return `
<div class="avatar">${index}</div>
<div class="content">
<div class="title">项目 ${index + 1}</div>
<div class="description">${descriptionText}</div>
</div>
`;
};

// 初始化虚拟列表
const container = document.getElementById('container');
const totalItems = 10000;

new VirtualScroll(container, totalItems, renderListItem);
</script>
</body>
</html>

改进点说明:

1、完全动态高度:

offsetHeight是DOM元素的一个只读属性,返回元素的像素高度,包括元素的内容、内边距(padding)和边框(border),但不包括外边距(margin)、::before或::after等伪元素。这个属性返回的是一个整数,表示元素的实际渲染高度,常用于获取元素的真实高度以进行布局计算或动态调整。

  • 每个项目的高度由实际内容决定,通过 offsetHeight 动态计算。
  • 当项目离开视口时,高度恢复为 auto,确保下次进入视口时重新计算高度。

2、随机内容:

  • renderListItem 函数生成随机行数的描述文本,模拟不定高度的项目。

3、占位元素:

  • 占位元素的最小高度设置为 70px,避免内容未渲染时布局塌陷。
  • 当项目进入视口时,占位元素的高度被动态调整为实际内容的高度。

4、性能优化:

  • 使用 IntersectionObserver 监听项目的可见性,避免频繁的滚动监听。
  • 仅渲染可见区域的项目,减少 DOM 操作。

虚拟滚动不定高实现出现上下白屏

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
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
<!DOCTYPE html>  
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>不定高虚拟长列表</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

#app {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}

.virtual-list-container {
height: 80vh;
overflow-y: auto;
border: 1px solid #eee;
position: relative;
background: #fff;
}

.list-item {
padding: 10px 15px;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
transition: background-color 0.2s;
}

.list-item:hover {
background-color: #f5f5f5;
}

.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #0066ff;
margin-right: 15px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
}

.content {
flex: 1;
}

.title {
font-size: 16px;
color: #333;
margin-bottom: 4px;
}

.description {
font-size: 14px;
color: #666;
}

.placeholder {
min-height: 70px; /* 最小高度,避免内容未渲染时布局塌陷 */
}
</style>
</head>
<body>
<div id="app">
<h2 style="margin-bottom: 20px;">不定高虚拟长列表示例</h2>
<div class="virtual-list-container" id="container"></div>
</div>

<script>
class VirtualScroll {
constructor(container, totalItems, renderItem) {
this.container = container;
this.totalItems = totalItems;
this.renderItem = renderItem;
this.observer = null;
this.init();
}

init() {
// 创建IntersectionObserver,扩大监听范围
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
const target = entry.target;
const index = parseInt(target.dataset.index);

if (entry.isIntersecting) {
// 当占位元素进入视口时,渲染实际内容
const content = this.renderItem(index);
target.innerHTML = content;
target.classList.remove('placeholder');

// 动态调整占位元素的高度
const height = target.offsetHeight;
target.style.height = `${height}px`;
} else {
// 当元素离开视口时,恢复为占位元素
target.innerHTML = '';
target.classList.add('placeholder');
target.style.height = 'auto'; // 恢复为自适应高度
}
});
},
{
root: this.container,
rootMargin: '200px 0px', // 扩大监听范围,预渲染更多内容
threshold: 0
}
);

// 使用文档片段批量插入占位元素
const fragment = document.createDocumentFragment();
for (let i = 0; i < this.totalItems; i++) {
const placeholder = document.createElement('div');
placeholder.className = 'list-item placeholder';
placeholder.dataset.index = i;
fragment.appendChild(placeholder);
}
this.container.appendChild(fragment);

// 监听所有占位元素
const placeholders = this.container.querySelectorAll('.placeholder');
placeholders.forEach(placeholder => this.observer.observe(placeholder));
}

destroy() {
if (this.observer) {
this.observer.disconnect();
}
this.container.innerHTML = '';
}
}

// 定义渲染函数
const renderListItem = (index) => {
const randomLines = Math.floor(Math.random() * 5) + 1; // 随机行数
const descriptionText = Array(randomLines).fill(`这是第 ${index + 1} 个项目的详细描述信息。`).join('<br>');
return `
<div class="avatar">${index}</div>
<div class="content">
<div class="title">项目 ${index + 1}</div>
<div class="description">${descriptionText}</div>
</div>
`;
};

// 初始化虚拟列表
const container = document.getElementById('container');
const totalItems = 10000;

new VirtualScroll(container, totalItems, renderListItem);
</script>
</body>
</html>

优化点说明:

扩大监听范围:

  • 通过设置 rootMargin: '200px 0px',提前渲染上下各 200px 的内容,避免快速滚动时出现白屏。

批量插入 DOM:

  • 使用 DocumentFragment 批量插入占位元素,减少频繁的 DOM 操作。

高度计算优化:

  • 在渲染内容后,立即计算并设置占位元素的高度,确保高度准确。

性能优化:

  • 减少不必要的 DOM 操作,确保滚动流畅。

通过以上优化,白屏问题应该会得到显著改善。如果仍有问题,可以进一步调整 rootMargin 或使用 ResizeObserver 动态监听高度变化。

虚拟滚动最终优化

为了进一步优化不定高度的虚拟长列表,我们可以结合以下策略来提升性能和用户体验:

1. 使用 ResizeObserver 动态监听高度变化

  • 当项目内容高度发生变化时,动态调整占位元素的高度,避免布局错乱。

2. 增加缓存机制

  • 缓存已经渲染过的项目内容,避免重复渲染。

3. 分帧渲染

  • 使用 requestAnimationFrame 分帧渲染内容,避免一次性渲染过多内容导致卡顿。

4. 优化占位元素

  • 使用 transform: translateY 动态调整占位元素的位置,减少布局计算。
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
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
<!DOCTYPE html>
<html lang="zh-CN">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>不定高虚拟长列表</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

#app {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}

.virtual-list-container {
height: 80vh;
overflow-y: auto;
border: 1px solid #eee;
position: relative;
background: #fff;
}

.list-item {
padding: 10px 15px;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
transition: background-color 0.2s;
}

.list-item:hover {
background-color: #f5f5f5;
}

.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #0066ff;
margin-right: 15px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
}

.content {
flex: 1;
}

.title {
font-size: 16px;
color: #333;
margin-bottom: 4px;
}

.description {
font-size: 14px;
color: #666;
}

.placeholder {
min-height: 70px;
/* 最小高度,避免内容未渲染时布局塌陷 */
}
</style>
</head>

<body>
<div id="app">
<h2 style="margin-bottom: 20px;">不定高虚拟长列表示例</h2>
<div class="virtual-list-container" id="container"></div>
</div>

<script>
class VirtualScroll {
constructor(container, totalItems, renderItem) {
this.container = container;
this.totalItems = totalItems;
this.renderItem = renderItem;
this.observer = null;
this.resizeObserver = null;
this.cache = new Map(); // 缓存已渲染的内容
this.init();
}

init() {
// 创建IntersectionObserver,扩大监听范围
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
const target = entry.target;
const index = parseInt(target.dataset.index);

if (entry.isIntersecting) {
// 当占位元素进入视口时,渲染实际内容
this.renderContent(target, index);
} else {
// 当元素离开视口时,恢复为占位元素
this.clearContent(target);
}
});
},
{
root: this.container,
rootMargin: '800px 0px', // 扩大监听范围,预渲染更多内容
threshold: 0
}
);

// 创建ResizeObserver,监听高度变化
this.resizeObserver = new ResizeObserver((entries) => {
entries.forEach(entry => {
const target = entry.target;
const index = parseInt(target.dataset.index);
const height = target.offsetHeight;
target.style.height = `${height}px`;
});
});

// 使用文档片段批量插入占位元素
const fragment = document.createDocumentFragment();
for (let i = 0; i < this.totalItems; i++) {
const placeholder = document.createElement('div');
placeholder.className = 'list-item placeholder';
placeholder.dataset.index = i;
fragment.appendChild(placeholder);
}
this.container.appendChild(fragment);

// 监听所有占位元素
const placeholders = this.container.querySelectorAll('.placeholder');
placeholders.forEach(placeholder => this.observer.observe(placeholder));
}

renderContent(target, index) {
if (this.cache.has(index)) {
// 如果缓存中有内容,直接使用缓存
target.innerHTML = this.cache.get(index);
target.classList.remove('placeholder');
this.resizeObserver.observe(target); // 监听高度变化
return;
}

// 使用 requestAnimationFrame 分帧渲染
requestAnimationFrame(() => {
const content = this.renderItem(index);
target.innerHTML = content;
target.classList.remove('placeholder');
this.cache.set(index, content); // 缓存内容
this.resizeObserver.observe(target); // 监听高度变化
});
}

clearContent(target) {
target.innerHTML = '';
target.classList.add('placeholder');
target.style.height = 'auto'; // 恢复为自适应高度
this.resizeObserver.unobserve(target); // 停止监听高度变化
}

destroy() {
if (this.observer) {
this.observer.disconnect();
}
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
this.container.innerHTML = '';
this.cache.clear();
}
}

// 定义渲染函数
const renderListItem = (index) => {
const randomLines = Math.floor(Math.random() * 5) + 1; // 随机行数
const descriptionText = Array(randomLines).fill(`这是第 ${index + 1} 个项目的详细描述信息。`).join('<br>');
return `
<div class="avatar">${index}</div>
<div class="content">
<div class="title">项目 ${index + 1}</div>
<div class="description">${descriptionText}</div>
</div>
`;
};

// 初始化虚拟列表
const container = document.getElementById('container');
const totalItems = 10000;

new VirtualScroll(container, totalItems, renderListItem);
</script>
</body>

</html>

优化点说明:

  1. IntersectionObserver
    • 监听占位元素的可见性,扩大监听范围(rootMargin: '200px 0px'),提前渲染内容,避免白屏。
  2. ResizeObserver
    • 动态监听项目内容的高度变化,确保占位元素的高度与实际内容一致。
  3. 缓存机制
    • 使用 Map 缓存已渲染的内容,避免重复渲染。
  4. 分帧渲染
    • 使用 requestAnimationFrame

参考文档:渲染十万条数据解决方案 | 五年前端三年面试

本文作者:Misaka12648
本文链接:https://misaka12648.xyz/2025/03/29/虚拟长列表的实现/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可