面试官最爱问的图片懒加载,我总结了这3种实现方式

图片懒加载(Lazy Loading)几乎是"性能优化"必考题:看起来简单,但一追问边界条件、兼容性、工程化落地,立刻能区分出"会写 demo"和"能上生产"的差别。本文按面试高频问法 + 项目实战,把 3 种实现方式一次讲清楚。


1. 什么是图片懒加载

图片懒加载的核心思想:先不加载视口外的图片,等它快进入视口时再加载

典型做法是:

  • 初始只给图片一个占位(空白/骨架/低质量缩略图)
  • 真实图片地址先放在 data-src
  • 当图片即将进入可视区域时,把 data-src 赋给 src,触发下载与渲染

技术图解

flowchart TB A[页面初始化] --> B[img 只渲染占位\n真实地址在 data-src] B --> C{图片接近视口?} C -- 否 --> D[继续滚动/观察] C -- 是 --> E[把 data-src -> src] E --> F[监听 load/error\n替换占位/兜底图]

2. 为什么需要懒加载

从"面试官视角",你需要能说出懒加载解决的是什么问题,以及代价是什么。

收益

  • 更快的首屏:减少首屏请求数、带宽占用和解码开销
  • 更小的内存压力:减少同时存在的图片解码位图(尤其长列表)
  • 更好的交互体验:滚动更顺滑,主线程更少被图片相关任务抢占

代价 / 风险点(面试加分)

  • 滚动监听方案可能引入频繁计算,需要节流/合并
  • 需要处理失败重试、占位、布局抖动(CLS)
  • SSR/预渲染场景下要考虑"首屏图片必须立即加载"

3. 实现方案一:scroll + getBoundingClientRect

这是最"原生、可控、兼容好"的方案,面试里常用来考你对 DOM 测量与性能的理解。

3.1 核心思路

  • 监听 scroll/resize(必要时监听 orientationchange
  • 对每张未加载图片做一次可视区域判断:img.getBoundingClientRect()
  • rect.top < viewportHeight + preload 时触发加载

技术图解

sequenceDiagram participant U as User Scroll participant W as Window participant L as LazyLoader participant I as Images U->>W: scroll W->>L: 触发回调(节流/合并) L->>I: 遍历未加载图片 I-->>L: getBoundingClientRect() L->>I: data-src -> src

3.2 代码示例(带注释)

HTML:

html 复制代码
<!-- 占位图建议是很小的 base64 或本地小图,避免额外请求 -->
<img
  class="lazy"
  src="placeholder.png"
  data-src="https://example.com/big-1.jpg"
  width="320"
  height="180"
  alt="demo"
/>

JS(推荐用 requestAnimationFrame 合并滚动期间的多次触发):

js 复制代码
const preloadPx = 200; // 提前 200px 预加载,避免滚动到可视区才开始下载

function inViewport(el) {
  const rect = el.getBoundingClientRect();
  const vh = window.innerHeight || document.documentElement.clientHeight;

  // rect.top < vh + preload:元素顶部进入"预加载区"
  // rect.bottom > 0:元素没有完全滚出上方(可选,避免反复触发)
  return rect.top < vh + preloadPx && rect.bottom > -preloadPx;
}

function loadImg(img) {
  const realSrc = img.getAttribute("data-src");
  if (!realSrc) return;

  // 防止重复加载
  img.setAttribute("data-loaded", "1");
  img.src = realSrc;

  img.addEventListener(
    "error",
    () => {
      // 失败兜底:替换为错误占位图,避免一直空白
      img.src = "error.png";
    },
    { once: true }
  );
}

function createScrollLazyLoader() {
  let ticking = false;

  const handler = () => {
    if (ticking) return;
    ticking = true;

    // 合并到下一帧,避免滚动事件高频触发造成的卡顿
    requestAnimationFrame(() => {
      const imgs = Array.from(document.querySelectorAll("img.lazy"))
        // 只处理未加载的
        .filter((img) => img.getAttribute("data-loaded") !== "1");

      imgs.forEach((img) => {
        if (inViewport(img)) loadImg(img);
      });

      // 全部加载完就解绑,避免无意义监听(面试加分点)
      const remain = document.querySelector(
        'img.lazy:not([data-loaded="1"])'
      );
      if (!remain) {
        window.removeEventListener("scroll", handler);
        window.removeEventListener("resize", handler);
      }

      ticking = false;
    });
  };

  // 初始化先跑一次,避免首屏图片不加载
  handler();

  window.addEventListener("scroll", handler, { passive: true });
  window.addEventListener("resize", handler);
}

createScrollLazyLoader();

3.3 面试追问点

  • 为什么 scrollpassive: true?减少主线程阻塞,提升滚动流畅度
  • 为什么不直接每次滚动都 getBoundingClientRect?测量+遍历成本高,需要节流/合帧
  • 列表很长怎么办?配合虚拟列表(只渲染视口附近 DOM)是更强的组合

4. 实现方案二:IntersectionObserver(推荐)

这是我在真实项目里最常用的方式:API 语义清晰、性能好、实现也简洁。

4.1 核心思路

浏览器在合适的时机告诉你:某个元素是否与根容器(默认视口)产生交叉。

你只要在回调里:

  • 判断 entry.isIntersecting
  • 加载图片
  • 取消观察 observer.unobserve(img)(避免重复回调)

技术图解

flowchart LR A[观察 img] --> B{isIntersecting?} B -- 否 --> A B -- 是 --> C[data-src -> src] C --> D[unobserve\n释放资源]

4.2 代码示例(带注释)

js 复制代码
function createIOLazyLoader() {
  const imgs = Array.from(document.querySelectorAll("img.lazy"));

  // rootMargin 用于"提前加载",等价于把视口向外扩一圈
  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (!entry.isIntersecting) return;

        const img = entry.target;
        const realSrc = img.getAttribute("data-src");
        if (!realSrc) {
          observer.unobserve(img);
          return;
        }

        // 标记已处理,避免某些场景下重复赋值
        img.setAttribute("data-loaded", "1");
        img.src = realSrc;

        img.addEventListener(
          "error",
          () => {
            img.src = "error.png";
          },
          { once: true }
        );

        observer.unobserve(img);
      });
    },
    {
      root: null, // 视口
      rootMargin: "200px 0px", // 上下提前 200px
      threshold: 0.01, // 进入一点点就触发
    }
  );

  imgs.forEach((img) => observer.observe(img));
}

// 如果你需要兼容非常老的浏览器,可以在这里做降级(见下文最佳实践)
createIOLazyLoader();

4.3 面试追问点

  • rootMarginthreshold 怎么选?前者控制提前量,后者控制触发时机
  • 为什么要 unobserve?减少回调次数与内存占用,避免重复加载
  • 监听容器滚动怎么办?把 root 设置为滚动容器即可(如某个列表容器)

5. 实现方案三:loading="lazy"

这是 HTML 原生属性:一句话就能懒加载,但它的控制力、兼容性和可预测性不如前两种。

5.1 使用方式

html 复制代码
<!-- 注意最好设置 width/height 或用 CSS 固定占位,避免 CLS -->
<img
  src="https://example.com/big-2.jpg"
  loading="lazy"
  width="320"
  height="180"
  alt="native lazy"
/>

5.2 面试追问点

  • 为什么仍然需要 width/height?避免布局抖动(CLS)是性能指标关键项
  • 是否所有图片都适合 loading="lazy"?首屏关键图不要懒加载,否则影响 LCP
  • 是否能控制提前量?原生行为由浏览器决定,可控性较弱

6. 三种方案对比

方案 兼容性 性能 控制力 工程复杂度 典型适用
scroll + getBoundingClientRect 最好 一般(需优化) 最强 需强兼容、需精细控制
IntersectionObserver 较好(可降级) 长列表/内容流(推荐)
loading="lazy" 现代浏览器好 最低 简单页面、对行为不敏感

面试里如果让你"选一种",建议回答:

  • 默认用 IntersectionObserver
  • 对极老环境做 scroll 降级
  • 对极简单场景可以直接 loading="lazy",但首屏关键图要排除

7. 实际项目最佳实践

把"能跑"变成"好用、可维护、可扩展",通常要补齐这些点。

7.1 首屏关键图:不要懒加载

规则很简单:影响 LCP 的图片(banner、主图、首屏卡片第一张)应优先加载

  • IntersectionObserver:直接不加 .lazy 类或不观察
  • 原生:不要加 loading="lazy"

7.2 占位与 CLS:固定尺寸/骨架屏

  • img 显式 width/height,或用容器按比例占位
  • 加载过程中可以显示骨架(灰块)/模糊缩略图(LQIP)

7.3 失败兜底与重试(面试高频)

建议:错误兜底图 + 可选一次重试(避免弱网短抖动导致永久失败)。

js 复制代码
function loadWithRetry(img, realSrc, retry = 1) {
  return new Promise((resolve) => {
    let tried = 0;

    const attempt = () => {
      tried += 1;
      img.src = realSrc;

      const cleanup = () => {
        img.removeEventListener("load", onLoad);
        img.removeEventListener("error", onError);
      };

      const onLoad = () => {
        cleanup();
        resolve(true);
      };

      const onError = () => {
        cleanup();
        if (tried <= retry) {
          // 简单重试:可以加上指数退避、换 CDN 域名等策略
          attempt();
        } else {
          img.src = "error.png";
          resolve(false);
        }
      };

      img.addEventListener("load", onLoad, { once: true });
      img.addEventListener("error", onError, { once: true });
    };

    attempt();
  });
}

7.4 结合响应式图片:srcset / sizes

懒加载解决"何时加载",srcset 解决"加载哪张更合适"。

html 复制代码
<img
  class="lazy"
  src="placeholder.png"
  data-src="https://example.com/img-800.jpg"
  data-srcset="https://example.com/img-400.jpg 400w, https://example.com/img-800.jpg 800w"
  sizes="(max-width: 600px) 400px, 800px"
  width="320"
  height="180"
  alt="responsive"
/>
js 复制代码
function applySrcset(img) {
  const src = img.getAttribute("data-src");
  const srcset = img.getAttribute("data-srcset");

  if (src) img.src = src;
  if (srcset) img.srcset = srcset;
}

7.5 统一封装:可插拔 + 可降级

项目里建议封装成一个小工具:优先用 IntersectionObserver,不支持就降级到 scroll。

js 复制代码
function createLazyImageLoader() {
  const supportsIO = typeof IntersectionObserver !== "undefined";
  if (supportsIO) {
    createIOLazyLoader();
  } else {
    createScrollLazyLoader();
  }
}

createLazyImageLoader();

8. 总结

  • 图片懒加载的本质:延后非关键资源的下载与解码,换取更快首屏与更低资源占用
  • 三种方案里:IntersectionObserver 在语义、性能、工程落地上最均衡,适合作为默认方案
  • 面试加分关键:能讲清楚 首屏排除、提前量(rootMargin)、解绑/释放、CLS、失败兜底、长列表与虚拟列表组合 这些"生产级细节"

如果你准备面试,建议最后用一句话收尾:

默认 IO 懒加载 + rootMargin 提前加载,首屏关键图不懒加载,失败兜底与尺寸占位保证体验与指标。

相关推荐
庄小焱2 小时前
Vue——Vue基础语法(1)
前端·javascript·vue.js·前端框架
weixin_443478512 小时前
flutter学习之状态管理相关组件
javascript·学习·flutter
掘金安东尼2 小时前
⏰前端周刊第 456 期(v2026.3.15)
前端·javascript·面试
还是大剑师兰特2 小时前
Vue3 通用可复用动态插槽组件(终极版)
前端·javascript·vue.js
nibabaoo3 小时前
前端开发攻略---在 Vue 3 项目中使用 vue-i18n 实现国际化多语言
前端·javascript·国际化·i18n·vue3
qq_437100663 小时前
ElasticSearch相关记录
大数据·前端·javascript·elasticsearch·全文检索
清空mega3 小时前
《Vue3 模板进阶:class/style 绑定、事件对象、修饰符、表单处理与高频易错点》
前端·javascript·vue.js
还是大剑师兰特3 小时前
Vue3 插槽完整实战(具名插槽 + 动态插槽)
前端·javascript·vue.js
fei_sun3 小时前
Vue+SpingBoot+MyBaits框架
前端·javascript·vue.js