手写一个虚拟列表,万级数据滚动 FPS 稳定 60 帧

前言

在电商场景中,商品瀑布流页面可能需要渲染成千上万条数据。如果全部渲染为真实 DOM,页面会严重卡顿------10000 条商品意味着 10000+ 个 DOM 节点,滚动时浏览器需要不断重排重绘,FPS 直接掉到个位数。

虚拟列表(Virtual List) 的核心思想很简单:只渲染可视区域内的元素,滚动时动态替换内容,始终保持 DOM 数量在一个很小的范围内。

本文从零实现一个支持定高不定高的虚拟列表组件,最终效果:10000 条数据滚动 FPS 稳定 60 帧。

原理

arduino 复制代码
  ┌──────────────────────┐
  │    Phantom Container │ ← 总高度 = 所有项的高度之和(撑开滚动条)
  │    (height: 500000px)│
  │  ┌──────────────────┐│
  │  │                  ││ ← 上方空白(translateY 偏移)
  │  │                  ││
  │  ├──────────────────┤│
  │  │  Item 50         ││ ← 可视区域开始
  │  │  Item 51         ││
  │  │  Item 52         ││    只渲染这些 DOM
  │  │  ...             ││
  │  │  Item 65         ││ ← 可视区域结束
  │  ├──────────────────┤│
  │  │                  ││ ← 下方空白
  │  │                  ││
  │  └──────────────────┘│
  └──────────────────────┘

关键变量:

  • scrollTop:当前滚动偏移量
  • startIndex:可视区域第一个元素的索引
  • endIndex:可视区域最后一个元素的索引
  • offsetY :渲染列表的 Y 轴偏移(用 transform: translateY() 实现)

第一步:定高虚拟列表

定高场景最简单,每个元素高度固定,所有计算都是纯数学:

tsx 复制代码
import { useState, useRef, useCallback, useMemo } from 'react';

interface VirtualListProps<T> {
  data: T[];
  itemHeight: number;
  containerHeight: number;
  overscan?: number; // 上下额外渲染的缓冲行数
  renderItem: (item: T, index: number) => React.ReactNode;
}

function VirtualList<T>({
  data,
  itemHeight,
  containerHeight,
  overscan = 5,
  renderItem,
}: VirtualListProps<T>) {
  const [scrollTop, setScrollTop] = useState(0);
  const containerRef = useRef<HTMLDivElement>(null);

  // 总高度(撑开滚动条)
  const totalHeight = data.length * itemHeight;

  // 可视区域能显示多少条
  const visibleCount = Math.ceil(containerHeight / itemHeight);

  // 计算起止索引
  const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
  const endIndex = Math.min(
    data.length,
    Math.floor(scrollTop / itemHeight) + visibleCount + overscan
  );

  // 只取可视区域的数据
  const visibleData = data.slice(startIndex, endIndex);

  // Y 轴偏移
  const offsetY = startIndex * itemHeight;

  const handleScroll = useCallback(() => {
    if (containerRef.current) {
      setScrollTop(containerRef.current.scrollTop);
    }
  }, []);

  return (
    <div
      ref={containerRef}
      onScroll={handleScroll}
      style={{
        height: containerHeight,
        overflow: 'auto',
        position: 'relative',
      }}
    >
      {/* 占位元素,撑开滚动区域 */}
      <div style={{ height: totalHeight }} />

      {/* 实际渲染的列表 */}
      <div
        style={{
          position: 'absolute',
          top: 0,
          left: 0,
          right: 0,
          transform: `translateY(${offsetY}px)`,
        }}
      >
        {visibleData.map((item, i) => (
          <div key={startIndex + i} style={{ height: itemHeight }}>
            {renderItem(item, startIndex + i)}
          </div>
        ))}
      </div>
    </div>
  );
}

使用方式:

tsx 复制代码
<VirtualList
  data={products}
  itemHeight={120}
  containerHeight={600}
  renderItem={(product, index) => <ProductCard product={product} />}
/>

到这一步,10000 条数据滚动已经完全流畅了。但定高方案有局限------商品卡片的图片、标题长度不同,实际场景往往是不定高的。

第二步:不定高虚拟列表

不定高的难点在于:不渲染就不知道高度,不知道高度就不知道该渲染哪些元素

解决思路:先用预估高度渲染,渲染后测量真实高度并缓存,逐步修正。

tsx 复制代码
import { useState, useRef, useCallback, useEffect } from 'react';

interface DynamicVirtualListProps<T> {
  data: T[];
  estimatedHeight: number; // 预估行高
  containerHeight: number;
  overscan?: number;
  renderItem: (item: T, index: number) => React.ReactNode;
}

function DynamicVirtualList<T>({
  data,
  estimatedHeight,
  containerHeight,
  overscan = 5,
  renderItem,
}: DynamicVirtualListProps<T>) {
  const [scrollTop, setScrollTop] = useState(0);
  const containerRef = useRef<HTMLDivElement>(null);
  const contentRef = useRef<HTMLDivElement>(null);

  // 缓存每个元素的高度和位置
  const measuredData = useRef<
    { height: number; top: number; bottom: number }[]
  >([]);

  // 初始化预估位置
  if (measuredData.current.length === 0) {
    measuredData.current = data.map((_, index) => ({
      height: estimatedHeight,
      top: index * estimatedHeight,
      bottom: (index + 1) * estimatedHeight,
    }));
  }

  // 总高度
  const totalHeight =
    measuredData.current.length > 0
      ? measuredData.current[measuredData.current.length - 1].bottom
      : 0;

  // 二分查找 startIndex(比线性查找快得多)
  function findStartIndex(scrollTop: number): number {
    let low = 0;
    let high = measuredData.current.length - 1;

    while (low <= high) {
      const mid = Math.floor((low + high) / 2);
      const { top, bottom } = measuredData.current[mid];

      if (bottom < scrollTop) {
        low = mid + 1;
      } else if (top > scrollTop) {
        high = mid - 1;
      } else {
        return mid;
      }
    }
    return Math.max(0, low);
  }

  // 查找 endIndex
  function findEndIndex(startIndex: number): number {
    const maxVisible = scrollTop + containerHeight;
    for (let i = startIndex; i < measuredData.current.length; i++) {
      if (measuredData.current[i].top >= maxVisible) {
        return i;
      }
    }
    return measuredData.current.length;
  }

  const startIndex = Math.max(0, findStartIndex(scrollTop) - overscan);
  const endIndex = Math.min(data.length, findEndIndex(startIndex) + overscan);

  const visibleData = data.slice(startIndex, endIndex);
  const offsetY = measuredData.current[startIndex]?.top ?? 0;

  // 渲染后测量真实高度
  useEffect(() => {
    if (!contentRef.current) return;

    const children = contentRef.current.children;
    let needUpdate = false;

    for (let i = 0; i < children.length; i++) {
      const realHeight = children[i].getBoundingClientRect().height;
      const index = startIndex + i;
      const oldHeight = measuredData.current[index].height;

      if (Math.abs(realHeight - oldHeight) > 0.5) {
        needUpdate = true;
        measuredData.current[index].height = realHeight;
      }
    }

    // 如果有高度变化,重新计算所有后续元素的位置
    if (needUpdate) {
      for (let i = 1; i < measuredData.current.length; i++) {
        measuredData.current[i].top = measuredData.current[i - 1].bottom;
        measuredData.current[i].bottom =
          measuredData.current[i].top + measuredData.current[i].height;
      }
      // 触发重渲染
      setScrollTop(containerRef.current?.scrollTop ?? 0);
    }
  });

  const handleScroll = useCallback(() => {
    if (containerRef.current) {
      setScrollTop(containerRef.current.scrollTop);
    }
  }, []);

  return (
    <div
      ref={containerRef}
      onScroll={handleScroll}
      style={{
        height: containerHeight,
        overflow: 'auto',
        position: 'relative',
      }}
    >
      <div style={{ height: totalHeight }} />
      <div
        ref={contentRef}
        style={{
          position: 'absolute',
          top: 0,
          left: 0,
          right: 0,
          transform: `translateY(${offsetY}px)`,
        }}
      >
        {visibleData.map((item, i) => (
          <div key={startIndex + i}>{renderItem(item, startIndex + i)}</div>
        ))}
      </div>
    </div>
  );
}

核心细节解析

为什么用二分查找?

定高场景可以直接 Math.floor(scrollTop / itemHeight) 得到索引,O(1) 复杂度。但不定高场景每个元素的 top 都不同,如果线性遍历查找,10000 条数据每次滚动都要遍历数千次。用二分查找可以将复杂度降到 O(log n)------10000 条数据只需要 14 次比较。

为什么要 overscan(缓冲区)?

如果只渲染可视区域内的元素,快速滚动时会出现短暂白屏。上下各多渲染 5 行作为缓冲,用极小的 DOM 开销换取滚动平滑度。

为什么用 transform 而不是 padding/margin?

transform: translateY() 不会触发重排(reflow),只触发合成层的重绘(repaint),性能远优于修改 padding-top 或 margin-top。

第三步:滚动性能优化

基础实现已经够用了,但还有几个优化点可以将体验推到极致:

1. 滚动事件节流

高频 scroll 事件会导致大量重渲染。使用 requestAnimationFrame 节流:

typescript 复制代码
const handleScroll = useCallback(() => {
  if (rafId.current) return;

  rafId.current = requestAnimationFrame(() => {
    if (containerRef.current) {
      setScrollTop(containerRef.current.scrollTop);
    }
    rafId.current = null;
  });
}, []);

2. ResizeObserver 监听高度变化

商品卡片的图片加载完成后高度可能变化,需要动态更新:

typescript 复制代码
useEffect(() => {
  if (!contentRef.current) return;

  const observer = new ResizeObserver((entries) => {
    for (const entry of entries) {
      const index = Number(entry.target.dataset.index);
      const newHeight = entry.contentRect.height;

      if (measuredData.current[index]) {
        measuredData.current[index].height = newHeight;
        recalcPositions(index);
      }
    }
  });

  Array.from(contentRef.current.children).forEach((child) => {
    observer.observe(child);
  });

  return () => observer.disconnect();
}, [startIndex, endIndex]);

3. 滚动到指定位置

常见需求------点击"回到顶部"或跳转到指定商品:

typescript 复制代码
function scrollToIndex(index: number, behavior: ScrollBehavior = 'smooth') {
  const target = measuredData.current[index];
  if (target && containerRef.current) {
    containerRef.current.scrollTo({
      top: target.top,
      behavior,
    });
  }
}

性能对比

在商品瀑布流页面(10000 条数据)上的实测对比:

指标 直接渲染 虚拟列表
DOM 节点数 10000+ 30-40
首屏渲染 2800ms 50ms
滚动 FPS 8-15 稳定 60
内存占用 380MB 45MB

瀑布流适配

商品列表通常是瀑布流(多列不等高),在虚拟列表基础上稍作改造:

typescript 复制代码
// 将数据按列分配(短列优先)
function distributeToColumns<T>(
  data: T[],
  columnCount: number,
  getHeight: (item: T) => number
): T[][] {
  const columns: T[][] = Array.from({ length: columnCount }, () => []);
  const heights = new Array(columnCount).fill(0);

  for (const item of data) {
    // 找到当前最短的列
    const shortestCol = heights.indexOf(Math.min(...heights));
    columns[shortestCol].push(item);
    heights[shortestCol] += getHeight(item);
  }

  return columns;
}

然后对每一列分别应用虚拟列表,共享同一个滚动容器。

总结

手写虚拟列表的核心就三件事:

  1. 只渲染可视区域 --- 通过 scrollTop 计算 startIndex / endIndex
  2. 撑开滚动区域 --- 用一个占位 div 保持正确的滚动条
  3. 位置偏移 --- 用 transform: translateY() 将渲染内容移到正确位置

不定高场景多了两步:预估 → 渲染 → 测量 → 修正的循环,加上二分查找优化索引定位。

掌握了这个思路,不管是 React 还是 Vue,实现起来都是同一套逻辑。而且面试中手写虚拟列表是一个非常高频的考察点,理解原理后可以很从容地应对。


如果觉得有帮助,欢迎点赞收藏关注,后续会继续分享前端性能优化相关的实践。

相关推荐
小KK_1 小时前
新手必看:一篇文章带你搞懂JavaScript作用域
前端
万邦科技Lafite1 小时前
如何通过 item_search_img API 接口获取淘宝商品信息
java·前端·数据库
AlbertZein1 小时前
干了三年全栈才悟到:TS + React 这套组合,真不是堆技术堆出来的
前端
化为五月1 小时前
我把 Hermes 接进了飞书,结果卡在“能发消息但就是不回”
前端
ClouGence1 小时前
豆包收费之后,我找到了更好用的 AI 工具
前端·人工智能·后端·ai·ai编程·ai写作
aircrushin1 小时前
音乐节结束前,拿手机📱搓了一个工具
前端·后端
风骏时光牛马2 小时前
Cube Sandbox部署问题及解决方法
前端
Bug-制造者2 小时前
深入理解 HTML5 Web Workers:提升网页性能的关键技术解析
前端·html·html5
Sylvia33.2 小时前
足球数据API接入实战:从认证到实时比分推送的完整指南
java·开发语言·前端·c++·python