为什么需要虚拟滚动?
当页面需要渲染 成千上万条数据 时,传统渲染方式会直接生成大量 DOM 节点,导致:
- 页面卡顿:DOM 节点过多,内存占用飙升
- 滚动迟滞:滚动时频繁重排重绘
- 白屏时间长:首次加载需要生成所有节点
虚拟滚动核心原理
只渲染用户看得见的内容,像透过窗户看风景,只展示窗口内的画面。
固定高度场景(简单版)
// 假设每个子项高度50px,容器高度500px
可视区域子项数 = 500 / 50 = 10条
总高度占位 = 总条数 * 50px
滚动时根据 scrollTop 计算起始索引,渲染对应子项
在通过绝对定位将渲染的子项移动到可视区域
高度不固定场景(进阶版)
实现流程图
A[初始化预估高度] --> B[渲染可视区域子项]
B --> C[动态测量真实高度]
C --> D[更新位置信息]
D --> E[调整占位总高度]
E --> F[滚动时重新计算渲染范围]
完整代码实现
class VirtualScroll {
constructor(container, data, itemHeight = 50) {
this.container = container; // 容器元素
this.data = data; // 数据源
this.itemHeight = itemHeight; // 预估高度
this.positions = []; // 位置数组
this.visibleData = []; // 当前可见数据
this.startIndex = 0; // 起始索引
this.endIndex = 0; // 结束索引
this.buffer = 5; // 缓冲区大小
this.init();
}
// 初始化
init() {
// 1. 初始化位置数组
this.initPositions();
// 2. 设置占位容器高度
this.setContainerHeight();
// 3. 渲染初始可见区域
this.renderVisibleData();
// 4. 监听滚动事件
this.bindScrollEvent();
}
// 初始化位置数组
initPositions() {
this.positions = this.data.map((item, index) => ({
index,
top: index * this.itemHeight,
bottom: (index + 1) * this.itemHeight,
height: this.itemHeight,
}));
}
// 设置占位容器高度
setContainerHeight() {
const totalHeight = this.positions[this.positions.length - 1].bottom;
this.container.style.height = `${totalHeight}px`;
}
// 渲染可见区域数据
renderVisibleData() {
// 1. 计算可见区域
const scrollTop = this.container.scrollTop;
const containerHeight = this.container.clientHeight;
this.startIndex = this.findStartIndex(scrollTop);
this.endIndex = this.findStartIndex(scrollTop + containerHeight) + this.buffer;
// 2. 获取可见数据
this.visibleData = this.data.slice(this.startIndex, this.endIndex);
// 3. 渲染子项
this.container.innerHTML = this.visibleData
.map((item, i) => {
const pos = this.positions[this.startIndex + i];
return `
<div class="item" data-index="${pos.index}" style="position: absolute; top: ${pos.top}px; height: ${pos.height}px;">
${item.content}
</div>
`;
})
.join('');
// 4. 动态测量真实高度
this.measureRealHeight();
}
// 动态测量真实高度
measureRealHeight() {
const items = this.container.querySelectorAll('.item');
items.forEach(item => {
const index = +item.dataset.index;
const realHeight = item.getBoundingClientRect().height;
const diff = realHeight - this.positions[index].height;
if (diff !== 0) {
// 更新当前子项高度
this.positions[index].height = realHeight;
this.positions[index].bottom += diff;
// 更新后续子项位置
for (let i = index + 1; i < this.positions.length; i++) {
this.positions[i].top = this.positions[i - 1].bottom;
this.positions[i].bottom += diff;
}
}
});
// 重新设置占位容器高度
this.setContainerHeight();
}
// 查找起始索引(二分查找)
findStartIndex(scrollTop) {
let left = 0,
right = this.positions.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (this.positions[mid].bottom < scrollTop) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return left;
}
// 绑定滚动事件
bindScrollEvent() {
this.container.addEventListener('scroll', () => {
this.renderVisibleData();
});
}
}
// 使用示例
const container = document.getElementById('container');
const data = Array.from({ length: 10000 }, (_, i) => ({ id: i, content: `Item ${i}` }));
new VirtualScroll(container, data);
性能优化技巧
- 懒测量:只测量可视区域及前后缓冲区的子项(如多渲染20%)
- 缓存策略:记录已测量的真实高度,避免重复计算
- 防抖处理:滚动事件添加防抖(100ms以内)
- 虚拟表格优化:横向滚动时复用列渲染
不同场景选型建议
| 场景 | 推荐方案 | 注意点 |
|---|---|---|
| 子项高度固定 | 简单版虚拟滚动 | 无需动态测量 |
| 子项高度变化少 | 预估高度 + 按需测量 | 设置合理的缓冲区域 |
| 子项高度频繁变化 | ResizeObserver 监听 | 注意解除监听防止内存泄漏 |
| 超大数据量(10万+) | 分页加载 + 虚拟滚动 | 结合 Web Worker 处理数据 |
总结
虚拟滚动的本质是 用空间换时间,通过三个核心步骤实现:
- 空间占位:用位置数组记录每个子项的位置信息
- 动态测量:渲染后获取真实高度并修正位置信息
- 精准定位:滚动时快速计算需要渲染的范围