图片懒加载

在一个内容丰富的电商页面或图片流社区中,如果用户一打开页面,服务器就把所有图片——包括滚动十几屏才能看到的——全部下载回来,会带来灾难性的后果:

  • 首屏加载极慢:几十上百张图片的请求在开始阶段全部并发触发,带宽瞬间饱和。
  • 流量浪费:用户只看了前三屏就关掉了,第十屏的图根本没机会被看到,却已经白白下载了。
  • Core Web Vitals 暴跌:LCP(最大内容绘制)和 FID(首次输入延迟)双双恶化。

图片懒加载(Lazy Loading) 的核心思路是:只加载用户当前能看到(或即将看到)的图片,剩余图片暂时以占位符替代,等待用户真正滚动到附近时再加载真实资源

方案一:原生懒加载(一行搞定)

现代浏览器已经原生支持图片懒加载!只需要给 <img> 标签加上 loading="lazy" 属性即可:

<img src="photo.jpg" loading="lazy" alt="产品图" />

浏览器会自动在图片即将进入视口时才发起请求。

优缺点:

  • ✅ 零 JS 代码,无性能损耗,实现成本极低。
  • ✅ 现代浏览器全面支持(Chrome 77+,Firefox 75+,Safari 15.4+)。
  • ❌ 无法精细控制触发时机(提前量固定由浏览器决定)。
  • ❌ IE 完全不支持。

方案二:IntersectionObserver(生产首选)

这是目前生产环境中最主流、最灵活的方案。

原理与无限滚动类似:用标准的 IntersectionObserver API 观察每一张图片,一旦该图片进入(或即将进入)视口,就把它真实的 src 地址从 data-src 属性上读出来并赋值,从而触发图片的真实加载。

<!-- 初始用 data-src 储存真实地址,src 先给一张极小的占位图(或空) -->
<img class="lazy" data-src="real-photo.jpg" src="placeholder.gif" alt="产品图" />
const images = document.querySelectorAll('img.lazy');

const observer = new IntersectionObserver((entries, obs) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src; // 替换真实地址,触发加载
      img.classList.remove('lazy');
      obs.unobserve(img); // 加载完后停止观察,节省资源
    }
  });
}, {
  // 提前 200px 触发加载,让用户感知不到任何等待
  rootMargin: '0px 0px 200px 0px'
});

images.forEach(img => observer.observe(img));

可交互 Demo

图片懒加载演示
import React, { useEffect, useRef } from "react";

// 生成带颜色的 SVG 占位图(避免使用外部图片服务)
const getPlaceholderUrl = (i: number) => {
  const colors = ["#e3f2fd", "#f3e5f5", "#e8f5e9", "#fff3e0", "#fce4ec", "#e0f7fa"];
  const c = colors[i % colors.length];
  const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='250' style='background:${c}'><text x='50%' y='50%' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='18' fill='#999'>Loading...</text></svg>`;
  return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
};

const getRealImageUrl = (i: number) => {
  const colors = ["#1a237e", "#4a148c", "#1b5e20", "#e65100", "#880e4f", "#006064"];
  const labels = ["深蓝图片", "深紫图片", "深绿图片", "深橙图片", "深粉图片", "深青图片"];
  const c = colors[i % colors.length];
  const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='250' style='background:${c}'><text x='50%' y='50%' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='18' fill='white'>✅ ${labels[i % labels.length]} #${i + 1} 已加载</text></svg>`;
  return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
};

const LazyImage = ({ index }: { index: number }) => {
  const imgRef = useRef<HTMLImageElement>(null);

  useEffect(() => {
    const img = imgRef.current;
    if (!img) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          // 模拟 300ms 网络延迟后加载真实图片
          setTimeout(() => {
            img.src = getRealImageUrl(index);
          }, 300);
          observer.unobserve(img);
        }
      },
      { rootMargin: "0px 0px 100px 0px" }
    );

    observer.observe(img);
    return () => observer.disconnect();
  }, [index]);

  return (
    <div style={{ marginBottom: "12px" }}>
      <img
        ref={imgRef}
        src={getPlaceholderUrl(index)}
        alt={`图片 ${index + 1}`}
        style={{ width: "100%", height: "120px", objectFit: "cover", borderRadius: "6px", display: "block", transition: "opacity 0.3s" }}
      />
      <p style={{ margin: "4px 0 0", fontSize: "12px", color: "#999" }}>图片 #{index + 1}(向下滚动触发加载)</p>
    </div>
  );
};

export default () => (
  <div style={{ height: "320px", overflowY: "auto", border: "1px solid #eee", borderRadius: "8px", padding: "12px" }}>
    <p style={{ color: "#666", fontSize: "13px", margin: "0 0 12px" }}>向下滚动列表,观察图片从占位图变为"真实图片"的时机</p>
    {Array.from({ length: 10 }).map((_, i) => (
      <LazyImage key={i} index={i} />
    ))}
  </div>
);

方案三:过时的 scroll + getBoundingClientRect(了解即可)

IntersectionObserver 普及之前,开发者们使用:

function isInViewport(el) {
  const rect = el.getBoundingClientRect();
  return rect.top < window.innerHeight && rect.bottom >= 0;
}

// 配合节流的 scroll 监听
window.addEventListener('scroll', throttle(() => {
  lazyImages.forEach(img => {
    if (isInViewport(img)) {
      img.src = img.dataset.src;
    }
  });
}, 200));

缺点getBoundingClientRect() 会强制触发回流;scroll 事件即便加了节流依然有性能损耗;代码量多。——被 IntersectionObserver 完全取代。


高频面试题剖析

Q:如何实现图片懒加载?有哪几种方案?各有什么优缺点?

回答思路:

  1. 说明问题根源:图片资源大、首屏并发请求多会导致加载慢、流量浪费和 Core Web Vitals 下降(LCP 提高),懒加载是最直接的优化手段。
  2. 方案一(原生 loading="lazy":直接给 <img> 加属性,零 JS 成本,浏览器原生支持,适合大多数场景;缺点是无法控制加载提前量,且 IE 不支持。
  3. 方案二(IntersectionObserver,重点):目前推荐的主流方案。初始时将真实 src 存在 data-src,用 IntersectionObserver 监听图片进入视口,一旦触发就赋值 src,并立刻 unobserve。可通过 rootMargin 控制提前量,异步无回流,性能极优。
  4. 方案三(scroll + getBoundingClientRect:旧时代方案,需手动节流,有强制回流风险,现已基本被淘汰,用于了解历史背景。
  5. 工程补充:在 Vue/React 中可以封装成 LazyImage 组件或使用 v-lazy 等指令库;还可结合图片的 srcsetsizes 属性实现响应式分辨率按需加在加上懒加载的双重优化。