响应式图片技术

张玥 2026年9月18日 阅读时间 30分钟
响应式图片 srcset picture AVIF LCP
CSS高级技巧响应式设计之响应式图片技术

一个网站在桌面端加载完美,在手机上却白屏三秒——问题往往不在布局,而在图片。图片平均占据一个网页 50% 以上的字节数,却常常只用一行 <img src="hero.jpg"> 就交给了浏览器。真正的响应式设计,不只是让排版随屏幕收放,更是让同一张视觉内容,在不同设备上下载不同尺寸、不同格式、不同裁切的文件。本篇 30 分钟长文,从设备像素比讲起,穿过 srcset、sizes、<picture>、现代图片格式、懒加载、CLS 与 LCP,配合五个可实时操作的实验台,帮你把「响应式图片」从一句口号,变成一套可落地、可测量、可维护的工程方案。

响应式图片的本质:一次编写,处处适配

在 @media 查询普及之后,开发者很快发现一个尴尬的事实:布局可以响应,图片却不行。CSS 能把两栏变成一栏,但 <img> 里的那个 URL 依然固若金汤——320px 宽的手机和 4K 显示器下载的是同一个文件。

这带来的后果有三层:

  • 带宽浪费:手机下载了一张 1920px 宽的图,实际只显示 320px,浪费约 85% 的字节。
  • 清晰度不足:反过来,桌面端在高 DPR 屏幕上显示一张 800px 的图,被拉伸到 1600 物理像素,糊成一片。
  • 格式落后:老旧浏览器只能吃 JPEG,新浏览器明明支持体积小一半的 AVIF,却拿不到。

响应式图片要解决的,正是这三个彼此独立的维度:

维度一 · 分辨率
视口宽度 × DPR

决定需要多少物理像素,由 srcset + sizes 解决

维度二 · 构图
艺术方向

不同屏幕看到不同裁切,由 <picture> + media 解决

维度三 · 格式
渐进增强

新格式优先、旧格式兜底,由 <source type> 解决

维度四 · 时机
加载优先级

谁先加载、谁后加载,由 loading / fetchpriority 解决

需要强调的是:这四个维度互相独立。你可以只做分辨率适配而不换格式,也可以只做格式降级而不做艺术裁切。把它们混为一谈,是很多项目图片方案混乱的根源。

<!-- 最原始:一个 URL 打天下 -->
<img src="hero.jpg" alt="首页大图">

<!-- 只解决分辨率 -->
<img
  src="hero-960.jpg"
  srcset="hero-480.jpg 480w,
        hero-960.jpg 960w,
        hero-1440.jpg 1440w"

  sizes="(max-width: 600px) 100vw, 960px"
  alt="首页大图">

<!-- 同时解决分辨率与格式 -->
<picture>
  <source type="image/avif" srcset="hero.avif 1x, hero@2x.avif 2x">
  <source type="image/webp" srcset="hero.webp 1x, hero@2x.webp 2x">
  <img src="hero.jpg" alt="首页大图">
</picture>
旧世界 一个 src 决定一切,浏览器毫无选择权
新世界 提供一组候选,把选择权交给浏览器
关键心态 你负责「提供选项」,浏览器负责「做决定」
常见误区 用 JS 手动判断屏幕宽度再改 src —— 会破坏预加载扫描

为什么不要用 JavaScript 切换 src

很多早期方案会监听 resize,然后用脚本替换 img.src。这个做法在今天已经被明确判定为反模式:

  • 破坏预加载扫描器:浏览器在解析 HTML 时会提前扫描 srcset 并开始下载。JS 改 src 意味着下载要等到脚本执行之后才开始。
  • 响应滞后:用户拖动窗口时,图片会在几百毫秒后才切换,出现明显的闪烁。
  • 重复下载:从小图切到大图时,小图的流量已经花掉了。
  • 无 JS 就崩:脚本失败时页面只剩占位图。

正确的心智模型是:在 HTML 里一次性把候选集声明完整,剩下的交给浏览器。浏览器知道当前视口宽度、DPR、网络状况,甚至知道用户是否开启了「节省流量」模式——它比你的 JS 更清楚该下载哪一张。

像素的物理现实:CSS 像素、设备像素与 DPR

要理解 srcset 为什么用「w 描述符」而不是「像素宽度」,必须先厘清三个概念。

/* 三个概念,一句话区分 */

CSS 像素 (px)
/* 你在 CSS 里写的那个 px,布局的计量单位 */
/* 与设备无关,320px 在手机上就是"一屏少一点" */

设备像素 (physical pixel)
/* 屏幕上真实发光的物理点 */
/* 屏幕参数里说的 1080×2400 就是这个 */

DPR = 设备像素 / CSS 像素
/* iPhone SE: 2x iPhone 15 Pro Max: 3x */
/* 普通显示器: 1x MacBook Retina: 2x */

/* 关键公式 */
所需图片宽度 = 槽位 CSS 宽度 × DPR

/* 举例:一个 400px 宽的图片槽位 */
/* 1x 屏需要 400px 的图 */
/* 2x 屏需要 800px 的图 */
/* 3x 屏需要 1200px 的图 */
1x 普通显示器、低端安卓机 · 400px 图即可
2x Retina MacBook、iPhone SE · 需要 800px
3x iPhone Pro 系列、部分旗舰安卓 · 需要 1200px
结论 同一段 CSS,在不同设备上需要的图片宽度最多差 3 倍

为什么不能「永远用最大的图」

既然 3x 屏需要 1200px,那干脆所有设备都发 1920px 的图不就行了?

这个想法忽略了两件事:字节成本与解码成本。一张 1920px 的 JPEG 大约是 300KB,而 480px 的只有 30KB。对于一个月流量 10GB 的手机用户,一张图就吃掉 3%。更隐蔽的是解码成本:浏览器需要把 JPEG 解码成 1920×1080×4 字节的位图,也就是约 8.3MB 内存——一张图而已。一屏十张图就是 83MB,移动端 Safari 会毫不留情地杀掉这个页面。

宽度 典型体积(JPEG q80) 解码后内存 适用场景
480w ≈ 30 KB ≈ 0.5 MB 手机单列、卡片缩略图
768w ≈ 62 KB ≈ 1.4 MB 平板、手机横屏
1024w ≈ 98 KB ≈ 2.4 MB 小笔记本、内容区主图
1440w ≈ 175 KB ≈ 4.7 MB 桌面端内容区全宽
1920w ≈ 280 KB ≈ 8.3 MB 超大屏、全屏 Hero
2560w ≈ 460 KB ≈ 14.7 MB 仅在 2K/4K 屏幕上确有收益

这张表的结论很直接:提供候选、让浏览器挑选,永远比「一律给最大」更划算。响应式图片的经济学,就是在这张表上做取舍。

反例
全站统一输出 2000px 宽的大图。
桌面端看着清晰,手机端默默多花 200KB,低端机上还容易因为内存不足被系统回收。
正解
按内容区最大宽度设定上限(如 1440px),再按 480 / 768 / 1024 / 1440 生成四档。
绝大多数场景,四档就够了。

srcset 与 sizes:描述符语法完全指南

srcset 有两种描述符:w 描述符(图片的固有宽度)和 x 描述符(对应多少倍 DPR)。两者不能混用。

/* ① w 描述符:声明图片的固有像素宽度 */
/* 必须配合 sizes 使用,否则浏览器按 100vw 计算 */
<img
  src="photo-800.jpg"
  srcset="photo-400.jpg 400w,
        photo-800.jpg 800w,
        photo-1200.jpg 1200w"

  sizes="(max-width: 600px) 100vw, 800px"
  alt="示例图片">

/* ② x 描述符:声明相对 DPR 的倍数 */
/* 适合固定尺寸的图片,如 logo、头像 */
<img
  src="avatar.png"
  srcset="avatar.png 1x, avatar@2x.png 2x, avatar@3x.png 3x"
  alt="用户头像">

/* ❌ 错误:w 与 x 混用,浏览器会直接忽略整个 srcset */
srcset="a.jpg 400w, b.jpg 2x" /* 无效 */
w 描述符 用于流式布局中的内容图,配合 sizes
x 描述符 用于固定尺寸的图,如头像、图标、logo
sizes 省略 等价于 100vw,常导致下载过大的图
经验法则 内容图用 w,装饰图用 x,别混着来

sizes 到底在描述什么

很多人误以为 sizes 是在「告诉浏览器该选哪张图」。其实不是。sizes 描述的是:在当前视口条件下,这张图会被渲染成多宽。浏览器拿到这个宽度,乘以 DPR 得到所需物理像素,再从 srcset 中挑选最合适的一项。

/* sizes 的完整语法:媒体条件 + 长度,逗号分隔 */
sizes="(max-width: 600px) 100vw, (max-width: 1024px) 50vw, 800px"

/* 读法: */
/* 视口 ≤ 600px → 图宽 = 100vw */
/* 视口 ≤ 1024px → 图宽 = 50vw */
/* 其他情况 → 图宽 = 800px(默认项,必须放最后) */

/* 常见布局的 sizes 写法 */

/* 1. 内容区最大 1200px,两侧留白 */
sizes="min(90vw, 1200px)"

/* 2. 手机单列、桌面两列 */
sizes="(max-width: 768px) 100vw, 50vw"

/* 3. 三列网格 + 间距 */
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"

/* 4. 带侧栏的正文区 */
sizes="(max-width: 900px) 100vw, calc(100vw - 320px)"

/* 5. 固定尺寸卡片 */
sizes="320px"

浏览器是如何挑图的

规范定义了明确的算法,理解它才能写出正确的标记:

  1. 解析 sizes,得到当前视口下这张图的槽位宽度(CSS 像素)。
  2. 乘以当前设备的 DPR,得到需要的物理像素数。
  3. 在 srcset 中寻找第一个不小于该数值的候选。
  4. 如果所有候选都偏小,选择最大的那一项。
  5. 如果有多个候选项密度相同(例如 2x 和 1000w 在特定条件下等价),取更靠前的一个。

注意第 3 步是「不小于」,不是「最接近」。所以候选集的粒度会直接影响浪费率:如果只有 480w 和 1440w 两档,一台需要 700px 的设备会被迫下载 1440w,浪费超过一倍。

反例:粒度太粗
srcset="img-480.jpg 480w, img-1440.jpg 1440w"
需要 700px 的设备只能拿到 1440w,浪费约 105% 的流量。
正解:四档覆盖
480w / 768w / 1024w / 1440w
相邻档位相差不超过 1.6 倍,最大浪费控制在 60% 以内。

一个实用的经验:相邻档位的宽度比不要超过 1.6~1.7 倍。480 → 768 是 1.6 倍,768 → 1024 是 1.33 倍,1024 → 1440 是 1.4 倍,这个梯度就比较理想。

交互实验室:srcset 选择模拟器

下面这个实验台把「视口宽度」「DPR」「布局模式」三个变量暴露出来。拖动滑块或切换选项,你会实时看到:浏览器计算出的槽位宽度、所需物理像素、最终命中的候选图,以及这次下载大约要花多少流量。

1280 × 800
1152px 槽位 CSS 宽度

试着做几组对比,你会立刻感受到 sizes 写错有多贵:

  • 把布局从「内容区」切换到「卡片 320px」,同一台设备需要的图片从 1920w 降到 768w,流量差了近 4 倍。
  • 把 DPR 从 2x 调到 3x,命中档位会立刻跳一级——这就是为什么高端手机需要更大的图。
  • 把视口拉到 1920px,无论怎么切布局,最终都会撞到候选集的上限 1920w,说明「候选集的上限应该等于内容区的最大宽度」。

<picture> 与艺术方向:不只是格式降级

很多人以为 <picture> 的唯一用途是「AVIF / WebP 降级」。其实它承担了两件事:格式降级和艺术方向裁切。后者常常被忽略,却是响应式设计里视觉质量的关键。

什么是艺术方向(Art Direction)

一张 16:9 的横幅大图,在桌面上展示完整场景,在手机竖屏上如果简单等比缩小,主体会变得极小、无法辨认。正确的做法是换一张重新裁切过的图——手机端用竖构图、主体居中、裁掉两侧的环境。

这不是「同一张图的不同分辨率」,而是不同的画面内容。srcset 无法表达这种需求,必须用 <picture> + media。

<!-- 艺术方向 + 格式降级,一次搞定 -->
<picture>
  <!-- 手机竖屏:裁切过的竖构图 -->
  <source
    media="(max-width: 600px)"
    type="image/avif"
    srcset="hero-portrait-480.avif 480w,
            hero-portrait-960.avif 960w"

    sizes="100vw">

  <!-- 平板:中等裁切 -->
  <source
    media="(max-width: 1024px)"
    type="image/avif"
    srcset="hero-square-800.avif 800w,
            hero-square-1200.avif 1200w"

    sizes="100vw">

  <!-- 桌面:宽幅构图 -->
  <source
    type="image/avif"
    srcset="hero-wide-1440.avif 1440w,
            hero-wide-1920.avif 1920w"

    sizes="100vw">

  <!-- 兜底:必须有 img,否则什么都不显示 -->
  <img
    src="hero-wide-1440.jpg"
    alt="团队协作的场景"
    width="1440"
    height="810">
</picture>
第一步 浏览器从上到下检查每个 <source>
第二步 同时满足 media 条件与 type 支持才采用
第三步 一旦命中就停止,后续 <source> 全部忽略
兜底 全部不命中时使用 <img> 的 src

顺序决定一切

<source> 的书写顺序至关重要。浏览器采用「首个匹配即采用」的策略,因此必须遵循两条规则:

  1. 按媒体条件从窄到宽:先写 max-width: 600px,再写 max-width: 1024px,最后写无条件的默认项。
  2. 按格式从新到旧:AVIF 在前,WebP 居中,JPEG 兜底。
反例:顺序颠倒
先写 type="image/jpeg",再写 type="image/avif"。
因为所有浏览器都支持 JPEG,第一个 source 永远命中,AVIF 永远轮不到。
正解:从新到旧
AVIF → WebP → JPEG。支持的浏览器走最优路径,不支持的自然退化到下一项。

media 与 sizes 的分工

初学者最容易混淆这两者。一句话区分:

  • media 决定用哪一组候选图(用哪张画面)。
  • sizes 决定在这组候选中挑多大(用哪个分辨率)。

所以艺术方向场景下,每个 <source> 内部的 srcset 是「同一张画的不同尺寸」,而不同 <source> 之间才是「不同的画」。

现代图片格式:AVIF、WebP 与渐进增强

格式是响应式图片里性价比最高的优化手段:不需要改布局,不需要写 JS,只要多写几行 <source>,就能省下一半以上的字节。

JPEG 100%
WebP ≈ 68%
AVIF ≈ 46%
JPEG XL ≈ 42%

同等主观画质下的相对体积(以 JPEG 为基准,数值为经验参考值)

格式 压缩率 编码成本 解码成本 建议
JPEG 基准 极低 低 始终保留为兜底格式
WebP -30% 中等 低 兼容性极好,可以放心作为第一梯队
AVIF -50% 较高 中等偏高 现代浏览器首选,注意低端机解码耗时
JPEG XL -55% 中等 低 前景很好,但浏览器支持仍在推进中

一个容易被忽略的点:AVIF 的解码成本显著高于 JPEG。在低端安卓机上,一张 1920px 的 AVIF 解码可能需要 40~80ms,这会直接吃掉 LCP 的时间预算。因此对于首屏最大的那张图,需要在「体积更小」与「解码更慢」之间做权衡——通常的做法是:大图优先 AVIF(省流量),超小图可以直接用 WebP 或 JPEG。

动手实验:格式降级是怎么工作的

点击下面的按钮,模拟三种不同的浏览器环境,看看 <picture> 最终会选择哪一个文件。

<source srcset="hero.avif" type="image/avif"> <source srcset="hero.webp" type="image/webp"> <img src="hero.jpg" alt="首页大图" width="1440" height="810">
当前环境 → 实际下载:AVIF 格式

这个机制的美妙之处在于:它是一个纯声明式的、零 JS 的特性探测。浏览器在解析 HTML 时就知道自己支持什么,不需要先下载一个探测脚本再决定加载哪张图——这正是它对性能友好的根本原因。

格式转换放在哪一层

  • 构建时:用 Sharp / Squoosh / imagemin 在打包阶段生成多格式多尺寸产物。适合图片数量可控的静态站点。
  • 运行时(CDN):由图片 CDN 根据 Accept 请求头动态返回 AVIF 或 WebP。适合 UGC 与海量图片场景。
  • 两者结合:常用图预生成,用户上传图走 CDN 实时转换。

无论选哪种,前端要做的都是同一件事:在 HTML 里把候选集写完整。至于这些文件是怎么来的,是构建脚本还是 CDN,HTML 并不关心。

布局稳定性:宽高比、CLS 与图片抖动

图片加载慢本身不算灾难,图片加载完成后把页面内容顶下去才是。这个现象叫布局偏移(Layout Shift),是 Core Web Vitals 中 CLS 指标的扣分大户。

想象一下:用户正在阅读第三段文字,突然一张图片加载完成,把这段文字推到了屏幕外面。用户点了个链接,点错了。这种体验的伤害,远超图片晚出现的那几百毫秒。

/* ❌ 反例:没有预留尺寸 */
<img src="photo.jpg" alt="照片">
/* 加载前高度为 0,加载后突然撑开 → 下方内容全体下移 */

/* ✅ 方案一:写上 width / height 属性(推荐) */
<img
  src="photo.jpg"
  alt="照片"
  width="1600"
  height="900">
/* 现代浏览器会自动按宽高比预留空间 */

/* ✅ 方案二:CSS aspect-ratio(需要自定义比例时) */
.hero-image {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  background: #e2e8f0;
}

/* ✅ 方案三:响应式宽高比 + 媒体查询 */
.thumb {
  aspect-ratio: 16 / 9;
}
@media (max-width: 600px) {
  .thumb { aspect-ratio: 4 / 3; }
}
首选 width / height 属性,零成本、零 CSS
次选 aspect-ratio,适合需要覆盖属性比例的场景
注意 宽高属性要写固有比例,不用写实际显示尺寸
禁止 用 padding-top 百分比黑魔法,可维护性差

为什么推荐 width / height 属性

很多人以为写了 width="1600" height="900" 会让图片显示成 1600px 宽。其实从 Chrome 79 开始,浏览器会对这两个属性做特殊处理:它们只用于计算宽高比,实际显示尺寸仍然由 CSS 决定。

更妙的是,浏览器在解析 HTML 的早期就能读到这两个属性,因此在图片开始下载之前就已经预留好了空间。这意味着连「首帧抖动」都不会发生。

/* 属性给固有比例,CSS 给显示尺寸,两者互不冲突 */
<img
  src="photo.jpg"
  width="1600"
  height="900"
  alt="照片">

img {
  width: 100%;        /* 覆盖属性的显示宽度 */
  height: auto;        /* 高度按比例自动计算 */
}

/* 结果:显示宽度自适应,宽高比恒定,布局零偏移 */

动手实验:对比两种做法的抖动幅度

点击按钮,模拟图片加载完成。左边是不预留尺寸的做法,右边使用 aspect-ratio 预留。面板下方会实时显示出「下方文字被推动了多远」。

未预留尺寸

这段文字位于图片下方。图片加载完成前,它在靠近顶部的位置;图片撑开后,它会被整个推下去。

文字偏移量:0 px
aspect-ratio 预留
16 : 9 占位

这段文字同样位于图片下方。由于空间早已预留,图片加载只是填充了空白,文字纹丝不动。

文字偏移量:0 px

在真实项目中,CLS 的合理目标是小于 0.1。图片是造成布局偏移的头号来源,而解决它往往只需要多写两个 HTML 属性——这是投入产出比最高的优化之一。

懒加载与优先级:loading、fetchpriority 与 LCP

响应式图片解决「下载多大的图」,懒加载解决「什么时候下载」。两者配合,才能让首屏更快、流量更省。

loading="lazy" 的适用边界

原生懒加载从 Chrome 77 开始支持,现在已是基线特性。但它不是万能的,用错地方反而会拖慢首屏。

<!-- ✅ 适合懒加载:首屏之外的图片 -->
<img src="article-3.jpg"
     loading="lazy"
     decoding="async"
     width="800" height="450"
     alt="文章配图">

<!-- ❌ 不适合懒加载:首屏 LCP 图 -->
<img src="hero.jpg" loading="lazy">
/* 浏览器会延迟到布局完成后才发起请求,LCP 直接推迟数百毫秒 */

<!-- ✅ 首屏 LCP 图:高优先级 + 提前发现 -->
<img
  src="hero-1440.avif"
  srcset="hero-768.avif 768w, hero-1440.avif 1440w"
  sizes="100vw"
  fetchpriority="high"
  decoding="sync"
  width="1440" height="810"
  alt="首页主视觉">

<!-- 关键图还可以用 preload 提前发现 -->
<link rel="preload" as="image"
     imagesrcset="hero-768.avif 768w, hero-1440.avif 1440w"
     imagesizes="100vw"
     fetchpriority="high">
首屏图 不加 lazy,加 fetchpriority="high"
次屏图 加 loading="lazy",让浏览器自己安排
异步解码 decoding="async" 避免解码阻塞主线程
注意 懒加载只对「视口外」的图有效,首屏图加了是反效果

动手实验:IntersectionObserver 驱动的懒加载

下面是一个可滚动区域,其中的卡片用 IntersectionObserver 检测可见性。向下滚动,你会看到每张卡片进入视口时才「加载」——这正是原生 loading="lazy" 背后的原理。

等待中
等待中
等待中
等待中
等待中
等待中
等待中
等待中

优先级的三个层级

  1. 最高:fetchpriority="high" + <link rel="preload">,用于首屏 LCP 图。
  2. 默认:普通 <img>,浏览器按视口位置自行排序。
  3. 最低:loading="lazy",用于视口外图片。

要注意的是,fetchpriority 和 loading 都不是「越多越好」的开关。把首屏所有图片都标成 high,等于没有优先级;把所有图片都标成 lazy,会让 LCP 大幅推迟。真正有效的做法是只给那一张最重要的图开绿灯。

CSS 侧的响应式图片:object-fit 与 object-position

HTML 负责「下载哪张图」,CSS 负责「怎么显示这张图」。当容器比例和图片固有比例不一致时,object-fit 决定了图片如何填充——这个属性在响应式设计里的重要性,被严重低估了。

示例图片 4:3

fill:默认值。图片被拉伸以填满容器,忽略原始宽高比,会产生变形。除非容器比例与图片完全一致,否则几乎不该用它。

五种取值的语义

取值 行为 是否变形 典型用途
fill 拉伸填满,忽略宽高比 会 几乎不用
contain 完整显示,留出空白 不会 产品图、图表、需要完整可见的内容
cover 填满容器,超出部分裁切 不会 卡片封面、头像、Hero 图
none 按原始尺寸显示,不缩放 不会 需要像素级精确控制的场景
scale-down none 与 contain 中较小的那个 不会 图标、Sprite,避免被放大

object-position:控制裁切的位置

使用 cover 时,图片必然会有部分被裁掉。object-position 决定保留哪一部分——这在艺术方向场景下非常关键。

/* 默认居中裁切 */
.cover {
  object-fit: cover;
  object-position: center center;
}

/* 保留顶部(适合人像,避免裁掉头部) */
.portrait {
  object-fit: cover;
  object-position: center top;
}

/* 百分比定位 */
.custom {
  object-position: 30% 70%;
}

/* 响应式:不同断点保留不同区域 */
.hero-img {
  object-fit: cover;
  object-position: center center;
}
@media (max-width: 600px) {
  .hero-img {
    /* 手机竖屏时,保留画面左侧的主体 */
    object-position: 25% center;
  }
}

object-fit 与艺术方向的取舍

你可能会想:既然 object-fit: cover 能裁切,那还需要 <picture> 吗?

需要,而且两者解决的是不同问题:

  • object-fit: cover 是机械裁切:它只知道「填满容器,多出来的剪掉」,不知道画面里主体在哪。
  • <picture> 是人工裁切:由设计师为每个断点单独调整构图,主体位置、留白、视觉重心都是刻意安排的。

在实践中,两者常常配合使用:用 <picture> 提供不同构图比例的图片,再用 object-fit: cover 兜住剩余的比例差异。这样即使容器比例因为某种原因改变了,画面也不会变形。

反例
用 <img width="100%"> 让图片自由缩放。
在极宽的容器里,图片高度会失控增长,把首屏内容全部推到折叠线以下。
正解
给容器固定宽高比(aspect-ratio),图片 object-fit: cover。
无论屏幕多宽,首屏高度都可控,构图也不会崩。

背景图的响应式:image-set() 与媒体查询

CSS 背景图没有 srcset 那样的多候选机制,但 CSS 提供了 image-set() 函数,让背景图也能享受 DPR 适配。

/* ① image-set():按分辨率选择背景图 */
.hero {
  background-image: image-set(
    url("hero-1x.avif") 1x,
    url("hero-2x.avif") 2x,
    url("hero-3x.avif") 3x
  )
;
  background-size: cover;
  background-position: center;
}

/* ② 现代语法:带 type() 声明格式,实现降级 */
.hero-modern {
  background-image: image-set(
    url("hero.avif") type("image/avif"),
    url("hero.webp") type("image/webp"),
    url("hero.jpg") type("image/jpeg")
  )
;
}

/* ③ 兼容旧写法 */
.hero-legacy {
  background-image: url("hero.jpg");
  background-image: -webkit-image-set(
    url("hero-1x.jpg") 1x,
    url("hero-2x.jpg") 2x
  )
;
}

/* ④ 媒体查询:按视口切换不同的画 */
.banner {
  background-image: url("banner-wide.jpg");
  background-size: cover;
}
@media (max-width: 600px) {
  .banner {
    background-image: url("banner-portrait.jpg");
  }
}

/* ⑤ 结合容器查询,让背景图跟随组件宽度 */
.card { container-type: inline-size; }
@container (min-width: 400px) {
  .card__cover {
    background-image: url("cover-wide.jpg");
  }
}
image-set() 只解决 DPR 适配,不能做艺术方向
媒体查询 可以做艺术方向,但无法感知 DPR
两者结合 媒体查询选画面,image-set 选分辨率
重要提醒 背景图不会触发预加载扫描,LCP 图请用 <img>

背景图 vs <img>:如何选择

这是一个经典问题。判断标准其实很简单:这张图是内容,还是装饰?

  • 是内容(需要 alt、会被搜索引擎索引、是 LCP 元素)→ 用 <img>。
  • 是装饰(纯视觉氛围、无语义、可省略)→ 用 CSS 背景图。

背景图的三个劣势需要在决策时考虑进去:

  1. 不会被预加载扫描器发现:只有当 CSS 解析完毕后才开始下载,通常比 <img> 晚 100~300ms。
  2. 无法直接携带替代文本:无障碍支持需要额外手段(如 role="img" + aria-label)。
  3. 不支持 loading="lazy":需要自己用 IntersectionObserver 实现。

因此,凡是出现在首屏、且承担内容表达作用的图片,都应该用 <img>。背景图留给那些「删掉也不影响理解」的装饰元素。

无障碍:alt 文本的四种正确写法

响应式图片的候选集可以很复杂,但无论浏览器最终选中哪一张,替代文本只有一个。这是规范明确规定的:<picture> 内所有 <source> 不允许有 alt,替代文本只能写在 <img> 上。

/* ① 有信息量的内容图:描述内容,不描述"图片" */
<img src="chart.png"
     alt="2026年第二季度销售额环比增长23%">
/* ❌ 不要写 "一张图表" */

/* ② 纯装饰图:alt 留空,让屏幕阅读器跳过 */
<img src="divider.png" alt="">
/* 注意:是空字符串,不是省略 alt 属性 */

/* ③ 图片本身就是链接:描述链接目标 */
<a href="/product/123">
  <img src="thumb.jpg" alt="查看机械键盘 K870 详情">
</a>

/* ④ 图片附近已有文字说明:用空 alt 避免重复朗读 */
<figure>
  <img src="photo.jpg" alt="">
  <figcaption>团队在 2026 年开发者大会现场</figcaption>
</figure>
有信息 描述图片传达的信息,而非画面本身
装饰性 写 alt="",让辅助技术忽略
功能性 描述点击后会发生什么
危险 省略 alt 属性 → 屏幕阅读器会朗读文件名

艺术方向下 alt 的写法

当不同断点使用了不同构图的图片时,alt 应该描述的是共同的语义,而不是某一张具体画面的细节。

<!-- 不同构图,同一语义 -->
<picture>
  <source media="(max-width: 600px)" srcset="team-portrait.avif">
  <img src="team-wide.avif"
       alt="前端团队在年度总结会上合影">
</picture>

/* ✅ 描述的是"团队合影"这件事 */
/* ❌ 而不是"左边三个人右边两个人"这种具体构图 */

还需要注意的三点

  • 不要重复 alt 和相邻文本:如果图片下方已经有图注,alt 写空即可,否则屏幕阅读器会把同一句话读两遍。
  • 不要在 alt 里写「图片」「照片」:屏幕阅读器已经知道这是一个图片元素。
  • 长描述用 aria-describedby:复杂图表、信息图需要详细说明时,把长文本放在页面上并用 ID 关联,而不是塞进 alt。

测量与验证:怎么知道方案真的生效了

响应式图片最容易出现的问题是「写了很多,但没生效」——比如 sizes 写错、srcset 全部被忽略、格式降级顺序颠倒。这些问题不会报错,只会默默浪费流量。因此必须学会验证。

/* ① DevTools Network 面板:看实际下载了哪个文件 */
/* 筛选 Img 类型,观察 Size 与文件名后缀 */
/* 如果 320px 视口下载了 1920w 的图,说明 sizes 写错了 */

/* ② Elements 面板:查看 currentSrc */
/* 选中 img 元素,在 Properties 里找 currentSrc */
/* 这就是浏览器最终决定使用的那张图 */

/* ③ 用 JS 批量检查(可在控制台执行) */
document.querySelectorAll('img').forEach((img) => {
  const natural = img.naturalWidth;
  const displayed = img.clientWidth * window.devicePixelRatio;
  if (natural > displayed * 1.8) {
    console.warn('图片过大:', img.currentSrc, natural, Math.round(displayed));
  }
  if (natural < displayed * 0.9) {
    console.warn('图片过小:', img.currentSrc, natural, Math.round(displayed));
  }
});

/* ④ 监听 LCP,确认首屏图加载及时 */
new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lcp = entries[entries.length - 1];
  console.log('LCP:', lcp.startTime.toFixed(0) + 'ms', lcp.url);
}).observe({ type: 'largest-contentful-paint', buffered: true });

/* ⑤ 监听布局偏移,定位抖动元素 */
new PerformanceObserver((list) => {
  list.getEntries().forEach((entry) => {
    if (!entry.hadRecentInput) {
      console.log('布局偏移:', entry.value, entry.sources);
    }
  });
}).observe({ type: 'layout-shift', buffered: true });
LCP 目标 < 2.5s,首屏大图是关键
CLS 目标 < 0.1,图片必须预留尺寸
浪费率 naturalWidth / (clientWidth × DPR) 应接近 1
格式命中 检查 currentSrc 后缀是否为 AVIF / WebP

三个最常见的「静默失效」

  1. sizes 缺失:浏览器按 100vw 计算,在窄容器里会下载严重超标的图。Network 面板里表现为「明明是小图标,却下载了 1200w 的文件」。
  2. srcset 中混用了 w 与 x 描述符:整个 srcset 会被判定为无效并忽略,浏览器退回使用 src。
  3. <source> 顺序错误:JPEG 写在 AVIF 前面,导致所有浏览器都命中 JPEG,格式优化完全失效。

这三类问题的共同点是:页面看起来完全正常。图片能显示,布局没崩,只是性能悄悄变差了。所以定期用脚本扫描一遍是很值得的投入。

工程化:CDN、构建流程与 srcset 生成

在真实项目里,手写 srcset 是不可持续的。你需要一套自动化流程,把「一张原图」变成「一组候选产物」,并生成对应的 HTML。

/* ① 图片 CDN 的 URL 参数模式 */
/* 优点:无需预生成,按需实时转换 */
/* 缺点:首次请求需要回源,依赖 CDN 能力 */

/* Cloudinary 风格 */
https://res.cloudinary.com/demo/image/upload/
  w_800,f_auto,q_auto/hero.jpg
/* f_auto 会根据 Accept 头自动返回 AVIF / WebP */

/* imgix 风格 */
https://example.imgix.net/hero.jpg?w=800&auto=format&q=75

/* ② 构建时生成多尺寸产物(Sharp 示例) */
const sharp = require('sharp');
const widths = [480, 768, 1024, 1440];

for (const w of widths) {
  await sharp('src/hero.jpg')
    .resize({ width: w })
    .avif({ quality: 60 })
    .toFile(`dist/hero-${w}.avif`);

  await sharp('src/hero.jpg')
    .resize({ width: w })
    .webp({ quality: 75 })
    .toFile(`dist/hero-${w}.webp`);
}

/* ③ 生成 manifest,供模板渲染 */
{
  "hero": {
    "width": 1440,
    "height": 810,
    "sources": {
      "avif": "hero-{w}.avif",
      "webp": "hero-{w}.webp",
      "jpeg": "hero-{w}.jpg"
    }
  }
}
静态图 构建时预生成,零运行时成本
UGC 图 CDN 实时转换,需要缓存策略配合
混合方案 高频图预生成,长尾图走 CDN
注意 CDN 转换会增加首字节时间,缓存命中率是关键

断点数量怎么定

一个常见的困惑是:到底要生成多少档尺寸?

  • 太少:相邻档位差距过大,浪费率上升。
  • 太多:构建产物爆炸,CDN 缓存碎片化,收益递减。

实践中的推荐配置是 4~5 档,并且按照内容的实际显示宽度来定,而不是机械地按设备分类:

场景 推荐档位 说明
内容区主图 480 / 768 / 1024 / 1440 覆盖手机到桌面内容区全宽
卡片缩略图 240 / 400 / 640 固定宽度,用 x 描述符也可以
全屏 Hero 768 / 1280 / 1920 / 2560 首屏主视觉,上限要足够大
头像 48 / 96 / 144 用 x 描述符更直观

最后一条工程建议:把候选集上限设为「内容区最大宽度 × 2」。因为很少有真实设备的有效 DPR 会超过 2,再大的图收益极小,却会显著增加构建时间与存储成本。

兼容性与渐进增强

响应式图片相关特性的支持度整体非常好,但少数现代特性仍有差异。下面是关键特性的现状与退化策略。

/* 兼容性速查 */
/* <img srcset> / sizes —— 全平台(IE 除外) */
/* <picture> / <source> —— 全平台(IE 除外) */
/* loading="lazy" —— Chrome 77+ / Safari 15.4+ */
/* decoding="async" —— Chrome 65+ / Safari 15.4+ */
/* fetchpriority —— Chrome 102+ / Safari 17.2+ */
/* WebP —— 全平台(Safari 14+) */
/* AVIF —— Chrome 85+ / Safari 16.4+ */
/* image-set() with type() —— Chrome 113+ / Safari 17+ */
/* aspect-ratio —— Chrome 88+ / Safari 15+ */
/* @container —— Chrome 105+ / Safari 16+ */

/* 渐进增强:确保基础路径永远可用 */
<picture>
  <source type="image/avif" srcset="hero.avif">
  <source type="image/webp" srcset="hero.webp">
  <!-- 无论前面是否命中,这一行都是最后的保障 -->
  <img src="hero.jpg" alt="..." width="1440" height="810">
</picture>

/* 特性检测:让 JS 走正确的分支 */
if ('loading' in HTMLImageElement.prototype) {
  /* 原生懒加载可用 */
} else {
  initIntersectionObserverLazyLoad();
}

if ('fetchPriority' in HTMLImageElement.prototype) {
  /* 可以设置优先级 */
} else {
  /* 退化:用 preload 提前发现 */
}
srcset / sizes
98%+

可以放心作为基础方案

picture / source
98%+

格式降级的标配

loading=lazy
92%+

旧浏览器自动忽略,无害降级

AVIF
85%+

必须保留 WebP / JPEG 兜底

值得庆幸的是,响应式图片的大部分特性都属于「不支持时优雅降级」的类型。浏览器不认识 srcset,就用 src;不认识 loading,就正常加载;不认识 image/avif,就跳到下一个 <source>。这意味着你可以放心地在今天就用上它们。

不要这样做
用 navigator.userAgent 判断浏览器类型再决定加载哪种格式。
UA 字符串可以伪造、更新滞后,而且会让服务端无法缓存同一份 HTML。
应该这样做
用 <picture> + type 让浏览器自己声明能力。
同一份 HTML 可以服务所有浏览器,缓存友好,零维护成本。

响应式图片最佳实践清单

把全文的核心结论浓缩成一份可以贴在工位上的清单。

  • 每张内容图都写 width 与 height:用固有比例,不是显示尺寸,零成本防 CLS。
  • 首屏之外的图加 loading="lazy":首屏图不加,反而要加 fetchpriority="high"。
  • 内容图用 w 描述符,固定尺寸图用 x 描述符:两者绝不混用。
  • 写了 srcset 就必须写 sizes:否则浏览器按 100vw 计算,白白浪费流量。
  • sizes 要反映真实布局宽度:用 min()、calc() 描述内容区,而不是照抄断点。
  • 相邻档位宽度比不超过 1.6 倍:四到五档覆盖绝大多数场景。
  • 候选集上限设为内容区最大宽度 × 2:再大收益极低。
  • 格式按 AVIF → WebP → JPEG 顺序排列:从新到旧,最后必有 <img> 兜底。
  • 艺术方向用 <picture> + media:媒体条件从窄到宽书写。
  • 用 object-fit: cover 兜住比例差异:配合 object-position 控制裁切重心。
  • 背景图只用于装饰:LCP 元素必须是 <img>,否则无法被预加载扫描器发现。
  • alt 描述信息而非画面:装饰图写 alt="",绝不省略属性。
  • 用 DevTools 验证 actual 下载文件:检查 currentSrc 与 naturalWidth。
  • 在真机上用 3G 网络测试:模拟器无法反映真实的解码与内存压力。
<!-- 一套可直接复用的响应式图片模板 -->

<!-- ① 通用内容图:分辨率 + 格式 + 尺寸预留 -->
<picture>
  <source
    type="image/avif"
    srcset="img-480.avif 480w,
            img-768.avif 768w,
            img-1024.avif 1024w,
            img-1440.avif 1440w"

    sizes="(max-width: 768px) 100vw, min(90vw, 1200px)">
  <source
    type="image/webp"
    srcset="img-480.webp 480w,
            img-768.webp 768w,
            img-1024.webp 1024w,
            img-1440.webp 1440w"

    sizes="(max-width: 768px) 100vw, min(90vw, 1200px)">
  <img
    src="img-1024.jpg"
    alt="内容描述"
    width="1440" height="810"
    loading="lazy"
    decoding="async">
</picture>

<!-- ② 首屏 LCP 图:高优先级 + 立即发现 -->
<link rel="preload" as="image"
     imagesrcset="hero-768.avif 768w, hero-1440.avif 1440w"
     imagesizes="100vw"
     fetchpriority="high">

<img
  src="hero-1440.avif"
  srcset="hero-768.avif 768w, hero-1440.avif 1440w"
  sizes="100vw"
  alt="首页主视觉"
  width="1440" height="810"
  fetchpriority="high"
  decoding="sync">

<!-- ③ 配套 CSS -->
img, picture {
  display: block;
  max-width: 100%;
  height: auto;
}

.media-cover {
  aspect-ratio: 16 / 9;
  object-fit: cover;
  object-position: center;
  background: #e2e8f0;
  border-radius: var(--radius);
}

响应式图片从来不是「加一个 srcset」这么简单,它是一整套从素材生产、标记编写、CSS 兜底到测量验证的完整链条。链条上任何一环缺失,都会让优化效果大打折扣:srcset 写对了但 sizes 写错,等于没写;格式降级做了但没有尺寸预留,CLS 照样扣分。

但好消息是,这套技术栈的每一项都已经足够成熟、足够稳定。你不需要等待任何新特性,今天就可以把它完整地落地到项目里。真正要做的,只是从下一张图片开始,把 <img src> 换成一段完整的候选声明——然后你会发现,用户可能永远不会注意到你做了什么,而这正是它成功的样子。