图片懒加载实战:IntersectionObserver、LQIP 占位与 CLS 布局防抖

网页慢,十次里有八次是图片拖的后腿。这篇不谈玄乎的理论,就把「怎么让页面里的图又快又不抖」拆成几个能直接抄进项目的做法:原生懒加载、手写 IntersectionObserver、响应式 srcset/sizes、WebP/AVIF 格式降级,以及最容易被忽视的 LQIP 模糊占位和 CLS 布局防抖。每段都配可跑代码,末尾专门留一节讲坑。

一、为什么图片是性能大头

打开浏览器 DevTools 的 Network 面板,按传输体积排序,绝大多数内容型页面排在最前面的都是图片。一个 1920px 宽的 banner,未经处理的 JPEG 动辄两三百 KB,一张没压过的 PNG 截图上兆也很常见。相比之下 HTML/CSS/JS 的体积经常加起来还没一张大图多。

图片对性能的伤害是双份的:

  • 拖慢 LCP(最大内容绘制)。首屏那张最大的图什么时候加载完,直接决定 LCP 数值,而 LCP 是 Core Web Vitals 里权重很高的一项。
  • 制造 CLS(累积布局偏移)。图片没写宽高、加载完才把下面的内容顶下去,用户正要点的按钮突然跑位,这种「抖动」就是 CLS。

所以优化图片不是单纯「压小一点」,而是要同时管好三件事:该晚加载的晚加载、该多大就给多大、加载过程中别让布局抖。下面逐个说。

二、loading="lazy":先用原生的

现代浏览器早就内置了懒加载,一个属性的事:

html 复制代码
<!-- 视口外的图片,滚动到附近时浏览器才会去下载 -->
<img
  src="/img/photo-800.jpg"
  alt="产品细节图"
  width="800"
  height="600"
  loading="lazy"
/>

loading 有三个取值:

  • lazy:延迟加载,滚动到临近视口时才请求。
  • eager:立即加载(默认行为)。
  • auto:交给浏览器判断(部分实现里等同不写)。

原生懒加载的好处是零 JS、无需第三方库、浏览器还会根据网络状况和滚动速度智能调整提前量。大多数场景优先用它就够了

一个必须记住的细节:<img> 显式写上 widthheight (或用 CSS 的 aspect-ratio)。否则图片没下载完时高度为 0,下载完再撑开,就是标准的 CLS 制造机。这条后面第六节还会展开。

<iframe> 同样支持 loading="lazy",嵌 B 站视频、地图这类重资源时很实用。

三、IntersectionObserver:需要精细控制时自己写

原生 loading="lazy" 够用,但有几种情况你会想自己接管:想统一控制提前加载的距离、想做加载动画、要兼容特别老的环境、或者要懒加载的不是 <img> 而是 CSS 背景图。这时候用 IntersectionObserver------它是浏览器提供的「元素进出视口」监听器,不用手写 scroll 监听去反复读 getBoundingClientRect,性能好得多。

核心套路:真实地址先藏在 data-src,元素进入视口再赋给 src

html 复制代码
<img
  class="lazy"
  data-src="/img/photo-800.jpg"
  alt="示例图"
  width="800"
  height="600"
/>
js 复制代码
// 挑出所有待懒加载的图片
const lazyImages = document.querySelectorAll('img.lazy');

const io = new IntersectionObserver(
  (entries, observer) => {
    entries.forEach((entry) => {
      // 还没进入视口就跳过
      if (!entry.isIntersecting) return;

      const img = entry.target;
      img.src = img.dataset.src;          // 触发真正的下载
      img.removeAttribute('data-src');
      img.classList.remove('lazy');

      // 加载完这一张就取消观察,别浪费
      observer.unobserve(img);
    });
  },
  {
    // 关键参数:视口向外扩 200px 提前触发,
    // 让用户滚到之前图基本已经就位,避免看到空白
    rootMargin: '200px 0px',
    threshold: 0,
  }
);

lazyImages.forEach((img) => io.observe(img));

几个要点:

  • rootMargin 是体验的命门 。设成 '200px 0px' 意味着元素距离视口还有 200px 就开始加载,用户滚到时图往往已经好了。设 0 会看到明显的「滚到才开始转圈」。
  • 加载完 unobserve,否则观察器一直挂着这些已经处理完的元素。
  • threshold: 0 表示只要露出 1px 就算相交,对懒加载来说足够。

如果要给背景图懒加载,把 img.src = ... 换成设置 style.backgroundImage 即可,逻辑完全一样。

再进一步,可以监听 load 事件加个淡入,体验会顺滑很多:

js 复制代码
img.addEventListener('load', () => {
  img.classList.add('loaded'); // 配合 CSS transition 做淡入
}, { once: true });
css 复制代码
img.lazy { opacity: 0; }
img.loaded { opacity: 1; transition: opacity .4s ease; }

四、srcset / sizes:别给手机塞桌面大图

懒加载解决「什么时候加载」,响应式图片解决「加载多大的」。给 375px 宽的手机推一张 1920px 的图,纯属浪费流量还拖慢加载。srcset + sizes 让浏览器自己挑合适尺寸。

按屏幕密度选(DPR,适合固定显示宽度的图,如头像、logo):

html 复制代码
<img
  src="/img/avatar-1x.jpg"
  srcset="/img/avatar-1x.jpg 1x,
          /img/avatar-2x.jpg 2x,
          /img/avatar-3x.jpg 3x"
  width="96"
  height="96"
  alt="用户头像"
/>

普通屏用 1x,Retina 用 2x/3x,浏览器根据 devicePixelRatio 自动选。

按实际渲染宽度选(w 描述符,适合响应式布局里宽度会变的图):

html 复制代码
<img
  src="/img/hero-800.jpg"
  srcset="/img/hero-400.jpg 400w,
          /img/hero-800.jpg 800w,
          /img/hero-1200.jpg 1200w,
          /img/hero-1600.jpg 1600w"
  sizes="(max-width: 600px) 100vw,
         (max-width: 1024px) 50vw,
         33vw"
  width="800"
  height="450"
  alt="首页大图"
/>

这里 srcsetw 描述符声明每个候选图的真实像素宽度;sizes 告诉浏览器「这张图在不同断点下大概占多宽」------600px 以下占满屏(100vw),平板占一半(50vw),桌面占三分之一(33vw)。浏览器把 sizes 算出的 CSS 宽度乘以设备像素比,再去 srcset 里挑最接近的那张下。

sizes 写不准不会报错,但会挑错尺寸,所以它得和你的 CSS 布局对得上。src 保留一个作为不支持 srcset 时的兜底。

五、picture + WebP/AVIF:格式降级不丢兼容

同样画质下,WebP 通常比 JPEG 小 25%~35%,AVIF 还能再小一截。但老浏览器不认,直接上会裂图。<picture> 的作用就是「优先用新格式,不支持就退回 JPEG」:

html 复制代码
<picture>
  <!-- 浏览器从上往下匹配,命中第一个支持的就用它 -->
  <source srcset="/img/photo.avif" type="image/avif" />
  <source srcset="/img/photo.webp" type="image/webp" />
  <!-- 兜底:所有浏览器都认的 JPEG -->
  <img
    src="/img/photo.jpg"
    width="800"
    height="600"
    alt="风景照"
    loading="lazy"
  />
</picture>

浏览器按顺序试 <source>,认识 AVIF 就用 AVIF,不认识往下走 WebP,再不行落到 <img> 的 JPEG。注意懒加载、宽高这些属性还是写在最底下的 <img> 上。

<picture> 也能和 srcset/sizes 组合,同时做「格式降级 + 尺寸自适应 + 艺术裁剪」(比如手机上用竖版、桌面用横版),但语法会长,按需要加即可。

说到底,格式和压缩才是收益最大的一环 ------一张图先压过、转成 WebP/AVIF 再上线,省下来的体积往往比任何前端加载技巧都实在。上线前把图过一遍压缩/转换工具是值得的习惯,常用的在线工具有 squoosh.app、tinypng.comtudingai.cncloudconvert.com 这类,命令行侧则有 cwebp、sharp、ImageMagick 可以批量跑。原则是:能在构建期/上传期一次压好的,别丢给用户的浏览器去扛

六、LQIP 模糊占位 + 防 CLS:让加载过程不难看也不抖

图片就算加载快,那段「从空白到出现」的过程也影响观感。LQIP(Low-Quality Image Placeholder,低质量占位图)的思路是:先放一张极小的模糊图垫着,真图加载完再替换,也就是常说的「blur-up」效果。

占位图可以是几百字节的微缩图,甚至内联成 base64 直接省一个请求:

html 复制代码
<div class="img-wrap">
  <!-- 极小的模糊占位,base64 内联,随 HTML 一起到达 -->
  <img
    class="lqip"
    src="data:image/jpeg;base64,/9j/4AAQSk...(几百字节的小图)"
    aria-hidden="true"
  />
  <!-- 真图,懒加载 -->
  <img
    class="real"
    data-src="/img/photo-1200.jpg"
    alt="作品展示"
    width="1200"
    height="800"
    loading="lazy"
  />
</div>
css 复制代码
.img-wrap {
  position: relative;
  /* 用宽高比锁住盒子,图没来之前位置就占好了 ------ 这是防 CLS 的关键 */
  aspect-ratio: 3 / 2;
  overflow: hidden;
}
.img-wrap img {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.lqip {
  /* 放大模糊,盖住低质量图的马赛克 */
  filter: blur(12px);
  transform: scale(1.05);
}
.real {
  opacity: 0;
  transition: opacity .4s ease;
}
.real.loaded {
  opacity: 1; /* 真图淡入,盖住底下的模糊占位 */
}

配套的 JS 只需在真图 load 完加个类:

js 复制代码
document.querySelectorAll('img.real').forEach((img) => {
  img.addEventListener('load', () => img.classList.add('loaded'), { once: true });
});

这里最该记住的是 aspect-ratio(或显式 width/height。CLS 的根源就是浏览器排版时不知道图有多高,等图来了才撑开、把后面内容往下挤。只要提前用宽高比把盒子占位占好,图片再怎么慢加载,周围布局都纹丝不动。

不想写 base64 也行,退一步用纯色/渐变占位同样能防抖:

css 复制代码
.img-wrap { aspect-ratio: 3 / 2; background: #f0f0f0; }

关键从来不是占位图多好看,而是盒子尺寸有没有提前定下来

七、诚实的工程边界(这些坑得先知道)

技术都有适用范围,下面这些是实践里真会踩的:

1. 首屏图千万别懒加载。 懒加载是给「视口外」的图用的。首屏那张(尤其是 LCP 元素)如果加了 loading="lazy",等于告诉浏览器晚点再下,反而拖慢 LCP,弄巧成拙。首屏关键图应该用 loading="eager",甚至配 <link rel="preload"> 提前拉。判断标准很简单:打开页面不滚动就能看到的图,一律不懒加载。

2. 懒加载对 SEO / 爬虫的影响。 用原生 loading="lazy" 时图片地址在 src 里,搜索引擎基本能抓到。但如果你用 data-src 的 JS 方案,真实地址在 JS 执行后才写入 src------不执行 JS 的爬虫可能抓不到图。要在意图片被收录,优先用原生方案,或给关键图配 <noscript> 兜底:

html 复制代码
<img class="lazy" data-src="/img/key.jpg" alt="关键图" width="800" height="600" />
<noscript>
  <img src="/img/key.jpg" alt="关键图" width="800" height="600" />
</noscript>

3. 兼容性别想当然。 loading="lazy"IntersectionObserver、WebP 在如今主流浏览器覆盖都很好,AVIF 稍晚一些但也够用。真正要照顾很老的环境时:IntersectionObserver 可以判断 'IntersectionObserver' in window,不支持就直接把 data-src 全部赋值给 src(降级成不懒加载,总比图不显示强);WebP/AVIF 靠 <picture><img> 兜底天然安全。上线前务必在真机、弱网、低端机各测一遍,别只信 DevTools 的模拟。

4. rootMargin 不是越大越好。 提前量给太大(比如 2000px),等于把大半页图都提前下了,懒加载就白做了。200~500px 是比较稳的区间,按图的密度和大小调。

5. 别忘了取消观察和内存。 长列表、无限滚动场景,处理完的元素要 unobserve;虚拟列表里被回收的节点也要记得断开观察,否则观察器越挂越多。

6. LQIP 的成本。 内联 base64 占位会撑大 HTML 体积,占位图要压到几百字节以内,别把「省一个请求」变成「首屏 HTML 膨胀」。图不多时用纯色占位反而更省心。

小结

把这几步串起来,一套稳妥的图片加载策略就是:

  1. 首屏关键图 eager 加载,必要时 preload,绝不懒加载;
  2. 视口外的图 优先用原生 loading="lazy",需要精细控制或做动画再上 IntersectionObserverdata-src + 合适的 rootMargin + 用完 unobserve);
  3. 尺寸srcset/sizes 按屏幕挑,别给手机塞大图;
  4. 格式<picture> 上 WebP/AVIF 并降级到 JPEG,上线前先压过;
  5. 占位与防抖aspect-ratio/宽高提前锁住盒子,配 LQIP 模糊占位让过程不难看,把 CLS 摁死;
  6. 全程记得 SEO 兜底、兼容降级、真机弱网实测。

图片优化没有银弹,但把「晚加载、给对尺寸、别抖」这三件事做扎实,页面的加载体验和 Core Web Vitals 数据通常都会有肉眼可见的改善。以上代码都可以直接抄进项目按需改,剩下的就是在自己的真实场景里量一量、调一调。

相关推荐
丁小未17 小时前
基于MVVM框架的XUUI HelloWorld 新手教程
unity·性能优化·c#·游戏引擎
飞天狗18 小时前
长列表滚动卡顿怎么破:虚拟列表选型 + react-virtuoso 实战调优指南
react.js·性能优化
zhangjw3421 小时前
第29篇:Java伪共享与对象分配:并发性能优化的关键
java·开发语言·性能优化
SelectDB2 天前
美团数十 PB 规模 Apache Doris 实践:从统一 OLAP 到 AI-Native 数据基座
大数据·数据库·性能优化
Patrick_Wilson2 天前
最佳实践是有保质期的:从一次 CDN external 白屏事故说起
前端·性能优化·前端工程化
侑虎科技2 天前
解决URP角色渲染短板,自研IllusionRP管线落地移动端
性能优化
紫霖ty2 天前
Session处理性能过慢的问题排查与优化
性能优化
大家的林语冰2 天前
Rust 再次打败 Go,Astro 7 首发,什么叫“万物皆可锈化“!
前端·javascript·性能优化
UWA2 天前
资源回收“两头堵”:RUUA调了会卡,不调内存又涨
性能优化·memory·游戏开发