图片懒加载实战:原生属性和它的三个坑

一、懒加载不是"加个属性"就完事

图片懒加载在今天的浏览器里已经几乎是免费的:给 <img> 加一个 loading="lazy" 就行。但真正把它用好的站点并不多,常见的两类翻车是:首屏大图也被懒加载,导致最大内容绘制时间反而变慢;以及只写了 loading="lazy" 却没写宽高,图片加载时把下面的内容整片顶下去,页面一直在跳。

这篇从浏览器原生能力讲起,再说清楚哪些图该懒、哪些图千万不能懒,最后给一套在 Nginx 侧兜底的思路和一段验收方法。

二、原生懒加载的触发逻辑

把 loading="lazy" 加上之后,浏览器不会立刻去下载这张图,而是等到图片"接近视口"时才发起请求。这个"接近"的距离由浏览器自己决定,各家实现不同,大体上相当于给了一个几百像素的提前量。这意味着两件事:图片不会在你滚动到它时才突然一卡,但也不会像早期 JS 方案那样精确可控。

基本写法是:

<img src="/usr/uploads/2026/10/photo-800.jpg"
     alt="机房机架正面照"
     width="800" height="534"
     loading="lazy" decoding="async">

decoding="async" 让浏览器把解码放到主线程之外,滚动时不容易卡顿。width 和 height 是必须的,浏览器会根据这两个值自动推导出宽高比,并在图片没下载完时先占好位置。这一条比懒加载本身更重要:没有它,懒加载只会让"位移"从加载时发生变成滚动时发生,用户体验更差。

如果图片尺寸不确定(比如用户上传、宽度自适应),也可以用 CSS 的 aspect-ratio 兜底:

.post-body img {
    aspect-ratio: 3 / 2;
    width: 100%;
    height: auto;
}

注意 aspect-ratio 和 HTML 属性不要互相打架:如果同时写了 width=800 height=534 又用 CSS 强制 aspect-ratio: 3/2,浏览器会以属性为准算占位,再用 CSS 拉伸,结果就是图片轻微变形。

三、首屏的图绝对不能懒加载

最大内容绘制(LCP)通常就是首屏那张最大的图。如果给这张图加了 loading="lazy",浏览器要等到布局完成、计算出"它接近视口"之后才会去下载,等于人为推迟了 LCP 的起点。这是很多站点"加了懒加载,Lighthouse 分数反而掉了"的原因。

首屏大图应该改成急加载,并给一个高优先级提示:

<img src="/usr/uploads/2026/10/hero-1200.webp"
     alt="机房全景"
     width="1200" height="675"
     fetchpriority="high" decoding="async">

这里没有写 loading,默认就是 eager。fetchpriority="high" 告诉浏览器这张图比其它资源更重要,可以在排队时往前挪。反过来,如果页面上还同时存在一个自动播放的视频封面或者背景大图,那也要一起处理,否则"图片抢到了资源,视频封面又抢回去"。

判定"哪张是首屏图"不要凭感觉,用浏览器 DevTools 的 Performance 面板录一段加载过程,看 LCP 标记落在哪个元素上。这里有一个经验值:桌面视口约 900 像素高、移动端约 700 像素高,视口内可见的图都应该 eager,视口下方一格以内可以视情况。

四、iframe 和背景图要不要懒

内嵌框架(iframe)是懒加载收益最大的场景之一:一个 YouTube 或地图 iframe 往往比整页图片加起来还重。原生属性同样适用:

<iframe src="https://example.com/embed" width="640" height="360" loading="lazy" title="演示视频"></iframe>

但 iframe 有一个坑:它的宽度高度一样要写死,否则懒加载触发时会给页面一个巨大的布局抖动。另外,广告位 iframe 不建议懒加载,因为广告商通常要求尽早请求,延迟加载会导致填充率下降。

背景图(CSS background-image)不享受原生懒加载,因为 CSS 里的图片没有"加载属性"这个概念。常见做法是用 IntersectionObserver 手动加类名,或者干脆把装饰性背景图改成 <img> 并用 object-fit: cover 保持效果。后者更简单,也更利于无障碍和搜索:

.cover { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; }

五、Nginx 侧的兜底与统计

懒加载本身是前端行为,但服务端可以做两件有价值的事:一是正确响应图片请求,二是能统计出"到底省了多少"。先看响应头,静态图片给长缓存是前提,否则懒加载只是在不同的时间点重复下载:

location ~* \.(jpg|jpeg|png|webp|avif)$ {
    expires 180d;
    add_header Cache-Control "public, max-age=15552000, immutable";
    access_log /var/log/nginx/img_access.log;
}

immutable 的含义是"这个 URL 的内容永不变化",配合文件名带哈希或日期目录使用最合适。如果图片会被原地替换(比如覆盖上传同名文件),就不要加 immutable,否则用户的浏览器半年都不会再去请求新图。

统计方面,把图片请求单独记一个日志,就能算出懒加载前后的请求数变化:

awk '{print $7}' /var/log/nginx/img_access.log | sort | uniq -c | sort -rn | head -20

如果懒加载生效,你会看到首屏那几张图请求数远高于下方图片,且页面的总请求数下降。反过来,如果每张图的请求数差不多,说明要么页面太短(一屏就看完了),要么懒加载没生效。

六、别把懒加载和图片优化搞混

懒加载解决的是"什么时候下载",不解决"下载多大"。真正决定图片体积的是格式、尺寸和压缩。常见的三种格式对比大致是:AVIF 体积最小但编码慢、兼容性稍差;WebP 兼顾体积与兼容;JPEG 兼容性最好但同画质体积最大。用 <picture> 让浏览器自己挑:

<picture>
  <source srcset="/usr/uploads/2026/10/photo-800.avif" type="image/avif">
  <source srcset="/usr/uploads/2026/10/photo-800.webp" type="image/webp">
  <img src="/usr/uploads/2026/10/photo-800.jpg" alt="机房机架正面照"
       width="800" height="534" loading="lazy" decoding="async">
</picture>

再配合 srcset 的宽度描述符,让手机只下载小图:

<img src="/usr/uploads/2026/10/photo-800.jpg"
     srcset="/usr/uploads/2026/10/photo-400.jpg 400w,
             /usr/uploads/2026/10/photo-800.jpg 800w,
             /usr/uploads/2026/10/photo-1600.jpg 1600w"
     sizes="(max-width: 700px) 100vw, 760px"
     alt="机房机架正面照" width="800" height="534" loading="lazy">

sizes 写错的后果很直接:写大了手机下载大图浪费流量,写小了高分屏上图片发虚。一个稳妥的写法是按内容区实际宽度来,正文区一般在 700 到 800 像素之间。

七、验收:三个可量化的判据

改完之后不要只看"感觉快了",用三个数字判断:

  • 页面初始请求的图片数量。用 DevTools 的 Network 面板,筛选 Img,看加载完成时请求了几张。正常情况下应该只有首屏的那几张;
  • 滚动前后的累计传输量。一口气从顶部滚到底,观察传输量是否分段增长,而不是一次性全下完;
  • 布局位移分数。Lighthouse 的 CLS 应该接近 0,如果还有明显抖动,先回去检查 width 和 height 有没有漏写。

最后提醒一句:图片懒加载的收益随页面长度线性增长。文章只有一屏的站点,加不加几乎没有区别;而列表页、图集页这类长页面,收益非常明显。别为了"最佳实践"的名义在短页面上加一堆复杂度,那是纯粹的负担。

八、懒加载在列表页与图集页的特殊处理

文章页的图片一般在十张以内,懒加载主要是省流量;而列表页和图集页动辄几十上百张缩略图,懒加载在这里直接决定了首屏能不能打开。列表页最忌讳的是把所有缩略图都 eager 加载,一旦某个图床响应慢,整页会一直挂着转圈。做法是把缩略图全部设成 lazy,但给"第一排"单独开一个急加载,保证视觉上不空:

<img src="/thumb/1001.jpg" width="240" height="160" alt="文章缩略图">
<img src="/thumb/1002.jpg" width="240" height="160" alt="文章缩略图" loading="lazy" decoding="async">

还有一个常被忽略的细节:列表页的缩略图如果用 src 直接写原图地址,即使加了 lazy,用户一滚动就会连下几十张几百 K 的原图。正确做法是让缩略图走单独的尺寸,或者在服务端配置 sizes 让它按容器宽度取小图。判断有没有做对,看 Network 面板里同一张图的请求体积是否与缩略图的实际显示尺寸匹配即可。

图集页还有一类问题来自"预加载"。很多图集插件会在页面加载时把后面几张图偷偷预取,本意是让翻页更顺滑,但预取过头就变成了"假懒加载":用户看到的是懒加载,实测请求数却和一次性加载一样。用 grep -c 'loading="lazy"' 数标签是看不出这个问题的,只能看请求曲线。

九、JS 兜底方案与它的退场时机

在原生懒加载普及之前,主流做法是用 IntersectionObserver 监听元素是否进入视口,再把 data-src 里的真实地址写到 src 上。这种方案今天仍有价值,但适用场景已经收窄到两类:需要自定义触发提前量,或者需要在图片进入视口时顺带做别的事(比如标记阅读进度、触发埋点)。

const io = new IntersectionObserver((entries) => {
  entries.forEach((e) => {
    if (!e.isIntersecting) return;
    const img = e.target;
    img.src = img.dataset.src;
    io.unobserve(img);
  });
}, { rootMargin: '300px 0px' });

document.querySelectorAll('img[data-src]').forEach((el) => io.observe(el));

rootMargin 就是"提前多少像素开始加载",写 300px 意味着图片距离视口还有 300 像素时就开始下载,绝大多数场景下这个值够用。unobserve 不能省,否则滚动一次回调多次,同一张图会被反复赋值。

这套方案最常见的两个 bug:一是 JS 失效时页面完全没有图(脚本报错、CDN 挂了、用户禁用了 JS),所以 src 里必须放一个极小的占位图或者真实的小图,不能留空;二是没处理"图片在视口内但页面是服务端渲染的",首屏图片会因为观察器要等一次布局而延迟触发。这两点合起来说明一件事:有原生方案就用原生方案,JS 只在确实需要精细控制时兜底。现在 loading="lazy" 的浏览器覆盖率已经足够高,绝大多数站点完全可以只靠原生属性,把那几十行 JS 删掉反而更稳。

如果要兼容极老的环境,写一个能力检测再降级即可,不要为了 1% 的旧浏览器让 99% 的用户多付一次脚本执行成本。

Last modification:October 30th, 2026 at 10:26 pm

Leave a Comment