网页慢,十次里有八次是图片拖的后腿。这篇不谈玄乎的理论,就把「怎么让页面里的图又快又不抖」拆成几个能直接抄进项目的做法:原生懒加载、手写 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> 显式写上 width 和 height (或用 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="首页大图"
/>
这里 srcset 用 w 描述符声明每个候选图的真实像素宽度;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.com、tudingai.cn、cloudconvert.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 膨胀」。图不多时用纯色占位反而更省心。
小结
把这几步串起来,一套稳妥的图片加载策略就是:
- 首屏关键图
eager加载,必要时preload,绝不懒加载; - 视口外的图 优先用原生
loading="lazy",需要精细控制或做动画再上IntersectionObserver(data-src+ 合适的rootMargin+ 用完unobserve); - 尺寸 用
srcset/sizes按屏幕挑,别给手机塞大图; - 格式 用
<picture>上 WebP/AVIF 并降级到 JPEG,上线前先压过; - 占位与防抖 用
aspect-ratio/宽高提前锁住盒子,配 LQIP 模糊占位让过程不难看,把 CLS 摁死; - 全程记得 SEO 兜底、兼容降级、真机弱网实测。
图片优化没有银弹,但把「晚加载、给对尺寸、别抖」这三件事做扎实,页面的加载体验和 Core Web Vitals 数据通常都会有肉眼可见的改善。以上代码都可以直接抄进项目按需改,剩下的就是在自己的真实场景里量一量、调一调。