前言
在电商场景中,商品瀑布流页面可能需要渲染成千上万条数据。如果全部渲染为真实 DOM,页面会严重卡顿------10000 条商品意味着 10000+ 个 DOM 节点,滚动时浏览器需要不断重排重绘,FPS 直接掉到个位数。
虚拟列表(Virtual List) 的核心思想很简单:只渲染可视区域内的元素,滚动时动态替换内容,始终保持 DOM 数量在一个很小的范围内。
本文从零实现一个支持定高 和不定高的虚拟列表组件,最终效果:10000 条数据滚动 FPS 稳定 60 帧。
原理
arduino
┌──────────────────────┐
│ Phantom Container │ ← 总高度 = 所有项的高度之和(撑开滚动条)
│ (height: 500000px)│
│ ┌──────────────────┐│
│ │ ││ ← 上方空白(translateY 偏移)
│ │ ││
│ ├──────────────────┤│
│ │ Item 50 ││ ← 可视区域开始
│ │ Item 51 ││
│ │ Item 52 ││ 只渲染这些 DOM
│ │ ... ││
│ │ Item 65 ││ ← 可视区域结束
│ ├──────────────────┤│
│ │ ││ ← 下方空白
│ │ ││
│ └──────────────────┘│
└──────────────────────┘
关键变量:
- scrollTop:当前滚动偏移量
- startIndex:可视区域第一个元素的索引
- endIndex:可视区域最后一个元素的索引
- offsetY :渲染列表的 Y 轴偏移(用
transform: translateY()实现)
第一步:定高虚拟列表
定高场景最简单,每个元素高度固定,所有计算都是纯数学:
tsx
import { useState, useRef, useCallback, useMemo } from 'react';
interface VirtualListProps<T> {
data: T[];
itemHeight: number;
containerHeight: number;
overscan?: number; // 上下额外渲染的缓冲行数
renderItem: (item: T, index: number) => React.ReactNode;
}
function VirtualList<T>({
data,
itemHeight,
containerHeight,
overscan = 5,
renderItem,
}: VirtualListProps<T>) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
// 总高度(撑开滚动条)
const totalHeight = data.length * itemHeight;
// 可视区域能显示多少条
const visibleCount = Math.ceil(containerHeight / itemHeight);
// 计算起止索引
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const endIndex = Math.min(
data.length,
Math.floor(scrollTop / itemHeight) + visibleCount + overscan
);
// 只取可视区域的数据
const visibleData = data.slice(startIndex, endIndex);
// Y 轴偏移
const offsetY = startIndex * itemHeight;
const handleScroll = useCallback(() => {
if (containerRef.current) {
setScrollTop(containerRef.current.scrollTop);
}
}, []);
return (
<div
ref={containerRef}
onScroll={handleScroll}
style={{
height: containerHeight,
overflow: 'auto',
position: 'relative',
}}
>
{/* 占位元素,撑开滚动区域 */}
<div style={{ height: totalHeight }} />
{/* 实际渲染的列表 */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
transform: `translateY(${offsetY}px)`,
}}
>
{visibleData.map((item, i) => (
<div key={startIndex + i} style={{ height: itemHeight }}>
{renderItem(item, startIndex + i)}
</div>
))}
</div>
</div>
);
}
使用方式:
tsx
<VirtualList
data={products}
itemHeight={120}
containerHeight={600}
renderItem={(product, index) => <ProductCard product={product} />}
/>
到这一步,10000 条数据滚动已经完全流畅了。但定高方案有局限------商品卡片的图片、标题长度不同,实际场景往往是不定高的。
第二步:不定高虚拟列表
不定高的难点在于:不渲染就不知道高度,不知道高度就不知道该渲染哪些元素。
解决思路:先用预估高度渲染,渲染后测量真实高度并缓存,逐步修正。
tsx
import { useState, useRef, useCallback, useEffect } from 'react';
interface DynamicVirtualListProps<T> {
data: T[];
estimatedHeight: number; // 预估行高
containerHeight: number;
overscan?: number;
renderItem: (item: T, index: number) => React.ReactNode;
}
function DynamicVirtualList<T>({
data,
estimatedHeight,
containerHeight,
overscan = 5,
renderItem,
}: DynamicVirtualListProps<T>) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
// 缓存每个元素的高度和位置
const measuredData = useRef<
{ height: number; top: number; bottom: number }[]
>([]);
// 初始化预估位置
if (measuredData.current.length === 0) {
measuredData.current = data.map((_, index) => ({
height: estimatedHeight,
top: index * estimatedHeight,
bottom: (index + 1) * estimatedHeight,
}));
}
// 总高度
const totalHeight =
measuredData.current.length > 0
? measuredData.current[measuredData.current.length - 1].bottom
: 0;
// 二分查找 startIndex(比线性查找快得多)
function findStartIndex(scrollTop: number): number {
let low = 0;
let high = measuredData.current.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const { top, bottom } = measuredData.current[mid];
if (bottom < scrollTop) {
low = mid + 1;
} else if (top > scrollTop) {
high = mid - 1;
} else {
return mid;
}
}
return Math.max(0, low);
}
// 查找 endIndex
function findEndIndex(startIndex: number): number {
const maxVisible = scrollTop + containerHeight;
for (let i = startIndex; i < measuredData.current.length; i++) {
if (measuredData.current[i].top >= maxVisible) {
return i;
}
}
return measuredData.current.length;
}
const startIndex = Math.max(0, findStartIndex(scrollTop) - overscan);
const endIndex = Math.min(data.length, findEndIndex(startIndex) + overscan);
const visibleData = data.slice(startIndex, endIndex);
const offsetY = measuredData.current[startIndex]?.top ?? 0;
// 渲染后测量真实高度
useEffect(() => {
if (!contentRef.current) return;
const children = contentRef.current.children;
let needUpdate = false;
for (let i = 0; i < children.length; i++) {
const realHeight = children[i].getBoundingClientRect().height;
const index = startIndex + i;
const oldHeight = measuredData.current[index].height;
if (Math.abs(realHeight - oldHeight) > 0.5) {
needUpdate = true;
measuredData.current[index].height = realHeight;
}
}
// 如果有高度变化,重新计算所有后续元素的位置
if (needUpdate) {
for (let i = 1; i < measuredData.current.length; i++) {
measuredData.current[i].top = measuredData.current[i - 1].bottom;
measuredData.current[i].bottom =
measuredData.current[i].top + measuredData.current[i].height;
}
// 触发重渲染
setScrollTop(containerRef.current?.scrollTop ?? 0);
}
});
const handleScroll = useCallback(() => {
if (containerRef.current) {
setScrollTop(containerRef.current.scrollTop);
}
}, []);
return (
<div
ref={containerRef}
onScroll={handleScroll}
style={{
height: containerHeight,
overflow: 'auto',
position: 'relative',
}}
>
<div style={{ height: totalHeight }} />
<div
ref={contentRef}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
transform: `translateY(${offsetY}px)`,
}}
>
{visibleData.map((item, i) => (
<div key={startIndex + i}>{renderItem(item, startIndex + i)}</div>
))}
</div>
</div>
);
}
核心细节解析
为什么用二分查找?
定高场景可以直接 Math.floor(scrollTop / itemHeight) 得到索引,O(1) 复杂度。但不定高场景每个元素的 top 都不同,如果线性遍历查找,10000 条数据每次滚动都要遍历数千次。用二分查找可以将复杂度降到 O(log n)------10000 条数据只需要 14 次比较。
为什么要 overscan(缓冲区)?
如果只渲染可视区域内的元素,快速滚动时会出现短暂白屏。上下各多渲染 5 行作为缓冲,用极小的 DOM 开销换取滚动平滑度。
为什么用 transform 而不是 padding/margin?
transform: translateY() 不会触发重排(reflow),只触发合成层的重绘(repaint),性能远优于修改 padding-top 或 margin-top。
第三步:滚动性能优化
基础实现已经够用了,但还有几个优化点可以将体验推到极致:
1. 滚动事件节流
高频 scroll 事件会导致大量重渲染。使用 requestAnimationFrame 节流:
typescript
const handleScroll = useCallback(() => {
if (rafId.current) return;
rafId.current = requestAnimationFrame(() => {
if (containerRef.current) {
setScrollTop(containerRef.current.scrollTop);
}
rafId.current = null;
});
}, []);
2. ResizeObserver 监听高度变化
商品卡片的图片加载完成后高度可能变化,需要动态更新:
typescript
useEffect(() => {
if (!contentRef.current) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const index = Number(entry.target.dataset.index);
const newHeight = entry.contentRect.height;
if (measuredData.current[index]) {
measuredData.current[index].height = newHeight;
recalcPositions(index);
}
}
});
Array.from(contentRef.current.children).forEach((child) => {
observer.observe(child);
});
return () => observer.disconnect();
}, [startIndex, endIndex]);
3. 滚动到指定位置
常见需求------点击"回到顶部"或跳转到指定商品:
typescript
function scrollToIndex(index: number, behavior: ScrollBehavior = 'smooth') {
const target = measuredData.current[index];
if (target && containerRef.current) {
containerRef.current.scrollTo({
top: target.top,
behavior,
});
}
}
性能对比
在商品瀑布流页面(10000 条数据)上的实测对比:
| 指标 | 直接渲染 | 虚拟列表 |
|---|---|---|
| DOM 节点数 | 10000+ | 30-40 |
| 首屏渲染 | 2800ms | 50ms |
| 滚动 FPS | 8-15 | 稳定 60 |
| 内存占用 | 380MB | 45MB |
瀑布流适配
商品列表通常是瀑布流(多列不等高),在虚拟列表基础上稍作改造:
typescript
// 将数据按列分配(短列优先)
function distributeToColumns<T>(
data: T[],
columnCount: number,
getHeight: (item: T) => number
): T[][] {
const columns: T[][] = Array.from({ length: columnCount }, () => []);
const heights = new Array(columnCount).fill(0);
for (const item of data) {
// 找到当前最短的列
const shortestCol = heights.indexOf(Math.min(...heights));
columns[shortestCol].push(item);
heights[shortestCol] += getHeight(item);
}
return columns;
}
然后对每一列分别应用虚拟列表,共享同一个滚动容器。
总结
手写虚拟列表的核心就三件事:
- 只渲染可视区域 --- 通过 scrollTop 计算 startIndex / endIndex
- 撑开滚动区域 --- 用一个占位 div 保持正确的滚动条
- 位置偏移 --- 用
transform: translateY()将渲染内容移到正确位置
不定高场景多了两步:预估 → 渲染 → 测量 → 修正的循环,加上二分查找优化索引定位。
掌握了这个思路,不管是 React 还是 Vue,实现起来都是同一套逻辑。而且面试中手写虚拟列表是一个非常高频的考察点,理解原理后可以很从容地应对。
如果觉得有帮助,欢迎点赞收藏关注,后续会继续分享前端性能优化相关的实践。