长列表优化
问题
前端如何优化长列表渲染?什么是虚拟滚动?如何实现虚拟列表?
前端如何优化长列表渲染? 不要只按条数选方案,同样 1000 行,纯文本和包含图表、输入框的行成本完全不同:
- 先测量:看首屏渲染长任务、DOM 数量、内存、滚动掉帧和交互延迟,确定瓶颈来自渲染、布局、图片还是数据请求。
- 数据体验优先:可检索表格常适合服务端分页;信息流适合增量加载;只需要延后屏外渲染时可先评估
content-visibility;DOM 确实过多再使用虚拟滚动。 - 虚拟化复杂度:必须同时处理动态高度、焦点、键盘导航、屏幕阅读器、滚动恢复、打印、页面内搜索和 SSR,不能只做到“滚起来不卡”。
- 配合优化:使用稳定业务 ID 作为 key,减少列表项工作量,图片懒加载,并用 profiler 验证 memo 是否真正减少了重渲染。
什么是虚拟滚动? 核心思想就一句话:只渲染可视区域内的元素,用占位元素撑起总滚动高度:
- 用一个
height = items.length * itemHeight的容器撑出滚动条,让浏览器以为列表是完整的。 - 监听
scrollTop,根据scrollTop / itemHeight算出当前应该渲染哪些项(startIndex到endIndex)。 - 渲染的部分用
transform: translateY(offsetY)定位到正确位置——所以 DOM 永远只有几十个,滚多远都不卡。 - 上下加几个
bufferSize缓冲,避免快速滚动时白屏。
如何实现虚拟列表? 分定高和不定高两种:
- 定高:最简单,公式直接算
startIndex = Math.floor(scrollTop / itemHeight),自己 100 行就能写出来。 - 不定高:需要预估高度 + 实测缓存——先按估值渲染,渲染后用
ResizeObserver拿真实高度更新缓存,再修正后续偏移;或用二分查找定位。 - 生产优先评估现成库:按动态测量、Grid、反向聊天、sticky、SSR、可访问性和维护状态选型;不要只按包体积或列表条数决定。
- 易踩坑:图片和字体加载后高度变化、prepend 数据导致位置跳动、用索引作 key、SSR 初始尺寸不一致,以及焦点项被回收。
答案
长列表是否成为瓶颈,取决于列表项复杂度、设备能力、交互和数据策略。虚拟滚动是控制 DOM 规模的重要手段,但它会改变原生文档语义与生命周期,因此应在测量确认后使用。
长列表性能问题
问题分析
下面是制定测试基线的方式,而不是固定结论:
| 维度 | 建议记录 |
|---|---|
| 列表规模 | 代表性的小、中、大数据集,例如 100、1000、10000 条 |
| 行复杂度 | 纯文本、图片、表单控件、图表、动态高度分别测试 |
| 设备与环境 | 用户常见的低端/中端设备、刷新率、浏览器和缩放比例 |
| 用户旅程 | 首次进入、快速滚动、过滤、展开、跳转并返回、键盘导航 |
| 结果 | DOM 数、长任务、INP、内存峰值、空白帧、滚动位置稳定性 |
优化方案对比
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 分页 | 每次只加载一页 | 简单 | 体验不连续 | 表格数据 |
| 懒加载 | 滚动加载更多 | 体验流畅 | 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 2.x
import { List, type RowComponentProps } from 'react-window';
type Row = { id: string; text: string };
function RowComponent({
index,
style,
rows,
}: RowComponentProps<{ rows: Row[] }>) {
return <div style={style}>{rows[index].text}</div>;
}
function VirtualList({ rows }: { rows: Row[] }) {
return (
<List
rowComponent={RowComponent}
rowCount={rows.length}
rowHeight={50}
rowProps={{ rows }}
overscanCount={5}
style={{ height: 500 }}
/>
);
}
react-window 2.x 的 API 与大量旧教程中的 FixedSizeList / VariableSizeList 不同;维护存量 1.x 项目时应查看对应版本文档,不要直接混用示例。动态行高需要实际测量或缓存,也通常比可预知高度更昂贵。
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>
);
}
对于动态高度,需要给行节点绑定 ref={virtualizer.measureElement} 并提供 data-index。estimateSize 应尽量接近真实尺寸;overscan 增大能减少快速滚动空白,但会增加渲染成本,需要实测平衡。
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>
动态高度与滚动锚定
动态列表的难点不是二分查找,而是“测量后如何保持用户正在看的内容不跳”:
- 用合理估值建立初始滚动范围,渲染后通过
ResizeObserver或库提供的测量函数更新高度。 - 图片、字体、折叠面板和流式文本都会让已渲染行继续增长,测量不能只做一次。
- 在当前视口上方的行高度变化时,要根据差值修正 scroll offset,维持锚点项的视觉位置。
- 插入或删除数据必须使用稳定 ID;索引 key 会把测量缓存和组件状态绑定到错误的数据项。
- 恢复页面时同时保存业务锚点、相对偏移和必要的测量缓存,仅保存绝对
scrollTop在数据变化后往往不可靠。
聊天、AI 流式输出和日志是更特殊的“尾部锚定”场景:只有用户原本就在底部时才自动跟随新消息;用户上滑阅读历史时应保持位置并显示“回到最新”。向顶部加载历史记录后,要保持原锚点消息不动,而不是粗暴设置 column-reverse。
可访问性、搜索与打印
虚拟列表只把部分元素放进 DOM,浏览器和辅助技术无法天然看到全部数据。上线前至少验证:
- 容器和列表项使用符合业务的语义,必要时提供
aria-rowcount、aria-rowindex等集合信息,但不要用 ARIA 掩盖错误 DOM 结构。 - Tab 或方向键移动到屏外项时,先滚动并挂载目标,再转移焦点;不要让有焦点的节点在滚动时突然被卸载。
- 屏幕阅读器能获知当前位置、总量、加载状态和新增内容,不应一次朗读几万项。
- 浏览器页面内查找只能搜索已挂载文本。需要全量搜索时提供应用内搜索、过滤或服务端检索。
- 打印、复制全部、导出和 SEO 通常需要独立的非虚拟化数据路径。
- 自动化测试覆盖键盘、缩放、字体变大、RTL、减少动画和低性能设备,而不仅是鼠标滚动。
什么时候不该虚拟化
- 数据本来就适合分页,并且用户需要可分享的页码、排序和筛选状态。
- 内容需要被搜索引擎完整索引、浏览器全文查找或打印。
- DOM 规模尚未形成可测瓶颈,虚拟化增加的状态、测量和可访问性成本大于收益。
- 只是希望延后屏外内容的布局和绘制,可以先评估
content-visibility: auto;它不会减少数据请求和所有内存,但保留了更自然的 DOM 语义。 - 无限滚动让 DOM 只增不减时,可以结合窗口化、分页或回收旧页,而不是把“加载更多”误认为虚拟列表。
性能优化技巧
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 | 组件式 API,2.x 支持定高与动态行高,注意版本差异 |
| react-virtualized | React | 功能丰富,常见于存量项目 |
| @tanstack/react-virtual | 多框架 | Headless,可组合列表、Grid、动态测量和滚动控制 |
| react-virtuoso | React | 封装度较高,适合动态高度、聊天等场景 |
| vue-virtual-scroller | Vue | Vue 生态常用方案,选型前核对维护状态和需求 |
选型时我会做一个包含真实列表项的小型验证,比较动态高度、sticky、反向聊天、滚动恢复、SSR、可访问性、包体积和维护状态。库名不是答案,能否覆盖产品交互才是。
Q4: 虚拟滚动有什么缺点?
答案:
- 实现复杂:特别是不定高列表
- 搜索功能受限:Ctrl+F 无法搜索未渲染的内容
- 可访问性:屏幕阅读器可能无法正确读取
- SEO 不友好:未渲染的内容无法被爬虫抓取
- 键盘导航:需要额外处理键盘焦点
- 滚动位置恢复:切换页面后返回需要额外处理
还包括动态高度测量误差、图片或字体加载后跳动、sticky 元素与绝对定位冲突,以及虚拟节点复用时组件状态串行。解决时要用稳定 key、锚点补偿、测量缓存和完整的键盘/辅助技术测试。
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 结构
requestIdleCallback 可能很晚才执行,不能承担必须及时完成的渲染。生产中应设置超时与降级,或使用带特性检测的 scheduler.yield() 分批让出主线程;每批大小也应按耗时预算动态决定,而不是固定 100 条。渐进式追加仍会让 DOM 持续增长,数据足够大时要配合分页或虚拟化。