Comment j'ai réduit mon LCP de 70%

Découvrez des méthodes avancées pour améliorer les Core Web Vitals

Arjen Karel Core Web Vitals Consultant
Arjen Karel - linkedin
Last update: 2024-11-27

Améliorer les métriques LCP avec les web-workers et le chargement d'image en 2 étapes

La plupart du temps, un grand élément image dans le viewport visible deviendra l'élément Largest Contentful Paint. Même après avoir appliqué toutes les meilleures pratiques Lighthouse comme le redimensionnement d'image, la compression d'image, la conversion WebP et le préchargement de l'élément LCP, votre Largest Contentful Paint pourrait ne pas valider les Core Web Vitals.

La seule façon de corriger cela est d'utiliser des tactiques plus avancées comme le chargement en 2 étapes et l'utilisation de webworkers pour libérer des ressources sur le main thread.

Dans cet article, je montrerai comment améliorer davantage le largest contentful paint.

Pourquoi devrais-je précharger l'image largest contentful paint

Contexte

Je suis un expert en pagespeed et mon site web est ma vitrine. Sur ma page d'accueil, je prétends fièrement que mon site est le plus rapide au monde. C'est pourquoi j'ai besoin que ma page se charge aussi vite que possible et que je tire chaque goutte de pagespeed de mon site.

Les techniques que je vais vous montrer aujourd'hui ne sont peut-être pas viables pour votre site moyen (WordPress) sans le soutien d'une équipe de développement dédiée et talentueuse. Si vous ne pouvez pas reproduire cette technique sur votre propre site, je vous encourage tout de même à lire l'article pour comprendre comment je pense le pagespeed et quelles sont mes considérations.

Le problème : grandes images dans le viewport visible

Une grande image dans le viewport visible deviendra souvent l'élément Largest Contentful Paint. Il arrive souvent que cette image LCP ne valide pas les Core Web Vitals. Je vois des résultats comme celui-ci quotidiennement.

mauvais LCP avec grande image

Il existe plusieurs façons de s'assurer que cet élément apparaisse rapidement à l'écran :

  1. Précharger l'élément LCP. Le préchargement de l'image LCP garantira que cette image soit disponible pour le navigateur le plus tôt possible.
  2. Utiliser des images responsives. Assurez-vous de ne pas servir des images de taille bureau aux appareils mobiles.
  3. Compresser vos images. La compression d'image pourrait réduire considérablement la taille de l'image
  4. Utiliser des formats d'image nouvelle génération. Les formats d'image nouvelle génération comme WebP surpassent les anciens formats comme JPEG et PNG dans presque tous les cas.
  5. Minimiser le chemin de rendu critique. Éliminez toutes les ressources bloquant le rendu comme les JavaScripts et les feuilles de style qui pourraient retarder le LCP.

Malheureusement, malgré toutes ces optimisations, dans certains cas, les métriques LCP pourraient ne pas valider l'audit Core Web Vitals. Pourquoi ? La taille de l'image seule suffit à retarder le LCP.

La solution : chargement en 2 étapes et web workers

La solution que j'ai implémentée (après avoir optimisé tous les autres problèmes sur mon site) est le chargement d'image en 2 étapes.

L'idée est simple : au premier rendu, afficher une image de basse qualité avec exactement les mêmes dimensions que l'image finale de haute qualité. Immédiatement après l'affichage de cette image, lancer le processus qui remplace l'image de basse qualité par une image de haute qualité.

Une implémentation très basique pourrait ressembler à ceci : D'abord ajouter un écouteur d'événement load à une image. Lorsque l'image se charge, ce même écouteur d'événement se détache et le src de l'image est remplacé par l'image finale de haute qualité.

<img 
     width="100" 
     height="100" 
     alt="some alt text" 
     src="lq.webp" 
     onload="this.onload=null;this.src='hq.webp'"
>

Étape 1 : webp basse qualité 3-5kb

Étape 2 : webp haute qualité 20-40kb

Cela peut sembler assez simple (et ça l'est) mais remplacer un grand nombre d'images tôt dans le processus de rendu causera trop d'activité sur le main thread et affectera d'autres métriques Core Web Vitals.

C'est pourquoi j'ai choisi de décharger une partie du travail vers un web worker. Un web worker s'exécute dans un nouveau thread et n'a pas réellement accès à la page actuelle. La communication entre le web worker et la page se fait via un système de messagerie. L'avantage évident est que nous n'utilisons pas le main thread de la page lui-même, nous y libérons des ressources. L'inconvénient est que l'utilisation d'un web worker peut être un peu lourde.

Le processus lui-même n'est pas si difficile. Une fois que l'événement DomContentLoaded a été déclenché, je collecte toutes les images sur la page. Si une image a été chargée, je la remplacerai immédiatement. Si elle n'a pas été chargée (parce que l'image pourrait être en lazy load), j'attacherai un écouteur d'événement qui remplace l'image après le lazy load.

Le résultat : spectaculaire

good lcp visible image

Le code pour le chargement LCP en 2 étapes via un web worker

Voici le code que j'utilise pour accélérer mon LCP via le chargement en 2 étapes et un web worker. Le code sur la page principale appelle un webworker qui récupérera les images. Le webworker passe le résultat sous forme de blob à la page principale. À la réception du blob, l'image est remplacée.

Worker.js

Le worker a une seule tâche. Il écoute les messages. Un message contiendra une url d'image et un identifiant unique d'image. D'abord, il transformera l'url de l'image en la version haute qualité. Dans mon cas en changeant /lq en /resize dans l'url de l'image. Le worker récupérera ensuite l'image haute qualité, créera un blob puis renverra le blob de l'image avec l'identifiant unique.
self.addEventListener('message', async event => {
    const newimageURL = event.data.src.replace("/lq-","/resize-");

    const response = await fetch(newimageURL)
    const blob = await response.blob()

    // Send the image data to the UI thread!
    self.postMessage({
        uid: event.data.uid,
        blob: blob,
    })
})

Script.js

Le script.js s'exécutera comme un script normal sur la page web active. Le script charge d'abord le worker. Ensuite, il parcourra toutes les images sur une page. Cela se produit tôt dans le processus de rendu. Une image peut déjà être chargée ou non. Si une image de basse qualité est déjà chargée, il appellera le processus de remplacement immédiatement. Si elle n'a pas encore chargée, il attachera un écouteur à l'événement de chargement de l'image qui lance le processus de remplacement dès que cette image est chargée..
Lorsqu'une image est chargée, un identifiant unique est généré pour cette image. Cela me permet de retrouver facilement l'image sur la page (rappelez-vous, le worker n'a pas accès au DOM donc je ne peux pas envoyer le nœud DOM de l'image).
L'URL de l'image et l'identifiant unique sont ensuite envoyés au worker.
Lorsque le webworker a récupéré l'image, elle est renvoyée au script sous forme de blob. Le script finit par remplacer l'ancienne URL de l'image par l'URL du blob qui a été créée par le web worker.

var myWorker = new Worker('/path-to/worker.js');

// send a message to worker
const sendMessage = (img) => {

        // uid makes it easier to find the image 
        var uid = create_UID();

        // set data-uid on image element
        img.dataset.uid = uid;

        // send message to worker
        myWorker.postMessage({ src: img.src, uid: uid });
};

// generate the uid
const create_UID = () => {
    var dt = new Date().getTime();
    var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
        var r = (new Date().getTime() + Math.random() * 16) % 16 | 0;
        dt = Math.floor(dt / 16);
        return (c == 'x' ? r : (r & 0x3 | 0x8)).toString(16);
    });
    return uid;
}

// when we get a result from the worker
myWorker.addEventListener('message', event => {
    // Grab the message data from the event
    const imageData = event.data

    // Get the original element for this image
    const imageElement = document.querySelectorAll("img[data-uid='" + imageData.uid + "']");

    // We can use the `Blob` as an image source! We just need to convert it
    // to an object URL first
    const objectURL = URL.createObjectURL(imageData.blob)

    // Once the image is loaded, we'll want to do some extra cleanup
    imageElement.onload = () => {
        URL.revokeObjectURL(objectURL)
    }
    imageElement[0].setAttribute('src', objectURL)
})

// get all images
document.addEventListener("DOMContentLoaded", () => {
    document.querySelectorAll('img[loading="lazy"]').forEach(
        img => {

            // image is already visible?
            img.complete ?

                // swap immediately
                sendMessage(img) :

                // swap on load
                img.addEventListener(
                    "load", i => { sendMessage(img) }, { once: true }
                )
        })
})

Score Core Web Vitals avec image LCP préchargée

Stop debating in Jira.

Get a definitive answer on your performance issues. I deliver a granular breakdown of your critical rendering path.

Book a Deep Dive >>

  • Definitive Answers
  • Granular Breakdown
  • Critical Path Analysis
Comment j'ai réduit mon LCP de 70%Core Web Vitals Comment j'ai réduit mon LCP de 70%