

1/1/1970
Comment j'ai implémenté le SSG en 2 jours
Salut ! Il y a un an, je pensais que c'était impossible. Mais je viens de finir d'implémenter la Génération de Site Statique (SSG) pour Foony en 2 jours, et j'en suis plutôt content. Et ce n'est pas la première fois que j'essaie de résoudre la question du SSG pour Foony. J'ai déjà étudié NextJS, Vike, Astro, Gatsby, et quelques autres solutions par le passé. J'ai même eu un faux départ avec NextJS, mais je me suis heurté à des difficultés liées à la complexité du SPA de Foony et à ses milliers de fichiers. La migration aurait été un cauchemar et aurait pris des mois. Cela aurait aussi ajouté de la complexité pour toutes les autres personnes travaillant sur le site, qui auraient dû apprendre NextJS et ses bizarreries.
Je voulais quelque chose de léger et facile à mettre en place. Quelque chose qui nous permettrait de continuer à écrire du code de la même manière que d'habitude, sans avoir à penser au SSG (à l'exception de useMediaQuery, là il n'y a pas vraiment moyen de faire autrement). Ci-dessous, je vais expliquer pourquoi j'ai opté pour une solution sur mesure, les défis spécifiques que j'ai rencontrés (en particulier avec les Suspense boundaries de React), et comment je les ai résolus.
Pourquoi pas les solutions standards ?
Quand j'ai commencé à envisager d'ajouter le SSG à Foony, j'ai naturellement pensé à NextJS (le standard de l'industrie), Vike, et Astro.
NextJS : trop de migration
NextJS est puissant, mais cela aurait nécessité une migration massive du SPA React existant de Foony. Nous avons des milliers de fichiers, une logique de routage complexe, et beaucoup d'infrastructure personnalisée. Migrer vers NextJS aurait signifié :
- Réécrire l'intégralité de notre système de routage
- Restructurer la façon dont nous chargeons les jeux et les composants
- Des mois de travail juste pour retrouver la même parité fonctionnelle
- Des ruptures potentielles pour les utilisateurs
- Changer la manière dont nous gérons les images
- Des temps de build nettement plus longs (potentiellement 5 à 30 minutes. Je n'ai pas de chiffres concrets pour étayer ça, à part cette discussion vieille de 5 ans sur GitHub)
- Toute l'équipe devant apprendre quelque chose de nouveau (NextJS), et une vélocité de développement durablement réduite
- Migrer le code à chaque fois que NextJS décide de faire des changements cassants.
J'ai même tenté un faux départ avec NextJS, mais j'ai vite réalisé que le coût de migration était trop élevé. La complexité n'en valait pas la peine.
Vike : une complexité similaire
Vike (anciennement vite-plugin-ssr) avait des problèmes similaires. Bien que plus flexible que NextJS, il aurait quand même demandé une restructuration significative de notre base de code. La courbe d'apprentissage et l'effort de migration ne justifiaient pas les bénéfices.
Astro : la mauvaise architecture
Astro est génial pour les sites riches en contenu, mais Foony est une plateforme de jeux multijoueurs complexe. Nous avons besoin de mises à jour en temps réel, de connexions WebSocket, et de composants React dynamiques. L'architecture d'Astro ne correspond tout simplement pas à ce que nous construisons.
La solution : un SSG sur mesure
Encouragé par mon approche de "faux SSG" que j'avais implémentée quelques jours auparavant après l'i18n, j'ai opté pour une petite solution légère et sur mesure pour le SSG de Foony.
Mon approche de "faux SSG" consistait à extraire le contenu des articles de blog des pages contenant des articles (routes
/postset pages de jeux), et à les positionner exactement là où le client les rendrait, spécifiquement pour aider les moteurs de recherche et les LLM à comprendre Foony. Elle appliquait aussi un schéma ld+json et quelques petites choses de SEO.
L'approche est simple :
- S'appuyer sur le SPA React existant : aucune migration nécessaire, on ajoute juste la génération SSG au moment du build.
- Utiliser
renderToReadableStream: l'API SSR streaming de React 18 gère Suspense nativement. - Générer des fichiers HTML statiques : pré-rendre les routes au moment du build et les servir comme fichiers statiques, en utilisant notre SitemapGenerator pour obtenir la liste des routes.
- Changements minimes au code existant : la plupart des composants fonctionnent tels quels.
L'implémentation principale se trouve dans client/src/generators/GenerateShellSsgFromSitemap.ts. Elle lit un sitemap, rend chaque route avec renderToReadableStream de React, et écrit le HTML dans des fichiers statiques. Simple, comme je l'aime !
C'est aussi devenu plutôt rapide. Environ 2 800 routes rendues en 10 secondes. Sympa. C'est nettement plus rapide que NextJS, Gatsby, et Astro. <img alt="Console SSG affichant le temps écoulé" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />
Je pourrais parler longuement de la simplicité. Même si elle ne vous vaudra pas une promotion dans les grandes entreprises faute de "complexité", un code simple est beau, maintenable, et globalement bien meilleur pour la vélocité des développeurs. C'est quelque chose que j'admire vraiment dans les principes Zen.
Le problème des Suspense Boundaries
Donc maintenant j'avais le SSG, et le contenu apparaissait dans le HTML... mais mes pages étaient vides ! Comment ça ?! <img alt="Page vide en SSG" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blank_page.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
Il s'avère que renderToReadableStream conserve les Suspense boundaries, même si vous faites await stream.allReady. Je suppose que c'est parce qu'il s'agit d'un "stream", conçu pour être envoyé aux clients à mesure que les octets sont reçus.
Ce que React produit
Quand vous utilisez renderToReadableStream avec Suspense, React produit du HTML comme ceci :
<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
<!-- Le contenu réel ici -->
</div>
...
<script>/*Script qui remplace les suspense boundaries*/</script>
Le <template id="B:0"> est un emplacement où le contenu doit aller. Le <div hidden id="S:0"> contient le contenu effectivement rendu. Le B:0 correspond à S:0 par numéro (index commençant à 0).
Sans JavaScript, les moteurs de recherche (oui, je te regarde Bing) et les LLM verraient une page quasiment vide avec juste l'emplacement du template. Cela va à l'encontre de tout l'objectif du SSG !
Je n'ai vu aucun moyen propre de supprimer ces Suspense boundaries, alors ma solution a été d'écrire quelques tests et une fonction resolveSuspenseBoundaries pour les remplacer. C'était plus rapide que de parser le HTML et d'exécuter le script avec un truc comme JSDOM. Et, plus important encore, c'était une exigence pour ce que j'avais prévu : un site agréable et lisible pour les moteurs de recherche / LLM sans JavaScript, mais avec le support des Suspense boundaries et de l'hydratation côté client.
Tester la transformation
J'ai commencé par écrire des tests pour la transformation en récupérant des exemples dans le DOM de ce que j'avais (JavaScript désactivé), et de ce que je voulais (JavaScript activé). J'ai donné ces exemples à un LLM et je lui ai laissé gérer la génération des tests, c'est quelque chose qu'il fait plutôt bien.
Ces tests se trouvent dans client/src/generators/ssr/renderRoute.test.ts et garantissent que la transformation fonctionne correctement. Les tests couvrent :
- Le remplacement simple de boundary (liste de blog)
- Les boundaries complexes avec du contenu entre le template et le commentaire de fermeture
- Les boundaries multiples
- Les boundaries sans marqueurs de commentaire
- Les cas particuliers
Ce type de "TDD" est en fait très utile pour ce cas d'usage où vous avez des entrées et des sorties attendues.
À ne pas confondre avec le "TDD partout parce que Robert C. Martin l'a dit" (qui ralentira la vélocité de développement de votre équipe). Vous ne devriez PAS utiliser le TDD pour l'UI ou pour des parties de code qui changent en permanence !
La solution : resolveSuspenseBoundaries
Maintenant que les tests étaient en place, j'ai laissé le LLM écrire la fonction resolveSuspenseBoundaries. J'ai choisi cheerio pour éviter la fragilité des RegEx, même si utiliser des RegEx ici réduirait le temps de SSG d'environ 40 %.
export function resolveSuspenseBoundaries(html: string): {html: string; didResolveSuspense: boolean} {
const originalHtml = html;
const $ = cheerio.load(originalHtml, {xml: false, isDocument: false, sourceCodeLocationInfo: true});
const operations: Array<{index: number; removeLength: number; insertText?: string}> = [];
// Collect hidden divs with their content and positions.
const hiddenDivs = new Map<string, {content: string; divStartIndex: number; divEndIndex: number}>();
$('div[hidden][id^="S:"]').each((_, el) => {
const id = $(el).attr('id');
if (!id) {
return;
}
const boundaryId = id.substring(2);
const content = $(el).html() || '';
const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
if (typeof startOffset === 'number' && typeof endOffset === 'number') {
hiddenDivs.set(boundaryId, {content, divStartIndex: startOffset, divEndIndex: endOffset});
}
});
if (hiddenDivs.size === 0) {
return {html: originalHtml, didResolveSuspense: false};
}
// Find templates (B:0) and replace them with the matching hidden content (S:0),
// following React’s internal $RV behavior.
$('template[id^="B:"]').each((_, el) => {
const id = $(el).attr('id');
if (!id) {
return;
}
const boundaryId = id.substring(2);
const divInfo = hiddenDivs.get(boundaryId);
if (!divInfo) {
return;
}
const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
if (typeof startOffset !== 'number' || typeof endOffset !== 'number') {
return;
}
const templateIndex = startOffset;
const templateLength = endOffset - startOffset;
const afterTemplate = originalHtml.substring(templateIndex + templateLength);
const closingCommentMatch = afterTemplate.match(/<!--\/[amp;]-->/);
const removeEndIndex = closingCommentMatch
? templateIndex + templateLength + closingCommentMatch.index!
: templateIndex + templateLength;
const divContentStartIndex = originalHtml.indexOf('>', divInfo.divStartIndex) + 1;
const divContentEndIndex = originalHtml.lastIndexOf('</', divInfo.divEndIndex);
const divContent = originalHtml.substring(divContentStartIndex, divContentEndIndex);
operations.push({index: templateIndex, removeLength: removeEndIndex - templateIndex});
operations.push({index: templateIndex, removeLength: 0, insertText: divContent});
operations.push({index: divContentStartIndex, removeLength: divContentEndIndex - divContentStartIndex});
operations.push({index: divInfo.divStartIndex, removeLength: divContentStartIndex - divInfo.divStartIndex});
operations.push({index: divContentEndIndex, removeLength: divInfo.divEndIndex - divContentEndIndex});
});
operations.sort((a, b) => (a.index !== b.index ? b.index - a.index : b.removeLength - a.removeLength));
let resultHtml = originalHtml;
for (const operation of operations) {
resultHtml = resultHtml.slice(0, operation.index) + (operation.insertText ?? '') + resultHtml.slice(operation.index + operation.removeLength);
}
return {html: resultHtml, didResolveSuspense: true};
}
Cela garantit qu'au lieu de voir une page presque vide, les moteurs de recherche et les LLM voient une page entièrement rendue.
Et voilà, on a maintenant un SSG qui fonctionne bien sans JavaScript !
<img alt="SSG sans JavaScript pour les blogs de Foony" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blog_ssg.webp" style={{ margin: "8px auto", height: 340, display: "block" }} />
À long terme, il est possible que React change le format de Suspense. Il se peut que je supprime le code de résolution Suspense une fois que j'aurai une meilleure solution pour les pages chargées en lazy (et qui nécessitent donc des Suspense boundaries).
Stratégie d'hydratation (Mise à jour : ça a pris 3 jours + 1 jour supplémentaire)
L'hydratation est un défi. Je le savais. Mais, après un peu de travail, j'ai réussi à la faire fonctionner !
Temps total pour l'hydratation : 3 jours, plus 1 jour supplémentaire pour remplacer l'approche de déshydratation.
Le plus délicat a été d'obtenir cette première hydratation minimale fonctionnelle. Une fois que j'ai réussi à rendre un "Hello World" avec la barre de navigation, j'ai pris confiance : oui, ça ne prendrait peut-être pas un mois entier !
<img alt="Le Hello World de Foony s'hydrate avec succès avec la barre de navigation" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_mvp.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
Pour cette première hydratation minimale fonctionnelle, j'avais un défi unique : je voulais l'hydratation, mais je voulais aussi un bon SEO pour les moteurs de recherche et les LLM, sans que les développeurs aient à se soucier des Suspense boundaries.
Le défi
L'hydratation React est extrêmement littérale : si le DOM ne ressemble pas à ce que React attend pour ce premier rendu, vous obtenez ce joli message d'erreur quasiment inutile dans votre console, et React jette tout par la fenêtre pour tout re-rendre depuis zéro. Même pas un diff pour vous indiquer ce qui n'a pas marché !
Dans notre cas, le SSG aggravait ça de plusieurs manières :
- Nous post-traitions le HTML pour supprimer/résoudre les artefacts du streaming Suspense de React 18 (ce qui est super pour les bots).
- Le client n'avait pas toujours les mêmes données disponibles à l'instant (t = 0) que le rendu serveur (données SSG, métadonnées de blog, etc).
- Notre i18n est "lazy" par défaut, ce qui signifie que des traductions peuvent manquer pour le premier rendu, à moins d'enregistrer quelles traductions ont été utilisées pour le SSG et de les injecter avant que React ne fasse son rendu.
Ce qui a fonctionné (Approche initiale : Déshydratation)
Au début, j'ai essayé quelque chose d'astucieux et de mignon : j'ai utilisé un command pattern pour enregistrer les commandes utilisées pour résoudre les Suspense boundaries du HTML, et renvoyé les commandes de transformation inverse pour pouvoir restaurer le HTML dans l'état attendu par React pour l'hydratation.
J'espérais pouvoir envoyer beaucoup moins d'octets dans index.html avec cette méthode de commandes. Mais, comme avec la plupart des solutions astucieuses, ça a échoué parce que les navigateurs modifient le HTML de manière subtile, par exemple en supprimant ou en ajoutant un ; ou un /, ce qui décalait les indices de remplacement.
Techniquement on pourrait probablement tenir compte de ces modifications subtiles des navigateurs, mais je n'allais pas livrer quelque chose d'aussi fragile.
Au lieu d'essayer d'"inverser" la transformation des Suspense boundaries pour revenir au markup streaming de React, j'ai fait quelque chose de super simple :
Embarquer le HTML original non résolu dans un <script type="text">.
Cette approche de "déshydratation" a fonctionné, mais j'ai passé un jour de plus à la remplacer par une meilleure solution.
La meilleure approche : Remplacement des Suspense Boundaries du chemin critique
Après l'implémentation initiale, je rencontrais encore quelques problèmes avec les Suspense boundaries. C'est là que j'ai réalisé qu'il existait une solution plus propre, meilleure et plus simple. J'ai remplacé l'approche de déshydratation par le remplacement des Suspense boundaries du chemin critique, qui :
- Charge le chemin critique avant l'hydratation : les composants préchargés pendant le SSR sont identifiés et préchargés côté client avant l'appel à
hydrateRoot
- Est plus simple à maintenir : pas besoin de toucher aux internes de React ni de parser des AST (l'approche de déshydratation devait parser et restaurer le HTML)
- Envoie moins d'octets : nous n'embarquons plus la réponse SSR originale de React dans une balise script
- Évite un flash potentiel : pas besoin de déshydrater/réhydrater le HTML, ce qui élimine un flash visuel potentiel
L'implémentation suit quels composants lazy ont été préchargés pendant le SSR (via SSRLazyComponentTracker), inclut leurs chemins d'import dans les données d'hydratation, et les précharge de manière synchrone avant l'hydratation. Les composants du chemin critique se rendent directement sans Suspense boundaries, correspondant exactement à la sortie SSR.
Pour tout le reste, on fait en sorte que le premier rendu client se comporte comme un SSR/SSG. C'est-à-dire utiliser les mêmes entrées, et les rendre disponibles de manière synchrone avant hydrateRoot. Cela se fait en les embarquant via notre "ssg-data".
Concrètement, les ajustements ont été :
Embarquer les entrées SSR dans un seul script de type texte
- Pendant le SSG, on injecte un
<script type="text/foony-ssg" id="foony-ssg-data">...</script> juste avant l'entrypoint module Vite.
- Ce script contient :
html : le HTML résolu que nous avons effectivement livré dans le fichier statique
ssgData : la SSGData sérialisée utilisée par le wrapper SSR. Je prévois de mettre à jour cela en Proxy ou quelque chose de similaire pour que seules les données accédées soient incluses.
translationData : les blobs clé-valeur de traduction touchés pendant le SSR
Injecter ces entrées juste avant l'hydratation
- Dans
main.tsx, on fait de manière synchrone :
- définir
#root.innerHTML au HTML résolu sérialisé (pour que le DOM soit exactement ce que voit l'hydratation)
- encapsuler l'application dans
SSGDataProvider pour que les composants aient les mêmes SSGData au premier rendu
Rendre l'i18n instantanée en injectant les valeurs de traduction
- Nous enregistrons les objets de traduction effectivement consultés pendant le SSR et les livrons dans le script SSG.
- Côté client, nous les injectons directement dans le cache de
LocaleQueryer via une méthode dédiée LocaleQueryer.inject(), pour que les traductions soient immédiatement disponibles.
Et avec ça, le premier rendu a les mêmes données que le SSR !
Le hook useIsSSRMode() est déjà implémenté dans client/src/generators/ssr/isSSRMode.ts :
export function useIsSSRMode(): boolean {
const [isSSRMode, setIsSSRMode] = React.useState(true);
React.useEffect(() => {
// After mount (hydration complete), switch to client mode
setIsSSRMode(false);
}, []);
return isSSRMode;
}
Ce hook retourne true pendant le SSR et au premier rendu client (hydratation), puis bascule à false après le mount. Des composants comme UserBanner, Navbar, et Dialog l'utilisent déjà pour éviter les non-correspondances d'hydratation.
- Patcher React pour de meilleurs diffs
J'espérais pouvoir simplement utiliser hydration-overlay. Mais il n'est plus activement maintenu, ne supporte que jusqu'à React 18, et n'était pas prêt pour la production. Alors j'ai fait cloner le repo par un LLM pour s'en inspirer, et il a ensuite créé une overlay d'hydratation minimale en quelques minutes. Je n'avais besoin de rien de fancy, juste de quelque chose qui apparaisse pendant le développement pour que je puisse identifier où les choses tournaient mal.
Cette nouvelle overlay est super basique, donc les diffs ne sont pas tout à fait parfaits. React supprime les commentaires, ajoute des ; après les attributs de style, modifie les espaces, et fait quelques autres petites choses dont notre overlay ne tient pas (encore) compte. Notre overlay inclut aussi les commentaires HTML, que React ignore pour son hydratation.
<img alt="Notre nouvelle overlay d'hydratation" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_overlay.webp" style={{ margin: "8px auto", height: 315, display: "block" }} />
Mais c'est suffisant pour comprendre ce qui doit être corrigé.
<img alt="diff entre notre SSG et le rendu client de la première page pour l'hydratation React" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />
En chiffres
Pour vous donner une idée de ce que cette implémentation a impliqué :
- 2 jours de travail (du début au SSG fonctionnel). C'était un peu plus de 24 heures pendant les vacances.
- 4 jours de travail pour que l'hydratation se comporte bien sans courses asynchrones de traduction ni
useMediaQuery qui mette le bazar.
- 1 jour supplémentaire pour remplacer l'approche de déshydratation par le remplacement des Suspense boundaries du chemin critique (plus simple, moins d'octets, pas de flash potentiel).
- ~200 lignes de code central de génération SSG (
GenerateShellSsgFromSitemap.ts)
- ~120 lignes de résolution des Suspense boundaries (
resolveSuspenseBoundaries dans renderRoute.tsx) - Note : Ceci a été remplacé plus tard par l'approche du chemin critique
- ~50 lignes d'utilitaires SSR (
isSSRMode.ts)
- ~100 lignes de tests (
renderRoute.test.ts)
- ~150 lignes de polyfills pour le SSR (
setupSSREnvironment)
- Changements minimes sur les composants existants (essentiellement l'ajout de vérifications
useIsSSRMode())
La solution est légère et maintenable. Elle ne nécessite pas de migration de framework, et elle fonctionne avec notre SPA React existant.
Points clés à retenir
Parfois, une solution sur mesure est meilleure
Tous les problèmes ne nécessitent pas un framework. Pour Foony, une petite solution SSG sur mesure était le bon choix. Elle est :
- Légère : pas de dépendances lourdes ni de surcharge de framework
- Maintenable : du code simple que nous comprenons
- Flexible : facile à modifier et à étendre selon les besoins
- Compatible : fonctionne avec notre SPA React existant sans migration
Le SSR streaming de React a ses bizarreries
Le renderToReadableStream de React est sympa pour gérer Suspense, mais il a ses bizarreries. Même avec await stream.allReady, vous obtenez toujours des Suspense boundaries dans la sortie. Ce n'est pas un bug, c'est voulu pour le streaming. Mais pour le SSG, on a besoin de HTML entièrement résolu. On dirait un échec de l'équipe React de ne pas gérer ce scénario de manière propre.
Ma solution a été de post-traiter le HTML et de résoudre les boundaries. Ce n'est pas joli, mais c'est rapide et suffisamment flexible pour mon cas d'usage.
Le TDD peut être utile pour les LLM
La transformation HTML est sujette aux erreurs. Un petit bug et vous pouvez casser toute la sortie SSG et l'expérience utilisateur final. J'ai fait écrire à un LLM des tests complets (avec mes contributions) pour garantir que la transformation fonctionne correctement.
Conclusion
Le SSG fonctionne maintenant pour Foony. Les pages sont entièrement rendues pour les moteurs de recherche et les LLM, et la solution est maintenable et légère. L'hydratation des routes SSG a pris plus de temps que prévu (3 jours), et j'ai passé un jour de plus à remplacer l'approche initiale de déshydratation par le remplacement des Suspense boundaries du chemin critique. La nouvelle approche est plus simple à maintenir, envoie moins d'octets, et évite les flashs visuels potentiels liés à la déshydratation/réhydratation du HTML.
Je suis encore choqué qu'il n'ait fallu que 2 jours pour implémenter une solution SSG sur mesure. Mais parfois, la bonne solution est la plus simple.
Les travaux futurs incluent l'achèvement du matching d'hydratation et potentiellement le patch de React pour un meilleur débogage. Mais pour l'instant, Foony a un SSG fonctionnel. Je vais garder un œil sur la Google Search Console et Bing Webmaster Tools dans les prochaines semaines pour voir l'effet que ça aura sur notre SEO.