虚拟列表实现
实现思路
- 渲染可视区域的数据:根据滚动位置计算出可见的起始索引和结束索引;
- 总高度占位:整个容器高度与真实数据等高,让滚动条正常工作;
- 滚动定位:通过一个"内层偏移容器"将可视区域的内容垂直偏移到正确位置;
- 动态渲染:滚动时实时计算需要渲染的数据子集。
图示概念
js
┌────────────────────┐
│ scroll container │ ← 固定高度、滚动容器
│ ┌────────────────┐ │
│ │ phantom │ │ ← 实际总高度,占位用
│ │ ┌────────────┐ │ │
│ │ │ visible │ │ │ ← 只渲染可视区域
│ │ └────────────┘ │ │
│ └────────────────┘ │
└────────────────────┘
关键计算参数
- 容器高度:可视区域的高度
- 项目高度:每个列表项的高度(固定或动态)
- 滚动位置:当前滚动条的位置
- 缓冲区:预渲染的额外项目数(防止滚动时空白)
实现代码
原生JavaScript
html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<title>虚拟列表 - 原生 JS</title>
<style>
#container {
height: 300px; /* 设置固定高度,超出部分滚动 */
overflow-y: auto;
position: relative;
border: 1px solid #ccc;
}
#phantom {
height: 0; /* 最终高度将被 JS 设置 */
}
#visible {
position: absolute;
top: 0;
left: 0;
right: 0;
}
</style>
</head>
<body>
<div id="container">
<div id="phantom"></div> <!-- 占位用容器撑起滚动条 -->
<div id="visible"></div> <!-- 实际渲染可视区域数据 -->
</div>
<script>
const container = document.getElementById('container'); // 获取滚动容器
const phantom = document.getElementById('phantom'); // 占位容器
const visible = document.getElementById('visible'); // 渲染内容的容器
const itemHeight = 30; // 每一项的固定高度
const total = 10000; // 总数据条数
const visibleCount = Math.ceil(container.clientHeight / itemHeight); // 可视区域最多渲染多少项
const data = Array.from({ length: total }, (_, i) => `Item ${i + 1}`); // 生成 1~10000 的数据项
phantom.style.height = `${total * itemHeight}px`; // 设置占位高度:总条数 × 每项高度
function render(startIndex) {
// 取出可视区域需要渲染的子集数据
const visibleData = data.slice(startIndex, startIndex + visibleCount);
// 生成 HTML,每项设置高度
visible.innerHTML = visibleData.map(item =>
`<div style="height:${itemHeight}px; border-bottom:1px solid #eee; padding-left: 8px;">${item}</div>`
).join('');
// 设置渲染容器向下偏移位置
visible.style.transform = `translateY(${startIndex * itemHeight}px)`;
}
// 初始化:从第 0 项开始渲染
render(0);
// 监听滚动事件
container.addEventListener('scroll', () => {
// 计算滚动位置对应的起始索引
const start = Math.floor(container.scrollTop / itemHeight);
render(start);
});
</script>
</body>
</html>
思路解释
- #container 设置固定高度和滚动条,是整个滚动容器
- #phantom 高度撑起整个列表滚动高度,不显示内容,只为撑起滚动条
- #visible 实际显示数据的容器,显示部分 DOM 项
- 根据当前滚动起点
startIndex
,通过slice(start, end)
截取当前需要展示的数据。 - 设置
transform: translateY
偏移,将渲染区域移动到应该出现的位置。 container.scrollTop
是滚动条卷去的高度,除以itemHeight
就得到当前在第几项开头。然后重新渲染当前窗口中要显示的内容。
Vue3
vue
<template>
<div class="viewport" @scroll="handleScroll" ref="viewport">
<!-- 占位元素 -->
<div class="phantom" :style="{ height: totalHeight + 'px' }"></div>
<!-- 实际渲染内容 -->
<div class="content" :style="{ transform: `translateY(${offset}px)` }">
<div
v-for="item in visibleData"
:key="item.id"
class="item"
:style="{ height: itemHeight + 'px' }"
>
{{ item.text }}
</div>
</div>
</div>
</template>
<script>
export default {
props: {
data: { type: Array, required: true }, // 列表数据
itemHeight: { type: Number, default: 50 }, // 每项高度
buffer: { type: Number, default: 5 } // 缓冲区大小
},
data() {
return {
startIndex: 0, // 起始索引
endIndex: 0, // 结束索引
scrollTop: 0 // 滚动位置
};
},
computed: {
// 列表总高度
totalHeight() {
return this.data.length * this.itemHeight;
},
// 可见区域数据
visibleData() {
return this.data.slice(this.startIndex, this.endIndex + 1);
},
// 内容偏移量
offset() {
return this.startIndex * this.itemHeight;
},
// 可见项目数
visibleCount() {
return Math.ceil(this.$refs.viewport?.clientHeight / this.itemHeight) || 0;
}
},
mounted() {
this.updateRange();
},
methods: {
// 更新可见范围
updateRange() {
this.startIndex = Math.floor(this.scrollTop / this.itemHeight);
this.endIndex = this.startIndex + this.visibleCount + this.buffer;
this.endIndex = Math.min(this.endIndex, this.data.length - 1);
},
// 滚动事件处理
handleScroll() {
this.scrollTop = this.$refs.viewport.scrollTop;
this.updateRange();
}
}
};
</script>
<style>
.viewport {
height: 100%;
overflow: auto;
position: relative;
}
.phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
}
.content {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.item {
position: absolute;
width: 100%;
box-sizing: border-box;
}
</style>
思路解释
.viewport
是滚动容器,监听scroll
事件。ref="viewport"
用于访问 DOM 获取scrollTop
、clientHeight
。.phantom
是"假元素",用于撑出滚动条总高度(类似原生实现中的phantom
).content
是"真实内容区",通过transform: translateY()
将其移动到正确显示区域。buffer
:让上下各多渲染几项,避免滚动过快时出现"白屏"现象。.content
的translateY
偏移值,让渲染内容出现在正确滚动位置。- 滚动事件触发时,更新
scrollTop
,并重新计算显示范围。
React
js
import React, { useState, useEffect, useRef } from 'react';
/**
* 虚拟列表组件
* @param {Object} props
* @param {Array} props.data 列表数据
* @param {number} props.itemHeight 列表项高度
* @param {number} props.buffer 缓冲区大小
* @param {number} props.height 容器高度
*/
function VirtualList({ data, itemHeight = 50, buffer = 5, height = 400 }) {
const [startIndex, setStartIndex] = useState(0); // 起始索引
const [endIndex, setEndIndex] = useState(0); // 结束索引
const [scrollTop, setScrollTop] = useState(0); // 滚动位置
const viewportRef = useRef(null); // 容器ref
// 列表总高度
const totalHeight = data.length * itemHeight;
// 可见项目数
const visibleCount = Math.ceil(height / itemHeight);
// 更新可见范围
const updateRange = () => {
const newStart = Math.floor(scrollTop / itemHeight);
const newEnd = newStart + visibleCount + buffer;
setStartIndex(newStart);
setEndIndex(Math.min(newEnd, data.length - 1));
};
// 处理滚动事件
const handleScroll = (e) => {
setScrollTop(e.target.scrollTop);
};
// 滚动位置变化时更新范围
useEffect(() => {
updateRange();
}, [scrollTop]);
// 初始计算可见范围
useEffect(() => {
updateRange();
}, []);
// 可见数据
const visibleData = data.slice(startIndex, endIndex + 1);
return (
<div
ref={viewportRef}
style={{
height: `${height}px`,
overflow: 'auto',
position: 'relative'
}}
onScroll={handleScroll}
>
{/* 占位元素 */}
<div style={{ height: `${totalHeight}px` }} />
{/* 实际内容 */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${startIndex * itemHeight}px)`
}}
>
{visibleData.map((item) => (
<div
key={item.id}
style={{
height: `${itemHeight}px`,
position: 'absolute',
top: `${item.id * itemHeight}px`,
width: '100%'
}}
>
{item.text}
</div>
))}
</div>
</div>
);
}
// 使用示例
const data = Array.from({length: 10000}, (_, i) => ({id: i, text: `Item ${i}`}));
function App() {
return (
<VirtualList
data={data}
itemHeight={50}
height={500}
/>
);
}
思路解释
- 设置容器固定高度,开启垂直滚动。
- 监听滚动事件
onScroll
,通过ref
获取 DOM 元素。 - 占位元素高度 = 总项数 × 每项高度。
- 用于让容器产生滚动条,但并不显示真实内容。
.content
内容容器被平移(transform)到底部;startIndex * itemHeight
就是当前要显示的首项的位置;- 只渲染
visibleData = data.slice(startIndex, endIndex + 1)
这部分;