Optimisations d'image avec Next.js

Next.js est un framework moderne basé sur React, permettant de créer des applications web contenant des pages aussi bien générées côté serveur (SSR) que côté client (SSG). Le but de ce framework est d'offrir toutes les clés permettant de produire des applications performantes.

50%, c'est la part qu'ont les images sur les pages web en moyenne. Il est indéniable que le chargement des images est un point central dans l'optimisation du chargement. Voyons comment Next.js adresse cette problématique.

Métriques Web Core Vitals

Qu'est-ce que qu'un bon site, indépendamment de son contenu sémantique ? De très importants acteurs du web ont tenté de donner une réponse à cette question. Nous allons nous intéresser aux métriques Web Core Vitals publiées par Google.

Il s'agit d'un ensemble d'indicateurs mesurables quantitativement qui permettent d'affirmer si un site propose une meilleure expérience utilisateur qu'un autre. Il est important de noter qu'un site proposant une bonne expérience utilisateur profite d'une meilleure indexation sur le moteur de recherche.

Parmi ces métriques, nous en retiendrons trois pour cet article concernant les images :

  • LCP (largest contentful paint): combien de temps dois-je attendre pour que le gros du contenu initial de la page soit visible ;
  • FID (first input delay): quelle est la latence lorsque je clique pour la première fois sur un élément interactif de la page (ex. un bouton) ;
  • CLS (cumulative shift layout): puis-je prédire où se situeront les élements à la fin du chargement, i.e. la stabilité de l'emplacement des éléments de la page tout au long du chargement.

Avec ces métriques au coeur de ses préoccupations, Next.js sort son propre composant Next <Image>, surcouche de HTML5 <img>avec le maximum d'optimisations par défaut.

Formats d'images modernes

Commençons par remplacer une balise <img> de notre projet avec la balise next <Image>.

import Image from "next/image";
import image1 from "@public/image1.jpg";

...

  <img   src={"./image1.jpg"} style={{ objectFit: "contain" }}/>
  <Image src={image1}         style={{ objectFit: "contain" }}/>

Par défaut, toutes les images statiques d'un projet Next.js sont converties aux formats légers WebP ou AVIF.

https://caniuse.com/webp

WebP est un format plus récent développé par Google et bien supporté par les navigateurs populaires. Il permet d'avoir environ un tiers de compression supplémentaire sur les images.

Lors de la phase de compilation, Next.js convertit les images jpeg/png en WebP dans son cache (un répertoire caché dont il se sert pour bundler l'application), en appliquant par défaut une réduction de qualité encore plus élevée mais avec dégradation d'image.

La valeur par défaut est de 75%, mais pouvez-vous la percevoir ? L'une des images qui suit est en jpeg, l'autre en WebP 75%.

Comparatif de deux rendus de la même image, l'un avec <img> l'autre avec <Image>
Comparatif de deux rendus de la même image, l'un avec <img> l'autre avec <Image>, zoomé

Il reste possible de configurer manuellement ce coefficient pour avoir une compression sans perte de qualité avec la props quality (1-100, par défaut 75, 100 la meilleure qualité).


Essayons de faire plusieurs essais avec des images de taille variable, entre 1Mo et 7Mo.

import image1 from "@public/image1.png";
import image2 from "@public/image2.jpg";
// etc ...

const images = [
    {
        servi: "./image1.png",
        importe: image1,
    },
    {
        servi: "./image2.jpg",
        importe: image2,
    },
    // etc ...
];

export function Images() {
    return images.map(({ servi, importe }) => {
        return (
            <>
                <img src={servi} style={{ objectFit: "contain" }} />
                <Image src={importe} style={{ objectFit: "contain" }} />
            </>
        );
    });
}
Inspection des médias téléchargés sur la page, les images alimentant <Image> en WebP sont 2/3 moins volumineuses que celles de <img> en png/jpeg

En cumulant ces deux optimisations, nous économisons presque deux tiers de la taille sur chacune des images !

Lazy loading et stabilité structurelle

Le CLS mesure la somme totale des décalages dans la mise en page lors du chargement.

Site https://www.msn.com/fr-fr en cours de chargement
Site https://www.msn.com/fr-fr chargé, la bannière de pub a décalé le contenu de la page

Dans cet exemple, nous pouvons clairement constater que lorsque le bloc de publicité est arrivé dans un second temps, cela a décalé tous les éléments en dessous de la barre de navigation. Au sens des métriques Web Core Vitals, cette translation donne une très mauvaise expérience utilisateur.

Afin d'avoir une stabilité visuelle lors du chargement des images, il est nécessaire de pré-allouer, dans la structure de la page, la taille des images. Avec le code précédent, la page reste blanche tant que les dimensions de l'image n'ont pas été récupérées, puis un espace vide est alloué lors du chargement de l'image. Next.js propose des attributs pour améliorer le comportement par défaut. Il est aussi possible de charger une seconde image basse resolution (ou d'un fond uni) pour temporiser le chargement de notre image.

<Image
    src={image}
    placeholder={"blur"} // permet d'avoir une version floutée très basse résolution  de l'image finale pendant le chargement
    blurDataURL={url} // facultatif: lien vers une image utilisée pendant le chargement
    style={{ objectFit: "contain" }}
/>
L'effet de flou apporté lors du chargement de l'image

Responsive sizing

Avec les appareils photos et smartphones récents, on prend des photos de 12Mpx, 24Mpx, 108Mpx. Pour 12Mpx, on se retrouve déjà avec des images faisant 4000x3000 pixels. Bien souvent, nous n'avons pas besoin d'images aussi grandes. Les images font rarement tout l'écran, qui lui-même n'a pas souvent autant de pixels qu'une image 12Mpx.

Taille intrinsèque vs taille rendue

L'"intrinsic size", c'est la taille de l'image en elle-même, tandis que la "rendered size" représente la taille de l'image rendue dans la page. Nous téléchargeons donc une image bien plus grande que nécessaire.

Utilisons la balise <Image> avec les attributs suivants:

<Image
    src={image}
    alt={"grande image aussi large que la fenêtre"}
    style={{ objectFit: "contain" }}
    placeholder={"blur"}
    sizes="(max-width: 300px) 100vw,
        (max-width: 500px) 100vw,
        (max-width: 800px) 100vw,
        (max-width: 1200px) 100vw,
        100vw"
/>

Prenons une image qui fait la même largeur que l'écran. En partant d'une fenêtre de la largeur la plus étroite possible, étirons-la au maximum de manière incrémentale. L'image téléchargée initialement est vraiment petite, elle est adaptée à la toute petite taille de fenêtre que nous lui avons accordé. Puis en agrandissant la largeur de la fenêtre, les résolutions supérieures sont peu à peu téléchargées. Cela nous évite de télécharger des images inutilement grandes.

Même image téléchargée de multiples fois à des résolutions différentes lors de l'agrandissement de la fenêtre pour s'adapter à sa largeur, en commençant par le plus petit format

Next.js effectue un échantillonnage des images à plusieurs résolutions à l'aide des media queries que nous lui passons dans l'attribut sizes. Dans l'exemple précédent, nous lui demandons de préparer une image correspondant à 100% de la largeur de l'écran lorsque la largeur est plus petite que 300px, que 500px, que 800px, que 1200px et l'image source (environ 5000px de largeur dans mon exemple).

Dans les faits, lors du processus de redimensionnement, tant que la résolution supérieure n'est pas téléchargée, la résolution la plus haute disponible reste affichée.

Extension aux images non locales

Puisque ces optimisations ont lieu lors du build time, il faut absolument avoir accès en local à ces images pour bénéficier des optimisations.

Cependant, si le serveur qui fournit les images propose une API adaptée, Next.js propose de surcharger une méthode afin de déléguer les optimisations.

// next.config.js
module.exports = {
    images: {
        loader: "custom",
        loaderFile: "./loader.js",
    },
};

// loader.js
export default function myImageLoader({ src, width, quality }) {
    return `https://exemple.com/${src}?w=${width}&q=${quality || 75}`;
}

Le loader doit prendre en argument le nom de l'image, la largeur et la qualité (1-100), et retourner une image que nous avons pris soin d'optimiser.

En pratique, il existe de nombreux CDN (Cloud Delivery Network) qui offrent des loader Next.js pré-intégrés, tels que Vercel, Imgix, Cloudinary, Akamai.

Conclusion

Le framework Next.js est recommandé pour la conception d'applications destinées au grand public pour lesquelles les problématiques de référencement et d'expérience utilisateur sont au coeur des choix techniques. Rien que sur le domaine des images, nous avons pu constater l'efficacité de Next.js pour améliorer aussi bien le temps de chargement que le poids des contenus, ou encore la stabilité structurelle de la page. Ces améliorations sont d'autant plus appréciables qu'elles arrivent avec des modifications mineures de notre code.

Par Christian Zheng