#React#TypeScript

React 虚拟化:大列表性能优化

渲染 10000 条数据卡死?虚拟列表只渲染可视区域的几行,滚动时动态替换。本文讲清虚拟化的原理(窗口计算/DOM 复用)、react-window 和 TanStack Virtual 的用法、什么场景该虚拟化、以及它的代价(搜索/无障碍/动态高度)。

本文要点

  • 主题聚焦:React、TypeScript
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年3月24日·更新于 2026年7月23日

React 虚拟化:大列表性能优化

一个后台管理页面,产品要你一次性展示 10000 条订单记录。你 <tbody>{orders.map(o => <Row />)}</tbody> 写下去,F12 一看,首屏渲染 3 秒,滚动帧率掉到 12fps,Chrome 任务管理器内存直接飙到 800MB。你心想:不就是个列表吗,怎么就卡成这样?

答案很简单:DOM 节点是浏览器最贵的资源,没有之一。而虚拟化(virtualization)就是解决这类问题的标准答案——它让 10000 条数据只渲染你看得见的那 20 行。

一、为什么 DOM 节点是浏览器最贵的资源

很多人对 React 性能的直觉停留在「组件渲染慢」,于是去优化 useMemo、React.memo、useCallback。但当数据量上到几千条时,瓶颈根本不在 React,而在浏览器:

  • 渲染开销:每个 DOM 节点要走完整的 parseHTML、attach style、layout、paint 流程。10000 个节点不是线性增长,而是接近指数级恶化。
  • 样式重算与布局:滚动、窗口 resize、任何一次 reflow 都可能触发对大量节点的布局重算。节点越多,每次 reflow 越慢。
  • 内存占用:一个普通 DOM 节点占几 KB 到几十 KB 内存(算上样式、事件、内部数据结构)。10000 个复杂行,轻松吃掉几百 MB。
  • 事件分发与 hit-testing:浏览器在每次鼠标移动、滚动时都要做 hit-test,节点越多越慢。

React 本身的 reconciliation 是 JS 层面的,几万次的 vnode diff 对现代引擎来说其实是小问题。真正要命的是 React 把这些 vnode 转成真实 DOM 之后,浏览器要维护这堆 DOM 的代价。所以当数据量大到一定量级,「减少 React 渲染」这条优化路径已经走到头了——真正要减的是 DOM 节点的绝对数量。

二、虚拟化的原理:窗口计算与 DOM 复用

虚拟化的核心思想一句话能说清:只渲染可视窗口(viewport)内能看到的那几行,滚动时动态替换内容。

假设容器高 600px,每行 40px,可视区域能容纳 15 行。虚拟列表做了三件事:

  1. 计算窗口:根据当前 scrollTop,算出可视区域应该渲染哪些行——比如 startIndex = Math.floor(scrollTop / 40),endIndex = startIndex + 15。
  2. 用一个大容器撑开滚动条:外层放一个高度为 10000 * 40 = 400000px 的占位元素,让浏览器原生滚动条看起来对,这样滚动行为和普通列表完全一致。
  3. 用 transform 或 absolute 定位把可视行贴到正确位置:滚动时不是真的「滚」这些 DOM,而是重新计算窗口,把新增的行用 transform 移过去,离开窗口的行直接卸载。

整个过程中,DOM 节点数始终恒定在 ~20 个(可视 15 + 上下各缓冲几行),无论你底下是 1 万条还是 100 万条。浏览器看到的始终是「一个 20 行的小列表」。

这也是为什么虚拟列表在 100 万条数据时依然丝滑——它的复杂度是 O(可视行数),和数据总量无关。

三、react-window vs TanStack Virtual:怎么选

生态里有两个主流方案,都值得了解。

react-window:Brian Vaughn(Vue/Radix 阵营外最懂列表的人之一)写的经典库,react-virtualized 的精神继承者,轻量(~6KB)、API 简单、稳定。适合固定行高场景。

tsx
import { FixedSizeList as List } from 'react-window';

function Row({ index, style }: { index: number; style: React.CSSProperties }) {
  return <div style={style}>第 {index} 行</div>;
}

function BigList() {
  return (
    <List height={600} itemCount={10000} itemSize={40} width={600}>
      {Row}
    </List>
  );
}

API 极简:itemSize 是固定的行高,库自己算窗口,你只管渲染单行。没有多余的概念,适合 90% 的固定高度场景。

TanStack Virtual(@tanstack/react-virtual):Tanner Linsley 的新一代方案。更灵活,不绑定 React 的 children render prop,而是给你一个 virtualizer 实例,你自己控制容器和定位。最重要的是它原生支持动态高度(通过 measureElement)。

tsx
import { useVirtualizer } from '@tanstack/react-virtual';

function BigList({ rows }: { rows: string[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 40, // 预估行高
    overscan: 5, // 上下各多渲染 5 行做缓冲
  });

  return (
    <div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
      <div
        style={{
          height: virtualizer.getTotalSize(),
          width: '100%',
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map((vi) => (
          <div
            key={vi.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              transform: `translateY(${vi.start}px)`,
            }}
          >
            第 {vi.index} 行:{rows[vi.index]}
          </div>
        ))}
      </div>
    </div>
  );
}

选型建议:固定行高、需求简单 → react-window;动态高度、水平+垂直双向虚拟化、表格、需要和 TanStack Table 配合 → TanStack Virtual。新项目我会直接上 TanStack Virtual,它的 mental model 更现代,而且和 TanStack 的生态(Table/Query)整合无缝。react-window 已经进入「维护模式」,功能基本不会再加。

四、什么时候该虚拟化,什么时候不该

这是本文最重要的判断,别见了长列表就上虚拟化。

该虚拟化:

  • 数据量 > 500 行,且行高固定或可算(比如 itemSize = 40)。
  • 无限滚动列表:用户会一直往下滑,DOM 持续累积。
  • 大数据表格:财务、订单、日志类后台,几千上万行是常态。

不该虚拟化:

  • 行数 < 500:浏览器扛得住,虚拟化带来的复杂度(测试、可访问性、SSR)完全不值。我见过有人给一个 200 行的设置项列表上虚拟化,纯属于没事找事。
  • 行高完全不可预测:比如富文本评论、内容长度差异巨大又无法预估。动态高度虚拟化能做,但坑很多(见第六节)。
  • 需要全文搜索:浏览器 Ctrl+F 搜不到没渲染的行,这是虚拟化的硬伤。
  • 内容是 SEO 关键:SSR 时虚拟列表只会渲染前几十行,爬虫拿不到完整内容。比如博客文章列表、商品列表这类靠搜索引流的页面,虚拟化会要了你的命。

一句话:虚拟化是用来解决「确实扛不住」的工程问题,不是用来炫技的。 先用分页/无限滚动,扛不住再上虚拟化。

五、虚拟化的代价:无障碍、Ctrl+F、SEO

虚拟化不是免费的午餐,它牺牲了三件你可能没意识到的东西:

无障碍(Accessibility):屏幕阅读器只能读出当前 DOM 里存在的行。虚拟列表只渲染 20 行,视障用户用 JAWS/NVDA 遍历时只能读到这 20 行,剩下的 9980 条「不存在」。补救方案是给容器加 aria-rowcount、aria-rowindex,让屏幕阅读器至少知道总行数和当前行号,但这只是部分缓解。

浏览器搜索(Ctrl+F):这是用户抱怨最多的点。用户在列表页按 Ctrl+F 搜「张三」,浏览器只能搜到当前可视区的 20 行,搜不到没渲染的行。这违反了用户对网页的朴素预期。如果你的列表是用户高频搜索的场景,虚拟化几乎是 deal-breaker。

SEO:服务端渲染时,虚拟列表只会输出初始可视区的行,爬虫看不到完整数据。React 的 renderToString 不会模拟滚动,自然只渲染第一屏。对需要 SEO 的页面(商品列表、文章归档),要么放弃虚拟化改用 SSR 友好的分页,要么用「首屏渲染前 N 条 + 客户端虚拟化」的混合策略。

这些代价不是 bug,是虚拟化原理的必然结果——DOM 不存在的东西,浏览器和爬虫自然感知不到。选型时必须把这些写进决策表。

六、动态高度:虚拟化最棘手的部分

固定行高时,窗口计算是 O(1),直接除法。但真实世界很多场景行高是变化的:评论、富文本、可折叠卡片。这时虚拟化复杂度暴增,因为窗口计算依赖每行的实际高度,而高度只有渲染出来才知道。

TanStack Virtual 的解法是 「预估 + 测量 + 修正」:

tsx
const virtualizer = useVirtualizer({
  count: rows.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 60, // 先给个保守预估值
  measureElement: (el) => el.getBoundingClientRect().height, // 测真实高度
});

// 渲染时
{virtualizer.getVirtualItems().map((vi) => (
  <div
    key={vi.key}
    ref={(node) => node && virtualizer.measureElement(node)}
    data-index={vi.index}
    style={{
      position: 'absolute',
      top: 0,
      left: 0,
      width: '100%',
      transform: `translateY(${vi.start}px)`,
    }}
  >
    {rows[vi.index]}
  </div>
))}

工作流程:初始用 estimateSize 估算所有行高,撑出滚动条;行进入可视区被渲染后,measureElement 测量真实高度并更新缓存,触发修正重新计算位置。data-index 是用来匹配缓存的关键。

这套机制能用,但有三个坑:

  1. 滚动条跳动:预估不准时,滚动到中段会突然「跳一下」,因为修正后总高度变了。estimateSize 越准,跳得越少。
  2. 滚动到指定行不准:scrollToIndex(5000) 算出的位置是估算值,到位后还要再修正。
  3. 快速滚动时闪烁:行刚进来时用预估高度定位,测量完又重排,用户看得到。overscan 调大能缓解,但增加渲染成本。

动态高度场景,我会尽量先尝试「内容固定 + 按类型分组」(同类行同一高度),把动态降级回固定。实在不行再上 measureElement,并接受一定的体验损失。

七、替代方案:分页与无限滚动

虚拟化不是唯一解,有时根本不该选它。

分页:最古老也最稳。每页 50 条,用户翻页时只渲染当前页。优点是 SSR 友好、可分享 URL(?page=3)、对搜索引擎友好、Ctrl+F 能搜全页。缺点是用户讨厌翻页,UX 上不如滚动自然。后台系统、电商筛选页,分页依然是首选。

无限滚动(非虚拟化):滚动到底部时 append 新数据,DOM 持续累积。这是 Instagram、Twitter 的做法。它和虚拟化的区别是:不卸载旧行,DOM 一直在涨。优点是简单、Ctrl+F 能搜到所有已加载内容、无障碍友好;缺点是用户滚太久(比如几千条)后 DOM 积累到拖慢页面。

判断标准很清晰:

  • 用户通常只看前几十条就走 → 无限滚动,简单优雅。
  • 用户会真的滚动浏览几千条 → 虚拟化。
  • 数据需要搜索、SEO、可分享 → 分页。

小结

  • DOM 节点是浏览器最贵的资源,大列表的性能瓶颈在浏览器,不在 React。
  • 虚拟化只渲染可视窗口的几十行,DOM 数量恒定,与数据总量解耦。
  • react-window 适合固定行高,TanStack Virtual 适合动态高度和复杂场景,新项目优先 TanStack。
  • 虚拟化有代价:屏幕阅读器读不全、Ctrl+F 搜不到、SEO 不友好——这些是原理性硬伤,不是 bug。
  • 先分页,再无限滚动,扛不住才虚拟化:动态高度尤其要想清楚,measureElement 的坑不少。

一句话:虚拟化不是银弹,它是一种「用工程复杂度换 DOM 数量」的交易。只有在 DOM 真的成为瓶颈、且你愿意承担可访问性和搜索体验的代价时,这笔交易才划算。

Related

相关推荐