长列表滚动卡顿怎么破:虚拟列表选型 + react-virtuoso 实战调优指南

长列表卡顿定位 + 虚拟列表 + react-virtuoso 实战 + 调优。


开头:当一个 1000 行的列表卡到 30 fps 以下

长列表性能问题在 H5 项目里极其常见------电商列表、订单流、消息流、商品搜索结果页,每多一类列表就多一个潜在的卡点。而这种卡顿不一定来自网络、不一定来自接口慢、不一定来自图加载------很多时候就是 DOM 节点太多、渲染链路太重。

这一类问题的棘手之处在于:

  • 用户感觉卡,但 Lighthouse 分数「看上去不差」
  • 网络请求已经优化完,CPU 仍然被打满
  • 「加个 setTimeout 分批渲染吧」试过,效果有了但也不是根治

虚拟列表(Virtual List / Windowing)是这类问题的标准答案------但「装上就完事」是错觉。这篇文章不谈虚拟列表的「是什么」(文档写得很清楚),只谈三件事:在真实项目里 react-virtuoso 为什么比 react-window 更顺手、4 类典型场景下 react-virtuoso 的工程化用法、几个常见但容易踩的坑。


01 长列表为什么会卡:4 个真正常被忽视的原因

很多人以为「列表卡 = DOM 节点多 = 上虚拟列表就好」。实际上虚拟列表只能解决其中一类问题。要让列表真流畅,得先弄清「卡在哪」。

1.1 DOM 节点数导致的渲染瓶颈

浏览器的渲染流程大致是:解析 HTML → 构建 DOM 树 → 计算样式 → 构建渲染树 → 布局(Layout)→ 绘制(Paint)→ 合成(Composite)。一屏内的 DOM 节点越多,每一帧的计算量越大:

  • DOM 节点 < 1000:通常不会感知到卡顿
  • DOM 节点 1000-3000:滚动开始有可感知延迟
  • DOM 节点 > 5000:在中低端机上明显卡顿,掉帧严重

1000 行表格里每一行有 20+ 节点,就意味着 20000+ 真实 DOM 节点------这是肉眼能感知的卡顿来源。

1.2 重渲染成本

列表场景里有一个常见的「隐性重渲染」:

jsx 复制代码
// 父组件状态更新
const [keyword, setKeyword] = useState('')
return <List keyword={keyword} />  // 即使 List 没传 keyword,内部所有项都重渲染

如果列表组件没有合理的 memo / shouldComponentUpdate / React.memo 优化,每次父组件状态变化都让所有列表项重渲染。1000 行 × 每次重渲染 = 1000 次 render 调用 = 卡顿。

1.3 滚动事件的高频触发

现代浏览器的滚动事件触发频率非常高------一秒钟可以触发数十次。每次触发都执行一次回调(包括计算位置、设置 transform 等)时,如果回调逻辑重,就直接拖慢主线程。

优化方式:用 requestAnimationFrame 节流、把 scroll 事件换成 IntersectionObserver 或 passive 监听。

1.4 图片 / 资源加载阻塞渲染

列表里每一项通常配有图片。如果图片加载时机不对(一次性全部加载),首屏渲染时所有图片都参与绘制,主线程压力大。常用优化:

  • IntersectionObserver 懒加载
  • 服务端图片压缩(WebP / AVIF)
  • 设置 loading="lazy"
  • 图片占位(避免布局位移)

虚拟列表解决的是 1.1,其他三类问题需要单独处理。

1.5 一份具体的「卡」清单

遇到列表卡顿案例时,可以按以下顺序逐项检查:

  1. DOM 节点数:DevTools Elements 面板查「Nodes」总数。如果超过 5000 节点,场景依然照旧,优先考虑虚拟列表。
  2. 重渲染次数 :DevTools Profiler 面板录一次滚动,看列表项 Component 是否被不必要重渲染,必要时接 React.memo
  3. 长任务:DevTools Performance 看是否有超过 50ms 的长任务,长任务多=主线程被其他逻辑占用,必需拆分。
  4. 滚动事件:看 scroll 事件的回调耗时,>5ms 就需要优化。

02 虚拟列表核心原理:可视区计算 + 占位高度

虚拟列表的核心思路非常简单:只渲染可视区内的 DOM,可视区外的 DOM 用占位元素「撑出」高度

可视区的计算公式:

javascript 复制代码
可视区高度 = window.innerHeight(视口高度)
可视区起始索引 = Math.floor(滚动距离 / 每项高度)
可视区结束索引 = Math.ceil((滚动距离 + 可视区高度) / 每项高度)
实际渲染的列表项 = 可视区起始索引 到 可视区结束索引 + 缓冲区

DOM 结构示意:

html 复制代码
<div class="virtual-list" style="height: 1000000px">  <!-- 总高度撑出滚动条 -->
  <div style="transform: translateY(50000px)"> <!-- 模拟偏移 -->
    <Item />  <!-- 实际渲染的项 1 -->
    <Item />  <!-- 实际渲染的项 2 -->
    ...
  </div>
</div>

关键点是用 transform 偏移而不是修改 DOM 顺序------这样浏览器无需重排只需重绘,性能更好。

可视区外的项虽然不渲染 DOM,但需要在内存里维护数据结构(数组 + 索引 + 高度),在滚动时根据索引取出对应数据。


03 主流方案对比:react-virtuoso / react-window / react-virtualized / 自研

虚拟列表不是一个新概念,社区里有大量实现方案。这一节给出基于真实选型经验的对比。

3.1 选型维度

实际选型时看这 4 个维度:

  • 是否支持动态高度:列表项高度不一(如消息流有图片、有纯文本)时的支持度
  • 是否支持表格:表格列固定、行选中、列排序等
  • 维护状态与社区活跃度:GitHub Star、最近一次发布、Issue 响应速度
  • 学习成本与扩展性:API 设计的直觉度、是否容易按需扩展

3.2 真实对比表

维度 react-virtuoso react-window react-virtualized 自研
维护状态 活跃 活跃 维护但放缓 取决于团队
动态高度 内置支持 需手动算 需手动算 完全可控
表格组件 TableVirtuoso 无(需 DIY) Grid(自实现) 完全可控
API 直觉度 高(声明式) 中(命令式)
包体积 ~30KB ~7KB ~25KB 0
学习曲线 低-中
反向滚动 内置 需手动实现 需手动实现 完全可控
分组列表 GroupedVirtuoso 需手动实现 需手动实现 完全可控

中小团队起步时通常直接选 react-virtuoso,理由:动态高度、表格、反向滚动等场景覆盖度最高,省去大量自研成本。react-window 适合「列表项高度固定、追求极致包体积」的极端场景。


04 react-virtuoso 实战:4 类典型场景的工程化用法

4.1 基础用法:固定项高 + 简单分页

最基础的场景:每项固定高度(如商品卡片),加载更多分页数据:

jsx 复制代码
import { Virtuoso } from 'react-virtuoso'

function ItemList({ items, loadMore, hasMore }) {
  return (
    <Virtuoso
      style={{ height: '100vh' }}
      data={items}
      endReached={loadMore}  // 触底加载
      itemContent={(index, item) => <Item data={item} />}
      components={{
        Footer: () => hasMore ? <Loading /> : <End />
      }}
    />
  )
}

itemContent 是必传 prop,接收索引和项,返回要渲染的内容。

4.2 动态高度:聊天消息流

聊天消息场景里,每条消息高度不一(一行文本、三行带图片、超长文本)。react-virtuoso 通过自动测量高度处理这类场景:

jsx 复制代码
import { Virtuoso } from 'react-virtuoso'

function MessageList({ messages, scrollToBottom }) {
  return (
    <Virtuoso
      style={{ height: '100vh' }}
      data={messages}
      followOutput="smooth"     // 新消息自动滚到底
      initialTopMostItemIndex={messages.length - 1}
      itemContent={(index, msg) => <Message data={msg} />}
    />
  )
}

followOutput="smooth" 启用「跟随最新消息」------新消息到达时自动平滑滚动到底部。

动态高度的工作机制是:组件渲染时用占位高度(如 50px),实际高度被 ResizeObserver 测量后回写到虚拟列表的尺寸缓存。这里面有一个常见的坑------见 6.2 段。

4.3 表格:TableVirtuoso

表格场景远比普通列表复杂:列固定、行选中、列排序、单元格编辑。TableVirtuoso 是 react-virtuoso 提供的封装:

jsx 复制代码
import { TableVirtuoso } from 'react-virtuoso'

function OrderTable({ rows, columns }) {
  return (
    <TableVirtuoso
      style={{ height: '80vh' }}
      data={rows}
      components={{
        Table: ({ children, ...props }) => (
          <table {...props}><tbody>{children}</tbody></table>
        ),
        TableRow: ({ children, item, ...props }) => (
          <tr {...props} onClick={() => selectRow(item.id)}>{children}</tr>
        )
      }}
      fixedHeaderContent={() => 
        columns.map(col => <th key={col.key}>{col.label}</th>)
      }
      itemContent={(index, row) => 
        columns.map(col => <td key={col.key}>{row[col.key]}</td>)
      }
    />
  )
}

fixedHeaderContent 让表头固定不动,列表滚动只有 tbody。

4.4 Grid 网格:VirtuosoGrid

商品网格、卡片墙、图片瀑布流这类二维布局:

jsx 复制代码
import { VirtuosoGrid } from 'react-virtuoso'

function CardGrid({ cards }) {
  return (
    <VirtuosoGrid
      style={{ height: '100vh' }}
      data={cards}
      itemContent={(index, card) => <Card data={card} />}
      listClassName="grid-container"  // 自定义网格样式
    />
  )
}

样式上配合 CSS Grid:

css 复制代码
.grid-container {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
  gap: 12px;
  padding: 12px;
}

05 真实案例:长列表性能调优全过程

5.1 项目起点

Hybrid H5 项目里有一个饰品列表页:展示 1000+ 行 CS2 饰品数据,每行包含图片、价格、按钮、悬浮提示。

迁移前实测:

  • 首屏加载:DOM 节点数 > 20000
  • 滚动 fps:30 fps 以下(部分机型掉到 20 fps)
  • 卡顿体感:滑动有明显拖影,触摸响应延迟 100ms+

问题定位:

  • 列表项 DOM 节点数过多(每行约 20+ 节点)
  • 滚动事件处理过重(每帧计算 + 比较 + 重渲染)
  • 图片一次性加载(首屏前 1000 张图全部请求)

5.2 选型对比

候选方案:react-virtuoso / react-window / 自研。

维度 react-virtuoso react-window 自研
动态高度支持 内置 ✅ 需手动 完全可控
表格支持 内置 ✅ 完全可控
维护活跃度 活跃 活跃 取决于团队
上手成本
团队 React 经验 高 ✅

最终选 react-virtuoso,核心原因:维护活跃 + 动态高度覆盖度足够 + API 直观。

5.3 落地步骤与结果

第一步:列表项 memo 化

jsx 复制代码
const Item = React.memo(function Item({ data }) {
  return (
    <div className="item">
      <img src={data.image} loading="lazy" alt={data.name} />
      <div className="price">${data.price}</div>
      <div className="name">{data.name}</div>
    </div>
  )
})

避免列表项不必要重渲染。

第二步:图片懒加载

IntersectionObserver 监听列表项进入可视区时再设置图片 src:

jsx 复制代码
const LazyImage = ({ src, alt }) => {
  const ref = useRef()
  const [loaded, setLoaded] = useState(false)
  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        setLoaded(true)
        observer.disconnect()
      }
    })
    if (ref.current) observer.observe(ref.current)
    return () => observer.disconnect()
  }, [])
  return <img ref={ref} src={loaded ? src : placeholder} alt={alt} />
}

第三步:接入 react-virtuoso

用 react-virtuoso 替换原渲染逻辑,可视区只渲染约 10-20 个列表项,DOM 节点数从 20000+ 降到 ~500。

第四步:滚动事件节流

react-virtuoso 内部用 requestAnimationFrame 节流 scroll 事件,外部使用即可。

5.4 落地效果

  • DOM 节点数:20000+ 降到 ~500(滚动可视区稳定)
  • 滚动帧率:30 fps 以下 → 55+ fps 稳定
  • 触摸响应:延迟 100ms+ 降至体感无感
  • 内存占用:长列表内存占用显著降低(虚拟化后只持有可视区引用)

06 反向清单:虚拟列表实战的 7 个常见误区

虚拟列表不是「装上就好」,下面是真实项目里反复踩到的坑:

6.1 别只装虚拟列表不调参

很多人装上 react-virtuoso 后没调 overscan(缓冲区大小)、increaseViewportBy(可视区扩大量)等关键参数。默认参数在某些场景下表现不佳------比如快速滚动到列表末端时可能短暂「空白」。

合理调参:overscan={{ main: 200, reverse: 200 }} 给上下各加 200px 缓冲区,减少快速滚动的视觉断层。

6.2 别忽视动态高度测量延迟

动态高度项的实际尺寸被 ResizeObserver 异步测量。第一次渲染时用的是占位高度,测量完成后会触发重排------如果列表里的内容频繁变化,会出现「高度抖动」。

解决方案:

  • 尽量让列表项高度稳定(图片用 aspect-ratio、文本固定行数截断)
  • 异步加载的内容(如图片加载完展开详情)尽量不放在高度测量敏感的场景

6.3 别在移动端硬塞 WebView 滚动

移动端 WebView 上的滚动事件比 PC 浏览器更敏感。如果 react-virtuoso 还叠了 iOS 的 -webkit-overflow-scrolling: touch、Android 的 scroll bounce 等兼容代码,容易触发莫名其妙的滚动穿透。

6.4 别用 CSS 动画代替 transform

虚拟列表的偏移用 transform: translateY() 实现,触发 GPU 加速。不要用 top / left 改位移------这两个属性会触发 Layout + Paint,掉帧明显。

6.5 别忽视首屏数据预取

虚拟列表只能减少 DOM 节点数,但数据本身还是要从后端拉。首屏 10 屏数据要预取到位,否则用户滚到第 5 屏开始「空白」。

6.6 别为 SEO 场景用虚拟列表

搜索引擎爬虫通常不执行滚动事件,看不到「可视区外」的内容。如果列表页对 SEO 依赖大(如商品分类页),虚拟列表反而是劣势------可以让爬虫看不到页面下半部分。

6.7 别忘了列表项的 key

react-virtuoso 内部用 key 做项复用优化。不要用 index 做 key------列表项顺序变化时会触发大量重渲染。用稳定唯一 ID(如商品 id)。


07 配套优化:分片渲染 + 懒加载 + 防抖

虚拟列表解决 DOM 节点问题,但还有 3 类配套优化要做:

7.1 分片渲染(Time Slicing)

首屏渲染巨大列表(一次性渲染 1000 项)会让主线程阻塞很久。分片渲染把渲染任务拆成多个小段,每段执行 5ms 后让出主线程:

jsx 复制代码
import { unstable_batchedUpdates } from 'react-dom'

async function renderInChunks(items, chunkSize = 50) {
  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize)
    unstable_batchedUpdates(() => {
      setRenderedItems([...renderedItems, ...chunk])
    })
    await new Promise(r => setTimeout(r, 0))  // 让出主线程
  }
}

实际项目中通常搭配 react-virtualunstable_invokeGuardedCallback 使用,不用自己写。

7.2 IntersectionObserver 懒加载

列表里的图片 / 视频 / iframe 用 IntersectionObserver 懒加载,进入可视区才加载:

jsx 复制代码
function LazyLoad({ children, rootMargin = '200px' }) {
  const ref = useRef()
  const [visible, setVisible] = useState(false)
  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => entry.isIntersecting && setVisible(true),
      { rootMargin }
    )
    if (ref.current) observer.observe(ref.current)
    return () => observer.disconnect()
  }, [rootMargin])
  return <div ref={ref}>{visible ? children : <Placeholder />}</div>
}

7.3 滚动事件防抖

如果业务需要监听滚动做特定动作(如下拉刷新、滚到底加载更多),用 requestAnimationFrame 节流:

jsx 复制代码
function useScrollHandler(handler) {
  useEffect(() => {
    let ticking = false
    const onScroll = () => {
      if (!ticking) {
        requestAnimationFrame(() => {
          handler()
          ticking = false
        })
        ticking = true
      }
    }
    window.addEventListener('scroll', onScroll, { passive: true })
    return () => window.removeEventListener('scroll', onScroll)
  }, [handler])
}

passive: true 告诉浏览器「这个 listener 不会 preventDefault」,避免滚动卡顿。


08 工程化建议:把长列表性能纳入团队常规

8.1 性能基线评估

接入虚拟列表前先建立性能基线:

  • DOM 节点数(DevTools Performance 录制)
  • 滚动 fps(Chrome DevTools fps meter / 真实设备测试)
  • 触摸响应延迟(自定义测试脚本)
  • 内存占用(Chrome DevTools Memory Profile)

接入虚拟列表后做对比,量化收益。

8.2 列表组件统一封装

团队应该有统一的「列表组件」封装,把虚拟列表、懒加载、分页、空状态、错误状态都封装好:

jsx 复制代码
<StandardList
  data={items}
  loading={isLoading}
  error={error}
  hasMore={hasMore}
  onLoadMore={loadMore}
  renderItem={(item) => <Item data={item} />}
  emptyState={<EmptyPlaceholder />}
  errorState={<ErrorRetry onRetry={reload} />}
/>

避免每个业务线自己实现一套。

8.3 性能监控

把长列表性能接入 APM 监控:

  • 列表首屏渲染耗时(PerformanceObserver PerformanceEntry first-paint / first-contentful-paint)
  • 滚动 fps(P75 数据上报)
  • 长任务监控(PerformanceObserver longtask)

性能数据接入 RUM(Real User Monitoring)才能反映真实用户体验。


结语

长列表性能调优的本质,是「把渲染成本从用户设备的全量降低到可视区」。虚拟列表是这类问题的标准答案,但「装上就完事」的错觉让很多项目只做了第一步就以为搞定。

实际工程化里要回答的问题更复杂:这个项目适不适合虚拟列表?动态高度怎么处理?数据预取怎么安排?配套的懒加载、分片渲染、滚动防抖都跟上没?

如果项目正在面对「列表卡但不知道卡哪」的问题,第一步不是立刻装 react-virtuoso------而是先打开 DevTools 看 fps meter,看真实掉帧位置再决定方案。先定位,再选型,再调优------这是性能优化的标准顺序。

不建议在「虚拟列表 vs 自研」上无限纠结------框架只是工具,性能基线达到比方案本身更重要。选一个团队吃得下、维护成本扛得住的方案,比选「业界最热门」的方案更重要。


09 哪些场景不一定需要虚拟列表

虚拟列表不是「万金油」,下面三类场景通常不需要:

场景一:列表长度始终 < 200

如果列表最长也只有一两百项,而且不会快速增长,虚拟列表反而是「过度优化」------可视区外 DOM 不会很多,记忆力也会正常。

实际上对这类场景,合理的代码 memo + 懒加载 + 图片优化已经足够。

场景二:列表项高度难以预测且变化剧烈

如果列表项的高度随内容动态变化(如未填充文本、占位用 "加载中"状态会伸缩),动态高度的测量会频繁重排,在某些场景下反而比「全部渲染」更糟糕。

这类场景的常见处理是「后端按固定高度渲染」,或者「业务上限定列表项高度」。

场景三:SEO 依赖重的列表页

搜索引擎爬虫不执行滚动事件。虚拟列表场景下,爬虫只能看到初始可视区内的列表项,其他项「不存在于 DOM」。

如果页面对 SEO 依赖高(如商品分类、商家列表等索引类页面),传统 SSR + 全部渲染可能更合适,或者使用首屏 SSR + 下拉增量加载的混合方案。


10 调试与验证工具推荐

长列表性能调优离不开调试工具。以下是项目里常用的几个:

  • Chrome DevTools Performance 面板:录制滚动过程,查看 fps、长任务、函数调用耗时
  • Chrome DevTools fps Meter:仅看实时帧率,快速找到「卡」的具体时间点
  • Chrome DevTools Memory:查看 DOM 节点数、JS 堆内存、是否内存泄漏
  • why-did-you-render:帮助发现不必要的 React 组件重渲染
  • react-virtuoso 的 Debug 组件:开启后会在控制台打印项测量、缓冲区使用情况

以上工具的组合可以覆盖绝大多数长列表调优场景。

相关推荐
binbin_524 小时前
React 井字棋教程:用一个小游戏理解组件和状态
前端·react.js·前端框架
zhangjw344 小时前
第29篇:Java伪共享与对象分配:并发性能优化的关键
java·开发语言·性能优化
张元清5 小时前
React useObjectUrl Hook:预览文件与 Blob,不留内存泄漏(2026)
javascript·react.js
真的想不出名儿8 小时前
Md编辑器整合-react
前端·react.js·编辑器
SelectDB1 天前
美团数十 PB 规模 Apache Doris 实践:从统一 OLAP 到 AI-Native 数据基座
大数据·数据库·性能优化
牛仔只喝牛奶1 天前
多端跨端技术选型:小程序 + H5 + App 的两栈拆分落地
react native·react.js
Patrick_Wilson1 天前
最佳实践是有保质期的:从一次 CDN external 白屏事故说起
前端·性能优化·前端工程化
侑虎科技1 天前
解决URP角色渲染短板,自研IllusionRP管线落地移动端
性能优化
wear工程师1 天前
setState 到底是不是异步的?这题从 React 16 问到 18,标准答案早就变了
react.js·面试