Vers le scroll infini, et au-delà


Dans ce post je vous propose d’implémenter un scroll infini en utilisant l’API Intersection Observer.

demo

Le code complet est disponible ici :

github jean-smaug/demo-infinite-scroll no-readme

Présentation

collider

Cette API permet de détecter lorsqu’un élément cible (target) devient visible pour un élément racine (root).

const options = {
  root: document.querySelector("#scrollArea"),
  rootMargin: "20px 5px",
  threshold: [0.5, 1.0],
};

function callback() {
  /*...*/
}

const observer = new IntersectionObserver(callback, options);

Le constructeur prend en paramètre un callback qui sera appelé lorsque la cible devient visible ou invisible pour l’élément racine. Il est possible de changer la manière dont le callback est appelé en spécifiant des options :

  • root : L’élément qui détecte la visibilité de la cible. Il doit être un ancêtre de la cible. Par défaut la zone d’affichage (le viewport) du navigateur est utilisée.
  • rootMargin : La marge autour de la propriété root. Elle prend des valeurs similaires à la propriété CSS margin. Cela permet de détecter la cible plus tôt (marge positive) ou plus tard (marge négative).
  • threshold : Soit un nombre, soit un tableau de nombre qui indique à quel pourcentage de la visibilité de la cible la fonction callback de la cible doit être exécuté.

Support navigateur

can-i-use

En termes de support, comme d’habitude IE pose des problèmes. À cela, j’ai envie de dire deux choses :

  • IE, pour moi tu es déjà mort
  • Les utilisateurs d’IE ne méritent que notre mépris

Il n’est donc plus nécessaire de se préoccuper de ce genre de détails. Si jamais vous éprouvez des remords pour ces êtres cruels, qui utilisent un outil ne respectant pas les conventions de Genève, alors vous pouvez toujours utiliser un polyfill.

Exemple

<!DOCTYPE html>
<html lang="fr">
  <head>
    <!-- ... -->
    <link rel="stylesheet" href="./spinner.css" />
  </head>
  <body>
    <!-- Le contenu d'une balise template n'est pas affiché. 
    On s'en sert uniquement côté JS -->
    <template>
      <div class="picture">
        <!-- Le spinner vient de https://loading.io/css -->
        <div class="lds-dual-ring"></div>
        <!-- Préciser la taille de l'image permet au navigateur de 
        reserver l'espace nécessaire pour l'image -->
        <img height="250" width="400" />
      </div>
    </template>

    <main></main>

    <script src="./app.js"></script>
  </body>
</html>
const main = document.querySelector("main");
const template = document
  .querySelector("template")
  .content.querySelector("div");

/* Cette fonction utilise l'API https://picsum.photos pour retourner
l'URL d'une image d'une taille donnée. Le paramètre nocache est là
pour éviter que le navigateur ne renvoi toujours la même photo
(le nom nocache a été choisi arbitrairement) */
function getImageUrl({ width, height }) {
  return `https://picsum.photos/${width}/${height}?nocache=${performance.now()}`;
}

// Cette fonction retourne un clone du template défini dans le HTML
function renderPicture() {
  const picture = template.cloneNode(true);
  const image = picture.querySelector("img");
  const spinner = picture.querySelector(".lds-dual-ring");

  /* La largeur et la hauteur de l'image sont déterminées
  par les attributs qui ont été définis côté HTML */
  image.src = getImageUrl({
    width: image.width,
    height: image.height,
  });

  // Lorsque l'image est chargée on supprime le loader
  image.onload = function () {
    picture.removeChild(spinner);
  };

  return picture;
}

// Ajoute un élément au DOM et observe cet élément
function appendAndObserve(observer, element) {
  main.appendChild(element);
  observer.observe(element);
}

/* La fonction de callback passée au constructeur sera appelée
lorsqu'un nouvel élément est observé et à chaque fois qu'un
élément devient visible. Il prend en paramètre la liste des
éléments observés. */
const intersectionObserver = new IntersectionObserver(function (entries) {
  const entry = entries[0];

  /* Lorsque l'élément devient visible on arrête de l'observer et
  on ajoute un nouvel élément que l'on observe */
  if (entry.isIntersecting) {
    this.unobserve(entry.target);

    const picture = renderPicture();
    appendAndObserve(this, picture);
  }
});

// On ajoute l'image initiale au DOM et on l'observe
const intialPicture = renderPicture();
appendAndObserve(intersectionObserver, intialPicture);

Cette solution a l’avantage de s’adapter à la taille de l’écran. Lorsque l’écran est capable d’afficher 3 images alors on a 4 images dans le DOM. Sur un écran plus petit, capable d’afficher 2 images alors 3 images seront dans le DOM. De manière générale on a toujours (N images visibles + 1) images chargées dans le DOM.

demo-viewport

Cela est possible car l’attribut height a été passé a la balise img, ce qui permet a l’Intersection Observer de faire ses calculs avant que l’image ne soit chargée.

Conclusion

Le scroll infini est un cas d’utilisation très pratique pour démontrer la puissance de l’API Intersection Observer.

Combiné aux frameworks comme React ou Vue.js, on pourrait détecter quels sont les éléments qui doivent être rendus et ainsi gagner en performances sur des composants comme les carousels ou toute autre liste d’éléments dont la taille dépasse l’affichage actuel du navigateur.

Merci à Julien Ducrot pour la relecture.

Merci de m’avoir lu.

Liens