梦亦同趋

如何解决大图“流式加载”带来的视觉割裂

图片明明已经加载完成,但在页面上仍然会“从上到下一点点出现”,看起来像在流式加载。

#如何解决大图“流式加载”带来的视觉割裂

在做网页全屏背景图时,我遇到一个很影响体验的问题:

图片明明已经加载完成,但在页面上仍然会“从上到下一点点出现”,看起来像在流式加载。尤其是大图或网络稍慢时,这种效果会让页面显得很廉价,即使实际上资源已经提前加载过。

于是我尝试了几种不同的优化方式,想弄清楚一件事:如何让图片在“可见的瞬间”是完整的,而不是渐进式出现?

#一、第一反应:用预加载解决“加载过程可见”

最直觉的做法是预加载:

javascript
const img = new Image(); img.onload = () => { showPage(); }; img.src = wallpaperUrl;

逻辑很简单:等图片下载完成,再展示页面。按理说,这样 <img> 渲染时应该是“瞬间出现”的。

但实际效果仍然存在“从上到下逐步显现”。

#为什么预加载没有解决问题?

关键点在于:onload 只保证“网络下载完成”,但不保证“解码完成”或“渲染完成”。

浏览器图片管线大致是:

下载完成 → 解码 → 栅格化 → 绘制

而“逐步出现”的现象,往往发生在解码 + 绘制阶段,而不是下载阶段。

#二、第二步尝试:控制解码行为(decoding)

我尝试使用:

html
<img src="wallpaper.jpg" decoding="sync" />

理论上它可以让浏览器在解码时同步执行,避免异步调度带来的闪烁。但结果是:依然存在渐进式显示问题。

#为什么 decoding 没用?

查阅 MDN 后可以确认:decoding 控制的是“解码是否阻塞渲染调度”,而不是“图片是否逐步显示”。也就是说它影响的是主线程是否等待 decode 完成,而不是图片是否渐进渲染。

而“从上到下出现”的现象,本质通常来自:

  • progressive JPEG 渐进编码
  • 浏览器分块解码 + 多帧绘制策略

因此,decoding 并不能控制“图片第一次可见的完整性”。

#三、第三步尝试:延迟插入 DOM(decode API)

下一步思路变成:既然问题出在 decode + render,那就等它完全 ready 再插入 DOM。

jsx
const [ready, setReady] = useState(false); useEffect(() => { const img = new Image(); img.src = wallpaperUrl; img.decode().then(() => { setReady(true); }); }, []); return ( <img src={wallpaperUrl} style={{ opacity: ready ? 1 : 0 }} /> );

MDN 对 decode() 的定义是:Promise resolves once image data is ready to be used。理论上这是最接近“完全准备好再显示”的 API。

但问题出现了:为什么还是有延迟?甚至出现了“页面先空一段时间,再突然出现图片”的情况。并且 Network 面板发现:图片请求发生了第二次

#四、真正的问题:缓存并没有被你以为的方式复用

进一步排查后发现关键点:开发环境(Vite dev server)返回:

Cache-Control: no-cache
ETag: ...

这里的重点是:no-cache 并不是“不缓存”,而是“每次使用前必须验证”。

#浏览器实际行为

<img> 加载资源时:

  1. 先发条件请求(If-None-Match)
  2. 等服务器返回 304
  3. 再读取本地缓存
  4. 再 decode + render

所以即使你“预加载过”,也会发生一次额外的网络往返。这就导致:

  • preload 和 <img> 并没有真正共享“即时可用状态”
  • 体验上仍然有“空白等待”

#五、最终方案:绕过网络与缓存 —— Blob URL

既然问题在于网络 + 缓存验证链路无法被完全消除,那就直接跳过它。

#核心思路

把图片从“URL资源”变成“内存资源”:

javascript
const res = await fetch(wallpaperUrl); const blob = await res.blob(); const blobUrl = URL.createObjectURL(blob);

然后:

jsx
<img src={blobUrl} />

#这一步发生了什么?

Blob URL 的特点是:

  • 不发 HTTP 请求
  • 不走 cache layer
  • 不触发 revalidation
  • 直接从内存读取二进制

#结果

  • 无流式显示
  • 无渐进渲染过程可见
  • 图片瞬间出现

#代价

当然也有代价:

  • 占用内存
  • 无法复用 HTTP cache
  • 需要手动 URL.revokeObjectURL
  • CDN 优化完全失效

本质是:用“内存确定性”换“网络不确定性”

#六、回头看:浏览器图片渲染到底在做什么?

#可以把整个流程抽象成:

URL

Cache lookup(可能触发 revalidate)

Network fetch

Image decode

Rasterize / GPU upload

Paint