background blurbackground mobile blur

1/1/1970

Sådan implementerede jeg SSG på 2 dage

Howdy! For et år siden troede jeg, det var umuligt. Men jeg har lige færdiggjort implementeringen af Static Site Generation (SSG) til Foony på 2 dage, og jeg er ret begejstret over det. Det er ikke første gang, jeg har forsøgt at løse SSG til Foony. Jeg har tidligere kigget på NextJS, Vike, Astro, Gatsby og et par andre løsninger. Jeg havde endda en falsk start med NextJS, men løb ind i vanskeligheder med kompleksiteten af Foonys SPA og tusindvis af filer. Migreringen ville have været et mareridt og ville have taget måneder. Det ville også have tilført ekstra kompleksitet for alle andre, der arbejder på siden, fordi de skulle lære NextJS og dens særheder.

Jeg ville have noget let og enkelt at implementere. Noget, der ville lade os fortsætte med at skrive kode på samme måde, som vi har gjort det, uden at skulle tænke på SSG (med undtagelse af useMediaQuery, der er ingen rigtig vej udenom den ene). Nedenfor vil jeg gennemgå, hvorfor jeg valgte en skræddersyet løsning, de specifikke udfordringer jeg løb ind i (især med Reacts Suspense-grænser), og hvordan jeg løste dem.

Hvorfor ikke standardløsninger?

Da jeg først kiggede på at tilføje SSG til Foony, overvejede jeg naturligvis NextJS (industristandard), Vike og Astro.

NextJS: For meget migrering

NextJS er kraftfuldt, men det ville have krævet en massiv migrering af Foonys eksisterende React SPA. Vi har tusindvis af filer, kompleks routing-logik og en masse skræddersyet infrastruktur. At migrere til NextJS ville have betydet:

  • Omskrivning af hele vores routing-system
  • Omstrukturering af, hvordan vi indlæser spil og komponenter
  • Måneders arbejde bare for at komme tilbage til feature-paritet
  • Potentielle breaking changes for brugerne
  • Ændring af måden vi håndterer billeder på
  • Betydeligt langsommere build-tider (potentielt 5-30 minutter. Jeg har ingen konkrete tal til at bakke det op udover denne 5 år gamle diskussion på GitHub)
  • Hele teamet skulle lære noget nyt (NextJS), og langsommere udviklerhastighed for evigt
  • Migrering af koden hver gang NextJS beslutter at lave breaking changes.

Jeg prøvede endda en falsk start med NextJS, men indså hurtigt, at migreringsomkostningen var for høj. Kompleksiteten var det ikke værd.

Vike: Lignende kompleksitet

Vike (tidligere vite-plugin-ssr) havde lignende problemer. Selvom det er mere fleksibelt end NextJS, ville det stadig have krævet betydelig omstrukturering af vores kodebase. Læringskurven og migreringsindsatsen retfærdiggjorde ikke fordelene.

Astro: Forkert arkitektur

Astro er fantastisk til indholdstunge sider, men Foony er en kompleks multiplayer-spilplatform. Vi har brug for opdateringer i realtid, WebSocket-forbindelser og dynamiske React-komponenter. Astros arkitektur passer simpelthen ikke til det, vi bygger.

Løsningen: Skræddersyet SSG

Opmuntret af min "falske SSG"-tilgang, som jeg implementerede for et par dage siden efter i18n, valgte jeg en lille, letvægts, skræddersyet løsning til Foonys SSG.

Min "falske SSG"-tilgang gik ud på at trække blogindlægsindholdet ud fra sider med blogindlæg (/posts-ruter og spilsider), og placere det præcis der, hvor klienten ville rendere det, specifikt for søgemaskiner og LLM'er for at hjælpe med at forstå Foony. Den anvendte også ld+json-skema og lidt SEO.

Tilgangen er enkel:

  1. Byg ovenpå eksisterende React SPA: Ingen migrering nødvendig, bare tilføj SSG-generering ved build-tid.
  2. Brug renderToReadableStream: React 18's streaming SSR API håndterer Suspense indfødt.
  3. Generér statiske HTML-filer: Pre-render ruter ved build-tid og servér dem som statiske filer ved hjælp af vores SitemapGenerator til at få en liste over ruter.
  4. Minimale ændringer i eksisterende kodebase: De fleste komponenter fungerer som de er.

Kerneimplementeringen ligger i client/src/generators/GenerateShellSsgFromSitemap.ts. Den læser et sitemap, renderer hver rute ved hjælp af Reacts renderToReadableStream, og skriver HTML'en til statiske filer. Enkelt, lige som jeg kan lide det!

Det endte også med at være ret hurtigt. Omkring 2.800 ruter renderet på 10 sekunder. Lækkert. Det er betydeligt hurtigere end NextJS, Gatsby og Astro. <img alt="SSG-konsollog der viser tidsforbrug" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />

Jeg kunne blive ved og ved omkring enkelhed. Selvom det ikke vil give dig en forfremmelse i store virksomheder på grund af "manglende kompleksitet", er enkel kode smuk, vedligeholdelsesvenlig og generelt meget bedre for udviklerhastighed. Det er noget, jeg virkelig beundrer ved Zen-principperne.

Suspense-grænse-problemet

Så nu havde jeg SSG, og indholdet dukkede op i HTML'en. Men mine sider var blanke! Hvordan?! <img alt="SSG blank side" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blank_page.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />

Det viser sig, at renderToReadableStream stadig har Suspense-grænser, selvom du await stream.allReady. Mit gæt er, at det er fordi det er en "stream", og designet til at blive sendt til klienter efterhånden som bytes modtages.

Hvad React udsender

Når du bruger renderToReadableStream med Suspense, udsender React HTML som dette:

<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
  <!-- Faktisk indhold her -->
</div>
...
<script>/*Script der erstatter suspense-grænserne*/</script>

<template id="B:0"> er en pladsholder, hvor indhold skal placeres. <div hidden id="S:0"> indeholder det faktiske renderede indhold. B:0 matcher S:0 ved nummer (0-baseret indeks).

Uden JavaScript ville søgemaskiner (jeg kigger på dig, Bing) og LLM'er se en næsten blank side med kun template-pladsholderen. Det modarbejder hele formålet med SSG!

Jeg så ingen ren måde at fjerne disse Suspense-grænser på, så min løsning var at skrive nogle tests og en resolveSuspenseBoundaries-funktion til at bytte dem ud. Det var hurtigere end at parse HTML'en og udføre scriptet med noget som JSDOM. Og endnu vigtigere, det var et krav for det, jeg havde planlagt: en pæn, læsbar side for søgemaskiner / LLM'er uden JavaScript, men med support for Suspense-grænser og hydrering på klienten.

Test af transformationen

Jeg startede med at skrive tests for transformationen ved at tage nogle eksempler fra DOM'en fra det, jeg havde (JavaScript deaktiveret), og det jeg ville have (JavaScript aktiveret). Jeg fodrede disse ind i en LLM og lod den håndtere testgenereringen, noget den er ret god til. Disse tests ligger i client/src/generators/ssr/renderRoute.test.ts og sikrer, at transformationen fungerer korrekt. Testene dækker:

  • Simpel grænseudskiftning (blogliste)
  • Komplekse grænser med indhold mellem template og afsluttende kommentar
  • Flere grænser
  • Grænser uden kommentarmarkører
  • Edge cases

Denne type "TDD" er faktisk ret nyttig til denne brugssituation, hvor du har forventede inputs og outputs.

Dette skal ikke forveksles med "TDD alt fordi Robert C. Martin sagde det" (hvilket vil bremse dit teams udviklingshastighed). Du bør IKKE bruge TDD til UI eller områder af din kode, der konstant ændrer sig!

Løsningen: resolveSuspenseBoundaries

Nu hvor testene var på plads, fik jeg LLM'en til at skrive funktionen til resolveSuspenseBoundaries. Jeg valgte cheerio til dette for at undgå skrøbeligheden ved RegEx, selvom brug af RegEx her ville reducere SSG-tiden med omkring 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}; }

Dette sikrer, at i stedet for at se en næsten blank side, ser søgemaskiner og LLM'er en fuldt renderet side.

Nu har vi SSG, der fungerer godt uden JavaScript! <img alt="Ingen JavaScript SSG til Foonys blogs" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blog_ssg.webp" style={{ margin: "8px auto", height: 340, display: "block" }} />

På lang sigt er det muligt, at React vil ændre deres Suspense-format. Jeg vil måske fjerne Suspense-resolution-koden, når jeg har en bedre løsning til siderne, der lazy-loades (og dermed kræver Suspense-grænser).

Hydreringsstrategi (Opdatering: Dette tog 3 dage + 1 ekstra dag)

Hydrering er udfordrende. Det vidste jeg. Men efter lidt arbejde fik jeg det til at fungere!

Samlet tid brugt på hydrering: 3 dage, plus 1 ekstra dag til at erstatte dehydreringstilgangen.

Den vanskeligste del var bare at få den første minimale, fungerende hydrate. Da jeg fik renderet et "Hello World" med navbar, fik jeg tilliden til, at ja, det her vil måske ikke tage en hel måned!

<img alt="Foonys Hello World hydrerer succesfuldt med 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" }} />

Til den første minimale, fungerende hydrate havde jeg en unik udfordring: Jeg ville have hydrering, men jeg ville også have god SEO til søgemaskiner og LLM'er, uden at udviklere skulle tænke på Suspense-grænser.

Udfordringen

React-hydrering er ekstremt bogstavelig: Hvis DOM'en ikke ser ud, som React forventer for den første render, får du denne pæne, næsten ubrugelige fejlmeddelelse i din konsol, og React smider alt ud og re-renderer fra bunden. Ikke engang et diff til at fortælle dig, hvad der gik galt!

I vores tilfælde gjorde SSG dette værre på et par måder:

  1. Vi efterbehandlede HTML'en for at fjerne/opløse React 18 streaming Suspense-artefakter (hvilket er fantastisk for bots).
  2. Klienten havde ikke altid de samme data tilgængelige på tidspunkt (t = 0), som server-renderingen havde (SSG-data, blog-metadata osv).
  3. Vores i18n er "lazy" som standard, hvilket betyder, at oversættelser kan mangle for den første render, medmindre du registrerer hvilke oversættelser der blev brugt til SSG og injicerer dem før React renderer.

Hvad fungerede (Indledende tilgang: Dehydrering)

Først prøvede jeg noget smart og sødt: Jeg brugte et command-pattern til at registrere de kommandoer, der blev brugt til at opløse HTML'ens Suspense-grænser, og returnerede de omvendte transformationskommandoer, så jeg kunne genoprette HTML'en til det, React har brug for til hydrering. Mit håb var, at jeg kunne sende langt færre bytes i index.html med denne kommandometode. Men som med de fleste smarte løsninger, fejlede dette, fordi browsere modificerer HTML'en på subtile måder, såsom at fjerne eller tilføje et ; eller /, hvilket kastede erstatningsindekserne af. Teknisk set kunne du sandsynligvis tage højde for disse subtile browserændringer, men jeg ville ikke sende noget så skrøbeligt. I stedet for at forsøge at "vende" Suspense-grænse-transformationen tilbage til Reacts streaming-markup, gjorde jeg noget super enkelt:

Bundl den originale, uopløste HTML i en <script type="text">.

Denne "dehydrerings"-tilgang fungerede, men jeg brugte en ekstra dag på at erstatte den med en bedre løsning.

Den bedre tilgang: Critical Path Suspense Boundary Replacement

Efter den indledende implementering løb jeg stadig ind i nogle problemer med Suspense-grænser. Det var da jeg indså, at der var en renere, bedre, enklere løsning. Jeg erstattede dehydreringstilgangen med critical path Suspense boundary replacement, som:

  • Indlæser den kritiske sti før hydrering: Komponenter, der blev preloadet under SSR, identificeres og preloades på klienten, før hydrateRoot kaldes
  • Er enklere at vedligeholde: Ingen React-internals eller AST-parsing krævet (dehydreringstilgangen skulle parse og genoprette HTML)
  • Sender færre bytes: Vi bundler ikke længere det originale SSR-respons fra React i et script-tag
  • Forhindrer et potentielt flash: Ingen grund til at dehydrere/rehydrere HTML, hvilket eliminerer et potentielt visuelt flash

Implementeringen sporer hvilke lazy-komponenter der blev preloadet under SSR (via SSRLazyComponentTracker), inkluderer deres import-stier i hydreringsdataene og preloader dem synkront før hydrering. Critical path-komponenter renderes direkte uden Suspense-grænser, hvilket matcher SSR-outputtet præcist.

For alt andet får vi den første klient-render til at agere som SSR/SSG. Det betyder at bruge de samme inputs og gøre disse inputs tilgængelige synkront før hydrateRoot. Dette gøres ved at bundle via vores "ssg-data".

Konkret var justeringerne:

  1. Bundl SSR-inputs ind i et enkelt tekst-script

    • Under SSG injicerer vi en <script type="text/foony-ssg" id="foony-ssg-data">...</script> lige før Vite-modulets entrypoint.
    • Dette script indeholder:
      • html: den opløste HTML, som vi rent faktisk sendte i den statiske fil
      • ssgData: den serialiserede SSGData, der bruges af SSR-wrapperen. Jeg planlægger at opdatere dette til en Proxy eller noget, så kun tilgåede data inkluderes.
      • translationData: de oversættelses-key-value-blobs, vi rørte ved under SSR
  2. Injicér disse inputs lige før hydrering

    • I main.tsx gør vi synkront:
      • sætter #root.innerHTML til den serialiserede opløste HTML (så DOM'en er præcis det, hydrering ser)
      • pakker appen ind i SSGDataProvider, så komponenter har den samme SSGData ved første render
  3. Gør i18n øjeblikkelig ved at injicere oversættelsesværdier

    • Vi registrerer de faktiske oversættelsesobjekter, der tilgås under SSR, og sender dem i SSG-scriptet.
    • På klienten injicerer vi dem direkte i LocaleQueryer's cache via en dedikeret LocaleQueryer.inject()-metode, så oversættelser er tilgængelige med det samme.

Og dermed har første render de samme data, som SSR havde!

useIsSSRMode()-hooken er allerede implementeret i 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;
}

Denne hook returnerer true under SSR og ved den første klient-render (hydrering), og skifter derefter til false efter mount. Komponenter som UserBanner, Navbar og Dialog bruger allerede dette til at forhindre hydreringsmismatch.

  1. Patch React for bedre diffs

Jeg håbede, jeg bare kunne bruge hydration-overlay. Men den vedligeholdes ikke aktivt, understøttes kun op til React 18 og var ikke produktionsklar. Så jeg fik en LLM til at klone repoen for inspiration, og så lavede den et minimalt hydration overlay på få minutter. Jeg havde ikke brug for noget fancy, bare noget der ville dukke op under udvikling, så jeg kunne finde ud af, hvor tingene gik galt.

Dette nye overlay er super basalt, så diffs er ikke helt perfekte. React fjerner kommentarer, tilføjer ;'er efter style-attributter, modificerer whitespace og et par andre små ting, som vores overlay ikke tager højde for (endnu). Vores overlay inkluderer også HTML-kommentarer, som React ignorerer ved sin hydrering.

<img alt="Vores nye hydration overlay" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_overlay.webp" style={{ margin: "8px auto", height: 315, display: "block" }} />

Men det er godt nok til at finde ud af, hvad der skal rettes.

<img alt="diff af vores SSG vs klient first-page render for React-hydrering" 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 tal

For at give dig en fornemmelse af, hvad denne implementering involverede:

  • 2 dages arbejde (fra start til fungerende SSG). Det var lidt over 24 timer mens jeg var på ferie.
  • 4 dages arbejde for at få hydrering til at opføre sig pænt uden async-oversættelses-races eller useMediaQuery der rodede tingene til.
  • 1 ekstra dag til at erstatte dehydreringstilgangen med critical path Suspense boundary replacement (enklere, færre bytes, intet potentielt flash).
  • ~200 linjer kerne SSG-genereringskode (GenerateShellSsgFromSitemap.ts)
  • ~120 linjer Suspense boundary-resolution (resolveSuspenseBoundaries i renderRoute.tsx). Bemærk: Dette blev senere erstattet af critical path-tilgangen
  • ~50 linjer SSR-utilities (isSSRMode.ts)
  • ~100 linjer tests (renderRoute.test.ts)
  • ~150 linjer polyfills til SSR (setupSSREnvironment)
  • Minimale ændringer i eksisterende komponenter (mest tilføjelse af useIsSSRMode()-tjek)

Løsningen er letvægts og vedligeholdelsesvenlig. Den kræver ingen framework-migrering, og den fungerer med vores eksisterende React SPA.

Vigtigste pointer

Nogle gange er en skræddersyet løsning bedre

Ikke ethvert problem har brug for et framework. Til Foony var en lille, skræddersyet SSG-løsning det rigtige valg. Den er:

  • Letvægts: Ingen tunge afhængigheder eller framework-overhead
  • Vedligeholdelsesvenlig: Enkel kode, som vi forstår
  • Fleksibel: Nem at modificere og udvide efter behov
  • Kompatibel: Fungerer med vores eksisterende React SPA uden migrering

Reacts streaming SSR har særheder

Reacts renderToReadableStream er rar til at håndtere Suspense, men den har særheder. Selv med await stream.allReady får du stadig Suspense-grænser i outputtet. Det er ikke en bug, det er ved design for streaming. Men til SSG har vi brug for fuldt opløst HTML. Det føles som en fiasko fra React-teamet ikke at håndtere dette scenarie på en ren måde.

Min løsning var at efterbehandle HTML'en og opløse grænser. Det er ikke smukt, men det er hurtigt og fleksibelt nok til mit brug.

TDD kan være nyttigt for LLM'er

HTML-transformation er fejlbehæftet. En lille bug og du kunne bryde hele SSG-outputtet og ødelægge slutbrugeroplevelsen. Jeg fik en LLM til at skrive omfattende tests (med min input) for at sikre, at transformationen fungerer korrekt.

Konklusion

SSG fungerer nu for Foony. Sider er fuldt renderet for søgemaskiner og LLM'er, og løsningen er vedligeholdelsesvenlig og letvægts. Hydrering for SSG-ruterne tog længere tid end jeg forventede (3 dage), og jeg brugte en ekstra dag på at erstatte den indledende dehydreringstilgang med critical path Suspense boundary replacement. Den nye tilgang er enklere at vedligeholde, sender færre bytes og forhindrer potentielle visuelle flash fra dehydrering/rehydrering af HTML.

Jeg er stadig chokeret over, at det kun tog 2 dage at implementere en skræddersyet løsning til SSG. Men nogle gange er den rigtige løsning den enkleste.

Fremtidigt arbejde inkluderer at færdiggøre hydreringsmatchningen og potentielt patche React for bedre debugging. Men indtil videre har Foony fungerende SSG. Jeg vil holde et øje med Google Search Console og Bing Webmaster Tools i de kommende uger for at se, hvilken effekt det har på vores SEO.

8 Ball Pool online multiplayer billiards icon