← 返回技术札记
MOBILE PERFORMANCE · 2026.08.26

271 张照片,为什么没有同时留在页面里?

长相册真正昂贵的不是列表数据,而是图片下载、解码和像素内存。图派如何用视口窗口、缩略图分级与主动卸载,让手机端选片保持流畅。

客户打开选片链接时,照片数量通常不是十几张,而是几百张。对摄影师来说,271 张只是一次正常拍摄;对手机浏览器来说,它意味着 271 次网络请求、271 次图片解码,以及可能长期留在内存里的 271 份像素数据。

我们曾经把“图片已经懒加载”当作问题结束。实际测试却发现,图片滚出屏幕后如果仍然留在页面里,浏览器可能继续保留解码后的位图。页面刚打开时很顺,越往后滑越重,回到前面时还可能出现明显掉帧。

因此,图派现在做的不是简单地推迟加载,而是控制一个会随滚动移动的图片窗口:页面保留完整相册的结构,但只让视口附近的图片真正存在。

真正昂贵的不是 271 个 React 节点

一条照片记录本身很小:文件名、选中状态、几个 URL 和标签。即使把 271 条记录都放进 JavaScript 数组,也很少成为手机内存的主要压力。

昂贵的是图片被浏览器解码之后的像素数据。假设一张缩略图最终按 600 × 750 像素解码,RGBA 缓冲大约需要:

600 × 750 × 4 bytes ≈ 1.72 MiB

如果 271 张都以这个尺寸同时保留,单是理论像素量就可能超过 460 MiB。实际占用还会受到浏览器缓存、图片格式、设备像素比和解码策略影响,但数量级已经足够说明问题:压缩后的 JPEG 文件很小,并不代表显示它时也只占同样的内存。

所以我们没有把优化重点放在“少渲染几个文字节点”,而是放在三件更直接的事上:

  • 什么时候创建 <img>
  • 应该请求哪个尺寸的图片;
  • 图片离开视口后,什么时候把它从页面移除。

保留网格,移动图片窗口

完全虚拟化列表通常只渲染视口附近的整行内容,并用计算出来的高度维持滚动条。它效率很高,但图片网格还有选中状态、标签、文件名和不同屏幕列数,过早引入完整虚拟列表会增加滚动定位与状态同步的复杂度。

图派采用了更轻量的折中:每张照片的卡片容器仍然存在,并用固定的 4 / 5 比例维持网格高度;卡片内部的图片是否存在,则交给 IntersectionObserver 决定。

const io = new IntersectionObserver(([entry]) => {
  const visible = entry.isIntersecting;
  setInView(visible);
  if (!visible) setLoaded(false);
}, { rootMargin: '400px 0px' });

这里的 400px 是视口上下的预加载缓冲。用户向下滑动时,下一批图片会在真正进入屏幕前开始请求和解码;已经远离屏幕的图片则退出窗口。

渲染逻辑也不是只把图片设为透明:

{inView && (
  <img
    src={thumbnailUrl}
    decoding="async"
    onLoad={() => setLoaded(true)}
  />
)}

inView 变成 false<img> 会从 DOM 中卸载。这给浏览器释放解码位图提供了条件。我们不能保证每个浏览器都会在同一时刻立即回收内存,但相比让所有图片永久挂在页面上,内存上限会稳定得多。

这个方案保留了完整网格,所以滚动条长度和已选照片的位置不会突然变化;真正随视口移动的,是图片资源本身。

网格、预览和原图不应该用同一地址

即使只加载视口附近的图片,如果网格直接请求原图,移动端仍然会浪费带宽和解码时间。图派为不同场景选择不同级别的资源:

// 网格优先使用缩略图
picture.publicUrlThumbnail
  || picture.publicUrlSmall
  || picture.publicUrl;

// 查看大图时优先使用预览图占位,再加载原图
picture.publicUrlSmall
  || picture.publicUrl;

相册网格首先请求缩略图;只有用户打开单张查看器时,才加载更大的图片。查看器先显示较小预览并做轻微模糊,原图加载完成后再淡入。这不是为了做视觉特效,而是避免用户点击后面对一块空白区域。

网格图片还设置了 decoding="async",让浏览器尽量不要用同步解码阻塞当前绘制。它不是性能保证,但在快速滚动和多张图片同时进入缓冲区时,能给浏览器更多调度空间。

资源分级有一个容易忽略的前提:服务端必须稳定返回缩略图、小图和原图地址,客户端的回退顺序也必须明确。否则某一批历史照片缺少缩略图时,页面可能悄悄退回原图,性能问题只在特定相册中出现。

流畅不仅是帧率,也是不跳

图片尚未加载时,如果卡片高度为零,加载完成后整个页面会不断重新排版。即使帧率数字不差,客户仍会感觉照片在手指下面跳动。

因此每个网格卡片在图片出现之前就有固定的 aspect-ratio: 4 / 5 和背景占位。加载过程中显示 shimmer,图片完成解码后只改变透明度。高度不变,后面的照片也不需要重新计算位置。

响应式列数同样影响稳定性。目前选片页在手机上使用三列;随着屏幕变宽切换到四、五或六列。列数由媒体查询决定,而不是在滚动过程中反复读取窗口宽度和写入状态,减少不必要的主线程工作。

照片的选中状态则只保存为 ID 列表。点击心形时更新 selectedIds,本地暂存的也是这组 ID,而不是复制整份照片对象。筛选“全部、已选、未选”时,从原始照片数组和 ID 集合派生当前列表。这样图片是否在视口内,与照片是否被客户选中,是两套独立状态;图片卸载不会让选择结果丢失。

我们也避免把滚动位置直接绑定到高频 scroll 事件。视口判断由浏览器的 IntersectionObserver 完成,React 只在卡片跨过观察边界时更新,而不是每滚动一个像素就重新计算一次。

57 FPS 说明了什么,又没有说明什么

在一组包含 271 张照片的 iPhone 长相册测试中,我们记录到约 57 FPS 的平均滚动表现。这个结果说明当前方案在该测试设备、网络和照片集合上接近流畅目标,但它不是“所有手机都能稳定 57 FPS”的承诺。

移动端性能会受到很多条件影响:

  • 设备型号、可用内存和温度;
  • 浏览器或微信 WebView 版本;
  • 缩略图实际尺寸与压缩质量;
  • 网络延迟和缓存命中情况;
  • 用户滚动速度,以及同一时间进入缓冲区的图片数量。

我们更关注的是趋势:从相册开头滚到末尾时,帧率是否持续下降;快速往返后,内存是否只增不减;图片进入屏幕时是否频繁空白;选择照片时是否让整个网格重新渲染。

400px 缓冲也不是永远正确的常数。太小会让快速滚动追上加载过程,太大又会同时解码更多图片。它是目前测试条件下,在预加载及时性和内存占用之间的折中,后续仍需要根据真实设备数据调整。

长相册优化最后并没有依赖一个神奇的库。它来自几个朴素的约束:缩略图只做缩略图的工作,原图只在需要时出现;屏幕外的图片不长期占着位置;布局在图片到达前就已经稳定;性能数字带着测试条件一起解释。

客户不会关心视口窗口或解码内存。他们只会感觉到,滑到第 271 张时,页面是不是还和第一屏一样听话。

FIELD NOTES

更多工程记录,都在技术札记里。

只写已经发生的问题、验证过的方案,以及仍然存在的边界。

查看全部札记