前言
今天看到一篇关于虚拟滚动的文章,突然又想起了这个曾经面试时反复背诵的技术点——四个核心要素:容器固定高度、计算可视区域、只渲染可见项、使用垫高元素撑开滚动条。
虽然现在业务中暂时没有涉及大数据量的场景,已经很久没有亲手实现过虚拟滚动了,但回想起来,这项技术依然很重要。如今的组件库已经把这项能力内置了——比如 Ant Design 早在 2022 年发布的 5.x 版本就已经正式支持虚拟滚动,用起来简单多了。
一、核心思路:只渲染“看得见”的
好比在高铁看窗外的风景:
- 你只需要关心窗外当前看见的树。
- 你不需要把沿途几千公里的树都搬进车厢。
虚拟滚动也是这个道理:
- 容器固定:外层盒子高度固定(比如 500px),开启
overflow: auto。 - 计算可视区:根据滚动条位置(
scrollTop),算出当前应该显示哪几项数据(比如第 100 项到第 110 项)。 - 只渲染这几项:DOM 里永远只有这 10 个左右的
<div>,不管总数据有多少。 - 占位撑开:为了不让滚动条消失,我们需要用一个巨大的空白
div(垫高元素)把容器撑起来,假装我们有 10 万行数据。
二、React 实战代码
写一个hooks
export function useVirtualScroll<T>(
items: T[],
itemHeight: number, //单项高度
containerHeight: number, //容器高度
overscan = 5 //缓冲
) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const visibleRange = useMemo(() => {
// 当前可见的第一项索引 = 滚动距离 / 单项高度
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(
//当前可见的最后一项索引=起始索引+可视区域的容纳数加上缓冲项(前后多渲染几个防止空白)
startIndex + Math.ceil(containerHeight / itemHeight) + overscan,
items.length
);
return {
startIndex: Math.max(0, startIndex - overscan),
endIndex,
offsetY: startIndex * itemHeight //偏移量,向上抵消一段距离
};
}, [scrollTop, itemHeight, containerHeight, items.length, overscan]);
const visibleItems = useMemo(() => {
//从接口返回的数据里生成当前需要渲染的数据切片
return items.slice(visibleRange.startIndex, visibleRange.endIndex);
}, [items, visibleRange.startIndex, visibleRange.endIndex]);
//容器
const totalHeight = useMemo(() => {
return items.length * itemHeight;
}, [items.length, itemHeight]);
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
setScrollTop(event.currentTarget.scrollTop);
}, []);
return {
containerRef,
visibleItems,
visibleRange,
totalHeight,
handleScroll,
scrollTop
};
}
三、关键概念解析
3.1 scrollTop vs offsetY 的区别
这是最容易混淆的概念:
scrollTop:用户滚动了多少距离(滚动容器的状态)offsetY:需要向上偏移多少距离(用于占位元素的高度)
// 滚动到第100项时
scrollTop = 5000; // 用户滚动了5000px
startIndex = 100; // 应该显示第100项
offsetY = 5000; // 用5000px高度的占位元素把前100项"顶"上去
// DOM结构
<div style={{ height: 5000 }} /> // 占位元素,用户看不到
<div>Item 100</div> // 从这里开始渲染用户能看到的内容
<div>Item 101</div>
3.2 overscan 缓冲区的作用
// 没有缓冲区的问题
// 屏幕能显示10个项目,用户快速滚动
// 滚动过程中可能出现白屏(来不及渲染新项目)
// 有缓冲区的情况
// 实际渲染15个项目(10个可见 + 5个缓冲)
// 用户滚动时,缓冲区的项目立即可见,避免白屏
四、实际使用示例
function VirtualListDemo() {
// 模拟10000条数据
const items = useMemo(() =>
Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `Item ${i + 1}`,
description: `这是第${i + 1}项的详细描述`
})),
[]
);
const {
containerRef,
visibleItems,
visibleRange,
totalHeight,
handleScroll
} = useVirtualScroll(items, 60, 400, 5);
return (
<div style={{ padding: 20 }}>
<h2>虚拟滚动示例</h2>
<div
ref={containerRef}
onScroll={handleScroll}
style={{
height: 400,
overflow: 'auto',
border: '1px solid #ddd',
borderRadius: 4
}}
>
{/* 顶部占位元素 */}
<div style={{ height: visibleRange.offsetY }} />
{/* 可见项目 */}
{visibleItems.map((item, index) => {
const actualIndex = visibleRange.startIndex + index;
return (
<div
key={actualIndex}
style={{
height: 60,
borderBottom: '1px solid #eee',
display: 'flex',
alignItems: 'center',
paddingLeft: 16,
backgroundColor: actualIndex % 2 === 0 ? '#f9f9f9' : 'white'
}}
>
<span style={{ fontWeight: 'bold', color: '#1890ff' }}>
{item.name}
</span>
<span style={{ marginLeft: 12, color: '#999', fontSize: '12px' }}>
{item.description}
</span>
</div>
);
})}
{/* 底部占位元素 */}
<div style={{ height: totalHeight - visibleRange.endIndex * 60 }} />
</div>
<div style={{ marginTop: 10, fontSize: '14px', color: '#666' }}>
显示范围: {visibleRange.startIndex + 1} - {visibleRange.endIndex} |
实际渲染: {visibleItems.length} 项 |
总计: {items.length} 项
</div>
</div>
);
}
五、性能对比
普通列表 vs 虚拟滚动
// 普通列表:10000个项目 = 10000个DOM节点
// 内存占用:几百MB,FPS:<10
// 虚拟滚动:10000个项目 = 20个DOM节点(可见项+缓冲)
// 内存占用:几MB,FPS:60
六、适用场景
适合使用虚拟滚动:
- 长列表(>1000条数据)
- 表格展示大量数据
- 日志查看器
- 聊天记录(历史消息很多)
不适合使用:
- 数据量小(<100条)
- 需要SEO的内容
- 高频交互的列表项
- 简单的导航菜单
七、常见问题及解决方案
虚拟滚动通常用于在同一视图内浏览海量数据因此在Ant Design组件库中常与 pagination={false} 搭配使用。不过二者并不是不能共同使用的,只要对每页的数据使用虚拟滚动即可。
八、结尾
虽然现在主流组件库已经内置了虚拟滚动能力,AI辅助开发也越来越便捷,我觉得理解其原理仍然很重要。一方面,在面对复杂业务场景时,咱能够灵活调整;另一方面,当需要借助AI IDE,解决问题时,扎实的原理基础能让我们更准确地描述问题从而更快解决嘛。