React 虚拟列表面试题整理
一、面试题:React 为什么需要虚拟列表?长列表的性能瓶颈在哪里?
核心思路(一句话)
长列表真正的问题不是“数据多”,而是一次性创建、渲染和维护了过多的 DOM 节点以及对应的 React 组件树,因此虚拟列表通过“只渲染当前需要看到的少量节点”降低实际渲染成本。
解决方案流程图
大量列表数据
↓
一次性渲染全部列表项
↓
产生大量 React Element / Fiber / DOM 节点
↓
React 渲染、协调成本增加
↓
浏览器 Layout / Paint / Composite 成本增加
↓
内存占用增加
↓
滚动、更新时主线程工作量增加
↓
掉帧 / 卡顿 / 响应变慢
↓
虚拟列表
↓
只渲染可视区域 + 少量缓冲区域
↓
DOM 节点数量维持在较小范围
↓
降低渲染、布局、绘制和内存成本
主要矛盾
主要矛盾:DOM 和组件实际渲染数量远远超过用户当前真正需要看到的数量。
例如:
总数据:100000 条
普通列表:
100000 条 → 100000 个列表项 → 大量 DOM
虚拟列表:
100000 条数据
↓
当前视口只能看到约 20 条
↓
实际渲染 20 ~ 40 条
↓
随着滚动不断替换渲染窗口
因此虚拟列表的核心并不是“让 100000 条数据消失”,而是:
数据可以有 100000 条,但 DOM 不需要同时存在 100000 个。
次要矛盾
除了 DOM 数量之外,还包括:
底层实现原理:为什么 DOM 多会导致卡顿?
一个列表项并不是简单的一个 JavaScript 对象。
例如:
<div className="item">
<img />
<span />
<button />
</div>
10000 个这样的列表项,可能意味着:
10000 个 React 列表组件
↓
大量 React Element
↓
大量 Fiber 节点
↓
大量 DOM 节点
↓
浏览器维护巨大的 DOM Tree
↓
布局 / 样式计算 / 绘制成本增加
特别需要注意:
React 性能问题 ≠ 只有 React
实际上存在两层成本:
React 层
├── 组件执行
├── Element 创建
├── Fiber 协调
└── Commit
浏览器层
├── Style Calculation
├── Layout
├── Paint
└── Composite
所以即使 React 本身已经进行了很多优化:
React.memo
useMemo
useCallback
如果最终页面仍然存在十几万个 DOM 节点,也无法从根本上解决浏览器层面的压力。
一个非常重要的面试纠正
不要简单回答:
“因为 React 的 Diff 算法复杂度是 O(n),所以列表很卡。”
这个说法不准确。
现代 React 使用 Fiber 架构,并且协调算法有大量优化,不能简单用“Diff 是 O(n)”解释长列表问题。
更准确的回答是:
长列表性能问题来自多个层面,包括大量组件执行、Fiber 协调、Commit、DOM 节点维护,以及浏览器样式计算、布局和绘制等成本。虚拟列表通过减少实际挂载的列表项数量,从源头降低这些成本。
二、面试题:什么是虚拟列表?核心原理是什么?
核心思路(一句话)
虚拟列表就是“数据全量存在,但 DOM 只渲染当前视口附近的少量数据”,通过计算可视区索引和元素位置,让用户感觉整个列表都存在。
架构图
完整数据
┌─────────────────────────┐
│ 0 1 2 3 … 99999 │
└─────────────────────────┘
│
↓
虚拟列表计算器
│
┌───────────┴───────────┐
↓ ↓
scrollTop containerHeight
│ │
└───────────┬───────────┘
↓
计算可视范围
↓
startIndex ~ endIndex
↓
加上 overscan 缓冲区
↓
实际渲染少量节点
↓
┌─────────────────────┐
│ Item Item Item Item │
│ Item Item Item Item │
│ Item Item Item Item │
└─────────────────────┘
虚拟列表最重要的三个概念
1. Viewport:可视区域
用户真正能够看到的区域。
例如:
容器高度:600px
每项高度:50px
理论可见数量:
600 / 50 = 12
因此当前大约只需要渲染 12 项。
实际通常还会多渲染几项:
可视区
+ 上方 overscan
+ 下方 overscan
避免用户快速滚动时出现白屏。
2. Scroll Container:滚动容器
通常:
.container {
height: 600px;
overflow: auto;
}
3. Virtual Content:虚拟内容区域
虽然只渲染 20~40 个节点,但是滚动条必须表现得像整个列表都存在。
例如:
100000 条
每项 50px
总高度:
100000 × 50
= 5,000,000px
因此内部需要一个具有巨大高度的容器:
<div class="container">
<div class="virtual-content">
<!– 只渲染当前可见的几十项 –>
</div>
</div>
其中:
container
↓
负责产生滚动条
virtual-content
↓
负责撑起完整列表的滚动空间
“通过一个总高度区域撑起原生滚动条”的核心机制。
三、面试题:固定高度虚拟列表是怎么计算可视区域的?
核心思路(一句话)
固定高度列表可以通过 scrollTop / itemHeight 直接计算起始索引,再根据容器高度计算结束索引。
计算公式
假设:
itemHeight = 50px
containerHeight = 500px
scrollTop = 1250px
那么:
startIndex = floor(scrollTop / itemHeight)
= floor(1250 / 50)
= 25
可见数量:
visibleCount = ceil(containerHeight / itemHeight)
= ceil(500 / 50)
= 10
所以:
endIndex = startIndex + visibleCount
= 35
如果增加 overscan:
overscan = 5
实际开始:
20
实际结束:
40
最终:
[20 … 40]
这些数据才真正需要渲染。
完整流程
用户滚动
↓
scrollTop 改变
↓
计算 startIndex
↓
计算 endIndex
↓
增加 overscan
↓
从完整数据中切片
↓
渲染这一小段数据
↓
计算每一项 top
↓
绝对定位
四、面试题:为什么虚拟列表需要“总高度容器”?
核心思路(一句话)
因为实际 DOM 只有几十项,如果没有一个代表完整列表高度的容器,浏览器就无法产生正确的滚动条和 scrollTop 范围。
例如
10000 条:
每项高度:50px
总高度:
10000 × 50 = 500000px
但是实际只渲染:
第 100~120 项
如果页面真实 DOM 高度只有:
20 × 50 = 1000px
那么浏览器会认为:
“这个列表只有 1000px。”
滚动条当然就不正确。
所以需要:
<div class="container">
<div style="height: 500000px; position: relative;">
<!– 实际只存在少量 DOM –>
</div>
</div>
五、面试题:虚拟列表为什么通常使用绝对定位?
核心思路(一句话)
通过 position: absolute + top,将少量实际渲染的节点直接定位到它们在完整列表中的逻辑位置。
架构图
完整列表
0px
│
├── Item 0
│
├── Item 1
│
├── Item 2
│
├── …
│
├── Item 500
│
└── Item 99999
实际 DOM:
virtual-content
│
├── Item 500 → top: 25000px
├── Item 501 → top: 25050px
├── Item 502 → top: 25100px
└── Item 503 → top: 25150px
因此:
.item {
position: absolute;
left: 0;
right: 0;
}
然后:
style={{
position: 'absolute',
top: index * itemHeight,
height: itemHeight,
}}
这样:
DOM 数量少,但视觉位置仍然和完整列表一致。
六、面试题:如何手写一个固定高度的 React 虚拟列表?
核心思路(一句话)
固定高度场景下,只需要维护 scrollTop,根据 scrollTop 计算可视索引,再用总高度容器 + 绝对定位渲染可视数据。
完整示例代码
import React, { useCallback, useEffect, useMemo, useState } from 'react';
/**
* 一个最基础的固定高度虚拟列表。
*
* 核心能力:
* 1. 只渲染当前可视区域附近的列表项
* 2. 使用一个总高度容器撑起完整滚动区域
* 3. 使用绝对定位将实际渲染的节点放到正确位置
* 4. 使用 requestAnimationFrame 合并高频滚动更新
*/
function VirtualList({
items,
height = 500,
itemHeight = 50,
overscan = 5,
}) {
/**
* scrollTop 表示当前滚动距离。
*
* 注意:
* scroll 事件触发频率可能非常高,
* 因此这里不要直接在每一次事件里做大量计算。
*/
const [scrollTop, setScrollTop] = useState(0);
/**
* 使用 requestAnimationFrame 合并同一帧内的多次滚动事件。
*
* 这里比简单 debounce 更适合滚动场景:
*
* debounce 会等待滚动停止以后才执行,
* 用户快速滚动时可能导致可视内容更新不及时。
*
* requestAnimationFrame 更适合跟随浏览器绘制节奏更新 UI。
*/
const handleScroll = useCallback((event) => {
const currentScrollTop = event.currentTarget.scrollTop;
requestAnimationFrame(() => {
setScrollTop(currentScrollTop);
});
}, []);
/**
* 根据 scrollTop 计算理论可见区域。
*/
const { startIndex, endIndex } = useMemo(() => {
/**
* 当前滚动位置对应的第一个列表项。
*/
const visibleStartIndex = Math.floor(
scrollTop / itemHeight
);
/**
* 容器理论上能够显示多少项。
*
* 使用 Math.ceil 是为了避免最后一个部分可见的元素没有被计算进去。
*/
const visibleCount = Math.ceil(
height / itemHeight
);
/**
* 先计算理论结束位置。
*/
const visibleEndIndex =
visibleStartIndex + visibleCount;
/**
* 在可见区域上下增加 overscan。
*
* 例如:
*
* 可见区域:
* 100 ~ 110
*
* overscan = 5
*
* 实际渲染:
* 95 ~ 115
*
* 这样快速滚动时不容易出现白屏。
*/
const start = Math.max(
0,
visibleStartIndex – overscan
);
const end = Math.min(
items.length – 1,
visibleEndIndex + overscan
);
return {
startIndex: start,
endIndex: end,
};
}, [
scrollTop,
height,
itemHeight,
overscan,
items.length,
]);
/**
* 只截取需要渲染的数据。
*
* 如果 items 有 100000 条,
* 这里可能只得到 20~30 条。
*/
const visibleItems = useMemo(() => {
return items.slice(
startIndex,
endIndex + 1
);
}, [
items,
startIndex,
endIndex,
]);
/**
* 整个列表的逻辑高度。
*
* 例如:
*
* 100000 条 × 50px
* = 5000000px
*
* 这个高度并不意味着真的创建了 5000000px 的 DOM 内容,
* 它只是用于告诉浏览器:
*
* “整个列表理论上有这么长。”
*/
const totalHeight =
items.length * itemHeight;
return (
<div
/**
* 外层滚动容器。
*/
style={{
height,
overflowY: 'auto',
position: 'relative',
border: '1px solid #ddd',
}}
onScroll={handleScroll}
>
<div
/**
* 内层容器负责撑起完整滚动高度。
*/
style={{
position: 'relative',
height: totalHeight,
}}
>
{visibleItems.map((item, offset) => {
/**
* visibleItems 是从 startIndex 开始截取的,
* 所以真实 index:
*
* startIndex + offset
*/
const index =
startIndex + offset;
return (
<div
/**
* key 必须尽可能使用稳定的业务 ID,
* 不建议在数据可变的情况下使用 index。
*
* 这里假设 item.id 是稳定唯一 ID。
*/
key={item.id}
style={{
position: 'absolute',
/**
* 根据真实 index 计算元素在完整列表中的位置。
*/
top:
index * itemHeight,
left: 0,
right: 0,
/**
* 固定高度列表。
*/
height: itemHeight,
/**
* 示例样式。
*/
boxSizing: 'border-box',
padding: '0 16px',
display: 'flex',
alignItems: 'center',
borderBottom:
'1px solid #eee',
}}
>
第 {index + 1} 项:{item.name}
</div>
);
})}
</div>
</div>
);
}
/**
* 示例页面。
*/
export default function App() {
/**
* 模拟 100000 条数据。
*
* 注意:
* 虚拟列表解决的是“渲染性能”问题,
* 并不意味着一次性创建 100000 条 JavaScript 数据
* 就完全没有成本。
*/
const items = useMemo(() => {
return Array.from(
{ length: 100000 },
(_, index) => ({
id: `item-${index}`,
name: `商品 ${index + 1}`,
})
);
}, []);
return (
<div>
<h2>React 固定高度虚拟列表</h2>
<VirtualList
items={items}
height={500}
itemHeight={50}
overscan={5}
/>
</div>
);
}
这段代码最核心的地方只有四步
1. scrollTop
↓
2. startIndex / endIndex
↓
3. items.slice(startIndex, endIndex)
↓
4. position: absolute + top
这四步就是固定高度虚拟列表的核心。
七、面试题:虚拟列表的 overscan 是什么?为什么需要它?
核心思路(一句话)
overscan 就是在真正可视区域之外额外渲染少量列表项,用少量额外 DOM 换取快速滚动时的流畅度和减少白屏。
示例
假设:
当前可见:
100 ~ 110
设置:
overscan = 5
实际渲染:
95 ~ 115
结构:
overscan
↓ ↓
95 96 97 98 99
———————
100 101 102 … 110 ← 可视区域
———————
111 112 113 114 115
↑ ↑
overscan
overscan 太小
DOM 少
↓
内存更低
↓
但是快速滚动时
↓
容易来不及渲染
↓
出现白屏
overscan 太大
白屏概率降低
↓
但是实际 DOM 增多
↓
虚拟化收益下降
因此:
overscan 是性能和滚动体验之间的平衡参数。
八、面试题:虚拟列表是怎么做到“数据 10 万条但 DOM 只有几十个”的?
核心思路(一句话)
虚拟列表把“数据规模”和“DOM 规模”解耦。
普通列表
数据量:
100000
DOM:
≈ 100000
虚拟列表
数据量:
100000
DOM:
≈ viewport / itemHeight + overscan
例如:
容器高度:600px
item 高度:50px
overscan:5
可见:
12
实际渲染:
12 + 5 + 5
≈ 22 个
所以列表从:
100000 DOM
下降到:
约 22 DOM
这才是虚拟列表真正的性能收益来源。
九、面试题:虚拟列表有哪些性能收益?
核心思路(一句话)
虚拟列表最核心的收益是把“与总数据量相关的实际渲染规模”降低为“与视口大小相关的渲染规模”。
主要收益
1. 降低 DOM 数量
100000 → 20~50
2. 降低初始渲染成本
不需要一次性创建所有列表项。
3. 降低内存占用
少量 DOM、组件和 Fiber 节点常驻。
4. 降低浏览器布局和绘制成本
浏览器需要维护的真实节点明显减少。
5. 降低列表更新成本
更新时实际参与渲染的列表项数量减少。
但有一个重要纠正
说虚拟列表属于:
“时间换空间”。
这个说法不够准确。
更准确应该说:
虚拟列表通过增加可视区域计算、滚动管理、位置计算、动态高度测量等额外运行时逻辑,换取更少的 DOM、组件实例和浏览器布局/绘制开销。
它不是简单意义上的“时间换空间”。
十、面试题:React 虚拟列表应该如何处理滚动事件?
核心思路(一句话)
滚动事件属于高频事件,核心不是简单使用防抖,而是控制滚动过程中的计算和 React 更新频率,通常优先考虑 requestAnimationFrame 或合理节流。
这里需要特别注意:
不推荐简单 debounce
因为:
用户开始快速滚动
↓
debounce 一直等待
↓
可视区域更新不及时
↓
容易出现白屏
更适合:
scroll
↓
requestAnimationFrame
↓
每一帧最多更新一次
↓
计算可视区域
↓
更新渲染窗口
推荐流程
scroll event
↓
读取 scrollTop
↓
requestAnimationFrame
↓
计算 startIndex / endIndex
↓
更新渲染范围
↓
React 更新
十一、面试题:动态高度列表怎么实现虚拟化?
核心思路(一句话)
动态高度的核心难点是:在不知道所有真实高度的情况下,仍然要快速计算每一项的位置,因此需要“预估高度 + 实际测量 + 高度缓存 + 位置重新计算”。
固定高度
非常简单:
index
↓
index × itemHeight
↓
top
例如:
第 100 项:
top = 100 × 50
= 5000px
动态高度
假设:
Item 0 = 50px
Item 1 = 80px
Item 2 = 120px
Item 3 = 60px
那么:
Item 0:
top = 0
Item 1:
top = 50
Item 2:
top = 50 + 80
= 130
Item 3:
top = 50 + 80 + 120
= 250
所以:
动态高度列表的核心是前缀和。
动态高度架构
数据
↓
预估高度
↓
首次渲染
↓
DOM 实际渲染
↓
ResizeObserver / DOM 测量
↓
获得真实高度
↓
更新 heightCache
↓
重新计算 prefix sum
↓
重新计算列表项位置
↓
重新计算总高度
↓
更新虚拟列表
高度缓存
const heightCache = new Map();
heightCache.set(0, 50);
heightCache.set(1, 80);
heightCache.set(2, 120);
然后:
heightCache
↓
计算每个 item 的 top
↓
计算总高度
十二、面试题:为什么动态高度虚拟列表比固定高度复杂很多?
核心思路(一句话)
固定高度可以 O(1) 直接定位,而动态高度需要维护高度信息并计算累计高度,因此定位、滚动、缓存和测量都会变复杂。
固定高度
startIndex = floor(scrollTop / itemHeight)
直接 O(1)。
动态高度
例如:
scrollTop = 10000
你需要知道:
第几个 item 的累计高度刚好超过 10000?
逻辑类似:
height[0]
+ height[1]
+ height[2]
+ …
+ height[n]
这就涉及:
前缀和
二分查找
高度缓存
动态测量
位置修正
更进一步的优化
如果列表非常大,可以使用:
高度前缀和数组
↓
二分查找 scrollTop 对应的 index
从而避免每次从头累加。
十三、面试题:动态高度列表第一次渲染时高度不知道怎么办?
核心思路(一句话)
先使用估算高度建立初始布局,真正渲染后再测量实际高度,然后更新缓存和后续元素的位置。
流程
未知真实高度
↓
estimatedHeight
↓
建立初始虚拟布局
↓
渲染
↓
测量真实高度
↓
更新 heightCache
↓
重新计算位置
↓
重新计算总高度
常见测量方案
可以使用:
ResizeObserver
例如:
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const height = entry.contentRect.height;
// 更新对应列表项的高度缓存
// 然后触发布局重新计算
}
});
相比手动频繁读取:
element.getBoundingClientRect()
ResizeObserver 更适合监听尺寸变化。
十四、面试题:React 虚拟列表使用第三方库还是自己实现?
核心思路(一句话)
生产环境优先使用成熟虚拟化方案,手写实现主要用于理解原理、特殊场景定制以及面试考察。
自己实现
优点:
完全可控
↓
可以针对业务定制
↓
可以深入理解原理
缺点:
固定高度
↓
动态高度
↓
横向滚动
↓
键盘导航
↓
焦点管理
↓
overscan
↓
滚动位置恢复
↓
ResizeObserver
↓
数据变化
↓
插入 / 删除
↓
锚点定位
↓
浏览器兼容
这些都会逐渐变复杂。
因此生产环境通常考虑成熟方案
- React Window
- React Virtualized
- TanStack Virtual
其中 React Window 为轻量方案,React Virtualized 为功能更加丰富的方案,还有无头虚拟化方案。
需要注意的是:
具体库的 API 和能力会随版本变化,面试时重点应该放在虚拟化原理,而不是死记某个库的 API。
十五、面试题:React Window 的固定高度列表怎么使用?
核心思路(一句话)
固定高度场景直接告诉虚拟列表容器高度、数据数量以及每项高度,由库负责计算可视范围和节点定位。
典型思想:
<FixedSizeList
height={500}
itemCount={100000}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
代码示例
import React, { useMemo } from 'react';
import { FixedSizeList } from 'react-window';
/**
* 列表行组件。
*
* React Window 会把当前需要渲染的行对应的参数传递给这里。
*/
function Row({ index, style }) {
return (
<div
/**
* 注意:
* style 是虚拟列表库计算好的定位样式。
*
* 不应该随意覆盖其中的 top、height、position 等关键属性。
*/
style={{
…style,
/**
* 这里只添加业务自己的视觉样式。
*/
display: 'flex',
alignItems: 'center',
padding: '0 16px',
borderBottom: '1px solid #eee',
boxSizing: 'border-box',
}}
>
第 {index + 1} 项
</div>
);
}
export default function App() {
/**
* 模拟大量数据。
*
* 真正业务中通常会从接口获取数据。
*/
const items = useMemo(() => {
return Array.from(
{ length: 100000 },
(_, index) => ({
id: index,
name: `商品 ${index + 1}`,
})
);
}, []);
return (
<FixedSizeList
/**
* 虚拟列表可视区域高度。
*/
height={500}
/**
* 列表总数据量。
*/
itemCount={items.length}
/**
* 每一项固定高度。
*/
itemSize={50}
/**
* 宽度可以根据业务调整。
*/
width="100%"
>
{Row}
</FixedSizeList>
);
}
十六、面试题:可变高度和固定高度应该怎么选择?
核心思路(一句话)
如果列表项高度稳定,优先固定高度;只有确实存在不同高度时才引入动态高度,因为动态高度会显著增加测量和布局复杂度。
固定高度
适合:
商品列表
用户列表
简单消息列表
后台数据表格
日志列表
例如:
每项统一 48px
这是虚拟化最容易实现、性能也最稳定的情况。
动态高度
适合:
聊天消息
富文本
展开/收起内容
不同长度的卡片
评论
文章列表
十七、面试题:虚拟列表有什么缺点和限制?
核心思路(一句话)
虚拟列表解决的是“大量 DOM 渲染”问题,但会增加布局、滚动、焦点、动态高度和可访问性等复杂度,并不能解决列表项自身计算过重的问题。
1. 动态高度复杂
需要测量
↓
缓存
↓
重新计算位置
↓
处理滚动位置修正
2. 快速滚动可能出现白屏
例如:
用户快速拖动滚动条
↓
scrollTop 突然跳跃几千像素
↓
需要立即计算新区域
↓
新节点还没有完成渲染
↓
短暂白屏
解决:
overscan
+
减少单项渲染成本
+
合理控制滚动更新
3. 列表项本身很重时,虚拟列表仍然可能卡
例如:
<Item>
<复杂图表 />
<复杂富文本 />
<大量计算 />
<多个图片 />
</Item>
即使一次只有:
20 个 Item
如果每个 Item 都很重:
20 × 很重
仍然可能卡顿。
所以:
虚拟列表解决的是“节点太多”,不是“单节点太重”。
4. 键盘导航更加复杂
例如:
Tab
↑
↓
Home
End
PageUp
PageDown
如果元素实际上没有挂载:
用户无法直接 focus 一个当前没有 DOM 节点的列表项。
因此需要设计:
activeIndex
+
focus management
+
scrollToItem
5. 浏览器查找功能体验可能受影响
例如用户:
Command + F
搜索:
第 90000 条数据
如果第 90000 条目前没有 DOM:
浏览器原生查找可能找不到它。
因此一些业务需要额外实现:
搜索
↓
得到 index
↓
scrollToIndex(index)
↓
挂载目标节点
6. SEO 需要谨慎
有资料指出虚拟列表对于需要搜索引擎优化的公开页面可能存在问题,因为初始 DOM 中并没有所有列表内容。
更准确地说:
如果页面依赖客户端虚拟化才能产生大量内容,搜索引擎抓取和内容发现可能受到影响。
但并不是:
“用了虚拟列表就一定没有搜索引擎优化。”
如果使用:
服务端渲染
+
预渲染
+
结构化数据
+
合理分页
+
可访问的 URL
仍然可以改善搜索引擎可发现性。
对于真正的公开内容页面,分页通常比无限虚拟列表更容易做好搜索引擎优化和可分享性。
十八、面试题:虚拟列表和分页、无限滚动有什么区别?
核心思路(一句话)
分页解决的是“数据获取量”,虚拟列表解决的是“DOM 渲染量”,无限滚动主要解决的是“用户连续浏览数据”的交互方式,三者可以组合使用。
分页
服务器
↓
只获取第 1 页
↓
20 条
↓
用户点击下一页
↓
再获取 20 条
主要解决:
网络传输
数据获取
服务器压力
虚拟列表
已经有 100000 条数据
↓
只渲染当前 20~50 条
主要解决:
DOM
React 渲染
浏览器布局绘制
无限滚动
用户滚动到底部
↓
请求下一批数据
↓
追加数据
主要解决:
连续浏览体验
三者可以组合
服务器分页
↓
客户端持续加载
↓
累计 100000 条
↓
虚拟列表
↓
只渲染当前 30 条
这是实际项目中非常常见的方案。
十九、面试题:虚拟列表是不是数据量超过 1000 条就一定要用?
核心思路(一句话)
不能仅根据数据条数决定是否虚拟化,应该根据实际 DOM 数量、列表项复杂度、交互需求和性能指标决定。
不应该死记:
超过 1000 → 虚拟列表
超过 10000 → 虚拟列表
应该考虑:
列表项是否复杂?
↓
DOM 节点有多少?
↓
是否频繁更新?
↓
是否需要流畅滚动?
↓
是否动态高度?
↓
是否存在 SEO?
↓
是否需要键盘导航?
↓
实际性能指标如何?
例如:
1000 个非常简单的 div
可能完全没有问题。
但是:
500 个复杂卡片
+ 图片
+ 图表
+ 富文本
+ 多层组件
也可能已经需要优化。
二十、面试题:虚拟列表和 React.memo 能不能一起使用?
核心思路(一句话)
可以,而且两者解决的是不同问题:虚拟列表减少“同时存在多少节点”,React.memo 减少“已经存在的节点需要重复渲染多少次”。
两者关系
虚拟列表
↓
减少节点数量
React.memo
↓
减少不必要的组件重新渲染
所以:
100000 条
↓
虚拟列表
↓
实际 30 条
↓
React.memo
↓
这 30 条中只有真正发生变化的才重新渲染
但不要滥用
如果:
Item 本身非常简单
那么:
React.memo
增加的比较成本可能没有明显收益。
应该通过:
React DevTools Profiler
确认真正的性能瓶颈。
二十一、面试题:虚拟列表和 React 并发渲染是什么关系?
核心思路(一句话)
虚拟列表减少工作量,React 并发渲染负责更合理地调度工作,两者属于不同层面的优化,可以组合但不能互相替代。
虚拟列表
解决:
工作太多
核心:
100000 DOM
↓
30 DOM
React 并发渲染
解决:
工作执行过程中如何避免长时间阻塞用户交互
例如:
用户输入
+
低优先级列表更新
可以让 React 更合理地安排更新。
所以:
虚拟列表
= 减少工作量
并发渲染
= 调度工作量
React.memo
= 减少重复工作
requestAnimationFrame
= 配合浏览器绘制节奏
分页 / 无限滚动
= 减少数据获取量
这是非常好的性能优化体系。
二十二、面试题:虚拟列表最核心的实现原理可以概括成什么?
核心思路(一句话)
用“总高度”模拟完整列表,用“可视区索引”决定渲染哪些项,用“绝对定位”把少量真实 DOM 放到完整列表对应的位置。
核心架构图
数据源
│
↓
┌─────────────────┐
│ Virtual List │
└─────────────────┘
│
┌─────────────┼─────────────┐
↓ ↓ ↓
scrollTop containerHeight itemHeight
│ │ │
└─────────────┼─────────────┘
↓
计算可视索引
↓
startIndex / endIndex
↓
overscan
↓
slice 数据
↓
渲染少量列表项
↓
absolute + top
↓
放到正确的位置
↓
用户看到完整列表
二十三、面试题:如果让你从零实现一个虚拟列表,你会怎么设计?
核心思路(一句话)
先实现固定高度版本,再抽象成动态高度版本,最后补齐滚动、缓存、overscan、键盘导航、数据变化和可访问性。
第一阶段:固定高度
先解决:
scrollTop
↓
startIndex
↓
endIndex
↓
slice
↓
absolute positioning
第二阶段:overscan
visible range
↓
扩大上下范围
↓
减少快速滚动白屏
第三阶段:动态高度
加入:
estimatedHeight
heightCache
ResizeObserver
prefix sum
binary search
第四阶段:复杂交互
处理:
插入
删除
排序
筛选
搜索定位
滚动到指定 index
键盘导航
焦点管理
第五阶段:工程优化
处理:
requestAnimationFrame
React.memo
稳定 key
图片懒加载
组件拆分
错误边界
性能监控
二十四、面试题:虚拟列表实现中有哪些容易踩坑的地方?
1. 使用 index 作为 key
不推荐:
items.map((item, index) => (
<Item key={index} />
))
尤其当列表存在:
插入
删除
排序
筛选
时容易造成组件状态错乱。
应该优先:
key={item.id}
2. scroll 事件中做大量计算
错误:
onScroll={() => {
// 大量计算
// 大量 setState
// 大量 DOM 操作
}}
应该:
scroll
↓
轻量读取 scrollTop
↓
requestAnimationFrame
↓
统一计算
3. overscan 太小
可能:
快速滚动
↓
白屏
4. overscan 太大
可能:
实际渲染节点过多
↓
虚拟列表收益下降
5. 动态高度导致滚动跳动
例如:
预估高度:50px
真实高度:300px
测量后:
后续所有元素位置发生变化
如果没有正确处理:
用户当前位置可能突然跳动
6. 列表项内部有昂贵计算
例如:
<Item>
<HugeChart />
<RichText />
<ComplexCalculation />
</Item>
这时候还需要:
React.memo
useMemo
数据预计算
图片懒加载
拆分组件
7. 忽略可访问性
需要考虑:
键盘导航
aria 属性
焦点管理
当前活动项
屏幕阅读器
二十五、面试题:虚拟列表、懒加载和分页是不是一回事?
核心思路(一句话)
不是,它们优化的是不同层面:分页优化数据获取,懒加载优化资源加载,虚拟列表优化 DOM 渲染。
三者对比
| 分页 | 数据一次获取太多 | 网络 / 服务端 |
| 无限滚动 | 连续获取数据 | 交互 / 数据获取 |
| 图片懒加载 | 图片资源太多 | 网络 / 图片 |
| 虚拟列表 | DOM 节点太多 | React / DOM / 浏览器 |
| React.memo | 重复渲染 | React |
| requestAnimationFrame | 高频更新 | 浏览器帧调度 |
实际项目通常组合使用。
二十六、面试题:如果面试官问“React 中如何优化超长列表”,应该怎么回答?
核心思路(一句话)
先判断瓶颈是数据获取、DOM 数量、组件重复渲染还是单项计算,再针对性采用分页/虚拟列表、React.memo、数据缓存和资源懒加载等方案。
推荐回答逻辑
第一步:定位瓶颈
↓
数据获取过多?
↓
分页 / 无限滚动
DOM 太多?
↓
虚拟列表
组件重复渲染?
↓
React.memo / 稳定 props
单个 Item 很重?
↓
拆分组件 / useMemo / 预计算
图片太多?
↓
图片懒加载 / 响应式图片
滚动更新太频繁?
↓
requestAnimationFrame / 合理节流
动态高度?
↓
高度缓存 + ResizeObserver
公开 SEO 页面?
↓
考虑分页 / 服务端渲染 / 预渲染
二十七、面试题:虚拟列表的真正核心矛盾是什么?
核心思路(一句话)
用户需要“看到一个完整的长列表”,但浏览器没有必要“真实维护一个完整的长列表”。
主要矛盾
用户认知:
我看到的是 100000 条列表
但浏览器实际需要:
只维护:
当前可视区域
+
少量缓冲区域
于是:
逻辑列表 ≠ 真实 DOM 列表
这就是虚拟列表最核心的设计思想。
次要矛盾
实现过程中还要处理:
逻辑位置
vs
真实 DOM 位置
性能
vs
滚动流畅度
DOM 数量
vs
overscan
固定高度
vs
动态高度
渲染性能
vs
键盘导航
虚拟化
vs
SEO / 浏览器查找
二十八、面试题:为什么虚拟列表能够让 10 万条数据仍然保持较好的滚动性能?
核心思路(一句话)
因为滚动过程中变化的不是 10 万个 DOM,而只是一个很小的“渲染窗口”。
普通列表
100000 条
↓
100000 DOM
↓
滚动
↓
浏览器维护巨大 DOM
虚拟列表
100000 条数据
↓
当前窗口约 20 条
↓
实际 DOM 约 20~50 个
↓
滚动
↓
窗口从:
100~120
移动到:
110~130
↓
只需要增删/复用少量节点
所以:
性能的关键不是“100000 条数据本身”,而是“同时参与渲染的节点数量”。
二十九、最终满分答案
面试官:React 中如何实现虚拟列表?为什么虚拟列表可以优化长列表性能?
满分回答
在 React 中,如果一次性渲染几千甚至几万条列表数据,会产生大量 React 组件、Fiber 节点和真实 DOM,同时浏览器还需要承担较大的样式计算、布局和绘制成本,因此会导致初始化慢、内存占用增加以及滚动卡顿。
虚拟列表的核心思想是按需渲染:数据可以全部存在,但只渲染当前视口内以及上下少量缓冲区域的列表项。这样就把实际 DOM 数量从与总数据量相关,降低到了与视口大小相关。
它通常有一个固定高度的滚动容器,内部再放一个代表完整列表高度的容器,用它撑起正确的滚动条。用户滚动时,根据 scrollTop、容器高度和列表项高度计算当前的 startIndex 和 endIndex,然后只从完整数据中截取这一部分进行渲染。
对于固定高度列表,可以直接通过:
startIndex = floor(scrollTop / itemHeight)
来计算起始位置,再根据可视区域高度计算结束位置,并增加 overscan,避免快速滚动时出现白屏。
实际渲染出来的节点通常使用 position: absolute 定位,通过:
top = index × itemHeight
放到它在完整列表中的逻辑位置。
如果是动态高度列表,就不能简单使用 index × itemHeight,需要通过预估高度、真实 DOM 测量、高度缓存以及位置重新计算来维护布局,工程上通常会使用成熟的虚拟化方案,而不是完全手写。
另外,虚拟列表并不是万能的。如果列表项本身非常复杂,即使只渲染几十个节点仍然可能卡顿,这时候还需要结合 React.memo、useMemo、图片懒加载、数据预计算等方式优化列表项本身。对于动态高度、键盘导航、焦点管理、浏览器查找以及搜索引擎优化等场景,也需要额外设计。
所以我会把长列表优化理解为一个完整的性能优化体系:
数据获取过多
↓
分页 / 无限滚动
DOM 数量过多
↓
虚拟列表
组件重复渲染
↓
React.memo / 稳定 props
单个列表项太重
↓
组件拆分 / useMemo / 预计算
图片资源太多
↓
图片懒加载
滚动更新频繁
↓
requestAnimationFrame / 合理节流
因此,虚拟列表最核心的本质就是:让用户感觉自己在浏览一个完整的超长列表,但浏览器实际上只维护当前真正需要的少量 DOM 节点。
三十、最后记忆版:虚拟列表面试只记住这 8 句话
① 为什么做?
长列表最大的问题是实际渲染节点太多。
② 核心思想?
数据全量存在,DOM 按需渲染。
③ 怎么知道渲染哪些?
根据 scrollTop 和容器高度计算可视区索引。
④ 为什么需要总高度?
让浏览器产生正确的滚动条。
⑤ 为什么绝对定位?
把少量真实 DOM 放到完整列表对应的逻辑位置。
⑥ 为什么 overscan?
提前渲染上下少量节点,避免快速滚动白屏。
⑦ 动态高度怎么办?
预估高度 + 实际测量 + 高度缓存 + 重新计算位置。
⑧ 有什么限制?
虚拟列表解决的是节点数量问题,不解决单个节点过重的问题,同时要考虑动态高度、键盘导航、焦点管理、浏览器查找和搜索引擎优化。
一句话终极总结
虚拟列表
=
用“总高度”模拟完整列表
+
用“可视区计算”决定渲染范围
+
用“overscan”保证滚动体验
+
用“绝对定位”恢复真实位置
+
用“少量 DOM”换取长列表性能
这就是 React 虚拟列表最核心的原理。
网硕互联帮助中心


评论前必须登录!
注册