在 iPad 和移动端设备上实现虚拟滚动,面临的挑战远比桌面端复杂。本文记录了一个包含 6000+ 张照片的画廊项目从多次迭代中总结出的经验教训。

虚拟滚动的核心思路很简单:只渲染可视区域内的元素。但在移动端,触控惯性滚动、不定高的列表项、以及有限的内存,让这项技术充满了陷阱。

陷阱一:滚动速度与加载节奏

在 iOS Safari 上,用户在照片列表中快速滑动时会产生大量惯性滚动事件。如果每触发一次滚动事件就加载一批新的缩略图,服务端瞬间承受的并发请求可能达到数十甚至上百个。

解决方案是引入滚动速度锁:在快速滚动期间暂停加载新图片,只渲染占位符。我们从三个维度锁定了快速滚动状态:

这种多层锁机制在测试中效果显著——API 请求量降低了约 85%。

陷阱二:DOM 创建与销毁的开销

许多虚拟滚动库的实现方式是:滚动出可视范围的元素直接移除 DOM,滚动进入时再重新创建。对于包含图片的列表项,这意味着一系列昂贵的操作:创建 DOM 节点、设置属性、请求图片、解码、绘制。

我们的方案是用 Map 缓存已渲染的 DOM 元素。元素滚出可视范围时不销毁,而是标记为 visibility: hidden(保留在 DOM 中),滚回时直接复用。关键点是必须使用 visibility: hidden 而非 display: none——前者保留元素的布局空间,后者会导致浏览器重排,并且会中断 IntersectionObserver 的监听。

陷阱三:IntersectionObserver 的时序问题

IntersectionObserver 是浏览器提供的用于检测元素可见性的 API,理论上非常适合虚拟滚动场景。但在快速滚动时,观察器的回调可能在元素已经离开视口后才触发,导致"看到空白后再加载"的闪烁。

解决方案是预加载缓冲区:在可视区域下方额外提前渲染 20 行元素。这样当用户滚动到这些行时,它们的图片已经在加载或已加载完成。对于向上滚动,同样在可视区域上方保留缓冲。

陷阱四:服务端过载防护

即使前端做了优化,某些边缘场景仍可能产生大量请求。我们的后端(Flask)和 Nginx 配置了多层防护:

层级策略效果
Nginxlimit_req 200r/s防止瞬间流量尖峰
Flask API分页 100 条/页单次响应控制在 26KB
缩略图服务端缓存重复请求零开销
前端速度锁 + 预加载减少 85% 无效请求

数据指标

经过上述优化后的实际效果:

虚拟滚动的优化是一个系统工程,需要前端渲染策略、网络请求策略、服务端 API 设计三方面的协同配合。单独优化任何一个环节,效果都有限。