长列表卡顿定位 + 虚拟列表 + 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 一份具体的「卡」清单
遇到列表卡顿案例时,可以按以下顺序逐项检查:
- DOM 节点数:DevTools Elements 面板查「Nodes」总数。如果超过 5000 节点,场景依然照旧,优先考虑虚拟列表。
- 重渲染次数 :DevTools Profiler 面板录一次滚动,看列表项 Component 是否被不必要重渲染,必要时接
React.memo。 - 长任务:DevTools Performance 看是否有超过 50ms 的长任务,长任务多=主线程被其他逻辑占用,必需拆分。
- 滚动事件:看 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-virtual 的 unstable_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 组件:开启后会在控制台打印项测量、缓冲区使用情况
以上工具的组合可以覆盖绝大多数长列表调优场景。