尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

react-window虚拟滚动实战:从卡顿到丝滑的大数据列表优化

react-window虚拟滚动实战:从卡顿到丝滑的大数据列表优化 接手运营后台半个月我被一个看似简单的需求整懵了订单列表要一次性展示一万三千多条数据每行还有十几个字段。第一版实现很直接闭着眼睛setState塞进数组然后map渲染。测试环境下数据量小还看不出毛病一接真实库就原形毕露——滚动一下页面要卡一两秒才反应过来Chrome 直接提示页面无响应内存占用一路飙到 800MB。后来我把渲染方案改成了基于react-window的虚拟滚动整个改造大约花了一天时间列表流畅度从不可用直接拉回丝滑内存也降了一个数量级。这篇文章就围绕react-window这套方案把虚拟滚动在大数据列表和表格数据渲染场景下的原理、选型、代码实现和坑位一次讲透。不管你是刚接触 React 的新人还是做后台系统做到头大的老手这篇文章应该都能帮你省下几天的排查时间。1. 大数据列表卡顿的根源不是 React 慢是 DOM 撑不住了很多人第一反应是React 渲染一万条数据太慢了这句话只对了一半。React 创建出一万个虚拟 DOM 节点确实有成本但真正的瓶颈在浏览器那一层——那些节点一旦被挂到页面上浏览器要维护的可就不是一棵简单的树了。1.1 浏览器在滚动时到底在忙什么一万条数据每条如果按 5 个子元素算就是五六万个真实 DOM 节点。浏览器拿到这个 DOM 树之后要经历样式计算、布局、绘制、合成这几个阶段。样式计算会遍历所有节点匹配 CSS 规则布局阶段要计算每个节点的几何位置遇到一个节点尺寸变化可能引发连锁重排绘制阶段要把每个像素画出来。这几步你说它是 O(n) 复杂度都太乐观了有些场景下是 O(n^2) 的地狱。滚动时之所以卡是因为浏览器每一帧大约 16.7ms都要处理新出现在视口里的元素。如果一帧内算不完这些样式和布局就直接掉帧表现出来就是滚动像 PPT。我还实测过内存直接渲染一万条记录浏览器内存增加两三百 MB 很正常因为每个 DOM 节点在浏览器内部还有对应的 JS 对象、样式数据、事件监听这些都不是白给的。而虚拟滚动之后内存占用能压到几十 MB 的级别。1.2 虚拟滚动的核心思想用欺骗视觉的方式换性能虚拟滚动的思路其实很朴素既然可视区就那么大用户一次性只能看到二三十条数据那我凭什么要渲染一万条 DOM 节点出来我只需要渲染可视区内看得见的那些行然后用一个占位容器撑起整个滚动条的高度让浏览器以为内容真的有一万条那么高。具体到react-window里它的做法是外层一个overflow: auto的容器内层一个高度等于itemCount * itemSize的占位元素然后滚动的每一行都用position: absolute定位通过transform: translateY(offset)把它放到正确的位置上。滚动时它只计算当前 scrollTop 对应应该渲染哪些 index 的行其余的行统统不渲染。这种方案的好处是 DOM 节点数跟数据量解耦了你是一万条还是十万条页面上的节点始终只有可视区加缓冲区那几十个。React 的 diff 成本、浏览器的布局绘制成本、内存占用全部跟着降下来。理解了这一点后面所有react-window的用法都是在围绕哪些行该渲染、怎么放位置这两个问题做文章。2. 选型分析为什么我从 react-virtualized 换到了 react-window虚拟滚动不是一个新概念市面上方案不少我当时其实先在react-virtualized和react-window之间纠结了一阵子。如果你也在这两个库里摇摆我给你一个明确的结论没有历史包袱的话直接用react-window。2.1 体积和 API 设计的现实对比先看一组我实际测过的打包数据。react-virtualized压缩后大约 110KBreact-window大约 18KB。在如今前端项目都在卷体积、卷首屏加载的情况下这将近 100KB 的差距不是小数目。而且react-window是react-virtualized的作者 Brian Vaughn 后来重新设计的轻量版本把原来十几个组件砍到只剩几个核心组件API 也重新梳理过开始用之后你会明显感觉到这个库我知道它在干什么。react-virtualized功能确实更全比如它内置了AutoSizer、InfiniteLoader、CellMeasurer这些辅助组件听起来很方便。但实际用下来这些组件反而增加了理解成本而且它们维护状态的方式有时候很隐晦出问题很难排查。react-window把这些辅助能力拆成独立的包你需要什么再加什么核心逻辑保持干净可控。另外社区活跃度也是重要参考react-virtualized这几年基本处于维护停滞状态issue 没人回而react-window还在持续更新。2.2 react-window 的组件体系全景react-window核心就 4 个组件分别是固定尺寸的列表FixedSizeList、动态尺寸的列表VariableSizeList、固定尺寸的网格FixedSizeGrid、动态尺寸的网格VariableSizeGrid。列表组件处理一行一列的数据网格组件处理多行多列的数据。它还会默认把List、Grid作为属性导出方便你扩展默认组件。配合使用的还有两个官方兄弟包一个是react-virtualized-auto-sizer负责自动获取容器宽高另一个是react-window-infinite-loader负责跟无限滚动做结合。这套组合拳覆盖了我接触过的绝大多数大数据渲染场景。选择虚拟滚动组建时我的建议是先确认自己的行高是不是固定值。绝大部分后台列表、日志列表、订单列表都是固定行高那就无脑FixedSizeList。只有内容高度不确定、由文本或图片撑开的时候才上VariableSizeList。3. 核心组件实战FixedSizeList 与 VariableSizeList 的完整用法光说不练没有意义这一节我把react-window两个主力组件的用法拆开讲透包括那些文档里不会特意提醒、但很容易踩中的细节。3.1 五步跑通你的第一个虚拟列表安装依赖的步骤略过直接看代码。一个最基础的固定行高虚拟列表长这样import { FixedSizeList } from react-window; const Row ({ index, style }) ( div style{style} classNamelist-row Row {index} /div ); const VirtualList () ( FixedSizeList height{400} width{600} itemSize{50} itemCount{10000} overscanCount{5} {Row} /FixedSizeList );就这么几行一万条数据就能流畅滚动了。这里有一个细节传给Row组件的style参数必须应用到实际渲染的 DOM 节点上这是新手最常犯的错误。react-window内部给每个条目标好了position: absolute和transform: translateY(...)一旦你的自定义组件里没接这个style所有条目会叠在可视区顶部表现出来就是只看到几行文字叠在一起页面还乱成一团。itemSize是固定行高单位是像素。height是可视区高度itemCount是你的数据总量。你可能已经看出来了这个组件的设计思路就是拿了这四个参数一步算出可视区能显示多少条、每条应该放在哪。内部逻辑非常直观这也是它性能好的原因之一。3.2 动态行高列表itemSize 函数与 resetAfterIndex 的双人舞现实中更常见的情况是每条数据高度不一有的内容三行有的内容八行。这时候用FixedSizeList就不行了得换成VariableSizeListimport { VariableSizeList } from react-window; const items []; // 你的数据假设每条都有 height 属性 const Row ({ index, style }) ( div style{style}{items[index].content}/div ); const VirtualList () ( VariableSizeList height{400} width{600} itemCount{items.length} itemSize{(index) items[index].height ?? 80} {Row} /VariableSizeList );关键区别在于itemSize从数值变成了函数每个 index 返回自己的高度。VariableSizeList内部会维护一个累计高度数组通过这个数组定位滚动位置对应的起始索引。但坑也在这里如果内容高度是异步加载的比如富文本里的图片图片加载完之后行高变了列表却不知道。这时候你需要拿到列表的 ref手动调用resetAfterIndex(index)告诉它从第 index 行开始后面的高度都变了重新算。我一般这样处理const listRef useRef(null); // 某行内容高度变化后调用 const handleContentResize (changedIndex) { listRef.current?.resetAfterIndex(changedIndex); };有个经验是在真实业务里不要指望每次都能精确测出每条数据的高度。我做过一个聊天记录列表每条记录长短不一但图片还没加载完我又不能阻塞渲染。最终方案是估算一个默认高度先渲染然后在图片的onLoad回调里用 ref 实测这一行的 DOM 高度再调用resetAfterIndex修正。这个方法虽然有点暴力但确实稳。3.3 二维数据用 Grid把虚拟滚动从行延伸到列如果数据是表格的结构但你又想横向纵向都能虚拟滚动Grid是更合适的选择。它的用法逻辑跟List几乎一致import { FixedSizeGrid } from react-window; const Cell ({ columnIndex, rowIndex, style }) ( div style{style}row {rowIndex} col {columnIndex}/div ); const VirtualGrid () ( FixedSizeGrid columnCount{20} columnWidth{120} rowCount{1000} rowHeight{40} height{500} width{800} {Cell} /FixedSizeGrid );Grid的场景包含电子表格、日历视图、地图图块这类数据。大多数后台列表用不到它但你知道有这么个东西遇到既要横向滚动又要纵向滚动的需求时就不慌了。如果网格的高度也不固定就对应上VariableSizeGrid思路跟VariableSizeList一样列宽和行高都可以传函数。4. 表格场景的虚拟滚动实操让 Table 也能驾驭十万行标题里提到了表格数据渲染这部分在真实项目中占比很大但坑也比单纯列表多得多。4.1 为什么表格虚拟滚动比普通列表更棘手普通列表只要管好每行渲染什么就行了但表格至少多出三件事表头要固定且和列对齐、行内多个列的宽度要对齐、横向滚动时表头和表体要同步。如果你直接拿table标签做虚拟滚动很快会发现一个问题虚拟滚动的行是用绝对定位排版的而table的行必须遵守表格布局算法两者天然冲突。你当然可以强行给每个tr设置position: absolute最终效果却大概率是列全乱了或宽度对不上。我试验下来最稳的方案是放弃原生table布局用div flex模拟表格表头单独渲染表体用FixedSizeList虚拟滚动。把每一行变成一个 flex 容器每一列设置固定宽度和相同 flex 属性。这样行与行之间、表头与表体之间都能严格对齐同时还能享受虚拟滚动带来的性能红利。4.2 用 TanStack Table 和 react-window 组合实现虚拟表格如果你项目里已经用了 TanStack Table也就是前 React Table集成起来很顺。下面这段代码是从我项目里摘出来的核心结构import { useReactTable, getCoreRowModel, flexRender } from tanstack/react-table; import { FixedSizeList } from react-window; const VirtualTable ({ rawData, columns }) { const table useReactTable({ data: rawData, columns, getCoreRowModel: getCoreRowModel(), }); const { rows } table.getRowModel(); const renderRow ({ index, style }) { const row rows[index]; return ( div style{{ ...style, display: flex, borderBottom: 1px solid #eee }} rolerow {row.getVisibleCells().map((cell) ( div key{cell.id} style{{ flex: 1 1 ${cell.column.getSize()}px, padding: 8px, overflow: hidden, }} {flexRender(cell.column.columnDef.cell, cell.getContext())} /div ))} /div ); }; return ( div div style{{ display: flex, borderBottom: 2px solid #ddd }} {/* 表头单独渲染列宽与表体保持一致 */} {table.getHeaderGroups()[0].headers.map((header) ( div key{header.id} style{{ flex: 1 1 ${header.getSize()}px, padding: 8px, fontWeight: 600 }} {flexRender(header.column.columnDef.header, header.getContext())} /div ))} /div FixedSizeList height{600} itemCount{rows.length} itemSize{48} width100% {renderRow} /FixedSizeList /div ); };这套结构的核心思路是TanStack Table 负责列定义、排序、筛选这些业务逻辑react-window只负责渲染可视区域的行。两者各有专攻互不干扰。4.3 表头固定、列宽对齐和横向滚动的处理细节上面的代码里表头和表体是分开渲染的两个 flex 容器列宽必须严格一致。我把列宽配置收敛到了一个地方比如列定义里的size字段表头用header.getSize()、表体用cell.column.getSize()这样天然对齐。千万不能表头硬编码宽度、表体又用百分比几行数据看不出问题数据一多必定错位。表头固定最简单的方式是给表头容器设置position: sticky; top: 0这样表体滚动时表头始终吸在顶部。我特意把表头放在FixedSizeList外层而不是放进列表里这样表头是普通文档流、表体是虚拟滚动容器两者的滚动状态互不干扰体验也顺滑。横向滚动要复杂一些。我的做法是外层包一个overflow-x: auto的容器表头和表体共用这个横向滚动容器这样水平方向天然同步。列宽不固定时每个单元格的 flex 值要相同否则横向滚动时又会出现错位。列数特别多的情况下我会优先考虑减少列数把次要字段收进详情弹窗而不是硬做一个横向滚动的十列表格——虚拟滚动解决的是纵向性能问题横向滚动条配合绝对定位布局在操作体验上很难做到完美。5. 踩坑实录与性能调优真实项目里的那些坑这节是我最想写的部分。react-window的文档写得很简洁但真实场景下你总会遇到几个文档里没细说、自己排查半天才找到根因的问题。我把实际项目中踩过的坑按排查链路整理出来你遇到相似症状时可以直接按图索骥。5.1 快速滚动白屏先别加 loading查 overscanCount第一次把FixedSizeList接进项目后我发现在快速滚动时视口边缘会出现一片空白过一会儿内容才补上来。我之前第一反应是数据没加载完想加 loading 态后来才意识到问题出在overscanCount默认值太小。overscanCount的作用是控制可视区之外额外渲染多少行作为缓冲。默认值是 1也就是说只多渲染上面一行和下面一行。正常慢速滚动没问题但快速滚动时浏览器一帧内滚过了好几行的距离缓冲区还没准备好就会白屏闪一下。这个值调到 5 到 10 之后白屏明显消失。代价是多渲染几行 DOM对于虚拟列表来说这个代价可以忽略不计。所以我的建议是上线前先快速拖一遍滚动条如果边缘有白屏闪烁直接调大overscanCount不要怀疑是数据加载问题。5.2 图片加载导致行高跳动滚动位置全乱用VariableSizeList做动态高度列表时遇到过一个很隐蔽的坑每条数据里有缩略图图片异步加载后把行高撑高了但列表组件不知道导致滚动位置和目标行错位用户点的第 30 条滚过去之后却是第 28 条的内容。排查链路是这样的先怀疑数据顺序错了打印 index 对比后确认顺序没问题接着怀疑滚动计算有误差在onScroll里打印当前渲染的起始索引发现图片加载前后起始索引确实变了最后才定位到是行高变化引起了累计高度数组失准。解决方案分两层。如果图片尺寸在数据里是可预知的比如接口返回了宽高直接给图片容器一个固定宽高行高就不会变。如果完全不可预知就在图片onLoad时通过ref实测这一行的新高度保存下来后调用resetAfterIndex(index, true)修正。注意第二个参数传true强制刷新否则部分场景下列表不会重排。5.3 点击回到顶部失效scrollToItem 的正确姿势产品给列表加了一个回到最新一条的按钮我用的是listRef.current.scrollToItem(9999, center)。结果在列表底部时按钮反应正常在顶部时点击却纹丝不动。排查半天发现scrollToItem必须等列表完成渲染之后调用才有效而我的按钮在列表组件挂载之前就触发了调用此时 ref 还没绑定等于打了个空拳。真正的解决办法是确保在组件挂载完成useEffect之后再调用或者包一层setTimeout。如果是异步数据加载的场景还得等数据到位后再调不然itemCount都还不是最终值定位自然失灵。另外scrollToItem的第二个参数支持auto | smart | center | start | end如果列表本身就在视口内用smart能避免不必要的滚动跳动。5.4 容器尺寸自适应AutoSizer 和 ResizeObserver 的取舍react-window的height和width是必填参数而且是具体的数值。如果你把列表放在一个 flex 布局里想让它自动撑满剩余高度就得自己想办法拿到容器的实际宽高。react-virtualized-auto-sizer这个官方兄弟包就是干这个的用法很简洁import AutoSizer from react-virtualized-auto-sizer; import { FixedSizeList } from react-window; const AutoHeightList () ( AutoSizer {({ height, width }) ( FixedSizeList height{height} width{width} itemSize{40} itemCount{10000} {Row} /FixedSizeList )} /AutoSizer );需要注意AutoSizer的父容器必须有一个明确的高度否则它拿到的始终是 0列表直接无法显示。如果你觉得这个包有额外体积负担也可以自己写一个ResizeObserver监控容器尺寸变化实测效果差不多。但说实话这个包很小直接用更省事。5.5 键盘导航与焦点管理虚拟列表的隐藏难题虚拟列表只渲染可视区随之而来的一个麻烦是用 Tab 键或方向键导航时焦点一移出可视区那个 DOM 节点就被回收了焦点直接丢到页面其他地方键盘用户会突然迷失。这一点对无障碍要求高的系统来说很重要。我的妥协方案是对支持键盘导航的列表增加一个较大的overscanCount保证焦点移动的行在缓冲区里还存在同时监听焦点事件一旦焦点离开可视区就调用scrollToItem把对应行滚回可视区。这个方案不能做到 100% 顺滑但在大多数业务场景下已经够用。5.6 浏览器缩放与响应式布局下的尺寸变化还有一类问题是浏览器窗口尺寸变化或者设备横竖屏切换时容器高度变了列表却还是老尺寸底部内容被截断。AutoSizer内部用了ResizeObserver能自动响应容器尺寸变化所以尽量让AutoSizer负责宽度和高度的监听不要自己在外部用 window resize 去手动更新——window 的尺寸变化不等于容器的尺寸变化移动端浏览器地址栏收起时尤其离谱手写 resize 逻辑很容易翻车。如果你不用AutoSizer那就要自己监听容器尺寸变化并触发一个 React state 更新把新的宽高传给列表。记住核心原则react-window的宽高参数变了它自己会重新计算可视区和渲染范围你只需要保证容器尺寸变了就更新参数这一件事。写在最后什么时候真的需要虚拟滚动说了这么多最后分享一点个人判断标准。我见过不少项目把简单的几百条列表也硬套上虚拟滚动结果引入了一堆对齐、焦点、自适应的麻烦完全得不偿失。以我自己的经验数据量在 500 条以内、行高固定、内容简单直接map渲染就行不会有性能问题数据量在 1000 到 5000 条先看一眼页面卡不卡卡再上虚拟滚动数据量超过 5000 条或者数据量虽然不大但每行内容特别重直接上react-window。另外如果你们项目本来就在用 antd 的 Table5.x 版本在设置scroll.y后会自动启用内置虚拟滚动那种场景下其实不用额外集成react-window。反而是自研的列表、私有的报表组件、聊天记录这类非标准表格才是react-window发光发热的主场。虚拟滚动这个方案说起来核心就一句话——把渲染一万条改成渲染看得见的三十条但把它用好需要对浏览器渲染机制、组件 API 和业务场景都有足够理解。希望这篇文章能帮你少走一些弯路。
返回列表