Virtual 入门:虚拟化原理
本教程共 38 篇 · 第 35 篇 · 更新于 2026-07-27 · 约 10 分钟阅读
35. Virtual 入门:虚拟化原理
本节目标:理解虚拟化(Virtualization)的底层原理,学会安装 TanStack Virtual 并用
useVirtualizer实现基础列表虚拟化,搞懂VirtualItem的结构和定位方式。学完你能让万级列表流畅滚动。
35.1 为什么需要虚拟化
假设你有一个 10000 条数据的列表。如果你直接 map 渲染 10000 个 DOM 节点,浏览器会卡到爆—创建上万个 DOM 节点很慢,渲染、布局、绘制都是大开销。
但你想想:屏幕就那么大,能看到的也就二三十条。剩下 9970 条都藏在滚动区域里,看不到也要渲染,纯属浪费。
**虚拟化(Virtualization)**就是解决这个问题的:只渲染当前可视区域内的条目,滚动时动态替换。用户看起来列表是完整的(有完整的滚动条),但实际上 DOM 里只有二三十个节点。
打个比方:你看一栋 100 层的大楼,眼睛能看到的就那几层。虚拟化就是「你看到哪层,就建哪层」,而不是一次性把 100 层全盖好。楼看起来还是 100 层(滚动条高度对),但实际建筑材料省了 99%。
Note虚拟化也叫「窗口化(Windowing)」,因为只有「窗口」内的内容被渲染。
35.2 虚拟化的核心原理
虚拟化的工作原理可以拆成三步:
- 算出总高度:知道每条的高度和总条数,算出列表的总高度,撑开滚动条。
- 算出可视范围:监听滚动位置,算出当前可视区域内应该显示哪些条目。
- 定位渲染:只渲染可视范围内的条目,用绝对定位把它们放到正确的位置。
┌─────────────────────────┐
│ 滚动容器(固定高度) │ ← overflow: auto
│ ┌───────────────────┐ │
│ │ 条目 0 (不渲染) │ │
│ │ 条目 1 (不渲染) │ │
│ │ ... │ │
│ │ 条目 20 (渲染) ←──┼──┼── 可视区域上边
│ │ 条目 21 (渲染) │ │
│ │ 条目 22 (渲染) │ │
│ │ ... │ │
│ │ 条目 30 (渲染) ←──┼──┼── 可视区域下边
│ │ 条目 31 (不渲染) │ │
│ │ ... │ │
│ │ 条目 9999 (不渲染) │ │
│ └───────────────────┘ │ ← 内层容器(总高度撑开滚动条)
└─────────────────────────┘
滚动时,可视范围变化,渲染的条目跟着变。但 DOM 节点数量始终只有二三十个。
35.3 安装
npm install @tanstack/react-virtual
核心逻辑在 @tanstack/virtual-core 里,React 适配器包已经依赖了。
Note本教程基于 TanStack Virtual v3(当前稳定版 3.14.x)。v3 的 API 和 v2 有较大变化,主要在 Hook 命名和配置项上。
35.4 useVirtualizer:虚拟化的核心 Hook
useVirtualizer 是 React 适配器的核心 Hook。先看一个最小虚拟化列表:
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
function VirtualList() {
// 滚动容器
const parentRef = useRef<HTMLDivElement>(null)
// 虚拟化器
const rowVirtualizer = useVirtualizer({
count: 10000, // 总条目数
getScrollElement: () => parentRef.current, // 滚动容器
estimateSize: () => 35, // 每条预估高度
})
return (
<div
ref={parentRef}
style={{ height: 400, overflow: 'auto' }}
>
{/* 撑开滚动条的容器 */}
<div
style={{
height: rowVirtualizer.getTotalSize(), // 总高度
position: 'relative',
}}
>
{/* 只渲染可视区域的条目 */}
{rowVirtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: virtualItem.size,
transform: `translateY(${virtualItem.start}px)`, // 定位
}}
>
第 {virtualItem.index} 行
</div>
))}
</div>
</div>
)
}
这段代码实现了 10000 条数据的虚拟化,但 DOM 里始终只有二三十个节点。
35.5 拆解关键配置
35.5.1 count:总条目数
count: 10000
告诉虚拟化器总共有多少条数据。不是数据的值,只是数量。虚拟化器不需要你的数据,它只管「第几条应该渲染在哪」。
35.5.2 getScrollElement:滚动容器
getScrollElement: () => parentRef.current
一个返回滚动 DOM 元素的函数。虚拟化器需要监听这个元素的滚动事件和尺寸变化。用 useRef 拿到 DOM 引用。
Warning
getScrollElement返回的是函数,不是直接传 ref。因为虚拟化器需要在挂载后拿 DOM 元素,函数形式能保证拿到最新的引用。直接传parentRef.current可能是null(挂载前)。
35.5.3 estimateSize:预估尺寸
estimateSize: () => 35
每条的预估高度(垂直列表)或宽度(水平列表),单位是像素。这个值越准确,滚动体验越好。不准的话滚动条长度会跳变。
estimateSize 也可以接收索引参数,给不同条目设不同的预估值:
estimateSize: (index) => {
// 偶数行高 35,奇数行高 50
return index % 2 === 0 ? 35 : 50
}
Tip如果用动态测量(下一章讲),
estimateSize设成最大可能高度更合理。这样初始位置更准确,减少滚动时的跳动。
35.6 VirtualItem 结构
getVirtualItems() 返回一个 VirtualItem 数组,每个对象描述一个需要渲染的条目:
interface VirtualItem {
key: string | number // 唯一标识
index: number // 条目索引
start: number // 起始像素偏移
end: number // 结束像素偏移
size: number // 条目尺寸(高度或宽度)
lane: number // 通道索引(网格/瀑布流用)
}
35.6.1 key
条目的唯一标识。默认是索引,但建议用 getItemKey 自定义:
const rowVirtualizer = useVirtualizer({
count: data.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
getItemKey: (index) => data[index].id, // 用数据的 id 当 key
})
用稳定的 id 当 key,列表数据变化时 React 能正确复用 DOM,避免闪烁。
35.6.2 index
条目在原始数据中的索引。用它从数据数组里取对应的数据:
const virtualItem = rowVirtualizer.getVirtualItems()[0]
const itemData = data[virtualItem.index] // 拿到这条的数据
35.6.3 start 和 size
start 是条目在列表中的起始像素偏移,size 是条目的高度(或宽度)。这俩决定了条目在 DOM 中的位置:
style={{
position: 'absolute',
top: 0,
left: 0,
height: virtualItem.size,
transform: `translateY(${virtualItem.start}px)`,
}}
用 transform: translateY() 而不是 top: virtualItem.start,因为 transform 不触发重排(reflow),性能更好。
Note条目的
position必须是absolute,并且top/left设为0,然后用transform移动到正确位置。这是虚拟化的标准定位方式。
35.7 渲染流程详解
把上面的组合起来,完整渲染流程是:
- 滚动容器设固定高度 +
overflow: auto。 - 内层容器高度设为
getTotalSize()(所有条目的总高度),撑开滚动条。 - 内层容器设
position: relative,作为绝对定位条目的参考。 - 遍历
getVirtualItems(),只渲染可视条目。 - 每个条目
position: absolute+transform: translateY(start)。
function MyVirtualList({ data }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: data.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
overscan: 5, // 上下额外渲染 5 条
getItemKey: (index) => data[index].id,
})
return (
<div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: virtualItem.size,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{data[virtualItem.index].name}
</div>
))}
</div>
</div>
)
}
35.8 overscan:额外渲染的条目数
overscan: 5
overscan 是在可视区域上下额外渲染的条目数。默认是 1。
为什么需要它?快速滚动时,虚拟化器可能来不及渲染新进入可视区域的条目,导致短暂出现空白。多渲染几条作为缓冲,滚动时空白就不明显了。
┌──────────────────┐
│ 额外渲染 (overscan) │
├──────────────────┤
│ │
│ 可视区域 │
│ │
├──────────────────┤
│ 额外渲染 (overscan) │
└──────────────────┘
Tip
overscan不是越大越好。值大了 DOM 节点多,性能下降。通常 3-5 够了。如果条目渲染很慢(比如有复杂图片),可以适当增大。
35.9 用真实数据渲染
上面的例子渲染的是简单的文字。实际项目里每条可能有复杂内容:
type Item = { id: number; name: string; description: string }
function ItemList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60,
overscan: 5,
getItemKey: (index) => items[index].id,
})
return (
<div ref={parentRef} style={{ height: 500, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = items[virtualItem.index]
return (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: virtualItem.size,
transform: `translateY(${virtualItem.start}px)`,
padding: '12px',
borderBottom: '1px solid #e5e7eb',
boxSizing: 'border-box',
}}
>
<div className="font-medium">{item.name}</div>
<div className="text-sm text-gray-500">{item.description}</div>
</div>
)
})}
</div>
</div>
)
}
35.10 useWindowVirtualizer:整页滚动
如果你的列表不是在某个固定高度的容器里滚动,而是整页滚动(列表占满浏览器窗口),用 useWindowVirtualizer:
import { useWindowVirtualizer } from '@tanstack/react-virtual'
function FullPageList({ items }: { items: Item[] }) {
const virtualizer = useWindowVirtualizer({
count: items.length,
estimateSize: () => 60,
overscan: 5,
getItemKey: (index) => items[index].id,
// 不需要 getScrollElement,默认用 window
})
return (
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: virtualItem.size,
transform: `translateY(${virtualItem.start}px)`,
padding: '12px',
borderBottom: '1px solid #e5e7eb',
}}
>
{items[virtualItem.index].name}
</div>
))}
</div>
)
}
useWindowVirtualizer 不需要 getScrollElement,它直接用 window 作为滚动容器。
Note整页虚拟化时,列表上方有固定高度内容(如导航栏),用
scrollMargin选项告诉虚拟化器列表起始位置。否则滚动偏移计算会不对。
35.11 常见坑
坑一:内层容器没设 position: relative。 绝对定位的条目找不到参考点,全部相对于最近的定位祖先元素(可能是 body)定位,位置全错。
坑二:用 top 而不是 transform 定位。 top 改变会触发重排,滚动时卡顿。用 transform: translateY() 只触发合成层,性能好很多。
坑三:estimateSize 严重不准。 预估 35px 但实际 100px,滚动条长度先按 35px 算,实际渲染后发现不对又跳变。预估值尽量接近实际,或者用动态测量(下一章讲)。
坑四:getScrollElement 返回 null。 ref 还没挂载时 parentRef.current 是 null。虚拟化器会处理 null,但如果你手动调用虚拟化器方法时 ref 还没就绪,可能出错。在 useEffect 里调用更安全。
坑五:滚动容器没设固定高度。 外层容器没有明确高度,height: auto 的话内层容器撑开后外层也跟着撑开,不产生滚动条。外层必须设固定高度(height: 400px 或 flex: 1 等)。
35.12 小结
这一章你认识了 TanStack Virtual:
- 虚拟化原理:只渲染可视区域内的条目,用绝对定位 + transform 定位,撑开总高度保证滚动条正确。
- useVirtualizer:核心 Hook,配置
count(总数)、getScrollElement(滚动容器)、estimateSize(预估尺寸)。 - VirtualItem:
key(标识)、index(数据索引)、start(起始偏移)、size(尺寸),用来定位和渲染。 - overscan:额外渲染的缓冲条目数,防止快速滚动时空白。
- useWindowVirtualizer:整页滚动场景用,不需要指定滚动容器。
下一章讲 Virtual 进阶:动态尺寸测量、水平虚拟化、网格虚拟化,以及和 Table 的集成。