去年优化一个带长列表的页面,最初的图片懒加载用的是基于 scroll 事件的第三方库。功能没问题,就是总感觉有点重,而且每次新增动态内容都得手动调一下 API,麻烦得要死。后来一跺脚,把代码全部清理干净,换成了 HTML 原生的 loading="lazy" 和 Intersection Observer。这波操作下来,不仅代码砍了几十行,页面滚动也变得更丝滑了。
这篇就把我这个折腾过程完整地记录下来,不是单纯介绍 API,而是面向真实工程,探讨怎么把原生懒加载用到极致,以及你需要注意的细节。
一、为什么我不建议你再用第三方懒加载库
咱们不是否定所有第三方库,但懒加载这事情,浏览器已经帮我们做了大部分。原生的 loading="lazy" 从 2019 年开始就逐步得到支持,到现在主流浏览器基本都兼容。它最大的优点是不需要 JS 去手动计算图片位置,不用监听滚动事件,浏览器自己会在合适的时机加载图片。
第三方库往往要自己绑定 scroll 或 touchmove 事件,然后处理节流、判断元素进入视口、再替换 data-src 为真正的 src。这一套逻辑放在现在显得有些多余,而且性能并不一定比原生好。我之前那个插件,在低端安卓机上滚动时还是会有明显抖动,换了原生之后反而更顺。
当然,loading="lazy" 只对 img 和 iframe 有效,背景图什么的它管不了。这就是我们还需要 Intersection Observer 的原因。
二、基础篇:从前那样写,现在这样写
以前咱们写图片:
<img src="placeholder.gif" data-src="real-image.jpg" alt="商品图">
然后 JS 里监听滚动,把 data-src 赋给 src。现在只需要:
<img src="real-image.jpg" alt="商品图" loading="lazy">
就这么简单。
但有个小坑:如果你给 img 设置 srcset 来做响应式,那么 loading="lazy" 仍然有效。它不会影响 srcset 的属性,所以你可以放心用。
<img
src="small.jpg"
srcset="medium.jpg 600w, large.jpg 1200w"
sizes="(max-width: 600px) 480px, 800px"
alt="响应式图片"
loading="lazy">
需要注意的是:loading="lazy" 对已经处于视口内的图片没有任何影响,它只拦截视口之外的图片请求。而且它还有一条默认的“距离阈值”,比如 Chrome 是 1250px。这个阈值无法用 JS 调整,但通常够用。
三、Intersection Observer 才是万能钥匙
如果你要懒加载背景图、视频、广告位,甚至做一些滚动动画,那 Intersection Observer 是比滚动监听更科学的选择。它通过异步观察元素是否进入视口,不需要 JS 计算位置,性能好很多。
一个最典型的例子:懒加载背景图。注意,背景图的 url() 写在 CSS 里,浏览器不会因为元素不可见就不加载。所以我们可以把真实背景放在一个 data-* 属性里,等元素进入可视范围再通过 JS 设置样式。
<div class="hero" data-bg="/images/hero-wide.jpg">
<h1>欢迎光临</h1>
</div>
然后在 JS 里这样操作:
const hero = document.querySelector('.hero');
function loadBg(element) {
const bgUrl = element.dataset.bg;
if (bgUrl) {
element.style.backgroundImage = `url('${bgUrl}')`;
element.removeAttribute('data-bg');
}
}
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadBg(entry.target);
observer.unobserve(entry.target);
}
});
}, { rootMargin: '200px' }); // 提前200px开始加载
observer.observe(hero);
这里的 rootMargin 可以让图片在距离视口 200px 的时候就开始加载,体验上几乎是即时的。
四、完整案例:列表页商品图的懒加载 + 从占位图过渡
我们项目里有一个商品列表页,接口返回的数据会渲染成卡片。每张卡片有一张商品图、一个标题、一个优惠标签。为了美观,图片加载前显示一个淡淡的背景色和占位小图标。这个需求用原生的 loading="lazy" 就可以搞定,因为 img 有一个 onload 事件可以判断加载完成。
具体玩法是这样的:
<img
src="/images/placholder.svg"
data-src="https://cdn.example.com/product/123.jpg"
alt="商品名称"
class="product-img">
注意我用了 data-src 而不是直接的 src。为什么要这样?因为我想让图片真正加载时触发一个淡入效果,而不是浏览器自动加载后直接显示。如果直接用 loading="lazy" 加上 src,虽然能做原生懒加载,但没法控制加载完成的时机。所以我采用 Intersection Observer 配合 data-src,这样既精准又能做样式过渡。
整段 JS 如下:
const imgObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const img = entry.target;
const realSrc = img.dataset.src;
if (realSrc) {
// 先把占位图保持住,等真实图片加载完再替换
const tempImage = new Image();
tempImage.onload = () => {
img.src = realSrc;
img.classList.add('loaded');
img.removeAttribute('data-src');
};
tempImage.src = realSrc;
}
observer.unobserve(img);
});
}, { rootMargin: '100px' });
document.querySelectorAll('.product-img').forEach(img => imgObserver.observe(img));
CSS 中给 .product-img 设置了占位底色和 opacity 过渡,这里就不贴具体样式了,你自己写个 transition: opacity .3s 就行。加载完成后加一个 loaded 类,把 opacity 变成 1。
这里其实偷了个懒:我先用 new Image() 预加载真实图,等缓存在浏览器里后,再设置 img.src。这样用户看不到从占位图“闪变”成真实图的过程,体验更平滑。这也是第三方库常用的套路。
五、小心动态渲染的内容
现在前后端分离很常见,很多页面在加载完初始数据后,还会根据用户操作或 WebSocket 推送往 DOM 里插入新列表项。这些新图片不会自动被 Intersection Observer 观察。需要重新调用 observe。
可以封装一个函数,用来观察新增的容器内的所有图片:
function observeImages(container = document) {
const lazyImages = container.querySelectorAll('img[data-src]');
lazyImages.forEach(img => imgObserver.observe(img));
}
// 新数据插入后
const newContainer = renderProducts(list); // 渲染函数
document.querySelector('#product-list').appendChild(newContainer);
observeImages(newContainer);
但要注意,这里我把 Observer 变量放在外层,方便复用。如果你不想为每个页面都创建一堆 observer,可以把 observer 放在一个单例里。
六、原生 loading=”lazy” 和 Intersection Observer 怎么选?
其实两条路可以混合使用。我记得自己刚开始做的时候,简单地给所有 img 加上了 loading="lazy",然后又用 Intersection Observer 去动态添加 class 做动画。问题来了:浏览器原生懒加载会延迟图片请求,而 Intersection Observer 的回调也可能在图片加载前触发。这两者一起用会不会有冲突?
经过测试,没有冲突。你可以这样理解:loading="lazy" 是“请求层面”的控制,而 Intersection Observer 是“行为层面”的控制。就算你用了 Intersection Observer 去设置 img.src,这个 src 最终是否被加载,仍然由浏览器的懒加载机制决定。换句话说,设置 src 只是给浏览器一个指令,浏览器会选择合适时机去请求。
所以如果你想同时使用两者,逻辑上没问题。但建议你把 loading="lazy" 加在真正的 img 上,同时去掉 data-src 那种替换逻辑,让浏览器自己管理请求时机,Intersection Observer 只负责加载动画之类的副作用。
举个例子:
<img
src="real-image.jpg"
loading="lazy"
class="product-img"
alt="商品">
const animateObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('in-view');
animateObserver.unobserve(entry.target);
}
});
});
document.querySelectorAll('.product-img').forEach(img => animateObserver.observe(img));
这样一来,图片的加载完全交给浏览器,而且浏览器会在滚动快接近的时候提前加载,体验很好。你的 JS 就只需要管那些视觉感知类的交互。
七、还有几个值得注意的细节
1. 给图片提供宽高,避免布局位移
懒加载最常见的副作用就是图片加载前容器高度塌陷,导致页面来回跳。最简单的方法是给 img 设置固定的 width 和 height 属性,让浏览器在图片未加载时就计算出占位空间。
<img src="shoes.jpg" width="800" height="600" loading="lazy" alt="运动鞋">
现在浏览器的 aspect-ratio 特性也可以做到,但显式宽高兼容性更好。
2. iframe 懒加载也一样简单
嵌入第三方视频或地图时,可以用 loading="lazy" 直接控制 iframe 的加载时机。这个是最容易忽略的。
<iframe src="https://player.vimeo.com/video/123"
loading="lazy"
title="视频"></iframe>
还有更激进的做法:初始时 src 留空,当用户滚动到附近时再注入 src。但这会增加复杂度,通常直接用 loading 就够了。
3. JavaScript 频繁设置 src 会逼着浏览器重新加载
如果你在代码里动态替换 img.src,比如每次都从一个 data 属性里取值赋给 src,而且这个赋值发生在用户疯狂滚动过程中,浏览器可能会立刻请求图片,导致之前的懒加载策略白做。稳妥的做法是把需要懒加载的图片放在 data-src 里,只有 Observer 触发了才去替换。
4. 别忘了低版本浏览器的降级
如果你的用户还有用旧版 Safari 或 IE,Intersection Observer 都可以通过 polyfill 解决。但 loading="lazy" 无法 polyfill。我当时的处理是写了一个几行的检测函数:
function loadingSupported() {
return 'loading' in HTMLImageElement.prototype;
}
if (loadingSupported()) {
// 原生懒加载
} else {
// 用 Intersection Observer 手动实现加载,或者引入一个小的polyfill
}
实际上在 2024 年,全球的主流浏览器都支持 lazyload 了,基本可以放弃旧浏览器,但做项目还是留一手比较好。
八、性能实测:加载体积从 4.7 MB 变成 1.2 MB
我对比了一下改造前后首页的加载情况。原来用第三方懒加载库,页面全部加载完成后,所有图片都被下载了(因为库的优先级设置有问题)。改造后,首屏只加载了 3 张图片,其他图片在滚动到它们前 500px 才启动请求。整页最终加载体积少了 3.5MB,首屏 LCP 时间从 2.1 秒降到了 1.1 秒。
更明显的是 CPU 占用率。之前用 scroll 监听时,滚动过程中页面能明显感觉到掉帧。现在用原生和 Intersection Observer,几乎没有多余的 JS 计算,滚动非常流畅。
九、总结一下我的最终方案
目前我在所有的新页面里都遵循这个原则:
- 对于普通
img,直接加loading="lazy",不动任何 JS。 - 对于需要监听加载状态或做动画的图片,结合 Intersection Observer 加 data-src。
- 对于背景图、响应式图片、视频和 iframe,统一用 Intersection Observer 控制。
- 所有图片都强制写宽高,避免 CLS。
这套组合拳在我看来已经是目前解决“懒加载”问题的最优解了,既省流量又保证体验,代码量还少。如果你还在被各种懒加载库困扰,也试试回归原生吧。
最后提一句,网上有人说 loading="lazy" 会影响图片资源的优先级,导致核心图加载变慢。但其实我们可以给重要的首屏图加 fetchpriority="high",来告诉浏览器哪个图更紧迫。这个属性和懒加载搭配使用效果更佳。
<img src="hero.jpg" alt="主视觉" fetchpriority="high" loading="lazy">
别奇怪,这两个属性真的可以共存。浏览器的策略在这种组合下会合理地推迟非关键的请求,而优先加载你标记为高的东西。
这次就分享到这里,希望你的页面也能变成一个“懒得加载”的瘦子。

