虚拟列表实现原理深度解析
从定高到动态高度,从 offset 定位到 transform 优化,彻底搞懂虚拟列表的核心原理和实现细节
一句话概括
虚拟列表把「渲染 10000 个 DOM」变成「渲染屏幕上能看到的 10-20 个 DOM」——通过一个占位元素撑起正确的滚动条高度,再用滚动位置反算当前该显示哪些数据项,让浏览器的 DOM 节点数从 O(n) 降到 O(1)。
核心知识点
1. 核心公式:一个除法决定一切
虚拟列表的数学本质就两个公式:
1
2
3
const startIndex = Math.floor(scrollTop / itemHeight) // 从第几个开始
const endIndex = Math.ceil((scrollTop + containerHeight) / itemHeight) // 到第几个结束
const offsetY = startIndex * itemHeight // 数据块的视觉偏移量
用这三个值渲染的列表:
- 顶部放一个
height = offsetY的空白 div(撑出正确的起始位置) - 中间渲染
items.slice(startIndex, endIndex)的真实 DOM - 底部自然落在剩余空间内
1
2
3
4
5
6
7
8
9
<div class="container" style="height:600px; overflow:auto" onscroll="render()">
<div class="phantom" style="height:10000px"></div> <!-- 撑起完整滚动高度 -->
<div class="viewport" style="transform:translateY(500px); position:absolute">
<!-- 只有当前可见的 DOM -->
<div class="item">Item 10</div>
<div class="item">Item 11</div>
...
</div>
</div>
2. 定高虚拟列表:20 行代码就能跑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function FixedVirtualList({ items, itemHeight = 50, containerHeight = 600 }) {
const [scrollTop, setScrollTop] = useState(0)
const totalHeight = items.length * itemHeight
const startIndex = Math.floor(scrollTop / itemHeight)
const endIndex = Math.min(
Math.ceil((scrollTop + containerHeight) / itemHeight),
items.length
)
const visibleItems = items.slice(startIndex, endIndex)
return (
<div
style={{ height: containerHeight, overflow: 'auto' }}
onScroll={e => setScrollTop(e.currentTarget.scrollTop)}
>
{/* 占位层:撑起完整滚动高度 */}
<div style={{ height: totalHeight }} />
{/* 可视层:用 transform 偏移到正确位置,避免 reflow */}
<div style={{ transform: `translateY(${startIndex * itemHeight}px)` }}>
{visibleItems.map((item, i) => (
<div key={startIndex + i} style={{ height: itemHeight }}>
{item}
</div>
))}
</div>
</div>
)
}
为什么用 transform: translateY 而不是 top? top 触发 layout/reflow,transform 只触发 composite——GPU 合成线程处理,不阻塞主线程。
3. 动态高度:缓存是灵魂
实际场景中列表项高度几乎不会一致。核心思路:维护一个高度缓存数组 positions,滚动时二分查找 startIndex。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 每个列表项的元信息
interface ItemPosition {
index: number
top: number // 该项顶部距离列表顶部的距离
bottom: number // 该项底部距离列表顶部的距离
height: number // 该项的实际高度(首次测量后写入)
}
// 给定 scrollTop,二分找到第一个 bottom > scrollTop 的项
function findStartIndex(positions: ItemPosition[], scrollTop: number): number {
let low = 0, high = positions.length - 1
while (low <= high) {
const mid = (low + high) >> 1
if (positions[mid].bottom <= scrollTop) low = mid + 1
else high = mid - 1
}
return low
}
关键点:虚拟列表首次渲染时,height 是估算值(比如 50)。DOM 渲染后用 getBoundingClientRect() 拿到真实高度后,更新缓存并向后修正所有后续项的 top 和 bottom——这一步叫「缓存校准」,不做会导致滚动条跳动。
4. 缓冲区:防止快速滚动白屏
视口内显示 10 项时,快速滚动可能出现短暂白屏——因为 onScroll 还没触发,新项还未渲染。解决方案:在可视区域上下各多渲染 3-5 项缓冲:
1
2
3
4
5
6
7
8
const BUFFER = 4 // 上下各 4 项
const renderStart = Math.max(0, startIndex - BUFFER)
const renderEnd = Math.min(items.length, endIndex + BUFFER)
// 渲染范围 = 真的先用 startIndex 算 offset,但渲染时用 renderStart
// 偏移量修正:
const offsetY = renderStart > 0 ? positions[renderStart - 1]?.bottom ?? 0 : 0
5. 使用现成库:不要重复造轮子
生产环境直接用成熟的库:
1
2
3
4
5
6
7
8
9
10
11
12
13
// react-window(41KB,够用)
import { FixedSizeList } from 'react-window'
<FixedSizeList height={600} itemCount={10000} itemSize={50}>
{({ index, style }) => <div style={style}>Row {index}</div>}
</FixedSizeList>
// @tanstack/virtual(框架无关,支持动态高度)
import { useVirtualizer } from '@tanstack/react-virtual'
const virtualizer = useVirtualizer({
count: 10000,
getScrollElement: () => ref.current,
estimateSize: () => 50,
})
自己造轮子只在两种场景有意义:面试手写题 + 有特殊性能需求且能写对。
其实你每天都在用
- 微信聊天记录:几年几万条消息,往上翻时只有看到的几条有 DOM——这就是虚拟列表的典型场景。
- Ant Design / Element Plus 的 Table 组件:它们内置的
virtual属性开启后,100 万行表也不会崩。 - Notion / 飞书文档的大纲树:侧边栏无限嵌套的文档树,肉眼看不到的节点全被虚拟化了。
- VSCode 的文件列表:打开大项目时 File Explorer 不会卡,因为只渲染可见区域的文件项。
- 无限滚动的信息流:知乎、小红书往下刷,旧的 DOM 被回收、新的被创建——虚拟列表 + 数据分页的组合。
常见误解(FAQ)
❌ 误区:「虚拟列表就是懒加载,跟图片懒加载一样」
完全不同。图片懒加载是「不下载资源」,虚拟列表是「不创建 DOM」。一个省带宽,一个省 DOM 计算——虚拟列表的数据必须全量在内存里,只是不全部渲染。
❌ 误区:「虚拟列表总是更快」
列表项少于 100 条时,虚拟列表反而更慢——因为滚动计算和 DOM 交换有固定开销。经验值:超过 500 项再开虚拟化,否则直接渲染更简单。
❌ 误区:「transform: translateY 一定比 top 快」
用 transform 的前提是需要配合 will-change: transform 来提升到独立合成层。否则浏览器可能会为每个 scroll 事件重新合成。并且 will-change 也有代价——过度使用会导致 GPU 内存爆炸。
❌ 误区:「动态高度列表的预估高度可以随便填」
预估值越接近真实值,虚拟列表的初始布局越准确、后续校准开销越小。如果预估 50 实际 200,首次滚动时会频繁跳位置。好的做法是用实际数据的历史均值做预估。
一句话总结
虚拟列表是「用计算换 DOM」的典型——用几行除法和二分查找,把 O(n) 的渲染负担砍到 O(1),让浏览器可以平静地渲染百万级数据。