一张 8K 海报差点把首屏拖垮

你给后台管理系统加了一个「企业风采」模块,运营同学一口气上传了 200 张 8K 宣传海报。首屏直接飙到 8.3 s,LCP 红得发紫。
老板一句「能不能像朋友圈那样滑到哪看到哪?」——于是你把懒加载重新翻出来折腾了一轮。


解决方案:三条技术路线,你全踩了一遍

1. 最偷懒:原生 loading="lazy"

一行代码就能跑,浏览器帮你搞定。

 1<img
 2  src="https://cdn.xxx.com/poster1.jpg"
 3  loading="lazy"
 4  decoding="async"
 5  width="800" height="450"
 6/>

🔍 关键决策点

  • loading="lazy" 2020 年后现代浏览器全覆盖,IE 全军覆没。
  • 必须写死 width/height,否则 CLS 会抖成 PPT。

适用场景:内部系统、用户浏览器可控,且图片域名已开启 Accept-Ranges: bytes(支持分段加载)。


2. 最稳妥:scroll 节流 + getBoundingClientRect

老项目里还有 5% 的 IE11 用户,我们只能回到石器时代。

 1
 2const lazyImgs = [...document.querySelectorAll('[data-src]')];
 3let ticking = false;
 4
 5const loadIfNeeded = () => {
 6  if (ticking) return;
 7  ticking = true;
 8  requestAnimationFrame(() => {
 9    lazyImgs.forEach((img, idx) => {
10      const { top } = img.getBoundingClientRect();
11      if (top < window.innerHeight + 200) { 
12        img.src = img.dataset.src;
13        lazyImgs.splice(idx, 1); 
14      }
15    });
16    ticking = false;
17  });
18};
19
20window.addEventListener('scroll', loadIfNeeded, { passive: true });

🔍 关键决策点

  • requestAnimationFrame 把 30 ms 的节流降到 16 ms,肉眼不再掉帧。
  • 预加载阈值 200 px,实测 4G 网络滑动不白屏。

缺点:滚动密集时 CPU 占用仍高,列表越长越卡。


3. 最优雅:IntersectionObserver 精准观测

新项目直接上 Vue3 + TypeScript,我们用 IntersectionObserver 做统一调度。

 1
 2export const useLazyLoad = (selector = '.lazy') => {
 3  onMounted(() => {
 4    const imgs = document.querySelectorAll<HTMLImageElement>(selector);
 5    const io = new IntersectionObserver(
 6      (entries) => {
 7        entries.forEach((e) => {
 8          if (e.isIntersecting) {
 9            const img = e.target as HTMLImageElement;
10            img.src = img.dataset.src!;
11            img.classList.add('fade-in'); 
12            io.unobserve(img);            
13          }
14        });
15      },
16      { rootMargin: '100px', threshold: 0.01 } 
17    );
18    imgs.forEach((img) => io.observe(img));
19  });
20};
  1. 浏览器合成线程把「目标元素与视口交叉状态」异步推送到主线程。
  2. 主线程回调里只做一件事:把 data-src 搬到 src,然后 unobserve
  3. 整个滚动期间,零事件监听,CPU 占用 < 1%。

原理剖析:从「事件驱动」到「观测驱动」

维度scroll + 节流IntersectionObserver
触发时机高频事件(~30 ms)浏览器内部合成帧后回调
计算量每帧遍历 N 个元素仅通知交叉元素
线程占用主线程合成线程 → 主线程
兼容性IE9+Edge79+(可 polyfill)
代码体积0.5 KB0.3 KB(含 polyfill 2 KB)

一句话总结:把「我每隔 16 ms 问一次」变成「浏览器你告诉我啥时候到」。


应用扩展:把懒加载做成通用指令

在 Vue3 项目里,我们干脆封装成 v-lazy 指令,任何元素都能用。

 1
 2const lazyDirective = {
 3  mounted(el: HTMLImageElement, binding) {
 4    const io = new IntersectionObserver(
 5      ([entry]) => {
 6        if (entry.isIntersecting) {
 7          el.src = binding.value; 
 8          io.disconnect();
 9        }
10      },
11      { rootMargin: '50px 0px' }
12    );
13    io.observe(el);
14  },
15};
16
17app.directive('lazy', lazyDirective);

模板里直接写:

 1<img v-lazy="item.url" :alt="item.title" />

举一反三:三个变体场景思路

  1. 无限滚动列表
    IntersectionObserver 绑在「加载更多」占位节点上,触底即请求下一页,再把新节点继续 observe,形成递归观测链。

  2. 广告曝光统计
    广告位 50% 像素可见且持续 1 s 才算一次曝光。设置 threshold: 0.5 并在回调里用 setTimeout 延迟 1 s 上报,离开视口时 clearTimeout

  3. 背景图懒加载
    背景图没有 src,可以把真实地址塞在 style="--bg: url(...)",交叉时把 background-image 设成 var(--bg),同样零回流。


小结

  • 浏览器新特性能救命的,就别再卷节流函数了。
  • 写死尺寸、加过渡、及时 unobserve,是懒加载不翻车的三件套。
  • 把观测器做成指令/组合式函数,后续业务直接零成本接入。

现在你的「企业风采」首屏降到 1.2 s,老板滑得开心,运营继续传 8K 图,世界和平。

个人笔记记录 2021 ~ 2025