

1/1/1970
Come Ho Implementato l'SSG in 2 Giorni
Ehilà! Un anno fa pensavo fosse impossibile. Ma ho appena finito di implementare la Static Site Generation (SSG) per Foony in 2 giorni e ne sono piuttosto entusiasta. E non è nemmeno la prima volta che provo a risolvere il problema dell'SSG per Foony. In passato ho dato un'occhiata a NextJS, Vike, Astro, Gatsby e ad altre soluzioni. Ho anche fatto un tentativo a vuoto con NextJS, ma mi sono scontrato con la complessità della SPA di Foony e delle sue migliaia di file. La migrazione sarebbe stata un incubo e avrebbe richiesto mesi. Avrebbe anche aggiunto ulteriore complessità per chiunque altro lavorasse sul sito, perché tutti avrebbero dovuto imparare NextJS e le sue stranezze.
Volevo qualcosa di leggero e facile da implementare. Qualcosa che ci permettesse di continuare a scrivere codice come l'abbiamo sempre scritto, senza dover pensare all'SSG (con l'eccezione di useMediaQuery, per cui non c'è davvero modo di girarci intorno). Qui sotto spiegherò perché ho optato per una soluzione su misura, le sfide specifiche che ho incontrato (specialmente con i Suspense boundary di React) e come le ho risolte.
Perché Non le Soluzioni Standard?
Quando ho preso in considerazione per la prima volta l'aggiunta dell'SSG a Foony, ho naturalmente valutato NextJS (lo standard del settore), Vike e Astro.
NextJS: Troppa Migrazione
NextJS è potente, ma avrebbe richiesto una migrazione massiccia della SPA React esistente di Foony. Abbiamo migliaia di file, una logica di routing complessa e tanta infrastruttura personalizzata. Migrare a NextJS avrebbe significato:
- Riscrivere l'intero sistema di routing
- Ristrutturare il modo in cui carichiamo giochi e componenti
- Mesi di lavoro solo per tornare alla parità di funzionalità
- Potenziali modifiche che impattano gli utenti
- Cambiare il modo in cui gestiamo le immagini
- Tempi di build significativamente più lenti (potenzialmente 5-30 minuti. Non ho numeri concreti a supporto, se non questa discussione di 5 anni fa su GitHub)
- Tutto il team a imparare qualcosa di nuovo (NextJS), e una velocità di sviluppo più lenta per sempre
- Migrare il codice ogni volta che NextJS decide di introdurre breaking change.
Ho anche provato un tentativo a vuoto con NextJS, ma ho rapidamente capito che il costo della migrazione era troppo alto. La complessità non valeva la pena.
Vike: Complessità Simile
Vike (precedentemente vite-plugin-ssr) aveva problemi simili. Pur essendo più flessibile di NextJS, avrebbe comunque richiesto una ristrutturazione significativa della nostra codebase. La curva di apprendimento e lo sforzo di migrazione non giustificavano i benefici.
Astro: Architettura Sbagliata
Astro è ottimo per siti orientati ai contenuti, ma Foony è una piattaforma di giochi multiplayer complessa. Abbiamo bisogno di aggiornamenti in tempo reale, connessioni WebSocket e componenti React dinamici. L'architettura di Astro semplicemente non si adatta a ciò che stiamo costruendo.
La Soluzione: SSG Su Misura
Forte del mio approccio "fake SSG" che avevo implementato qualche giorno prima dopo la i18n, ho optato per una soluzione piccola, leggera e su misura per l'SSG di Foony.
Il mio approccio "fake SSG" prevedeva di estrarre il contenuto dei post dalle pagine con i blog post (rotte
/postse pagine dei giochi) e posizionarli esattamente dove il client li avrebbe renderizzati, specificamente per aiutare i motori di ricerca e gli LLM a comprendere Foony. Applicava anche lo schema ld+json e qualche piccola cosa SEO.
L'approccio è semplice:
- Costruire sopra la SPA React esistente: nessuna migrazione necessaria, basta aggiungere la generazione SSG in fase di build.
- Usare
renderToReadableStream: l'API di streaming SSR di React 18 gestisce nativamente il Suspense. - Generare file HTML statici: pre-renderizzare le rotte in fase di build e servirle come file statici, usando il nostro SitemapGenerator per ottenere l'elenco delle rotte.
- Modifiche minime alla codebase esistente: la maggior parte dei componenti funziona così com'è.
L'implementazione principale vive in client/src/generators/GenerateShellSsgFromSitemap.ts. Legge una sitemap, renderizza ogni rotta usando renderToReadableStream di React e scrive l'HTML in file statici. Semplice, proprio come piace a me!
Tra l'altro, è risultato anche piuttosto veloce. Circa 2.800 rotte renderizzate in 10 secondi. Niente male. È significativamente più veloce di NextJS, Gatsby e Astro. <img alt="Log della console SSG che mostra il tempo impiegato" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />
Potrei andare avanti all'infinito sulla semplicità. Anche se nelle grandi aziende non ti farà ottenere una promozione per "mancanza di complessità", il codice semplice è bello, manutenibile e in generale è molto meglio per la velocità di sviluppo. È una cosa che ammiro molto dei principi Zen.
Il Problema dei Suspense Boundary
Avevo l'SSG, e il contenuto compariva nell'HTML. Ma le mie pagine erano vuote! Come?! <img alt="Pagina vuota 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" }} />
Si scopre che renderToReadableStream ha comunque dei Suspense boundary, anche se fai await stream.allReady. La mia ipotesi è che ciò avvenga perché è uno "stream", progettato per essere passato ai client mano a mano che i byte vengono ricevuti.
Cosa Restituisce React
Quando usi renderToReadableStream con Suspense, React produce HTML come questo:
<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
<!-- Actual content here -->
</div>
...
<script>/*Script that replaces the suspense boundaries*/</script>
Il <template id="B:0"> è un placeholder dove dovrebbe andare il contenuto. Il <div hidden id="S:0"> contiene il contenuto effettivamente renderizzato. B:0 corrisponde a S:0 per numero (indice 0-based).
Senza JavaScript, i motori di ricerca (sì, sto guardando te, Bing) e gli LLM vedrebbero una pagina quasi vuota con solo il placeholder template. Il che vanifica completamente lo scopo dell'SSG!
Non vedevo un modo pulito per rimuovere questi Suspense boundary, quindi la mia soluzione è stata scrivere alcuni test e una funzione resolveSuspenseBoundaries per sostituirli. Questo era più veloce che fare il parsing dell'HTML ed eseguire lo script con qualcosa come JSDOM. E, soprattutto, era un requisito per ciò che avevo in mente: un sito bello e leggibile per motori di ricerca e LLM senza JavaScript, ma con supporto per Suspense boundary e idratazione sul client.
Testare la Trasformazione
Ho iniziato scrivendo dei test per la trasformazione, prendendo alcuni esempi nel DOM da ciò che avevo (JavaScript disabilitato) e ciò che volevo (JavaScript abilitato). Li ho dati in pasto a un LLM e gli ho fatto generare i test, una cosa in cui se la cava piuttosto bene.
Questi test vivono in client/src/generators/ssr/renderRoute.test.ts e garantiscono che la trasformazione funzioni correttamente. I test coprono:
- Sostituzione di un boundary semplice (elenco dei blog)
- Boundary complessi con contenuto tra il template e il commento di chiusura
- Boundary multipli
- Boundary senza marker di commento
- Casi limite
Questo tipo di "TDD" è in realtà piuttosto utile in casi d'uso come questo, dove hai input e output attesi.
Da non confondere con il "TDD per tutto perché Robert C. Martin dice così" (che rallenterà la velocità di sviluppo del tuo team). NON dovresti usare il TDD per la UI o per aree del codice in costante cambiamento!
La Soluzione: resolveSuspenseBoundaries
Una volta predisposti i test, ho fatto scrivere all'LLM la funzione resolveSuspenseBoundaries. Ho optato per cheerio per evitare la fragilità delle RegEx, anche se usare le RegEx qui avrebbe ridotto i tempi dell'SSG di circa il 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};
}
Questo garantisce che, invece di vedere una pagina quasi vuota, i motori di ricerca e gli LLM vedano una pagina completamente renderizzata.
E adesso abbiamo l'SSG che funziona bene anche senza JavaScript!
<img alt="SSG senza JavaScript per i blog di 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" }} />
Sul lungo termine, è possibile che React cambi il formato del Suspense. Potrei rimuovere il codice di risoluzione del Suspense una volta che avrò una soluzione migliore per le pagine in lazy loading (e che quindi richiedono i Suspense boundary).
Strategia di Idratazione (Aggiornamento: Ci Sono Voluti 3 Giorni + 1 Giorno Extra)
L'idratazione è impegnativa. Lo sapevo. Ma, dopo un po' di lavoro, sono riuscito a farla funzionare!
Tempo totale impiegato per l'idratazione: 3 giorni, più 1 giorno extra per sostituire l'approccio della deidratazione.
La parte più complicata è stata semplicemente ottenere quella prima idratazione minima funzionante. Una volta riuscito a renderizzare un "Hello World" con la navbar, ho acquisito la fiducia che, sì, magari non ci sarebbe voluto un mese intero!
<img alt="L'Hello World di Foony si idrata con successo con la navbar" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_mvp.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
Per quella prima idratazione minima funzionante avevo una sfida particolare: volevo l'idratazione, ma volevo anche una buona SEO per i motori di ricerca e gli LLM senza che gli sviluppatori dovessero pensare ai Suspense boundary.
La Sfida
L'idratazione di React è estremamente letterale: se il DOM non corrisponde a ciò che React si aspetta per quel primo render, ricevi questo bel messaggio di errore (e quasi inutile) nella console, e React butta via tutto e ri-renderizza da zero. Non c'è nemmeno un diff a farti capire cosa è andato storto!
Nel nostro caso, l'SSG peggiorava la situazione in un paio di modi:
- Post-elaboravamo l'HTML per rimuovere/risolvere gli artefatti dello streaming Suspense di React 18 (ottimo per i bot).
- Il client non aveva sempre disponibili gli stessi dati al tempo (t = 0) che aveva il render del server (dati SSG, metadata dei blog, ecc.).
- La nostra i18n è "lazy" di default, il che significa che le traduzioni possono mancare al primo render a meno che non si registri quali traduzioni sono state usate per l'SSG e le si inietti prima che React renderizzi.
Cosa Ha Funzionato (Approccio Iniziale: Deidratazione)
All'inizio ho provato qualcosa di intelligente e simpatico: ho usato un command pattern per registrare i comandi usati per risolvere i Suspense boundary dell'HTML, e ho restituito i comandi di trasformazione inversa per poter ripristinare l'HTML in ciò di cui React aveva bisogno per l'idratazione.
La mia speranza era di poter inviare molti meno byte in index.html con questo metodo a comandi. Ma, come spesso accade con le soluzioni intelligenti, è fallito perché i browser modificano l'HTML in modi sottili, ad esempio rimuovendo o aggiungendo un ; o /, mandando in tilt gli indici di sostituzione.
Tecnicamente probabilmente potresti tener conto di queste sottili modifiche del browser, ma non avevo intenzione di mettere in produzione qualcosa di così fragile.
Invece di provare a "invertire" la trasformazione dei Suspense boundary riportandola al markup di streaming di React, ho fatto qualcosa di super semplice:
Includere l'HTML originale, non risolto, in un <script type="text">.
Questo approccio "deidratazione" ha funzionato, ma ho speso un giorno extra per sostituirlo con una soluzione migliore.
L'Approccio Migliore: Sostituzione dei Suspense Boundary del Critical Path
Dopo l'implementazione iniziale, riscontravo ancora alcuni problemi con i Suspense boundary. È a quel punto che ho capito che c'era una soluzione più pulita, migliore e più semplice. Ho sostituito l'approccio della deidratazione con la sostituzione dei Suspense boundary del critical path, che:
- Carica il critical path prima dell'idratazione: i componenti che sono stati precaricati durante l'SSR vengono identificati e precaricati sul client prima che venga chiamato
hydrateRoot
- È più semplice da mantenere: nessun parsing degli internals di React o degli AST richiesto (l'approccio della deidratazione doveva fare il parsing e ripristinare l'HTML)
- Invia meno byte: non includiamo più la risposta SSR originale di React in un tag script
- Previene un potenziale flash: nessuna necessità di deidratare/reidratare l'HTML, eliminando un potenziale flash visivo
L'implementazione tiene traccia di quali componenti lazy sono stati precaricati durante l'SSR (tramite SSRLazyComponentTracker), include i loro percorsi di import nei dati di idratazione e li precarica sincronamente prima dell'idratazione. I componenti del critical path renderizzano direttamente senza Suspense boundary, corrispondendo esattamente all'output dell'SSR.
Per tutto il resto, facciamo in modo che il primo render del client agisca come SSR/SSG. Ciò significa usare gli stessi input e renderli disponibili in modo sincrono prima di hydrateRoot. Questo viene fatto includendo i dati tramite il nostro "ssg-data".
In concreto, gli aggiustamenti sono stati:
Includere gli input SSR in un singolo script di testo
- Durante l'SSG iniettiamo uno
<script type="text/foony-ssg" id="foony-ssg-data">...</script> proprio prima dell'entrypoint del modulo Vite.
- Quello script contiene:
html: l'HTML risolto che abbiamo effettivamente inviato nel file statico
ssgData: l'SSGData serializzato usato dal wrapper SSR. Ho intenzione di aggiornare questo a un Proxy o qualcosa di simile in modo che siano inclusi solo i dati a cui si accede.
translationData: i blob chiave-valore delle traduzioni che abbiamo toccato durante l'SSR
Iniettare quegli input subito prima dell'idratazione
- In
main.tsx, sincronicamente:
- impostiamo
#root.innerHTML sull'HTML risolto serializzato (così il DOM è esattamente ciò che vede l'idratazione)
- avvolgiamo l'app in
SSGDataProvider in modo che i componenti abbiano lo stesso SSGData al primo render
Rendere l'i18n istantanea iniettando i valori delle traduzioni
- Registriamo gli oggetti di traduzione effettivamente usati durante l'SSR e li inviamo nello script SSG.
- Sul client, li iniettiamo direttamente nella cache di
LocaleQueryer tramite un metodo dedicato LocaleQueryer.inject(), in modo che le traduzioni siano disponibili immediatamente.
E con ciò, il primo render ha gli stessi dati che aveva l'SSR!
L'hook useIsSSRMode() è già implementato in 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;
}
Questo hook restituisce true durante l'SSR e al primo render del client (idratazione), poi passa a false dopo il mount. Componenti come UserBanner, Navbar e Dialog lo usano già per prevenire i mismatch di idratazione.
- Patchare React per ottenere diff migliori
Speravo di poter semplicemente usare hydration-overlay. Ma non è mantenuto attivamente, è supportato solo fino a React 18 e non era pronto per la produzione. Quindi ho fatto clonare il repository da un LLM per trarne ispirazione, e poi ha creato un overlay di idratazione minimale in pochi minuti. Non avevo bisogno di nulla di sofisticato, solo qualcosa che si mostrasse durante lo sviluppo per capire dove le cose andavano storte.
Questo nuovo overlay è super basilare, quindi i diff non sono proprio perfetti. React rimuove i commenti, aggiunge ; dopo gli attributi style, modifica gli spazi bianchi e fa altre piccole cose di cui il nostro overlay non tiene conto (per ora). Il nostro overlay include anche commenti HTML che React ignora per la sua idratazione.
<img alt="Il nostro nuovo overlay di idratazione" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_overlay.webp" style={{ margin: "8px auto", height: 315, display: "block" }} />
Ma è abbastanza buono per capire cosa serve sistemare.
<img alt="diff dell'SSG vs primo render della pagina del client per l'idratazione di 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" }} />
I Numeri
Per darti un'idea di cosa abbia comportato questa implementazione:
- 2 giorni di lavoro (dall'inizio all'SSG funzionante). Si è trattato di poco più di 24 ore mentre ero in vacanza.
- 4 giorni di lavoro per far comportare bene l'idratazione senza race condition delle traduzioni asincrone o
useMediaQuery che mandasse all'aria le cose.
- 1 giorno extra per sostituire l'approccio della deidratazione con la sostituzione dei Suspense boundary del critical path (più semplice, meno byte, nessun flash potenziale).
- ~200 righe di codice principale di generazione SSG (
GenerateShellSsgFromSitemap.ts)
- ~120 righe di risoluzione dei Suspense boundary (
resolveSuspenseBoundaries in renderRoute.tsx). Nota: in seguito sostituite dall'approccio del critical path
- ~50 righe di utility SSR (
isSSRMode.ts)
- ~100 righe di test (
renderRoute.test.ts)
- ~150 righe di polyfill per l'SSR (
setupSSREnvironment)
- Modifiche minime ai componenti esistenti (per lo più aggiungendo i controlli
useIsSSRMode())
La soluzione è leggera e manutenibile. Non richiede una migrazione di framework e funziona con la nostra SPA React esistente.
Punti Chiave
A Volte una Soluzione Su Misura È Migliore
Non tutti i problemi richiedono un framework. Per Foony, una piccola soluzione SSG su misura è stata la scelta giusta. È:
- Leggera: nessuna dipendenza pesante o overhead di framework
- Manutenibile: codice semplice che capiamo
- Flessibile: facile da modificare ed estendere all'occorrenza
- Compatibile: funziona con la nostra SPA React esistente senza migrazione
L'SSR Streaming di React Ha le Sue Stranezze
Il renderToReadableStream di React è comodo per gestire il Suspense, ma ha le sue stranezze. Anche con await stream.allReady, ottieni comunque dei Suspense boundary nell'output. Non è un bug, è una scelta di design per lo streaming. Ma per l'SSG abbiamo bisogno di HTML completamente risolto. Sembra una mancanza da parte del team di React non gestire questo scenario in modo pulito.
La mia soluzione è stata post-elaborare l'HTML e risolvere i boundary. Non è bellissima, ma è veloce e abbastanza flessibile per il mio caso d'uso.
Il TDD Può Essere Utile per gli LLM
La trasformazione dell'HTML è soggetta a errori. Un piccolo bug e potresti rompere l'intero output dell'SSG e l'esperienza dell'utente finale. Ho fatto scrivere a un LLM dei test esaustivi (con il mio input) per garantire che la trasformazione funzioni correttamente.
Conclusione
L'SSG ora funziona per Foony. Le pagine sono completamente renderizzate per i motori di ricerca e gli LLM, e la soluzione è manutenibile e leggera. L'idratazione per le rotte SSG ha richiesto più tempo di quanto mi aspettassi (3 giorni), e ho speso un giorno extra per sostituire l'approccio iniziale della deidratazione con la sostituzione dei Suspense boundary del critical path. Il nuovo approccio è più semplice da mantenere, invia meno byte e previene potenziali flash visivi dovuti alla deidratazione/reidratazione dell'HTML.
Sono ancora sorpreso che ci siano voluti solo 2 giorni per implementare una soluzione su misura per l'SSG. Ma a volte la soluzione giusta è la più semplice.
Il lavoro futuro include il completamento del matching dell'idratazione e potenzialmente il patching di React per un debug migliore. Ma per ora Foony ha un SSG funzionante. Terrò d'occhio Google Search Console e Bing Webmaster Tools nelle prossime settimane per vedere che effetto avrà sulla nostra SEO.