跳到主要内容

长列表优化

问题

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

面试速答版

前端如何优化长列表渲染? 不要只按条数选方案,同样 1000 行,纯文本和包含图表、输入框的行成本完全不同:

  • 先测量:看首屏渲染长任务、DOM 数量、内存、滚动掉帧和交互延迟,确定瓶颈来自渲染、布局、图片还是数据请求。
  • 数据体验优先:可检索表格常适合服务端分页;信息流适合增量加载;只需要延后屏外渲染时可先评估 content-visibility;DOM 确实过多再使用虚拟滚动。
  • 虚拟化复杂度:必须同时处理动态高度、焦点、键盘导航、屏幕阅读器、滚动恢复、打印、页面内搜索和 SSR,不能只做到“滚起来不卡”。
  • 配合优化:使用稳定业务 ID 作为 key,减少列表项工作量,图片懒加载,并用 profiler 验证 memo 是否真正减少了重渲染。

什么是虚拟滚动? 核心思想就一句话:只渲染可视区域内的元素,用占位元素撑起总滚动高度

  • 用一个 height = items.length * itemHeight 的容器撑出滚动条,让浏览器以为列表是完整的。
  • 监听 scrollTop,根据 scrollTop / itemHeight 算出当前应该渲染哪些项(startIndexendIndex)。
  • 渲染的部分用 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-indexestimateSize 应尽量接近真实尺寸;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>

动态高度与滚动锚定

动态列表的难点不是二分查找,而是“测量后如何保持用户正在看的内容不跳”:

  1. 用合理估值建立初始滚动范围,渲染后通过 ResizeObserver 或库提供的测量函数更新高度。
  2. 图片、字体、折叠面板和流式文本都会让已渲染行继续增长,测量不能只做一次。
  3. 在当前视口上方的行高度变化时,要根据差值修正 scroll offset,维持锚点项的视觉位置。
  4. 插入或删除数据必须使用稳定 ID;索引 key 会把测量缓存和组件状态绑定到错误的数据项。
  5. 恢复页面时同时保存业务锚点、相对偏移和必要的测量缓存,仅保存绝对 scrollTop 在数据变化后往往不可靠。

聊天、AI 流式输出和日志是更特殊的“尾部锚定”场景:只有用户原本就在底部时才自动跟随新消息;用户上滑阅读历史时应保持位置并显示“回到最新”。向顶部加载历史记录后,要保持原锚点消息不动,而不是粗暴设置 column-reverse


可访问性、搜索与打印

虚拟列表只把部分元素放进 DOM,浏览器和辅助技术无法天然看到全部数据。上线前至少验证:

  • 容器和列表项使用符合业务的语义,必要时提供 aria-rowcountaria-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 元素的技术。

原理

  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组件式 API,2.x 支持定高与动态行高,注意版本差异
react-virtualizedReact功能丰富,常见于存量项目
@tanstack/react-virtual多框架Headless,可组合列表、Grid、动态测量和滚动控制
react-virtuosoReact封装度较高,适合动态高度、聊天等场景
vue-virtual-scrollerVueVue 生态常用方案,选型前核对维护状态和需求

选型时我会做一个包含真实列表项的小型验证,比较动态高度、sticky、反向聊天、滚动恢复、SSR、可访问性、包体积和维护状态。库名不是答案,能否覆盖产品交互才是。

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

答案

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

还包括动态高度测量误差、图片或字体加载后跳动、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 持续增长,数据足够大时要配合分页或虚拟化。

相关链接