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
- Unobserve rapidement : arrêtez l'observation après le chargement pour réduire les callbacks
- rootMargin sensé :
200-500pxpour le lazy load,0pxpour le suivi d'exposition - Observations groupées : un seul Observer pour plusieurs éléments, pas un par élément
- Choix du threshold :
[0]pour le lazy load,[0, 0.25, 0.5, 0.75, 1]pour la progression - 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 →