一个网站在桌面端加载完美,在手机上却白屏三秒——问题往往不在布局,而在图片。图片平均占据一个网页 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,却拿不到。
响应式图片要解决的,正是这三个彼此独立的维度:
决定需要多少物理像素,由 srcset + sizes 解决
不同屏幕看到不同裁切,由 <picture> + media 解决
新格式优先、旧格式兜底,由 <source type> 解决
谁先加载、谁后加载,由 loading / fetchpriority 解决
需要强调的是:这四个维度互相独立。你可以只做分辨率适配而不换格式,也可以只做格式降级而不做艺术裁切。把它们混为一谈,是很多项目图片方案混乱的根源。
<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 决定一切,浏览器毫无选择权
为什么不要用 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 的图 */
为什么不能「永远用最大的图」
既然 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 屏幕上确有收益 |
这张表的结论很直接:提供候选、让浏览器挑选,永远比「一律给最大」更划算。响应式图片的经济学,就是在这张表上做取舍。
桌面端看着清晰,手机端默默多花 200KB,低端机上还容易因为内存不足被系统回收。
绝大多数场景,四档就够了。
srcset 与 sizes:描述符语法完全指南
srcset 有两种描述符:w 描述符(图片的固有宽度)和 x 描述符(对应多少倍 DPR)。两者不能混用。
/* 必须配合 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" /* 无效 */
sizes
100vw,常导致下载过大的图
sizes 到底在描述什么
很多人误以为 sizes 是在「告诉浏览器该选哪张图」。其实不是。sizes 描述的是:在当前视口条件下,这张图会被渲染成多宽。浏览器拿到这个宽度,乘以 DPR 得到所需物理像素,再从 srcset 中挑选最合适的一项。
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"
浏览器是如何挑图的
规范定义了明确的算法,理解它才能写出正确的标记:
- 解析
sizes,得到当前视口下这张图的槽位宽度(CSS 像素)。 - 乘以当前设备的 DPR,得到需要的物理像素数。
- 在
srcset中寻找第一个不小于该数值的候选。 - 如果所有候选都偏小,选择最大的那一项。
- 如果有多个候选项密度相同(例如 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」「布局模式」三个变量暴露出来。拖动滑块或切换选项,你会实时看到:浏览器计算出的槽位宽度、所需物理像素、最终命中的候选图,以及这次下载大约要花多少流量。
试着做几组对比,你会立刻感受到 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> 的书写顺序至关重要。浏览器采用「首个匹配即采用」的策略,因此必须遵循两条规则:
- 按媒体条件从窄到宽:先写
max-width: 600px,再写max-width: 1024px,最后写无条件的默认项。 - 按格式从新到旧:AVIF 在前,WebP 居中,JPEG 兜底。
type="image/jpeg",再写 type="image/avif"。因为所有浏览器都支持 JPEG,第一个 source 永远命中,AVIF 永远轮不到。
media 与 sizes 的分工
初学者最容易混淆这两者。一句话区分:
media决定用哪一组候选图(用哪张画面)。sizes决定在这组候选中挑多大(用哪个分辨率)。
所以艺术方向场景下,每个 <source> 内部的 srcset 是「同一张画的不同尺寸」,而不同 <source> 之间才是「不同的画」。
现代图片格式:AVIF、WebP 与渐进增强
格式是响应式图片里性价比最高的优化手段:不需要改布局,不需要写 JS,只要多写几行 <source>,就能省下一半以上的字节。
同等主观画质下的相对体积(以 JPEG 为基准,数值为经验参考值)
| 格式 | 压缩率 | 编码成本 | 解码成本 | 建议 |
|---|---|---|---|---|
JPEG |
基准 | 极低 | 低 | 始终保留为兜底格式 |
WebP |
-30% | 中等 | 低 | 兼容性极好,可以放心作为第一梯队 |
AVIF |
-50% | 较高 | 中等偏高 | 现代浏览器首选,注意低端机解码耗时 |
JPEG XL |
-55% | 中等 | 低 | 前景很好,但浏览器支持仍在推进中 |
一个容易被忽略的点:AVIF 的解码成本显著高于 JPEG。在低端安卓机上,一张 1920px 的 AVIF 解码可能需要 40~80ms,这会直接吃掉 LCP 的时间预算。因此对于首屏最大的那张图,需要在「体积更小」与「解码更慢」之间做权衡——通常的做法是:大图优先 AVIF(省流量),超小图可以直接用 WebP 或 JPEG。
动手实验:格式降级是怎么工作的
点击下面的按钮,模拟三种不同的浏览器环境,看看 <picture> 最终会选择哪一个文件。
这个机制的美妙之处在于:它是一个纯声明式的、零 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 的早期就能读到这两个属性,因此在图片开始下载之前就已经预留好了空间。这意味着连「首帧抖动」都不会发生。
<img
src="photo.jpg"
width="1600"
height="900"
alt="照片">
img {
width: 100%; /* 覆盖属性的显示宽度 */
height: auto; /* 高度按比例自动计算 */
}
/* 结果:显示宽度自适应,宽高比恒定,布局零偏移 */
动手实验:对比两种做法的抖动幅度
点击按钮,模拟图片加载完成。左边是不预留尺寸的做法,右边使用 aspect-ratio 预留。面板下方会实时显示出「下方文字被推动了多远」。
这段文字位于图片下方。图片加载完成前,它在靠近顶部的位置;图片撑开后,它会被整个推下去。
这段文字同样位于图片下方。由于空间早已预留,图片加载只是填充了空白,文字纹丝不动。
在真实项目中,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">
fetchpriority="high"
loading="lazy",让浏览器自己安排
decoding="async" 避免解码阻塞主线程
动手实验:IntersectionObserver 驱动的懒加载
下面是一个可滚动区域,其中的卡片用 IntersectionObserver 检测可见性。向下滚动,你会看到每张卡片进入视口时才「加载」——这正是原生 loading="lazy" 背后的原理。
优先级的三个层级
- 最高:
fetchpriority="high"+<link rel="preload">,用于首屏 LCP 图。 - 默认:普通
<img>,浏览器按视口位置自行排序。 - 最低:
loading="lazy",用于视口外图片。
要注意的是,fetchpriority 和 loading 都不是「越多越好」的开关。把首屏所有图片都标成 high,等于没有优先级;把所有图片都标成 lazy,会让 LCP 大幅推迟。真正有效的做法是只给那一张最重要的图开绿灯。
CSS 侧的响应式图片:object-fit 与 object-position
HTML 负责「下载哪张图」,CSS 负责「怎么显示这张图」。当容器比例和图片固有比例不一致时,object-fit 决定了图片如何填充——这个属性在响应式设计里的重要性,被严重低估了。
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 适配。
.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");
}
}
<img>
背景图 vs <img>:如何选择
这是一个经典问题。判断标准其实很简单:这张图是内容,还是装饰?
- 是内容(需要 alt、会被搜索引擎索引、是 LCP 元素)→ 用
<img>。 - 是装饰(纯视觉氛围、无语义、可省略)→ 用 CSS 背景图。
背景图的三个劣势需要在决策时考虑进去:
- 不会被预加载扫描器发现:只有当 CSS 解析完毕后才开始下载,通常比
<img>晚 100~300ms。 - 无法直接携带替代文本:无障碍支持需要额外手段(如
role="img"+aria-label)。 - 不支持
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 全部被忽略、格式降级顺序颠倒。这些问题不会报错,只会默默浪费流量。因此必须学会验证。
/* 筛选 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 });
三个最常见的「静默失效」
sizes缺失:浏览器按100vw计算,在窄容器里会下载严重超标的图。Network 面板里表现为「明明是小图标,却下载了 1200w 的文件」。srcset中混用了 w 与 x 描述符:整个srcset会被判定为无效并忽略,浏览器退回使用src。<source>顺序错误:JPEG 写在 AVIF 前面,导致所有浏览器都命中 JPEG,格式优化完全失效。
这三类问题的共同点是:页面看起来完全正常。图片能显示,布局没崩,只是性能悄悄变差了。所以定期用脚本扫描一遍是很值得的投入。
工程化:CDN、构建流程与 srcset 生成
在真实项目里,手写 srcset 是不可持续的。你需要一套自动化流程,把「一张原图」变成「一组候选产物」,并生成对应的 HTML。
/* 优点:无需预生成,按需实时转换 */
/* 缺点:首次请求需要回源,依赖 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"
}
}
}
断点数量怎么定
一个常见的困惑是:到底要生成多少档尺寸?
- 太少:相邻档位差距过大,浪费率上升。
- 太多:构建产物爆炸,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 提前发现 */
}
可以放心作为基础方案
格式降级的标配
旧浏览器自动忽略,无害降级
必须保留 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> 换成一段完整的候选声明——然后你会发现,用户可能永远不会注意到你做了什么,而这正是它成功的样子。