用 IntersectionObserver 实现图片懒加载与占位占高

列表页一次性渲染 200 张商品图,首屏要下载十几 MB 的图片,用户滑动时白屏、闪烁、滚动位置乱跳,这是最典型的图片加载问题。把 loading="lazy" 加在 <img> 上只能解决「什么时候下载」,解决不了「下载之前这块地方占多高」。图片没有尺寸,浏览器在图片解码前把它当 0 高度处理,图片到达后页面被撑开,原本已经滚到第 80 条的用

列表页一次性渲染 200 张商品图,首屏要下载十几 MB 的图片,用户滑动时白屏、闪烁、滚动位置乱跳,这是最典型的图片加载问题。把 loading="lazy" 加在 <img> 上只能解决「什么时候下载」,解决不了「下载之前这块地方占多高」。图片没有尺寸,浏览器在图片解码前把它当 0 高度处理,图片到达后页面被撑开,原本已经滚到第 80 条的用户会突然被推到第 60 条附近,这就是 CLS(Cumulative Layout Shift)的来源,也是很多后台管理系统体验差的根因。

本文用原生 JavaScript 的 IntersectionObserver 做两件事:一是把图片加载推迟到即将进入视口时,减少无效请求;二是给每张图预留精确到像素的占位高度,让图片到达时页面不发生任何位移。下面所有代码都是可直接运行的完整实现,不依赖任何框架与构建工具。

为什么用 IntersectionObserver 而不是 scroll 事件

早期懒加载的写法是监听 scroll 事件,在回调里遍历所有图片、用 getBoundingClientRect() 判断是否进入视口。这段代码在移动端的问题有三个:一是 scroll 事件触发频率极高,回调里调用 getBoundingClientRect() 会强制浏览器同步计算布局(强制 reflow),200 张图遍历一遍就是 200 次布局计算;二是用户快速甩动时回调排队,主线程被占满,出现滑动掉帧;三是必须配合 throttle 手动节流,节流间隔写多少都不合适,写大了图片出现才加载,写小了等于没节流。

IntersectionObserver 把「元素是否与视口相交」这个判断交给浏览器内部完成,相交计算不占主线程,回调只在相交状态真正变化时触发一次。它天然支持阈值(threshold)和提前量(rootMargin),滚动容器无论是 window 还是某个 overflow: auto 的 div,都能通过 root 参数指定。

兼容性方面,IntersectionObserver 在 Chrome 51+、Firefox 55+、Safari 12.1+ 全部可用,IE11 不支持。如果项目仍需支持 IE11,必须准备降级方案,本文最后给出基于 scroll 的兜底实现。

占位占高的核心:提前知道图片的宽高比

懒加载本身很简单,难点在于「图片还没下载,怎么知道它渲染出来多高」。答案是:在高度的计算链路上,图片的原始宽高必须在下载之前就拿到。通常有三种数据来源:

  • 接口返回宽高字段。图片上传或转码时由服务端记录 width、height,随列表数据一起下发,这是最可靠的方式。
  • URL 约定。图片服务返回的 URL 中带尺寸参数,例如 ?w=800&h=600,前端解析 query 即可。
  • 固定比例容器。列表卡片统一使用 4:3 或 1:1 显示,直接用 aspect-ratio 或 padding-top 撑开,不依赖真实尺寸,代价是图片被裁切。

现代浏览器已经原生支持 aspect-ratio 属性,配合 width/height HTML 属性,图片占位可以做到零 JS 参与:

<img
  src="https://picsum.photos/id/1015/800/600"
  width="800"
  height="600"
  alt="山谷"
  style="max-width: 100%; height: auto;"
>

只要 <img> 上同时写了 width 和 height,浏览器在图片解码前就能根据这两个属性和 CSS 的 max-width: 100% 算出比例,再结合容器宽度得出占位高度。这是最省事的做法,但不是所有场景都适用:如果图片尺寸未知,或者需要响应式地按容器宽度决定裁切区域,就得手动用 padding-top 百分比方案兜底。

padding-top 方案依赖一个 CSS 特性:padding 的百分比值始终相对于包含块的宽度计算,与高度无关。所以给一个宽度自适应、高度为 0 的容器设 padding-top: 66.67%,它的高度就恒等于宽度的 2/3,比例 3:2 稳定成立:

<div class="ph" style="--ratio: 66.67%">
  <img data-src="https://picsum.photos/id/1015/800/533" alt="山谷">
</div>

<style>
  .ph {
    position: relative;
    width: 100%;
    padding-top: var(--ratio);
    overflow: hidden;
    background: #f2f3f5;
  }
  .ph > img {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
  }
</style>

比例的计算公式是 height / width * 100,比如 800×533 得到 533 / 800 * 100 = 66.625%,取两位小数不影响像素级对齐。这段 CSS 的好处是对布局零侵入,容器高度在 HTML 渲染时就已经确定,图片后来居上也不会改变任何兄弟节点的位置。

完整实现:IntersectionObserver + data-src

下面是完整可运行的实现,含图片加载失败重试、多图并发控制与销毁逻辑。把 HTML 和 JS 存成单个文件用浏览器打开即可验证效果。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>懒加载演示</title>
<style>
  body { margin: 0; font-family: system-ui, sans-serif; background: #fff; }
  .list { max-width: 720px; margin: 0 auto; padding: 16px; display: grid; gap: 12px; }
  .card { position: relative; width: 100%; padding-top: var(--ratio, 66.67%); overflow: hidden; border-radius: 8px; background: #f2f3f5; }
  .card > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; opacity: 0; transition: opacity .3s ease; }
  .card > img.loaded { opacity: 1; }
  .card > img.error { opacity: 1; outline: 2px solid #f5222d; outline-offset: -2px; }
  .sentinel { height: 1px; }
</style>
</head>
<body>
<div class="list" id="list"></div>
<div class="sentinel" id="sentinel"></div>

<script>
(function () {
  'use strict';

  // 1. 模拟后端返回的列表数据,width/height 是服务端记录的真实尺寸
  const DATA = [
    { id: 1015, w: 800, h: 533 },
    { id: 1016, w: 800, h: 1200 },
    { id: 1018, w: 800, h: 533 },
    { id: 1020, w: 800, h: 800 },
    { id: 1024, w: 800, h: 534 },
    { id: 1025, w: 800, h: 1200 },
    { id: 1035, w: 800, h: 533 },
    { id: 1039, w: 800, h: 1067 }
  ];

  const listEl = document.getElementById('list');

  // 2. 渲染占位 DOM,图片真实地址只写到 data-src
  function render(items) {
    const frag = document.createDocumentFragment();
    items.forEach(function (it) {
      const card = document.createElement('div');
      card.className = 'card';
      // padding-top 百分比 = 高 / 宽 * 100
      card.style.setProperty('--ratio', (it.h / it.w * 100).toFixed(4) + '%');

      const img = document.createElement('img');
      img.alt = '图片 ' + it.id;
      img.dataset.src = 'https://picsum.photos/id/' + it.id + '/' + it.w + '/' + it.h;
      img.dataset.retry = '0';
      img.width = it.w;
      img.height = it.h;
      card.appendChild(img);
      frag.appendChild(card);
    });
    listEl.appendChild(frag);
  }

  // 3. 单张图片的加载逻辑,带一次重试
  function loadImage(img) {
    const url = img.dataset.src;
    if (!url) return;
    delete img.dataset.src;

    img.addEventListener('load', function onLoad() {
      img.removeEventListener('load', onLoad);
      img.classList.add('loaded');
    });

    img.addEventListener('error', function onError() {
      img.removeEventListener('error', onError);
      const retry = Number(img.dataset.retry || 0);
      if (retry < 1) {
        img.dataset.retry = String(retry + 1);
        // 加时间戳绕开缓存中的失败结果
        img.src = url + (url.indexOf('?') > -1 ? '&' : '?') + 't=' + Date.now();
      } else {
        img.classList.add('error');
        img.alt = '图片加载失败';
      }
    });

    img.src = url;
  }

  // 4. 观察器:rootMargin 提前 300px 预加载,避免用户滑到时才请求
  const io = new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
      if (!entry.isIntersecting) return;
      const img = entry.target;
      io.unobserve(img);
      loadImage(img);
    });
  }, {
    root: null,
    rootMargin: '300px 0px',
    threshold: 0
  });

  // 5. 启动:渲染后统一观察
  render(DATA);
  document.querySelectorAll('img[data-src]').forEach(function (img) {
    io.observe(img);
  });

  // 6. 页面卸载时释放,防止 SPA 路由切换后观察器泄漏
  window.addEventListener('pagehide', function () {
    io.disconnect();
  });
})();
</script>
</body>
</html>

几个容易踩的细节值得单独说明。

rootMargin 的提前量不等于越大越好。300px 适合移动端列表,桌面端宽屏可以放到 600px。如果提前量过大,首屏可能一次性触发几十张图的请求,反而加剧带宽竞争。比较稳妥的做法是按设备指定:const margin = window.innerWidth < 768 ? '300px' : '600px'。

加载完成后必须 unobserve。观察器在元素相交状态变化时会持续回调,图片加载完成后若继续观察,每次滚动进出视口都会重复触发回调。在回调内先 unobserve 再加载,既省内存也省 CPU。

删除 data-src 而不是保留。有些实现用 img.dataset.src 判断是否加载过,但属性残留会导致后续逻辑误判。加载时执行 delete img.dataset.src,判断条件变成「有没有 data-src」即可,语义更清晰。注意 delete 对 dataset 有效,等价于 removeAttribute('data-src')。

图片地址用 src 赋值而不是 setAttribute。两者在懒加载场景效果相同,但直接赋值 img.src 会触发浏览器的图片加载流程,语义更直接。

常见报错与排查

实际项目中这几条报错出现频率最高,逐条给出原因与解法。

报错 / 现象 原因 解决
Uncaught TypeError: Failed to construct 'IntersectionObserver': rootMargin must be specified in pixels or percent. rootMargin 写成了 '300px 0 300px 0',其中 0 没带单位 所有值必须带 px 或 %,写成 '300px 0px'
Uncaught DOMException: Failed to execute 'observe' on 'IntersectionObserver': parameter 1 is not of type 'Element'. 传入 null 或字符串,常见于 querySelector 拼错 ID 观察前加 if (!el) return; 保护
图片始终不加载,控制台无报错 观察器 root 指向了错误容器,或元素本身 display:none display:none 的元素不产生布局盒,永远不会相交;切换显示后需重新 observe
图片加载后页面仍然跳动 占位容器没有设置比例,或 img 未绝对定位填满容器 检查 padding-top 是否生效、position: absolute 是否被覆盖
滚动容器内图片不加载 滚动的是内部 div 而非 window,root 仍是 null 把 root 改为该滚动容器的 DOM 引用

关于 root 有一个反直觉的点:当滚动发生在内层容器时,视口是浏览器窗口,但观察器的参照系不是视口,而是 root 指定的元素。如果 root 为 null,观察的是「目标与浏览器视口是否相交」,内层滚动容器中位于视口范围内但被容器裁掉的图片会被误判为可见,导致提前加载。所以只要存在内层滚动,就必须显式传 root:

const scrollBox = document.querySelector('.list');
const io = new IntersectionObserver(callback, {
  root: scrollBox,
  rootMargin: '0px 0px 200px 0px',
  threshold: 0
});

还有一种情况是图片在 tab 切换的隐藏面板里。隐藏面板内的图片在切换前布局高度为 0,切换后瞬间全部进入视口,观察器会一次性触发所有回调。如果面板内图片数量大,可以在切换后延迟一帧再观察:requestAnimationFrame(() => io.observe(img))。

降级方案与性能对比

降级方案只在 IntersectionObserver 不存在时启用,逻辑用一次性 scroll 监听 + 节流即可,不必追求极致:

function fallbackLazy() {
  const imgs = Array.prototype.slice.call(document.querySelectorAll('img[data-src]'));
  let ticking = false;

  function check() {
    const vh = window.innerHeight || document.documentElement.clientHeight;
    imgs.forEach(function (img, idx) {
      if (!img.dataset.src) return;
      const rect = img.getBoundingClientRect();
      if (rect.top < vh + 300 && rect.bottom > -300) {
        loadImage(img);
        imgs.splice(idx, 1);
      }
    });
    ticking = false;
  }

  function onScroll() {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(check);
  }

  window.addEventListener('scroll', onScroll, { passive: true });
  window.addEventListener('resize', onScroll, { passive: true });
  check();
}

if ('IntersectionObserver' in window) {
  // 走上面的观察器逻辑
} else {
  fallbackLazy();
}

注意 scroll 监听必须传 { passive: true },否则浏览器无法确定回调中是否会调用 preventDefault,滚动时主线程要等待 JS 执行完毕才能合成下一帧,掉帧会非常明显。这个参数在 Chrome 51+ 生效,是老项目中最容易被忽略的一处性能优化。

两种方案的性能差异在长列表上体现得很直接:500 张图片的列表中,scroll 方案在每次滚动时都要执行 500 次 getBoundingClientRect(即使有 splice 减少数量,首屏也接近 500 次),每次调用都会触发样式与布局的同步计算;IntersectionObserver 方案在初始化时只做一次 observe 注册,之后的相交计算由浏览器合成线程完成,滚动过程中主线程基本无感。

如果同时需要「进入视口时加载」和「列表无限滚动」,两者可以共用一个观察器,用 entry.target.dataset.type 区分目标类型,减少实例数量。观察器实例本身的创建成本不高,但每个实例都持有一份已观察元素的引用列表,数量多了会拖慢 GC,能复用就复用。

小结

图片懒加载的完整链路是三步:先用服务端下发的宽高(或固定比例)在 CSS 层算出占位高度,保证图片到达前后布局不变;再用 IntersectionObserver 在图片接近视口时把 data-src 赋给 src 并取消观察;最后为加载失败补一次带时间戳的重试,为不支持的环境留一条 scroll + requestAnimationFrame 的降级路径。占位占高是体验的下限,懒加载是带宽的上限,两者缺一不可。

FAQ

为什么图片加载完成后 opacity 从 0 到 1 的淡入会闪一下?

因为 transition 的起始值是元素首次渲染时的计算样式。opacity: 0 是在 <img> 被创建时应用的,如果 img.src 赋值发生在图片元素插入 DOM 之前,load 事件可能在元素还没进入渲染树时触发,loaded 类名加上去时过渡没有起点。解决办法是在 observe 之前确保元素已在 DOM 中,或者用 img.decode() 等解码完成后再加类:img.decode().then(() => img.classList.add('loaded')).catch(() => {})。

用 loading=”lazy” 原生属性和 IntersectionObserver 能一起用吗?

可以,但没必要。原生 loading="lazy" 只能告诉浏览器延迟加载,无法控制提前量、无法做加载失败重试、也无法与占位逻辑联动,且各浏览器对「何时开始加载」的判定策略不同(Chrome 大约在距离视口 1250px 至 2500px 的区间内,视网络类型调整)。如果需要精确控制加载时机或做埋点统计,用 IntersectionObserver 自己实现更可控。两者同时使用时,浏览器会以自身策略优先,data-src 还没赋值就不会发请求,实际行为由你的 JS 决定。

占位高度和图片实际高度差 1px,页面还是会轻微跳动怎么办?

这是百分比取整导致的累积误差。把比例保留到 4 位小数(例如 66.6250%)基本可以消除;如果容器宽度是奇数像素,小数点后误差仍可能让高度差 0.5px,浏览器按 1px 取整后就会跳动。更彻底的做法是给占位容器加 contain: layout size,或者直接用 aspect-ratio 属性配合 width: 100%,让浏览器自己做比例计算,避免 JS 参与取整。

SPA 路由切换后观察器需要手动销毁吗?

需要。组件卸载时如果观察器不 disconnect,它仍持有对已移除 DOM 的引用,既阻止 GC 回收,也可能在后续触发回调时操作已经不存在的元素。在 Vue 的 onBeforeUnmount、React 的 useEffect 清理函数里调用 io.disconnect() 即可,一个观察器实例对应一次销毁。

为什么 rootMargin 设置的提前量在移动端表现不稳定?

rootMargin 的参照是 root 的边界框,在浏览器地址栏收起/展开时视口高度变化,观察器会重新计算,个别机型上会出现回调延迟。移动端建议把提前量控制在 200px 到 400px 之间,同时给占位容器留出稳定的背景色,避免图片未到时的空白被用户感知为「卡顿」。