IntersectionObserver in Practice: Lazy Loading, Infinite Scroll, and Viewport Detection
性能优化(Updated Jun 2, 2026)
IntersectionObserver: High-Performance Viewport Detection
IntersectionObserver is a native asynchronous viewport intersection API that replaces traditional scroll event listeners:
| Aspect | scroll listener | IntersectionObserver |
|---|---|---|
| Execution | Main thread, sync | Browser internal, async |
| Trigger frequency | Every frame possible | Only on intersection change |
| Performance cost | High (manual throttle) | Low (browser-optimized) |
| Computation | getBoundingClientRect() |
Compositor thread |
| Support | All browsers | 98%+ global coverage |
Core API Deep Dive
Basic Usage
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
console.log(entry.isIntersecting);
console.log(entry.intersectionRatio);
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: Early/Delayed Triggering
// Trigger 200px before entering viewport (preload)
const lazyObserver = new IntersectionObserver(callback, {
rootMargin: '200px 0px'
});
// Trigger only when fully inside viewport
const fullObserver = new IntersectionObserver(callback, {
rootMargin: '-100px 0px'
});
threshold: Precise Trigger Control
// Trigger at every 10% intersection
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]
});
// Only when fully visible
const observer2 = new IntersectionObserver(callback, {
threshold: 1.0
});
Practice 1: Image Lazy Loading
Basic Implementation
<img data-src="photo.webp" alt="description" 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));
}
}
Progressive Loading (Blur → Sharp)
class ProgressiveImageLoader {
private observer: IntersectionObserver;
constructor() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const img = entry.target as HTMLImageElement;
const thumb = img.dataset.thumb!;
img.src = thumb;
img.classList.add('blurred');
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;
}
Practice 2: Infinite Scroll
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();
}
}
Practice 3: Virtual Scrolling
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);
}
}
Practice 4: Exposure Tracking
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 });
});
Performance Comparison
| Scenario | scroll listener | IntersectionObserver | Gain |
|---|---|---|---|
| 1000 lazy images | ~60fps (throttle 200ms) | ~60fps (no jank) | Smoothness ↑ |
| Scroll events/sec | ~300 | ~5 | 60× ↓ |
| getBoundingClientRect | 1000 per trigger | 0 (browser computes) | 100% ↓ |
| Main thread time | 15-30ms/frame | <1ms/callback | 30× ↓ |
Browser Compatibility
| Browser | Version | Notes |
|---|---|---|
| Chrome | 51+ | Full support |
| Firefox | 55+ | Full support |
| Safari | 12.1+ | Full support |
| Edge | 15+ | Full support |
| IE | None | Needs polyfill |
if ('IntersectionObserver' in window) {
// Use IntersectionObserver
} else {
// Fallback: load all images immediately
document.querySelectorAll('.lazy-img').forEach((img) => {
(img as HTMLImageElement).src = (img as HTMLImageElement).dataset.src!;
});
}
Best Practices
- Unobserve promptly: Stop observing after loading to reduce callbacks
- Sensible rootMargin:
200-500pxfor lazy load,0pxfor exposure tracking - Batch observations: One Observer for many elements, not one per element
- Threshold choice:
[0]for lazy load,[0, 0.25, 0.5, 0.75, 1]for progress - Disconnect on cleanup: Call
disconnect()on unmount to prevent leaks
Summary
IntersectionObserver is the standard viewport detection API for modern frontends, offering orders-of-magnitude performance gains over scroll listeners. Lazy loading, infinite scroll, exposure tracking, and animation triggers are its four core use cases, with rootMargin and threshold enabling fine-grained control.
Use Image Compress to optimize lazy-loaded image sizes, Image Resize to normalize dimensions, and Image Convert to convert to WebP format.
Try these browser-local tools — no sign-up required →
#IntersectionObserver#懒加载#虚拟滚动#性能#视口检测