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

  1. Unobserve a tiempo: detén la observación tras cargar para reducir callbacks
  2. rootMargin sensato: 200-500px para lazy load, 0px para seguimiento de exposición
  3. Observaciones en lote: un Observer para muchos elementos, no uno por elemento
  4. Elección de threshold: [0] para lazy load, [0, 0.25, 0.5, 0.75, 1] para progreso
  5. 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#懒加载#虚拟滚动#性能#视口检测