一、 一张图从 URL 到屏幕,中间发生了什么?
浏览器或客户端拿到图片后,大致会经历:
- 下载:从网络或磁盘读到压缩数据(JPEG/PNG/WebP 等)
- 解码(decode):把压缩数据解压成位图(像素数据),才能交给 GPU 合成、绘制
- 上传 / 合成:把位图交给渲染管线,和页面一起画出来用户常说的「卡顿」,很多时候出在 第 2 步解码:解码是 CPU 密集型工作,若在首帧要显示的那一瞬间才解码,主线程可能被占住,出现掉帧、白屏、闪烁。
二、 什么是「预解码」?
预解码 = 在图片真正要出现在视口里之前,提前完成 decode,把「压缩数据 → 位图」这一步做完。
典型场景:列表里下一屏的头像、封面,轮播下一张,路由切换前,提前加载详情页大图。
目标:显示时只做合成,不再临时 decode,首帧更稳。
三、 什么是「缓存」?和预解码怎么配合?
这里的「缓存」通常指:把已经 decode 好的位图(或可直接绘制的对象)留在内存里,下次再用同一张图时,跳过 decode。
浏览器通常按 URL(+ 缓存策略) 复用底层资源:
- 网络层:HTTP 缓存,同 URL 不必再下
- 解码层:同 URL 的位图可能已在内存里,可见
<img>再绑这个 URL 时更快
可以粗分为几层(不同平台命名不一,思想类似):
| 层级 | 存什么 | 作用 |
|---|---|---|
| HTTP / 磁盘缓存 | 压缩文件字节 | 省下载,不解码 |
| 内存中的压缩缓冲 | 原始 image 数据 | 少一次 IO,仍可能要 decode |
| 解码后缓存(decoded cache) | 解压后的位图 / 纹理 | 省 decode,占内存大 |
预解码 + 缓存 的组合就是:
提前 decode → 把结果放进 decoded cache → 需要显示时直接取用。
四、img.decode() / createImageBitmap 是什么?
img.decode()- 作用:在你真正把图片显示出来前,先让浏览器完成解码(decoded cache)。
- 是
HTMLImageElement的方法。
TypeScript
const img = new Image();
img.src = url;
await img.decode(); // 解码完成
createImageBitmap(...)
- 把图片源(Blob/Image/Canvas 等)变成
ImageBitmap。 ImageBitmap是更"渲染友好"的位图对象,常用于 Canvas、OffscreenCanvas。- 好处:通常解码路径更高效、可异步,适合大图/频繁渲染场景。
TypeScript
const bitmap = await createImageBitmap(blob);
// canvas.drawImage(bitmap, ...)
或者
const bitmap = await createImageBitmap(img);
ctx.drawImage(bitmap, 0, 0);
简单记:
decode() 更偏 <img> 展示链路优化;
createImageBitmap() 更偏 Canvas/位图渲染链路优化。
📌 示例:
TypeScript
// 缓存:同一资源只 decode/create 一次
// key: src
const decodedCacheRef = useRef<Map<string, Promise<void>>>(new Map());
const bitmapCacheRef = useRef<Map<string, Promise<ImageBitmap>>>(new Map());
async function preloadWithDecode(src: string) {
if (!decodedCacheRef.current.has(src)) {
const task = (async () => {
const img = new Image();
img.src = src;
await img.decode();
})();
decodedCacheRef.current.set(src, task);
}
await decodedCacheRef.current.get(src);
}
async function preloadWithBitmap(src: string) {
// 实际可二选一:decode 或 bitmap。这里演示 bitmap 缓存思路。
if (!bitmapCacheRef.current.has(src)) {
const task = (async () => {
const res = await fetch(src);
const blob = await res.blob();
// async 函数执行完毕后,返回值会自动包装成 Promise。
return await createImageBitmap(blob);
})();
bitmapCacheRef.current.set(src, task);
}
await bitmapCacheRef.current.get(src);
}
// 预热全部资源(演示用;真实项目可只预热相邻档位)
useEffect(() => {
RESOURCES.forEach((r) => {
preloadWithDecode(r.src).catch(console.error);
preloadWithBitmap(r.src).catch(console.error);
});
}, []);
// 缩放中不切图;仅在"停止移动后"延迟切换
useEffect(() => {
if (isMoving) return;
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(async () => {
if (targetLevel === activeLevel) return;
const next = RESOURCES.find((r) => r.level === targetLevel);
if (!next) return;
// 切换前保证它已经解码好(不卡显示)
await preloadWithDecode(next.src);
setDisplaySrc(next.src);
setActiveLevel(targetLevel);
}, SWITCH_DELAY_MS);
return () => {
if (timerRef.current) window.clearTimeout(timerRef.current);
};
}, [isMoving, targetLevel, activeLevel]);
流程是:
- 从
RESOURCES找到目标档位next - 用
next.src去decodeCache查- 有:复用缓存中
- 无:新建 Promise 并存入缓存
await完成后,再setDisplaySrc(next.src)
五、 为什么值得做?代价是什么?
收益
- 减少滚动、切页时的 jank(卡顿)
- 大图、多图列表体验更顺滑
- 动画、转场时减少「闪一下再清晰」
代价
- 内存:解码后体积 ≈
宽 × 高 × 每像素字节数(例如 RGBA 约 4 字节/像素),一张 2000×2000 的图就可能十几 MB - CPU / 电量:预解码本身要花时间,预太多会抢主线程或其它任务
- 策略复杂:缓存多大、何时淘汰(LRU)、弱网/低内存设备要不要关,都要设计
所以这不是「越多越好」,而是 在合适时机、对合适图片、用有上限的缓存。
六、 常见实现思路(跨平台概念)
Web
HTMLImageElement.decode()(或createImageBitmap):在显示前异步 decode,避免 decode 和首帧绘制挤在同一帧。<link rel="preload" as="image">:更早开始下载,不等于 decode,但常和预解码一起用。- 浏览器内部往往还有 decoded image cache:同 URL、同尺寸的图片 decode 一次后复用(具体由引擎管理,开发者不能直接完全控制)。
移动端(iOS / Android)
- 系统图片库(如 UIImage、Bitmap、Coil/Glide)常有 内存缓存里分「原始数据」和「已解码 Bitmap」。
- 列表滚动库常做:即将进入屏幕的 item 提前 decode + 滑出屏幕的释放。
七、 和「预加载」的区别
| 概念 | 主要解决 |
|---|---|
| 预加载(preload / prefetch) | 更早下载数据 |
| 预解码(pre-decode) | 更早解压成位图 |
| 解码缓存 | 同一张图不要重复 decode |
只预加载不解码:网络好了,但第一次显示仍可能卡一下。
只缓存不预解码:第二次快,第一次仍可能卡。
三者常一起用:preload → pre-decode → 放进 decoded cache → 显示。
八、 实践里常见的策略(原则)
- 只预解码「高概率会显示」的图(下一张、下一屏、hover 预览),避免内存爆炸。
- 控制并发:同时 decode 太多张会抢 CPU。
- 按尺寸 decode:显示只要 200px 宽,就不要先 decode 4000px 原图再缩小(很多库支持 sampleSize / resize 后再缓存)。
- 有淘汰策略:LRU、内存警告时清空 decoded cache,保留磁盘缓存即可。
- 弱网 / 低内存设备可降级:只做下载缓存,不做或少量预解码。
九、 一句话总结
图片预解码缓存 = 在需要显示之前就把图片从压缩格式解压成位图,并把解压结果留在内存里复用,从而用更多内存换更少的显示瞬间卡顿;需要和预加载、尺寸控制、缓存上限一起设计,不能无脑全开。