在 iPad 和移动端设备上实现虚拟滚动,面临的挑战远比桌面端复杂。本文记录了一个包含 6000+ 张照片的画廊项目从多次迭代中总结出的经验教训。
虚拟滚动的核心思路很简单:只渲染可视区域内的元素。但在移动端,触控惯性滚动、不定高的列表项、以及有限的内存,让这项技术充满了陷阱。
陷阱一:滚动速度与加载节奏
在 iOS Safari 上,用户在照片列表中快速滑动时会产生大量惯性滚动事件。如果每触发一次滚动事件就加载一批新的缩略图,服务端瞬间承受的并发请求可能达到数十甚至上百个。
解决方案是引入滚动速度锁:在快速滚动期间暂停加载新图片,只渲染占位符。我们从三个维度锁定了快速滚动状态:
- 触控状态锁:手指触摸屏幕期间(
touchstart到touchend),持久锁定加载。 - 惯性滚动锁:手指离开后继续惯性滑动的 1 秒内,保持锁定。
- 鼠标/触控板锁:桌面端的
scroll事件设置 200ms 的防抖锁。
这种多层锁机制在测试中效果显著——API 请求量降低了约 85%。
陷阱二:DOM 创建与销毁的开销
许多虚拟滚动库的实现方式是:滚动出可视范围的元素直接移除 DOM,滚动进入时再重新创建。对于包含图片的列表项,这意味着一系列昂贵的操作:创建 DOM 节点、设置属性、请求图片、解码、绘制。
我们的方案是用 Map 缓存已渲染的 DOM 元素。元素滚出可视范围时不销毁,而是标记为 visibility: hidden(保留在 DOM 中),滚回时直接复用。关键点是必须使用 visibility: hidden 而非 display: none——前者保留元素的布局空间,后者会导致浏览器重排,并且会中断 IntersectionObserver 的监听。
陷阱三:IntersectionObserver 的时序问题
IntersectionObserver 是浏览器提供的用于检测元素可见性的 API,理论上非常适合虚拟滚动场景。但在快速滚动时,观察器的回调可能在元素已经离开视口后才触发,导致"看到空白后再加载"的闪烁。
解决方案是预加载缓冲区:在可视区域下方额外提前渲染 20 行元素。这样当用户滚动到这些行时,它们的图片已经在加载或已加载完成。对于向上滚动,同样在可视区域上方保留缓冲。
陷阱四:服务端过载防护
即使前端做了优化,某些边缘场景仍可能产生大量请求。我们的后端(Flask)和 Nginx 配置了多层防护:
| 层级 | 策略 | 效果 |
|---|---|---|
| Nginx | limit_req 200r/s | 防止瞬间流量尖峰 |
| Flask API | 分页 100 条/页 | 单次响应控制在 26KB |
| 缩略图 | 服务端缓存 | 重复请求零开销 |
| 前端 | 速度锁 + 预加载 | 减少 85% 无效请求 |
数据指标
经过上述优化后的实际效果:
- 首屏加载:6000+ 张照片的列表,首屏仅加载 100 条元数据(约 26KB),0.11 秒完成。
- 内存占用:缓存的 DOM 节点控制在 150 个以内(可视 30 + 缓冲 20 × 2 + 额外缓冲),总内存占用约 15MB。
- 流畅度:在 iPad Air(M1)上,即使快速滑动,帧率保持 60fps,无肉眼可见的空白闪烁。
- 服务端负载:平均 QPS 从优化前的 350 降至 50 以下。
虚拟滚动的优化是一个系统工程,需要前端渲染策略、网络请求策略、服务端 API 设计三方面的协同配合。单独优化任何一个环节,效果都有限。