首页 / TanStack 生态入门教程 / Virtual 入门:虚拟化原理

TanStack 生态入门教程

Virtual 入门:虚拟化原理

本教程共 38 篇 · 第 35 篇 · 更新于 2026-07-27 · 约 10 分钟阅读

TanStackTanStack 生态入门教程TanStack Virtual虚拟化useVirtualizerVirtualItem性能优化

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 虚拟化的核心原理

虚拟化的工作原理可以拆成三步:

  1. 算出总高度:知道每条的高度和总条数,算出列表的总高度,撑开滚动条。
  2. 算出可视范围:监听滚动位置,算出当前可视区域内应该显示哪些条目。
  3. 定位渲染:只渲染可视范围内的条目,用绝对定位把它们放到正确的位置。
┌─────────────────────────┐
│  滚动容器(固定高度)     │  ← 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 渲染流程详解

把上面的组合起来,完整渲染流程是:

  1. 滚动容器设固定高度 + overflow: auto
  2. 内层容器高度设为 getTotalSize()(所有条目的总高度),撑开滚动条。
  3. 内层容器设 position: relative,作为绝对定位条目的参考。
  4. 遍历 getVirtualItems(),只渲染可视条目。
  5. 每个条目 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.currentnull。虚拟化器会处理 null,但如果你手动调用虚拟化器方法时 ref 还没就绪,可能出错。在 useEffect 里调用更安全。

坑五:滚动容器没设固定高度。 外层容器没有明确高度,height: auto 的话内层容器撑开后外层也跟着撑开,不产生滚动条。外层必须设固定高度(height: 400pxflex: 1 等)。

35.12 小结

这一章你认识了 TanStack Virtual:

  • 虚拟化原理:只渲染可视区域内的条目,用绝对定位 + transform 定位,撑开总高度保证滚动条正确。
  • useVirtualizer:核心 Hook,配置 count(总数)、getScrollElement(滚动容器)、estimateSize(预估尺寸)。
  • VirtualItemkey(标识)、index(数据索引)、start(起始偏移)、size(尺寸),用来定位和渲染。
  • overscan:额外渲染的缓冲条目数,防止快速滚动时空白。
  • useWindowVirtualizer:整页滚动场景用,不需要指定滚动容器。

下一章讲 Virtual 进阶:动态尺寸测量、水平虚拟化、网格虚拟化,以及和 Table 的集成。