IntersectionObserver en la práctica: lazy loading, scroll infinito y detección de viewport
性能优化
IntersectionObserver: detección de viewport de alto rendimiento
IntersectionObserver es una API nativa asíncrona de intersección con el viewport que reemplaza a los listener de eventos scroll tradicionales:
| Aspecto | Listener de scroll | IntersectionObserver |
|---|---|---|
| Ejecución | Hilo principal, síncrono | Interno del navegador, asíncrono |
| Frecuencia de disparo | Posible en cada frame | Solo al cambiar la intersección |
| Costo de rendimiento | Alto (throttle manual) | Bajo (optimizado por el navegador) |
| Cálculo | getBoundingClientRect() |
Hilo del compositor |
| Soporte | Todos los navegadores | 98%+ de cobertura global |
Análisis profundo de la API central
Uso básico
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: disparo temprano/retardado
// 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: control preciso del disparo
// 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
});
Práctica 1: Lazy loading de imágenes
Implementación básica
<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));
}
}
Carga progresiva (borroso → nítido)
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;
}
Práctica 2: Scroll infinito
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();
}
}
Práctica 3: Scroll virtual
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);
}
}
Práctica 4: Seguimiento de exposición
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 });
});
Comparación de rendimiento
| Escenario | Listener de scroll | IntersectionObserver | Ganancia |
|---|---|---|---|
| 1000 imágenes lazy | ~60fps (throttle 200ms) | ~60fps (sin tirones) | Suavidad ↑ |
| Eventos de scroll/seg | ~300 | ~5 | 60× ↓ |
| getBoundingClientRect | 1000 por disparo | 0 (lo calcula el navegador) | 100% ↓ |
| Tiempo de hilo principal | 15-30ms/frame | <1ms/callback | 30× ↓ |
Compatibilidad de navegadores
| Navegador | Versión | Notas |
|---|---|---|
| Chrome | 51+ | Soporte completo |
| Firefox | 55+ | Soporte completo |
| Safari | 12.1+ | Soporte completo |
| Edge | 15+ | Soporte completo |
| IE | Ninguna | Requiere 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!;
});
}
Mejores prácticas
- Unobserve a tiempo: detén la observación tras cargar para reducir callbacks
- rootMargin sensato:
200-500pxpara lazy load,0pxpara seguimiento de exposición - Observaciones en lote: un Observer para muchos elementos, no uno por elemento
- Elección de threshold:
[0]para lazy load,[0, 0.25, 0.5, 0.75, 1]para progreso - Disconnect al limpiar: llama a
disconnect()al desmontar para evitar fugas de memoria
Resumen
IntersectionObserver es la API estándar de detección de viewport para frontends modernos, ofreciendo ganancias de rendimiento de órdenes de magnitud frente a los listener de scroll. Lazy loading, scroll infinito, seguimiento de exposición y disparadores de animación son sus cuatro casos de uso centrales, y rootMargin y threshold permiten un control fino.
Usa Image Compress para optimizar el tamaño de las imágenes lazy, Image Resize para normalizar dimensiones y Image Convert para convertir al formato WebP.
Prueba estas herramientas que se ejecutan en tu navegador — no requieren registro →
#IntersectionObserver#懒加载#虚拟滚动#性能#视口检测