长列表优化
问题
前端如何优化长列表渲染?什么是虚拟滚动?如何实现虚拟列表?
答案
长列表是前端常见的性能瓶颈。当列表项达到数千甚至数万条时,直接渲染会导致页面卡顿甚至崩溃。虚拟滚动是解决这一问题的核心技术。
长列表性能问题
问题分析
| 数据量 | 渲染时间 | 内存占用 | 滚动 FPS |
|---|---|---|---|
| 100 条 | 10ms | 5MB | 60 |
| 1000 条 | 100ms | 50MB | 50 |
| 10000 条 | 1000ms | 500MB | 20 |
| 100000 条 | 崩溃 | 崩溃 | 崩溃 |
优化方案对比
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 分页 | 每次只加载一页 | 简单 | 体验不连续 | 表格数据 |
| 懒加载 | 滚动加载更多 | 体验流畅 | DOM 持续增加 | 有限数据量 |
| 虚拟滚动 | 只渲染可见区域 | 性能最佳 | 实现复杂 | 海量数据 |
虚拟滚动原理
核心概念
虚拟滚动的核心思想:只渲染可见区域的元素,用占位元素撑起滚动高度。
计算公式
// 基本计算
const visibleCount = Math.ceil(containerHeight / itemHeight);
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = startIndex + visibleCount;
// 缓冲区(上下多渲染几个)
const bufferSize = 5;
const renderStart = Math.max(0, startIndex - bufferSize);
const renderEnd = Math.min(totalCount, endIndex + bufferSize);
// 位置偏移
const offsetY = renderStart * itemHeight;
定高虚拟列表实现
import { useState, useRef, useMemo, useCallback } from 'react';
interface VirtualListProps<T> {
items: T[];
itemHeight: number;
containerHeight: number;
renderItem: (item: T, index: number) => React.ReactNode;
bufferSize?: number;
}
function VirtualList<T>({
items,
itemHeight,
containerHeight,
renderItem,
bufferSize = 5,
}: VirtualListProps<T>) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
// 计算可见范围
const visibleRange = useMemo(() => {
const visibleCount = Math.ceil(containerHeight / itemHeight);
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = startIndex + visibleCount;
return {
start: Math.max(0, startIndex - bufferSize),
end: Math.min(items.length, endIndex + bufferSize),
};
}, [scrollTop, containerHeight, itemHeight, items.length, bufferSize]);
// 总高度
const totalHeight = items.length * itemHeight;
// 偏移量
const offsetY = visibleRange.start * itemHeight;
// 处理滚动
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
setScrollTop(e.currentTarget.scrollTop);
}, []);
// 渲染的列表项
const visibleItems = items.slice(visibleRange.start, visibleRange.end);
return (
<div
ref={containerRef}
style={{
height: containerHeight,
overflow: 'auto',
position: 'relative',
}}
onScroll={handleScroll}
>
{/* 占位元素,撑起总高度 */}
<div style={{ height: totalHeight }}>
{/* 实际渲染的列表 */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
transform: `translateY(${offsetY}px)`,
}}
>
{visibleItems.map((item, index) =>
<div key={visibleRange.start + index} style={{ height: itemHeight }}>
{renderItem(item, visibleRange.start + index)}
</div>
)}
</div>
</div>
</div>
);
}
// 使用示例
function App() {
const items = Array.from({ length: 100000 }, (_, i) => ({
id: i,
text: `Item ${i}`,
}));
return (
<VirtualList
items={items}
itemHeight={50}
containerHeight={500}
renderItem={(item) => (
<div className="item">{item.text}</div>
)}
/>
);
}
不定高虚拟列表
不定高列表更复杂,需要动态计算每个项的高度。
import { useState, useRef, useEffect, useCallback } from 'react';
interface DynamicVirtualListProps<T> {
items: T[];
estimatedHeight: number; // 预估高度
containerHeight: number;
renderItem: (item: T, index: number) => React.ReactNode;
}
function DynamicVirtualList<T>({
items,
estimatedHeight,
containerHeight,
renderItem,
}: DynamicVirtualListProps<T>) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const itemsRef = useRef<HTMLDivElement>(null);
// 缓存每个项的高度和位置
const positions = useRef<Array<{
index: number;
top: number;
bottom: number;
height: number;
}>>([]);
// 初始化位置信息
useEffect(() => {
positions.current = items.map((_, index) => ({
index,
top: index * estimatedHeight,
bottom: (index + 1) * estimatedHeight,
height: estimatedHeight,
}));
}, [items, estimatedHeight]);
// 更新实际高度
const updatePositions = useCallback(() => {
const nodes = itemsRef.current?.children;
if (!nodes) return;
let heightChanged = false;
Array.from(nodes).forEach((node, i) => {
const realHeight = node.getBoundingClientRect().height;
const pos = positions.current[startIndex + i];
if (pos && pos.height !== realHeight) {
const diff = realHeight - pos.height;
pos.height = realHeight;
pos.bottom = pos.top + realHeight;
// 更新后续项的位置
for (let j = startIndex + i + 1; j < positions.current.length; j++) {
positions.current[j].top += diff;
positions.current[j].bottom += diff;
}
heightChanged = true;
}
});
if (heightChanged) {
// 触发重新渲染
setScrollTop(prev => prev);
}
}, []);
// 二分查找起始索引
const findStartIndex = useCallback((scrollTop: number) => {
let low = 0;
let high = positions.current.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const pos = positions.current[mid];
if (pos.bottom < scrollTop) {
low = mid + 1;
} else if (pos.top > scrollTop) {
high = mid - 1;
} else {
return mid;
}
}
return low;
}, []);
// 计算可见范围
const startIndex = findStartIndex(scrollTop);
const endIndex = findStartIndex(scrollTop + containerHeight) + 1;
const bufferSize = 5;
const renderStart = Math.max(0, startIndex - bufferSize);
const renderEnd = Math.min(items.length, endIndex + bufferSize);
// 总高度
const totalHeight = positions.current.length > 0
? positions.current[positions.current.length - 1].bottom
: items.length * estimatedHeight;
// 偏移量
const offsetY = positions.current[renderStart]?.top || 0;
// 滚动处理
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
setScrollTop(e.currentTarget.scrollTop);
}, []);
// 渲染后更新高度
useEffect(() => {
updatePositions();
});
const visibleItems = items.slice(renderStart, renderEnd);
return (
<div
ref={containerRef}
style={{ height: containerHeight, overflow: 'auto' }}
onScroll={handleScroll}
>
<div style={{ height: totalHeight, position: 'relative' }}>
<div
ref={itemsRef}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
transform: `translateY(${offsetY}px)`,
}}
>
{visibleItems.map((item, index) => (
<div key={renderStart + index}>
{renderItem(item, renderStart + index)}
</div>
))}
</div>
</div>
</div>
);
}
使用现成库
react-window
import { FixedSizeList, VariableSizeList } from 'react-window';
// 定高列表
function FixedList({ items }) {
const Row = ({ index, style }) => (
<div style={style}>{items[index].text}</div>
);
return (
<FixedSizeList
height={500}
width="100%"
itemCount={items.length}
itemSize={50}
>
{Row}
</FixedSizeList>
);
}
// 不定高列表
function VariableList({ items }) {
const getItemSize = (index: number) => {
return items[index].height || 50;
};
const Row = ({ index, style }) => (
<div style={style}>{items[index].text}</div>
);
return (
<VariableSizeList
height={500}
width="100%"
itemCount={items.length}
itemSize={getItemSize}
>
{Row}
</VariableSizeList>
);
}
react-virtualized
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized';
function VirtualizedList({ items }) {
// 缓存测量结果
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 50,
});
const rowRenderer = ({ index, key, parent, style }) => (
<CellMeasurer
cache={cache}
columnIndex={0}
key={key}
parent={parent}
rowIndex={index}
>
<div style={style}>{items[index].text}</div>
</CellMeasurer>
);
return (
<AutoSizer>
{({ height, width }) => (
<List
height={height}
width={width}
rowCount={items.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
deferredMeasurementCache={cache}
/>
)}
</AutoSizer>
);
}
@tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual';
function TanstackVirtualList({ items }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
overscan: 5, // 缓冲区
});
return (
<div ref={parentRef} style={{ height: 500, 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)`,
}}
>
{items[virtualItem.index].text}
</div>
))}
</div>
</div>
);
}
Vue 虚拟列表
<template>
<div
ref="container"
class="virtual-list"
:style="{ height: containerHeight + 'px' }"
@scroll="handleScroll"
>
<div :style="{ height: totalHeight + 'px' }">
<div
class="list-content"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
v-for="(item, index) in visibleItems"
:key="startIndex + index"
:style="{ height: itemHeight + 'px' }"
>
<slot :item="item" :index="startIndex + index" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
interface Props {
items: any[];
itemHeight: number;
containerHeight: number;
bufferSize?: number;
}
const props = withDefaults(defineProps<Props>(), {
bufferSize: 5,
});
const scrollTop = ref(0);
const totalHeight = computed(() => props.items.length * props.itemHeight);
const visibleCount = computed(() =>
Math.ceil(props.containerHeight / props.itemHeight)
);
const startIndex = computed(() => {
const index = Math.floor(scrollTop.value / props.itemHeight);
return Math.max(0, index - props.bufferSize);
});
const endIndex = computed(() => {
const index = startIndex.value + visibleCount.value + props.bufferSize * 2;
return Math.min(props.items.length, index);
});
const visibleItems = computed(() =>
props.items.slice(startIndex.value, endIndex.value)
);
const offsetY = computed(() => startIndex.value * props.itemHeight);
const handleScroll = (e: Event) => {
scrollTop.value = (e.target as HTMLElement).scrollTop;
};
</script>
<style scoped>
.virtual-list {
overflow: auto;
position: relative;
}
.list-content {
position: absolute;
top: 0;
left: 0;
right: 0;
}
</style>
性能优化技巧
1. 滚动节流
import { useCallback, useRef } from 'react';
function useThrottledScroll(callback: () => void, delay = 16) {
const lastRun = useRef(0);
const rafId = useRef<number>();
return useCallback((e: React.UIEvent) => {
const now = Date.now();
if (now - lastRun.current >= delay) {
lastRun.current = now;
callback();
} else {
// 使用 RAF 确保最后一次滚动被处理
cancelAnimationFrame(rafId.current!);
rafId.current = requestAnimationFrame(callback);
}
}, [callback, delay]);
}
2. 骨架屏占位
function VirtualListWithSkeleton() {
return (
<FixedSizeList {...props}>
{({ index, style, data }) => (
<div style={style}>
{data[index] ? (
<RealItem data={data[index]} />
) : (
<SkeletonItem />
)}
</div>
)}
</FixedSizeList>
);
}
3. 滚动锚定
/* 防止内容加载时滚动位置跳动 */
.virtual-list {
overflow-anchor: auto;
}
.list-item {
overflow-anchor: none;
}
常见面试问题
Q1: 什么是虚拟滚动?原理是什么?
答案:
虚拟滚动是一种只渲染可见区域 DOM 元素的技术。
原理:
- 计算可视区域能显示多少条数据
- 根据滚动位置计算当前应该渲染哪些数据
- 使用占位元素撑起滚动条高度
- 用 CSS transform 定位实际渲染的列表
// 核心计算
const visibleCount = Math.ceil(containerHeight / itemHeight);
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = startIndex + visibleCount;
const offsetY = startIndex * itemHeight;
Q2: 定高和不定高虚拟列表有什么区别?
答案:
| 特性 | 定高列表 | 不定高列表 |
|---|---|---|
| 高度 | 固定 | 动态 |
| 索引查找 | O(1),直接计算 | O(log n),二分查找 |
| 实现复杂度 | 简单 | 复杂 |
| 性能 | 更好 | 较好 |
| 准确性 | 精确 | 需要测量修正 |
不定高列表需要:
- 预估高度
- 渲染后测量实际高度
- 缓存高度信息
- 更新后续项的位置
Q3: 常用的虚拟列表库有哪些?
答案:
| 库 | 框架 | 特点 |
|---|---|---|
| react-window | React | 轻量(~6KB)、高性能 |
| react-virtualized | React | 功能丰富、体积较大 |
| @tanstack/react-virtual | React | Headless、灵活 |
| vue-virtual-scroller | Vue | Vue 官方推荐 |
| vue-virtual-scroll-list | Vue | 简单易用 |
Q4: 虚拟滚动有什么缺点?
答案:
- 实现复杂:特别是不定高列表
- 搜索功能受限:Ctrl+F 无法搜索未渲染的内容
- 可访问性:屏幕阅读器可能无法正确读取
- SEO 不友好:未渲染的内容无法被爬虫抓取
- 键盘导航:需要额外处理键盘焦点
- 滚动位置恢复:切换页面后返回需要额外处理
Q5: 除了虚拟滚动,还有哪些长列表优化方案?
答案:
// 1. 分页
function Pagination() {
const [page, setPage] = useState(1);
const pageSize = 20;
const data = allData.slice((page - 1) * pageSize, page * pageSize);
}
// 2. 无限滚动(懒加载)
function InfiniteScroll() {
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
loadMore();
}
});
observer.observe(sentinelRef.current);
}, []);
}
// 3. 时间分片渲染
function TimeSlicing() {
useEffect(() => {
const items = [...allItems];
function renderBatch() {
const batch = items.splice(0, 100);
if (batch.length) {
setRendered(prev => [...prev, ...batch]);
requestIdleCallback(renderBatch);
}
}
requestIdleCallback(renderBatch);
}, []);
}
// 4. 简化 DOM 结构
// 减少嵌套层级,使用简单的 HTML 结构