跳到主要内容

长列表优化

问题

前端如何优化长列表渲染?什么是虚拟滚动?如何实现虚拟列表?

答案

长列表是前端常见的性能瓶颈。当列表项达到数千甚至数万条时,直接渲染会导致页面卡顿甚至崩溃。虚拟滚动是解决这一问题的核心技术。


长列表性能问题

问题分析

数据量渲染时间内存占用滚动 FPS
100 条10ms5MB60
1000 条100ms50MB50
10000 条1000ms500MB20
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 元素的技术。

原理

  1. 计算可视区域能显示多少条数据
  2. 根据滚动位置计算当前应该渲染哪些数据
  3. 使用占位元素撑起滚动条高度
  4. 用 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-windowReact轻量(~6KB)、高性能
react-virtualizedReact功能丰富、体积较大
@tanstack/react-virtualReactHeadless、灵活
vue-virtual-scrollerVueVue 官方推荐
vue-virtual-scroll-listVue简单易用

Q4: 虚拟滚动有什么缺点?

答案

  1. 实现复杂:特别是不定高列表
  2. 搜索功能受限:Ctrl+F 无法搜索未渲染的内容
  3. 可访问性:屏幕阅读器可能无法正确读取
  4. SEO 不友好:未渲染的内容无法被爬虫抓取
  5. 键盘导航:需要额外处理键盘焦点
  6. 滚动位置恢复:切换页面后返回需要额外处理

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 结构

相关链接