12k
All articles

Comment implémenter le défilement infini en JavaScript pur

Implémentez le défilement infini en JavaScript vanilla avec Intersection Observer, un élément sentinelle, la pagination, des gardes de chargement et des alternatives d’accessibilité.

OpenReplay Team
OpenReplay Team
Comment implémenter le défilement infini en JavaScript pur

Implémentez le défilement infini en JavaScript pur avec l’Intersection Observer API : placez un élément sentinelle en bas de votre liste, observez-le et récupérez la page de données suivante chaque fois qu’il entre dans la zone d’affichage.

Si vous en avez déjà développé un, vous connaissez le mode de défaillance : vous faites glisser la barre de défilement un peu trop fort et les mêmes dix éléments se retrouvent trois fois dans la liste. Faire fonctionner la mécanique de base prend environ dix minutes ; la rendre capable de survivre à un véritable utilisateur occupe le reste de l’après-midi. Cette approche remplace l’ancienne méthode reposant sur l’événement scroll combiné à getBoundingClientRect, qui exécute des calculs de position à chaque tick de défilement. Ce guide construit un flux complet et exécutable, avec un vrai fetch, de la pagination et de l’ajout au DOM, puis aborde les quatre pièges de production (double requête, absence d’arrêt, gestion des erreurs et timing du préchargement) ainsi que les solutions de repli en matière d’accessibilité qui distinguent une démo d’un code prêt à être livré.

Points clés à retenir

  • Utilisez IntersectionObserver, pas les événements de défilement : un écouteur de scroll se déclenche en continu sur le thread principal et impose des calculs de position manuels, alors que l’observer n’exécute un callback que lorsque la cible franchit réellement la zone d’affichage.
  • IntersectionObserver fait partie de la Baseline sur tous les navigateurs modernes depuis mars 2019 : le défilement infini ne nécessite donc aucun polyfill aujourd’hui.
  • Protégez chaque requête avec un drapeau booléen afin qu’un défilement rapide ne puisse pas déclencher plusieurs requêtes concurrentes avant la résolution de la première.
  • Arrêtez-vous lorsque l’API renvoie une page incomplète ou vide. Appelez observer.disconnect() et masquez la sentinelle, sinon l’observer continuera à redemander des pages qui n’existent plus.
  • Associez le défilement infini à un bouton « Charger plus » visible : c’est à la fois la solution de repli pour le clavier, pour les lecteurs d’écran et pour l’absence de JavaScript.

Pourquoi IntersectionObserver est-il supérieur aux événements de défilement ?

Utilisez IntersectionObserver plutôt qu’un écouteur scroll car il signale la visibilité de façon asynchrone via un callback qui ne se déclenche que lorsque votre cible franchit la zone d’affichage, au lieu de s’exécuter à chaque frame de défilement. L’ancien modèle attache un gestionnaire scroll et appelle getBoundingClientRect() à chaque tick pour déterminer si le bas de la liste est proche. Il s’agit de calculs de lecture de mise en page sur le thread principal, exécutés bien plus souvent que nécessaire, et c’est une source bien connue de saccades au défilement.

scroll + getBoundingClientRect()IntersectionObserver
DéclenchementÀ chaque frame de défilementUniquement lorsque la cible franchit la zone d’affichage
Calculs de positionManuels, dans votre codeGérés par le navigateur
ExécutionSynchrone sur le thread principalLivrée de façon asynchrone
Polyfill nécessaires.o.Non (Baseline)

Aucun polyfill n’est requis. MDN classe l’API comme Baseline « largement disponible », avec une prise en charge par tous les principaux navigateurs remontant à mars 2019 ; les conseils plus anciens recommandant un polyfill (et citant la prise en charge à l’époque de Chrome 51) sont donc obsolètes. Une exception : n’utilisez pas trackVisibility par défaut, car MDN présente encore cette propriété de détection d’occlusion comme expérimentale et à disponibilité limitée.

Qu’est-ce que le modèle de la sentinelle ?

Le modèle de la sentinelle consiste à placer un unique élément marqueur en bas de la liste ; lorsque l’observer signale que la sentinelle est entrée dans la zone d’affichage, vous récupérez la page suivante et l’ajoutez. La sentinelle n’est qu’un élément vide placé après votre dernier item, et vous n’avez jamais besoin de la resélectionner, car l’ajout de nouveaux éléments la repousse continuellement vers le bas.

Les trois pièces mobiles :

  1. Construire l’observer : new IntersectionObserver(callback, options).
  2. Démarrer la surveillance : observer.observe(sentinel).
  3. Dans le callback, vérifier entry.isIntersecting et charger la page suivante lorsqu’il vaut true.

Parcourez le tableau entries plutôt que de lire entries[0]. La référence du constructeur IntersectionObserver() déconseille de présumer un nombre d’entrées particulier, car une seule exécution de votre callback peut transporter plusieurs franchissements à la fois.

Un exemple complet de défilement infini en JavaScript pur

Voici une implémentation complète et fonctionnelle s’appuyant sur JSONPlaceholder, une API REST fictive et gratuite qui repose sur JSON Server avec LowDB en arrière-plan. Son point de terminaison /posts contient 100 enregistrements et accepte les paramètres de requête _page et _limit, renvoyant la tranche demandée sous forme de tableau simple. Vous disposez ainsi d’un jeu de données fini, ce qui est pratique pour illustrer ce qui se passe lorsque les données sont épuisées.

Le balisage : une liste, un bouton de repli, une sentinelle et une ligne d’état dans une région live.

<main>
  <ul id="list" aria-label="Posts"></ul>
  <button id="load-more" type="button">Load more</button>
  <div id="sentinel" aria-hidden="true"></div>
  <p id="status" role="status" aria-live="polite"></p>
</main>

Le script relie l’observer à la sentinelle et récupère une page par intersection :

const LIMIT = 10;
let page = 1;
let loading = false;   // guard against overlapping requests
let done = false;      // stop at end of data

const list = document.getElementById("list");
const sentinel = document.getElementById("sentinel");
const loadMoreBtn = document.getElementById("load-more");
const status = document.getElementById("status");

async function fetchPosts(page) {
  const url = `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${LIMIT}`;
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

function render(posts) {
  const frag = document.createDocumentFragment();
  for (const post of posts) {
    const li = document.createElement("li");
    li.innerHTML = `<h2>${post.title}</h2><p>${post.body}</p>`;
    frag.appendChild(li);
  }
  list.appendChild(frag);
}

async function loadNextPage() {
  if (loading || done) return;
  loading = true;
  status.textContent = "Loading…";
  try {
    const posts = await fetchPosts(page);
    render(posts);
    page += 1;
    if (posts.length < LIMIT) {   // short/empty page = no more data
      done = true;
      observer.disconnect();
      loadMoreBtn.hidden = true;
      status.textContent = "You've reached the end.";
    } else {
      status.textContent = "";
    }
  } catch (err) {
    status.textContent = "Could not load posts. Tap Load more to retry.";
    console.error(err);
  } finally {
    loading = false;
  }
}

const observer = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) loadNextPage();
    }
  },
  { root: null, rootMargin: "200px", threshold: 0 }
);

observer.observe(sentinel);
loadMoreBtn.addEventListener("click", loadNextPage);
document.addEventListener("DOMContentLoaded", loadNextPage);

Chaque requête passe par loadNextPage : le callback de l’observer, le clic sur le bouton et le chargement initial au DOMContentLoaded partagent donc tous la même logique de protection et d’arrêt.

Les quatre pièges qui distinguent une démo d’un code prêt à être livré

La plupart des tutoriels s’arrêtent à « ça ajoute les données ». Ce sont ces quatre correctifs qui permettent au code de survivre à de vrais utilisateurs.

SymptômeCauseCorrectif
Requêtes dupliquées lors d’un défilement rapideAucune protection des requêtesDrapeau booléen if (loading) return;
La liste ne s’arrête jamais et redemande des pages videsAucune détection de finif (posts.length < LIMIT) observer.disconnect()
Les erreurs disparaissent silencieusementAucun traitement des erreurs de fetchVérifier res.ok, utiliser try/catch, exposer une possibilité de réessai
Pause visible en bas de pagerootMargin: "0px"rootMargin: "200px" pour précharger plus tôt

Protégez-vous contre la double requête. Un geste de défilement rapide peut déclencher le callback plusieurs fois avant la résolution du premier await. Le drapeau loading fait sortir immédiatement tout appel supplémentaire jusqu’à ce que la requête en cours se termine dans le bloc finally. Vous pouvez aussi appeler unobserve sur la sentinelle pendant la requête, puis l’observer à nouveau ensuite. Attention simplement à ne pas confondre unobserve (une cible) et disconnect (toutes les cibles).

Arrêtez-vous à la fin des données. Avec une source finie, si vous continuez à envoyer des requêtes, vous inonderez le serveur de demandes de pages inexistantes. Détectez une page plus courte que LIMIT — le même signal de fin de données que celui utilisé dans le tutoriel de Prismatic sur les boucles d’API paginées, qui sort de sa boucle dès qu’une requête renvoie un tableau vide. Appelez ensuite disconnect() et masquez la sentinelle ainsi que le bouton.

Privilégiez threshold: 0 avec rootMargin. Définir rootMargin: "200px" déclenche la récupération suivante environ 200 pixels avant que l’utilisateur n’atteigne le bas, ce qui élimine le blocage visible. Combinez-le avec threshold: 0, et non 1.0 : une sentinelle plus haute que la zone d’affichage peut ne jamais être visible à 100 %, si bien qu’un seuil de visibilité totale peut échouer silencieusement à se déclencher.

Les bugs de défilement infini dépendent du timing et de la vitesse de défilement : un défilement local prudent les reproduit donc rarement. Observer de véritables sessions grâce à un outil comme le session replay est un moyen de mettre au jour cette catégorie de défaillances qui reste invisible lors d’un test rapide : requêtes dupliquées lors d’un geste rapide, ou liste qui ne s’arrête jamais.

Accessibilité et le repli « Charger plus »

Associez toujours le défilement infini à un bouton « Charger plus » visible : c’est la solution de repli pour le clavier et les lecteurs d’écran, celle pour l’absence de JavaScript, et souvent le seul moyen pour un utilisateur de mettre le flux en pause afin d’atteindre le pied de page. Un contenu qui se charge automatiquement sans fin piège les utilisateurs de technologies d’assistance, enfouit les liens du pied de page sous un contenu en croissance perpétuelle et casse la restauration de la position de défilement par le bouton retour lorsque l’utilisateur revient à une position qui n’existe plus dans le DOM.

Trois mesures concrètes, toutes présentes dans le code ci-dessus :

  • Annoncez l’état de chargement via une région live : <p role="status" aria-live="polite"> permet aux lecteurs d’écran d’énoncer « Loading… » et « You’ve reached the end. »
  • Conservez le bouton comme un véritable contrôle focusable afin qu’il fonctionne lorsque l’observer ne se déclenche jamais ou que JavaScript est désactivé.
  • Marquez la sentinelle avec aria-hidden="true". C’est un mécanisme, pas du contenu : elle n’a pas à figurer dans l’arbre d’accessibilité.

Si le pied de page d’un flux compte réellement (liens de contact, mentions légales, pagination pour les liens profonds), demandez-vous si un bouton « Charger plus » seul ne serait pas le meilleur modèle, et réservez le chargement automatique aux contenus pour lesquels un flux sans fin est précisément l’objectif.

Le défilement infini en JavaScript pur se résume à une idée durable : observer une sentinelle, récupérer les données à l’intersection et gérer les cas limites. Reprenez le fichier complet ci-dessus, pointez fetchPosts vers votre propre point de terminaison paginé et vérifiez que la protection et le chemin d’arrêt en fin de données se déclenchent tous les deux avant de livrer. Ce sont ces deux lignes qui transforment une démo fonctionnelle en un code auquel vous pouvez faire confiance en production.

FAQ

Quelle est la différence entre unobserve et disconnect sur un IntersectionObserver ?

Appelez unobserve lorsque vous voulez que l'observer abandonne un élément particulier et poursuive avec les autres, et disconnect lorsque vous voulez qu'il relâche tout ce qu'il surveille actuellement. Pour le défilement infini, cela correspond à unobserve pour mettre en pause l'unique sentinelle pendant qu'une requête est en cours, et à disconnect une fois les données épuisées, lorsque l'observer n'a plus de travail à accomplir.

Quand faut-il utiliser la pagination ou un bouton « Charger plus » plutôt que le défilement infini ?

Choisissez la pagination ou un bouton « Charger plus » lorsque le pied de page compte, par exemple pour des liens de contact, des mentions légales ou une pagination servant aux liens profonds, car le chargement automatique sans fin repousse définitivement le contenu du pied de page hors de portée et piège les utilisateurs de clavier et de lecteur d'écran. Le défilement infini convient aux contenus ouverts pour lesquels un flux sans fin est le principe même, comme les fils sociaux. Lorsque les utilisateurs ont besoin d'un point d'arrêt ou doivent atteindre le bas de page, un contrôle explicite est le meilleur modèle.

Pourquoi un threshold de 1.0 échoue-t-il parfois à déclencher le défilement infini ?

Un threshold de 1.0 exige que l'élément observé soit visible à 100 pour cent avant que le callback ne se déclenche : une sentinelle plus haute que la zone d'affichage ne peut donc jamais entrer entièrement dans le champ et le callback ne s'exécute jamais, silencieusement. Utilisez plutôt threshold 0 combiné à une marge tampon rootMargin : le callback se déclenche alors dès qu'une partie quelconque de la sentinelle franchit la limite étendue de la racine, ce qui constitue la valeur par défaut la plus fiable pour le défilement infini.

Dois-je gérer plusieurs entrées dans le callback d'IntersectionObserver ?

Oui. La référence du constructeur sur MDN indique de ne pas présumer que le tableau entries a une longueur particulière, car une seule exécution de votre callback peut transporter plus d'un franchissement. Pour une sentinelle unique, entries[0] fonctionne souvent en pratique, mais parcourir toutes les entrées et vérifier isIntersecting sur chacune est l'approche correcte : elle évite les intersections manquées ou mal attribuées lorsque plusieurs cibles se signalent en même temps.

Le défilement infini casse-t-il le bouton retour du navigateur ?

Oui, le défilement infini peut casser la restauration de la position de défilement par le bouton retour, car le navigateur tente de ramener l'utilisateur à une position de défilement qui n'existe plus dans le DOM après que le contenu chargé dynamiquement a été supprimé lors de la navigation. L'utilisateur se retrouve au mauvais endroit ou en haut de la liste. Parmi les mesures d'atténuation : persister l'état chargé, restaurer manuellement la position de défilement, ou proposer un bouton « Charger plus » afin que la navigation corresponde à un état stable et reproductible.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.