本文深入探讨前端图片懒加载的各种实现方案,从传统的滚动监听到现代的 IntersectionObserver API,再到浏览器原生支持的 loading="lazy",帮助开发者选择最适合的技术方案。
一、为什么需要图片懒加载?
1.1 性能问题
在现代 Web 应用中,图片往往占据了页面总资源的 60-70%。如果一次性加载所有图片,会导致:
- 首屏加载时间过长:用户需要等待所有图片下载完成
- 带宽浪费:用户可能永远不会滚动到页面底部
- 内存占用过高:大量图片同时存在于内存中
- 用户体验差:页面卡顿、白屏时间长
1.2 典型应用场景
- 长列表页面(商品列表、新闻列表)
- 图片墙/瀑布流
- 社交媒体信息流
- 文章详情页
- 相册/画廊
二、图片懒加载的演进历程
2.1 演进时间线
yaml
2010 年前 → 滚动监听 + getBoundingClientRect
2016 年 → IntersectionObserver API 发布
2019 年 → 浏览器原生 loading="lazy" 支持
2020 年至今 → 混合方案 + 渐进增强
三、方案一:传统滚动监听(已过时)
3.1 实现原理
监听 scroll 事件,计算图片是否进入视口,如果进入则加载图片。
3.2 基本实现
javascript
class ScrollLazyLoad {
constructor(options = {}) {
this.images = [];
this.threshold = options.threshold || 0; // 提前加载的距离
this.handleScroll = this.debounce(this._checkImages.bind(this), 200);
}
init() {
// 收集所有需要懒加载的图片
this.images = Array.from(document.querySelectorAll('img[data-src]'));
// 监听滚动事件
window.addEventListener('scroll', this.handleScroll);
window.addEventListener('resize', this.handleScroll);
// 初始检查
this._checkImages();
}
_checkImages() {
this.images = this.images.filter((img) => {
if (this._isInViewport(img)) {
this._loadImage(img);
return false; // 已加载,从列表中移除
}
return true; // 未加载,保留在列表中
});
// 所有图片都加载完成,移除监听器
if (this.images.length === 0) {
this.destroy();
}
}
_isInViewport(element) {
const rect = element.getBoundingClientRect();
const windowHeight = window.innerHeight || document.documentElement.clientHeight;
return (
rect.top <= windowHeight + this.threshold &&
rect.bottom >= -this.threshold
);
}
_loadImage(img) {
const src = img.dataset.src;
if (!src) return;
// 创建临时 Image 对象预加载
const tempImg = new Image();
tempImg.onload = () => {
img.src = src;
img.classList.add('loaded');
img.removeAttribute('data-src');
};
tempImg.onerror = () => {
img.classList.add('error');
};
tempImg.src = src;
}
debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
destroy() {
window.removeEventListener('scroll', this.handleScroll);
window.removeEventListener('resize', this.handleScroll);
}
}
// 使用示例
const lazyLoad = new ScrollLazyLoad({ threshold: 200 });
lazyLoad.init();
3.3 HTML 结构
html
<!-- 使用 data-src 存储真实图片地址 -->
<img
src="placeholder.jpg"
data-src="real-image.jpg"
alt="描述"
class="lazy-image"
/>
<!-- 或者使用透明占位图 -->
<img
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-src="real-image.jpg"
alt="描述"
/>
3.4 优势
✅ 兼容性好 :支持所有浏览器(包括 IE6+)
✅ 实现简单 :逻辑清晰,易于理解
✅ 可控性强:可以精确控制加载时机
3.5 劣势
❌ 性能差 :频繁触发 scroll 事件,即使使用防抖也会影响性能
❌ 计算开销大 :每次都要调用 getBoundingClientRect()
❌ 阻塞主线程 :scroll 事件在主线程执行,可能导致卡顿
❌ 代码复杂:需要手动管理监听器、防抖、清理等
3.6 性能问题分析
javascript
// 问题 1:频繁触发
window.addEventListener('scroll', () => {
console.log('scroll 事件触发'); // 滚动时每秒触发 60+ 次
});
// 问题 2:getBoundingClientRect 触发重排
const rect = element.getBoundingClientRect(); // 强制浏览器重新计算布局
// 问题 3:主线程阻塞
// scroll 事件在主线程执行,如果处理逻辑复杂,会导致页面卡顿
3.7 适用场景
- 需要兼容老旧浏览器(IE9-)
- 需要精确控制加载时机
- 项目已有成熟的滚动监听方案
3.8 结论
⚠️ 不推荐使用:除非有特殊的兼容性要求,否则应该使用更现代的方案。
四、方案二:IntersectionObserver API(推荐)⭐️
4.1 实现原理
使用浏览器原生的 IntersectionObserver API,监听元素与视口的交叉状态,当元素进入视口时自动触发回调。
4.2 核心优势
✅ 异步执行 :不阻塞主线程,性能优秀
✅ 自动优化 :浏览器内部优化,无需手动防抖
✅ 精确控制 :支持 rootMargin、threshold 等配置
✅ 代码简洁:无需手动计算位置
4.3 基本实现
javascript
class IntersectionLazyLoad {
constructor(options = {}) {
this.rootMargin = options.rootMargin || '50px'; // 提前加载距离
this.threshold = options.threshold || 0; // 交叉比例
this.onLoad = options.onLoad; // 加载完成回调
this.observer = null;
}
init() {
// 创建 IntersectionObserver
this.observer = new IntersectionObserver(
(entries) => this._handleIntersection(entries),
{
rootMargin: this.rootMargin,
threshold: this.threshold,
}
);
// 观察所有需要懒加载的图片
const images = document.querySelectorAll('img[data-src]');
images.forEach((img) => this.observer.observe(img));
}
_handleIntersection(entries) {
entries.forEach((entry) => {
// 元素进入视口
if (entry.isIntersecting) {
const img = entry.target;
this._loadImage(img);
// 加载后停止观察
this.observer.unobserve(img);
}
});
}
_loadImage(img) {
const src = img.dataset.src;
if (!src) return;
// 创建临时 Image 对象预加载
const tempImg = new Image();
tempImg.onload = () => {
img.src = src;
img.classList.add('loaded');
img.removeAttribute('data-src');
this.onLoad?.(img, true);
};
tempImg.onerror = () => {
img.classList.add('error');
this.onLoad?.(img, false);
};
tempImg.src = src;
}
// 动态添加图片时调用
observe(img) {
if (this.observer && img.dataset.src) {
this.observer.observe(img);
}
}
// 停止观察某个图片
unobserve(img) {
if (this.observer) {
this.observer.unobserve(img);
}
}
destroy() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
}
}
// 使用示例
const lazyLoad = new IntersectionLazyLoad({
rootMargin: '100px', // 提前 100px 开始加载
threshold: 0.01, // 元素 1% 可见时触发
onLoad: (img, success) => {
console.log(`图片加载${success ? '成功' : '失败'}:`, img.src);
},
});
lazyLoad.init();
// 动态添加图片
const newImg = document.createElement('img');
newImg.dataset.src = 'new-image.jpg';
document.body.appendChild(newImg);
lazyLoad.observe(newImg); // 观察新图片
4.4 高级用法
4.4.1 渐进式加载(先加载缩略图)
javascript
class ProgressiveLazyLoad {
constructor(options = {}) {
this.observer = new IntersectionObserver(
(entries) => this._handleIntersection(entries),
{ rootMargin: '50px' }
);
}
init() {
const images = document.querySelectorAll('img[data-src]');
images.forEach((img) => {
// 先加载缩略图
if (img.dataset.thumbnail) {
img.src = img.dataset.thumbnail;
}
this.observer.observe(img);
});
}
_handleIntersection(entries) {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
this._loadHighResImage(img);
this.observer.unobserve(img);
}
});
}
_loadHighResImage(img) {
const src = img.dataset.src;
if (!src) return;
const tempImg = new Image();
tempImg.onload = () => {
// 淡入效果
img.style.opacity = 0;
img.src = src;
img.style.transition = 'opacity 0.3s';
setTimeout(() => {
img.style.opacity = 1;
}, 10);
img.classList.add('loaded');
};
tempImg.src = src;
}
}
4.4.2 响应式图片懒加载
javascript
class ResponsiveLazyLoad {
constructor() {
this.observer = new IntersectionObserver(
(entries) => this._handleIntersection(entries),
{ rootMargin: '50px' }
);
}
init() {
const images = document.querySelectorAll('img[data-srcset]');
images.forEach((img) => this.observer.observe(img));
}
_handleIntersection(entries) {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
this._loadResponsiveImage(img);
this.observer.unobserve(img);
}
});
}
_loadResponsiveImage(img) {
// 根据屏幕宽度选择合适的图片
const srcset = img.dataset.srcset;
const sizes = img.dataset.sizes;
if (srcset) {
img.srcset = srcset;
}
if (sizes) {
img.sizes = sizes;
}
// 设置默认 src(兜底)
if (img.dataset.src) {
img.src = img.dataset.src;
}
}
}
HTML 结构:
html
<img
data-srcset="
small.jpg 480w,
medium.jpg 800w,
large.jpg 1200w
"
data-sizes="
(max-width: 600px) 480px,
(max-width: 1000px) 800px,
1200px
"
data-src="medium.jpg"
alt="响应式图片"
/>
4.4.3 背景图片懒加载
javascript
class BackgroundLazyLoad {
constructor() {
this.observer = new IntersectionObserver(
(entries) => this._handleIntersection(entries),
{ rootMargin: '50px' }
);
}
init() {
const elements = document.querySelectorAll('[data-bg]');
elements.forEach((el) => this.observer.observe(el));
}
_handleIntersection(entries) {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const el = entry.target;
const bg = el.dataset.bg;
if (bg) {
// 预加载背景图
const img = new Image();
img.onload = () => {
el.style.backgroundImage = `url(${bg})`;
el.classList.add('loaded');
};
img.src = bg;
}
this.observer.unobserve(el);
}
});
}
}
HTML 结构:
html
<div
class="hero-section"
data-bg="hero-background.jpg"
style="background-color: #f0f0f0;"
>
<!-- 内容 -->
</div>
4.5 配置参数详解
javascript
const observer = new IntersectionObserver(callback, {
// root: 指定根元素(默认为视口)
root: document.querySelector('#scrollArea'),
// rootMargin: 根元素的外边距(提前/延迟加载)
rootMargin: '50px 0px', // 上下提前 50px,左右不变
// threshold: 交叉比例阈值
threshold: [0, 0.25, 0.5, 0.75, 1], // 多个阈值
});
rootMargin 示例:
javascript
// 提前 100px 开始加载
rootMargin: '100px'
// 上下提前 100px,左右提前 50px
rootMargin: '100px 50px'
// 上提前 100px,右提前 50px,下提前 80px,左提前 30px
rootMargin: '100px 50px 80px 30px'
// 延迟加载(元素完全进入视口后才加载)
rootMargin: '-50px'
threshold 示例:
javascript
// 元素刚进入视口就触发
threshold: 0
// 元素 50% 可见时触发
threshold: 0.5
// 元素完全可见时触发
threshold: 1
// 多个阈值(0%, 25%, 50%, 75%, 100% 时都会触发)
threshold: [0, 0.25, 0.5, 0.75, 1]
4.6 性能对比
| 指标 | 滚动监听 | IntersectionObserver |
|---|---|---|
| CPU 占用 | 高(主线程) | 低(异步) |
| 内存占用 | 中 | 低 |
| 触发频率 | 高(60+ 次/秒) | 低(按需触发) |
| 代码复杂度 | 高 | 低 |
| 浏览器优化 | 无 | 有 |
| 性能评分 | 60 分 | 95 分 |
4.7 浏览器兼容性
| 浏览器 | 版本 |
|---|---|
| Chrome | 51+ |
| Firefox | 55+ |
| Safari | 12.1+ |
| Edge | 15+ |
| IE | ❌ 不支持 |
Polyfill 方案:
javascript
// 检测浏览器是否支持
if (!('IntersectionObserver' in window)) {
// 动态加载 polyfill
import('intersection-observer').then(() => {
// 初始化懒加载
const lazyLoad = new IntersectionLazyLoad();
lazyLoad.init();
});
} else {
// 直接使用
const lazyLoad = new IntersectionLazyLoad();
lazyLoad.init();
}
4.8 适用场景
- ✅ 现代浏览器项目(Chrome 51+, Safari 12.1+)
- ✅ 需要高性能的懒加载
- ✅ 长列表、瀑布流、信息流
- ✅ 需要精确控制加载时机
4.9 结论
⭐️ 强烈推荐:IntersectionObserver 是目前最佳的图片懒加载方案,性能优秀、代码简洁、易于维护。
五、方案三:浏览器原生 loading="lazy"(最简单)
5.1 实现原理
HTML5 新增的 loading 属性,浏览器原生支持图片懒加载,无需任何 JavaScript 代码。
5.2 基本用法
html
<!-- 懒加载 -->
<img src="image.jpg" loading="lazy" alt="描述" />
<!-- 立即加载(默认) -->
<img src="image.jpg" loading="eager" alt="描述" />
<!-- 自动(浏览器决定) -->
<img src="image.jpg" loading="auto" alt="描述" />
5.3 iframe 也支持
html
<iframe src="video.html" loading="lazy"></iframe>
5.4 优势
✅ 零代码 :无需任何 JavaScript
✅ 性能最优 :浏览器底层优化
✅ 自动优化 :浏览器根据网络状况自动调整
✅ 维护成本低:无需管理监听器、Observer 等
5.5 劣势
❌ 兼容性差 :Safari 15.4+ 才支持
❌ 无法自定义 :无法控制提前加载距离
❌ 无回调:无法监听加载完成事件
5.6 浏览器兼容性
| 浏览器 | 版本 |
|---|---|
| Chrome | 77+ |
| Firefox | 75+ |
| Safari | 15.4+ |
| Edge | 79+ |
| IE | ❌ 不支持 |
5.7 渐进增强方案
html
<!-- 方案 1:loading + data-src(兼容老浏览器) -->
<img
src="placeholder.jpg"
data-src="real-image.jpg"
loading="lazy"
alt="描述"
class="lazy-image"
/>
<script>
// 检测浏览器是否支持 loading="lazy"
if ('loading' in HTMLImageElement.prototype) {
// 支持:直接设置 src
document.querySelectorAll('img[data-src]').forEach((img) => {
img.src = img.dataset.src;
});
} else {
// 不支持:使用 IntersectionObserver 降级
const lazyLoad = new IntersectionLazyLoad();
lazyLoad.init();
}
</script>
html
<!-- 方案 2:使用 <picture> 标签 -->
<picture>
<source
srcset="image-large.jpg"
media="(min-width: 1200px)"
loading="lazy"
/>
<source
srcset="image-medium.jpg"
media="(min-width: 768px)"
loading="lazy"
/>
<img
src="image-small.jpg"
loading="lazy"
alt="响应式图片"
/>
</picture>
5.7 适用场景
- ✅ 只需要兼容现代浏览器(Chrome 77+, Safari 15.4+)
- ✅ 追求零代码、零维护
- ✅ 不需要自定义加载逻辑
- ✅ 简单的图片懒加载需求
5.8 结论
⭐️ 推荐使用 :如果不需要兼容老浏览器,loading="lazy" 是最简单、最优雅的方案。
六、方案对比总结
| 维度 | 滚动监听 | IntersectionObserver | loading="lazy" |
|---|---|---|---|
| 性能 | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 兼容性 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| 代码复杂度 | 高 | 中 | 零代码 |
| 可定制性 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐ |
| 维护成本 | 高 | 中 | 零维护 |
| 推荐指数 | ⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
七、业界最佳实践
7.1 混合方案(推荐)⭐️
结合 loading="lazy" 和 IntersectionObserver,实现渐进增强:
javascript
class HybridLazyLoad {
constructor() {
this.supportsNativeLazy = 'loading' in HTMLImageElement.prototype;
this.observer = null;
}
init() {
const images = document.querySelectorAll('img[data-src]');
if (this.supportsNativeLazy) {
// 支持原生懒加载:直接设置 src 和 loading 属性
images.forEach((img) => {
img.src = img.dataset.src;
img.loading = 'lazy';
img.removeAttribute('data-src');
});
} else {
// 不支持:使用 IntersectionObserver 降级
this.observer = new IntersectionObserver(
(entries) => this._handleIntersection(entries),
{ rootMargin: '50px' }
);
images.forEach((img) => this.observer.observe(img));
}
}
_handleIntersection(entries) {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute('data-src');
this.observer.unobserve(img);
}
});
}
}
// 使用
const lazyLoad = new HybridLazyLoad();
lazyLoad.init();
7.2 响应式图片懒加载
html
<!-- 使用 srcset 和 sizes -->
<img
srcset="
small.jpg 480w,
medium.jpg 800w,
large.jpg 1200w,
xlarge.jpg 1600w
"
sizes="
(max-width: 600px) 480px,
(max-width: 1000px) 800px,
(max-width: 1400px) 1200px,
1600px
"
src="medium.jpg"
loading="lazy"
alt="响应式图片"
/>
<!-- 使用 <picture> 标签 -->
<picture>
<source
media="(min-width: 1200px)"
srcset="desktop.jpg"
loading="lazy"
/>
<source
media="(min-width: 768px)"
srcset="tablet.jpg"
loading="lazy"
/>
<img
src="mobile.jpg"
loading="lazy"
alt="响应式图片"
/>
</picture>
7.3 WebP 格式支持
html
<picture>
<!-- WebP 格式(现代浏览器) -->
<source
srcset="image.webp"
type="image/webp"
loading="lazy"
/>
<!-- JPEG 格式(降级) -->
<img
src="image.jpg"
loading="lazy"
alt="描述"
/>
</picture>
7.4 避免布局抖动(CLS)
问题: 图片加载前后高度变化,导致页面跳动
解决方案 1:设置固定尺寸
html
<img
src="image.jpg"
loading="lazy"
width="800"
height="600"
alt="描述"
/>
解决方案 2:使用 aspect-ratio
html
<img
src="image.jpg"
loading="lazy"
style="aspect-ratio: 16/9; width: 100%;"
alt="描述"
/>
解决方案 3:使用 padding-top 技巧
html
<div class="image-wrapper">
<img
src="image.jpg"
loading="lazy"
alt="描述"
/>
</div>
<style>
.image-wrapper {
position: relative;
width: 100%;
padding-top: 56.25%; /* 16:9 比例 = 9/16 * 100% */
}
.image-wrapper img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
7.5 预加载关键图片
html
<!-- 首屏关键图片:使用 preload -->
<link rel="preload" as="image" href="hero.jpg" />
<!-- 首屏图片:不使用懒加载 -->
<img src="hero.jpg" loading="eager" alt="首屏大图" />
<!-- 非首屏图片:使用懒加载 -->
<img src="image.jpg" loading="lazy" alt="描述" />
7.6 图片加载优先级
html
<!-- 高优先级(首屏关键图片) -->
<img src="hero.jpg" loading="eager" fetchpriority="high" alt="首屏" />
<!-- 低优先级(非关键图片) -->
<img src="icon.jpg" loading="lazy" fetchpriority="low" alt="图标" />
<!-- 自动优先级(默认) -->
<img src="image.jpg" loading="lazy" fetchpriority="auto" alt="描述" />
八、参考资料
- MDN - Lazy loading
- MDN - IntersectionObserver
- Web.dev - Browser-level image lazy-loading
- Can I Use - loading attribute
- Can I Use - IntersectionObserver