用 IntersectionObserver 做图片懒加载与曝光统计:一次滚动性能优化实测
最近在优化一个商品列表页时遇到一个典型问题:页面一次渲染几十张商品图,首屏还没滑到下面的图片就已经全部发起了请求。结果首屏关键图片反而要排队,加载完成时间被拖长,手机上流量也白白浪费。排查之后,我把图片加载改成了 IntersectionObserver 方案,顺带把曝光统计也接了进来,效果很明显。
为什么不用 scroll 监听
最常见的懒加载写法是监听 scroll 事件,再用 getBoundingClientRect 逐个判断图片是否进入视口。这种写法有两个硬伤:第一,scroll 事件触发非常频繁,即使加了节流,每次回调里遍历几十个节点做矩形计算,依然会占用主线程,低端机上滚动会有顿挫感;第二,判断逻辑要自己处理节流时间、边界条件,稍不注意就会出现图片迟迟不加载或者重复加载。
IntersectionObserver 把“元素是否可见”这个判断交给了浏览器。它在渲染流水线里异步计算交叉状态,只有当目标元素的可见状态发生变化时才触发回调,不会跟着滚动事件高频执行,也不会打断滚动本身的流畅性。对开发者来说,只需要关心“可见了之后做什么”,代码反而更简单。
懒加载的核心写法
思路很直接:图片初始用一张轻量占位图,真实地址放在 data-src 里;用 observer 监听所有待加载图片,一旦进入视口就把 data-src 换成 src,然后解除监听。
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const img = entry.target;
img.src = img.dataset.src;
io.unobserve(img);
});
}, {
rootMargin: '200px 0px',
threshold: 0
});
document.querySelectorAll('img[data-src]').forEach((img) => io.observe(img));
rootMargin 设为 200px 很关键:它把“进入视口”的判定范围提前放宽,图片在用户滑到之前就开始加载,滑到时通常已经完成,视觉上没有从空白到图片的闪烁感。加载完成后立即 unobserve,避免重复触发,也减少 observer 的持续开销。
同一个 API 顺手做曝光统计
曝光统计的需求和懒加载几乎一样:知道某个卡片是否被用户真正看到过。以前的做法同样是 scroll 事件里算位置,容易重复上报、也容易漏报。用同一个 observer、把 threshold 设为 0.5,再配合一个定时器,就能判断“可见面积超过一半且停留超过 1 秒”才算一次有效曝光。
const timers = new Map();
const statIo = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const id = entry.target.dataset.id;
if (entry.isIntersecting) {
timers.set(id, setTimeout(() => {
navigator.sendBeacon('/api/expose', JSON.stringify({ id }));
statIo.unobserve(entry.target);
}, 1000));
} else {
clearTimeout(timers.get(id));
}
});
}, { threshold: 0.5 });
元素滑出视口时清掉定时器,这样快速划过的卡片不会被误算;上报后立即解除监听,避免重复统计。用 sendBeacon 发送,即使页面马上被关闭,上报请求也不会被轻易丢掉。
实测效果与三个坑
在我的测试页面上改完之后,首屏图片请求从 42 个降到 6 个,LCP 从 3.8 秒降到 1.9 秒左右,滚动时的帧率也稳定了不少。过程中踩到的三个坑值得记下来:
第一,图片容器一定要预留高度。可以用 aspect-ratio 或固定宽高比占位,否则图片加载完成后撑高页面,会把下面的内容顶下去,既影响体验,也会抬高 CLS 指标。
第二,动态新增的节点要重新 observe。如果列表是下拉加载更多,新渲染出来的图片不会自动进入观察列表,需要在数据更新后重新调用 observe。
第三,低版本浏览器需要降级。IntersectionObserver 在现代浏览器里支持很好,但如果要兼容旧环境,准备一个 scroll 兜底分支是更稳妥的做法。
小结
IntersectionObserver 的价值在于把“可见性判断”这件事从主线程的事件回调里抽了出来:懒加载、曝光统计、无限滚动这些看似不同的需求,背后都是同一个“元素进入视口时触发一次回调”的模型。先想清楚触发条件,再决定回调里做什么,代码会比到处绑 scroll 监听清爽得多。下次再遇到长列表页面,不妨先从这个 API 开始排查。
- 点赞
- 收藏
- 关注作者
评论(0)