Intersection Observer API(Intersection Observer API)
一、为什么需要 Intersection Observer
1.1 传统滚动检测的问题
在 Intersection Observer 出现之前,检测元素是否进入视口需要使用 scroll 事件或定时轮询。这些方法有几个根本性的问题:
性能问题:scroll 事件在滚动时高频触发,即使使用节流(throttle)也会带来性能开销。更糟糕的是,每次事件处理中调用 getBoundingClientRect() 会触发强制同步布局(layout thrashing),进一步加剧性能问题。
边缘情况复杂:要准确判断元素是否"进入视口",需要考虑多种边界情况——滚动方向、元素大小变化、父元素滚动、iframe 嵌套等。
// 传统方式的性能问题
let lastKnownScrollY = 0;
function checkElementVisibility() {
const element = document.getElementById('target');
const rect = element.getBoundingClientRect();
// getBoundingClientRect() 触发强制同步布局
const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
if (isVisible) {
loadContent();
}
}
window.addEventListener('scroll', () => {
lastKnownScrollY = window.scrollY;
checkElementVisibility(); // 滚动时持续执行,性能差
});
1.2 Intersection Observer 的优势
Intersection Observer API 是浏览器原生提供的观察者模式实现,它的优势在于:
- 异步执行:回调在事件循环的微任务阶段执行,不阻塞主线程
- 自动批量处理:多个观察目标的变化在同一次回调中处理
- 无需手动计算:浏览器自动处理所有边界情况
- 低开销:不需要在滚动时执行任何计算
// Intersection Observer 的优雅实现
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadContent();
observer.unobserve(entry.target); // 加载后停止观察
}
});
}, {
root: null, // 使用视口作为根
threshold: 0.1 // 10% 可见时触发
});
observer.observe(document.getElementById('target'));
二、API 详解
2.1 基本用法
const observer = new IntersectionObserver(callback, options);
observer.observe(targetElement);
observer.unobserve(targetElement);
observer.disconnect();
callback 接收一个 IntersectionObserverEntry 数组:
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
console.log('Target:', entry.target);
console.log('isIntersecting:', entry.isIntersecting);
console.log('intersectionRatio:', entry.intersectionRatio);
console.log('boundingClientRect:', entry.boundingClientRect);
console.log('rootBounds:', entry.rootBounds);
console.log('intersectionRect:', entry.intersectionRect);
});
}, options);
2.2 IntersectionObserverEntry 属性
interface IntersectionObserverEntry {
// 目标元素
target: Element;
// 目标元素边界矩形(相对于根元素)
boundingClientRect: DOMRectReadOnly;
// 根元素边界矩形(null 表示视口)
rootBounds: DOMRectReadOnly | null;
// 相交矩形(目标元素与根元素的交集)
intersectionRect: DOMRectReadOnly;
// 相交比例(0 到 1)
intersectionRatio: number;
// 是否正在与根相交
isIntersecting: boolean;
// 目标进入/离开根的时间戳
time: DOMHighResTimeStamp;
}
2.3 options 配置
const options = {
// 观察目标元素的父元素(或祖先元素)
// null 表示使用视口作为根
root: document.querySelector('.scroll-container'),
// root 元素的外边距
// 类似于 CSS 的 margin,可以扩大/缩小观察范围
rootMargin: '0px 0px -100px 0px', // 下边距 -100px
// 相交比例阈值数组
// 可以设置多个阈值,在不同相交比例时触发回调
threshold: [0, 0.25, 0.5, 0.75, 1],
// 或单个阈值
threshold: 0.5 // 50% 可见时触发
};
rootMargin 的语法:
// 与 CSS margin 类似的语法
rootMargin: '10px' // 所有方向 10px
rootMargin: '-10px 20px' // 上下 -10px,左右 20px
rootMargin: '0px 0px -50% 0px' // 底部向上收缩 50%
三、懒加载实现
3.1 图片懒加载
这是 Intersection Observer 最常见的应用场景:
class LazyImageLoader {
constructor(options = {}) {
this.options = {
root: null,
rootMargin: '50px', // 提前 50px 开始加载
threshold: 0.1,
...options
};
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
this.options
);
this.loadedCount = 0;
}
observe(element) {
// 存储原始 src
element.dataset.src = element.src;
element.src = ''; // 先清除,placeholder 可以在 CSS 中设置
element.dataset.loaded = 'false';
this.observer.observe(element);
}
handleIntersection(entries) {
entries.forEach(entry => {
if (entry.isIntersecting && entry.target.dataset.loaded === 'false') {
this.loadImage(entry.target);
}
});
}
loadImage(element) {
const src = element.dataset.src;
const img = new Image();
img.onload = () => {
element.src = src;
element.dataset.loaded = 'true';
this.loadedCount++;
this.observer.unobserve(element);
};
img.onerror = () => {
console.error('Failed to load image:', src);
element.dataset.loaded = 'error';
};
img.src = src;
}
disconnect() {
this.observer.disconnect();
}
}
// HTML
// <img data-src="real-image.jpg" class="lazy" alt="description">
// 使用
const loader = new LazyImageLoader();
document.querySelectorAll('.lazy').forEach(img => loader.observe(img));
3.2 背景图片懒加载
class LazyBackgroundLoader {
constructor(options = {}) {
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{ threshold: 0.1, ...options }
);
}
observe(element) {
element.dataset.bgSrc = element.style.backgroundImage;
element.style.backgroundImage = 'none';
this.observer.observe(element);
}
handleIntersection(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
const element = entry.target;
const bgSrc = element.dataset.bgSrc;
element.style.backgroundImage = bgSrc;
this.observer.unobserve(element);
}
});
}
}
3.3 视频懒加载
// 对于 <video> 元素,懒加载 poster 和预加载元数据
const videoObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const video = entry.target;
if (entry.isIntersecting) {
// 进入视口:开始加载
video.poster = video.dataset.poster;
video.preload = 'metadata';
// 如果需要自动播放
if (video.dataset.autoplay) {
video.play().catch(() => {});
}
} else {
// 离开视口:暂停播放
video.pause();
}
});
}, { threshold: 0.25 });
document.querySelectorAll('video[data-lazy]').forEach(v => videoObserver.observe(v));
四、无限滚动
4.1 基础实现
class InfiniteScroller {
constructor(fetcher, options = {}) {
this.fetcher = fetcher; // async function that returns next page
this.options = {
rootMargin: '200px', // 距离底部 200px 时触发加载
threshold: 0,
...options
};
this.page = 1;
this.loading = false;
this.hasMore = true;
this.container = document.getElementById('content');
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
this.options
);
}
async handleIntersection(entries) {
const entry = entries[0];
if (entry.isIntersecting && !this.loading && this.hasMore) {
await this.loadMore();
}
}
async loadMore() {
this.loading = true;
this.showLoader();
try {
const items = await this.fetcher(this.page);
if (items.length === 0) {
this.hasMore = false;
this.showEndMessage();
} else {
this.appendItems(items);
this.page++;
}
} catch (error) {
this.showError(error);
} finally {
this.loading = false;
this.hideLoader();
}
}
appendItems(items) {
items.forEach(item => {
const el = document.createElement('div');
el.className = 'item';
el.textContent = JSON.stringify(item);
this.container.appendChild(el);
});
}
showLoader() {
const loader = document.getElementById('loader');
if (loader) loader.style.display = 'block';
}
hideLoader() {
const loader = document.getElementById('loader');
if (loader) loader.style.display = 'none';
}
showEndMessage() {
const end = document.getElementById('end-message');
if (end) end.style.display = 'block';
this.observer.disconnect();
}
showError(error) {
console.error('Failed to load:', error);
}
observe(triggerElement) {
this.observer.observe(triggerElement);
}
}
4.2 带分页的无限滚动
// 使用 sentinel 元素触发加载
const scroller = new InfiniteScroller(async (page) => {
const response = await fetch(`/api/posts?page=${page}&limit=20`);
return response.json();
});
// 观察 sentinel 元素
scroller.observe(document.getElementById('sentinel'));
五、其他应用场景
5.1 广告可见性追踪
class AdVisibilityTracker {
constructor() {
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{ threshold: [0, 0.25, 0.5, 0.75, 1] }
);
this.impressions = new Map();
}
track(adElement, adId) {
adElement.dataset.adId = adId;
adElement.dataset.firstVisible = '';
adElement.dataset.totalVisibleTime = '0';
adElement.dataset.lastVisibleTime = '';
this.observer.observe(adElement);
}
handleIntersection(entries) {
entries.forEach(entry => {
const adId = entry.target.dataset.adId;
const now = Date.now();
if (entry.isIntersecting) {
// 广告开始可见
if (!entry.target.dataset.firstVisible) {
entry.target.dataset.firstVisible = now;
}
entry.target.dataset.lastVisibleTime = now;
} else {
// 广告不再可见,计算可见时间
if (entry.target.dataset.lastVisibleTime) {
const lastTime = parseInt(entry.target.dataset.lastVisibleTime);
const currentTotal = parseInt(entry.target.dataset.totalVisibleTime) || 0;
entry.target.dataset.totalVisibleTime = currentTotal + (now - lastTime);
// 发送可见性数据到广告服务器
this.reportImpression(adId, entry.target.dataset.totalVisibleTime);
}
}
});
}
reportImpression(adId, visibleTime) {
// 发送可见性报告
console.log(`Ad ${adId} visible for ${visibleTime}ms`);
}
}
5.2 动画触发
// 当元素进入视口时触发动画
const animationObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate');
// 或者使用 Web Animations API
entry.target.animate([
{ opacity: 0, transform: 'translateY(20px)' },
{ opacity: 1, transform: 'translateY(0)' }
], {
duration: 500,
easing: 'ease-out'
});
animationObserver.unobserve(entry.target);
}
});
}, { threshold: 0.2 });
document.querySelectorAll('.animate-on-scroll').forEach(el => {
animationObserver.observe(el);
});
5.3 "阅读更多"功能
// 检测用户是否阅读完一篇文章
class ReadingProgress {
constructor() {
this.progress = 0;
this.article = document.querySelector('article');
this.progressBar = document.getElementById('reading-progress');
if (!this.article) return;
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{
threshold: Array.from({ length: 101 }, (_, i) => i / 100) // 每 1% 触发
}
);
this.observer.observe(this.article);
}
handleIntersection(entries) {
const entry = entries[0];
const rect = entry.boundingClientRect;
const windowHeight = window.innerHeight;
// 计算阅读进度
// 元素顶部在视口顶部时为 0,底部在视口底部时为 1
if (entry.isIntersecting) {
const elementTop = rect.top;
const elementHeight = rect.height;
// 顶部不可见时(向下滚动超过元素顶部)= 0%
// 底部不可见时(向上滚动超过元素底部)= 100%
const visibleTop = Math.max(0, elementTop);
const visibleBottom = Math.min(windowHeight, elementTop + elementHeight);
this.progress = Math.min(1, Math.max(0,
(visibleBottom - visibleTop) / windowHeight +
(windowHeight - visibleBottom) / elementHeight
));
this.progressBar.style.width = `${this.progress * 100}%`;
} else {
// 完全不可见
if (rect.top > windowHeight) {
this.progress = 1; // 已经滚过了
}
}
}
}
六、注意事项与最佳实践
6.1 内存管理
class MemorySafeObserver {
constructor() {
this.observers = new Map();
}
observe(element, callback) {
const observer = new IntersectionObserver((entries) => {
callback(entries);
});
observer.observe(element);
this.observers.set(element, observer);
}
unobserve(element) {
const observer = this.observers.get(element);
if (observer) {
observer.disconnect();
this.observers.delete(element);
}
}
disconnectAll() {
this.observers.forEach(observer => observer.disconnect());
this.observers.clear();
}
}
6.2 与 ResizeObserver 配合
// 同时观察交叉和尺寸变化
class VisibilityTracker {
constructor(element) {
this.element = element;
this.intersectionObserver = null;
this.resizeObserver = null;
this.setupIntersectionObserver();
this.setupResizeObserver();
}
setupIntersectionObserver() {
this.intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.onVisible();
}
});
});
this.intersectionObserver.observe(this.element);
}
setupResizeObserver() {
this.resizeObserver = new ResizeObserver((entries) => {
entries.forEach(entry => {
const width = entry.contentRect.width;
const height = entry.contentRect.height;
this.onResize(width, height);
});
});
this.resizeObserver.observe(this.element);
}
onVisible() {
console.log('Element is visible');
}
onResize(width, height) {
console.log(`Size changed: ${width}x${height}`);
}
destroy() {
this.intersectionObserver?.disconnect();
this.resizeObserver?.disconnect();
}
}
参考资料
- MDN: Intersection Observer API
- W3C Intersection Observer Specification
- Google Developers: Efficiently Load Third-Party JavaScript