性能优化☞虚拟滚动

13 阅读4分钟

前言

今天看到一篇关于虚拟滚动的文章,突然又想起了这个曾经面试时反复背诵的技术点——四个核心要素:容器固定高度、计算可视区域、只渲染可见项、使用垫高元素撑开滚动条。

虽然现在业务中暂时没有涉及大数据量的场景,已经很久没有亲手实现过虚拟滚动了,但回想起来,这项技术依然很重要。如今的组件库已经把这项能力内置了——比如 Ant Design 早在 2022 年发布的 5.x 版本就已经正式支持虚拟滚动,用起来简单多了。

一、核心思路:只渲染“看得见”的

好比在高铁看窗外的风景:

  • 你只需要关心窗外当前看见的树。
  • 你不需要把沿途几千公里的树都搬进车厢。

虚拟滚动也是这个道理:

  1. 容器固定:外层盒子高度固定(比如 500px),开启 overflow: auto
  2. 计算可视区:根据滚动条位置(scrollTop),算出当前应该显示哪几项数据(比如第 100 项到第 110 项)。
  3. 只渲染这几项:DOM 里永远只有这 10 个左右的 <div>,不管总数据有多少。
  4. 占位撑开:为了不让滚动条消失,我们需要用一个巨大的空白 div(垫高元素)把容器撑起来,假装我们有 10 万行数据。

 二、React 实战代码

写一个hooks

export function useVirtualScroll<T>(
  items: T[],
  itemHeight: number, //单项高度
  containerHeight: number, //容器高度
  overscan = 5 //缓冲
) {
  const [scrollTop, setScrollTop] = useState(0);
  const containerRef = useRef<HTMLDivElement>(null);

  const visibleRange = useMemo(() => {
  
  // 当前可见的第一项索引 = 滚动距离 / 单项高度
    const startIndex = Math.floor(scrollTop / itemHeight);
    const endIndex = Math.min(
//当前可见的最后一项索引=起始索引+可视区域的容纳数加上缓冲项(前后多渲染几个防止空白)
      startIndex + Math.ceil(containerHeight / itemHeight) + overscan,
      items.length
    );
    
    return {
      startIndex: Math.max(0, startIndex - overscan),
      endIndex,
      offsetY: startIndex * itemHeight //偏移量,向上抵消一段距离
    };
  }, [scrollTop, itemHeight, containerHeight, items.length, overscan]);

  const visibleItems = useMemo(() => {
//从接口返回的数据里生成当前需要渲染的数据切片
    return items.slice(visibleRange.startIndex, visibleRange.endIndex);
  }, [items, visibleRange.startIndex, visibleRange.endIndex]);
//容器
  const totalHeight = useMemo(() => {
    return items.length * itemHeight;
  }, [items.length, itemHeight]);

  const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
    setScrollTop(event.currentTarget.scrollTop);
  }, []);

  return {
    containerRef,
    visibleItems,
    visibleRange,
    totalHeight,
    handleScroll,
    scrollTop
  };
}

三、关键概念解析

3.1 scrollTop vs offsetY 的区别

这是最容易混淆的概念:

  • scrollTop:用户滚动了多少距离(滚动容器的状态)
  • offsetY:需要向上偏移多少距离(用于占位元素的高度)
// 滚动到第100项时
scrollTop = 5000; // 用户滚动了5000px
startIndex = 100; // 应该显示第100项
offsetY = 5000;   // 用5000px高度的占位元素把前100项"顶"上去

// DOM结构
<div style={{ height: 5000 }} /> // 占位元素,用户看不到
<div>Item 100</div>              // 从这里开始渲染用户能看到的内容
<div>Item 101</div>

3.2 overscan 缓冲区的作用

// 没有缓冲区的问题
// 屏幕能显示10个项目,用户快速滚动
// 滚动过程中可能出现白屏(来不及渲染新项目)

// 有缓冲区的情况
// 实际渲染15个项目(10个可见 + 5个缓冲)
// 用户滚动时,缓冲区的项目立即可见,避免白屏

四、实际使用示例

function VirtualListDemo() {
  // 模拟10000条数据
  const items = useMemo(() => 
    Array.from({ length: 10000 }, (_, i) => ({
      id: i,
      name: `Item ${i + 1}`,
      description: `这是第${i + 1}项的详细描述`
    })), 
    []
  );

  const {
    containerRef,
    visibleItems,
    visibleRange,
    totalHeight,
    handleScroll
  } = useVirtualScroll(items, 60, 400, 5);

  return (
    <div style={{ padding: 20 }}>
      <h2>虚拟滚动示例</h2>
      <div
        ref={containerRef}
        onScroll={handleScroll}
        style={{
          height: 400,
          overflow: 'auto',
          border: '1px solid #ddd',
          borderRadius: 4
        }}
      >
        {/* 顶部占位元素 */}
        <div style={{ height: visibleRange.offsetY }} />
        
        {/* 可见项目 */}
        {visibleItems.map((item, index) => {
          const actualIndex = visibleRange.startIndex + index;
          return (
            <div
              key={actualIndex}
              style={{
                height: 60,
                borderBottom: '1px solid #eee',
                display: 'flex',
                alignItems: 'center',
                paddingLeft: 16,
                backgroundColor: actualIndex % 2 === 0 ? '#f9f9f9' : 'white'
              }}
            >
              <span style={{ fontWeight: 'bold', color: '#1890ff' }}>
                {item.name}
              </span>
              <span style={{ marginLeft: 12, color: '#999', fontSize: '12px' }}>
                {item.description}
              </span>
            </div>
          );
        })}
        
        {/* 底部占位元素 */}
        <div style={{ height: totalHeight - visibleRange.endIndex * 60 }} />
      </div>
      
      <div style={{ marginTop: 10, fontSize: '14px', color: '#666' }}>
        显示范围: {visibleRange.startIndex + 1} - {visibleRange.endIndex} | 
        实际渲染: {visibleItems.length} 项 | 
        总计: {items.length} 项
      </div>
    </div>
  );
}

五、性能对比

普通列表 vs 虚拟滚动

// 普通列表:10000个项目 = 10000个DOM节点
// 内存占用:几百MB,FPS:<10

// 虚拟滚动:10000个项目 = 20个DOM节点(可见项+缓冲)
// 内存占用:几MB,FPS:60

六、适用场景

适合使用虚拟滚动:

  • 长列表(>1000条数据)
  • 表格展示大量数据
  • 日志查看器
  • 聊天记录(历史消息很多)

不适合使用:

  • 数据量小(<100条)
  • 需要SEO的内容
  • 高频交互的列表项
  • 简单的导航菜单

七、常见问题及解决方案

虚拟滚动通常用于在同一视图内浏览海量数据因此在Ant Design组件库中常与 pagination={false} 搭配使用。不过二者并不是不能共同使用的,只要对每页的数据使用虚拟滚动即可。

八、结尾

虽然现在主流组件库已经内置了虚拟滚动能力,AI辅助开发也越来越便捷,我觉得理解其原理仍然很重要。一方面,在面对复杂业务场景时,咱能够灵活调整;另一方面,当需要借助AI IDE,解决问题时,扎实的原理基础能让我们更准确地描述问题从而更快解决嘛。