Accueil / Articles / Quel est le coût réel du rendu de React sur le thread principal

Quel est le coût réel du rendu de React sur le thread principal

Render versus commit en React : pourquoi le travail de rendu invisible continue de concurrencer les entrées sur le thread principal, et ce que la mémorisation permet réellement d’économiser.

1665 mots

Un « render » de React ne modifie pas en soi ce qui s’affiche dans le navigateur. Un composant qui s’exécute une seule fois et le même composant qui s’exécute cent fois peuvent paraître identiques aux yeux de l’utilisateur de l’application. Ils ont l’air identiques parce que le navigateur n’est mis à jour que lorsque la phase de commit écrit dans le DOM, et un simple render ne garantit jamais cette écriture. Alors pourquoi tant de conseils sur les performances de React portent-ils sur l’évitement des renders — useCallback (conserver l’identité d’une fonction entre les renders), useMemo (conserver une valeur calculée entre les renders), React.memo (éviter le render d’un enfant lorsque les props ne changent pas), ainsi que la réduction des mises à jour d’état ?

Cette tension est réelle : on a l’impression d’optimiser quelque chose que l’utilisateur ne remarquerait jamais.

Si un render ne touche jamais le navigateur, à quoi consacre-t-il réellement son temps ?

Deux phases cachées à l’intérieur d’une seule rendu

On utilise souvent le terme « rendu » pour désigner un cycle d’actualisation complet. React divise ce cycle en deux phases ayant des tâches différentes. Une phase crée une description de l’interface utilisateur suivante. L’autre phase décide si cette description doit se traduire en modifications réelles du DOM.

Les mêmes quatre déclencheurs activent les deux phases : une mise à jour d’état, un changement de propriété, un nouveau rendu du parent, ou une modification de la valeur du contexte (une valeur de l’API Context lue sans passer par des propriétés). Ce que fait chacune des phases par la suite — et quel est leur coût respectif — c’est là qu’elles diffèrent.

Que se passe-t-il pendant la première phase, celle qui s’exécute toujours lorsque React planifie une mise à jour ?

Décortiquer la phase de rendu

Lorsque React décide qu’une mise à jour est nécessaire, il invoque à nouveau la fonction du composant depuis le début. Chaque instruction contenue dans ce corps s’exécute : calculs, boucles, création d’objets écrite en ligne de code.

Ensuite, React évalue le JSX après return. Le JSX (<div>...</div>) n’est pas du HTML et ne parvient jamais seul au navigateur. Lors de la compilation, Babel ou le compilateur TypeScript le transforme en appels de fonction — historiquement React.createElement, plus souvent l’outil moderne jsx(). Ce sont ces appels qui construisent la description.

Le résultat est généralement appelé DOM virtuel ; le nom interne de React est l’arbre d’éléments. C’est un objet JavaScript ordinaire qui décrit l’interface utilisateur prévue — ni du vrai HTML, ni un nœud DOM réel.

Considérons un petit composant :

function Greeting({ name }) {
  const message = `Hello, ${name}`;
  return (
    <div>
      <h3>{message}</h3>
    </div>
  );
}

Un changement dans la valeur de name pousse React à appeler à nouveau Greeting. La chaîne de template associée à message est exécutée une fois de plus. Les JSX compilés génèrent alors un arbre d’objets similaire au suivant :

{
  "type": "div",
  "props": {
    "children": {
      "type": "h3",
      "props": { "children": "Hello, Akshat" }
    }
  }
}

Cet objet représente le DOM virtuel mis à jour de Greeting. La structure reste en mémoire — aucune mutation du DOM, aucun redessin ni réorganisation. Qu’est-ce qui va consommer cet arbre ensuite ?

Décortiquer la phase d’application des modifications

React compare le nouvel arbre d’éléments avec l’ancien — c’est la réconciliation, une étape qui identifie ce qui a vraiment changé. Seules les différences détectées lors de cette comparaison sont écrites dans le DOM réel. S’il n’y a aucune différence, rien n’est enregistré.

Retournez à Greeting et supposez que name passe d’une chaîne de caractères à une autre. L’arbre précédent contenait l’ancien texte de salutation dans un h3 ; le nouvel arbre contient le texte mis à jour. La structure reste identique — un div entourant un h3 — de sorte que la réconciliation enregistre une seule différence : ce nœud de texte. La phase d’inscription met à jour uniquement ce texte ; le reste de l’arbre est laissé intact.

C’est la seule phase qui implique le navigateur réel, c’est aussi pourquoi c’est la seule phase capable de forcer un recalcul du rendu (un recomptage de la géométrie) ou une nouvelle redessin. Si vous modifiez suffisamment de contenu, le navigateur doit refaire ce travail. S’il n’y a aucune modification, il ne le fait pas.

Ce cas est décisif pour le reste de l’argumentation. Si name ne change pas lors d’une nouvelle déclenchement, le nouvel arbre correspond à l’ancien, la réconciliation ne détecte aucune différence, et l’opération de commit n’a aucun effet. Aucune écriture dans le DOM, pas de mise en page, pas de redessin — rien que les yeux de l’utilisateur ne puissent percevoir. Pourtant, la phase de rendu — l’appel de fonction, le message recalculé, l’arbre d’objets nouvellement alloué — s’est toujours exécutée complètement un instant auparavant.

Si le rendu peut se terminer sans laisser de trace visible, quel a été le coût de cette opération ?

La phase de rendu peut s’exécuter sans rien changer

C’est ici que le débat devient cohérent. Une génération qui produit un résultat identique consomme néanmoins des ressources réelles : une véritable appel de fonction, des allouations réelles pour chaque nœud de l’arbre, ainsi qu’un travail de vérification qui parcourt l’arbre avant de conclure qu’il n’y a eu aucun changement. Rien de tout cela n’apparaît à l’écran. Pourtant, tout s’est bien produit.

Cette lacune — ce travail réel qui reste invisible — explique pourquoi les affirmations « les générations n’ont pas d’importance » et « les générations sont très importantes » peuvent toutes deux être justes, mais concerner des niveaux différents. Les générations ne déterminent vraiment pas ce que voit l’utilisateur ; c’est le commit qui en est responsable. Les générations ont bien de l’importance pour autre chose qui n’a rien à voir avec les pixels.

Qu’est-ce que cet autre chose ?

Le thread principal se fiche que le travail soit visible

Cet élément est la file principale de JavaScript : une file commune qui exécute les tâches une par une. La même file exécute votre code JavaScript, calcule le rendu et gère les événements d’entrée — clics, défilements, frappes de clavier.

Les navigateurs créent un nouveau cadre environ tous les 16,7 millisecondes afin d’atteindre 60 cadres par seconde. Tout ce qui concerne un cadre donné — la logique de phase de rendu, la réconciliation, l’enregistrement des modifications du DOM, le calcul du rendu, la peinture — doit tenir dans ce délai, et la phase de rendu ne bénéficie d’aucun traitement prioritaire simplement parce que son résultat pourrait être ignoré. Elle partage exactement la même file que celle que l’utilisateur perçoit lors des interactions.

Soyez précis : pas chaque étape du navigateur liée à un cadre s’exécute sur ce thread. La rasterisation (conversion des commandes de dessin en pixels) et la composition (assemblage des couches pour former le cadre final) s’effectuent souvent ailleurs, c’est pourquoi une couche pré-rastérisée peut continuer à se dérouler pendant que le thread principal est occupé. La phase de rendu elle-même, ainsi que l’événement qui l’a déclenchée, restent sur le thread principal. Des threads de composition distincts expliquent en partie la fluidité sous charge ; ils n’éliminent pas pour autant les coûts liés à la phase de rendu.

Un seul rendu inutile peut coûter une fraction de milliseconde. À quel moment cela devient-il perceptible pour l’utilisateur ?

Pourquoi la phase de rendu a encore un impact

Car elle s’exécute rarement une seule fois. Lorsqu’un élément parent est rendu à nouveau, React exécute par défaut la phase de rendu pour chaque enfant, même si les props de cet enfant n’ont pas changé, à moins que l’enfant ne soit enveloppé dans React.memo.

function Dashboard() {
  const [searchTerm, setSearchTerm] = useState('');
  const products = useProducts(); // 200 items
  return (
      <div>
        <input
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
        />
        <ProductList products={products} />
      </div>
    );
  }

function ProductList({ products }) {
  return (
    <div>
      {products.map((product) => (
        <ProductRow key={product.id} product={product} />
      ))}
    </div>
  );
}

Entrez un caractère dans la zone de recherche et searchTerm se met à jour. On s’attend à ce que le tableau de bord redémarre, car son affichage a changé. ProductList, ainsi que les 200 composants ProductRow qui lui sont associés, redémarrent également, non pas parce que leurs propriétés ont changé (la frappe de touche n’a aucun rapport avec les données des produits), mais parce que les éléments enfants se rérendent à nouveau lorsque leur parent le fait, à moins qu’un mécanisme ne stoppe cette cascade.

Chacune de ces 200 appels représente toujours une exécution réelle d’une fonction, un arbre d’éléments réel par ligne, ainsi qu’un processus de conciliation qui conclut que aucune des lignes n’a besoin d’une écriture dans le DOM. Une frappe de touche est peu coûteuse. Un champ de recherche en temps réel, à une vitesse de frappe normale, déclenche ce flux plusieurs fois par seconde sur le même thread qui doit gérer la prochaine frappe.

C’est le scénario visé par useMemo, useCallback et React.memo — alors, que protègent-ils réellement ?

Que protègent vraiment useMemo, useCallback et React.memo

React.memo enrobe un composant et saute sa phase de rendu lorsque les nouvelles propriétés sont égales de manière superficielle aux précédentes (=== pour chaque propriété). En enrobant ProductRow, une frappe au clavier dans le tableau de bord ne force plus 200 appels à render ; React compare les propriétés une fois par ligne et s’arrête lorsque la référence product reste inchangée.

useMemo cache le résultat d’un calcul entre les rendus, de sorte que les opérations coûteuses au sein du corps du composant ne sont exécutées à nouveau que lorsque les dépendances indiquées changent. useCallback fait de même pour garantir l’identité d’une fonction. Son utilisation vise généralement moins à économiser de la mémoire en évitant l’allocation d’une fonction qu’à protéger un enfant mémorisé : une nouvelle fonction à chaque rendu correspond à une nouvelle référence, et une nouvelle référence perturbe la vérification superficielle effectuée par React.memo sur ce qui reçoit cette propriété.

Aucun de ces trois outils ne modifie la phase d’inscription au journal des modifications. L’inscription était déjà conditionnée par la détection d’une véritable différence lors de la réconciliation. Si rien n’aurait été affiché sur l’écran de toute façon, ces outils n’influencent pas le fait qu’une écriture dans le DOM ait lieu ou non. Ce qu’ils permettent d’économiser, c’est le temps de calcul durant la phase de rendu — le temps consommé sur le thread principal, même lorsque le résultat reste identique.

Ils ne sont pas non plus gratuits. La comparaison effectuée par React.memo ainsi que la recherche dans le cache réalisée par useMemo entraînent des coûts à chaque exécution, de sorte que l’enveloppement d’un composant peu coûteux et utilisé rarement peut se révéler une perte nette : il faut payer des frais supplémentaires pour protéger du travail qui n’a jamais été coûteux.

Quand tout cela a-t-il de l’importance pour quelqu’un qui utilise le produit ?

Le coût des rendus affecte réellement le thread ressenti par l’utilisateur

Rien dans la phase de rendu ne modifie un pixel par lui-même — cette partie de l’argument initial était toujours vraie. Un composant qui s’exécute une fois ou cent fois peut avoir l’air identique, car c’est le commit, et non le rendu, qui détermine ce qui arrive à l’écran.

Le rendu n’est toujours pas gratuit parce qu’il reste invisible. Il s’agit d’un travail réel effectué sur la même file d’attente à thread unique que le mise en page, les écritures dans le DOM, ainsi que chaque clic, glissement de page et frappe. Éviter d’ajouter des tâches inutiles pendant la phase de rendu à cette file d’attente — en particulier lorsque une mise à jour parentale se propage à travers une liste de sous-éléments profonde, ou lorsque la saisie et le glissement déclenchent rapidement des mises à jour — permet de récupérer les millisecondes nécessaires aux interactions.

Les gens mentionnent rarement l’aspect discret de ce processus. Un nombre réduit de rendus n’a jamais été l’objectif final. L’objectif est de laisser suffisamment de temps dans le budget global d’environ 16,7 ms pour les tâches de mise à jour et le traitement des entrées que l’utilisateur perçoit.