一、虚拟滚动原理

虚拟滚动(Virtual Scrolling)是一种用于优化长列表性能的技术,其核心思想是:只渲染可视区域内的列表项,当用户滚动时动态更新列表项的内容。这种技术可以显著减少 DOM 节点数量,提高页面性能。

1. 基本概念

  1. 可视区域:用户当前能看到的列表区域
  2. 缓冲区:可视区域外预渲染的额外项
  3. 虚拟列表:实际渲染在 DOM 中的列表项
  4. 偏移量:用于模拟真实滚动位置的空白区域

二、基础实现

1. 简单虚拟滚动

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
class VirtualScroll {
constructor(options) {
this.containerHeight = options.height; // 容器高度
this.itemHeight = options.itemHeight; // 每项高度
this.totalCount = options.totalCount; // 总数据量
this.bufferSize = options.bufferSize || 5; // 缓冲区大小

this.visibleCount = Math.ceil(this.containerHeight / this.itemHeight); // 可视区域项数
this.startIndex = 0; // 起始索引
this.endIndex = this.startIndex + this.visibleCount + this.bufferSize; // 结束索引

this.init();
}

init() {
this.container = document.createElement('div');
this.container.style.height = `${this.containerHeight}px`;
this.container.style.overflow = 'auto';

this.content = document.createElement('div');
this.content.style.position = 'relative';
this.content.style.height = `${this.totalCount * this.itemHeight}px`;

this.container.appendChild(this.content);
this.bindEvents();
}

bindEvents() {
this.container.addEventListener('scroll', this.onScroll.bind(this));
}

onScroll(e) {
const scrollTop = e.target.scrollTop;
this.updateVisibleRange(scrollTop);
}

updateVisibleRange(scrollTop) {
this.startIndex = Math.floor(scrollTop / this.itemHeight);
this.endIndex = this.startIndex + this.visibleCount + this.bufferSize;

this.render();
}

render() {
// 清空现有内容
this.content.innerHTML = '';

// 渲染可见区域的项
for (let i = this.startIndex; i < this.endIndex; i++) {
if (i >= this.totalCount) break;

const item = document.createElement('div');
item.style.position = 'absolute';
item.style.top = `${i * this.itemHeight}px`;
item.style.height = `${this.itemHeight}px`;
item.textContent = `Item ${i}`;

this.content.appendChild(item);
}
}
}

// 使用示例
const virtualScroll = new VirtualScroll({
height: 400,
itemHeight: 50,
totalCount: 10000,
bufferSize: 5
});

document.body.appendChild(virtualScroll.container);

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
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
class DynamicVirtualScroll {
constructor(options) {
this.containerHeight = options.height;
this.estimatedItemHeight = options.estimatedItemHeight;
this.totalCount = options.totalCount;

this.positions = []; // 存储每项的位置信息
this.cachedHeights = new Map(); // 缓存实际高度

this.init();
}

init() {
// 初始化位置信息
this.initPositions();

this.container = document.createElement('div');
this.container.style.height = `${this.containerHeight}px`;
this.container.style.overflow = 'auto';

this.content = document.createElement('div');
this.content.style.position = 'relative';

this.container.appendChild(this.content);
this.bindEvents();
}

initPositions() {
this.positions = new Array(this.totalCount);
let accumHeight = 0;

for (let i = 0; i < this.totalCount; i++) {
this.positions[i] = {
index: i,
top: accumHeight,
bottom: accumHeight + this.estimatedItemHeight,
height: this.estimatedItemHeight
};
accumHeight += this.estimatedItemHeight;
}

this.content.style.height = `${accumHeight}px`;
}

updateItemHeight(index, height) {
const oldHeight = this.positions[index].height;
const dHeight = height - oldHeight;

this.positions[index].bottom = this.positions[index].top + height;
this.positions[index].height = height;
this.cachedHeights.set(index, height);

// 更新后续项的位置
for (let i = index + 1; i < this.totalCount; i++) {
this.positions[i].top += dHeight;
this.positions[i].bottom += dHeight;
}

// 更新总高度
this.content.style.height = `${this.positions[this.totalCount - 1].bottom}px`;
}

findStartIndex(scrollTop) {
return this.binarySearch(scrollTop);
}

binarySearch(scrollTop) {
let start = 0;
let end = this.totalCount - 1;

while (start <= end) {
const mid = Math.floor((start + end) / 2);
const midVal = this.positions[mid];

if (midVal.bottom >= scrollTop && midVal.top <= scrollTop) {
return mid;
} else if (midVal.bottom < scrollTop) {
start = mid + 1;
} else {
end = mid - 1;
}
}

return 0;
}

render(scrollTop) {
const startIndex = this.findStartIndex(scrollTop);
const endIndex = this.findEndIndex(startIndex, scrollTop);

// 清空现有内容
this.content.innerHTML = '';

// 渲染可见项
for (let i = startIndex; i <= endIndex; i++) {
const item = this.createItem(i);
this.content.appendChild(item);
}
}

createItem(index) {
const item = document.createElement('div');
item.style.position = 'absolute';
item.style.top = `${this.positions[index].top}px`;
item.style.width = '100%';

// 监听项高度变化
const observer = new ResizeObserver(entries => {
const height = entries[0].contentRect.height;
if (height !== this.positions[index].height) {
this.updateItemHeight(index, height);
}
});

observer.observe(item);
return item;
}
}

三、性能优化

1. 防抖与节流

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
class OptimizedVirtualScroll extends VirtualScroll {
constructor(options) {
super(options);
this.scrollTimer = null;
}

// 节流处理滚动事件
onScroll(e) {
if (this.scrollTimer) return;

this.scrollTimer = setTimeout(() => {
const scrollTop = e.target.scrollTop;
this.updateVisibleRange(scrollTop);
this.scrollTimer = null;
}, 16); // 约60fps
}

// 使用 RAF 优化渲染
render() {
requestAnimationFrame(() => {
// 渲染逻辑
super.render();
});
}
}

2. 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
class RecycleVirtualScroll extends VirtualScroll {
constructor(options) {
super(options);
this.itemPool = []; // DOM 节点池
}

getItem() {
// 优先从节点池获取
if (this.itemPool.length > 0) {
return this.itemPool.pop();
}

// 创建新节点
const item = document.createElement('div');
item.className = 'virtual-item';
return item;
}

recycleItem(item) {
// 回收节点到节点池
this.itemPool.push(item);
}

render() {
const oldItems = Array.from(this.content.children);
const newItems = [];

// 渲染新的可见项
for (let i = this.startIndex; i < this.endIndex; i++) {
if (i >= this.totalCount) break;

const item = this.getItem();
item.style.transform = `translateY(${i * this.itemHeight}px)`;
item.textContent = `Item ${i}`;
newItems.push(item);
}

// 回收不可见的项
oldItems.forEach(item => {
this.recycleItem(item);
});

// 更新 DOM
this.content.innerHTML = '';
newItems.forEach(item => {
this.content.appendChild(item);
});
}
}

四、进阶特性

1. 无限滚动

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
class InfiniteVirtualScroll extends VirtualScroll {
constructor(options) {
super(options);
this.loading = false;
this.hasMore = true;
}

async onScroll(e) {
super.onScroll(e);

// 检查是否需要加载更多
const scrollTop = e.target.scrollTop;
const scrollHeight = e.target.scrollHeight;
const clientHeight = e.target.clientHeight;

if (scrollHeight - scrollTop - clientHeight < 100) {
await this.loadMore();
}
}

async loadMore() {
if (this.loading || !this.hasMore) return;

this.loading = true;
try {
const newItems = await this.fetchMoreData();
this.totalCount += newItems.length;
this.updateContent();
} finally {
this.loading = false;
}
}
}

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
class GroupVirtualScroll {
constructor(options) {
this.groups = options.groups;
this.groupHeaderHeight = options.groupHeaderHeight;
this.itemHeight = options.itemHeight;

this.initGroupPositions();
}

initGroupPositions() {
let accumHeight = 0;
this.groupPositions = this.groups.map(group => {
const groupHeight = this.groupHeaderHeight + group.items.length * this.itemHeight;
const position = {
top: accumHeight,
height: groupHeight,
bottom: accumHeight + groupHeight
};
accumHeight += groupHeight;
return position;
});
}

findVisibleGroups(scrollTop, viewportHeight) {
const visibleGroups = [];
const viewportBottom = scrollTop + viewportHeight;

for (let i = 0; i < this.groupPositions.length; i++) {
const group = this.groupPositions[i];
if (group.bottom > scrollTop && group.top < viewportBottom) {
visibleGroups.push(i);
}
}

return visibleGroups;
}
}

五、最佳实践

  1. 合理设置缓冲区大小
  2. 优化滚动事件处理
  3. 使用 transform 代替 top
  4. 实现 DOM 节点复用
  5. 优化重排重绘

参考文献