React 大规模虚拟滚动的高级策略:处理包含多维嵌套与异步加载内容的 React 长列表回收算法

欢迎来到 DOM 的地狱与天堂:React 大规模虚拟滚动的高级艺术

各位前端同仁,下午好!

把手里的咖啡放下,深呼吸。我知道,你们中的很多人现在正盯着屏幕上那个转了又转、停了又转、永远停不下来的加载圈圈,内心在咆哮:“为什么我的列表只有 100 条数据,渲染出来却像个瘫痪的老人家?”

今天,我们不谈什么“Hello World”,不谈什么简单的 map 循环。今天,我们要聊聊一个让无数初学者脱发、让资深架构师抓狂,但一旦掌握就能让你在面试中傲视群雄(或者至少能保住你的发际线)的高级话题——React 虚拟滚动

特别是那种嵌套结构复杂数据异步加载大小不一的“核弹级”长列表。

准备好了吗?我们要开始解剖浏览器了。


第一部分:为什么你的浏览器要“吐”出来?

首先,我们要明白一个残酷的事实:DOM 节点不是免费的。它们是昂贵的。

想象一下,你有一个包含 10,000 条数据的列表。如果你用普通的 div 把它们全部渲染在页面上,你的 DOM 树里会有 10,000 个节点。这就像是让你的浏览器去背 10,000 块石头。

当你滚动鼠标滚轮时,浏览器需要做的事情是:

  1. 计算布局:重新计算这 10,000 个 div 的位置、宽高、边距。
  2. 重绘:把这些 div 画在屏幕上。
  3. 合成:把像素层叠起来。

如果列表是 100,000 条呢?你的浏览器会直接给你一记耳光,或者直接卡死。这就是所谓的“DOM 神经症”。

虚拟滚动,就是那个神奇的“幽灵列表”。

它的核心思想很简单:你只需要渲染你眼睛能看到的那些。 其他的?让它们在内存里待着,或者干脆不要生成 DOM 节点。

就像你透过窗户看风景。你不需要把整个山都搬进屋子里,你只需要看到窗户框住的那一小块风景。当你滚动时,你只是把窗户往前挪了一步。


第二部分:1D 虚拟滚动的“Hello World”

让我们先从一个最基础的版本开始。假设我们有 10,000 个高度固定的项目,每个高度 50px。

// 简单的 1D 虚拟滚动逻辑
const VirtualList = ({ items }) => {
  // 1. 计算视口高度
  const containerHeight = 600; 
  // 2. 计算每个项目的高度
  const itemHeight = 50;

  // 3. 计算可视区域内的起始和结束索引
  // 比如:scrollTop 是 100,那么 start 就是 2
  const start = Math.floor(scrollTop / itemHeight);
  const end = start + Math.ceil(containerHeight / itemHeight);

  // 4. 截取数据
  const visibleItems = items.slice(start, end);

  // 5. 计算偏移量,让它们归位
  const offsetY = start * itemHeight;

  return (
    <div 
      style={{ height: containerHeight, overflow: 'auto', position: 'relative' }}
      onScroll={(e) => { /* 这里需要更新 scrollTop 状态 */ }}
    >
      <div style={{ height: items.length * itemHeight, position: 'absolute', top: 0, left: 0 }}>
        {visibleItems.map((item, index) => (
          <div key={item.id} style={{ height: itemHeight, top: offsetY }}>
            {item.content}
          </div>
        ))}
      </div>
    </div>
  );
};

看,这很简单,对吧?我们只渲染了大概 12-20 个 div,而不是 10,000 个。

但是,现实世界从来不会这么温柔。现实世界充满了嵌套异步


第三部分:嵌套地狱——当俄罗斯套娃遇上虚拟滚动

现在,假设你的列表不是平铺直叙的,而是一个树状结构,或者是一个手风琴组件。每个父级下面可能有子级,子级下面还有孙级。

如果父级折叠了,子级应该消失吗?如果父级展开了,子级的高度突然增加了,怎么办?

普通的 1D 虚拟滚动在这里会崩溃。因为“滚动”的概念变得模糊了。你是滚动父级列表?还是滚动子级列表?

策略:坐标变换

要解决这个问题,我们需要把“嵌套结构”扁平化,或者更准确地说,我们需要给每个节点分配一个全局坐标

想象一下,你把所有的子节点都拉直了,像一条长龙一样排成一排。虽然它们在 DOM 结构上还是嵌套的,但在计算逻辑上,它们是一个连续的流。

// 伪代码:扁平化数据结构
function flattenTree(items) {
  const result = [];
  items.forEach(item => {
    result.push({ type: 'parent', data: item, depth: 0 });
    if (item.children) {
      item.children.forEach(child => {
        result.push({ type: 'child', data: child, depth: 1 });
        // 如果还有孙级...
      });
    }
  });
  return result;
}

但是,直接扁平化会破坏 React 的渲染结构。我们不能直接 map 一个扁平数组,然后强行塞进嵌套的 JSX 里,那会让代码变得不可读且难以维护。

高级策略:

我们需要一种“混合渲染”模式。

  1. 外层容器:负责处理滚动事件,计算全局的 scrollTop
  2. 递归渲染器:遍历数据树,但根据全局的 scrollTopcontainerHeight 来决定是否渲染当前节点及其子节点。

代码示例:嵌套虚拟滚动

这是一个简化的示例,展示了如何处理嵌套。

const NestedVirtualList = ({ data }) => {
  const containerRef = React.useRef(null);
  const [scrollTop, setScrollTop] = React.useState(0);
  const containerHeight = 800;
  const itemHeight = 50; // 假设基础高度

  // 辅助函数:计算节点在全局流中的起始位置
  const getGlobalOffset = (node, items) => {
    // 这里需要遍历 items 来找到当前节点的位置
    // 在实际生产中,这通常在 useMemo 中预先计算好
    return 0; // 简化演示
  };

  // 核心渲染逻辑
  const renderItem = (node, globalIndex) => {
    // 1. 检查是否在视口内
    // 我们假设每个节点至少占 itemHeight
    const nodeStart = globalIndex * itemHeight;
    const nodeEnd = nodeStart + itemHeight;

    if (nodeEnd < scrollTop || nodeStart > scrollTop + containerHeight) {
      return null; // 完全不可见,不渲染
    }

    return (
      <div key={node.id} style={{ height: itemHeight, position: 'relative' }}>
        {/* 渲染节点内容 */}
        <div style={{ padding: node.depth * 20 }}>
          {node.title} (Depth: {node.depth})
        </div>

        {/* 递归渲染子节点 */}
        {node.children && node.children.length > 0 && (
          <div style={{ marginLeft: 20, borderLeft: '1px solid #ccc' }}>
            {node.children.map((child, i) => 
              renderItem(child, globalIndex + 1 + i)
            )}
          </div>
        )}
      </div>
    );
  };

  return (
    <div 
      ref={containerRef}
      style={{ height: containerHeight, overflow: 'auto' }}
      onScroll={(e) => setScrollTop(e.target.scrollTop)}
    >
      {/* 这是一个占位符,确保总高度足够滚动 */}
      <div style={{ height: data.length * itemHeight, position: 'relative' }}>
        {data.map((node, i) => renderItem(node, i))}
      </div>
    </div>
  );
};

注意看上面的代码,这其实是一个“偷懒”的实现。它并没有真正优化子节点的渲染。如果你展开一个子节点,它下面的所有孙节点都会被渲染出来,哪怕它们不可见。

真正的高级策略是:当父节点不可见时,直接跳过渲染它的子节点。


第四部分:异步加载——数据不是送上门的

现在,我们有了嵌套结构,接下来是异步加载。

想象一下,你正在加载一个包含 100,000 条数据的论坛帖子列表。你不能一次性把所有帖子都拉下来,那会撑爆你的内存。你只能“按需加载”,比如每次只拉 50 条。

挑战:
当数据还没拉到的时候,你怎么知道这一项有多高?如果你不知道高度,你就无法计算它在列表中的位置,也就无法滚动!

策略:动态测量

我们需要一个 measureItem 函数。当数据返回时,我们使用 ResizeObserver 来测量实际渲染后的大小,然后更新虚拟滚动的状态。

这就像你去餐厅点餐。服务员不知道汉堡有多大,直到汉堡端上来,你量一下,告诉他:“嘿,这个汉堡 15cm,下一个汉堡 20cm。”

代码示例:处理异步高度

const AsyncVirtualList = ({ fetchData }) => {
  const [visibleItems, setVisibleItems] = useState([]);
  const [scrollTop, setScrollTop] = useState(0);
  const [itemSizes, setItemSizes] = useState({}); // 缓存测量结果

  const containerHeight = 600;
  const buffer = 5; // 缓冲区,多渲染几个防止闪烁

  const loadMoreData = async () => {
    // 模拟异步获取数据
    const newData = await fetchData(10); 
    setVisibleItems(prev => [...prev, ...newData]);
  };

  // 当数据加载完成,我们需要重新计算布局
  React.useEffect(() => {
    if (visibleItems.length > 0) {
      // 这里是一个简化的测量逻辑
      // 实际中,我们需要遍历 DOM 节点获取 offsetHeight
      const newSizes = {};
      visibleItems.forEach((item, index) => {
        // 假设我们有一个 ref 到每个 item
        const el = document.getElementById(`item-${index}`);
        if (el) {
          newSizes[index] = el.offsetHeight;
        }
      });
      setItemSizes(newSizes);
    }
  }, [visibleItems]);

  // 计算可视窗口
  const startIndex = Math.max(0, Math.floor(scrollTop / 100) - buffer);
  const endIndex = Math.min(
    visibleItems.length, 
    Math.floor((scrollTop + containerHeight) / 100) + buffer
  );

  return (
    <div 
      style={{ height: containerHeight, overflow: 'auto' }}
      onScroll={(e) => setScrollTop(e.target.scrollTop)}
    >
      {/* 这里的 100 是估算值,用于初始滚动 */}
      <div style={{ position: 'relative' }}>
        {visibleItems.map((item, index) => {
           // 如果我们还没测量过这个 item 的高度,先用估算值
           const height = itemSizes[index] || 100;

           // 如果 index 不在可视范围内,就不渲染 DOM(高级优化)
           if (index < startIndex || index >= endIndex) return null;

           return (
             <div 
               key={item.id} 
               id={`item-${index}`}
               style={{ 
                 height: `${height}px`, 
                 position: 'absolute', 
                 top: `${index * 100}px` // 这里的 100 应该是累计高度
               }}
             >
               {item.content}
             </div>
           )
        })}
      </div>
    </div>
  );
};

等等,上面的代码有个大坑!

如果你在 useEffect 里测量高度,DOM 已经渲染完了。测量之后,height 变了,但 top 没变,列表会瞬间跳动。这是虚拟滚动中的“重排闪烁”。

高级策略:延迟渲染与批量更新

不要在每次数据返回时都触发重渲染。我们需要一个“测量队列”。

  1. 初始渲染:使用估算高度(比如 100px)渲染所有可见项。
  2. 测量阶段:遍历可见项,获取真实高度。
  3. 更新阶段:收集所有变化的高度,一次性更新状态,让 React 批量处理 DOM 变化。

第五部分:核心算法——视口管理器

好了,现在我们有了嵌套,有了异步。我们需要一个真正的“视口管理器”。这通常是 react-windowreact-virtualized 这类库的核心。

让我们来手写一个稍微复杂一点的逻辑,处理动态高度缓冲区

关键概念:虚拟缓冲区

你不能只渲染视口内的元素。为什么?因为当你滚动到列表边缘时,浏览器需要一点时间去计算下一个元素的位置。如果视口边缘正好有一个元素,你会看到它在闪烁。

所以,我们多渲染 buffer 个元素(比如 5 个)。

完整的高级实现逻辑

const AdvancedVirtualScroller = ({ data, renderItem }) => {
  const containerRef = React.useRef(null);
  const [scrollTop, setScrollTop] = React.useState(0);
  const [itemHeights, setItemHeights] = React.useState(new Map());
  const [estimatedHeights, setEstimatedHeights] = React.useState(new Map());

  const containerHeight = 800;
  const buffer = 5;

  // 计算累计高度
  const getOffsetTop = (index) => {
    let top = 0;
    for (let i = 0; i < index; i++) {
      top += itemHeights.get(i) || estimatedHeights.get(i) || 50; // 使用缓存或估算值
    }
    return top;
  };

  // 滚动处理
  const handleScroll = (e) => {
    const newScrollTop = e.target.scrollTop;

    // 防抖动,避免高频滚动触发重计算
    requestAnimationFrame(() => {
      setScrollTop(newScrollTop);
    });
  };

  // 测量元素
  const handleItemResize = (index, height) => {
    setItemHeights(prev => new Map(prev).set(index, height));
  };

  // 核心计算:确定渲染范围
  const getRenderRange = () => {
    let start = 0;
    let end = data.length;

    let currentOffset = 0;

    // 向下查找起始索引
    for (let i = 0; i < data.length; i++) {
      const h = itemHeights.get(i) || estimatedHeights.get(i) || 50;
      if (currentOffset + h > scrollTop) {
        start = Math.max(0, i - buffer);
        break;
      }
      currentOffset += h;
    }

    // 向上查找结束索引
    currentOffset = 0;
    for (let i = 0; i < data.length; i++) {
      const h = itemHeights.get(i) || estimatedHeights.get(i) || 50;
      if (currentOffset + h > scrollTop + containerHeight + buffer * 50) { // buffer 估算
        end = i;
        break;
      }
      currentOffset += h;
    }

    return { start, end };
  };

  const { start, end } = getRenderRange();

  return (
    <div 
      ref={containerRef}
      style={{ height: containerHeight, overflow: 'auto', position: 'relative' }}
      onScroll={handleScroll}
    >
      <div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
        {data.map((item, index) => {
          if (index < start || index >= end) return null; // 超出范围,不渲染

          const top = getOffsetTop(index);
          const height = itemHeights.get(index) || estimatedHeights.get(index) || 50;

          // 我们传入一个 ref 给 renderItem,以便测量
          const itemRef = (node) => {
            if (node) {
              handleItemResize(index, node.offsetHeight);
            }
          };

          return (
            <div 
              key={item.id || index}
              ref={itemRef}
              style={{ 
                position: 'absolute', 
                top: `${top}px`, 
                height: `${height}px`,
                width: '100%'
              }}
            >
              {renderItem(item, index)}
            </div>
          );
        })}
      </div>
    </div>
  );
};

这个实现处理了:

  1. 动态高度:通过 ResizeObserver 的思想(这里简化为 ref 回调)来更新高度。
  2. 缓冲区:多渲染了一点点内容。
  3. 性能:通过 position: absolute 排除掉不可见元素,避免它们参与布局计算。

第六部分:React 的陷阱——重渲染

写完了算法,我们还要面对 React 本身。

如果你在一个大列表里,每个 item 都是一个复杂的组件,当你滚动时,VirtualScroller 父组件更新了 scrollTop,导致整个列表重新渲染。

问题: 即使 VirtualScroller 很聪明地只渲染了可见的 div,但如果每个 div 里面包裹的是一个 React.memo 优化不好的组件,那个组件依然会重新渲染!

策略:Item 组件优化

const MemoizedItem = React.memo(({ content }) => {
  console.log(`Rendering item: ${content}`); // 只有当 props 变化时才会打印
  return <div>{content}</div>;
});

// 在 VirtualScroller 中使用
{data.map((item, index) => (
  <MemoizedItem key={item.id} content={item.content} />
))}

策略:保持引用稳定

如果你的 item 内容会频繁变化,而 React 认为它引用变了,它就会重渲染。确保你的 key 是稳定的(通常是 ID),并且尽量减少 renderItem prop 的变化。

策略:使用 window.requestAnimationFrame

滚动事件是高频触发的。不要在 onScroll 里做任何昂贵的 DOM 操作。onScroll 是由浏览器触发的,非常快。

const handleScroll = (e) => {
  // 1. 保存滚动位置到 state
  setScrollTop(e.target.scrollTop); 

  // 2. 使用 requestAnimationFrame 进行节流或防抖
  // 这样可以避免在滚动的一瞬间疯狂计算
  window.requestAnimationFrame(() => {
    // 这里可以做一些计算
  });
};

第七部分:终极奥义——无限滚动与瀑布流

现在,我们有了处理嵌套和异步的基础。让我们看看更高级的玩法。

无限滚动

当用户滚动到底部时,自动加载更多数据。
在虚拟滚动里,这很简单。我们只需要在 end 接近 data.length 时,触发数据获取函数。

const handleScroll = (e) => {
  const { scrollTop, scrollHeight, clientHeight } = e.target;
  // 如果滚动到底部附近 (比如距离底部还有 200px)
  if (scrollTop + clientHeight >= scrollHeight - 200) {
    loadMoreData(); // 触发加载
  }
};

瀑布流布局

这是最难搞的。因为每列的高度不同,你不能简单地用 top 来定位。

高级策略:分列渲染

不要把所有东西渲染在一个巨大的绝对定位的 div 里。我们可以把列表分成几列(比如 3 列)。

  1. 数据分配:把数据按顺序分配给列 1、列 2、列 3。
  2. 独立滚动:每列有自己的滚动条(或者共用一个滚动条,但内部计算独立)。
  3. 视口计算:计算每一列在视口内渲染哪些数据。

这会大大降低单列的数据量,从而让计算变得轻松。

// 简化的分列逻辑
const columns = 3;
const columnData = data.reduce((acc, item, index) => {
  const colIndex = index % columns;
  if (!acc[colIndex]) acc[colIndex] = [];
  acc[colIndex].push(item);
  return acc;
}, []);

return (
  <div style={{ display: 'flex' }}>
    {columnData.map((colItems, i) => (
      <div key={i} style={{ flex: 1, overflow: 'auto' }}>
        {/* 这里可以套用上面的 AdvancedVirtualScroller 逻辑 */}
        {colItems.map(item => renderItem(item))}
      </div>
    ))}
  </div>
);

结语:不要重复造轮子,但要懂轮子

好了,各位。

我们今天聊了虚拟滚动的原理,聊了如何处理嵌套结构的“俄罗斯套娃”,聊了如何应对异步加载的“不确定性”,甚至聊了瀑布流这种“硬骨头”。

写一个像 react-window 那样的库,需要深厚的数学功底和对浏览器渲染引擎的深刻理解。在实际工作中,不要为了炫技而完全自己造轮子。

但是,一定要理解这些原理。

为什么你的列表会卡?为什么展开一个折叠面板会导致整个列表重排?为什么你的异步数据加载后列表会跳动?

当你理解了“视口”、“坐标映射”、“重排与重绘”这些概念时,你就不再是一个只会写 map 的初级开发者,而是一个真正掌控 DOM 的架构师。

记住,虚拟滚动不是魔法,它是数学和性能优化的胜利。它让浏览器从“负担”变成了“引擎”。

现在,去优化你的列表吧。让你的用户在滚动 100,000 条数据时,依然能感受到丝般顺滑的快感。

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注