Skip to content

性能优化 - 虚拟滚动 🚀 ​

前言 ​

在项目开发中,我遇到了一个棘手的性能问题:我们的产品是一个无限滚动加载图片展示的应用,随着用户不断滚动加载更多图片,页面中的DOM节点数量呈指数级增长。当滚动加载的图片越来越多时,页面性能急剧下降,用户体验变得糟糕。

这种情况下,虚拟滚动成为了我们的救星。那么,什么是虚拟滚动?虚拟滚动又该如何实现呢?让我们深入探讨这个前端性能优化的利器。

什么是虚拟滚动? 💡 ​

虚拟滚动是一种针对大量数据列表的高效渲染优化技术。简单来说,它的核心思想是只渲染用户当前可见区域的DOM元素,将不在可视范围内的元素从DOM树中移除,从而大幅提升页面性能。

🎯 核心理念:眼不见,心不烦 —— 看不到的内容就不渲染

为什么需要虚拟滚动? 🤔 ​

当页面中存在过多DOM节点时,会带来以下问题:

  • 🐌 内存占用过高:每个DOM节点都会消耗内存资源
  • ⏰ 渲染时间过长:浏览器需要计算更多元素的布局和样式
  • 🎭 页面卡顿严重:滚动、交互等操作响应缓慢
  • 📱 移动端体验差:在配置较低的移动设备上表现更为明显

虚拟滚动的实现方案对比 🔄 ​

在深入实现之前,我们需要了解虚拟滚动的多种情况,它们各有应用场景,实现方式以及难点也各不相同。

虚拟滚动的实现可以从以下 5 个主要维度组合来看:

分类维度可能取值说明
1️⃣ 布局方向单列 / 多列 / 网格决定滚动的主轴方向与定位逻辑
2️⃣ 每项尺寸固定高度 / 动态高度决定是否可以直接通过数学计算定位
3️⃣ 容器特性固定高度 / 自动高度(撑开)决定是否用 scrollTop 驱动
4️⃣ 数据特性固定数量 / 动态数量(无限加载)决定是否需要懒加载机制
5️⃣ 定位方案Padding 占位法 / 绝对定位法可视区域的定位方式

首先我们从定位方案区分一下,因为这个没有难度上的差异,这里不再赘述。

定位方案区分 ​

  1. 方案一:Padding 占位法 📐

实现原理: 在容器中使用正常的文档流布局,只渲染可见部分的数据,通过padding-top和padding-bottom来撑起总高度,维持滚动条的正确长度。

核心代码示例:

html
<div class="virtual-container" style="height: 400px; overflow-y: auto;">
  <div class="virtual-list" style="
    padding-top: 1000px;    /* 前面被隐藏项目的总高度 */
    padding-bottom: 2000px; /* 后面被隐藏项目的总高度 */
  ">
    <!-- 只渲染可见的几十个项目 -->
    <div class="item">可见项目1</div>
    <div class="item">可见项目2</div>
    <div class="item">可见项目3</div>
  </div>
</div>
  1. 方案二:绝对定位法 🎯

实现原理: 容器使用position: relative,每个项目通过position: absolute精确计算位置,通过top和left属性控制元素的确切位置(这里也可以用transform: translateY()来控制整体位移)。

核心代码示例:

html
<div class="virtual-container" style="
  height: 400px; 
  overflow-y: auto; 
  position: relative;
">
  <!-- 撑起滚动条的占位元素 -->
  <div style="height: 100000px;"></div>
  
  <!-- 单独对每个可见项目使用绝对定位 -->
  <div class="item" style="position: absolute; top: 0px; left: 0;">项目1</div>
  <div class="item" style="position: absolute; top: 50px; left: 0;">项目2</div>
  <div class="item" style="position: absolute; top: 100px; left: 0;">项目3</div>
  <!-- 对整个容器进行位移/定位 -->
  <div
    style={{
        position: 'absolute',
        top: 0,
        left: 0,
        width: '100%',
        transform: `translateY(${virtualizedState.offsetY}px)`,
        willChange: 'transform',
        display: 'grid',
        gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
        gap: '16px',
    }}></div>

其他的方案分类 ​

如果实际要搭配各种分类划分,将会有很多场景,我们这里举出其中一些典型场景的方案来对比一下实现要点:

1. 单列 + 固定高度 + 定容器 + 定量(最简单) ​

思路:

每项高度已知,直接用 index * itemHeight 计算偏移量。只渲染可视区 + buffer,多余项用 transform: translateY() 定位。

难点:

几乎没有复杂难点,注意滚动抖动、buffer 大小调整与 key 使用稳定。

完整代码:

js
// FixedSizeList.jsx
import React, { useRef, useState, useMemo, useEffect } from 'react';

/**
 * props:
 *  - data: Array<any>
 *  - itemHeight: number (px)
 *  - height: number (container height px)
 *  - renderItem: (item, index) => ReactNode
 *  - overscan: number (可视区上下 buffer 行数)
 */
export default function FixedSizeList({ data, itemHeight, height, renderItem, overscan = 2 }) {
  const containerRef = useRef(null);
  const [scrollTop, setScrollTop] = useState(0);

  const totalHeight = data.length * itemHeight;
  const visibleCount = Math.ceil(height / itemHeight);

  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    function onScroll(e) {
      setScrollTop(e.target.scrollTop);
    }
    el.addEventListener('scroll', onScroll, { passive: true });
    return () => el.removeEventListener('scroll', onScroll);
  }, []);

  const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
  const endIndex = Math.min(data.length, startIndex + visibleCount + overscan * 2);

  const items = [];
  for (let i = startIndex; i < endIndex; i++) {
    const item = data[i];
    items.push(
      <div
        key={i}
        style={{
          height: itemHeight,
          boxSizing: 'border-box',
          position: 'absolute',
          top: 0,
          left: 0,
          right: 0,
          transform: `translateY(${i * itemHeight}px)`,
        }}
      >
        {renderItem(item, i)}
      </div>
    );
  }

  return (
    <div
      ref={containerRef}
      style={{ position: 'relative', height, overflow: 'auto', width: '100%' }}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        {items}
      </div>
    </div>
  );
}

要点与优化:

  • overscan 影响平滑性与渲染量:滑动卡顿时增大 overscan,渲染压力高时减小
  • 生产使用建议:直接使用 react-window(FixedSizeList) 以获得更完善的边界处理与性能

2. 单列 + 固定高度 + 定容器 + 动态数量(无限加载) ​

思路:

基于类型 1,再在底部或锚点使用 IntersectionObserver 触发加载更多(防抖),并保持已有 scroll position 的稳定。

难点:

  • 防止重复触发加载(节流/锁)
  • 加载完成后要正确更新 totalHeight 与渲染范围
  • 在插入大量数据时避免跳跃(可通过保持 scrollTop 或计算差值)

完整代码:

js
// InfiniteFixedList.jsx
import React, { useRef, useState, useEffect, useCallback } from 'react';
import FixedSizeList from './FixedSizeList'; // 上面实现的组件

export default function InfiniteFixedList({ fetchMore, itemHeight, height, renderItem }) {
  const [data, setData] = useState([]);
  const [loading, setLoading] = useState(false);
  const loadMoreAnchorRef = useRef(null);
  const isMountedRef = useRef(true);

  useEffect(() => { return () => { isMountedRef.current = false } }, []);

  const loadMore = useCallback(async () => {
    if (loading) return;
    setLoading(true);
    try {
      const newItems = await fetchMore(); // 应返回数组(可能空)
      if (!isMountedRef.current) return;
      setData(prev => [...prev, ...newItems]);
    } finally {
      if (isMountedRef.current) setLoading(false);
    }
  }, [fetchMore, loading]);

  useEffect(() => {
    const obs = new IntersectionObserver((entries) => {
      entries.forEach(e => {
        if (e.isIntersecting) {
          loadMore();
        }
      });
    }, { root: null, rootMargin: '200px' }); // 提前 200px 触发

    if (loadMoreAnchorRef.current) obs.observe(loadMoreAnchorRef.current);
    return () => obs.disconnect();
  }, [loadMore]);

  return (
    <div style={{ position: 'relative' }}>
      <FixedSizeList
        data={data}
        itemHeight={itemHeight}
        height={height}
        renderItem={renderItem}
        overscan={3}
      />
      <div ref={loadMoreAnchorRef} style={{ height: 1 }} />
      {loading && <div style={{ textAlign: 'center' }}>加载中...</div>}
    </div>
  );
}

要点与优化:

  • rootMargin 可用来提前加载,给用户更平滑体验
  • 在 fetchMore 中考虑分页(cursor、page)与空数据终止条件
  • CI/SSR 场景下注意预加载/SSR 数据注入

3. 单列 + 固定高度 + 容器撑开(window滚动)+ 定量 ​

思路:

整个页面滚动而不是独立容器。监听 window.scroll,计算相对文档偏移。常用于"页面自然滚动"的长列表。

难点:

  • 计算 scrollTop 时要将容器相对于文档顶部的 offset 考虑进来(container.getBoundingClientRect().top + window.scrollY)
  • 在复杂页面中 getBoundingClientRect 的频繁调用造成性能压力,需 throttle

完整代码:

js
// WindowFixedList.jsx
import React, { useRef, useState, useEffect } from 'react';

export default function WindowFixedList({ data, itemHeight, renderItem, overscan = 3 }) {
  const containerRef = useRef(null);
  const [scrollTop, setScrollTop] = useState(0);
  const [containerTop, setContainerTop] = useState(0);
  
  useEffect(() => {
    function updateContainerTop() {
      const rect = containerRef.current?.getBoundingClientRect();
      if (!rect) return;
      // containerTop 相对于文档顶部
      setContainerTop(rect.top + window.scrollY);
    }
    updateContainerTop();
    window.addEventListener('resize', updateContainerTop);
    return () => window.removeEventListener('resize', updateContainerTop);
  }, []);

  useEffect(() => {
    // throttle using requestAnimationFrame
    let ticking = false;
    function onScroll() {
      if (!ticking) {
        window.requestAnimationFrame(() => {
          setScrollTop(window.scrollY);
          ticking = false;
        });
        ticking = true;
      }
    }
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => window.removeEventListener('scroll', onScroll);
  }, []);

  const height = window.innerHeight;
  const totalHeight = data.length * itemHeight;
  // 视口相对于 container 的 scrollTop
  const relativeScrollTop = Math.max(0, scrollTop - containerTop);
  const startIndex = Math.max(0, Math.floor(relativeScrollTop / itemHeight) - overscan);
  const endIndex = Math.min(data.length, startIndex + Math.ceil(height / itemHeight) + overscan * 2);

  const items = [];
  for (let i = startIndex; i < endIndex; i++) {
    items.push(
      <div key={i} style={{ position: 'absolute', top: i * itemHeight, left: 0, right: 0, height: itemHeight }}>
        {renderItem(data[i], i)}
      </div>
    );
  }

  return (
    <div ref={containerRef} style={{ position: 'relative' }}>
      <div style={{ height: totalHeight, position: 'relative' }}>
        {items}
      </div>
    </div>
  );
}

要点与优化:

  • window.requestAnimationFrame 节流很重要,避免 scroll 事件触发过多重渲
  • 在 SSR 或移动端要测试 window.innerHeight 与 safe area 情况

4. 单列 + 动态高度 + 定容器 + 定量(测高 + 二分索引) ​

思路:

项目高度不一致,需在渲染后测量每项高度并构建 offsetTable(累计偏移)。滚动时通过二分查找 offsetTable 找到 startIndex。用 ResizeObserver 动态维护高度表。

难点:

  • 需要准确且高效地维护每项高度(避免频繁 reflow)
  • 新测量到高度变化后要更新累计偏移并修正滚动位置(防止闪跳)
  • 二分查找实现要求正确

完整代码:

js
// VariableSizeList.jsx
import React, { useRef, useState, useCallback, useEffect } from 'react';

/**
 * props:
 * - data
 * - height (container height px)
 * - renderItem: (item, index, measureRef) => ReactNode  // measureRef 用于测量 DOM
 * - estimatedItemHeight: number
 * - overscan
 *
 * 实现思路:
 * - 维护 heights Map<index, height>
 * - 维护 offsets array: offsets[i] = sum_{0..i-1} heights
 * - 二分查找给定 scrollTop -> 找到 startIndex 使 offsets[startIndex] <= scrollTop < offsets[startIndex+1]
 */
export default function VariableSizeList({ data, height, renderItem, estimatedItemHeight = 80, overscan = 3 }) {
  const containerRef = useRef(null);
  const [scrollTop, setScrollTop] = useState(0);
  const heightsRef = useRef(new Map()); // index -> height
  const offsetsRef = useRef([]); // 累计偏移,length = data.length + 1
  const resizeObserverRef = useRef(null);

  // 创建/更新 offsets(根据 heightsRef)
  const recomputeOffsets = useCallback(() => {
    const offsets = new Array(data.length + 1);
    offsets[0] = 0;
    for (let i = 0; i < data.length; i++) {
      const h = heightsRef.current.get(i) ?? estimatedItemHeight;
      offsets[i + 1] = offsets[i] + h;
    }
    offsetsRef.current = offsets;
  }, [data.length, estimatedItemHeight]);

  useEffect(() => {
    recomputeOffsets();
  }, [recomputeOffsets]);

  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const onScroll = () => setScrollTop(el.scrollTop);
    el.addEventListener('scroll', onScroll, { passive: true });
    return () => el.removeEventListener('scroll', onScroll);
  }, []);

  // 二分查找:找到最大的 idx 使 offsets[idx] <= value
  function binarySearch(value) {
    const offsets = offsetsRef.current;
    let low = 0, high = offsets.length - 1;
    while (low < high) {
      const mid = Math.floor((low + high + 1) / 2);
      if (offsets[mid] <= value) low = mid;
      else high = mid - 1;
    }
    return low;
  }

  // 渲染的 start/end
  const startIndex = Math.max(0, binarySearch(scrollTop) - overscan);
  // compute endIndex by scanning forward until offsets[end+1] > scrollTop + height
  let endIndex = startIndex;
  const offsets = offsetsRef.current;
  while (endIndex < data.length && offsets[endIndex] < scrollTop + height + overscan * estimatedItemHeight) {
    endIndex++;
  }

  // 用于每个 item 的测量 ref
  function MeasureWrapper({ index, children }) {
    const ref = useRef(null);
    useEffect(() => {
      const node = ref.current;
      if (!node) return;
      function measure() {
        const h = node.getBoundingClientRect().height;
        const prev = heightsRef.current.get(index);
        if (prev !== h) {
          heightsRef.current.set(index, h);
          recomputeOffsets();
        }
      }
      // 初次测量
      measure();
      // observe
      const ro = new ResizeObserver(measure);
      ro.observe(node);
      return () => ro.disconnect();
    }, [index]);
    return <div ref={ref} style={{ position: 'absolute', left: 0, right: 0 }}>{children}</div>;
  }

  const totalHeight = offsetsRef.current.length ? offsetsRef.current[offsetsRef.current.length - 1] : data.length * estimatedItemHeight;

  const items = [];
  for (let i = startIndex; i < Math.min(data.length, endIndex + overscan); i++) {
    const top = offsetsRef.current[i] ?? (i * estimatedItemHeight);
    items.push(
      <div key={i} style={{ position: 'absolute', top }}>
        <MeasureWrapper index={i}>
          {renderItem(data[i], i)}
        </MeasureWrapper>
      </div>
    );
  }

  return (
    <div ref={containerRef} style={{ position: 'relative', height, overflow: 'auto' }}>
      <div style={{ height: totalHeight, position: 'relative' }}>
        {items}
      </div>
    </div>
  );
}

要点与优化:

  • estimatedItemHeight 对初次滚动与首屏展示非常重要。设置合理估值能减少布局抖动
  • ResizeObserver 用于动态调整,避免 getBoundingClientRect 的频繁同步布局读取
  • 使用二分查找能将滚动定位复杂度从 O(n) 降为 O(log n)
  • 生产使用建议:使用 react-virtual 或 react-virtualized 的动态尺寸实现来避免重复造轮子

5. 单列 + 动态高度 + 定容器 + 动态数量(最常见的复杂场景) ​

思路:

在类型 4 的基础上再支持分页/无限加载。需要注意新数据插入时 offsets 的增量更新,以及避免滚动位置跳动(如果在顶部插入或中间插入会影响位置)。

难点:

  • 新数据插入(尤其是前端插入)会改变 offsets,导致用户看到跳动
  • 需要处理加载时的占位、Skeleton 与测量顺序

完整代码:

js
// InfiniteVariableList.jsx
import React, { useState, useRef, useEffect, useCallback } from 'react';
import VariableSizeList from './VariableSizeList'; // 上一个实现

export default function InfiniteVariableList({ fetchMore, renderItem, height, estimatedItemHeight = 80 }) {
  const [data, setData] = useState([]);
  const [loading, setLoading] = useState(false);
  const anchorRef = useRef(null);

  const loadMore = useCallback(async () => {
    if (loading) return;
    setLoading(true);
    try {
      const more = await fetchMore();
      if (more && more.length) setData(prev => [...prev, ...more]);
    } finally {
      setLoading(false);
    }
  }, [fetchMore, loading]);

  useEffect(() => {
    const obs = new IntersectionObserver(entries => {
      for (const e of entries) if (e.isIntersecting) loadMore();
    }, { root: null, rootMargin: '300px' });
    if (anchorRef.current) obs.observe(anchorRef.current);
    return () => obs.disconnect();
  }, [loadMore]);

  return (
    <div>
      <VariableSizeList
        data={data}
        height={height}
        renderItem={(item, index) => renderItem(item, index)}
        estimatedItemHeight={estimatedItemHeight}
      />
      <div ref={anchorRef} style={{ height: 1 }} />
      {loading && <div>加载中...</div>}
    </div>
  );
}

要点与优化:

如果需要在顶部 prepend(如聊天历史加载更早消息),则必须在插入前记录当前第一个可视元素与它到容器顶部的距离,插入后调整 scrollTop 以保持用户视图稳定。实现步骤:

  1. 记录当前 firstVisibleIndex 与 offset(scrollTop - offsets[firstIndex])
  2. 插入数据并更新 offsets
  3. 计算新 offsets[firstIndex + newItems.length] 并设置 scrollTop = newOffsets[firstIndex + newLen] + offset

💡 提示:生产用例建议使用成熟库或自己封装稳健的"prepend"逻辑。

6. 多列(网格)+ 定高(FixedGrid) ​

思路:

按行列映射:已知每个 item 宽高(或已知列宽与行高),可以把列表看成若干行,每行渲染 colCount 个项,按行进行虚拟化。

难点:

  • 宽度自适应下列数动态计算(窗口 resize)
  • 横向填充/对齐与空白项处理

完整代码:

js
// FixedGrid.jsx
import React, { useRef, useEffect, useState } from 'react';

/**
 * props:
 * - data
 * - containerHeight
 * - itemWidth
 * - itemHeight
 * - renderItem
 * - gap
 */
export default function FixedGrid({ data, containerHeight, itemWidth, itemHeight, renderItem, gap = 8 }) {
  const containerRef = useRef(null);
  const [containerWidth, setContainerWidth] = useState(0);
  useEffect(() => {
    const onResize = () => {
      setContainerWidth(containerRef.current?.clientWidth || 0);
    };
    onResize();
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);

  const colCount = Math.max(1, Math.floor((containerWidth + gap) / (itemWidth + gap)));
  const rowCount = Math.ceil(data.length / colCount);
  const rowHeightWithGap = itemHeight + gap;

  const [scrollTop, setScrollTop] = useState(0);
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    function onScroll() {
      setScrollTop(el.scrollTop);
    }
    el.addEventListener('scroll', onScroll, { passive: true });
    return () => el.removeEventListener('scroll', onScroll);
  }, []);

  const visibleRows = Math.ceil(containerHeight / rowHeightWithGap);
  const startRow = Math.max(0, Math.floor(scrollTop / rowHeightWithGap) - 2);
  const endRow = Math.min(rowCount, startRow + visibleRows + 4);

  const items = [];
  for (let row = startRow; row < endRow; row++) {
    for (let col = 0; col < colCount; col++) {
      const index = row * colCount + col;
      if (index >= data.length) break;
      const left = col * (itemWidth + gap);
      const top = row * rowHeightWithGap;
      items.push(
        <div key={index} style={{ position: 'absolute', left, top, width: itemWidth, height: itemHeight }}>
          {renderItem(data[index], index)}
        </div>
      );
    }
  }

  return (
    <div ref={containerRef} style={{ height: containerHeight, overflow: 'auto', position: 'relative' }}>
      <div style={{ height: rowCount * rowHeightWithGap, position: 'relative', width: '100%' }}>
        {items}
      </div>
    </div>
  );
}

要点与优化:

  • 处理 gap 与 colCount 计算时要注意容器宽度与滚动条宽度
  • 生产使用建议:react-window 的 FixedSizeGrid 更成熟,支持列/行虚拟化

7. 多列 + 不定高(Masonry / 瀑布流) ​

思路:

瀑布流通常按列维护高度数组 colHeights,把每个 item 放进当前最短列,从而实现不定高的纵向填充。虚拟化的难点是:如何根据 scrollTop 快速判断哪些 items 在视口内——因为 items 的 top 是不均匀分布。

常见做法有两种:

  1. 按列维护列表:对每列分别做虚拟化(每列是一个竖直列表)
  2. 全局索引 + 布局缓存:在布局阶段计算每个 item 的 top/left/height 并缓存,滚动时二分查找 top 数组找到可视区

难点:

  • 需要一次性布局或逐步布局并缓存(首次布局代价高)
  • 瀑布布局需测量每个 item 的高度(异步图片加载会影响)
  • 当 item 高度后加载(图片)改变会引起重新布局与大量重排

完整代码:

js
// Masonry.jsx (简化实现,只为把核心思路展现清楚)
import React, { useEffect, useRef, useState } from 'react';

/**
 * props:
 * - data
 * - columnWidth
 * - gap
 * - containerHeight
 * - renderItem: (item, index) => ReactNode  (item must render an element that auto-height fits)
 */
export default function Masonry({ data, columnWidth = 250, gap = 12, containerHeight, renderItem }) {
  const containerRef = useRef(null);
  const [colCount, setColCount] = useState(1);
  const [columns, setColumns] = useState([]); // array of arrays of {index, top, height}

  useEffect(() => {
    const onResize = () => {
      const w = containerRef.current?.clientWidth || 0;
      const c = Math.max(1, Math.floor((w + gap) / (columnWidth + gap)));
      setColCount(c);
    };
    onResize();
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, [columnWidth, gap]);

  // layout: naive: place items into shortest column, but need heights
  // We'll render items invisibly first to measure heights, then build columns.
  useEffect(() => {
    // create a measurement container offscreen
    const measureContainer = document.createElement('div');
    measureContainer.style.position = 'absolute';
    measureContainer.style.left = '-99999px';
    measureContainer.style.top = '0';
    document.body.appendChild(measureContainer);

    const tempHeights = new Array(data.length);
    let measuredCount = 0;

    data.forEach((item, idx) => {
      const node = document.createElement('div');
      node.style.width = `${columnWidth}px`;
      node.innerHTML = `<div>${String(renderItem(item, idx) ?? '')}</div>`;
      measureContainer.appendChild(node);
      // we can't reliably measure react node html this way; in real world use ReactDOM.render or render into iframe/container
      // Here we simulate synchronous measurement by reading clientHeight
      tempHeights[idx] = node.getBoundingClientRect().height || 0;
      measuredCount++;
    });

    // build columns
    const cols = Array.from({ length: colCount }, () => ({ items: [], height: 0 }));
    for (let i = 0; i < data.length; i++) {
      let minCol = 0;
      for (let c = 1; c < cols.length; c++) {
        if (cols[c].height < cols[minCol].height) minCol = c;
      }
      const top = cols[minCol].height;
      cols[minCol].items.push({ index: i, top, height: tempHeights[i] });
      cols[minCol].height += tempHeights[i] + gap;
    }
    setColumns(cols.map(c => c.items));
    document.body.removeChild(measureContainer);
  }, [data, colCount, columnWidth, gap, renderItem]);

  // render
  const containerStyle = { position: 'relative', height: containerHeight, overflow: 'auto' };
  const columnStyle = (i) => ({
    position: 'absolute',
    left: i * (columnWidth + gap),
    top: 0,
    width: columnWidth,
  });

  return (
    <div ref={containerRef} style={containerStyle}>
      <div style={{ position: 'relative', height: Math.max(...columns.map(col => col.reduce((a,b)=>Math.max(a,b.top+b.height),0)), 0) }}>
        {columns.map((col, ci) =>
          <div key={ci} style={columnStyle(ci)}>
            {col.map(item =>
              <div key={item.index} style={{ position: 'absolute', top: item.top, width: '100%' }}>
                {renderItem(data[item.index], item.index)}
              </div>
            )}
          </div>
        )}
      </div>
    </div>
  );
}

⚠️ 注意:上面示例为教学化简版。真实工程中不建议用同步 DOM 拼字符串测量(示例只是展示思路)。真实实现应:

  • 使用真实 React 节点渲染到一个隐藏测量区并使用 ResizeObserver 监听图片加载引起的高度变化(或先用占位高度,图片加载后触发 reflow)
  • 或使用像 react-virtualized 的 Masonry、masonic、react-virtual-masonry 这类库,它们处理了大量边界情况与图片加载问题

要点与优化:

  • 对图片做占位(占位高度 + 图片比例)以减少重排
  • 使用 IntersectionObserver 只渲染可视区内的列项
  • 大型列表建议使用第三方成熟库(masonic / react-virtual-masonry)

8. 混合(不定高 + 撑开容器 + 动态数据)——最复杂(自研示例) ​

思路:

这是现实大型 feed 的场景(页面滚动、动态加载、内容高度不定)。最佳实践通常是混合方案:

  1. 测量并缓存:对每条 item 测量高度,维护 offsets(累计高度表),持久化缓存(local/session/PERSIST)以便后续打开时复用
  2. 二分 + 页块化:用二分查找定位 startIndex;为避免每次都遍历大量 offsets,可把 offsets 划分成块(bucket),每块维护累计高度,二分先在块上定位,再在块内二分
  3. 虚拟化 + 懒渲染:只渲染窗口内 + buffer;图片使用占位(skeleton)并在图片加载完成后更新高度
  4. 增量加载 & 前置/后置插入:插入数据时保持或修正 scrollTop(记录视口第一个元素的相对偏移并在数据变更后重定位)
  5. 防抖/节流测量:将测量操作聚合在微任务或 requestIdleCallback 里执行以减少 layout thrash

下面给出一个尽可能完整的示例(有点长),它展示了:offset 缓存、二分查找、ResizeObserver、页面滚动、以及底部无限加载。此实现为示范用途,真实工程建议用经过高度优化的库或把核心逻辑进一步分离优化。

完整代码:

js
// ComplexFeed.jsx
import React, { useEffect, useRef, useState, useCallback } from 'react';

/**
 * ComplexFeed 要点:
 * - 支持页面滚动(container 撑开)
 * - 支持动态高度(ResizeObserver)
 * - 支持无限加载
 * - 维护 offsets (累积高度),并做二分查找
 *
 * props:
 * - fetchMore(cursor) -> {items: [], nextCursor}
 * - renderItem(item, index, measureRef)
 * - initialData?
 * - estimatedHeight
 */
export default function ComplexFeed({ fetchMore, renderItem, estimatedHeight = 120 }) {
  const containerRef = useRef(null);
  const [items, setItems] = useState([]);
  const heightsRef = useRef(new Map()); // index -> height
  const offsetsRef = useRef([0]); // offsets[0]=0; offsets[i]=sum heights[0..i-1]
  const [scrollY, setScrollY] = useState(window.scrollY);
  const [cursor, setCursor] = useState(null);
  const [loading, setLoading] = useState(false);
  const anchorRef = useRef(null);

  // init: compute offsets
  const recomputeOffsets = useCallback(() => {
    const offsets = [0];
    for (let i = 0; i < items.length; i++) {
      const h = heightsRef.current.get(i) ?? estimatedHeight;
      offsets[i + 1] = offsets[i] + h;
    }
    offsetsRef.current = offsets;
  }, [items.length, estimatedHeight]);

  useEffect(() => { recomputeOffsets(); }, [recomputeOffsets]);

  // scroll listener (throttled via rAF)
  useEffect(() => {
    let ticking = false;
    function onScroll() {
      if (!ticking) {
        window.requestAnimationFrame(() => {
          setScrollY(window.scrollY);
          ticking = false;
        });
        ticking = true;
      }
    }
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => window.removeEventListener('scroll', onScroll);
  }, []);

  // binary search using offsets
  function findStartIndexByScroll(scrollTop) {
    const offsets = offsetsRef.current;
    let low = 0, high = offsets.length - 1;
    while (low < high) {
      const mid = Math.floor((low + high + 1) / 2);
      if (offsets[mid] <= scrollTop) low = mid;
      else high = mid - 1;
    }
    return Math.max(0, low - 1); // low is the largest idx with offsets[idx] <= scrollTop; item index = low
  }

  // compute visible window indexes
  const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800;
  const startIndex = Math.max(0, findStartIndexByScroll(scrollY) - 3);
  let endIndex = startIndex;
  const offsets = offsetsRef.current;
  while (endIndex < items.length && offsets[endIndex] < scrollY + viewportHeight + 300) { // 300px buffer
    endIndex++;
  }

  // measurement wrapper to attach ResizeObserver and update heights
  function MeasuredItem({ index, children }) {
    const ref = useRef(null);
    useEffect(() => {
      const node = ref.current;
      if (!node) return;
      const measure = () => {
        const h = node.getBoundingClientRect().height;
        const prev = heightsRef.current.get(index);
        if (prev !== h) {
          heightsRef.current.set(index, h);
          recomputeOffsets();
        }
      };
      measure();
      const ro = new ResizeObserver(measure);
      ro.observe(node);
      return () => ro.disconnect();
    }, [index]);
    return <div ref={ref} style={{ position: 'absolute', left: 0, right: 0 }}>{children}</div>;
  }

  // render visible items
  const rendered = [];
  for (let i = startIndex; i < Math.min(items.length, endIndex + 3); i++) {
    const top = offsetsRef.current[i] ?? (i * estimatedHeight);
    rendered.push(
      <div key={i} style={{ position: 'absolute', top }}>
        <MeasuredItem index={i}>
          {renderItem(items[i], i)}
        </MeasuredItem>
      </div>
    );
  }

  // total height for spacer
  const totalHeight = offsetsRef.current.length ? offsetsRef.current[offsetsRef.current.length - 1] : items.length * estimatedHeight;

  // infinite loading at bottom
  useEffect(() => {
    const obs = new IntersectionObserver((entries) => {
      for (const e of entries) if (e.isIntersecting && !loading) {
        // load more
        (async () => {
          setLoading(true);
          try {
            const res = await fetchMore(cursor);
            if (res?.items?.length) {
              setItems(prev => [...prev, ...res.items]);
              setCursor(res.nextCursor ?? null);
            }
          } finally {
            setLoading(false);
          }
        })();
      }
    }, { root: null, rootMargin: '400px' });

    if (anchorRef.current) obs.observe(anchorRef.current);
    return () => obs.disconnect();
  }, [fetchMore, cursor, loading]);

  return (
    <div ref={containerRef} style={{ position: 'relative' }}>
      <div style={{ height: totalHeight, position: 'relative' }}>
        {rendered}
      </div>
      <div ref={anchorRef} style={{ height: 1 }} />
      {loading && <div style={{ textAlign: 'center' }}>加载中...</div>}
    </div>
  );
}

要点与优化:

  • 分块 offsets(Bucket):当 items 数量非常大(几十万)时,不要在 offsets 中做全量重算;可以把 offsets 按块(例如每 1000 项)缓存块内累加值,只在涉及到的块做重算
  • 持久化缓存:将 item 高度缓存到 IndexedDB 或 localStorage(带版本)以便下次打开时直接使用,减少首次布局成本
  • 图片占位策略:图片应先占位或用 srcset / loading="lazy" 并监听 load 事件在加载完成后测量高度
  • 并发测量聚合:把测量更新合并到一个 requestIdleCallback 或 setTimeout(...,0) 批次中执行,避免大量同步 layout

⚠️ 生产建议:混合方案的实现非常容易出边界 bug(闪动、跳位、内存暴涨),建议使用成熟库或在内部封装成通用组件并进行大量测试。

实战案例:响应式网格的虚拟滚动实现 🎯 ​

在实际项目中,我们经常需要处理更复杂的场景:响应式多列网格布局 + 无限滚动 + 虚拟化优化。下面分享一个真实项目中的完整实现案例。

项目背景 ​

这是一个图片/卡片展示应用,需要支持:

  • 📱 响应式布局:不同屏幕尺寸显示不同列数(2-6列)
  • ♾️ 无限滚动:分页加载大量数据
  • ⚡ 虚拟滚动:优化渲染性能,支持数万张卡片
  • 🎨 动态高度:卡片高度根据屏幕宽度自适应

核心实现思路 ​

与前面介绍的单列虚拟滚动不同,这个案例我们首先使用了Padding占位法 + 多列网格的方案,但在实践过程中,我们发现了一些值得注意的问题,特此记录。

padding方案实现代码 ​

jsx
import { useRef, useCallback, useState, useEffect, useMemo } from 'react';
import useBreakpoint from './useBreakpoint';

function useResponsiveVirtualGrid(cards) {
    const virtualizedContainerRef = useRef(null);
    const { isAbove } = useBreakpoint();
    const initialTop = useRef(0);

    useEffect(() => {
        if (virtualizedContainerRef.current) {
            const rect =
                virtualizedContainerRef.current.getBoundingClientRect();
            initialTop.current = rect.top;
        }
    }, []);

    const VIRTUALIZATION_CONFIG = {
        gap: 16, // 行间距
        bufferRows: 5, // 缓冲行数,避免滚动时闪烁
    };

    const getLayoutConfig = useCallback(() => {
        const vw = window.innerWidth;

        switch (true) {
            case isAbove('3xl'):
                return {
                    columns: 6,
                    cardHeight: 341,
                    renderRows: 10,
                };
            case isAbove('2xl'):
                return {
                    columns: 6,
                    cardHeight: 284,
                    renderRows: 10,
                };
            case isAbove('xl'):
                return {
                    columns: 5,
                    cardHeight: Math.max(180, (0.9 * vw - 346) / 5 - 28),
                    renderRows: 8,
                };
            case isAbove('md'):
                return {
                    columns: 4,
                    cardHeight: Math.max(180, (0.9 * vw - 316) / 4 - 16),
                    renderRows: 6,
                };
            case isAbove('sm'):
                return {
                    columns: 3,
                    cardHeight: Math.max(180, (0.9 * vw - 316) / 3 - 16),
                    renderRows: 4,
                };
            case isAbove('mobile'):
                return {
                    columns: 3,
                    cardHeight: Math.max(0.28 * vw, 180),
                    renderRows: 4,
                };
            default:
                return {
                    columns: 2,
                    cardHeight: 180,
                    renderRows: 4,
                };
        }
    }, [isAbove]);

    const [virtualizedState, setVirtualizedState] = useState({
        startIndex: 0,
        endIndex: 0,
        paddingTop: 0,
        paddingBottom: 0,
    });

    const layoutConfig = useMemo(() => getLayoutConfig(), [getLayoutConfig]);

    /**
     *  总高度 = paddingTop + 上buffer + 可视区域 + 下buffer + paddingBottom
     *  可视区域 = 可视行数(renderRows) * 行高(cardHeight + gap)
     *  总行数 = 卡片数量(cards.length) / 列数(columns)
     *  列数 = 总列数(columns)
     *  卡片数量 = 卡片数量(cards.length)
     *  缓冲行数 = 缓冲行数(bufferRows)
     *  滚动距离 = 滚动距离(-containerRect.top)
     */
    const calculateVirtualization = useCallback(() => {
        const container = virtualizedContainerRef.current;
        if (!container || cards.length === 0) {
            return {
                startIndex: 0,
                endIndex: 0,
                paddingTop: 0,
                paddingBottom: 0,
            };
        }

        const { columns, cardHeight, renderRows } = layoutConfig;
        const { gap, bufferRows } = VIRTUALIZATION_CONFIG;
        const totalCards = cards.length;
        // const rowHeight = cardHeight + gap;
        const totalRows = Math.ceil(totalCards / columns);
        // const containerRect = container.getBoundingClientRect();
        // const viewportHeight = window.innerHeight;

        // 可见区域
        const scrollTop = window.scrollY - initialTop.current;

        // 可见行范围
        const firstVisibleRow = Math.floor(scrollTop / (cardHeight + gap));
        const startRow = Math.max(0, firstVisibleRow - bufferRows);
        const endRow = Math.min(
            totalRows - 1,
            firstVisibleRow + renderRows + bufferRows - 1
        );
        const paddingTop = startRow * (cardHeight + gap);
        const paddingBottom = (totalRows - endRow - 1) * (cardHeight + gap);
        const startIndex = startRow * columns;
        const endIndex = Math.min(totalCards - 1, (endRow + 1) * columns - 1);

        return {
            startIndex,
            endIndex,
            paddingTop,
            paddingBottom,
        };
    }, [cards, layoutConfig]);

    const updateVirtualization = useCallback(() => {
        const newState = calculateVirtualization();
        // console.log('newState', newState);
        setVirtualizedState(newState);
    }, [calculateVirtualization]);

    const throttledUpdateRef = useRef();
    const lastUpdateTimeRef = useRef(0);

    const throttledUpdate = useCallback(() => {
        const now = Date.now();

        if (throttledUpdateRef.current) {
            clearTimeout(throttledUpdateRef.current);
        }

        const timeSinceLastUpdate = now - lastUpdateTimeRef.current;
        const delay = Math.max(0, 16 - timeSinceLastUpdate);

        throttledUpdateRef.current = setTimeout(() => {
            updateVirtualization();
            lastUpdateTimeRef.current = Date.now();
        }, delay);
    }, [updateVirtualization]);

    useEffect(() => {
        const handleScroll = () => throttledUpdate();
        const handleResize = () => updateVirtualization(); // resize不需要节流

        window.addEventListener('scroll', handleScroll, { passive: true });
        window.addEventListener('resize', handleResize);

        updateVirtualization();

        return () => {
            window.removeEventListener('scroll', handleScroll);
            window.removeEventListener('resize', handleResize);
            if (throttledUpdateRef.current) {
                clearTimeout(throttledUpdateRef.current);
            }
        };
    }, [throttledUpdate, updateVirtualization]);

    useEffect(() => {
        updateVirtualization();
    }, [cards.length, updateVirtualization]);

    const poolCount = useMemo(() => {
        const { columns, renderRows } = layoutConfig;
        const { bufferRows } = VIRTUALIZATION_CONFIG;
        // 固定池大小 = (可视行 + 上下 buffer)* 列数
        return (renderRows + bufferRows * 2) * columns;
    }, [layoutConfig]);

    return {
        virtualizedContainerRef,
        virtualizedState,
        layoutConfig,
        poolCount,
    };
}

export default useResponsiveVirtualGrid;


// 组件中的使用示例
function GalleryComponent({ cards, loadMore, allLoaded, isLoading }) {
    const { virtualizedContainerRef, virtualizedState, poolCount } =
        useResponsiveVirtualGrid(cards);

    return (
        <div
            className={`gallery-container h-full pb-[40px] ${
                cards.length === 0 ? 'overflow-hidden' : ''
            }`}
            ref={virtualizedContainerRef}
            style={{
                paddingTop: virtualizedState.paddingTop,
                paddingBottom: virtualizedState.paddingBottom,
            }}
        >
               <ScrollLoader
                            allLoaded={allLoaded}
                            loadMore={loadMore}
                            ref={ScrollLoaderRef}
                            width="100%"
                        >
                            {Array.from({ length: poolCount }).map((_, i) => {
                                const actualIndex =
                                    virtualizedState.startIndex + i;
                                const inRange =
                                    actualIndex <= virtualizedState.endIndex &&
                                    actualIndex < cards.length;
                                const card = inRange
                                    ? cards[actualIndex]
                                    : null;
                                if (!card) return null;
                                return (
                                    <div
                                        key={card.task_uuid} // key={i}
                                    >
                                        {inRange && card ? (
                                            <CardDataContext.Provider
                                                value={card}
                                            >
                                                <RodinCard />
                                            </CardDataContext.Provider>
                                        ) : null}
                                    </div>
                                );
                            })}
                        </ScrollLoader>
        </div>
    );
}

上述方案的缺陷 ​

上面的示例代码我们在实践过程当中,发现了两个坑:

1. key值的使用

  • 这里特别要注意,虚拟列表里面的key值不能使用唯一的id值,而要使用索引值!!!
  • 因为当快速滚动的时候,react的渲染机制可能会复用旧的DOM节点,导致出现多个重复的DOM节点。

2. padding的问题

  • 这里使用padding机制会有些隐藏的bug,因为我这里是一个滚动触底加载的场景,这里可能会出现当触底加载的时候,scrollTop的计算问题,导致滚动到底部时,会闪烁一下直接渲染新数据,也就是当你在第10页触发新数据加载的时候,瞬间到了第11页的底部。
  • 我这里猜测是浏览器自动布局的问题,因此后面转用定位的方案来解决这个问题

transform方案实现代码 ​

完整hook:

js
import { useRef, useCallback, useState, useEffect, useMemo } from 'react';
import useBreakpoint from './useBreakpoint';

function useResponsiveVirtualGrid(cards) {
    const virtualizedContainerRef = useRef(null);
    const { isAbove } = useBreakpoint();
    const initialTop = useRef(0);

    useEffect(() => {
        if (virtualizedContainerRef.current) {
            const rect =
                virtualizedContainerRef.current.getBoundingClientRect();
            // 记录容器相对文档的初始位置
            initialTop.current = rect.top + window.scrollY;
        }
    }, []);

    const VIRTUALIZATION_CONFIG = {
        gap: 16,
        bufferRows: 10,
    };

    const getLayoutConfig = useCallback(() => {
        const vw = window.innerWidth;

        switch (true) {
            case isAbove('3xl'):
                return { columns: 6, cardHeight: 341, renderRows: 10 };
            case isAbove('2xl'):
                return { columns: 6, cardHeight: 284, renderRows: 10 };
            case isAbove('xl'):
                return {
                    columns: 5,
                    cardHeight: Math.max(180, (0.9 * vw - 346) / 5 - 28),
                    renderRows: 8,
                };
            case isAbove('md'):
                return {
                    columns: 4,
                    cardHeight: Math.max(180, (0.9 * vw - 316) / 4 - 16),
                    renderRows: 6,
                };
            case isAbove('sm'):
                return {
                    columns: 3,
                    cardHeight: Math.max(180, (0.9 * vw - 316) / 3 - 16),
                    renderRows: 4,
                };
            case isAbove('mobile'):
                return {
                    columns: 3,
                    cardHeight: Math.max(0.28 * vw, 180),
                    renderRows: 4,
                };
            default:
                return { columns: 2, cardHeight: 180, renderRows: 4 };
        }
    }, [isAbove]);

    const [virtualizedState, setVirtualizedState] = useState({
        startIndex: 0,
        endIndex: 0,
        offsetY: 0,
        totalHeight: 0,
    });

    const layoutConfig = useMemo(() => getLayoutConfig(), [getLayoutConfig]);

    const calculateVirtualization = useCallback(() => {
        const container = virtualizedContainerRef.current;
        if (!container || cards.length === 0) {
            return { startIndex: 0, endIndex: 0, offsetY: 0, totalHeight: 0 };
        }

        const { columns, cardHeight, renderRows } = layoutConfig;
        const { gap, bufferRows } = VIRTUALIZATION_CONFIG;
        const totalCards = cards.length;
        const rowHeight = cardHeight + gap;
        const totalRows = Math.ceil(totalCards / columns);
        const totalHeight = totalRows * rowHeight - gap;

        const scrollTop = window.scrollY - initialTop.current;
        const firstVisibleRow = Math.floor(scrollTop / rowHeight);
        const startRow = Math.max(0, firstVisibleRow - bufferRows);
        const endRow = Math.min(
            totalRows - 1,
            firstVisibleRow + renderRows + bufferRows - 1
        );

        const offsetY = startRow * rowHeight;
        const startIndex = startRow * columns;
        const endIndex = Math.min(totalCards - 1, (endRow + 1) * columns - 1);

        return { startIndex, endIndex, offsetY, totalHeight };
    }, [cards, layoutConfig]);

    const updateVirtualization = useCallback(() => {
        const newState = calculateVirtualization();
        setVirtualizedState(newState);
    }, [calculateVirtualization]);

    useEffect(() => {
        const handleScroll = () => updateVirtualization();
        const handleResize = () => updateVirtualization();

        window.addEventListener('scroll', handleScroll, { passive: true });
        window.addEventListener('resize', handleResize);

        updateVirtualization();

        return () => {
            window.removeEventListener('scroll', handleScroll);
            window.removeEventListener('resize', handleResize);
        };
    }, [updateVirtualization]);

    const poolCount = useMemo(() => {
        const { columns, renderRows } = layoutConfig;
        const { bufferRows } = VIRTUALIZATION_CONFIG;
        return (renderRows + bufferRows * 2) * columns;
    }, [layoutConfig]);

    const minHeight = useMemo(() => {
        const { cardHeight } = layoutConfig;
        const { gap } = VIRTUALIZATION_CONFIG;
        return 2 * cardHeight + gap;
    }, [layoutConfig]);

    return {
        virtualizedContainerRef,
        virtualizedState,
        layoutConfig,
        poolCount,
        minHeight,
    };
}

export default useResponsiveVirtualGrid;

使用样例:

js
const { 
        virtualizedContainerRef,
        virtualizedState,
        layoutConfig,
        poolCount,
        minHeight,
    } = useResponsiveVirtualGrid(cards);

const { columns } = layoutConfig;

<ScrollLoader
  allLoaded={allLoaded}
  loadMore={loadMore}
  ref={ScrollLoaderRef}
  sentinelRefAbsolute
  width="100%"
>
  {/* 内层内容通过 translateY 定位 */}
  <div
      style={{
          position: 'absolute',
          top: 0,
          left: 0,
          width: '100%',
          transform: `translateY(${virtualizedState.offsetY}px)`,
          willChange: 'transform',
          display: 'grid',
          gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
          gap: '16px',
      }}
  >
      {Array.from({ length: poolCount }).map(
          (_, i) => {
              const actualIndex =
                  virtualizedState.startIndex + i;
              const inRange =
                  actualIndex <=
                      virtualizedState.endIndex &&
                  actualIndex < cards.length;
              const card = inRange
                  ? cards[actualIndex]
                  : null;
              if (!card) return null;

              return (
                  <CardDataContext.Provider
                      key={actualIndex}
                      value={card}
                  >
                      <RodinCard />
                  </CardDataContext.Provider>
              );
          }
      )}
  </div>
</ScrollLoader>

实现亮点分析 ✨ ​

这个实战案例展现了虚拟滚动在复杂场景下的应用技巧:

1. 响应式配置统一管理 📱 ​

js
// 🎯 亮点:将所有断点配置集中管理,避免重复计算
const getLayoutConfig = useCallback(() => {
    const vw = window.innerWidth;
    
    // 根据视口宽度返回不同的布局配置
    switch (true) {
        case isAbove('3xl'): return { columns: 6, cardHeight: 341, renderRows: 7 };
        case isAbove('2xl'): return { columns: 6, cardHeight: 284, renderRows: 6 };
        case isAbove('xl'):  return { columns: 5, cardHeight: Math.max(180, (0.9 * vw - 346) / 5 - 28), renderRows: 5 };
        // ... 更多断点配置
    }
}, [isAbove]);

2. 行级虚拟化策略 📏 ​

js
// 🎯 亮点:以行为单位进行虚拟化,而不是单个卡片
const visibleStartRow = Math.floor(scrollTop / rowHeight);
const visibleEndRow = Math.min(totalRows, Math.ceil(scrollBottom / rowHeight));

// 添加缓冲区,提升滚动体验
const renderStartRow = Math.max(0, visibleStartRow - bufferRows);
const renderEndRow = Math.min(totalRows, visibleEndRow + bufferRows);

3. 性能优化细节 🚀 ​

js
// 节流处理,控制更新频率
const throttledUpdateVirtualization = useCallback(
    useThrottle(updateVirtualization, 16), // 约60fps
    [updateVirtualization]
);

// 被动监听,避免阻塞滚动
window.addEventListener('scroll', handleScroll, { passive: true });

// 开发环境调试信息
if (process.env.NODE_ENV === 'development') {
    console.log('虚拟化更新:', { /* 调试数据 */ });
}

三方库使用 react-window ​

1. 什么是 React-Window? ​

react-window 是一个用于实现虚拟列表的 React 库,它提供了:

  • List - 一维列表(垂直或水平)
  • Grid - 二维网格(多列多行)

2. 核心原理 ​

┌─────────────────────────┐
│  可视区域(Viewport)    │  ← 只渲染这个区域内的元素
├─────────────────────────┤
│  [1] [2] [3] [4] [5]    │  ← 可见的单元格
│  [6] [7] [8] [9] [10]   │
│  ...                    │
├─────────────────────────┤
│  虚拟占位空间            │  ← 不渲染,但占据空间
│  [11-1000]              │
└─────────────────────────┘

Grid 组件使用详解 ​

1. 安装 ​

bash
npm install react-window
# 或
pnpm add react-window

2. 基本导入 ​

typescript
import { Grid, CellComponentProps, useGridRef } from 'react-window'
  • Grid - 网格组件
  • CellComponentProps - 单元格组件的 props 类型
  • useGridRef - 创建 Grid 的 ref(用于获取 Grid 的 DOM 元素)

2. Grid 组件必需参数 ​

2.1 cellComponent - 单元格渲染函数 ​
typescript
// 这是渲染每个单元格的函数
const Cell = ({ columnIndex, rowIndex, style, ...props }: CellComponentProps) => {
  // columnIndex: 列索引(从 0 开始)
  // rowIndex: 行索引(从 0 开始)
  // style: Grid 自动计算的样式(包含 position、transform 等)
  
  const index = rowIndex * columnCount + columnIndex
  
  return (
    <div style={style}>
      {/* 你的单元格内容 */}
      项目 #{index}
    </div>
  )
}

重要: style 必须应用到单元格的根元素上,否则定位会出错!

2.2 cellProps - 传递给单元格的额外 props ​
typescript
<Grid
  cellProps={{ 
    // 这里的数据会作为 props 传递给 Cell 组件
    items: myItems 
  }}
/>
2.3 columnCount - 列数 ​
typescript
columnCount={4}  // 一行显示 4 列
2.4 rowCount - 行数 ​
typescript
// 行数 = 数据总数 / 列数(向上取整)
const rowCount = Math.ceil(items.length / columnCount)
2.5 columnWidth - 列宽 ​
typescript
columnWidth={280}  // 每列 280 像素
// 或者使用函数:(index) => number
columnWidth={(index) => index === 0 ? 300 : 280}
2.6 rowHeight - 行高 ​
typescript
rowHeight={200}  // 每行 200 像素
// 或者使用函数:(index) => number
rowHeight={(index) => index % 2 === 0 ? 200 : 250}
2.7 width 和 height - Grid 容器尺寸 ​
typescript
width={800}        // Grid 宽度(像素)
height={600}       // Grid 高度(像素)
// 或者
width="100%"       // 百分比宽度
2.8 style - Grid 容器样式 ​
typescript
style={{
  height: '600px',
  width: '800px',
  overflowY: 'auto',  // 允许垂直滚动
  overflowX: 'hidden' // 隐藏水平滚动
}}

3. 完整的基础示例 ​

typescript
import { Grid, CellComponentProps, useGridRef } from 'react-window'

function MyGrid() {
  const gridRef = useGridRef()
  const items = Array.from({ length: 1000 }, (_, i) => ({ id: i, name: `项目 ${i}` }))
  const columnCount = 4
  const rowCount = Math.ceil(items.length / columnCount)

  // 单元格渲染函数
  const Cell = ({ columnIndex, rowIndex, style }: CellComponentProps) => {
    const index = rowIndex * columnCount + columnIndex
    
    if (index >= items.length) {
      return null  // 超出范围不渲染
    }

    const item = items[index]

    return (
      <div style={style} className="p-4 border">
        {item.name}
      </div>
    )
  }

  return (
    <Grid
      gridRef={gridRef}
      cellComponent={Cell}
      cellProps={{}}
      columnCount={columnCount}
      columnWidth={280}
      rowCount={rowCount}
      rowHeight={200}
      width={1200}
      style={{
        height: '600px',
        overflowY: 'auto',
        overflowX: 'hidden'
      }}
    />
  )
}