IntersectionObserver 实战:图片懒加载、无限列表与有效曝光
本文目录
滚动页面时,我们常常要回答“这个元素现在进入观察区域了吗”。过去常见做法是在 scroll 事件里反复读取 getBoundingClientRect(),再自行计算位置。目标多、滚动频繁时,这些工作容易挤占主线程;读写布局交错时,还可能诱发布局抖动。
IntersectionObserver 把“目标与根区域的相交变化”交给浏览器异步观察。它适合懒加载、列表分页、进入视口动画和曝光候选判断。它不是逐帧滚动传感器:回调只在首次观察以及交叉状态跨过配置阈值时通知,不承诺给出每一个像素位置。
可以直接在本文运行一个本地 Observer 实验,也可以在在线实验室使用更完整的视口/滚动容器配置,观察交叉比例和事件日志。
先建立准确的模型
一个 observer 持有一组共享配置,可以观察多个 target。每个 target 的变化通过 IntersectionObserverEntry 批量交给回调。浏览器决定合适的调度时机,回调仍运行在主线程,因此回调应短小:更新状态、安排轻量任务即可,不要在里面解析大文件或执行长计算。
intersectionRatio 是交叉矩形面积相对 target 边界框面积的比例,通常落在 0 到 1 之间。isIntersecting 表示 target 与 root 的几何相交状态。不要只检查“回调触发过”就判定曝光:observe() 后会有首次异步通知,target 当时可能仍在区域外;还要结合 isIntersecting 和业务阈值判断。
有一个特殊边界:target 的边界框面积为 0 时,规范规定 isIntersecting 为真会得到 intersectionRatio === 1,否则为 0。因此,不要用零尺寸元素充当哨兵再把比例当成可见面积;分页 sentinel 应有明确的非零尺寸,并且业务上仍要检查加载锁和末页状态。对于非零尺寸目标,边界刚好接触时 isIntersecting 也可能为真而比例仍为 0。
交叉区域按矩形计算。圆形、透明遮罩、其他元素盖住内容等视觉因素,不一定能从普通相交结果推断出来。即使 intersectionRatio 为 1,也只能说明几何交叉比例达到阈值,不代表用户认真阅读了内容。
三个关键选项
| 选项 | 含义 | 常见选择 |
|---|---|---|
root | 用于计算相交的祖先区域;null 表示浏览器视口 | 普通页面滚动用 null;内部滚动列表用对应滚动容器 |
rootMargin | 对 root 边界向外扩张或向内收缩,支持 1–4 个 px 或 % 值 | 提前加载可用正边距;只在更靠近区域时触发可用负边距 |
threshold | target 交叉比例跨过哪些点时通知,可以是数字或数组 | 0 表示边界附近就通知;[0, 0.5, 1] 观察进入、半可见和完全可见 |
rootMargin 的百分比值(包括上下边距)都相对于尚未扩张的 root 宽度计算,并不等同于 CSS 中上下百分比按包含块高度计算的直觉。需要固定垂直预加载距离时,通常用 px 更容易预测;用百分比时要把窄屏、横屏和内部滚动容器都纳入测试。
设置了元素型 root 时,所有 target 都必须是它的后代。配置不同的 target 不能塞进同一个 observer:不同的 root、边距或阈值需要不同 observer。
较新的 scrollMargin 用于调整 root 到 target 路径上的嵌套滚动容器裁剪区域;旧浏览器支持情况需按目标设备确认。trackVisibility 会尝试检测遮挡、滤镜等视觉状态,但计算成本更高、浏览器支持有限且算法偏保守;广告或合规曝光场景应先验证兼容性,不能把它当成普通 isIntersecting 的无条件替代品。
回调中的 entry
回调收到的是一批 entry,不是单个元素。常用字段包括:
target:本次变化对应的 DOM 元素。isIntersecting:是否处于几何相交状态。intersectionRatio:交叉面积比例。boundingClientRect、intersectionRect、rootBounds:本次计算相关矩形。time:这条 entry 被生成的时间;如果一批通知需要排序,应按它处理,而不是假定数组顺序就是业务时间顺序。
一次滚动可能让多个 target 越过阈值,浏览器会把它们放到同一批回调里。阈值是通知边界,不是连续采样频率;如果需求是精确像素位置或持续追踪滚动速度,应选择更合适的事件与调度方案。
图片懒加载
下面的示例让图片接近视口时再设置真实地址。正 rootMargin 提前触发,避免图片已经进入屏幕才开始请求。成功启动加载后就 unobserve,不再为已完成的目标接收多余通知。
const lazyImages = document.querySelectorAll('img[data-src]');
if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries, observer) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const image = entry.target;
const source = image.dataset.src;
if (source) {
image.src = source;
image.removeAttribute('data-src');
observer.unobserve(image);
}
}
}, {
root: null,
rootMargin: '300px 0px',
threshold: 0,
});
lazyImages.forEach((image) => imageObserver.observe(image));
} else {
// 渐进增强:不支持观察器时,仍加载内容,不要让图片永久空白。
lazyImages.forEach((image) => {
if (image.dataset.src) image.src = image.dataset.src;
});
}
真实页面还应提供有意义的 alt、稳定占位尺寸和加载失败状态。对普通图片,也可以评估浏览器原生 loading="lazy";只有需要自定义预加载边距或与其他状态联动时,再引入 observer。
无限列表
监听列表末尾的 sentinel,比监听每次滚动再猜“是否到底”更直接。root 应指向实际滚动容器;loading 锁防止一段时间内重复发起请求,hasNextPage 则避免到末页后继续请求。仍应保留可聚焦的“加载更多”按钮:它既是观察器不支持时的退路,也是请求失败后的明确重试入口。加载与错误状态通过 role="status" 播报,避免只依赖按钮禁用或视觉变化。
<section class="list-scroll" data-scroll-container aria-label="文章列表">
<ul id="article-list" data-list></ul>
<div class="list-sentinel" data-list-sentinel aria-hidden="true"></div>
<button type="button" data-load-more aria-controls="article-list">加载更多</button>
<p data-load-status role="status" aria-live="polite"></p>
</section>
示例把列表和 sentinel 放在同一个内部滚动容器中;空元素默认没有面积,sentinel 需要明确的非零尺寸:
.list-scroll {
max-block-size: 24rem;
overflow-y: auto;
}
.list-sentinel {
width: 1px;
height: 1px;
}
下面把 fetchNextPage({ signal })、appendItems(list, items) 留作业务注入点。插入列表时应创建 DOM 节点并写入 textContent,不要把接口文本拼接到 innerHTML。
const list = document.querySelector('[data-list]');
const sentinel = document.querySelector('[data-list-sentinel]');
const loadMoreButton = document.querySelector('[data-load-more]');
const status = document.querySelector('[data-load-status]');
let loading = false;
let hasNextPage = true;
let disposed = false;
let activeController = null;
if (!list || !sentinel || !loadMoreButton || !status) {
throw new Error('列表分页所需的 DOM 节点不存在');
}
async function loadNextPage() {
if (loading || !hasNextPage || disposed) return;
loading = true;
loadMoreButton.disabled = true;
status.textContent = '正在加载…';
const controller = new AbortController();
activeController = controller;
try {
const page = await fetchNextPage({ signal: controller.signal });
if (disposed) return;
if (!page || !Array.isArray(page.items) || typeof page.hasNextPage !== 'boolean') {
throw new TypeError('分页响应格式不正确');
}
appendItems(list, page.items);
hasNextPage = page.hasNextPage;
status.textContent = hasNextPage ? `已加载 ${page.items.length} 条。` : '已加载全部内容。';
loadMoreButton.disabled = !hasNextPage;
if (!hasNextPage) pageObserver?.unobserve(sentinel);
} catch (error) {
if (!disposed && error?.name !== 'AbortError') {
status.textContent = '加载失败,请重试。';
loadMoreButton.disabled = false;
}
} finally {
if (activeController === controller) activeController = null;
loading = false;
}
}
function onIntersection(entries) {
if (entries.some((entry) => entry.target === sentinel && entry.isIntersecting)) {
void loadNextPage();
}
}
const pageObserver = typeof IntersectionObserver === 'function'
? new IntersectionObserver(onIntersection, {
root: document.querySelector('[data-scroll-container]'),
rootMargin: '0px 0px 400px 0px',
threshold: 0,
})
: null;
function onLoadMore() {
void loadNextPage();
}
loadMoreButton.addEventListener('click', onLoadMore);
pageObserver?.observe(sentinel);
function dispose() {
disposed = true;
pageObserver?.disconnect();
loadMoreButton.removeEventListener('click', onLoadMore);
activeController?.abort();
}
在 React effect cleanup、Vue 的 onBeforeUnmount 或 Astro 页面卸载时调用 dispose();分页游标应只在请求成功后推进,必要时按稳定 ID 去重。若新页面很短,sentinel 仍留在预加载区域内,产品可能需要继续补页,但应设置自动补页上限,避免短列表形成无限请求环。
曝光候选与停留时间
“滚进视口”与“有效曝光”不是一回事。一个常见的业务定义是:至少 50% 几何可见、持续 1 秒、文档处于前台,才记录一次候选曝光。下面演示一次性去重与后台切换处理;真实埋点仍需按产品口径、隐私政策和反作弊要求设计。
const cards = [...document.querySelectorAll('[data-card-id]')];
const pending = new Map();
const reported = new WeakSet();
const currentlyQualified = new WeakSet();
function cancelTimer(card) {
const timer = pending.get(card);
if (timer !== undefined) clearTimeout(timer);
pending.delete(card);
}
const exposureObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
const card = entry.target;
const qualifies = entry.isIntersecting
&& entry.intersectionRatio >= 0.5
&& document.visibilityState === 'visible';
if (!qualifies || reported.has(card)) {
currentlyQualified.delete(card);
cancelTimer(card);
continue;
}
currentlyQualified.add(card);
if (!pending.has(card)) {
const timer = setTimeout(() => {
pending.delete(card);
if (!currentlyQualified.has(card) || document.visibilityState !== 'visible' || reported.has(card)) return;
reported.add(card);
reportExposure({ id: card.dataset.cardId });
}, 1000);
pending.set(card, timer);
}
}
}, { threshold: [0, 0.5] });
cards.forEach((card) => exposureObserver.observe(card));
function onVisibilityChange() {
if (document.visibilityState === 'hidden') {
for (const card of pending.keys()) {
currentlyQualified.delete(card);
cancelTimer(card);
}
return;
}
// 重新观察会触发新一轮异步初始检查,避免后台期间的停留计时被累计。
for (const card of cards) {
if (!reported.has(card)) {
exposureObserver.unobserve(card);
exposureObserver.observe(card);
}
}
}
document.addEventListener('visibilitychange', onVisibilityChange);
function dispose() {
for (const card of pending.keys()) cancelTimer(card);
exposureObserver.disconnect();
document.removeEventListener('visibilitychange', onVisibilityChange);
}
在 React 的 effect cleanup、Vue 的 onBeforeUnmount 或 Astro 页面卸载阶段调用 dispose(),不要让旧 observer 持续持有页面节点。去重状态放在服务端还是客户端,也要结合跨页和跨设备的产品定义决定。
普通 IntersectionObserver 只提供几何相交信息,本身不会知道标签页是否在后台,也不保证 target 没被其他元素遮挡。使用 document.visibilityState 可以排除后台标签页的计时,但仍不能证明用户正在阅读。若采用 trackVisibility,要检测浏览器支持并评估更高的计算成本与兼容性。开启它时应同时设置 delay;浏览器会把小于 100ms 的值提升到至少 100ms,建议在可接受范围内使用更大的间隔。此能力采用保守判断,不适合替代业务侧的停留、去重与反作弊规则。
常见误区
- 把 observer 当作 scroll 事件。 它只在初始检查和阈值跨越时通知,不适合逐像素动画或精确滚动进度。
- 只检查
entry.isIntersecting。 初始通知、边界接触和业务可见比例不同,要依据实际定义组合条件。 - target 不在指定 root 内。 内部滚动列表里 target 必须是 root 的后代;否则不要期待得到相对于该容器的观察结果。
- 把
rootMargin当成 target 的 CSS margin。 它改变的是用于计算的根区域,不会改变布局,也不会真的让元素提前显示。 - 忽略停用和页面生命周期。 单次目标用完后
unobserve(target);整组销毁用disconnect();组件卸载时清 timer 和事件监听。 - 认为相交就代表阅读。 普通 observer 不能可靠判断遮挡、窗口前后台或人的注意力;广告曝光和分析应把规则、可见性、去重和隐私边界分别设计。
- 没有不支持时的退路。 Observer 属于渐进增强:主内容不能因为 API 缺失而永久隐藏。动画最好在观察器不可用时直接显示内容。
怎么选
| 需求 | 建议 |
|---|---|
| 仅延迟加载图片 | 优先评估原生 loading="lazy";要自定义预加载距离时用 observer |
| 内部滚动列表分页 | 容器作为 root,末尾放 sentinel,并加请求锁与末页状态 |
| 进入视口播放动效 | 进入后执行一次并 unobserve;不支持时让内容保持可见 |
| 可见比例/曝光候选 | 设定少量业务阈值,另外处理前后台状态、停留计时和去重 |
| 精确滚动位置或逐帧动画 | 使用合适的 scroll/timeline 方案与帧调度,不要用 observer 模拟采样器 |
参考资料
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。