IntersectionObserver en pratique : lazy loading, défilement infini et détection de viewport

性能优化

IntersectionObserver : détection de viewport haute performance

IntersectionObserver est une API native asynchrone d'intersection avec le viewport qui remplace les écouteurs d'événements scroll traditionnels :

Aspect Écouteur de scroll IntersectionObserver
Exécution Thread principal, synchrone Interne au navigateur, asynchrone
Fréquence de déclenchement Possible à chaque frame Uniquement au changement d'intersection
Coût en performance Élevé (throttle manuel) Faible (optimisé par le navigateur)
Calcul getBoundingClientRect() Thread du compositeur
Prise en charge Tous les navigateurs Couverture mondiale 98%+

Analyse approfondie de l'API centrale

Utilisation de base

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 : déclenchement anticipé/retardé

// 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 : contrôle précis du déclenchement

// 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
});

Pratique 1 : Lazy loading d'images

Implémentation de base

<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));
  }
}

Chargement progressif (flou → net)

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;
}

Pratique 2 : Défilement infini

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();
  }
}

Pratique 3 : Défilement virtuel

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);
  }
}

Pratique 4 : Suivi d'exposition

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 });
});

Comparaison des performances

Scénario Écouteur de scroll IntersectionObserver Gain
1000 images lazy ~60fps (throttle 200ms) ~60fps (aucun à-coup) Fluidité ↑
Événements de scroll/sec ~300 ~5 60× ↓
getBoundingClientRect 1000 par déclenchement 0 (calculé par le navigateur) 100% ↓
Temps de thread principal 15-30ms/frame <1ms/callback 30× ↓

Compatibilité des navigateurs

Navigateur Version Notes
Chrome 51+ Prise en charge complète
Firefox 55+ Prise en charge complète
Safari 12.1+ Prise en charge complète
Edge 15+ Prise en charge complète
IE Aucune Nécessite un 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!;
  });
}

Bonnes pratiques

  1. Unobserve rapidement : arrêtez l'observation après le chargement pour réduire les callbacks
  2. rootMargin sensé : 200-500px pour le lazy load, 0px pour le suivi d'exposition
  3. Observations groupées : un seul Observer pour plusieurs éléments, pas un par élément
  4. Choix du threshold : [0] pour le lazy load, [0, 0.25, 0.5, 0.75, 1] pour la progression
  5. Disconnect lors du nettoyage : appelez disconnect() au démontage pour éviter les fuites mémoire

Résumé

IntersectionObserver est l'API standard de détection de viewport pour les frontends modernes, offrant des gains de performance de plusieurs ordres de grandeur par rapport aux écouteurs de scroll. Le lazy loading, le défilement infini, le suivi d'exposition et les déclencheurs d'animation sont ses quatre cas d'usage centraux, et rootMargin et threshold permettent un contrôle fin.

Utilisez Image Compress pour optimiser la taille des images lazy, Image Resize pour normaliser les dimensions et Image Convert pour convertir au format WebP.

Essayez ces outils exécutés localement dans le navigateur — aucune inscription requise →

#IntersectionObserver#懒加载#虚拟滚动#性能#视口检测