Accueil / Articles

Penser en termes d’invalidation : un modèle pratique de la manière dont les navigateurs affichent les pages

Apprenez à raisonner sur le rendu des navigateurs en vous demandant quelle modification le rend invalide, que ce soit en termes de problèmes de mise en page, de coût de rendu graphique, ou encore de composition, d’hydratation et de streaming.

Éditeur : REACTAPP.TOP11 min de lecture2533 mots

Sources: The Web Page Rendering Process In Browsers: A Deep Dive

Presque tous les développeurs frontend peuvent réciter le pipeline de rendu : l’HTML devient le DOM, le CSS devient le CSSOM, puis viennent l’agencement, la peinture et la composition. Cette séquence constitue une bonne introduction, mais elle est presque inutile lorsque une page réelle fonctionne de manière hésitante, car elle décrit un seul passage alors que les navigateurs effectuent constamment des passages partiels et répétés. Ce guide remplace cette liste mémorisée par un modèle fonctionnel basé sur une seule question, afin que vous puissiez expliquer les problèmes d’agencement, les peintures lentes, les animations peu performantes et les retards d’hydratation en utilisant le même raisonnement, et valider vos hypothèses dans les DevTools.

Le rendu est continu, ce n’est pas un événement ponctuel

Une page n’est jamais rendue une seule fois et laissée telle quelle. Les scripts modifient le DOM, les changements de classe altèrent les styles, les images et les polices web arrivent en retard, les utilisateurs passent leur curseur dessus et font défiler la page, les animations s’exécutent à chaque frame, et les frameworks ré-renderissent les composants après mise à jour de l’état. Chacun de ces événements oblige le moteur de rendu à répondre à la même question :

Qu’est-ce qui n’est plus valide, et quelle quantité de travail doit être refaite ?

Garder cette question à l’esprit est plus utile que de connaître les noms des étapes. C’est la raison pour laquelle deux applications qui ont l’air identiques peuvent se comporter très différemment sur le même téléphone : l’une continue de déclencher des calculs coûteux, tandis que l’autre utilise principalement ce que le navigateur peut réutiliser.

Étape un : le navigateur crée des modèles internes

Lorsque l’HTML arrive, le parseur le transforme en DOM. Le DOM n’est pas une image de la page ; c’est un arbre d’éléments, d’attributs et de nœuds de texte, ainsi que les relations de parenté et de fratrie entre eux. Prenons un petit fragment de balisage :

<main>
  <h1>Browser Rendering</h1>
  <p>Understanding what happens after HTML arrives.</p>
</main>

Le parseur le convertit en un arbre que le moteur peut interroger et mettre à jour :

Document
└── main
    ├── h1
    │   └── "Browser Rendering"
    └── p
        └── "Understanding what happens after HTML arrives."

Les feuilles de style subissent leur propre traitement. Le navigateur les analyse, applique la cascade et l’héritage, détermine quelles règles s’appliquent à quels éléments, et calcule la valeur finale de chaque propriété pour chaque élément. À ce stade, rien n’a encore été affiché. Le moteur accumule des informations sur l’état de la page, et il a besoin à la fois de la structure et des styles calculés avant que la géométrie ne puisse être déterminée. Un schéma orienté dépendances ressemble à ceci :

HTML ──→ DOM ───────────┐
                        ├──→ Style + Layout ──→ Paint ──→ Composite
CSS  ──→ Styles ────────┘

Considérez cette image comme une carte des dépendances plutôt que comme une description de l’implémentation. Les moteurs réels comprennent bien plus d’étapes internes, de caches, de structures de suivi de l’invalidation, de threads multiples ainsi que des raccourcis spécifiques à la plateforme. Ce que le diagramme montre correctement, c’est quels résultats dépendent de quels entrées. Si vous souhaitez en savoir plus sur la manière dont les valeurs de style sont résolues avant le rendu, notre article sur l’emplacement du CSS dans le pipeline du navigateur offre des explications plus détaillées.

Le rendu est un calcul de dépendances

Avec le DOM et les styles calculés disponibles, le navigateur détermine la géométrie. Le rendu doit résoudre des questions telles que :

  • Quelles sont la largeur et la hauteur de chaque boîte ?
  • Où est placée chaque boîte ?
  • Où le texte se répartit-il à l’intérieur d’un conteneur ?
  • Où se retrouvent les enfants d’un élément ?
  • Comment la taille d’un élément influence-t-elle ses voisins ?
  • C’est la dernière question qui rend le calcul des mises en page coûteux. Supposons qu’un paragraphe devienne plus haut parce que la taille de son police a augmenté. Tout ce qui se trouve après lui dans le flux peut devoir descendre, et la hauteur d’un conteneur peut changer, ce qui peut affecter ses propres éléments enfants. Un changement qui semble local peut avoir des répercussions sur une grande partie du document. C’est pourquoi il est utile de considérer la mise en page comme la résolution d’un réseau de dépendances, et non comme le placement de boîtes sur une toile.

    Les moteurs font de leur mieux pour limiter les recalculs au sous-arbre concerné, mais la quantité de travail effectivement réalisée dépend du type de changement et de la manière dont les éléments sont liés entre eux. Par exemple, le contenu à l’intérieur d’un conteneur de taille fixe a généralement moins d’effets secondaires que le contenu dans un conteneur de taille automatique.

    L’invalidation est plus importante que le pipeline

    Imaginez qu’un script exécute card.style.width = "500px". Le navigateur sait alors que sa géométrie précédente pour cette carte, et éventuellement pour les éléments qui l’entourent, peut être obsolète. Avant le prochain frame, il doit déterminer quels étapes relancer. En termes simplifiés :

    Something changes
          │
          ▼
    What does it invalidate?
          │
          ├── Style
          ├── Layout
          ├── Paint
          └── Composite
    

    La constatation clé est que les modifications ne parcourent pas toujours l’intégralité du pipeline :

    • Une nouvelle largeur modifie la géométrie, ce qui entraîne un relancement du rendu de l’agencement, et la zone concernée est généralement recoloriée par la suite.
    • Une nouvelle couleur d’arrière-plan change l’apparence sans déplacer quoi que ce soit, il est donc nécessaire de recolorier, mais généralement pas de relancer l’agencement.
    • Une nouvelle valeur transform peut souvent être appliquée avec beaucoup moins d’efforts, mais uniquement lorsque les décisions de superposition du navigateur et les conditions environnantes permettent de gérer la mise à jour à l’étape de composition.

    C’est pourquoi des conseils généraux tels que « éviter les répeintures » ou « utiliser uniquement la GPU » échouent en pratique. La question diagnostique utile est toujours : quelle opération cette modification particulière a-t-elle rendue invalide ? Notre article sur ce que coûte chaque modification CSS au navigateur recense les propriétés courantes en fonction des étapes qu’elles ont tendance à déclencher.

    Comment JavaScript force une exécution précoce du rendu

    Une façon courante de provoquer accidentellement un rendu coûteux est d’intercaler des écritures et des lectures de mise en page. La boucle ci-dessous définit la largeur de chaque carte puis demande immédiatement sa largeur mesurée :

    for (const card of cards) {
      card.style.width = "500px";
      const width = card.getBoundingClientRect().width;
      console.log(width);
    }
    

    Il n’y a rien de mal avec getBoundingClientRect() lui-même. Il s’agit d’une API normale et utile. Le problème provient de l’ordre des opérations. L’affectation du style laisse les tâches de mise en page en attente, et la ligne suivante demande une valeur qui ne peut être correcte qu’une fois ces tâches terminées. Le navigateur n’a d’autre choix que d’exécuter les opérations de style et de mise en page de manière synchrone, immédiatement avant de retourner la valeur. Dans une boucle, on observe ce qu’on appelle layout thrashing : écriture, lecture, écriture, lecture, avec une mise en page forcée à chaque itération.

    En restructurant le code de manière à ce que toutes les lectures aient lieu en premier et toutes les écritures par la suite, on élimine cette synchronisation forcée :

    const widths = cards.map(card =>
      card.getBoundingClientRect().width
    );
    
    for (const card of cards) {
      card.style.width = "500px";
    }
    

    Désormais, les mesures sont prises par rapport à un seul layout déjà valide, et les écritures sont laissées au navigateur pour être traitées ensemble lors de sa prochaine mise en page. Notez que les deux versions ne sont pas strictement équivalentes : la seconde enregistre les largeurs avant le changement, il convient donc d’utiliser ce réordonnancement uniquement lorsque c’est vraiment nécessaire. Si une lecture dépend réellement d’une écriture précédente, les techniques habituelles consistent à regrouper les écritures et à planifier les lectures pour le prochain frame, par exemple avec requestAnimationFrame.

    La leçon principale n’est pas « ne jamais mesurer le layout ». JavaScript et le rendu partagent le même thread ainsi que les mêmes données, ce qui permet à votre code de créer des dépendances obligeant le navigateur à effectuer immédiatement ce qu’il aurait normalement planifié et traité de manière efficace en lots. Le problème lié au layout peut également être déclenché par d’autres facteurs subtils, que nous abordons dans les collections DOM en temps réel et les problèmes de layout.

    Le processus de peinture est un coût distinct du layout

    Lorsque la géométrie est connue, le navigateur doit encore déterminer à quoi ressemble chaque région : arrière-plans, bordures, glyphes de texte, images, ombres, dégradés, effets de découpe et autres effets visuels. Une façon concise de séparer ces deux étapes :

    • Le layout détermine où se trouvent les éléments et quelle est leur taille.
    • Le processus de peinture détermine à quoi ressemblent ces éléments.

    Changer la valeur de background-color ne nécessite généralement pas de nouvelles géométries pour l’ensemble du document, tandis que modifier sa taille ou sa position peut le requérir. Cependant, il ne faut pas en déduire que ce processus est gratuit en termes de ressources. La peinture de grandes surfaces, l’application d’effets lourds tels que de gros ombres floues, les modifications fréquentes du contenu ainsi qu’une invalidation excessive peuvent tous augmenter considérablement le temps de traitement, au point de retarder l’affichage d’une frame.

    La rasterisation et la composition génèrent la frame finale

    Le processus de peinture produit des instructions de dessin, et non nécessairement les pixels finaux. Ces instructions sont ensuite rasterisées en données de pixels, souvent divisées en tuiles, avant d’être traitées par la pile graphique de la plateforme. Enfin, la composition assemble ces surfaces ou couches pour former la frame affichée à l’écran.

    C’est à ce stade que transform et opacity gagnent leur réputation. Lorsque les conditions sont favorables, animer ces propriétés permet à un élément de se déplacer, de changer de taille ou de s’estomper sans que le navigateur doive recalculer la mise en page du document ou redessiner son contenu ; il se contente de récombiner les couches existantes.

    Cependant, le raccourci populaire « la transformation s’exécute sur la GPU, donc elle est toujours rapide » cache les véritables coûts :

    • Chaque couche supplémentaire consomme de la mémoire, ce qui est important sur les appareils mobiles peu puissants.
    • Le contenu doit néanmoins être rasterisé à un moment donné, et le processus de rasterisation peut être coûteux.
    • Le fait qu’un élément dispose ou non de sa propre couche dépend des heuristiques du navigateur et du contexte de rendu, de sorte que le même CSS peut se comporter différemment selon les situations.

    La règle fondamentale est plus simple que toute liste de propriétés : une modification qui entraîne moins d’invalidations oblige le moteur à recalculer moins de données.

    Le budget de traitement est partagé

    Un écran à 60 Hz se met à jour environ tous les 16,67 millisecondes, car 1000 / 60 équivaut à peu près à 16,67. À 120 Hz, cet intervalle diminue à environ 8,33 millisecondes.

    Cet intervalle n’est pas une portion de temps CPU réservée à votre code. Pendant cette période, le navigateur doit gérer l’exécution des scripts, le calcul des styles, l’agencement de l’interface, la mise en page, la rasterisation, la composition des éléments, le traitement des entrées, les appels réseau ainsi que ses propres tâches internes. Une seule tâche JavaScript longue peut donc retarder le rendu, même si la tâche de rendu en soi aurait été simple.

    C’est aussi pour cette raison que des métriques telles que le FCP (First Contentful Paint) et l’INP (Interaction to Next Paint) existent séparément. Afficher rapidement le contenu et répondre rapidement aux entrées sont des objectifs liés, mais ils échouent pour des raisons différentes et doivent être mesurés séparément.

    Où est construite l’interface utilisateur influence le coût de rendu

    Avant que le navigateur ne rende quoi que ce soit, un choix architectural a déjà influencé l’ampleur du travail à effectuer : où est générée l’interface utilisateur initiale ?

    Rendu côté client

    Avec le CSR, le serveur envoie une structure HTML minimale ainsi que le code de l’application, et c’est l’appareil qui effectue la majeure partie du travail :

    Server
       │
       ├── HTML shell
       └── JavaScript + data
                │
                ▼
             Browser
                │
                ├── Parse JS
                ├── Execute JS
                ├── Fetch data
                ├── Build UI
                └── Render
    

    Rendu côté serveur

    Avec le SSR, la récupération des données, la logique de l’application et la génération de l’HTML ont lieu sur le serveur avant que la réponse ne soit envoyée :

    Request
       │
       ▼
    Server
       │
       ├── Fetch data
       ├── Execute application logic
       └── Generate HTML
                │
                ▼
             Browser
                │
                ▼
          Parse + Render
    

    Le SSR permet d’envoyer au navigateur du markup significatif plus rapidement, tandis que le CSR déplace les calculs et l’affichage sur le matériel de l’utilisateur. Aucune des deux approches n’est en général supérieure. Le choix approprié dépend de la complexité de l’application, des données dont elle a besoin, du degré d’optimisation possible pour le cache, des appareils utilisés par les clients, ainsi que de la quantité de JavaScript qui doit être exécutée dans le navigateur.

    Hydratation : paraître prêt ne signifie pas l’être

    De nombreux frameworks modernes combinent ces deux approches grâce à l’hydratation. Le serveur envoie de l’HTML qui peut être affiché immédiatement, et le JavaScript côté client lance ensuite l’application et ajoute les gestionnaires d’événements nécessaires à l’interaction :

    Server HTML
         │
         ▼
    Browser renders content
         │
         ▼
    JavaScript loads
         │
         ▼
    Application initializes
         │
         ▼
    Hydration
         │
         ▼
    Interactive UI
    

    La conséquence est facile à négliger : une page peut sembler prête bien avant de pouvoir répondre à un clic. L’hydratation implique le téléchargement, l’analyse, la compilation et l’exécution d’une grande quantité de JavaScript, l’initialisation des composants ainsi que la configuration des écouteurs, tout cela sur le thread principal. Une application volumineuse peut donc faire apparaître du contenu plus rapidement tout en générant soudainement une charge importante sur le thread principal, c’est précisément à ce moment que les utilisateurs commencent à cliquer.

    Cette tension explique l’orientation actuelle des stratégies de rendu : envoyer moins de JavaScript côté client, diviser les bundles et activer uniquement les parties d’une page qui nécessitent réellement de l’interactivité, plutôt que l’ensemble de la structure en même temps.

    Les changements apportés par le streaming selon le moment où le travail commence

    Le SSR classique construit la réponse complète avant d’en envoyer ne serait-ce qu’une partie. Le streaming, quant à lui, envoie l’HTML par tranches au fur et à mesure qu’il devient disponible :

    Server
      │
      ├── HTML shell ──────────→ Browser
      ├── Content ─────────────→ Browser
      ├── More content ────────→ Browser
      └── Remaining response ──→ Browser
    

    Le navigateur peut parser et afficher les premiers fragments tandis que le serveur continue de générer le reste. Le streaming ne rend pas l’affichage global moins coûteux. Ce qu’il change, c’est le moment où les tâches utiles peuvent commencer, ce qui peut raccourcir le temps entre la navigation et la mise en page du contenu visible. L’hydratation progressive applique la même idée du côté client, en différant l’initialisation dans le temps au lieu d’activer toute l’application en une seule fois.

    Observez le pipeline plutôt que de l’apprendre par cœur

    Le moyen le plus rapide d’acquérir de l’intuition est d’observer directement le navigateur. Ouvrez le panneau Performance dans Chrome DevTools, enregistrez une interaction et examinez ce qui s’exécute lorsque l’interface change. Ensuite, créez délibérément un réaménagement forcé en définissant une largeur et en lisant immédiatement les données géométriques :

    element.style.width = "600px";
    element.getBoundingClientRect();
    

    Enregistrez à nouveau avec une mise à jour qui ne modifie qu’une transformation :

    element.style.transform = "translateX(100px)";
    

    L’objectif de cet exercice n’est pas de qualifier l’une des propriétés comme bonne et l’autre comme mauvaise. Pour chaque enregistrement, répondez aux questions dans l’ordre suivant :

    • Le style a-t-il été recalculé ?
    • L’affichage a-t-il été exécuté, et cela a-t-il été forcé par un script ?
    • Une zone quelconque a-t-elle été recolorée ?
    • Y a-t-il eu de la rasterisation ?
    • L mise à jour pourrait-elle être entièrement gérée par le composant ?

    Lorsque vous lisez les traces de cette manière, le panneau Performance cesse d’être un outil de débogage de dernier recours pour devenir un moyen de voir comment le moteur réagit à chaque ligne de votre code.

    Au-delà du diagramme en six étapes

    Il reste utile de connaître le flux de travail classique :

    HTML → DOM → CSSOM → Layout → Paint → Composite
    

    Le système réel est bien plus complexe. Le moteur maintient en permanence synchronisées plusieurs représentations liées entre elles, allant de l’arbre des éléments et des styles calculés jusqu’à la géométrie des boîtes, aux instructions de dessin, en passant par les tuiles rasterisées et les couches composées. Un changement oblige rarement à reconstruire l’ensemble de ces éléments. Le moteur détermine ce qui reste valide, identifie ce qui est devenu obsolète et réutilise tout ce qu’il peut.

    Points clés

    • Remplacez « quelle optimisation dois-je appliquer ? » par « qu’est-ce qui a changé, qu’a cela rendu obsolète, et pourquoi le navigateur doit-il refaire ce travail ? »
    • Le rendu s’inscrit dans un problème de dépendances ; des modifications qui semblent locales peuvent avoir des effets à l’échelle du document.
    • Les lectures et écritures entremêlées obligent à un rendu synchronisé ; séparez-les ou reportez les lectures au frame suivant.
  • La peinture et la composition ont leurs propres coûts ; transform et opacity ne sont économiques que lorsque les calques et les conditions coopèrent.
  • Le budget de traitement des frames est partagé avec les scripts et la gestion des entrées, de sorte que des tâches longues nuisent même lorsque le rendu est peu coûteux.
  • CSR, SSR, hydratation et streaming déplacent principalement le travail dans l’espace et dans le temps ; évaluez-les en fonction de la quantité de travail sur le thread principal qui atteint l’appareil de l’utilisateur et du moment où cela se produit.