虚拟滚动:构建高性能界面
在浏览器中渲染 50 万行数据,你的界面很可能会冻结、卡顿或崩溃。虽然现代浏览器可以处理大型 DOM 树,但随着节点数量的增长,由于布局、样式计算和内存开销,性能往往会急剧下降。虚拟滚动通过仅渲染用户实际能看到的内容来解决这个问题——而这一个约束条件就彻底改变了数据密集型界面的性能表现。
核心要点
- 虚拟滚动仅渲染视口中可见的项目(加上一个小的缓冲区),无论数据集大小如何,都能保持 DOM 节点数量恒定。
- 它通过从
scrollTop计算可见索引、渲染这些项目,并使用填充元素模拟完整的可滚动高度来工作。 - 固定项目高度使实现保持简单。动态高度需要测量缓存和仔细的滚动位置校正。
- 浏览器原生搜索(Ctrl+F)、可访问性和滚动位置稳定性在虚拟化列表中都需要额外关注。
- React、Angular 和 Vue 都有成熟的库——在生产环境中很少需要从头构建。
什么是虚拟滚动(以及它为什么不是无限滚动)?
虚拟滚动(也称为列表虚拟化或窗口化)仅渲染当前在视口中可见的项目,加上上下各一小段缓冲区。当用户滚动时,离开视口的项目会从 DOM 中移除,新项目会在其位置插入。整个数据集永远不会完全进入 DOM。
这与无限滚动有本质区别。无限滚动在你滚动时追加项目到 DOM——列表持续增长。虚拟滚动交换项目的进出,无论数据集大小如何,都能保持 DOM 节点数量大致恒定。
实际差异很显著。一个简单渲染的 10 万项列表可能会在内存中创建 10 万个以上的 DOM 节点。而同样数据集的虚拟化列表在任何给定时刻可能只持有 50-80 个节点。
虚拟化列表的概念原理
该机制依赖于几个简单的想法协同工作:
视口窗口。 你为滚动容器设置一个固定高度。这定义了一次可见多少项目——称为 visibleCount = Math.ceil(containerHeight / itemHeight)。
索引计算。 当用户滚动时,你读取 scrollTop 来确定哪个项目位于可见区域的顶部:startIndex = Math.floor(scrollTop / itemHeight)。结束索引随之而来:endIndex = startIndex + visibleCount。
滚动位置错觉。 如果你只渲染 50 个项目,滚动条会反映一个很小的列表。为了模拟完整高度,你在渲染项目上方放置一个空的填充元素(高度 = startIndex × itemHeight),下方再放置一个(高度 = 剩余空间)。滚动条的行为就像完整数据集存在一样。
过扫描(缓冲区)。 仅渲染精确可见的项目会在快速滚动时造成突兀的弹出效果。过扫描在视口上下额外渲染几行——通常是 5-10 个项目,取决于使用场景——这样项目在滑入视图之前就已经在 DOM 中了。
固定高度 vs. 动态高度
固定高度虚拟化简单可靠。每个计算都是简单的算术运算。
动态高度要困难得多。你需要在渲染后测量每个项目并缓存这些测量值,或者预先估计高度并在测量后进行校正。这两种方法都增加了复杂性,如果处理不当可能导致滚动位置不稳定。如果你的使用场景允许,固定高度值得设计采用。
Discover how at OpenReplay.com.
需要预期的实际权衡
虚拟滚动不是免费的。有几件事会失效或需要额外工作:
- 浏览器文本搜索(Ctrl+F) 停止可靠工作,因为大部分内容不在 DOM 中。你需要实现自己的搜索功能。
- 可访问性需要关注。为容器应用
role="list"、role="feed"或role="grid"。你可以使用aria-setsize和aria-posinset等属性,让辅助技术能够理解完整列表大小和每个项目的位置。维护焦点管理,以便在项目卸载时键盘导航不会中断。小的过扫描缓冲区也有助于屏幕阅读器检测到存在更多内容。 - 滚动位置稳定性在数据动态更新时变得棘手——在当前滚动位置上方添加或删除项目可能导致突兀的跳动。
跨框架的生态系统支持
在生产环境中,你很少需要从头构建这个功能。成熟的库处理了边缘情况:
- React: TanStack Virtual(无头、灵活)和 react-window(轻量级,支持固定和可变大小,需要额外设置)
- Angular: CDK Virtual Scroll 内置于 Angular Component Dev Kit 中
- Vue: vue-virtual-scroller 涵盖了大多数常见模式
一个值得了解的 CSS 替代方案:content-visibility: auto 让浏览器在没有 JavaScript 的情况下跳过渲染屏幕外内容。它可以改善中等列表的绘制性能,但不会减少 DOM 节点数量,也不能替代大型数据集的完全虚拟化。
何时真正使用它
虚拟滚动增加了复杂性。在以下情况下值得使用:
- 你的列表超过几百个项目,并且滚动性能明显下降
- 你正在构建表格、日志查看器、信息流或电子表格样式的界面
- 内存使用是一个限制因素(移动设备、长时间运行的会话)
对于短列表,分页或简单的懒加载通常更简单且足够好。
结论
用户不需要 10 万个 DOM 节点——他们需要感觉能够滚动浏览 10 万个项目。虚拟滚动以极小的渲染成本提供了这种感觉。通过仅渲染数据集的可见切片,并在用户滚动时交换项目的进出,你可以保持 DOM 节点数量低、内存使用可预测、帧率流畅。权衡——失效的 Ctrl+F、可访问性考虑、滚动位置管理——是真实存在的但已被充分理解,而且 React、Angular 和 Vue 的库生态系统开箱即用地处理了其中大部分问题。如果你的列表大到足以损害性能,虚拟化是最有效的可用工具。
常见问题
可以,但需要额外小心。大多数虚拟化库假设单列列表布局。对于基于网格的布局,你需要一起计算可见的行和列,考虑每行的项目数。TanStack Virtual 原生支持网格虚拟化。对于其他库,你可能需要将每行视为包含多个单元格的单个虚拟化项目。
搜索引擎爬虫通常不会滚动内容,因此初始渲染之外的项目不会被索引。如果 SEO 对你的列表内容很重要,考虑分页的 HTML 输出或对爬虫友好的替代方案。如果你渲染服务器端内容,仅在客户端水合后应用虚拟化。
这通常意味着你的过扫描缓冲区太小或项目渲染太慢。增加过扫描计数,以便预渲染更多屏幕外项目。还要检查列表项是否触发昂贵的布局重新计算或同步加载图像。简化项目组件并为图像使用占位符内容可以显著减少空白帧。
可以。在导航之前,将当前的 scrollTop 值和相应的 startIndex 保存到状态或会话存储中。当用户返回时,将滚动容器位置恢复到保存的 scrollTop 值。大多数虚拟化库公开了 scrollToIndex 或 scrollToOffset 方法,使得在重新挂载时实现这一点变得简单。
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.