前端图片懒加载方案全解析

3 阅读8分钟

本文深入探讨前端图片懒加载的各种实现方案,从传统的滚动监听到现代的 IntersectionObserver API,再到浏览器原生支持的 loading="lazy",帮助开发者选择最适合的技术方案。

一、为什么需要图片懒加载?

1.1 性能问题

在现代 Web 应用中,图片往往占据了页面总资源的 60-70%。如果一次性加载所有图片,会导致:

  • 首屏加载时间过长:用户需要等待所有图片下载完成
  • 带宽浪费:用户可能永远不会滚动到页面底部
  • 内存占用过高:大量图片同时存在于内存中
  • 用户体验差:页面卡顿、白屏时间长

1.2 典型应用场景

  • 长列表页面(商品列表、新闻列表)
  • 图片墙/瀑布流
  • 社交媒体信息流
  • 文章详情页
  • 相册/画廊

二、图片懒加载的演进历程

2.1 演进时间线

2010 年前      滚动监听 + getBoundingClientRect
2016         IntersectionObserver API 发布
2019         浏览器原生 loading="lazy" 支持
2020 年至今    混合方案 + 渐进增强

三、方案一:传统滚动监听(已过时)

3.1 实现原理

监听 scroll 事件,计算图片是否进入视口,如果进入则加载图片。

3.2 基本实现

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 结构

<!-- 使用 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 性能问题分析

// 问题 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 基本实现

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 渐进式加载(先加载缩略图)

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 响应式图片懒加载

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 结构:

<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 背景图片懒加载

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 结构:

<div 
  class="hero-section" 
  data-bg="hero-background.jpg"
  style="background-color: #f0f0f0;"
>
  <!-- 内容 -->
</div>

4.5 配置参数详解

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 示例:

// 提前 100px 开始加载
rootMargin: '100px'

// 上下提前 100px,左右提前 50px
rootMargin: '100px 50px'

// 上提前 100px,右提前 50px,下提前 80px,左提前 30px
rootMargin: '100px 50px 80px 30px'

// 延迟加载(元素完全进入视口后才加载)
rootMargin: '-50px'

threshold 示例:

// 元素刚进入视口就触发
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 浏览器兼容性

浏览器版本
Chrome51+
Firefox55+
Safari12.1+
Edge15+
IE❌ 不支持

Polyfill 方案:

// 检测浏览器是否支持
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 基本用法

<!-- 懒加载 -->
<img src="image.jpg" loading="lazy" alt="描述" />

<!-- 立即加载(默认) -->
<img src="image.jpg" loading="eager" alt="描述" />

<!-- 自动(浏览器决定) -->
<img src="image.jpg" loading="auto" alt="描述" />

5.3 iframe 也支持

<iframe src="video.html" loading="lazy"></iframe>

5.4 优势

零代码:无需任何 JavaScript
性能最优:浏览器底层优化
自动优化:浏览器根据网络状况自动调整
维护成本低:无需管理监听器、Observer 等

5.5 劣势

兼容性差:Safari 15.4+ 才支持
无法自定义:无法控制提前加载距离
无回调:无法监听加载完成事件

5.6 浏览器兼容性

浏览器版本
Chrome77+
Firefox75+
Safari15.4+
Edge79+
IE❌ 不支持

5.7 渐进增强方案

<!-- 方案 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>
<!-- 方案 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" 是最简单、最优雅的方案。


六、方案对比总结

维度滚动监听IntersectionObserverloading="lazy"
性能⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
兼容性⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
代码复杂度零代码
可定制性⭐⭐⭐⭐⭐⭐⭐⭐⭐
维护成本零维护
推荐指数⭐⭐⭐⭐⭐⭐⭐⭐⭐

七、业界最佳实践

7.1 混合方案(推荐)⭐️

结合 loading="lazy"IntersectionObserver,实现渐进增强:

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 响应式图片懒加载

<!-- 使用 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 格式支持

<picture>
  <!-- WebP 格式(现代浏览器) -->
  <source 
    srcset="image.webp" 
    type="image/webp"
    loading="lazy"
  />
  <!-- JPEG 格式(降级) -->
  <img 
    src="image.jpg" 
    loading="lazy" 
    alt="描述"
  />
</picture>

7.4 避免布局抖动(CLS)

问题: 图片加载前后高度变化,导致页面跳动

解决方案 1:设置固定尺寸

<img 
  src="image.jpg" 
  loading="lazy"
  width="800"
  height="600"
  alt="描述"
/>

解决方案 2:使用 aspect-ratio

<img 
  src="image.jpg" 
  loading="lazy"
  style="aspect-ratio: 16/9; width: 100%;"
  alt="描述"
/>

解决方案 3:使用 padding-top 技巧

<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 预加载关键图片

<!-- 首屏关键图片:使用 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 图片加载优先级

<!-- 高优先级(首屏关键图片) -->
<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="描述" />

八、参考资料