react-window虚拟滚动实战:解决大数据列表与表格渲染性能瓶颈
做前端的朋友应该都遇到过这种场景后台管理系统里的日志列表、消息中心的历史记录、或者某个数据看板里的明细表格后端一次性把几万条数据全量返回给前端。直接用table或者div列表渲染页面卡到怀疑人生Chrome 的“页面无响应”提示直接糊脸。我最早处理这种问题试过手动分页、试过懒加载、甚至试过setTimeout分片渲染但这些方案要么牺牲交互体验要么代码复杂度爆炸。直到后来接触到 React 生态里的虚拟滚动方案才算是真正把这个问题从根上解决掉。今天这篇就围绕react-window这个库聊聊大数据列表和表格数据渲染组件里的虚拟滚动到底是怎么回事、怎么落地以及我在实际项目里踩过的那些坑。react-window是 Brian Vaughn 写的专门用于渲染大数据列表和表格的组件库核心卖点就是虚拟滚动。它能把 10 万条数据的列表渲染压到几十毫秒级别因为你屏幕上永远只存在可视区域内那十几个 DOM 节点。适合什么场景后端一次性返回大量数据、前端需要全量展示、列表项高度固定或可预估、以及需要在表格里展示成千上万行数据的场景。如果你正在被大数据渲染卡顿折磨或者想给自己的表格组件提升性能这篇文章应该能给你一个完整可落地的思路。1. 为什么大数据列表必须用虚拟滚动1.1 一次性渲染几千条数据的性能瓶颈先说一个基本事实浏览器不是不能渲染几万个 DOM 节点而是渲染几万个复杂 DOM 节点并进行布局、绘制时主线程会被彻底占用。每条列表数据假设只包含一个简单的div 两行文本那 5 万条数据就是 5 万个 DOM 节点。你试着滚动页面每次滚轮事件触发浏览器重新计算布局和绘制这期间帧率可能跌到个位数滚动就跟看幻灯片一样。更麻烦的是内存占用。每个 DOM 节点在内存里都有对应的对象模型几万个节点意味着几百 MB 级别的额外内存。用户用的又不是顶配工作站普通办公笔记本直接卡死。我见过一次真实的线上事故运营人员在后台打开一个包含 8 万条操作日志的页面Chrome 直接弹出了 Aw Snap 页面崩溃提示运营当时就炸毛了。所以根本问题是你能不能做到只渲染用户当前看得见的那部分DOM节点其余的用占位内容代替。这就是虚拟滚动的核心思路。1.2 虚拟滚动的核心原理撑高、定位、按需渲染虚拟滚动说白了就是一个障眼法。它分三步操作第一步创建一个高度等于所有列表项总高度的撑高容器也就是innerContainer。比如 100 条数据每条高 40px那么这个容器高度就是 4000px。这样浏览器自带的滚动条就能正常工作用户可以像滚普通页面一样往下滚。第二步监听滚动事件计算出当前滚动位置对应的起始索引和结束索引。比如滚到 1000px每条高 40px那第 25 到第 50 条就是可见区域需要渲染的部分。第三步绝对定位这些需要被渲染的列表项到它们在整体列表中的位置。第 25 条的位置就是25 * 40 1000px第 26 条就是1040px以此类推。通过transform: translateY或者absolute定位把它们放到正确的位置。这样整个列表的 DOM 节点数量从几万缩减到几十个滚动时的性能自然就上来了。滚动的过程中组件会持续计算并更新渲染范围但因为只需要重渲染当前窗口内的几十个节点所以非常轻量。1.3 为什么选择 react-window 而不是 react-virtualized 或自己造轮子提到虚拟滚动React 生态里绕不开react-virtualized。功能确实强大支持列表、表格、瀑布流、无限加载器但体积也感人打包后主包大概 100KB 左右。react-window是同一个作者意识到react-virtualized太庞大之后写的精简重构版打包体积只有大约 5KBAPI 也更平易近人。对于绝大多数只需要列表和表格虚拟滚动的业务场景react-window是完全够用的。自己造轮子呢我坦白讲虚拟滚动看起来简单做起来其实有很多细节滚动事件的节流压缩、列表项动态高度测量、快速滚动时的白屏闪烁、列表项复用时的状态残留、以及和对齐ScrollTo定位相关的逻辑。这些细节没个两三周时间打磨不完善而且测试难度不小。既然已经有成熟方案与其重复造轮子不如把精力花在业务逻辑上这也符合工程化的原则。2. 快速上手固定高度列表的完整实现2.1 安装与基础用法react-window用起来非常简单安装就是一个命令的事npm install react-window # 或者 yarn add react-window最基础的用法是渲染一个固定高度的列表这时候用FixedSizeList组件。每个列表项的高度必须完全一致这样性能最好代码也最简单。import { FixedSizeList as List } from react-window; const Row ({ index, style }) ( div className{index % 2 ? ListItemOdd : ListItemEven} style{style} Row {index} /div ); const Example () ( List height{500} itemCount{10000} itemSize{40} width{800} {Row} /List );这个示例里height和width是可视区域的大小itemCount是列表数据总数itemSize是每行的高度。Row组件接收index和style两个参数style必须传给列表项的根元素因为里面包含了定位信息。2.2 核心参数逐个拆解这几个参数每个都值得展开聊理解透了才能在各种场景下灵活运用。width和height指定可视区域的尺寸也就是滚动容器的尺寸。这两个值可以是固定数值也可以是一个返回尺寸的函数用于响应式场景。注意这里高度和宽度界定的是视口不是内容。itemCount是数据源的总条数react-window会根据它乘以itemSize得到总滚动高度。如果你实际渲染的列表项数量变更了记得同步更新itemCount否则会出现滚动条高度异常的问题。itemSize可以是固定数值也可以是函数。使用VariableSizeList时它接收index返回对应的高度。即使使用FixedSizeList也要求itemSize是一个数字不能缺省。这个参数看起来很基础但它直接决定了虚拟滚动的总高度计算和定位计算是否正确。假如你的列表项实际高度和设置的itemSize不一致会出现滚动时列表项跳动或者重叠的现象。另外还有一个经常被忽略但很关键的参数overscanCount。它表示在可视区域外额外渲染多少条数据。默认值是 1也就是说首屏会额外多渲染一条数据用于缓冲。在快速滚动时如果渲染跟不上滚动速度会出现白屏闪烁这时候适当调大overscanCount能明显改善体验。但也不要设置太大否则会失去虚拟滚动的意义。2.3 列表项组件的编写要点style 必须透传很多新手在写列表项组件时容易犯一个错误忘记把rowData的style参数传给根元素。这个style里面包含了position: absolute和transform: translateY(...)是虚拟滚动定位的关键。如果你把它忽略了整个列表的项会叠在一个位置视觉上就是全部重叠在一起。所以正常写法是const Row ({ index, style }) ( div style{style} {/* 业务内容 */} /div );同时也注意不要让列表项的根元素设置margin因为margin会影响高度计算。如果列表项之间有间隔需求用内边距处理即给根元素内部的内容包一层设置padding。2.4 在真实项目中接入后端数据的完整示例实际业务不会像 DEMO 那么简单你需要把后端返回的数据映射到列表项里去。这里有一个我在项目中一直在用的模式不要直接把完整数据塞给列表项组件而是用itemData属性传递数据源在渲染函数里通过index去取对应数据。import { FixedSizeList as List } from react-window; import { useMemo } from react; const LogList ({ logs }) { const Row ({ index, style, data }) { const item data[index]; return ( div style{style} classNamelog-row span classNamelog-time{item.time}/span span classNamelog-level{item.level}/span span classNamelog-message{item.message}/span /div ); }; return ( List height{600} itemCount{logs.length} itemSize{48} width100% itemData{logs} {Row} /List ); };这里把logs数组通过itemData传进去然后在Row里用data[index]获取当前行对应的日志数据。注意一个细节width如果设置为100%react-window会使用父容器的宽度但父容器必须有明确宽度且不能是display: contents这类特殊元素。当然你也可以在useMemo里提前处理数据格式避免每行渲染时做不必要的计算。我自己的习惯是如果列表项内部还有复杂的交互逻辑比如点击展开、内嵌按钮把列表项单独抽成一个组件并且用React.memo包一层避免父组件状态变化导致所有列表项重新渲染。这一点后面在性能优化章节会展开。3. 表格场景Grid 组件与跨列布局的实战3.1 用 Grid 渲染表格数据的思路很多人以为react-window只能做列表其实它的Grid组件就是专门用来做二维虚拟滚动的也就是表格场景。Grid的原理和List一样只不过从一维变成了二维既要虚拟化行也要虚拟化列。使用Grid组件渲染一张有 5000 行 20 列的数据表格代码如下import { Grid } from react-window; const Cell ({ columnIndex, rowIndex, style }) ( div style{{ ...style, width: 120, borderBottom: 1px solid #eee, boxSizing: border-box, }} {rowIndex} - {columnIndex} /div ); const Table () ( Grid columnCount{20} columnWidth{120} height{600} rowCount{5000} rowHeight{40} width{1000} {Cell} /Grid );columnCount、columnWidth、rowCount、rowHeight分别对应列数、列宽、行数、行高。Cell渲染函数接收三个参数columnIndex、rowIndex和style。注意这里style同样必须传给根元素否则定位会乱。3.2 表头与表体的同步滚动方案表格最麻烦的是表头。如果你把表头放在Grid内部那么往下滚动时表头会跟着滚走。常规做法是把表头单独放到一个固定行的Grid里然后把两个Grid的滚动事件绑定起来实现表头跟随滚动。我这里提供一个我实际用的方案用两个Grid一个渲染表头高度固定为一行另一个渲染表体高度占满剩余区域。表体的onScroll事件被触发时把scrollLeft同步给表头组件。const [scrollLeft, setScrollLeft] useState(0); const headerGridRef useRef(null); const bodyGridRef useRef(null); const handleBodyScroll ({ scrollLeft }) { setScrollLeft(scrollLeft); headerGridRef.current.scrollTo({ scrollLeft }); }; Grid ref{headerGridRef} columnCount{columnCount} columnWidth{columnWidth} height{40} rowCount{1} rowHeight{40} width{width} scrollLeft{scrollLeft} / Grid ref{bodyGridRef} columnCount{columnCount} columnWidth{columnWidth} height{height - 40} rowCount{data.length} rowHeight{40} width{width} onScroll{handleBodyScroll} /这里有几个注意点。第一表头Grid渲染的Cell组件不要用rowIndex去取数据因为表头的rowCount只有 1数据本来就对应不上你需要单独写一个HeaderCell渲染函数。第二onScroll事件里同步scrollLeft的操作要带上节流或者requestAnimationFrame否则高频率滚动事件会导致性能问题。第三如果用户使用鼠标在表头区域横向滚动也要反向同步给表体这个可以通过onScroll同时监听两个 Grid 实现。如果不想自己做同步也可以考虑把表头固定在滚动容器外然后用 CSS 控制表体内容对齐。但这种方式对于列数较多的表格比较难维护需要额外照顾横向滚动条对齐。相比之下双Grid同步方案更通用一些。3.3 固定列、列宽调整等复杂需求怎么处理固定列是表格虚拟滚动里一个比较进阶的需求。实现思路其实还是多Grid左侧固定列一个Grid右侧滚动区域一个Grid两个Grid的纵向滚动同步。固定列的Grid列宽固定滚动区域的Grid自行处理横向滚动。列宽调整的实现在react-window里比较朴素你需要维护一个列宽数组columnWidths在拖拽调整列宽时更新数组然后通过columnWidth函数返回对应列的宽度。由于Grid每次渲染都会调用columnWidth函数所以动态列宽是天然支持的。我建议先评估业务是否需要这些复杂功能。react-window本身不提供表格组件没有排序、筛选、合并单元格这些开箱即用的能力。如果你的表格需要这些功能react-window可能不是最佳选择用Ant Design Table配合virtual属性会更省力。但如果你的场景比较纯粹就是展示大量数据react-window的Grid反而更轻量可控性也更强。3.4 Grid 与普通 Table 组件在渲染性能上的对比我之前在项目里做过一个粗略的性能对比渲染 1 万行、20 列的数据每行单元格里包含文本和一个小状态标签。用原生table渲染页面初始渲染耗时大约在 1.5 秒左右内存占用约 380MB滚动时帧率在 8 到 15 帧之间徘徊操作起来有明显的迟滞感。换用react-window的Grid之后初始渲染耗时降到 100ms 以内内存占用只有 30MB 左右滚动帧率稳定在 60 帧。差距就是这么明显。当然这里面有一部分原因是原生table对单元格的border-collapse和其他样式计算成本比较高。但即便你用div平铺的方式模拟表格几万个 DOM 节点依然会让浏览器吃力。虚拟滚动的意义就是让你用极少的 DOM 节点模拟出完整内容的效果。4. 动态高度与响应式布局可变尺寸列表的实战4.1 为什么需要 VariableSizeList固定高度列表用FixedSizeList确实简单但现实世界里的文本长度千变万化。比如一个消息列表有的消息只有几个字有的消息是一大段长文本换行后能撑出 200px 的高度。如果你强行用FixedSizeList要么每条都按最大高度来计算白白浪费大量空间要么高度不够导致内容被截断或者重叠。这时候就需要VariableSizeList。它的核心区别是每一行的高度可能是不同的组件在计算总高度和定位时需要通过一个itemSize函数去获取每一行的实际高度。import { VariableSizeList as List } from react-window; List height{600} itemCount{messages.length} itemSize{(index) messageHeights[index]} width100% {Row} /List这里的messageHeights是预先计算好的高度数组。难点在于你怎么知道每一条消息渲染后的实际高度4.2 动态高度的测量方案estimateSize 与缓存策略计算动态高度有两条路估算和实测。估算是用estimatedItemSize参数设置一个大概值。比如消息列表的平均高度是 80px你就设置estimatedItemSize{80}。VariableSizeList会先用这个值计算总高度在浏览器渲染完成后它会调用resetAfterIndex等方式重新计算确认的高度。你可以理解为先按平均身高帮忙占个座等实际数据出来再精确调整。实测的逻辑是在列表项渲染完成后异步测量 DOM 的实际高度并更新高度数组。我写过一个简单版的高度测量封装const ListItem ({ index, style, data }) { const rowRef useRef(null); useEffect(() { if (rowRef.current) { const height rowRef.current.getBoundingClientRect().height; data.setRowHeight(index, height); } }, [index, data]); return ( div style{style} div ref{rowRef} {/* 内容 */} /div /div ); };setRowHeight函数维护一个高度数组并在更新后调用listRef.current.resetAfterIndex(index)让列表重新计算定位。这里注意一个点不要在滚动过程中频繁调用resetAfterIndex它会导致列表整体重新布局性能损耗很大。合理的方案是在内容加载完成、数据稳定之后一次性批量测量并重置。还要提醒一点estimatedItemSize与itemSize函数缺一不可。如果你只设置了itemSize而忘记估计值列表在初始渲染时可能因为总高度不准确出现滚动条跳动。这个值尽量贴近真实平均值过低的话滚动条高度变化会比较明显过高则初始可视区域的渲染条数偏少。4.3 响应式容器尺寸调整很多组件库里的List要求height和width是固定值这对于后台管理系统的内容区来说有点麻烦因为内容区的尺寸通常随窗口大小变化。react-window给了一个叫AutoSizer的组件专门帮我们解决这个问题。不过注意AutoSizer并不在react-window主包里而是在配套的react-virtualized-auto-sizer包里。npm install react-virtualized-auto-sizer用法也非常直接import { FixedSizeList as List } from react-window; import AutoSizer from react-virtualized-auto-sizer; const App () ( AutoSizer {({ height, width }) ( List height{height} itemCount{10000} itemSize{40} width{width} {Row} /List )} /AutoSizer );AutoSizer的渲染函数会接收height和width这个模式在项目里很常用。我自己用的过程中发现一个坑AutoSizer要求它的父容器必须能正常计算尺寸如果父容器的height是auto或者为 0会导致它永远测量不到正确的尺寸组件就不会渲染任何内容。所以使用前优先确保父容器有明确的height或者flex: 1这类正常的布局。另外当容器尺寸变化时AutoSizer默认会触发List的重新渲染。如果你用的是VariableSizeList尺寸变化后还需要配合resetAfterIndex来调整缓存的行高否则可能出现滚动条高度不对的问题。4.4 结合路由实现滚动位置保持还有一个实际项目里经常碰到的问题从列表详情页返回列表页时滚动位置被重置了。react-window默认不保存滚动位置需要自己处理。我的处理方式是在路由切换前记录scrollTop返回时用ref.scrollTo(scrollTop)恢复位置。以 React Router 为例可以在组件卸载前把滚动位置存到全局缓存或者路由 state 里const listRef useRef(null); useEffect(() { const savedScrollTop sessionStorage.getItem(listScrollTop); if (savedScrollTop) { listRef.current.scrollTo(Number(savedScrollTop)); } return () { sessionStorage.setItem(listScrollTop, listRef.current?.state?.scrollOffset || 0); }; }, []);这里要注意List组件的scrollTo方法接收的是像素值scrollToItem接收的是索引值。如果你的列表项高度不固定用scrollToItem时还需要传入align参数指定对齐方式否则恢复出来的位置可能不对。sessionStorage在页面刷新后依然保留但在新标签页会失效按需选择存储方式。5. 常见问题与排查技巧实录5.1 列表项内容全部空白怎么办这是刚接触react-window时最常遇到的问题而且原因通常只有一个忘了把style传给列表项根元素。因为虚拟滚动依赖absolute定位来摆放列表项的位置没有style的话所有项都会堆叠在左上角看起来就像什么都没有渲染。如果你确认style已经传了但还是白屏第二个要检查的是容器高度。比如AutoSizer的父容器高度为 0那List拿到的height也是 0自然什么都看不到。用开发者工具查看一下List的外层容器看它的高度和宽度是否正常。5.2 滚动时出现闪烁、跳动、错位闪烁通常是因为overscanCount设置太小了。快速滚动时渲染范围更新跟不上手指滚动的速度可视区域内还没来得及渲染出对应列表项就会出现白屏。把overscanCount调大到 5 到 10 通常能缓解。当然如果设置太大预渲染的 DOM 节点增多性能也会下降需要平衡。跳动和错位的问题大概率是itemSize与列表项实际高度不一致。比如你用FixedSizeList但列表项里有不同长度的文本撑出来的实际高度不完全等于设置的itemSize。这时候要么统一高度要么改用VariableSizeList并维护好高度数组。5.3 用 React.memo 优化列表项的重复渲染父组件已经用React.memo包裹了列表项组件但列表项在滚动时仍然不断重渲染这是怎么回事react-window的设计里滚动行为本身就会触发渲染范围内的变化。这是正常现象滚到新的位置旧的项滚出屏幕被卸载新的项滚进屏幕被挂载。但如果你想减少不必要的重复渲染可以注意两点第一itemData引用必须稳定。如果每次渲染你都把数据源临时组装成一个新数组那memo的浅比较永远不相等列表项组件会全部重渲染。正确的做法是用useMemo缓存itemData或者直接传稳定的 state 变量。第二不要在列表项组件内部使用上下文。比如在Row组件里通过useContext获取某状态那么上下文值一变化所有列表项都会重渲染。如果无法避免可以考虑把Row拆成两层外层从上下文取值后只传给需要的项内层组件接收 props 用memo包裹。5.4 与 Ant Design 等组件库配合时的注意事项react-window和Ant Design这类 UI 库不是水火不容但要注意几个问题。首要是组件库的 Table 组件不要直接用虚拟滚动包在外层因为 Ant Design Table 内部也管理了一套自己的 DOM 结构外部虚拟滚动可能导致布局错乱。我踩过的坑是把List包在Popover里Popover 的浮层默认渲染在body下第一次打开时浮层宽度没有测量正确导致列表宽度为 0。解决方案是给Popover设置getPopupContainer或者使用AutoSizer兜底。另外antd的Table如果要实现虚拟滚动官方推荐的是在components属性里传入自定义body渲染函数然后内部用react-window的Grid渲染。这种方式比直接嵌入更优雅但代码复杂度也直线上升。如果只是因为数据量大而性能差先检查是否真的需要一次性渲染全部数据只要能分页或者滚动加载更多很多时候根本用不上虚拟滚动。5.5 常见问题速查表问题现象最可能的原因解决方案列表空白未传 style 或容器高度为 0检查列表项根元素是否透传 style检查 AutoSizer 父容器高度滚动闪烁overscanCount 太小调大 overscanCount 到 5-10列表项重叠/跳动itemSize 与实际高度不一致改 VariableSizeList 或统一列表项高度滚动条高度异常itemCount 未及时更新确保 itemCount 与数据源长度一致表格表头不同步未同步 scrollLeft用 onScroll 同步两个 Grid 的 scrollLeft列表项重复渲染itemData 引用不稳定/组件内使用 contextuseMemo 缓存 itemData拆分组件并 memo6. 后续扩展方向虚拟滚动还能用到哪里说完了基础用法和常见坑再聊聊虚拟滚动还能怎么玩。第一个方向是无限滚动加载。react-window本身只管渲染可视区域它不会帮你处理数据加载。但你可以借助onItemsRendered回调判断当前渲染的最后一个索引是否接近数据末尾如果是就触发后端分页请求把新数据追加到数据源里。const handleItemsRendered ({ visibleStopIndex }) { if (visibleStopIndex logs.length - 10) { loadMore(); } }; List onItemsRendered{handleItemsRendered} ... /这个模式在移动端的消息列表、评论列表里非常实用。注意好防重复请求的开关避免滚动到末尾时连续触发多次请求。第二个方向是结合虚拟滚动做树形表格。树形结构天然行高和展开状态都比较复杂但你可以在Grid的单元格渲染函数里处理缩进和展开按钮配合行索引与树节点的映射关系实现一个性能不错的树形表格虚拟滚动。第三个方向是自定义滚动条。react-window把滚动相关的逻辑封装在内部但你依然可以通过自定义外层容器的scrollbarCSS 样式来美化滚动条。如果业务对滚动条样式要求很高可以考虑把List包在一个设置了自定义滚动条样式的容器里让List的滚动容器隐藏原生滚动条外层显示自定义滚动条。从我自己这段时间的实践来看react-window最打动人心的点不是 API 有多炫酷而是它把一个硬件级的性能问题降维成了一次性的配置工作。你不需要懂浏览器渲染管线不需要手动优化 DOM 操作只需要按它的约定把数据源和尺寸传递清楚就能获得接近原生的滚动体验。最后分享一个小技巧在使用react-window的项目里我非常建议在开发环境里装上 React DevTools 的 Profiler滚动页面时观察列表项组件的渲染耗时。你会发现第一次滚动时有少量列表项挂载耗时会高一点后续滚动基本保持在毫秒级。如果你发现某次滚动耗时突然飙升优先排查是不是有列表项内部触发了重型计算或者同步布局读取。抓住渲染成本的源头虚拟滚动的性能优势才能真正发挥到极致。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →