当列表数据从 100 条膨胀到 10000 条,你的页面还能保持 60fps 吗?本文从原理到实战,带你彻底搞懂虚拟列表与无限滚动的性能优化方案。
前言
在前端开发中,长列表渲染是最常见的性能瓶颈之一。无论是电商商品列表、聊天记录、还是数据表格,当数据量达到千级甚至万级时,直接渲染所有 DOM 节点会导致:
-
页面初次加载时间飙升(FCP > 3s)
-
滚动卡顿掉帧(FPS < 30)
-
内存占用过高,低端设备直接崩溃
虚拟列表(Virtual List) 就是为了解决这个问题而生的核心技术。它的思想很简单:只渲染可视区域内的 DOM 节点,其余数据用占位符代替。配合无限滚动(Infinite Scroll),可以实现"数据无上限、性能不下降"的用户体验。
本文将从零开始,带你手写一个生产级虚拟列表组件,并深入讲解性能调优的每一个细节。
一、虚拟列表核心原理
1.1 为什么长列表会卡顿?
浏览器渲染一帧的流程是:JS 计算 → 样式计算 → 布局(Layout)→ 绘制(Paint)→ 合成(Composite)。当你一次性渲染 10000 个列表项时:
// ❌ 灾难性做法:直接渲染全部数据
const listData = Array.from({ length: 10000 }, (_, i) => ({
id: i,
title: `商品 ${i}`,
price: (Math.random() * 1000).toFixed(2),
}));
function BadList({ data }) {
return (
<div className="list">
{data.map(item => (
<div key={item.id} className="item">
<span>{item.title}</span>
<span>¥{item.price}</span>
</div>
))}
</div>
);
}
10000 个 DOM 节点意味着:
-
布局计算要遍历 10000 个元素
-
每个元素的样式要单独计算
-
内存中维护 10000 个 DOM 对象
实测数据:在 MacBook Pro 上,10000 条数据的列表首次渲染耗时约 800ms ,滚动时 FPS 降至 25 左右。
1.2 虚拟列表的工作原理
虚拟列表的核心思路是:
可视区域高度 = 600px
每项高度 = 60px
可视区域内最多显示 = 600 / 60 = 10 项
实际渲染 = 可视项数 + 上下缓冲区(各 2 项)= 14 项
总占位高度 = 10000 × 60 = 600000px
┌──────────────────────┐ ← scrollTop = 1200px
│ (空白占位 1200px) │
├──────────────────────┤
│ item 20 ← 可视区 │
│ item 21 │
│ item 22 │
│ item 23 │
│ item 24 │
│ item 25 │
│ item 26 ← 缓冲区 │
│ item 27 ← 缓冲区 │
├──────────────────────┤
│ (空白占位 479600px) │
└──────────────────────┘
关键公式:
// 计算起始索引
const startIndex = Math.floor(scrollTop / itemHeight) - bufferSize;
// 计算结束索引
const endIndex = Math.ceil((scrollTop + containerHeight) / itemHeight) + bufferSize;
// 计算顶部占位高度
const paddingTop = startIndex * itemHeight;
// 计算底部占位高度
const paddingBottom = (total - endIndex) * itemHeight;
二、手写虚拟列表组件(React 版)
2.1 基础版本
import React, { useRef, useState, useMemo, useCallback } from 'react';
interface VirtualListProps<T> {
data: T[];
itemHeight: number;
bufferSize?: number;
renderItem: (item: T, index: number) => React.ReactNode;
containerHeight?: number;
}
function VirtualList<T>({
data,
itemHeight,
bufferSize = 3,
renderItem,
containerHeight = 600,
}: VirtualListProps<T>) {
const scrollRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const totalHeight = data.length * itemHeight;
const visibleData = useMemo(() => {
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - bufferSize);
const end = Math.min(
data.length,
Math.ceil((scrollTop + containerHeight) / itemHeight) + bufferSize
);
return data.slice(start, end).map((item, i) => ({
item,
index: start + i,
}));
}, [scrollTop, data.length, itemHeight, bufferSize, containerHeight]);
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
setScrollTop(e.currentTarget.scrollTop);
}, []);
const paddingTop = visibleData.length > 0
? visibleData[0].index * itemHeight
: 0;
const paddingBottom = totalHeight - paddingTop - visibleData.length * itemHeight;
return (
<div
ref={scrollRef}
onScroll={handleScroll}
style={{
height: containerHeight,
overflow: 'auto',
willChange: 'transform',
}}
>
<div style={{ height: totalHeight, position: 'relative' }}>
<div style={{ paddingTop }} />
{visibleData.map(({ item, index }) => (
<div
key={index}
style={{
height: itemHeight,
position: 'absolute',
width: '100%',
top: index * itemHeight,
}}
>
{renderItem(item, index)}
</div>
))}
<div style={{ height: Math.max(0, paddingBottom) }} />
</div>
</div>
);
}
export default VirtualList;
2.2 使用示例
// 模拟 50000 条数据
const hugeData = Array.from({ length: 50000 }, (_, i) => ({
id: i,
avatar: `https://i.pravatar.cc/40?u=${i}`,
name: `用户 ${i}`,
message: `这是第 ${i} 条消息内容...`,
time: new Date(Date.now() - i * 60000).toLocaleString(),
}));
function ChatList() {
return (
<VirtualList
data={hugeData}
itemHeight={80}
bufferSize={5}
containerHeight={window.innerHeight - 100}
renderItem={(item) => (
<div style={{ display: 'flex', padding: '12px', gap: '12px', borderBottom: '1px solid #eee' }}>
<img src={item.avatar} alt="" style={{ width: 40, height: 40, borderRadius: '50%' }} />
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600 }}>{item.name}</div>
<div style={{ color: '#666', fontSize: 14 }}>{item.message}</div>
</div>
<div style={{ color: '#999', fontSize: 12 }}>{item.time}</div>
</div>
)}
/>
);
}
三、不定高度虚拟列表
实际项目中,列表项高度往往不固定(如聊天记录、富文本列表)。这时候需要动态测量每项的实际高度。
3.1 ResizeObserver 方案
import { useRef, useState, useCallback, useEffect } from 'react';
interface DynamicVirtualListProps<T> {
data: T[];
estimateHeight: number;
bufferSize?: number;
renderItem: (item: T, index: number) => React.ReactNode;
containerHeight?: number;
}
function DynamicVirtualList<T>({
data,
estimateHeight,
bufferSize = 3,
renderItem,
containerHeight = 600,
}: DynamicVirtualListProps<T>) {
const scrollRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
// 存储每项的实际高度和偏移量
const [positionMap, setPositionMap] = useState<Map<number, { height: number; top: number }>>(
new Map()
);
// 初始化估算位置
const totalHeight = useMemo(() => {
if (positionMap.size === data.length) {
const last = positionMap.get(data.length - 1);
return last ? last.top + last.height : data.length * estimateHeight;
}
return data.length * estimateHeight;
}, [data.length, estimateHeight, positionMap]);
// 根据累积高度计算起止索引
const getRange = useCallback(() => {
let start = 0;
let accumulatedHeight = 0;
// 找到第一个 top > scrollTop 的项
for (let i = 0; i < data.length; i++) {
const pos = positionMap.get(i) || { top: i * estimateHeight, height: estimateHeight };
if (pos.top + pos.height > scrollTop - bufferSize * estimateHeight) {
start = Math.max(0, i - bufferSize);
break;
}
}
let end = start;
let visibleHeight = 0;
while (end < data.length && visibleHeight < containerHeight + bufferSize * estimateHeight) {
const pos = positionMap.get(end) || { height: estimateHeight };
visibleHeight += pos.height;
end++;
}
return { start, end: Math.min(end, data.length) };
}, [scrollTop, data.length, estimateHeight, bufferSize, containerHeight, positionMap]);
const { start, end } = getRange();
const visibleItems = data.slice(start, end);
// 使用 ResizeObserver 测量实际高度
const measureRef = useCallback(
(node: HTMLDivElement | null, index: number) => {
if (!node) return;
const observer = new ResizeObserver((entries) => {
const height = entries[0].contentRect.height;
setPositionMap((prev) => {
const next = new Map(prev);
// 计算当前项的 top
let top = 0;
for (let i = 0; i < index; i++) {
const p = next.get(i);
if (p) top += p.height;
else top += estimateHeight;
}
next.set(index, { height, top });
return next;
});
});
observer.observe(node);
},
[estimateHeight]
);
// 计算起始位置的偏移
const getOffsetTop = useCallback(
(index: number) => {
let top = 0;
for (let i = 0; i < index; i++) {
const pos = positionMap.get(i);
top += pos ? pos.height : estimateHeight;
}
return top;
},
[positionMap, estimateHeight]
);
return (
<div
ref={scrollRef}
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
style={{ height: containerHeight, overflow: 'auto' }}
>
<div style={{ height: totalHeight, position: 'relative' }}>
{visibleItems.map((item, i) => {
const globalIndex = start + i;
const offsetTop = getOffsetTop(globalIndex);
return (
<div
key={globalIndex}
ref={(node) => measureRef(node, globalIndex)}
style={{
position: 'absolute',
top: offsetTop,
left: 0,
right: 0,
}}
>
{renderItem(item, globalIndex)}
</div>
);
})}
</div>
</div>
);
}
3.2 二分查找优化
当数据量很大时,线性查找起止索引会成为瓶颈。可以用前缀和数组 + 二分查找来加速:
// 构建前缀和数组
function buildPrefixSum(heights: number[]): number[] {
const prefix = [0];
for (let i = 0; i < heights.length; i++) {
prefix.push(prefix[i] + heights[i]);
}
return prefix;
}
// 二分查找:找到第一个 top >= target 的索引
function binarySearch(prefix: number[], target: number): number {
let left = 0;
let right = prefix.length - 1;
while (left < right) {
const mid = (left + right) >>> 1;
if (prefix[mid] < target) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
// 使用示例
const prefixSum = buildPrefixSum(actualHeights);
const startIndex = binarySearch(prefixSum, scrollTop - bufferHeight);
const endIndex = binarySearch(prefixSum, scrollTop + containerHeight + bufferHeight);
二分查找将索引计算从 O(n) 降至 O(log n),50000 条数据时,查找次数从 50000 次降至约 16 次。
四、无限滚动(Infinite Scroll)
虚拟列表解决了"渲染"问题,无限滚动解决了"数据加载"问题。两者结合,才能处理真正海量的数据。
4.1 基础实现
import { useState, useRef, useCallback, useEffect } from 'react';
interface UseInfiniteScrollOptions {
onLoadMore: (page: number) => Promise<any[]>;
threshold?: number; // 距离底部多少 px 时触发
}
function useInfiniteScroll<T>({ onLoadMore, threshold = 200 }: UseInfiniteScrollOptions) {
const [data, setData] = useState<T[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
try {
const newData = await onLoadMore(page);
if (newData.length === 0) {
setHasMore(false);
} else {
setData((prev) => [...prev, ...newData]);
setPage((prev) => prev + 1);
}
} finally {
setLoading(false);
}
}, [page, loading, hasMore, onLoadMore]);
// 监听滚动
useEffect(() => {
const container = scrollRef.current;
if (!container) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
if (scrollHeight - scrollTop - clientHeight < threshold) {
loadMore();
}
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [loadMore, threshold]);
// 首次加载
useEffect(() => {
loadMore();
}, []);
return { data, loading, hasMore, scrollRef };
}
export default useInfiniteScroll;
4.2 与虚拟列表结合
function InfiniteVirtualList() {
const { data, loading, hasMore, scrollRef } = useInfiniteScroll({
onLoadMore: async (page) => {
const res = await fetch(`/api/items?page=${page}&size=50`);
return res.json();
},
threshold: 300,
});
return (
<div ref={scrollRef} style={{ height: 600, overflow: 'auto' }}>
<VirtualList
data={data}
itemHeight={60}
renderItem={(item) => <ItemCard data={item} />}
/>
{loading && <div style={{ textAlign: 'center', padding: 16 }}>加载中...</div>}
{!hasMore && <div style={{ textAlign: 'center', padding: 16, color: '#999' }}>没有更多了</div>}
</div>
);
}
五、性能调优实战
5.1 滚动节流(Throttle)
高频 scroll 事件会导致大量重渲染。使用 requestAnimationFrame 或 throttle 来优化:
// 使用 rAF 节流
function useThrottledScroll(callback: (scrollTop: number) => void) {
const rafRef = useRef<number | null>(null);
const lastScrollTop = useRef(0);
return useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
const scrollTop = e.currentTarget.scrollTop;
lastScrollTop.current = scrollTop;
if (rafRef.current === null) {
rafRef.current = requestAnimationFrame(() => {
callback(lastScrollTop.current);
rafRef.current = null;
});
}
},
[callback]
);
}
5.2 组件 memo 化
列表项组件一定要用 React.memo 包裹,避免不必要的重渲染:
const ItemCard = React.memo(({ data }: { data: Item }) => {
return (
<div className="item-card">
<img src={data.avatar} loading="lazy" alt="" />
<div className="content">
<h3>{data.title}</h3>
<p>{data.description}</p>
</div>
</div>
);
});
5.3 图片懒加载
虚拟列表中的图片也需要懒加载,避免不可见图片浪费网络请求:
// 使用 IntersectionObserver 实现图片懒加载
function LazyImage({ src, alt, ...props }: ImgProps) {
const imgRef = useRef<HTMLImageElement>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && imgRef.current) {
imgRef.current.src = src;
setLoaded(true);
observer.disconnect();
}
},
{ rootMargin: '200px' } // 提前 200px 开始加载
);
if (imgRef.current) observer.observe(imgRef.current);
return () => observer.disconnect();
}, [src]);
return <img ref={imgRef} alt={alt} {...props} />;
}
5.4 性能对比数据
在我的实际项目中,对一个电商商品列表进行了优化,数据对比如下:
| 指标 | 优化前(全量渲染) | 优化后(虚拟列表) | 改善幅度 |
|---|---|---|---|
| 首次渲染时间 | 1200ms | 85ms | ↓ 93% |
| 滚动 FPS | 28fps | 59fps | ↑ 111% |
| DOM 节点数 | 10000 | 14 | ↓ 99.8% |
| 内存占用 | 180MB | 25MB | ↓ 86% |
六、现成方案推荐
如果你不想从零手写,以下开源方案可以直接使用:
| 方案 | 框架 | 特点 |
|---|---|---|
react-window |
React | 轻量(~4KB),API 简洁,官方维护 |
react-virtualized |
React | 功能全面但较重,已停止维护 |
@tanstack/virtual |
框架无关 | 支持 React/Vue/Solid,最新一代方案 |
vue-virtual-scroller |
Vue | Vue 生态最成熟的虚拟滚动方案 |
cdk-virtual-scroll |
Angular | Angular CDK 内置方案 |
推荐 :新项目优先用 @tanstack/virtual,它是目前最灵活、性能最好的方案,且框架无关。
npm install @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual';
function TanstackVirtualList({ data }: { data: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: data.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{data[virtualItem.index].title}
</div>
))}
</div>
</div>
);
}
总结
虚拟列表和无限滚动是前端性能优化的必修课。掌握它们,你就能从容应对任何规模的数据展示场景。
核心要点回顾:
-
虚拟列表只渲染可视区域内的 DOM,其余用占位符代替
-
不定高度场景用 ResizeObserver 动态测量 + 前缀和二分查找加速
-
无限滚动配合虚拟列表,实现"数据无上限、性能不下降"
-
滚动节流、React.memo、图片懒加载是必备的辅助优化手段
-
生产环境推荐
@tanstack/virtual,轻量且框架无关
延伸阅读:
-
Chrome DevTools Performance 面板调优指南
如果你觉得这篇文章对你有帮助,欢迎点赞收藏,也欢迎在评论区分享你的虚拟列表实战经验!