IntersectionObserver 实战:懒加载、无限滚动与视口检测
性能优化(更新于 2026年6月2日)
IntersectionObserver:高性能视口检测 API
IntersectionObserver 是浏览器提供的异步视口交叉检测 API,替代传统的 scroll 事件监听:
| 对比项 | scroll 监听 | IntersectionObserver |
|---|---|---|
| 执行线程 | 主线程同步 | 浏览器内部异步 |
| 触发频率 | 每帧都可能触发 | 仅交叉状态变化时 |
| 性能开销 | 高(需手动 throttle) | 低(浏览器优化) |
| 计算方式 | getBoundingClientRect() |
合成线程计算 |
| 兼容性 | 全浏览器 | 98%+ 全球覆盖 |
核心 API 详解
基本用法
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
console.log(entry.isIntersecting); // 是否可见
console.log(entry.intersectionRatio); // 可见比例 0~1
console.log(entry.target); // 观察的目标元素
console.log(entry.boundingClientRect); // 边界矩形
console.log(entry.rootBounds); // 根元素边界
console.log(entry.time); // 变化时间戳
});
},
{
root: null, // 视口作为根(默认)
rootMargin: '0px', // 根元素边距扩展
threshold: [0, 0.5, 1.0] // 触发阈值
}
);
observer.observe(document.querySelector('.target'));
observer.unobserve(element);
observer.disconnect();
rootMargin:提前/延迟触发
// 提前 200px 触发(用于懒加载预加载)
const lazyObserver = new IntersectionObserver(callback, {
rootMargin: '200px 0px' // 上下扩展 200px
});
// 仅在完全进入视口时触发
const fullObserver = new IntersectionObserver(callback, {
rootMargin: '-100px 0px' // 缩小根范围
});
threshold:精确控制触发时机
// 元素每进入 10% 就触发一次
const observer = new IntersectionObserver(callback, {
threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
});
// 仅关心完全可见
const observer = new IntersectionObserver(callback, {
threshold: 1.0
});
实战一:图片懒加载
基础实现
<img data-src="photo.webp" alt="描述" class="lazy-img" />
class LazyImageLoader {
private observer: IntersectionObserver;
constructor() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement;
img.src = img.dataset.src!;
img.removeAttribute('data-src');
this.observer.unobserve(img);
}
});
},
{ rootMargin: '200px 0px' }
);
}
observe(container: HTMLElement) {
container.querySelectorAll<HTMLImageElement>('.lazy-img')
.forEach((img) => this.observer.observe(img));
}
}
渐进式加载(模糊 → 清晰)
class ProgressiveImageLoader {
private observer: IntersectionObserver;
constructor() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const img = entry.target as HTMLImageElement;
// 1. 先加载缩略图(20px 宽,模糊)
const thumb = img.dataset.thumb!;
img.src = thumb;
img.classList.add('blurred');
// 2. 再加载高清图
const fullSrc = img.dataset.full!;
const fullImg = new Image();
fullImg.onload = () => {
img.src = fullSrc;
img.classList.remove('blurred');
};
fullImg.src = fullSrc;
this.observer.unobserve(img);
});
},
{ rootMargin: '300px 0px' }
);
}
}
.blurred {
filter: blur(20px);
transition: filter 0.3s ease;
}
实战二:无限滚动
class InfiniteScroll {
private observer: IntersectionObserver;
private loading = false;
private page = 1;
constructor(
private container: HTMLElement,
private loadMore: (page: number) => Promise<HTMLElement[]>
) {
const sentinel = document.createElement('div');
sentinel.className = 'scroll-sentinel';
container.appendChild(sentinel);
this.observer = new IntersectionObserver(
async (entries) => {
if (!entries[0].isIntersecting || this.loading) return;
this.loading = true;
this.page++;
const items = await this.loadMore(this.page);
items.forEach((item) => container.insertBefore(item, sentinel));
this.loading = false;
},
{ rootMargin: '500px' }
);
this.observer.observe(sentinel);
}
destroy() {
this.observer.disconnect();
}
}
实战三:虚拟滚动
class VirtualScroller<T> {
private observer: IntersectionObserver;
private visibleRange = { start: 0, end: 0 };
private itemHeight = 48;
constructor(
private container: HTMLElement,
private items: T[],
private renderItem: (item: T, index: number) => HTMLElement
) {
this.container.style.height = `${this.items.length * this.itemHeight}px`;
this.container.style.position = 'relative';
this.setupObserver();
}
private setupObserver() {
const sentinelTop = document.createElement('div');
const sentinelBottom = document.createElement('div');
this.container.prepend(sentinelTop);
this.container.appendChild(sentinelBottom);
this.observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
this.updateVisibleItems();
});
});
this.observer.observe(sentinelTop);
this.observer.observe(sentinelBottom);
}
private updateVisibleItems() {
const scrollTop = this.container.scrollTop;
const viewHeight = this.container.clientHeight;
const start = Math.floor(scrollTop / this.itemHeight);
const end = Math.min(
start + Math.ceil(viewHeight / this.itemHeight) + 2,
this.items.length
);
if (start === this.visibleRange.start && end === this.visibleRange.end) return;
this.visibleRange = { start, end };
this.render();
}
private render() {
const fragment = document.createDocumentFragment();
for (let i = this.visibleRange.start; i < this.visibleRange.end; i++) {
const el = this.renderItem(this.items[i], i);
el.style.position = 'absolute';
el.style.top = `${i * this.itemHeight}px`;
fragment.appendChild(el);
}
this.container.innerHTML = '';
this.container.appendChild(fragment);
}
}
实战四:曝光打点
class ExposureTracker {
private observer: IntersectionObserver;
private tracked = new WeakSet<Element>();
constructor(private onExpose: (element: Element) => void) {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
if (this.tracked.has(entry.target)) return;
this.tracked.add(entry.target);
this.onExpose(entry.target);
});
},
{ threshold: 0.5 }
);
}
track(elements: Element[]) {
elements.forEach((el) => this.observer.observe(el));
}
}
const tracker = new ExposureTracker((el) => {
const id = el.getAttribute('data-track-id');
analytics.track('element_exposed', { id });
});
性能对比
| 场景 | scroll 监听 | IntersectionObserver | 提升 |
|---|---|---|---|
| 1000 图片懒加载 | ~60fps(throttle 200ms) | ~60fps(无抖动) | 流畅度 ↑ |
| 滚动事件触发次数 | ~300 次/秒 | ~5 次/秒 | 60× ↓ |
| getBoundingClientRect | 1000 次/触发 | 0(浏览器计算) | 100% ↓ |
| 主线程占用 | 15-30ms/帧 | <1ms/回调 | 30× ↓ |
浏览器兼容性
| 浏览器 | 版本 | 备注 |
|---|---|---|
| Chrome | 51+ | 完整支持 |
| Firefox | 55+ | 完整支持 |
| Safari | 12.1+ | 完整支持 |
| Edge | 15+ | 完整支持 |
| IE | 不支持 | 需 polyfill |
// 降级方案
if ('IntersectionObserver' in window) {
// 使用 IntersectionObserver
} else {
// 降级:立即加载所有图片
document.querySelectorAll('.lazy-img').forEach((img) => {
(img as HTMLImageElement).src = (img as HTMLImageElement).dataset.src!;
});
}
最佳实践
- 及时 unobserve:元素加载后立即取消观察,减少回调触发
- 合理 rootMargin:懒加载用
200-500px,曝光打点用0px - 批量观察:创建一个 Observer 实例观察多个元素,而非每个元素一个
- threshold 选择:懒加载
[0],进度条[0, 0.25, 0.5, 0.75, 1] - disconnect 清理:组件卸载时调用
disconnect()避免内存泄漏
总结
IntersectionObserver 是现代前端视口检测的标准方案,相比 scroll 监听有数量级的性能优势。懒加载、无限滚动、曝光打点、动画触发是其四大核心场景,配合 rootMargin 和 threshold 可实现精细控制。
本站提供浏览器本地工具,免注册即可试用 →
#IntersectionObserver#懒加载#虚拟滚动#性能#视口检测