background blurbackground mobile blur

1/1/1970

Hur jag implementerade SSG på 2 dagar

Tjena! För ett år sedan trodde jag att det här var omöjligt. Men jag har precis blivit klar med att implementera Static Site Generation (SSG) för Foony på 2 dagar, och jag är ganska taggad över det. Det här är inte heller första gången jag försöker lösa SSG för Foony. Jag har tittat på NextJS, Vike, Astro, Gatsby och några andra lösningar tidigare. Jag hade till och med en falsk start med NextJS, men stötte på svårigheter med komplexiteten i Foonys SPA och tusentals filer. Migrationen hade varit en mardröm och hade tagit månader. Det hade också lagt till extra komplexitet för alla andra som jobbar på sajten eftersom de skulle behöva lära sig NextJS och dess egenheter.

Jag ville ha något lätt och enkelt att implementera. Något som lät oss fortsätta skriva kod på samma sätt som vi har gjort utan att behöva tänka på SSG (med undantag för useMediaQuery, det finns ingen riktig väg runt den). Nedan bryter jag ner varför jag valde en skräddarsydd lösning, de specifika utmaningar jag stötte på (särskilt med Reacts Suspense-gränser), och hur jag löste dem.

Varför inte standardlösningar?

När jag först tittade på att lägga till SSG i Foony övervägde jag naturligtvis NextJS (industristandard), Vike och Astro.

NextJS: För mycket migration

NextJS är kraftfullt, men det hade krävt en massiv migration av Foonys befintliga React-SPA. Vi har tusentals filer, komplex routinglogik och en hel del egen infrastruktur. Att migrera till NextJS hade inneburit:

  • Skriva om hela vårt routingsystem
  • Strukturera om hur vi laddar spel och komponenter
  • Månader av arbete bara för att komma tillbaka till samma funktionalitet
  • Potentiella förändringar som kunde påverka användarna negativt
  • Förändra hur vi hanterar bilder
  • Betydligt långsammare byggtider (potentiellt 5-30 minuter. Jag har inga konkreta siffror att backa upp detta med utöver den här 5 år gamla diskussionen på GitHub)
  • Hela teamet skulle behöva lära sig något nytt (NextJS), och långsammare utvecklingstakt för alltid
  • Migrera koden varje gång NextJS bestämmer sig för att göra brytande ändringar.

Jag testade till och med en falsk start med NextJS, men insåg snabbt att migrationskostnaden var för hög. Komplexiteten var inte värd det.

Vike: Liknande komplexitet

Vike (tidigare vite-plugin-ssr) hade liknande problem. Även om det är mer flexibelt än NextJS, hade det fortfarande krävt betydande omstrukturering av vår kodbas. Inlärningskurvan och migrationsarbetet motiverade inte fördelarna.

Astro: Fel arkitektur

Astro är toppen för innehållstunga sajter, men Foony är en komplex multiplayer-spelplattform. Vi behöver realtidsuppdateringar, WebSocket-anslutningar och dynamiska React-komponenter. Astros arkitektur passar helt enkelt inte det vi bygger.

Lösningen: Skräddarsydd SSG

Inspirerad av min "fejk-SSG"-metod som jag implementerade för några dagar sedan efter i18n, bestämde jag mig för en liten, lättviktig, skräddarsydd lösning för Foonys SSG.

Min "fejk-SSG"-metod gick ut på att hämta blogginlägginnehållet från sidor med blogginlägg (/posts-rutter och spelsidor) och placera dem exakt där klienten skulle rendera dem, specifikt för sökmotorer och LLM:er för att hjälpa dem förstå Foony. Den applicerade också ld+json-schema och lite små SEO-grejer.

Tillvägagångssättet är enkelt:

  1. Bygg ovanpå befintligt React-SPA: Ingen migration behövs, lägg bara till SSG-generering vid byggtid.
  2. Använd renderToReadableStream: React 18:s strömmande SSR-API hanterar Suspense nativt.
  3. Generera statiska HTML-filer: Förrendera rutter vid byggtid och servera dem som statiska filer, med vår SitemapGenerator för att få en lista över rutter.
  4. Minimala ändringar i befintlig kodbas: De flesta komponenter fungerar som de är.

Kärnimplementationen finns i client/src/generators/GenerateShellSsgFromSitemap.ts. Den läser en sitemap, renderar varje rutt med Reacts renderToReadableStream och skriver HTML:en till statiska filer. Enkelt, precis som jag gillar det!

Det här blev rätt snabbt också. Cirka 2 800 rutter renderade på 10 sekunder. Najs. Det är betydligt snabbare än NextJS, Gatsby och Astro. <img alt="SSG-konsollogg som visar tidsåtgången" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />

Jag skulle kunna prata om enkelhet i evigheter. Även om det inte ger dig en befordran på stora företag på grund av "brist på komplexitet", är enkel kod vacker, underhållbar och generellt sett mycket bättre för utvecklingstakten. Det är något jag verkligen beundrar med Zen-principerna.

Suspense-gränsproblemet

Så nu hade jag SSG, och innehållet dök upp i HTML... men mina sidor var blanka! Hur?! <img alt="SSG blank sida" 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 visar sig att renderToReadableStream fortfarande har Suspense-gränser, även om man kör await stream.allReady. Min gissning är att det är för att det är en "ström", och designad för att skickas till klienter när byten tas emot.

Vad React skickar ut

När du använder renderToReadableStream med Suspense, skickar React ut HTML som ser ut så här:

<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
  <!-- Actual content here -->
</div>
...
<script>/*Script that replaces the suspense boundaries*/</script>

<template id="B:0"> är en platshållare där innehåll ska placeras. <div hidden id="S:0"> innehåller det faktiska renderade innehållet. B:0 matchar S:0 med nummer (0-indexerat).

Utan JavaScript skulle sökmotorer (jag tittar på dig, Bing) och LLM:er se en nästan blank sida med bara platshållarmallen. Det motverkar hela syftet med SSG!

Jag såg ingen ren väg att ta bort dessa Suspense-gränser, så min lösning var att skriva några tester och en resolveSuspenseBoundaries-funktion för att byta ut dessa. Det var snabbare än att parsa HTML:en och köra scriptet med något som JSDOM. Och, ännu viktigare, det var ett krav för det jag hade planerat: en fin, läsbar sajt för sökmotorer/LLM:er utan JavaScript, men med stöd för Suspense-gränser och hydrering på klienten.

Testa transformationen

Jag började med att skriva tester för transformationen genom att ta några exempel i DOM från det jag hade (JavaScript inaktiverat), och det jag ville ha (JavaScript aktiverat). Jag matade in dessa i en LLM och lät den hantera testgenereringen, något den är ganska bra på. Dessa tester finns i client/src/generators/ssr/renderRoute.test.ts och säkerställer att transformationen fungerar korrekt. Testerna täcker:

  • Enkel gränsersättning (blogglistning)
  • Komplexa gränser med innehåll mellan mall och avslutande kommentar
  • Flera gränser
  • Gränser utan kommentarsmarkörer
  • Edge cases

Den här typen av "TDD" är faktiskt rätt användbar för det här fallet där man har förväntade in- och utdata.

Detta ska inte förväxlas med "TDD allt eftersom Robert C. Martin sa så" (vilket kommer att bromsa ditt teams utvecklingstakt). Du borde INTE använda TDD för UI eller områden av din kod som ständigt ändras!

Lösningen: resolveSuspenseBoundaries

Nu när testerna var på plats lät jag LLM:en skriva funktionen för resolveSuspenseBoundaries. Jag valde cheerio för det här för att undvika sårbarheten hos RegEx, även om RegEx här hade kortat SSG-tiden med cirka 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}; }

Detta säkerställer att istället för att se en nästan blank sida, ser sökmotorer och LLM:er en helt renderad sida.

Nu har vi SSG som funkar bra utan JavaScript! <img alt="Ingen JavaScript-SSG för Foonys bloggar" 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å lång sikt är det möjligt att React kommer att ändra sitt Suspense-format. Jag kanske tar bort Suspense-resolutionskoden när jag har en bättre lösning för sidorna som lazy-laddas (och därför kräver Suspense-gränser).

Hydreringsstrategi (Uppdatering: Detta tog 3 dagar + 1 extra dag)

Hydrering är utmanande. Det visste jag. Men efter lite arbete lyckades jag få det att fungera!

Total tid för hydrering: 3 dagar, plus 1 extra dag för att ersätta dehydreringsmetoden.

Den knepigaste biten var bara att få den första minimala, fungerande hydreringen. När jag väl lyckades rendera ett "Hello World" med navbar fick jag förtroendet att, ja, det här kanske inte tar en hel månad!

<img alt="Foonys Hello World hydreras framgångsrikt 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" }} />

För den första minimala, fungerande hydreringen hade jag en unik utmaning: jag ville ha hydrering, men jag ville också ha bra SEO för sökmotorer och LLM:er utan att utvecklare behöver tänka på Suspense-gränser.

Utmaningen

React-hydrering är extremt bokstavlig: om DOM:en inte ser ut som vad React förväntar sig för den första renderingen, får du det här fina, nästan oanvändbara felmeddelandet i din konsol, och React kastar ut allt och renderar om från grunden. Inte ens en diff för att låta dig veta vad som gick fel!

I vårt fall gjorde SSG detta värre på ett par sätt:

  1. Vi efterbehandlade HTML:en för att ta bort/lösa upp React 18:s strömmande Suspense-artefakter (vilket är toppen för bottar).
  2. Klienten hade inte alltid exakt samma data tillgänglig vid tidpunkt (t = 0) som serverrenderingen hade (SSG-data, bloggmetadata, etc).
  3. Vår i18n är "lazy" som standard, vilket betyder att översättningar kan saknas för den första renderingen om man inte registrerar vilka översättningar som användes för SSG och injicerar dem innan React renderar.

Vad som fungerade (Initial metod: Dehydrering)

Först provade jag något smart och gulligt: jag använde ett kommandomönster för att registrera kommandona som användes för att lösa upp HTML:ens Suspense-gränser och returnerade de omvända transformationskommandona så att jag kunde återställa HTML:en till vad React behöver för hydrering. Mitt hopp var att jag kunde skicka mycket färre byte i index.html med den här kommandometoden. Men, som med de flesta smarta lösningar, misslyckades detta eftersom webbläsare modifierar HTML:en på subtila sätt, som att ta bort eller lägga till ett ; eller /, vilket kastade ut ersättningsindexen. Tekniskt sett skulle man förmodligen kunna ta hänsyn till dessa subtila webbläsarförändringar, men jag tänkte inte skicka något så bräckligt. Istället för att försöka "vända" Suspense-gränstransformationen tillbaka till Reacts strömmande markup, gjorde jag något supersimpelt:

Bunta in den ursprungliga, ej upplösta HTML:en i ett <script type="text">.

Den här "dehydrerings"-metoden fungerade, men jag spenderade en extra dag på att ersätta den med en bättre lösning.

Den bättre metoden: Kritisk sökväg-Suspense-gränsersättning

Efter den initiala implementationen stötte jag fortfarande på vissa problem med Suspense-gränser. Det var då jag insåg att det fanns en renare, bättre, enklare lösning. Jag ersatte dehydreringsmetoden med kritisk sökväg-Suspense-gränsersättning, som:

  • Laddar den kritiska sökvägen före hydrering: Komponenter som förladdades under SSR identifieras och förladdas på klienten innan hydrateRoot anropas
  • Är enklare att underhålla: Inga React-internals eller AST-parsning krävs (dehydreringsmetoden behövde parsa och återställa HTML)
  • Skickar färre byte: Vi buntar inte längre den ursprungliga SSR-responsen från React i en script-tagg
  • Förhindrar en potentiell flash: Inget behov av att dehydrera/rehydrera HTML, vilket eliminerar en potentiell visuell flash

Implementationen spårar vilka lazy-komponenter som förladdades under SSR (via SSRLazyComponentTracker), inkluderar deras importsökvägar i hydreringsdatan och förladdar dem synkront innan hydrering. Komponenter på den kritiska sökvägen renderar direkt utan Suspense-gränser, vilket matchar SSR-utdatan exakt.

För allt annat gör vi så att den första klientrenderingen agerar som SSR/SSG. Det betyder att använda samma indata och göra dessa indata tillgängliga synkront före hydrateRoot. Detta görs genom att bunta via vår "ssg-data".

Konkret var justeringarna:

  1. Bunta SSR-indata till ett enda textscript

    • Under SSG injicerar vi en <script type="text/foony-ssg" id="foony-ssg-data">...</script> precis före Vite-modulens entrypoint.
    • Det scriptet innehåller:
      • html: den upplösta HTML:en vi faktiskt skickade i den statiska filen
      • ssgData: den serialiserade SSGData som används av SSR-wrappern. Jag planerar att uppdatera detta till en Proxy eller liknande så att endast åtkomst data inkluderas.
      • translationData: översättnings-key-value-blobbarna vi rörde under SSR
  2. Injicera dessa indata precis före hydrering

    • I main.tsx, gör vi synkront:
      • sätter #root.innerHTML till den serialiserade upplösta HTML:en (så att DOM:en är exakt vad hydreringen ser)
      • omger appen i SSGDataProvider så att komponenter har samma SSGData vid första renderingen
  3. Gör i18n omedelbart genom att injicera översättningsvärden

    • Vi registrerar de faktiska översättningsobjekten som användes under SSR och skickar dem i SSG-scriptet.
    • På klienten injicerar vi dem direkt i LocaleQueryers cache via en dedikerad LocaleQueryer.inject()-metod, så att översättningar är tillgängliga omedelbart.

Och med det har den första renderingen samma data som SSR hade!

Hooken useIsSSRMode() är redan implementerad 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;
}

Den här hooken returnerar true under SSR och vid den första klientrenderingen (hydrering), och växlar sedan till false efter mount. Komponenter som UserBanner, Navbar och Dialog använder redan detta för att förhindra hydreringskrockar.

  1. Patcha React för bättre diffar

Jag hoppades att jag bara skulle kunna använda hydration-overlay. Men det underhålls inte aktivt, stöds bara upp till React 18, och var inte produktionsklart. Så jag lät en LLM klona repot för inspiration, och sedan skapade den en minimal hydreringsoverlay på några minuter. Jag behövde inget fancy, bara något som dök upp under utveckling så att jag kunde lista ut var saker gick fel.

Den här nya overlayen är jättebasic, så diffarna är inte helt perfekta. React strippar kommentarer, lägger till ; efter style-attribut, modifierar whitespace, och några andra små saker som vår overlay inte tar hänsyn till (än). Vår overlay inkluderar också HTML-kommentarer som React ignorerar för sin hydrering.

<img alt="Vår nya hydreringsoverlay" 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 är tillräckligt bra för att lista ut vad som behöver fixas.

<img alt="diff av vår SSG vs klient första-sidans rendering för 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 siffror

För att ge dig en känsla av vad den här implementationen innebar:

  • 2 dagars arbete (från start till fungerande SSG). Det var lite drygt 24 timmar under semestern.
  • 4 dagars arbete för att få hydreringen att bete sig snyggt utan asynkrona översättningskapplöpningar eller useMediaQuery som ställer till det.
  • 1 extra dag för att ersätta dehydreringsmetoden med kritisk sökväg-Suspense-gränsersättning (enklare, färre byte, ingen potentiell flash).
  • ~200 rader kärn-SSG-genereringskod (GenerateShellSsgFromSitemap.ts)
  • ~120 rader av Suspense-gränsupplösning (resolveSuspenseBoundaries i renderRoute.tsx) - Obs: Detta ersattes senare av den kritiska sökväg-metoden
  • ~50 rader av SSR-utilities (isSSRMode.ts)
  • ~100 rader tester (renderRoute.test.ts)
  • ~150 rader polyfills för SSR (setupSSREnvironment)
  • Minimala ändringar av befintliga komponenter (mestadels att lägga till useIsSSRMode()-kontroller)

Lösningen är lättviktig och underhållbar. Den kräver ingen frameworkmigration, och den fungerar med vårt befintliga React-SPA.

Viktiga lärdomar

Ibland är en skräddarsydd lösning bättre

Inte varje problem behöver ett ramverk. För Foony var en liten, skräddarsydd SSG-lösning rätt val. Den är:

  • Lättviktig: Inga tunga beroenden eller frameworkoverhead
  • Underhållbar: Enkel kod som vi förstår
  • Flexibel: Lätt att modifiera och utöka efter behov
  • Kompatibel: Fungerar med vårt befintliga React-SPA utan migration

Reacts strömmande SSR har egenheter

Reacts renderToReadableStream är trevlig för att hantera Suspense, men den har egenheter. Även med await stream.allReady får du fortfarande Suspense-gränser i utdatan. Det är inte en bugg, det är så det är designat för strömning. Men för SSG behöver vi helt upplöst HTML. Det känns som ett misslyckande från React-teamet att inte hantera det här scenariot på ett rent sätt.

Min lösning var att efterbehandla HTML:en och lösa upp gränser. Det är inte vackert, men det är snabbt och flexibelt nog för mitt användningsfall.

TDD kan vara användbart för LLM:er

HTML-transformation är felbenägen. En liten bugg och du kan förstöra hela SSG-utdatan och sänka slutanvändarupplevelsen. Jag lät en LLM skriva omfattande tester (med min input) för att säkerställa att transformationen fungerar korrekt.

Slutsats

SSG fungerar nu för Foony. Sidor är helt renderade för sökmotorer och LLM:er, och lösningen är underhållbar och lättviktig. Hydrering för SSG-rutterna tog längre tid än jag förväntade mig (3 dagar), och jag spenderade en extra dag på att ersätta den initiala dehydreringsmetoden med kritisk sökväg-Suspense-gränsersättning. Den nya metoden är enklare att underhålla, skickar färre byte och förhindrar potentiella visuella flashar från att dehydrera/rehydrera HTML.

Jag är fortfarande chockad över att det bara tog 2 dagar att implementera en skräddarsydd lösning för SSG. Men ibland är den rätta lösningen den enklaste.

Framtida arbete inkluderar att slutföra hydreringsmatchningen och eventuellt patcha React för bättre felsökning. Men för tillfället har Foony fungerande SSG. Jag kommer att hålla ett öga på Google Search Console och Bing Webmaster Tools under de kommande veckorna för att se vilken effekt detta har på vår SEO.

8 Ball Pool online multiplayer billiards icon