虚拟滚动与大数据渲染

概述

现代 Web 应用经常需要展示大量的数据列表。想象一下电商网站的商品列表、社交媒体的信息流、或者数据分析平台的数据表格——这些场景可能需要同时展示数千甚至数万条数据。如果在页面加载时创建所有这些 DOM 节点,浏览器的内存占用会急剧上升,初始渲染时间会变得难以接受,滚动时也会出现明显的卡顿。

虚拟滚动(Virtual Scrolling,也称为 Windowing)技术是解决这一问题的标准方案。它的核心思想简单而优雅:只渲染用户当前能看到的内容。通过维护一个「窗口」来覆盖可视区域,并只创建这个窗口内的 DOM 节点,虚拟滚动可以在 DOM 节点数量从数万个减少到几十个的同时,保持用户看到的数据完整性和滚动体验的流畅性。

本节将从虚拟滚动的基本原理出发,详细讲解其实现机制,然后介绍 React 生态中主流的虚拟化库,包括 react-window、react-virtuoso 和 @tanstack/react-virtual。我们会探讨动态高度、无限滚动、分组列表等复杂场景的处理方法,最后分析虚拟化技术的局限性,帮助你在合适的场景中选择最优方案。

目标

  • 深入理解虚拟滚动的核心原理和实现机制
  • 掌握 react-window、react-virtuoso、@tanstack/react-virtual 等主流库的使用方法和适用场景
  • 学会处理动态高度、无限滚动、分组列表等复杂场景
  • 了解虚拟化的局限性以及在什么情况下应该选择其他方案

知识体系

1. 虚拟滚动的核心原理

理解虚拟滚动的关键在于认识到:用户滚动页面时,他看到的内容只占列表的一小部分。以一个可视区域高度为 600px、每项高度为 50px 的列表为例,屏幕上同时可见的项目最多只有 12 个。即使列表包含 10,000 个项目,用户一次也只能看到这 12 个。

传统渲染方式会创建全部 10,000 个 DOM 节点,每个节点占用内存,渲染它们需要时间,滚动时浏览器还需要管理这大量图层的绘制和合成。而虚拟滚动只创建可见范围加上少量缓冲区(overscan)的 DOM 节点,比如 20 个左右。当用户滚动时,通过计算新的可见范围,销毁超出范围的节点,创建进入范围的节点,并更新剩余节点的位置。

传统渲染(10000 项):
┌─────────────────────┐
│ Item 1              │ ← 全部创建 DOM
│ Item 2              │
│ ...                 │
│ Item 10000          │
└─────────────────────┘
DOM 节点数:10000  内存:高  首次渲染:慢

虚拟滚动(10000 项):
┌─────────────────────┐ ← overscan(预渲染缓冲区)
│ Item 51             │
│ Item 52             │ ← 可视区域
│ ...                 │
│ Item 65             │
└─────────────────────┘ ← overscan
DOM 节点数:~20   内存:低  首次渲染:快

实现虚拟滚动的核心技术包括几个方面。首先是滚动位置到数据索引的映射:通过 scrollTop / itemHeight 可以计算出当前滚动位置对应的起始索引,然后结合可视区域高度计算出可见项的数量。其次是总高度的模拟:为了让浏览器正确显示滚动条,需要在列表容器内部创建一个撑起总高度的占位元素,它的高度等于所有项的总高度,但实际上不渲染任何内容。最后是可见项的绝对定位:可见项通过 transform: translateY(offset) 定位到正确的位置,这样在滚动时只需要更新 transform 值,而不需要重新布局。

2. 基础虚拟滚动实现

理解虚拟滚动的最直接方式是看一个简化版的实现。这个实现展示了所有核心概念,而不依赖任何外部库。

// 简化版虚拟滚动实现
function VirtualList({ items, itemHeight, containerHeight }) {
  const [scrollTop, setScrollTop] = useState(0);

  // 计算总高度来模拟完整列表
  const totalHeight = items.length * itemHeight;

  // 计算当前可见范围的起始索引
  const startIndex = Math.floor(scrollTop / itemHeight);

  // 计算可见项的数量(多渲染几个作为缓冲区)
  const visibleCount = Math.ceil(containerHeight / itemHeight);
  const overscan = 5; // 上下各额外渲染的项数

  // 确定实际渲染的范围
  const renderStart = Math.max(0, startIndex - overscan);
  const renderEnd = Math.min(items.length, startIndex + visibleCount + overscan);

  // 提取需要渲染的数据
  const visibleItems = items.slice(renderStart, renderEnd);

  // 计算渲染起始位置的偏移量
  const offsetY = renderStart * itemHeight;

  return (
    <div
      style={{ height: containerHeight, overflow: 'auto' }}
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
    >
      {/* 撑起完整高度的容器 */}
      <div style={{ height: totalHeight, position: 'relative' }}>
        {/* 偏移到正确位置的可见项 */}
        <div style={{ transform: `translateY(${offsetY}px)` }}>
          {visibleItems.map((item, i) => (
            <div key={renderStart + i} style={{ height: itemHeight }}>
              {item.name}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

这个实现展示了虚拟滚动的基本模式。overscan 参数是一个重要的优化:它定义了可视区域外额外渲染的项目数。设置适当的 overscan(通常是 3-10 个)可以防止用户在快速滚动时看到空白区域。当滚动速度超过渲染速度时,overscan 区域提供了一个缓冲区,让新项有足够的时间被创建和渲染。

在实际应用中,还需要考虑键盘导航、屏幕阅读器支持、滚动条行为等技术细节。这些都会增加实现的复杂性,这也是为什么通常推荐使用成熟的虚拟化库而不是从头实现。

3. react-window 的使用

react-window 是 React 生态中最经典的虚拟滚动库,以其简洁的 API 和高效的实现著称。它主要提供两个组件:FixedSizeList 用于固定高度的列表,VariableSizeList 用于高度可变的列表。

固定高度列表

当列表项高度一致时,FixedSizeList 是最高效的选择,因为它不需要在滚动时重新测量每项的高度。

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

function VirtualizedList({ items }) {
  const Row = ({ index, style }) => (
    <div style={style} className="list-row">
      <span>{items[index].name}</span>
      <span>{items[index].email}</span>
    </div>
  );

  return (
    <List
      height={600}
      itemCount={items.length}
      itemSize={50}
      width="100%"
      overscanCount={10}
    >
      {Row}
    </List>
  );
}

style 参数是 react-window 最重要的概念之一。每个行组件必须将传递给它的 style 应用到自己的根元素上。这个 style 包含了元素的 position: absolutetopheight 属性,由 react-window 计算后传入。如果你忘记应用 style 或者修改了它,布局就会完全错乱。

overscanCount 控制可视区域外额外渲染的项数。增加这个值可以减少快速滚动时出现空白区域的几率,但会增加 DOM 节点数量和内存占用。

可变高度列表

当列表项高度不固定时(例如社交媒体动态、展开的评论等),需要使用 VariableSizeList。这种模式下,react-window 依赖你提供的函数来获取每项的高度。

import { VariableSizeList as List } from 'react-window';

function DynamicList({ items }) {
  const listRef = useRef(null);

  // 根据内容计算每项的高度
  const getItemSize = (index) => {
    const item = items[index];
    const baseHeight = 50;
    const lineHeight = 20;
    const lines = Math.ceil(item.content.length / 80);
    return baseHeight + lines * lineHeight;
  };

  const Row = ({ index, style }) => (
    <div style={style} className="list-row">
      <h4>{items[index].title}</h4>
      <p>{items[index].content}</p>
    </div>
  );

  // 当数据变化时重置高度缓存
  useEffect(() => {
    listRef.current?.resetAfterIndex(0);
  }, [items]);

  return (
    <List
      ref={listRef}
      height={600}
      itemCount={items.length}
      itemSize={getItemSize}
      width="100%"
      overscanCount={5}
    >
      {Row}
    </List>
  );
}

可变高度列表的主要挑战在于高度测量和缓存。react-window 不会在初始渲染时测量所有项的高度——它只在项进入可视区域时测量一次,然后缓存结果。当列表数据变化时,需要调用 resetAfterIndex(0) 来清除缓存,否则可能出现布局错误。

另一个需要注意的是,getItemSize 函数必须返回确定性的结果。如果相同索引在不同调用时返回不同高度,缓存就会失效并导致布局错误。

虚拟化网格

对于同时需要行和列的场景(如数据表格或图片网格),react-window 提供了 FixedSizeGrid 组件。

import { FixedSizeGrid as Grid } from 'react-window';

function VirtualizedGrid({ data, columnCount }) {
  const Cell = ({ columnIndex, rowIndex, style }) => {
    const item = data[rowIndex * columnCount + columnIndex];
    if (!item) return null;

    return (
      <div style={style} className="grid-cell">
        <img src={item.thumbnail} alt={item.title} loading="lazy" />
        <span>{item.title}</span>
      </div>
    );
  };

  return (
    <Grid
      columnCount={columnCount}
      columnWidth={200}
      height={600}
      rowCount={Math.ceil(data.length / columnCount)}
      rowHeight={250}
      width={800}
      overscanColumnCount={2}
      overscanRowCount={3}
    >
      {Cell}
    </Grid>
  );
}

网格的 Cell 组件接收 columnIndexrowIndexstyle 参数。和列表一样,style 必须应用到根元素。overscanColumnCountoverscanRowCount 分别控制列和行方向上的 overscan 数量。

4. react-virtuoso 的使用

react-virtuoso 是一个更现代的虚拟滚动库,与 react-window 相比,它最大的优势是内置了动态高度测量。你不需要手动计算每项的高度,react-virtuoso 会自动测量并缓存。这种设计大大简化了开发体验,特别是在处理复杂、动态内容时。

基础用法

import { Virtuoso } from 'react-virtuoso';

function AutoHeightList({ items }) {
  return (
    <Virtuoso
      style={{ height: '600px' }}
      data={items}
      itemContent={(index, item) => (
        <div className="list-item">
          <h4>{item.title}</h4>
          <p>{item.description}</p>
        </div>
      )}
      overscan={200}
    />
  );
}

注意这里使用的是 data 属性传入完整数据数组,而不是像 react-window 那样通过索引访问。这是一种更声明式的 API 设计。itemContent 接收索引和对应项的数据,返回要渲染的内容。

overscan 在 react-virtuoso 中以像素为单位而不是项数,这提供了更精确的控制。200 像素的 overscan 意味着在可视区域上下各额外渲染 200 像素的内容。

无限滚动

无限滚动是很多应用的核心功能。react-virtuoso 提供了 endReached 回调来简化实现。

import { Virtuoso } from 'react-virtuoso';

function InfiniteList() {
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(false);

  const loadMore = useCallback(async () => {
    if (loading) return;
    setLoading(true);

    const newItems = await fetchItems(items.length, 50);
    setItems((prev) => [...prev, ...newItems]);
    setLoading(false);
  }, [items.length, loading]);

  return (
    <Virtuoso
      style={{ height: '600px' }}
      data={items}
      endReached={loadMore}
      overscan={200}
      itemContent={(index, item) => (
        <div className="list-item">
          <span>{item.name}</span>
        </div>
      )}
      components={{
        Footer: () =>
          loading ? <div className="loading">Loading...</div> : null,
      }}
    />
  );
}

endReached 回调在用户滚动到列表末尾时触发,用于加载更多数据。react-virtuoso 不会自动管理数据追加,你需要自己在回调中更新状态。Footer 组件用于渲染加载状态,当 endReached 回调执行期间可以显示 loading 指示器。

分组列表

对于需要分组显示的数据(如通讯录的字母分组),react-virtuoso 提供了 GroupedVirtuoso 组件。

import { GroupedVirtuoso } from 'react-virtuoso';

function GroupedList({ groups }) {
  // 将分组展平以便虚拟滚动处理
  const groupCounts = groups.map((g) => g.items.length);
  const allItems = groups.flatMap((g) => g.items);

  return (
    <GroupedVirtuoso
      style={{ height: '600px' }}
      groupCounts={groupCounts}
      groupContent={(index) => (
        <div className="group-header">{groups[index].label}</div>
      )}
      itemContent={(index) => (
        <div className="group-item">{allItems[index].name}</div>
      )}
    />
  );
}

分组列表的难点在于同时管理组标题和组内项。react-virtuoso 通过 groupCounts 来区分哪些索引是组标题,哪些是实际项。groupContent 渲染组标题,itemContent 渲染组内每一项。

5. @tanstack/react-virtual 的使用

@tanstack/react-virtual(原 react-virtual)是 TanStack 生态系统的一部分,提供了更底层但更灵活的虚拟化实现。它的设计理念是提供 primitives 而不是封装好的组件,让你可以更自由地构建符合自己需求的虚拟化 UI。

基础用法

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

function TanStackVirtualList({ items }) {
  const parentRef = useRef(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,
    overscan: 10,
  });

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

与 react-window 和 react-virtuoso 不同,@tanstack/react-virtual 使用 Hooks 模式 并暴露了更底层的状态。你可以看到 virtualizer.getVirtualItems() 返回的虚拟项包含 keyindexstartsize 等属性,这给了你完全控制渲染方式的能力。

动态高度测量

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

function DynamicHeightList({ items }) {
  const parentRef = useRef(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 80,
    // 可选:提供测量函数来获取实际高度
    measureElement: (element) => element.getBoundingClientRect().height,
  });

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div
        style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}
      >
        {virtualizer.getVirtualItems().map((virtualRow) => (
          <div
            key={virtualRow.key}
            ref={virtualizer.measureElement}
            data-index={virtualRow.index}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              transform: `translateY(${virtualRow.start}px)`,
            }}
          >
            <div className="dynamic-item">
              <h4>{items[virtualRow.index].title}</h4>
              <p>{items[virtualRow.index].content}</p>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

注意这里需要给被测量的元素添加 ref={virtualizer.measureElement}data-index={virtualRow.index} 属性。measureElement ref callback 会在元素渲染后测量其实际高度,并缓存结果供后续使用。

6. 虚拟化表格

数据表格是虚拟滚动的另一个重要应用场景。一个 10 万行 × 50 列的表格,如果渲染全部 DOM,浏览器会直接崩溃。虚拟化表格让你可以只渲染可见行的数据,同时保持所有列的可访问性。

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

function VirtualTable({ data, columns }) {
  const parentRef = useRef(null);

  const rowVirtualizer = useVirtualizer({
    count: data.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 40,
    overscan: 20,
  });

  return (
    <div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
      <table style={{ width: '100%' }}>
        <thead style={{ position: 'sticky', top: 0, zIndex: 1 }}>
          <tr>
            {columns.map((col) => (
              <th key={col.key}>{col.title}</th>
            ))}
          </tr>
        </thead>
        <tbody style={{ height: `${rowVirtualizer.getTotalSize()}px`, position: 'relative' }}>
          {rowVirtualizer.getVirtualItems().map((virtualRow) => {
            const row = data[virtualRow.index];
            return (
              <tr
                key={virtualRow.key}
                style={{
                  position: 'absolute',
                  top: 0,
                  transform: `translateY(${virtualRow.start}px)`,
                  width: '100%',
                  display: 'table-row',
                }}
              >
                {columns.map((col) => (
                  <td key={col.key}>{row[col.key]}</td>
                ))}
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
}

实现虚拟化表格有几个关键点需要注意。首先,表头需要 sticky 定位,这样当用户滚动数据行时,表头始终可见。其次,表行必须使用 position: absolute 并通过 transform 定位,这是虚拟滚动的标准实现方式。最后,每个 tr 需要 display: table-row 来保持表格的正常布局计算。

7. 虚拟化的局限性与适用场景

虚拟滚动是一种强大的技术,但它不是银弹。在某些场景下,虚拟化可能不是最佳选择,甚至会带来负面效果。

虚拟化适合的场景包括:数据量超过数百项的长列表(如商品列表、搜索结果);需要同时展示大量行的数据表格;社交媒体风格的信息流(无限滚动);需要流畅滚动体验的任何大数据集。这些场景的共同特点是数据量大但相对简单,虚拟化可以显著减少 DOM 节点数量和内存占用。

虚拟化可能不适合的场景包括:数据量较小(少于 50 项)时,虚拟化带来的复杂性超过了收益;需要 SEO 的内容,因为搜索引擎无法索引虚拟化渲染的内容;需要浏览器全文搜索(Ctrl+F)的场景,因为只有可见区域的内容在 DOM 中;打印场景,打印机只能获取可视区域的内容。

虚拟化适用场景:
✅ 长列表(>100 项)
✅ 大数据表格
✅ 无限滚动
✅ 聊天消息列表

虚拟化不适用场景:
❌ 少量数据(<50 项)— 增加复杂度无收益
❌ 需要 SEO 的内容 — 搜索引擎无法索引虚拟化内容
❌ 需要 Ctrl+F 搜索的场景 — 浏览器搜索只能找到已渲染的项
❌ 打印场景 — 打印时只能打印可见区域

选择虚拟化方案时,还需要考虑库的维护状态、API 稳定性、类型支持等因素。react-window 是最成熟的方案,但近年来维护不活跃;react-virtuoso 功能最全面且维护积极;@tanstack/react-virtual 提供了最大的灵活性,是 TanStack 生态的一部分。


实战练习

练习 1:虚拟列表实现

从零实现一个支持固定高度的虚拟滚动列表。渲染 100,000 条模拟数据,确保滚动流畅。使用 Chrome DevTools 的 Performance 面板录制滚动过程,确认帧率稳定在 60fps。尝试不同的 overscan 值,观察对内存占用和滚动体验的影响。

练习 2:无限滚动 Feed

使用 react-virtuoso 实现一个类似 Twitter 或微博的信息流页面。支持动态高度的微博内容、无限滚动加载、图片懒加载。实现「滚动到顶部加载历史消息」的功能,模拟双向无限滚动。

练习 3:虚拟化数据表格

实现一个支持 10 万行 × 50 列的虚拟化表格。支持列排序(点击表头切换升序/降序)、行选中(点击选中行并高亮)、固定列(在滚动时保持某些列始终可见)。使用 react-window 的 Grid 组件或 @tanstack/react-virtual 自定义实现。


延展阅读


关键术语

术语 解释
Virtual Scrolling 虚拟滚动,只渲染可视区域内的元素以优化大量数据的渲染性能
Windowing 窗口化,虚拟滚动的另一种说法,强调「窗口」的概念
Overscan 可视区域外额外渲染的缓冲项数,防止快速滚动时出现空白
Infinite Scroll 无限滚动,滚动到底部时自动加载更多数据
Dynamic Height 动态高度,列表项高度不固定的场景,需要运行时测量
measureElement 动态测量 DOM 元素实际高度的方法,用于可变高度列表
estimateSize 在实际测量前提供的预估高度,帮助浏览器计算滚动条
Virtualized Table 虚拟化表格,通过虚拟滚动技术处理大数据量表格的渲染