10 万条数据不卡顿:不定高虚拟列表从原理到生产实现

前言

一个「消息记录」页面,产品说要支持无限滚动,数据量 10 万条。

前端同学很自然地写了:

jsx 复制代码
{list.map(item => <MessageItem key={item.id} data={item} />)}

本地 500 条数据跑得飞快,一上灰度就炸了:首屏白屏 4 秒,滚动帧率掉到 12fps,Chrome 内存涨到 1.2G。

原因不复杂------10 万条数据就是 10 万个 DOM 节点。浏览器要为每个节点做样式计算、布局、绘制,这个成本是线性叠加的。

解法是虚拟列表:只渲染可视区域内的那几十个节点

网上讲定高虚拟列表的文章很多,二十行代码就能跑通。但真实业务里,列表项高度几乎从来不是固定的------一条消息可能一行,也可能带图片带引用占五行。不定高才是虚拟列表真正的难点

这篇文章从定高讲到不定高,把每一处坑都摊开讲清楚,代码可直接使用。

一、先算一笔账:为什么必须虚拟化

先明确虚拟列表到底省了什么。

方案 DOM 节点数 首屏渲染 滚动帧率 内存
全量渲染 10 万条 100000+ 3.8s 10~15fps ~1.2G
分页加载(每页 50) 递增 越滚越卡 递增
虚拟列表 ~30 60ms 60fps 稳定 ~80M

分页加载看似缓解了首屏问题,但它只是把痛苦推迟了------用户滚到第 50 页时,DOM 里依然堆着 2500 个节点。

虚拟列表的核心是让 DOM 数量与数据总量解耦,恒定在可视区能容纳的数量级。

二、定高虚拟列表:建立基本模型

定高场景下一切都可以直接算出来。三个核心公式:

ini 复制代码
startIndex = floor(scrollTop / itemHeight)
visibleCount = ceil(containerHeight / itemHeight)
offsetY = startIndex * itemHeight

结构上需要三层:

  • 容器层 :固定高度 + overflow: auto,负责产生滚动
  • 占位层 :高度 = 总条数 × 行高,负责把滚动条撑到正确长度
  • 内容层 :绝对定位,通过 transform: translateY(offsetY) 顶到正确位置
jsx 复制代码
import { useState, useRef, useMemo } from 'react';

function FixedVirtualList({ data, itemHeight = 60, height = 600, renderItem }) {
  const [scrollTop, setScrollTop] = useState(0);
  const containerRef = useRef(null);

  // 上下各多渲染 3 条,避免快速滚动时露白
  const OVERSCAN = 3;

  const { start, end, offsetY } = useMemo(() => {
    const visibleCount = Math.ceil(height / itemHeight);
    const start = Math.max(0, Math.floor(scrollTop / itemHeight) - OVERSCAN);
    const end = Math.min(data.length, start + visibleCount + OVERSCAN * 2);
    return { start, end, offsetY: start * itemHeight };
  }, [scrollTop, data.length, itemHeight, height]);

  const visibleData = data.slice(start, end);

  return (
    <div
      ref={containerRef}
      style={{ height, overflow: 'auto', position: 'relative' }}
      onScroll={e => setScrollTop(e.currentTarget.scrollTop)}
    >
      {/* 占位层:撑开滚动条 */}
      <div style={{ height: data.length * itemHeight }} />
      {/* 内容层 */}
      <div
        style={{
          position: 'absolute',
          top: 0,
          left: 0,
          right: 0,
          transform: `translateY(${offsetY}px)`,
        }}
      >
        {visibleData.map((item, i) => (
          <div key={item.id} style={{ height: itemHeight }}>
            {renderItem(item, start + i)}
          </div>
        ))}
      </div>
    </div>
  );
}

这里有两个容易被忽略的细节:

1. 为什么用 transform 而不是 top

top 会触发 layout(重排),transform 只触发 composite(合成),可以直接走 GPU。在每帧都要更新位置的滚动场景里,这个差别直接决定了能不能跑满 60fps。

2. key 必须用数据的稳定 id,不能用索引

key={i} 的话,滚动时索引会不断变化,React 的 diff 会认为是同一个节点在更新内容,导致内部 state(比如展开状态、输入框内容)串位。

三、不定高:真实业务的分水岭

定高方案的所有公式都依赖一个前提:任意 index 的位置可以 O(1) 算出来

高度不确定时,这个前提没了。而且我们面对一个鸡生蛋的问题:

要知道一个元素多高,必须先把它渲染出来; 但虚拟列表的意义就是不渲染不可见的元素。

工程上的解法是三步走:先预估 → 渲染后测量 → 缓存并修正

3.1 位置缓存表

维护一张表,记录每一项的 top / height / bottom,初始值全部用预估高度填充:

js 复制代码
class PositionCache {
  constructor(count, estimatedHeight = 80) {
    this.estimated = estimatedHeight;
    this.list = Array.from({ length: count }, (_, i) => ({
      index: i,
      height: estimatedHeight,
      top: i * estimatedHeight,
      bottom: (i + 1) * estimatedHeight,
      measured: false, // 是否已被真实测量过
    }));
  }

  get totalHeight() {
    return this.list.length ? this.list[this.list.length - 1].bottom : 0;
  }

  /** 用真实高度更新某一项,并批量修正后续所有项的偏移 */
  update(index, realHeight) {
    const item = this.list[index];
    const diff = realHeight - item.height;
    if (diff === 0 && item.measured) return;

    item.height = realHeight;
    item.bottom = item.top + realHeight;
    item.measured = true;

    // 后续项整体平移 diff
    for (let i = index + 1; i < this.list.length; i++) {
      this.list[i].top += diff;
      this.list[i].bottom += diff;
    }
  }

  /** 二分查找:给定 scrollTop,找出第一个 bottom >= scrollTop 的项 */
  findIndex(scrollTop) {
    let lo = 0;
    let hi = this.list.length - 1;
    let res = this.list.length - 1;
    while (lo <= hi) {
      const mid = (lo + hi) >> 1;
      if (this.list[mid].bottom >= scrollTop) {
        res = mid;
        hi = mid - 1;
      } else {
        lo = mid + 1;
      }
    }
    return res;
  }
}

因为 listbottom 天然单调递增,所以定位可以用二分,复杂度 O(log n)。10 万条数据只需要 17 次比较。

3.2 关于 update 的性能

上面的 update 里有个 O(n) 的循环。10 万条数据、每次测量都要遍历一遍,看着很吓人。

实际测下来这不是瓶颈,原因有三:

  1. 每项只测量一次 ,测完 measured = true,后续复用缓存
  2. 一次滚动只新增十几项需要测量,不是全量
  3. 纯数字加法的循环,10 万次在现代 V8 上约 0.3ms

如果确实要极致优化(比如 100 万条),可以换成树状数组(Fenwick Tree),把更新和前缀和查询都压到 O(log n):

js 复制代码
class FenwickTree {
  constructor(n) {
    this.n = n;
    this.tree = new Float64Array(n + 1);
  }
  add(i, delta) { // i 从 1 开始
    for (; i <= this.n; i += i & -i) this.tree[i] += delta;
  }
  prefixSum(i) {
    let s = 0;
    for (; i > 0; i -= i & -i) s += this.tree[i];
    return s;
  }
}

但对绝大多数业务,数组 + 二分已经完全够用,不要过早优化。

3.3 用 ResizeObserver 做测量

早期方案是在 useEffect 里遍历 getBoundingClientRect()。问题是:图片加载完、字体加载完、内容异步展开,高度都会变,而这些时机拿不到回调。

ResizeObserver 才是正解------它能监听元素尺寸的任意变化:

jsx 复制代码
import { useEffect, useRef } from 'react';

function MeasuredItem({ index, onResize, children }) {
  const ref = useRef(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const ro = new ResizeObserver(entries => {
      for (const entry of entries) {
        // borderBoxSize 比 contentRect 更准(含 padding + border)
        const box = entry.borderBoxSize?.[0];
        const h = box ? box.blockSize : entry.contentRect.height;
        onResize(index, h);
      }
    });

    ro.observe(el);
    return () => ro.disconnect();
  }, [index, onResize]);

  return <div ref={ref} data-index={index}>{children}</div>;
}

注意 onResize 必须用 useCallback 包裹,否则每次父组件渲染都会重新创建函数,导致 ResizeObserver 反复销毁重建。

3.4 完整实现

把上面几块拼起来:

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

export function VirtualList({
  data,
  height = 600,
  estimatedItemHeight = 80,
  overscan = 3,
  renderItem,
}) {
  const [scrollTop, setScrollTop] = useState(0);
  const [, forceUpdate] = useState(0);

  const cacheRef = useRef(null);
  if (!cacheRef.current || cacheRef.current.list.length !== data.length) {
    cacheRef.current = new PositionCache(data.length, estimatedItemHeight);
  }
  const cache = cacheRef.current;

  const handleResize = useCallback((index, h) => {
    const old = cache.list[index].height;
    if (Math.abs(old - h) < 1) return; // 忽略亚像素抖动
    cache.update(index, h);
    forceUpdate(v => v + 1);
  }, [cache]);

  const { start, end, offsetY, totalHeight } = useMemo(() => {
    const start = Math.max(0, cache.findIndex(scrollTop) - overscan);
    let end = start;
    let acc = 0;
    // 从 start 开始累加高度,直到填满可视区
    while (end < data.length && acc < height) {
      acc += cache.list[end].height;
      end++;
    }
    end = Math.min(data.length, end + overscan);
    return {
      start,
      end,
      offsetY: cache.list[start]?.top ?? 0,
      totalHeight: cache.totalHeight,
    };
  }, [scrollTop, data.length, height, overscan, cache, /* eslint-disable-line */ cache.totalHeight]);

  // 用 rAF 节流滚动事件,避免一帧内多次 setState
  const tickingRef = useRef(false);
  const onScroll = useCallback(e => {
    const st = e.currentTarget.scrollTop;
    if (tickingRef.current) return;
    tickingRef.current = true;
    requestAnimationFrame(() => {
      setScrollTop(st);
      tickingRef.current = false;
    });
  }, []);

  return (
    <div
      style={{ height, overflow: 'auto', position: 'relative', willChange: 'transform' }}
      onScroll={onScroll}
    >
      <div style={{ height: totalHeight }} />
      <div
        style={{
          position: 'absolute',
          top: 0,
          left: 0,
          right: 0,
          transform: `translateY(${offsetY}px)`,
        }}
      >
        {data.slice(start, end).map((item, i) => (
          <MeasuredItem key={item.id} index={start + i} onResize={handleResize}>
            {renderItem(item, start + i)}
          </MeasuredItem>
        ))}
      </div>
    </div>
  );
}

四、五个真实踩过的坑

坑 1:滚动跳动(Scroll Jump)

现象:向上滚动时,列表突然向下窜一段,视觉上像弹了一下。

原因 :上方元素被测量后真实高度大于预估高度,totalHeight 变大,但 scrollTop 没变,导致可视内容整体偏移。

解法 :测量修正后,主动补偿 scrollTop

js 复制代码
const compensate = (container, diff) => {
  if (diff !== 0) {
    container.scrollTop += diff; // diff = 真实高度 - 预估高度
  }
};

更彻底的做法是记录锚点元素:滚动前记下第一个可见项的 index 和它相对容器顶部的偏移,修正后重新定位到这个锚点。

坑 2:iOS 惯性滚动白屏

现象:iOS Safari 上快速甩动,出现大片空白。

原因 :iOS 惯性滚动期间 scroll 事件会被节流甚至暂停派发,JS 来不及更新可视区。

解法

  • 增大 overscan(移动端建议 5~8)
  • 内容层加 will-change: transform 提前提升合成层
  • 避免在滚动回调里做重计算

坑 3:预估高度设得太离谱

预估 80px、实际平均 300px 的话,初始 totalHeight 只有真实值的 1/4,滚动条一开始很长,随着测量不断变短,用户体验非常割裂。

建议:用真实数据统计一个平均值作为预估值。哪怕只是渲染前 20 条取平均,也远好过拍脑袋。

坑 4:图片撑高

图片没有显式尺寸时,加载完成会导致高度突变。

解法 :给图片容器加 aspect-ratio,让高度在图片加载前就确定:

css 复制代码
.msg-image {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}

这条其实对普通页面的 CLS(累积布局偏移)优化也同样有效。

坑 5:数据变化后缓存不失效

列表前面插入了一条数据,后面所有项的 top 都应该平移,但缓存还是老的,于是整个列表错位。

解法 :数据变化时不要粗暴地整表重建(会丢失所有测量结果),而是按稳定 id 做增量映射,只重算受影响的区间。

五、Vue 3 版本的关键差异

思路完全一致,只是 API 不同。Vue 里更自然的写法是把位置缓存做成 shallowRef,并用 computed 派生可视区间:

vue 复制代码
<script setup>
import { ref, shallowRef, computed, onMounted } from 'vue';

const props = defineProps({
  data: { type: Array, required: true },
  height: { type: Number, default: 600 },
  estimated: { type: Number, default: 80 },
});

const scrollTop = ref(0);
const version = ref(0); // 用于测量后触发重算
const cache = shallowRef(new PositionCache(props.data.length, props.estimated));

const range = computed(() => {
  version.value; // 建立依赖
  const c = cache.value;
  const start = Math.max(0, c.findIndex(scrollTop.value) - 3);
  let end = start, acc = 0;
  while (end < props.data.length && acc < props.height) {
    acc += c.list[end].height;
    end++;
  }
  return { start, end: Math.min(props.data.length, end + 3) };
});

const onResize = (index, h) => {
  cache.value.update(index, h);
  version.value++;
};
</script>

关键点:用 shallowRef 存缓存对象,避免 Vue 对上万个元素的数组做深层响应式代理------那个开销比虚拟列表省下来的还多。

六、什么时候不该用虚拟列表

虚拟列表不是免费的,它有明确的代价:

  • Ctrl+F 页内搜索失效(未渲染的内容搜不到)
  • SEO 不友好(爬虫拿不到完整内容)
  • 打印/导出只能拿到可视区
  • 无障碍读屏需要额外处理 aria-setsize / aria-posinset
  • 代码复杂度显著上升

判断标准很简单:

数据量 建议
< 200 条 直接渲染,别折腾
200 ~ 1000 条 先做 content-visibility: auto 试试
> 1000 条 上虚拟列表

顺带一提,content-visibility: auto 是个被低估的方案,一行 CSS 就能让浏览器跳过屏幕外元素的渲染:

css 复制代码
.list-item {
  content-visibility: auto;
  contain-intrinsic-size: auto 80px; /* 预估高度,避免滚动条抖动 */
}

在中等数据量下,它的效果接近虚拟列表,但成本只有一行 CSS。缺点是 DOM 节点依然存在,内存占用没省,且需要 Chrome 85+ / Safari 18+。

七、生产环境优先用成熟库

自己实现一遍对理解原理很有价值,但真上生产,建议直接用轮子:

适用 特点
TanStack Virtual React / Vue / Svelte 框架无关,仅提供 hook,UI 完全自控
react-virtuoso React 不定高开箱即用,支持分组、置顶
vue-virtual-scroller Vue Vue 生态成熟方案

这些库已经处理了滚动锚定、RTL、嵌套滚动、动态数据源等一堆边缘情况,比自己维护划算得多。

总结

回顾整条链路:

  1. 定高是基本模型,三个公式 + 三层结构,二十行搞定
  2. 不定高的核心是「预估 → 测量 → 缓存修正」三步走
  3. 定位用二分查找 ,测量用 ResizeObserver
  4. 滚动跳动靠锚点补偿 ,移动端靠增大 overscan
  5. 数据量不大时,content-visibility: auto 可能是更划算的选择
  6. 生产环境优先用成熟库,自己实现主要用于理解原理

性能优化的本质从来不是把代码写得更炫,而是让浏览器少干活。虚拟列表只是这个思路在长列表场景下的一个具体应用。

如果你在项目里遇到过别的虚拟列表坑,欢迎在评论区交流。

相关推荐
windliang1 小时前
Claude Code 源码分析(六):上下文的发现、注入与压缩
前端·javascript·人工智能
玉鸯1 小时前
界面用完即消失:Agent 生成式 UI 的短暂性哲学与前端工程的未来
前端·llm·agent
妙码生花2 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(五十四):管理员个人资料页面、管理员日志优化
前端·后端·go
陆枫Larry2 小时前
并发、并行、竞态的区别梳理
前端
水煮白菜王2 小时前
商用地图全面收费?从天地图到开源生态的替代路线
前端·javascript·高德地图·amap·开源地图
程序员黑豆2 小时前
鸿蒙应用开发:网络请求三种方式详解(http / rcp / axios)
前端·harmonyos
黄敬峰2 小时前
前端路由到底是个啥?React Router v7 从零到一,大白话一次讲透
前端·面试
先吃饱再说2 小时前
从多页到单页:前端路由的演进与 React Router
前端·react.js·前端框架