虚拟列表完全指南:从零到一手写实现
前言
在现代前端开发中,处理大量数据展示是一个常见的性能挑战。当需要渲染成千上万条数据时,传统的全量渲染会导致页面卡顿甚至崩溃。虚拟列表(Virtual List)技术应运而生,它是解决大数据量渲染性能问题的核心方案。
本文将带你从零开始理解虚拟列表的原理,并手写一个完整的实现。
什么是虚拟列表?
问题场景
假设你需要渲染 10 万条用户数据:
javascript
// 传统做法 - 性能灾难
const users = new Array(100000)
.fill(0)
.map((_, i) => ({ id: i, name: `用户${i}` }));
return (
<div>
{users.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
这样做会创建 10 万个 DOM 节点,浏览器直接卡死。
虚拟列表的解决方案
核心思想:用户屏幕只能看到有限的条目(比如 20 条),我们只需要渲染这 20 条,其他的用空白撑开滚动条即可。
scss
可见区域示意图:
┌─────────────────┐
│ 项目1 (渲染) │ ← 用户能看到
│ 项目2 (渲染) │ ← 用户能看到
│ 项目3 (渲染) │ ← 用户能看到
├─────────────────┤
│ 项目4 (不渲染) │ ← 用空白代替
│ 项目5 (不渲染) │
│ ... │
│ 项目100000 │
└─────────────────┘
核心原理详解
1. 关键计算公式
虚拟列表的核心是 4 个计算公式:
javascript
// 1. 用户滚动了多少距离?
const scrollTop = 容器.scrollTop;
// 2. 第一个可见项目的索引
const startIndex = Math.floor(scrollTop / itemHeight);
// 3. 最后一个可见项目的索引
const endIndex = startIndex + Math.ceil(containerHeight / itemHeight);
// 4. 可见区域的偏移量
const offsetY = startIndex * itemHeight;
2. 实际计算示例
假设:
- 容器高度:300px
- 每项高度:50px
- 用户滚动了:250px
计算过程:
javascript
scrollTop = 250;
itemHeight = 50;
containerHeight = 300;
// 第一个可见项索引:250 ÷ 50 = 5
startIndex = Math.floor(250 / 50) = 5;
// 最后一个可见项索引:5 + (300 ÷ 50) = 5 + 6 = 11
endIndex = 5 + Math.ceil(300 / 50) = 11;
// 可见区域偏移:5 × 50 = 250px
offsetY = 5 * 50 = 250;
结果:渲染第 5-11 项,并将它们向下偏移 250px。
DOM 结构设计
虚拟列表需要 3 层 DOM 结构:
html
<!-- 第1层:外层滚动容器 -->
<div class="scroll-container" style="height: 300px; overflow-y: auto;">
<!-- 第2层:撑开滚动条的容器 -->
<div class="total-container" style="height: 5000000px; position: relative;">
<!-- 第3层:可见项目容器 -->
<div
class="visible-container"
style="position: absolute; transform: translateY(250px);"
>
<div>项目5</div>
<div>项目6</div>
<div>项目7</div>
<!-- ... -->
</div>
</div>
</div>
要写成通用组件的话就把这里的常数换成通过参数计算的变量就行 各层作用:
- 第 1 层:提供滚动能力,监听滚动事件
- 第 2 层:撑开滚动条,高度 = 总数据量 × 每项高度
- 第 3 层:实际渲染可见项目,通过 transform 定位
手写实现步骤
第 1 步:搭建基础框架
javascript
import { useRef, useState, useCallback } from "react";
const VirtualList = ({
data, // 数据数组
height, // 容器高度
itemHeight, // 每项高度
renderItem, // 渲染函数
}) => {
const containerRef = useRef(null);
const [scrollTop, setScrollTop] = useState(0);
return (
<div
ref={containerRef}
style={{
height,
overflowY: "auto",
position: "relative",
}}
>
{/* 内容待实现 */}
</div>
);
};
第 2 步:添加滚动监听
javascript
const onScroll = useCallback(() => {
if (containerRef.current) {
setScrollTop(containerRef.current.scrollTop);
}
}, []);
// 在div上添加onScroll事件
<div
ref={containerRef}
onScroll={onScroll}
style={{...}}
>
第 3 步:核心计算逻辑
javascript
// 计算总高度
const totalHeight = data.length * itemHeight;
// 计算可见区域的起始和结束索引
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(
startIndex + Math.ceil(height / itemHeight),
data.length - 1
);
// 计算偏移量
const offsetY = startIndex * itemHeight;
第 4 步:构建可见项目数组
javascript
// 生成可见项目列表
const visibleItems = [];
for (let i = startIndex; i <= endIndex; i++) {
visibleItems.push({
index: i,
data: data[i],
});
}
第 5 步:完成 DOM 渲染
javascript
return (
<div
ref={containerRef}
onScroll={onScroll}
style={{
height,
overflowY: "auto",
position: "relative",
}}
>
{/* 撑开滚动条的容器 */}
<div style={{ height: totalHeight, position: "relative" }}>
{/* 可见项目容器 */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
transform: `translateY(${offsetY}px)`,
}}
>
{/* 渲染可见项目 */}
{visibleItems.map(({ index, data: item }) => (
<div
key={index}
style={{
height: itemHeight,
overflow: "hidden",
}}
>
{renderItem(item, index)}
</div>
))}
</div>
</div>
</div>
);
完整代码实现
javascript
import { useRef, useState, useCallback } from "react";
const VirtualList = ({
data,
height,
itemHeight,
renderItem,
overscan = 3, // 缓冲区,默认3项
}) => {
const containerRef = useRef(null);
const [scrollTop, setScrollTop] = useState(0);
// 计算总高度
const totalHeight = data.length * itemHeight;
// 计算可见区域的起始和结束索引
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(
startIndex + Math.ceil(height / itemHeight),
data.length - 1
);
// 考虑缓冲区的实际渲染范围
const visibleStartIndex = Math.max(0, startIndex - overscan);
const visibleEndIndex = Math.min(data.length - 1, endIndex + overscan);
// 可见项目数组
const visibleItems = [];
for (let i = visibleStartIndex; i <= visibleEndIndex; i++) {
visibleItems.push({
index: i,
data: data[i],
});
}
// 滚动事件处理
const onScroll = useCallback(() => {
if (containerRef.current) {
setScrollTop(containerRef.current.scrollTop);
}
}, []);
// 可见区域的偏移量
const offsetY = visibleStartIndex * itemHeight;
return (
<div
ref={containerRef}
onScroll={onScroll}
style={{
height,
overflowY: "auto",
position: "relative",
// 性能优化:提示浏览器该元素会变换
willChange: "transform",
}}
>
{/* 总容器,撑开滚动条 */}
<div style={{ height: totalHeight, position: "relative" }}>
{/* 可见项目容器 */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
transform: `translateY(${offsetY}px)`,
}}
>
{/* 渲染可见项目 */}
{visibleItems.map(({ index, data: item }) => (
<div
key={index}
style={{
height: itemHeight,
overflow: "hidden",
}}
>
{renderItem(item, index)}
</div>
))}
</div>
</div>
</div>
);
};
export default VirtualList;
性能优化技巧
1. 缓冲区(Overscan)
在可见区域前后多渲染几项,避免快速滚动时出现白屏:
javascript
// 不使用缓冲区:滚动时可能出现白屏
const visibleItems = data.slice(startIndex, endIndex + 1);
// 使用缓冲区:前后各多渲染3项
const bufferStart = Math.max(0, startIndex - 3);
const bufferEnd = Math.min(data.length - 1, endIndex + 3);
const visibleItems = data.slice(bufferStart, bufferEnd + 1);
2. 滚动事件优化
使用useCallback
避免滚动事件处理函数重复创建:
javascript
const onScroll = useCallback(() => {
if (containerRef.current) {
setScrollTop(containerRef.current.scrollTop);
}
}, []); // 空依赖数组,函数只创建一次
3. GPU 加速
使用willChange
提示浏览器优化渲染:
javascript
style={{
willChange: 'transform', // 提示浏览器该元素会发生变换
transform: `translateY(${offsetY}px)` // 使用transform而非top
}}
4. 节流优化(可选)
对于极高频率的滚动,可以添加节流:
javascript
import { throttle } from "lodash";
const onScroll = useCallback(
throttle(() => {
if (containerRef.current) {
setScrollTop(containerRef.current.scrollTop);
}
}, 16), // 约60fps
[]
);
使用示例
javascript
import VirtualList from "./VirtualList";
const App = () => {
// 生成10万条测试数据
const data = new Array(100000).fill(0).map((_, i) => ({
id: i,
name: `用户${i}`,
email: `user${i}@example.com`,
}));
// 自定义渲染函数
const renderItem = (item, index) => (
<div
style={{
padding: "10px",
borderBottom: "1px solid #eee",
display: "flex",
justifyContent: "space-between",
}}
>
<span>{item.name}</span>
<span>{item.email}</span>
</div>
);
return (
<div style={{ padding: "20px" }}>
<h1>虚拟列表示例 - 10万条数据</h1>
<VirtualList
data={data}
height={400} // 容器高度400px
itemHeight={60} // 每项高度60px
renderItem={renderItem}
overscan={5} // 缓冲区5项
/>
</div>
);
};
进阶功能扩展
1. 动态高度支持
实际项目中,每项的高度可能不同:
javascript
// 使用高度映射表
const itemHeights = new Map(); // 存储每项的实际高度
// 计算累积高度
const getOffsetY = (index) => {
let offset = 0;
for (let i = 0; i < index; i++) {
offset += itemHeights.get(i) || estimatedHeight;
}
return offset;
};
2. 水平虚拟滚动
将垂直滚动的逻辑应用到水平方向:
javascript
// 水平滚动的关键变化
const scrollLeft = containerRef.current.scrollLeft; // 使用scrollLeft
const startIndex = Math.floor(scrollLeft / itemWidth); // 使用itemWidth
const offsetX = startIndex * itemWidth; // 使用offsetX
transform: `translateX(${offsetX}px)`; // 使用translateX
3. 二维虚拟滚动
同时支持水平和垂直虚拟滚动,适用于表格场景。
常见问题与解决方案
Q1: 滚动时出现白屏怎么办?
A: 增加缓冲区(overscan)参数,在可见区域前后多渲染几项。
Q2: 滚动性能仍然不够好?
A:
- 使用
transform
而非top/left
定位 - 添加
willChange: 'transform'
- 考虑使用
requestAnimationFrame
优化滚动事件
Q3: 如何处理不等高的项目?
A: 需要维护一个高度映射表,并使用累积高度计算偏移量。
Q4: 能否支持无限滚动加载?
A : 可以在滚动到底部时触发数据加载,并动态更新data
数组。
总结
虚拟列表是前端性能优化的重要技术,核心原理是:
- 只渲染可见部分:大幅减少 DOM 节点数量
- 动态计算索引:根据滚动位置确定渲染范围
- 使用 transform 定位:利用 GPU 加速提升性能
- 添加缓冲区:优化滚动体验
掌握虚拟列表不仅能解决大数据量渲染问题,更能让你深入理解前端性能优化的核心思想。在面试中,这也是考察候选人技术深度的经典题目。
希望通过本文,你能完全理解虚拟列表的原理,并具备从零实现的能力。记住核心公式:滚动距离 ÷ 项目高度 = 起始索引 ,起始索引 × 项目高度 = 偏移距离。
本文示例代码基于 React 实现,核心思想适用于所有前端框架。