Un listener scroll sur window peut se dĂ©clencher des centaines de fois pendant un seul swipe. Si vous exĂ©cutez getBoundingClientRect() sur chaque carte d’un feed Ă  chacun de ces Ă©vĂ©nements, cinquante images suffisent Ă  faire ralentir la page. L’Intersection Observer API retire cette vĂ©rification du scroll : vous enregistrez les Ă©lĂ©ments Ă  observer, et le navigateur vous prĂ©vient quand leur visibilitĂ© change rĂ©ellement.

Comment fonctionne IntersectionObserver

Un observer surveille un ou plusieurs Ă©lĂ©ments target par rapport Ă  un root (le viewport, par dĂ©faut) et appelle une callback chaque fois qu’un target franchit un seuil de visibilitĂ©.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    console.log(entry.target, entry.isIntersecting, entry.intersectionRatio);
  }
}, {
  root: null,         // null = le viewport du navigateur
  rootMargin: "0px",  // agrandit ou réduit la boîte du root avant le test
  threshold: 0,       // se déclenche dès qu'1px est visible
});

document.querySelectorAll(".card").forEach((el) => observer.observe(el));

Chaque entry contient isIntersecting (un boolĂ©en), intersectionRatio (la part du target Ă  l’intĂ©rieur du root, de 0 Ă  1) et le boundingClientRect du target lui-mĂŞme. La callback est asynchrone. Le navigateur calcule les intersections une fois par frame, dans le cadre de son propre travail de rendu, donc votre code ne force jamais un recalcul de layout pendant que l’utilisateur fait dĂ©filer la page.

threshold accepte aussi un tableau. [0, 0.5, 1] dĂ©clenche la callback sĂ©parĂ©ment Ă  0 %, 50 % et 100 % de visibilitĂ© : c’est ainsi que vous envoyez un Ă©vĂ©nement « marquĂ© comme lu Ă  mi-scroll » sans crĂ©er trois observers.

Comment faire du lazy loading d’images avec IntersectionObserver

Le cas d’usage classique : ne pas tĂ©lĂ©charger une image avant qu’elle ne soit sur le point d’entrer dans le viewport.

1
<img class="lazy" data-src="/media/hero-photo.jpg" alt="CrĂŞte de montagne au coucher du soleil" width="1200" height="800">
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const imgObserver = new IntersectionObserver((entries, obs) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    const img = entry.target;
    img.src = img.dataset.src;
    img.classList.remove("lazy");
    obs.unobserve(img); // terminé pour celle-ci
  }
}, { rootMargin: "200px 0px" }); // commence à charger 200px avant la visibilité

document.querySelectorAll("img.lazy").forEach((img) => imgObserver.observe(img));

rootMargin est ce qui rend tout cela instantanĂ©. Une valeur positive agrandit la boĂ®te effective du root, donc la callback se dĂ©clenche pendant que l’image est encore 200px sous le pli, au lieu d’attendre son premier pixel visible. Gardez aussi width et height explicites sur la balise : ils rĂ©servent l’espace pour que le layout ne saute pas Ă  l’arrivĂ©e de l’image rĂ©elle, une cause distincte de layout shift que la plupart des dĂ©mos de lazy loading ignorent.

Avant d’Ă©crire tout cela, vĂ©rifiez si <img loading="lazy"> suffit. Tous les navigateurs actuels le supportent nativement, sans JavaScript. La version avec observer se justifie quand vous avez besoin de ce que l’attribut ne peut pas faire : un swap de placeholder personnalisĂ©, de la vidĂ©o ou des iframes en lazy, ou des images d’arrière-plan posĂ©es en CSS.

Comment construire un scroll infini avec un élément sentinelle

Le scroll infini ne surveille pas le contenu déjà chargé. Il surveille un élément vide sous la liste et charge la suite quand cette sentinelle devient visible.

1
2
<ul id="results"></ul>
<div id="sentinel"></div>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const sentinel = document.querySelector("#sentinel");
let page = 1;

const scrollObserver = new IntersectionObserver(async (entries) => {
  if (!entries[0].isIntersecting) return;
  scrollObserver.unobserve(sentinel); // pause pendant le chargement de cette page
  await loadNextPage(page++);
  scrollObserver.observe(sentinel);   // reprend la surveillance pour la suite
}, { rootMargin: "400px" });

scrollObserver.observe(sentinel);

La paire unobserve/observe est là pour les réseaux lents. Sans elle, un utilisateur qui fait défiler assez vite peut redéclencher la callback avant que la première requête ne soit résolue, et deux pages de résultats arrivent dans le désordre.

loadNextPage est un appel fetch ordinaire, qui a donc besoin du mĂŞme contrĂ´le response.ok que n’importe quelle autre requĂŞte. Le guide sur la gestion des erreurs de la Fetch API couvre ce qu’il faut faire quand cette requĂŞte renvoie une 404 ou que le rĂ©seau coupe en plein scroll. En cas d’Ă©chec, laissez la sentinelle non observĂ©e et affichez un bouton de nouvelle tentative. La rĂ©observer tout de suite vous enferme dans une boucle de requĂŞtes qui Ă©chouent.

L’ancienne façon de faire Ă©tait un handler scroll avec throttle ou debounce appelant getBoundingClientRect() sur la sentinelle. Comme le montre la comparaison entre debounce et throttle, les deux techniques continuent de faire du polling, juste moins souvent. Un observer ne fait aucun polling. Le navigateur se charge de la vĂ©rification, et votre code rĂ©agit au rĂ©sultat.

Quand se dĂ©clenche la callback d’IntersectionObserver

Pas sur la microtask queue. Si vous avez lu comment async/await interagit avec l’event loop, ne supposez pas que la callback se comporte comme la continuation d’une promise. Le navigateur la planifie depuis son pipeline de rendu, autour du paint. C’est pour cela qu’elle est assez lĂ©gère pour une page qui dĂ©file, et aussi pour cela qu’elle n’est pas l’outil adaptĂ© Ă  une logique qui doit s’exĂ©cuter avant le prochain repaint.

Pièges courants d’IntersectionObserver

Il teste une bounding box, pas des pixels. Un Ă©lĂ©ment signalĂ© comme intersectant Ă  100 % peut rester complètement cachĂ© derrière une modale opaque ou un header sticky peint par-dessus. L’API rĂ©pond Ă  « cette boĂ®te est dans cette autre boĂ®te », pas Ă  « l’utilisateur peut voir ceci ».

Un observer par Ă©lĂ©ment, ça s’accumule. Appeler new IntersectionObserver() pour chacune de deux cents cartes crĂ©e deux cents observers distincts. CrĂ©ez-en un par comportement (un pour toutes les images lazy, un pour la sentinelle) et appelez .observe() sur chaque target qui en a besoin. La callback reçoit dĂ©jĂ  les entries des targets qui ont changĂ©.

jsdom ne l’implĂ©mente pas. Dans une suite Jest ou Vitest qui tourne sur jsdom, IntersectionObserver est undefined sauf si vous le mockez ou installez un polyfill. Mockez le constructeur et appelez vous-mĂŞme la callback avec de fausses entries pour tester la logique de lazy loading isolĂ©ment. N’essayez pas de simuler un vrai scroll dans un test unitaire.

Observer avant que l’Ă©lĂ©ment existe. Dans un framework qui fait un rendu asynchrone, appeler observer.observe(el) sur une ref encore null Ă©choue silencieusement : aucune erreur, aucun warning, l’Ă©lĂ©ment n’est jamais observĂ©. Observez Ă  l’intĂ©rieur du hook qui s’exĂ©cute après le montage du nĹ“ud DOM (useEffect en React, mounted en Vue).

Quand utiliser IntersectionObserver plutĂ´t que loading=“lazy”

Pour les balises <img> simples, ajoutez loading="lazy" et n’Ă©crivez pas de JavaScript. Utilisez IntersectionObserver pour ce qu’un attribut ne peut pas exprimer : dĂ©clencheurs de pagination, autoplay vidĂ©o Ă  l’apparition, Ă©vĂ©nements « vu » pour l’analytics, swaps placeholder vers image. Un bon premier rĂ©flexe : repĂ©rer dans votre code le listener scroll qui appelle getBoundingClientRect() et le remplacer par un unique observer partagĂ©.

Articles associés