图片优化全链路:格式、尺寸、懒加载与缓存策略
本文目录
内容型站点里,图片常年占页面总重量的六成以上,也是 LCP 最常见的元素。图片优化不是"把图片压小"一个动作,而是一条链路:构建期决定格式与尺寸、加载期决定下载时机与优先级、渲染期决定占位与布局稳定性、缓存期决定"第二次访问还要不要下载"。这条链上任何一环缺位,其他环节的收益都会被吃掉。这篇按链路顺序逐段展开。
先建立全链路视角
| 阶段 | 核心问题 | 手段 |
|---|---|---|
| 构建期 | 用户到底需要什么格式、什么尺寸 | WebP/AVIF 编码、多档位输出 |
| 加载期 | 这张图什么时候开始下载 | 响应式 srcset、懒加载、优先级提示 |
| 渲染期 | 图片没到时页面留多大位置 | 宽高属性、aspect-ratio 占位 |
| 缓存期 | 下次还要不要下载 | 内容 hash URL、Cache-Control |
一个实际项目里的典型失误链:AVIF 压得很棒(构建期做到位),但首屏大图加了 loading="lazy"(加载期拉垮 LCP),且没有占位高度(渲染期推高 CLS),URL 带随机参数导致每次访问重新下载(缓存期归零)。四期视角能提前暴露这种"单点最优、整体拉垮"的组合。
格式选型:JPEG、PNG、WebP 与 AVIF
没有"最好的格式",只有"这张图适合什么格式":
| 格式 | 适合 | 压缩效率 | 兼容性 |
|---|---|---|---|
| JPEG | 照片类、色彩渐变丰富的图 | 基线 | 全部浏览器 |
| PNG | 需要透明通道的截图、图标、色块图 | 差(照片场景) | 全部浏览器 |
| WebP | 照片 + 需要透明通道 | 显著优于 JPEG/PNG | 几乎全部现代浏览器 |
| AVIF | 照片,尤其大图 | 通常优于 WebP,编码更慢 | 主流新浏览器,部分旧浏览器不支持 |
上图是同一张 1600px 宽照片用三种格式重新编码的示意体积(实际数值随图片内容浮动,建议用自己的首屏图实测)。两条选型规则:
- 照片一律从 JPEG 迁到 WebP 起步:收益确定性最高、兼容性风险最小;AVIF 作为增强层,给得起编码时间再上。
- 别用位图承载图形:单色图标、logo、插画优先 SVG,体积小且任意分辨率清晰;真实照片才进入上面的位图选型。
Picture 标签与回退链
多格式靠 <picture> 的源探测实现——浏览器从上往下找第一个自己支持的源:
<picture>
<source type="image/avif" srcset="/cover-800.avif 800w, /cover-1600.avif 1600w">
<source type="image/webp" srcset="/cover-800.webp 800w, /cover-1600.webp 1600w">
<img src="/cover-800.jpg" alt="文章封面" width="800" height="450" loading="lazy">
</picture>
三条纪律:
- 最优格式放最前,
<img>永远放兜底格式,删掉<img>整个结构直接失效。 - 兜底
<img>上必须带全所有属性(alt、width/height、loading),它是最终渲染的元素,<source>只是换src。 type属性别写错,浏览器按声明探测而不是下载后嗅探,写错会直接跳过该源。
另一种做法是服务端按请求头 Accept 协商返回格式,配合图片 CDN 时更常用,省一层标签复杂度;代价是缓存 key 要包含格式变体,否则 WebP 响应可能被缓存给不支持 WebP 的浏览器。
响应式尺寸:srcset 与 sizes
响应式图片的本质:让浏览器知道"我要显示多大、屏幕密度多少",由它选最合适的一档下载。
<img
src="/cover-800.avif"
srcset="/cover-400.avif 400w, /cover-800.avif 800w, /cover-1200.avif 1200w, /cover-1600.avif 1600w"
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 60vw, 800px"
alt="文章封面"
width="800" height="450"
>
拆开看:
srcset用宽度描述符(800w是图片实际宽度,不是 CSS 像素)声明档位。档位应该覆盖真实断点:取 2–3 个视口典型宽度 × DPR 上限。DPR 为 3 的手机渲染 400px 宽的图,会选 1200w 那档——这就是"桌面上很清晰、手机上反而糊"或反过来"手机下载了桌面大图"两种事故的共同来源。sizes告诉浏览器这张图最终以多大尺寸显示。浏览器拿sizes值 × DPR 去匹配srcset档位。sizes写错(比如全页面都写死100vw),浏览器永远选最大档,前面的档位白做。<img>必须显式声明 CSS 尺寸或保留 width/height 属性,否则档位计算与实际布局对不上。
sizes 的常见写法是媒体条件列表:(max-width: 600px) 100vw 表示小屏上图占满视口;最后一段是不带条件的兜底值,必须有。
装饰图用 CSS 背景,内容图用 img
srcset、sizes、懒加载、宽高占位这些能力全部只在 <img> 上生效,CSS 背景图一项都享受不到——这就是分界线的由来:
| 场景 | 放哪 | 理由 |
|---|---|---|
| 属于内容的图(文章配图、商品图、用户头像) | <img> | 响应式档位、懒加载、alt、SEO、LCP 优化全依赖它 |
| 纯装饰(氛围底纹、渐变、hover 态小图) | CSS 背景 | 与内容无关,可以随 CSS 缓存与复用,不占 DOM 描述 |
把内容图写成背景图的代价很直接:无法声明尺寸导致 CLS、无法进搜索引擎图片索引、无法响应式选档、屏幕阅读器拿不到任何信息。生产环境排查时,用"这张图算不算内容"一条判断就够。
懒加载:原生与 IntersectionObserver 的协作
loading="lazy":默认选择
<img src="/cover-800.avif" alt="封面" width="800" height="450" loading="lazy" decoding="async">
原生懒加载零脚本成本,浏览器自带"提前量"(接近视口即开始下载),生产环境应该作为默认值,两条铁律:
- 首屏 LCP 图片绝不能 lazy,还要配
fetchpriority="high"——这是 CWV 一文的重点结论。 - 尺寸属性必须齐:懒加载图片先不占资源,没有 width/height 时布局完全塌陷,图片到位后把内容推走,CLS 直接恶化。
decoding="async" 让解码不阻塞主线程,对大图滚动流畅度有感提升,可以无脑加。
IntersectionObserver:需要自定义策略时
原生懒加载的问题是不够可控:提前量由浏览器决定,无法做"分批进入动画""与埋点曝光共用观察器"这类逻辑。需要控制时用 IntersectionObserver 自己实现:
const IMAGE_SOURCE = 'data-src';
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const img = entry.target as HTMLImageElement;
img.src = img.getAttribute(IMAGE_SOURCE) ?? img.src; // 真实地址提前放 data-src
img.removeAttribute(IMAGE_SOURCE);
observer.unobserve(img); // 换完就停,别重复观察
}
},
{ rootMargin: '200px 0px' } // 距视口 200px 就开始下载,避免滚到才请求的可见延迟
);
document.querySelectorAll(`img[${IMAGE_SOURCE}]`).forEach((img) => observer.observe(img));
自实现的两个必做事项:
- 无 JS 回退:把原始地址留在
data-src的同时,确认爬虫路径(<noscript>内放真实src的图,或 SSR 直接输出真实src再由 JS 接管)。内容型站点图片进搜索结果,是流量的重要来源。 - 重复进入处理:
unobserve防止已加载的图被再次赋值;切页面时要disconnect并重建观察器(SPA 路由常见泄漏点)。
懒加载和无限加载是两件事
懒加载图片优化的是"已有内容的下载时机";无限加载(触底加载更多数据)优化的是"内容从无到有"。列表场景经常两个都要:新一批数据进来时,其中图片仍按懒加载策略处理。两者区别与组合方式在站内 IntersectionObserver 一文里有完整讨论。
图片 CDN 与构建期管线
图片数量超过几十张后,手工导出多格式多档位不现实,统一管线有两种。
图片 CDN:原图上传,URL 参数按需实时变换(示例使用保留域名):
<img src="https://cdn.example.com/img/hero.jpg?width=800&format=auto&quality=75"
srcset="https://cdn.example.com/img/hero.jpg?width=400&format=auto 400w,
https://cdn.example.com/img/hero.jpg?width=800&format=auto 800w"
sizes="80vw" alt="首屏图">
| 参数 | 作用 | 注意 |
|---|---|---|
width | 按需缩放尺寸 | 与 srcset 档位对齐 |
format=auto | 按请求 Accept 返回 WebP/AVIF | 缓存 key 必须含格式,防错误缓存 |
quality | 有损压缩质量 | 照片 70–85 是常用区间,需肉眼验收 |
fit | 裁切模式 | 填充型布局用,注意别裁掉主体 |
构建期处理:构建时用工具(如 Node 生态的 sharp)批量产出固定档位,适合图片集合稳定、追求极致体积的场景;Astro 一类框架也内置了资产管线,从构建产物里直接输出多档位与格式。两者取舍:CDN 灵活但引入运行时依赖与成本,构建期可控但图片变更要走构建流程。
占位与防抖动
图片加载完成前,页面要给它留好位置,否则就是 CLS 事故现场:
/* 用宽高比盒子占位,容器先占住空间 */
figure {
position: relative;
aspect-ratio: 16 / 9; /* 与真实图片宽高比一致 */
width: 100%;
margin: 0;
background: linear-gradient(135deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
overflow: hidden;
}
figure img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover; /* 比例有少量偏差时裁切而不是变形 */
}
三个进阶点:
- 底色/渐变占位优于纯白块:用户对"正在加载"有预期,对"凭空多出一块内容"没有预期。
- 模糊占位(低分辨率小图或 LQIP)适合媒体密集页:先渲染一张 20px 宽的极小图并放大模糊,视觉过渡更平滑;代价是每张图多一个请求,首屏外的图不必上。
- 宽高比必须来自真实图片:用 CMS 输出的宽高比或构建期测量,硬编码 16:9 而实际是 4:3 的图,占位本身就是错的。
缓存策略:让图片只下载一次
下载过的图片不再下载,是全链路收益最大、最容易被忽略的一段。前提是 URL 稳定且内容可变:
# 带内容 hash 的 URL + 强缓存:内容变了 URL 变,URL 不变内容就一定没变
https://cdn.example.com/img/cover.a3f9c2e1.avif
Cache-Control: public, max-age=31536000, immutable
# HTML 本身不能这样缓存
Cache-Control: no-cache
| 场景 | Cache-Control 策略 | 理由 |
|---|---|---|
| 内容 hash 命名的图片/字体/JS/CSS | public, max-age=31536000, immutable | URL 变更即失效,可放心缓存一年 |
| 无 hash 的可变图片(用户头像等) | public, max-age=86400 + 协商缓存回源验证 | 时效与新鲜度折中 |
| HTML 文档 | no-cache(可协商) | 保证用户拿到引用新 hash 资源的入口 |
几个生产环境常见的缓存翻车点:
- URL 里拼了随机数或时间戳当缓存穿透手段,顺手把 CDN 缓存也穿透了,每访问每下载。
immutable只对"内容变了 URL 必变"的资源安全,用在会原地覆盖的 URL 上等于把更新永久卡死。- CDN 层命中率要单独看:回源带宽是最贵的部分,命中率掉 10% 就是真金白银。
- 配置
stale-while-revalidate可以让 CDN 在后台刷新的同时回旧内容,图片更新场景常用。
上线前检查清单
| # | 检查项 | 通过标准 |
|---|---|---|
| 1 | 格式 | 内容图走 WebP/AVIF + 回退链,图形用 SVG |
| 2 | 档位 | srcset 覆盖真实断点,sizes 与实际布局宽度一致 |
| 3 | 首屏图 | LCP 图无 lazy、有尺寸声明、优先级提示到位 |
| 4 | 占位 | 每张内容图有 width/height 或 aspect-ratio 占位 |
| 5 | 缓存 | 内容 hash URL + immutable,二次访问零下载 |
| 6 | 回退 | JS 失败路径(SSR 直出 src)与老浏览器格式可用 |
| 7 | 体积 | 单张首屏图有预算上限并进 CI 检查 |
清单里第 1–4 条是每张新图上线时的人工检查,第 5–7 条交给构建和 CI 自动化;图片是持续进站的内容,靠人工盯不住,自动化是唯一能长期成立的防线。
注意事项与边界
- AVIF 编码比 WebP 慢得多,构建管线里要控制超时与并发,别让图片任务拖垮 CI。
object-fit: cover会裁图,图文编辑器场景优先contain或显式裁切参数,避免用户精心排版的内容被切掉。- 深色模式与暗色占位:占位底色跟随主题切换,否则暗色主题下一块亮灰占位非常刺眼。
- 图片 CDN 的
format=auto依赖 Accept 头,某些老 WebView 或代理会改写 Accept,保留 JPG 兜底路径。 - 懒加载 + 虚拟列表组合时,图片进入视口的时机由列表滚动驱动,
rootMargin要覆盖快速滚动的提前量,否则滚动中出白屏。 - 别把首屏图优化的赌注全押在格式上:一张 2MB 的 AVIF 依然是 2MB 级别的 LCP 资源,尺寸档位优先于格式。
总结
图片全链路优化是一组环环相扣的动作:构建期用 WebP/AVIF 和多档位把"要传的字节"压到最小;加载期用 srcset/sizes 让浏览器选对档、用懒加载把视口外的图挡在门外、用优先级提示保住 LCP;渲染期用宽高属性与 aspect-ratio 把位置提前留好;缓存期用内容 hash URL 加 immutable 让图片只下载一次。按链路检查每一环,比任何单点优化技巧都有效。
参考
- MDN: loading 属性
- MDN: srcset 与响应式图片
- MDN: picture 元素
- MDN: aspect-ratio
- MDN: Cache-Control
- 站内参考:IntersectionObserver 实战(懒加载、无限加载与有效曝光共用一套观察器设计)
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。