虚拟列表(Virtual List)是前端解决长列表渲染性能问题 的核心方案,其核心思想是「只渲染可视区域内的列表项,而非全部数据」,可将上万条数据的列表渲染耗时从秒级降至毫秒级。本文从「核心原理→实现步骤→知识点拆解→进阶优化」全维度讲解,附完整可运行代码。
一、虚拟列表核心原理
1. 为什么需要虚拟列表?
普通长列表渲染的问题:
- DOM 节点过多:10000 条数据会生成 10000 个 DOM 节点,导致首次渲染慢、滚动卡顿(重排 / 重绘成本高);
- 内存占用大:大量 DOM 节点占用浏览器内存,易引发页面崩溃。
虚拟列表的解决思路:
- 固定容器高度 :列表外层容器设置固定高度并开启滚动(
overflow: auto); - 计算可视区域:根据容器高度、列表项高度,计算「可视区域能显示的列表项数量」;
- 只渲染可视项:从全量数据中截取可视区域内的部分数据,仅渲染这些数据对应的 DOM;
- 滚动偏移模拟:通过「空白占位容器」模拟列表的整体滚动高度,通过「定位可视项容器」实现滚动时的位置对齐。
2. 核心术语与公式
| 术语 | 说明 | 核心公式 |
|---|---|---|
| 容器可视高度(viewHeight) | 列表外层容器的可见高度(如 500px) | - |
| 列表项高度(itemHeight) | 单个列表项的固定高度(如 50px,简化版虚拟列表假设高度固定) | - |
| 可视项数量(visibleCount) | 可视区域能显示的列表项数量(向上取整避免留白) | visibleCount = Math.ceil(viewHeight / itemHeight) + 2(+2 为缓冲项) |
| 滚动偏移量(scrollTop) | 容器的滚动距离(container.scrollTop) |
- |
| 起始索引(startIndex) | 可视区域第一个列表项的索引 | startIndex = Math.floor(scrollTop / itemHeight) |
| 结束索引(endIndex) | 可视区域最后一个列表项的索引 | endIndex = startIndex + visibleCount |
| 占位高度(totalHeight) | 模拟整个列表的高度(让滚动条显示正确) | totalHeight = 总数据长度 * itemHeight |
| 可视项偏移(offsetTop) | 可视项容器的垂直偏移(让可视项显示在正确位置) | offsetTop = startIndex * itemHeight |
二、基础版虚拟列表实现(固定高度)
1. 完整代码(原生 JS 版)
js
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>基础版虚拟列表</title>
<style>
/* 1. 列表容器:固定高度、开启滚动、相对定位 */
.virtual-list-container {
width: 300px;
height: 500px; /* 可视区域高度 */
border: 1px solid #e6e6e6;
overflow: auto;
position: relative;
}
/* 2. 占位容器:模拟整个列表的高度,让滚动条正常显示 */
.virtual-list-placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
/* 高度由JS动态计算:总数据长度 * 列表项高度 */
}
/* 3. 可视项容器:绝对定位,通过top偏移显示在正确位置 */
.virtual-list-items {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
/* 4. 列表项:固定高度 */
.list-item {
height: 50px; /* 单个列表项高度 */
line-height: 50px;
padding: 0 10px;
border-bottom: 1px solid #f5f5f5;
box-sizing: border-box;
}
</style>
</head>
<body>
<!-- 外层容器,固定高度 + 滚动 + 相对定位(作为子元素的定位参考) -->
<div class="virtual-list-container" id="container">
<!-- 占位容器:模拟总高度 -->
<!-- 占位容器,高度等于「总数据长度 × 列表项高度」,目的是让滚动条的长度和滚动范围与真实长列表一致 -->
<div class="virtual-list-placeholder" id="placeholder"></div>
<!-- 可视项容器:只渲染可视区域的列表项 -->
<!-- 可视项容器,绝对定位,通过top属性偏移到当前滚动位置对应的区域,只渲染可视数据。 -->
<div class="virtual-list-items" id="items"></div>
</div>
<script>
// 1. 模拟海量数据(10万条)
const TOTAL_DATA = Array.from({ length: 100000 }, (_, index) => ({
id: index,
content: `列表项 ${index + 1}`
}));
// 2. 核心配置
const config = {
viewHeight: 500, // 容器可视高度(px)
itemHeight: 50, // 单个列表项高度(px)
buffer: 2 // 缓冲项数量(避免滚动时出现空白)
};
// 3. 获取DOM元素
const container = document.getElementById('container');
const placeholder = document.getElementById('placeholder');
const items = document.getElementById('items');
// 4. 初始化:设置占位容器高度
placeholder.style.height = `${TOTAL_DATA.length * config.itemHeight}px`;
// 5. 核心渲染函数:根据滚动位置渲染可视项
function renderVisibleItems() {
// 5.1 获取滚动偏移量 container.scrollTop 表示容器向上滚动的距离
const scrollTop = container.scrollTop;
// 5.2 计算可视项的起始/结束索引
// 滚动偏移 150px → startIndex = 150/50 = 3(第 4 项);
// endIndex = 3+12 = 15(第 16 项);
const visibleCount = Math.ceil(config.viewHeight / config.itemHeight) + config.buffer;
const startIndex = Math.max(0, Math.floor(scrollTop / config.itemHeight)); // 避免负数
const endIndex = Math.min(TOTAL_DATA.length - 1, startIndex + visibleCount);
// 5.3 截取可视区域的数据
// 从 10 万条数据中截取第 3~15 项(仅 13 条);
const visibleData = TOTAL_DATA.slice(startIndex, endIndex + 1);
console.log("scrollTop:",scrollTop)
console.log("visibleCount:",visibleCount)
console.log("startIndex:",startIndex)
console.log("endIndex:",endIndex)
console.log("======")
// 5.4 计算可视项容器的偏移(让可视项显示在正确位置)
// 设置可视项偏移:items.style.top = 3×50 = 150px,让可视项容器对齐滚动位置
items.style.top = `${startIndex * config.itemHeight}px`;
// 5.5 渲染可视项(innerHTML简化版,实际可优化为DOM复用)
items.innerHTML = visibleData.map(item => `
<div class="list-item" data-id="${item.id}">
${item.content}
</div>
`).join('');
}
// 6. 监听滚动事件:滚动时重新渲染可视项
container.addEventListener('scroll', debounce(renderVisibleItems));
// 7. 初始化渲染
renderVisibleItems();
// 防抖函数 如果在16ms内多次执行滚动,则会重新计算时间
function debounce(fn, delay = 16) {
let timer = null;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
</script>
</body>
</html>
2. 核心逻辑拆解
(1)DOM 结构设计
virtual-list-container:外层容器,固定高度 + 滚动 + 相对定位(作为子元素的定位参考);virtual-list-placeholder:占位容器,高度等于「总数据长度 × 列表项高度」,目的是让滚动条的长度和滚动范围与真实长列表一致;virtual-list-items:可视项容器,绝对定位,通过top属性偏移到当前滚动位置对应的区域,只渲染可视数据。
(2)渲染函数核心步骤
-
获取滚动偏移量 :
container.scrollTop表示容器向上滚动的距离; -
计算可视项数量 :
Math.ceil(500/50)+2 = 12,即可视区域能显示 10 项,额外加 2 项缓冲(避免快速滚动时出现空白); -
计算起始 / 结束索引:
- 滚动偏移 150px →
startIndex = 150/50 = 3(第 4 项); endIndex = 3+12 = 15(第 16 项);
- 滚动偏移 150px →
-
截取可视数据:从 10 万条数据中截取第 3~15 项(仅 13 条);
-
设置可视项偏移 :
items.style.top = 3×50 = 150px,让可视项容器对齐滚动位置; -
渲染可视项:仅渲染 13 项 DOM,而非 10 万项。
三、虚拟列表核心知识点拆解
1. 滚动事件与性能优化
- scroll 事件的特性:滚动时会高频触发(每秒数十次),直接在事件回调中操作 DOM 会导致性能问题;
- 优化方案:防抖(Debounce),减少渲染频率(如每 16ms 渲染一次,对应 60fps):
2. 定位方式与偏移计算
- 绝对定位的作用 :可视项容器(
virtual-list-items)脱离文档流,仅在可视区域渲染,不影响占位容器的高度; - top 偏移的意义 :
startIndex × itemHeight保证可视项容器始终对齐滚动位置,例如滚动到第 100 项时,top = 100×50 = 5000px,可视项容器会定位到 5000px 位置,显示第 100~112 项。
3. 固定高度 vs 动态高度
基础版假设列表项高度固定,实际场景中列表项高度可能动态(如内容换行、不同类型项高度不同),解决方案:
(1)预估高度 + 修正
- 先按「预估高度」计算占位高度和偏移;
- 渲染后获取真实高度(
getBoundingClientRect()); - 记录每个项的真实高度,滚动时用真实高度修正偏移。
(2)核心代码示例(动态高度)
js
// 存储每个项的真实高度(初始为预估高度)
const itemHeights = new Array(TOTAL_DATA.length).fill(config.itemHeight);
function renderVisibleItems() {
const scrollTop = container.scrollTop;
// 计算累计高度(替代固定高度的startIndex计算)
let cumulativeHeight = 0;
let startIndex = 0;
// 找到滚动位置对应的起始索引
for (; startIndex < TOTAL_DATA.length; startIndex++) {
if (cumulativeHeight >= scrollTop) break;
cumulativeHeight += itemHeights[startIndex];
}
// 计算可视项结束索引(累计高度超过可视高度)
let endIndex = startIndex;
let visibleHeight = 0;
while (endIndex < TOTAL_DATA.length && visibleHeight < config.viewHeight + config.buffer * config.itemHeight) {
visibleHeight += itemHeights[endIndex];
endIndex++;
}
// 截取可视数据
const visibleData = TOTAL_DATA.slice(startIndex, endIndex);
// 计算可视项容器的偏移(累计到startIndex的高度)
const offsetTop = cumulativeHeight - itemHeights[startIndex];
items.style.top = `${offsetTop}px`;
// 渲染后修正真实高度
items.innerHTML = visibleData.map((item, idx) => `
<div class="list-item" data-id="${item.id}">${item.content}</div>
`).join('');
// 渲染完成后获取真实高度并更新
Array.from(items.children).forEach((el, idx) => {
const realHeight = el.getBoundingClientRect().height;
const realIndex = startIndex + idx;
itemHeights[realIndex] = realHeight;
});
// 更新占位容器的总高度(动态累加)
const totalHeight = itemHeights.reduce((sum, height) => sum + height, 0);
placeholder.style.height = `${totalHeight}px`;
}
4. 缓冲项(Buffer)的作用
- 问题:快速滚动时,可视项还未渲染完成,会出现短暂空白;
- 解决方案:在可视项前后各加 N 个缓冲项(通常 2~5 项),提前渲染部分数据,覆盖滚动的 "视觉延迟";
- 注意:缓冲项过多会增加 DOM 数量,过少则无法解决空白问题,需根据业务调整(一般 2~5 项)。
5. DOM 复用(进阶优化)
基础版每次渲染都用innerHTML重新生成 DOM,会销毁旧 DOM 并创建新 DOM,存在性能损耗。DOM 复用是更优方案:
- 初始化时创建「可视项数量 + 缓冲项」的空 DOM 节点池;
- 滚动时仅更新节点的内容,而非销毁 / 创建节点;
js
// 初始化DOM池
const domPool = [];
const visibleCount = Math.ceil(config.viewHeight / config.itemHeight) + config.buffer;
// 创建空节点池
for (let i = 0; i < visibleCount; i++) {
const el = document.createElement('div');
el.className = 'list-item';
domPool.push(el);
items.appendChild(el);
}
function renderVisibleItems() {
// ... 计算startIndex/endIndex/visibleData ...
// 复用DOM节点:仅更新内容,不创建新节点
visibleData.forEach((item, idx) => {
const el = domPool[idx];
el.dataset.id = item.id;
el.textContent = item.content;
});
// 隐藏超出可视数据的节点(可选)
for (let i = visibleData.length; i < domPool.length; i++) {
domPool[i].style.display = 'none';
}
}
四、框架版虚拟列表(Vue3 示例)
js
<template>
<div
class="virtual-list-container"
ref="containerRef"
@scroll="handleScroll"
>
<div class="virtual-list-placeholder" :style="{ height: totalHeight + 'px' }"></div>
<div class="virtual-list-items" :style="{ top: offsetTop + 'px' }">
<div
v-for="item in visibleData"
:key="item.id"
class="list-item"
>
{{ item.content }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
// 配置
const config = {
viewHeight: 500,
itemHeight: 50,
buffer: 2
};
// DOM引用
const containerRef = ref(null);
// 响应式数据
const totalData = ref(Array.from({ length: 100000 }, (_, i) => ({ id: i, content: `列表项 ${i+1}` })));
const scrollTop = ref(0);
// 计算属性:总高度
const totalHeight = computed(() => totalData.value.length * config.itemHeight);
// 计算属性:可视项相关
const visibleData = computed(() => {
const visibleCount = Math.ceil(config.viewHeight / config.itemHeight) + config.buffer;
const startIndex = Math.max(0, Math.floor(scrollTop.value / config.itemHeight));
const endIndex = Math.min(totalData.value.length - 1, startIndex + visibleCount);
return totalData.value.slice(startIndex, endIndex + 1);
});
// 计算属性:可视项偏移
const offsetTop = computed(() => {
const startIndex = Math.max(0, Math.floor(scrollTop.value / config.itemHeight));
return startIndex * config.itemHeight;
});
// 滚动事件处理
const handleScroll = () => {
if (containerRef.value) {
scrollTop.value = containerRef.value.scrollTop;
}
};
// 初始化
onMounted(() => {
// 防抖优化(可选)
const debouncedHandleScroll = debounce(handleScroll, 16);
containerRef.value?.addEventListener('scroll', debouncedHandleScroll);
});
// 防抖函数
function debounce(fn, delay = 16) {
let timer = null;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
</script>
<style scoped>
.virtual-list-container {
width: 300px;
height: 500px;
border: 1px solid #e6e6e6;
overflow: auto;
position: relative;
}
.virtual-list-placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.virtual-list-items {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.list-item {
height: 50px;
line-height: 50px;
padding: 0 10px;
border-bottom: 1px solid #f5f5f5;
box-sizing: border-box;
}
</style>
五、常见问题与避坑指南
1. 滚动时出现空白
-
原因:缓冲项不足、滚动防抖延迟过高、动态高度预估偏差;
-
解决方案:
- 增加缓冲项数量(如从 2 增至 5);
- 降低防抖延迟(如 16ms,对应 60fps);
- 动态高度场景下提前渲染更多缓冲项。
2. 滚动条抖动
-
原因:动态高度场景下,真实高度与预估高度偏差导致总高度频繁变化;
-
解决方案:
- 初始化时尽可能精准预估高度;
- 批量更新总高度(而非每次渲染都更新);
- 限制总高度的更新频率。
3. 大数据初始化卡顿
-
原因:初始渲染时计算累计高度遍历全量数据;
-
解决方案:
- 分段初始化(先渲染前 100 项,后续滚动时再计算);
- 使用 Web Worker 计算累计高度(避免阻塞主线程)。
4. 移动端滚动不流畅
-
原因:移动端滚动有惯性,scroll 事件触发更频繁;
-
解决方案:
- 使用
passive: true优化滚动事件(避免浏览器阻塞默认行为): - 开启硬件加速(
transform: translateZ(0)):
- 使用
js
container.addEventListener('scroll', debouncedHandleScroll, { passive: true });
js
.virtual-list-items { transform: translateZ(0); }
六、总结
虚拟列表的核心是「空间换时间」:用「占位容器 + 可视项容器」的 DOM 结构,换取「仅渲染可视区域 DOM」的性能提升。核心知识点可归纳为:
- 核心原理:可视区域计算 + 数据截取 + 偏移定位;
- 性能优化:防抖、DOM 复用、缓冲项、硬件加速;
- 适配场景:固定高度(简单)、动态高度(复杂)、加载更多(业务常用);
- 框架适配:利用响应式数据封装计算属性,简化滚动状态管理。