文章

虚拟列表完整实现深度解析:从定高到动态高度的工程实践

虚拟列表只渲染视口内可见的几十个 DOM 节点,让十万条数据也能秒开,核心是动态算 startIndex、endIndex 并用 padding 占满滚动高度。 面试常考定高版本原理、不定高 item 的预估加修正策略,以及滚动监听与缓冲区的实现。

虚拟列表完整实现深度解析:从定高到动态高度的工程实践

一句话概括

虚拟列表只渲染视口内可见的几十个 DOM 节点,让 10 万条数据也能秒开——核心技术是动态计算 startIndex/endIndex、用 padding-top/padding-bottom 占满滚动高度,以及处理不定高 item 的预估+修正策略。

核心知识点

1. 虚拟列表的核心原理(定高版本)

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
31
32
33
34
35
// 核心公式:
// startIndex = Math.floor(scrollTop / itemHeight)
// endIndex = Math.ceil((scrollTop + containerHeight) / itemHeight)
// visibleItems = data.slice(startIndex, endIndex + buffer)
// offsetY = startIndex * itemHeight  → 用 padding-top 占位

function FixedVirtualList({ items, itemHeight = 50, containerHeight = 600 }) {
  const [scrollTop, setScrollTop] = useState(0);
  const buffer = 5; // 上下各多渲染 5 个,避免快速滚动时白屏

  const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer);
  const endIndex = Math.min(
    items.length,
    Math.ceil((scrollTop + containerHeight) / itemHeight) + buffer
  );
  const visibleItems = items.slice(startIndex, endIndex);
  const totalHeight = items.length * itemHeight;
  const offsetY = startIndex * itemHeight;

  return (
    <div style={{ height: containerHeight, overflow: 'auto' }}
         onScroll={e => setScrollTop(e.target.scrollTop)}>
      <div style={{ height: totalHeight, position: 'relative' }}>
        {/* paddingTop 向上撑开,让可见区域定位在正确位置 */}
        <div style={{ paddingTop: offsetY }}>
          {visibleItems.map((item, i) => (
            <div key={startIndex + i} style={{ height: itemHeight }}>
              {item}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

10 万条数据,定高虚拟列表只渲染约(600/50 + 10)= 22 个 DOM 节点,而全量渲染会创建 10 万个 DOM → 浏览器直接卡死。

2. 不定高虚拟列表:预估 + 修正

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// 真实场景:每条消息/评论高度不一,无法用固定 itemHeight
function DynamicVirtualList({ items, estimateHeight = 80, containerHeight = 600 }) {
  const [scrollTop, setScrollTop] = useState(0);
  const [positions, setPositions] = useState([]);  // 每个 item 的实际位置
  const itemRefs = useRef({});
  const buffer = 3;

  // 二分查找:根据 scrollTop 找到起始索引
  const findStartIndex = (scrollTop) => {
    let lo = 0, hi = positions.length - 1;
    while (lo < hi) {
      const mid = Math.floor((lo + hi) / 2);
      if (positions[mid].bottom < scrollTop) lo = mid + 1;
      else hi = mid;
    }
    return lo;
  };

  // 首次用预估高度初始化位置缓存
  useEffect(() => {
    setPositions(items.map((_, i) => ({
      index: i, top: i * estimateHeight, bottom: (i + 1) * estimateHeight, height: estimateHeight
    })));
  }, [items.length]);

  // 渲染后测量实际高度并修正缓存
  useEffect(() => {
    let changed = false;
    const newPositions = [...positions];
    Object.entries(itemRefs.current).forEach(([key, el]) => {
      const idx = parseInt(key);
      const realHeight = el.offsetHeight;
      if (realHeight !== newPositions[idx].height) {
        const diff = realHeight - newPositions[idx].height;
        newPositions[idx].height = realHeight;
        newPositions[idx].bottom += diff;
        // 修正后续所有 item 的位置
        for (let j = idx + 1; j < newPositions.length; j++) {
          newPositions[j].top += diff;
          newPositions[j].bottom += diff;
        }
        changed = true;
      }
    });
    if (changed) setPositions(newPositions);
  }, [visibleItems]);

  const startIndex = Math.max(0, findStartIndex(scrollTop) - buffer);
  const endPos = positions.find(p => p.bottom >= scrollTop + containerHeight) || positions[positions.length - 1];
  const endIndex = Math.min(items.length, (endPos?.index || items.length - 1) + buffer + 1);
  const visibleItems = items.slice(startIndex, endIndex);
  const totalHeight = positions.length > 0 ? positions[positions.length - 1].bottom : items.length * estimateHeight;
  const offsetY = positions[startIndex]?.top || 0;

  return (
    <div style={{ height: containerHeight, overflow: 'auto' }} onScroll={e => setScrollTop(e.target.scrollTop)}>
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div style={{ paddingTop: offsetY }}>
          {visibleItems.map((item, i) => {
            const idx = startIndex + i;
            return <div key={idx} ref={el => itemRefs.current[idx] = el}>{item}</div>;
          })}
        </div>
      </div>
    </div>
  );
}

核心难点:不定高场景下滚动位置的计算依赖实际渲染后测量的高度,但高度测量又依赖 DOM 存在——这是一个”鸡生蛋”问题。解法是用预估高度先占位,渲染后用 getBoundingClientRect 测量真实高度,修正位置缓存。

3. 主流虚拟列表库对比

1
2
3
4
5
6
7
8
9
10
# react-window(轻量,定高场景首选)
npm i react-window
# <FixedSizeList height={600} itemCount={100000} itemSize={50}>{Row}</FixedSizeList>

# react-virtuoso(不定高自动处理,零配置,2026 推荐)
npm i react-virtuoso
# <Virtuoso data={items} itemContent={(i, item) => <Row item={item} />} />

# TanStack Virtual(框架无关,header/group 支持好)
npm i @tanstack/react-virtual

选择建议:定高 → react-window,不定高 + 复杂布局 → react-virtuoso,需要框架无关 + 分组/粘性头部 → @tanstack/react-virtual。

4. 虚拟列表的常见坑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 坑1:搜索/筛选后滚动位置异常
// ✅ 数据变化后重置 scrollTop
useEffect(() => { containerRef.current.scrollTop = 0; }, [items]);

// 坑2:快速滚动白屏
// ✅ 增加 buffer(上下各多渲染 3-5 个 item)+ 用 transform 代替 paddingTop 减少重排

// 坑3:图片加载撑开高度导致位置偏移
// ✅ 图片设置固定宽高 + aspect-ratio,或加载后触发重新测量

// 坑4:onScroll 节流不够导致高频 setState
// ✅ 用 useRef 存 scrollTop,rAF 中批量更新
const scrollTopRef = useRef(0);
const [, forceUpdate] = useReducer(x => x + 1, 0);
useEffect(() => {
  let raf;
  const loop = () => { forceUpdate(); raf = requestAnimationFrame(loop); };
  raf = requestAnimationFrame(loop);
  return () => cancelAnimationFrame(raf);
}, []);

5. 虚拟列表 vs 分页:选哪个?

维度虚拟列表分页
体验丝滑滚动,无感知切换需要点”下一页”或下拉加载
适用聊天、日志、股票行情搜索引擎、商品列表
SEO❌ 不好(DOM 不存在)✅ 好(每页独立 URL)
实现复杂(需处理不定高)简单
内存小(DOM 节点数恒定)随翻页累积(不断 append)

结论:用户体验优先用虚拟列表,SEO 优先用分页(或 SSR + 虚拟列表混合)。

其实你每天都在用

  • 微信聊天列表:几千条消息丝滑滚动——就是虚拟列表,只渲染屏幕上的 ~20 条,滚走了的 DOM 直接复用
  • 微博/Twitter 时间线:往下滑内容不断出现,往上滑之前的还在——虚拟滚动 + 数据回收
  • Excel Online:几万行数据在浏览器里流畅操作——虚拟列表 + Canvas 渲染
  • VS Code 编辑器:打开 10 万行的代码文件,滚动时只有视口内行被渲染

常见误解(FAQ)

❌ 误区:display: none 能让不可见的 item 不占渲染开销

display: none 的元素不参与布局和绘制,但它们仍然在 DOM 树中,仍然占用内存。10 万个 display: none 的 div 照样让浏览器 OOM。虚拟列表根本没有创建这些 DOM 节点,这才是省内存的根本。

❌ 误区:虚拟列表不需要处理键盘导航和无障碍

虚拟列表只渲染可见 item,Tab 键导航和屏幕阅读器会丢失不可见元素。需要实现 WAI-ARIA 的 aria-rowcount、aria-rowindex、aria-posinset 等属性,让屏幕阅读器知道有 10 万项而不是 20 项。

❌ 误区:虚拟列表和无限滚动(Infinite Scroll)是一回事

无限滚动是”加载更多数据并追加到列表”——数据量不断增长,最终还是会卡(DOM 节点累积)。虚拟列表是”数据全在内存,但只渲染可见的”——DOM 节点数恒定。生产环境经常两者结合:虚拟列表负责渲染性能,无限滚动负责数据分页加载。

一句话总结

虚拟列表的本质是”欺骗”——用 20 个 DOM 节点配合 paddingTop 骗过用户的眼睛,让他们以为所有数据都已渲染。

本文由作者按照 CC BY 4.0 进行授权