Les composants serveur

Voir la vidéo

Dans cet article, on va revenir en détail sur le fonctionnement des Composants serveur dans React, un sujet qui a causé pas mal de confusion après la sortie de ma précédente vidéo sur Next.js. Du coup, je me suis dit qu'il serait intéressant d'explorer plus en profondeur le fonctionnement technique de ces composants.

Le rendu côté serveur classique avec React

Avant de commencer, il est important de noter que techniquement, n'importe quel composant React (tant qu'il n'a pas de code spécifique au front-end) peut être utilisé pour du rendu back-end.

Si je prends un exemple simple avec un compteur, lorsque je suis côté serveur, je peux utiliser la fonction renderToString pour obtenir la l'HTML. Cette approche permet de générer le contenu de la page en utilisant React comme un moteur de template.

import { renderToString } from "react-dom/server";

export function Compteur() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Compteur : {count}</p>
      <button onClick={() => setCount(count + 1)}>Incrementer</button>
    </div>
  );
}

renderToString(<Compteur />);

En revanche, si je veux faire en sorte que le composant soit interactif , il va falloir faire une phase d'hydratation. C'est-à-dire qu'on va re-rendre le même composant et on va l'hydrater sur le HTML déjà existant.

import { hydrateRoot } from "react-dom/client";

hydrateRoot(document.getElementById("root"), <Compteur />);

Automatiquement, React va greffer les comportements et on va pouvoir cliquer et voir notre compteur s'incrémenter.

Fonctionnement des composants serveur ?

Les composants serveur désignent une nouvelle catégorie de composant qui fonctionne de manière complètement différente.

Côté code, la première grosse différence, c'est qu'un composant serveur ne peut pas utiliser de hooks et sera une fonction (potentiellement asynchrone) qui retournera du JSX.

export default async function Page() {
  const res = await fetch(url);
  const posts = await res.json();

  return (
    <div>
      <h1>Blog</h1>
      <div>
        {posts.map((post) => (
          <article key={post.id}>
            <h4>{post.title}</h4>
            <Excerpt>{post.body}</Excerpt>
          </article>
        ))}
      </div>
      <ClientComponent />
    </div>
  );
}

Côté backend le code peut être exécuté pour obtenir le virtual DOM et générer l'HTML comme un composant classique. En revanche, la subtilité se situe sur la manière d'envoyer le code du composant au front-end. Contrairement à un composant classique que l'on doit envoyer tel quel au front-end, les composants serveur vont être exécutés et générer le Virtual DOM statique. Dans ce Virtual DOM, on va interpréter tous les nœuds qui peuvent l'être et les noeud clients vont être conservé.

Une fois la version statique calculée, on va la replacer dans un nouveau composant qui sera ensuite envoyé au front-end.

export function HomePageContent() {
  return (
    <div>
      <h1>Blog</h1>
      <div>
        <article>
          <h4>Titre de mon premier article</h4>
          <p>Ceci est un article de test pour ...</p>
        </article>
        <article>
          <h4>Titre de mon second article</h4>
          <p>Dans cet article je vous propose...</p>
        </article>
        <article>
          <h4>Titre de mon troisième article</h4>
          <p>Lorsque j'ai découvert cette fonction...</p>
        </article>
      </div>
      <ClientComponent />
    </div>
  );
}

Si le front-end a besoin de régénérer la même page, il ne va pas utiliser le code source original du composant, mais cette version statique.

On notera que le composant serveur peut être construit au moment du build ou pour chaque requête (cela dépend de la situation et du besoin).

Implémentation dans Next.js

L'implémentation des composants serveur peut varier d'un framework à l'autre aussi on va maintenant explorer l'implémentation dans NextJS.

Si on regarde la structure HTML d'une page on remarque la présence de plusieurs scripts en bas de page.

<script>
  self.__next_f.push([
    1,
    '1:"$Sreact.fragment"\n2:I[26554,["8884","static/chunks/8884-73f4ad06ccc16e08.js"...',
  ]);
</script>

Cette variable est en réalité une chaine de caractère séparée en plusieurs parties (pour le streaming) qui représente les données envoyées par NextJS au front-end. Pour analyser ces données vous pouvez utiliser l'extension RSC Devtools qui permet de présenter les différents blocs de manière plus digeste.

Si on analyse les données d'une page, on retrouvera le JSX généré par notre composant serveur dans le contenu de la variable __next_f et on notera plusieurs points important :

  • Les composants serveurs sont aussi parcourus et seule le JSX est mis à plat dans le composant principal
  • Les composants clients sont remplacés par des appels vers des composants chargés "à la demande".

L'avantage apporté par les serveurs components

Comme on l'a compris un composant serveur est en fait un composant qui va être interprété côté serveur pour générer une version statique. Cela permet, dans certaines situations, d'alléger ce que l'on envoie au client.

Prenons un cas concret :

import { marked } from "marked";

export default async function Page() {
  const res = await fetch(url);
  const posts = await res.json();

  return (
    <div>
      <h1>Blog</h1>
      <div>
        {posts.map((post) => (
          <article key={post.id}>
            <h4>{post.title}</h4>
            <Excerpt>{post.body}</Excerpt>
          </article>
        ))}
      </div>
    </div>
  );
}

function Excerpt({ children }: { children: string }) {
  const html = marked.parse(children) as string;
  return (
    <div
      dangerouslySetInnerHTML={{
        __html: html.match(/<p[^>]*>(.*?)<\/p>/i)?.[1]!,
      }}
    />
  );
}

Sans les composants serveur, pour la phase d'hydratation il faudrait envoyer le JavaScript de ce composant et les données associées ce qui pose plusieurs problème :

  • La librairie pour parser le contenu de l'article (écrit en markdown) a un poid important.
  • La liste des articles contient le contenu complet de l'article alors que l'on utilise seulement le premier paragraphe.

En utilisant un composant serveur on peut envoyer une version déjà interprétée du composant ce qui permet de s'éviter les 2 problèmes précédent.

export function GeneractedComponent() {
  return (
    <div>
      <h1>Blog</h1>
      <div>
        <article>
          <h4>Titre de mon premier article</h4>
          <p>Ceci est un article de test pour ...</p>
        </article>
        <article>
          <h4>Titre de mon second article</h4>
          <p>Dans cet article je vous propose...</p>
        </article>
        <article>
          <h4>Titre de mon troisième article</h4>
          <p>Lorsque j'ai découvert cette fonction...</p>
        </article>
      </div>
    </div>
  );
}

Cette version statique est beaucoup plus légère.

  • On n'a plus besoin des librairies tiers côté front
  • On ne met que les extraits des contenus dans le code

Les inconvénients

En revanche, ce mode de rendu peut avoir 2 inconvénients majeurs.

Un JSX plus étendu

Le premier problème va être spécifique à certains composants. Dans certains cas, le code généré par un composant serveur peut être plus grand que le code utilisé pour le générer. Si on génère une liste par exemple on va répéter les même morceaux de code ce qui peut s'avérer plus lourd.

Un exemple simple (mais pas forcément réaliste) :

const arr = Array.from({ length: 365 }, (_, i) => i);

export default function Page() {
  return (
    <div>
      <h2>Jours de l'année</h2>
      <ul>
        {arr.map((day) => (
          <li key={day}>{day}</li>
        ))}
      </ul>
    </div>
  );
}

Envoyer ce composant au front-end est plus léger que d'envoyer le JSX interprété avec 365 éléments.

La sérialisation

L'autre problème concerne la serialisation de variables lorsqu'elles sont passée à un composant client.

export async function Page() {
  const user = await sql("SELECT * FROM user");
  return (
    <div>
      <UserAvatar user={user} />
    </div>
  );
}

Si le composant PostCard est un composant client, la variable post sera alors exposée dans le JSX généré par le composant serveur.

export GeneratedComponent() {
  return (
    <div>
        <UserAvatar user={{username: 'john', email: 'john@doe.fr', password: '$2y$10$OAnXG/FYh88.6knjex6hi.'}}/>
    </div>
  );
}

Sans s'en rendre compte on vient d'exposer toutes les données de notre utilisateur à l'extérieur. Aussi, il est important d'être très vigilant aux propriétées que l'on envoie à un composant et si possible ne pas envoyer des objets directement sous peine d'exposer plus que nécessaire.

Publié
Technologies utilisées
Auteur :
Grafikart
Partager