响应式图片:用合适的像素减少等待

等待常常花在一张过大的图上

页面首屏经常是一张通栏照片。设计稿按桌面宽屏导出,边长两千像素以上,文件达到一两兆,然后同一张图交给手机。手机视口也许只有大约四百 CSS 像素宽。即便设备像素比是 2 或 3,需要的资源宽度也远小于原图。多出来的像素要下载、解码、放进内存,却在布局时被缩回去。用户感受到的是首屏迟迟不出:文字已经排好,图片还没到,随后布局被突然出现的图撑开,阅读位置跟着跳。

响应式图片,用合适的像素减少等待
响应式图片,用合适的像素减少等待放大图解 ↗

响应式图片要解决的是:这份文档在当前视口和像素密度下,用哪一张足够清晰的资源。它不是把图做小到模糊,也不是只换一种更时髦的压缩格式。目标是让下载的像素接近屏幕真正会显示的像素,同时让格式和压缩适合照片或插画的内容。浏览器比服务器更清楚当前视口有多宽、像素比是多少,所以要把候选交给浏览器选,而不是在服务端猜一个「中等尺寸」发给所有人。

它不替代缓存,也不替代把文件放到离用户近的节点。它解决的是「有多张候选,并且选择依据写进了文档」。依据写错了,离用户再近,下的仍是那张过大的原图。

先分清分辨率切换和艺术指导

一种需求是分辨率切换。画面内容相同,只是宽度不同:手机和桌面看同一张产品图,桌面需要更多像素。用 srcset 配合宽度描述符,再配 sizes,浏览器会选。

另一种需求是艺术指导。画面内容不同:窄屏要裁成主体居中的方图,宽屏要左右留场景的横图;或者窄屏干脆不展示装饰性背景。这要用 picture 和多个 source,按媒体条件提供不同的文件。浏览器按源码顺序选用命中的来源。

不要用艺术指导的写法去做单纯的分辨率切换,否则你要维护许多份手工裁切,而其实只想省流量。也不要只给一张中等尺寸,指望样式把它拉满。那在大屏或高像素密度屏幕上会发糊,在小屏上仍然可能偏大。内容相同就走宽度候选;内容必须不同才走媒体条件。

宽度描述符和 sizes 各说什么

宽度描述符告诉浏览器每个候选文件的真实像素宽度。例如一张图导出了 480、768、1200 和 1600 四档,srcset 里写成对应的地址和 480w 这类标记。这里的数字是文件本身的宽度,不是样式里的显示宽度,也不是设备像素比。把显示宽度误写成资源宽度,浏览器会选错档。

sizes 告诉浏览器这张图在布局中大约占多宽。它是一段和媒体条件相关的长度,例如视口小于 800 像素时占满内容栏,否则占 800 像素。浏览器用这个估计值乘以设备像素比,得到需要的像素数,再从 srcset 里选足够清楚、又不过分大的那张。

若省略 sizes,浏览器会按规范使用默认值,通常相当于视口全宽。对通栏图,这还接近事实。对文章里只占半栏的图,默认值会让浏览器挑一张过大的候选。所以半栏、栅格和侧栏里的图都要写和样式一致的 sizes。样式改了栏宽,sizes 要一起改,否则选择依据是错的。评审页面时把这两处当成一对,不要只看样式文件。

密度描述符适合固定显示尺寸的图,例如一个始终显示成 64 CSS 像素的头像,可以提供普通密度和双倍密度两张。它不适合随视口伸缩的通栏图,因为你无法用一个密度描述所有宽度。宽度描述符和密度描述符不要写在同一个 srcset 里,混写会让选择规则变得难以预测。

src 仍然要留。它服务不理解 srcset 的环境,也是兜底。兜底不要选最大的那张,以免这些环境反而下载最重的文件。选中间档,并保证内容可用。src 应当是 srcset 里真实存在的一档,避免有的浏览器再额外请求一次不同地址。

picture、格式和裁切

picture 里可以先放 AVIF 或 WebP 的 source,再落到 JPEG 或 PNG 的 img。浏览器选用它支持的格式。新格式在照片上通常更小,但编码更慢,兼容性也要实测,不能只看编码工具的默认宣传。不要假设所有用户都有新格式,所以保留一种传统格式作为最后的 img。

每一档格式都应该有自己的宽度候选,而不是只把最大的图转成新格式就结束。否则支持新格式的手机会下载一张桌面尺寸的文件,体积未必比一张合适宽度的 JPEG 小。格式节省和尺寸节省是两笔账,只做其中一笔,另一笔仍会浪费在路上。

艺术指导的 source 带媒体条件,按顺序匹配。把更具体的条件放在前面,把默认放在 img。媒体条件和样式断点用同一套数字,避免样式在 768 像素切换布局,图片却在 900 像素才换裁切。换裁切时注意主体不要在边缘被切掉。文案若烧进图片里,两种裁切都要能读完,字号也不能小到在手机上无法辨认。更稳妥的做法是文字用 HTML 排,图片只负责照片。这样翻译、深色模式和字号变化都不必重新导出切图。

留出位置,避免加载时把页面顶开

响应式不等于不知道高度。只要知道宽高比,就在 img 上写 width 和 height,让浏览器在图片到达前按比例占位。样式里再让宽度不超过父元素,高度按比例自动计算。属性里的宽高表达的是资源的原始像素比,不要求等于最终显示尺寸。比例写错时,占位会和真实图片不一致,跳动仍会发生。

不同艺术指导裁切若比例不同,例如方图和横图,单一的宽高比不够表达所有断点。可以用样式按媒体条件切换宽高比,并检查切换瞬间是否仍有位移。对首屏主图,位移会直接把用户正在读的句子推走。

不要用没有尺寸的空图片,再靠脚本在加载完成后写高度。脚本晚于首次布局,跳动已经发生。占位用的骨架若高度和图片不一致,骨架消失时同样会跳。骨架的比例应和图片一致。

谁先下载,谁可以等等

首屏主图常常是最大内容元素。它不应该懒加载。loading="lazy" 适合首屏以下的图,让它们进入视口附近再下载,节省首屏带宽。把懒加载加在主图上,会故意推迟最影响体验的那一次下载。对首屏边缘的图,可以用默认加载,不要一律懒加载。判断标准是:首次进入视口时,这张图在不在画面里。模板若无法区分,就对文章正文的前几张保持默认,后面的再懒加载。

主图可以使用 fetchpriority="high",提示浏览器尽早获取。页面上只给这一张或很少的几张,不要每张图都是高优先级,否则提示没有区分度。首屏以下保持默认。优先级是提示,不是排队的保证,真正要避免的是主图被脚本和无关大图堵住。

decoding="async" 可以让解码少阻塞主线程,适合非关键图。主图是否使用,要看你更在意尽快绘出,还是更在意解码时少卡一下。在中低端手机上比较,比在开发机上比较更有意义。

仅靠页面后部的 img 发现主图,发现得偏晚。可以在文档头部预加载,并且使用 imagesrcset 和 imagesizes,与页面里的描述保持一致。若预加载的地址和最终选中的地址不同,你会下载两次。生成标记的模板应只有一处数据源,页面标签和预加载都从它渲染。预加载不要写死最大的那一档,否则响应式选择在主图上完全失效。

尺寸阶梯不用无限密

候选不是越多越好。每多一档,存储、缓存碎片和回源都会增加。常见做法是按显示场景定一条阶梯,例如 480、768、1200、1600、2000。窄屏单栏、平板、桌面内容栏、宽屏通栏,分别落在这些宽度附近,再乘上常见的设备像素比,看是否被某一档覆盖。

计算时用样式上的显示宽度乘以设备像素比。大约 320 CSS 像素宽的手机,像素比 3,大约需要 960 像素宽的资源。阶梯里若有 768 和 1200,浏览器会倾向于选不小于需求的那张。没有刚好 960 的一档是可以接受的,不必为每个整十像素都导出。差距很大时再补档。从 768 直接跳到 2000,中间的平板就会要么偏糊,要么浪费。

不要提供大于原始底片的档。放大不会增加细节,只增加体积和发虚的边缘。上传管道里若用户的图只有 1000 像素宽,就不要生成声称 2000 宽的候选,也不要在标记里谎报宽度描述符。描述符和文件真实宽度不一致时,浏览器的选择会系统性偏离。

压缩质量按内容区分。照片可以在视觉可接受的前提下用较重的压缩。带细线的界面截图和插画不适合过度有损,边缘会花,文字会糊。需要透明或硬边缘的小图可以用 PNG。照片不要用 PNG,体积会大得不划算。图标优先用 SVG,不要把图标做成一串位图档,也不要让 SVG 里嵌一张大位图,那会把矢量的好处抵消掉。

导出时去掉颜色配置以外的无用元数据,尤其是定位信息。同一张图的各档使用同一套色彩参数,避免切换档位时颜色跳一下。动画图要单独看:多帧图片的每一帧都会占流量,不要为装饰性动图提供过多宽度档。能用短视频或样式代替时,先比较体积。

构建期生成,还是请求时派生

构建时生成多档静态文件,地址带内容哈希,缓存可以设得很长。换图就是换地址。这种方式结果稳定,适合文章配图数量有限、内容不由用户随时替换的站点。构建要失败得明白:某一档导出失败时,不要静默退回原图,否则生产会在不知不觉中把大图发给所有终端。

用户上传的图片更适合在请求时按参数生成派生图:指定宽度、格式和质量,由图片服务或就近节点输出并缓存。应用模板只拼地址,不在应用服务器上同步压缩,避免拖慢页面请求。派生参数要有允许清单。宽度只能取阶梯上的值,防止每个访客请求一个独特宽度,把缓存击穿。质量参数同样限制在少数档位。未知参数拒绝,而不是按任意宽度现算。

签名参数可以防止别人用你的图片服务任意拉取外站地址,或生成超大图。服务若支持远程取图,就要限制目标地址,拒绝内网和元数据地址,并限制跳转和响应大小。这和文件上传里的远程拉取是同一类边界。

派生图的缓存键必须包含宽度、格式和质量。只按原图地址缓存,不同终端会互相拿到错误尺寸。原图更新时,缓存键或版本号要变,避免长缓存把旧图留在用户侧。处理失败时返回明确的错误,不要回源成一张未限制尺寸的原图,否则失败路径变成最贵的路径。

布局、背景和暗色模式

样式里的背景图也能写多个候选,但艺术指导和格式切换都更啰嗦,也无法使用浏览器对 srcset 的那套选择,还不利于替代文本。内容图片用 img 或 picture。背景只留给纯装饰。装饰图若体积大,同样会拖慢页面。可以用媒体查询在窄屏不加载,或改成样式渐变。

object-fit 可以在固定框架里做视觉裁切,但浏览器仍按下发的资源像素下载。用它裁切时,资源宽度仍应按框架的显示宽度来选,不要下发原图再靠裁切丢掉大半。框架本身要有确定的宽高比,否则裁切前仍会跳动。

暗色模式若使用另一张完全不同的图,会再次下载。只是微调对比度时,优先用样式,不要让每个用户下两次大图。若暗色图确实不同,考虑只在用户已经选择暗色时再请求,避免默认把两套都下下来。

高对比度或减少动态效果的系统设置也要尊重。装饰性的大图可以在减少数据或减少动态的条件下不加载。内容图仍然要在,但可以选择更小的一档。不要把「响应式」理解成只响应宽度,用户的节省流量偏好同样是条件。

怎样确认体积真的下来了

在开发者工具的网络面板里看图片请求的实际地址和传输大小,而不是只看标签上写了 srcset。把窗口拖窄,切换设备像素比,确认选中的档发生变化。若怎么拖都是最大的那张,多半是 sizes 写成了过大的值,或所有候选地址相同。

用性能记录看最大内容元素是哪一张,它的优先级,以及是否被懒加载误伤。比较「只有原图」和「使用阶梯」两种实现的传输字节。用真实的中低端手机看解码是否卡顿。桌面浏览器所在的快速机器会掩盖解码成本,也会让高像素比的选择看起来「反正很快」。

关注缓存命中。同一篇文章里,缩略图和正文图若能共用同一档地址,就可以共用缓存。参数略有差异,导致两套几乎相同的大图,节省会被抵消。列表页不要请求正文才需要的最大档。列表用小档,进入正文再请求合适的一档。

给图片写有意义的替代文本。响应式换的是像素,不是替代文本。装饰图用空的替代文本,避免读屏软件把文件名读出来。不要把一长段与画面无关的句子塞进替代文本。图片里若有必要的文字,而你还没把文字挪到 HTML 里,替代文本至少要包含这些文字,否则换格式和换裁切会把信息弄丢。

几个会让体积回到原点的错误

候选地址若都指向同一文件,浏览器没有选择余地。模板里忘记把宽度参数拼进去时,网络面板会显示多次相同地址。其中几次还可能因为缓存看起来很快,测量就被污染。生成地址的函数应当在测试里断言:不同宽度得到不同地址,且宽度落在允许清单上。

sizes 写成一个固定的很大长度,例如始终按桌面通栏估算,手机会领取大图。写 sizes 时先在窄屏看布局:这张图到底是全宽,还是扣除边距之后的内容宽。边距不属于图片的内容盒时,估计值应跟着变小。栅格在不同断点会从一列变成三列,sizes 要用对应的媒体条件写出每一段,而不是写一个平均值。

在脚本里把 src 设成原图,又在标签上留着 srcset,以最后执行的脚本为准,描述符可能被废掉。生成页面时在服务端写好标签。若必须延迟填入地址,就同时填入 srcset 和 sizes,不要只填原图地址。

懒加载与首屏动画冲突时,动画占位会被当成已经可视,导致一串图片同时开始下载。给首屏以下的容器留出真实高度,不要用零高度再突然展开。无限滚动里,未进入视口的卡片不要提前创建几十张图片标签,否则懒加载的边际会一次性触发。

描述符写了 1600,文件实际只有 800 像素宽,浏览器会以为这张图更清晰而选中它,结果既糊又打乱阶梯。导出脚本应读取文件真实宽度再写标记,不要手抄数字。

可以收成团队约定的几条

内容图使用 img。通栏和栅格图使用宽度描述符,并写与样式一致的 sizes。固定尺寸的小头像可以使用密度描述符。图标用 SVG。

候选宽度使用统一阶梯,图片服务不接受任意宽度。同时提供一种传统格式作为兜底。新格式放在 picture 的前置源里,并且每种格式都有阶梯,而不是只有最大档。

始终写 width 和 height,样式负责在父元素内收缩。只有首屏以下的图使用懒加载。主图可以预加载,但预加载的描述符必须和页面一致。

地址带内容哈希或版本,缓存按宽度、格式和质量区分。不把文字烤进需要多种裁切的位图里。上传的图不生成超过原图宽度的派生档。

这些约定的目的只有一个:让每个视口下载的像素和它能显示的像素处于同一量级,并且不因为加载过程把已经读到的内容推走。像素合适了,等待才会短下来。格式、优先级和缓存决定这些像素会不会被重复付出。标记写对了,后面的压缩才有意义;标记写错了,再快的节点也只是在更快地传送一张不该传送的大图。

见字如晤

图解