background blurbackground mobile blur

1/1/1970

Jak jsem za 2 dny implementoval SSG

Ahoj! Před rokem jsem si myslel, že je to nemožné. Ale právě jsem během 2 dnů dodělal Static Site Generation (SSG) pro Foony a mám z toho fakt radost. Tohle nebyl můj první pokus, jak SSG pro Foony vyřešit. V minulosti jsem se díval na NextJS, Vike, Astro, Gatsby a pár dalších řešení. Dokonce jsem měl s NextJS jeden falešný start, ale narazil jsem na problémy se složitostí Foony jako SPA a s tisíci souborů. Migrace by byla noční můra a trvala by měsíce. Navíc by přidala další vrstvu složitosti pro všechny, kdo na webu pracují, protože by se museli naučit NextJS a všechny jeho zvláštnosti.

Chtěl jsem něco lehkého a snadno implementovatelného. Něco, co nám umožní dál psát kód stejně jako doteď, aniž bychom museli přemýšlet nad SSG (až na useMediaQuery, tam se tomu prostě nevyhneme). Níže popíšu, proč jsem skončil u vlastního řešení, na jaké konkrétní problémy jsem narazil (hlavně u Suspense boundary v Reactu) a jak jsem je vyřešil.

Proč ne standardní řešení?

Když jsem se poprvé díval na to, jak přidat SSG do Foony, přirozeně jsem zvažoval NextJS (průmyslový standard), Vike a Astro.

NextJS: příliš velká migrace

NextJS je silný nástroj, ale vyžadoval by obrovskou migraci existující React SPA aplikace Foony. Máme tisíce souborů, složitou logiku routování a spoustu vlastní infrastruktury. Migrace na NextJS by znamenala:

  • Přepsat celý náš routing
  • Předělat způsob, jak načítáme hry a komponenty
  • Měsíce práce jen proto, abychom se dostali zpátky na stejnou funkcionalitu
  • Riziko rozbití věcí pro uživatele
  • Změnit způsob, jak zacházíme s obrázky
  • Výrazně pomalejší buildy (klidně 5 až 30 minut. Nemám k tomu pevná čísla, jen tuhle 5 let starou diskusi na GitHubu)
  • Celý tým by se musel učit něco nového (NextJS) a vývoj by byl navždy o něco pomalejší
  • Znovu migrovat kód pokaždé, když se NextJS rozhodne udělat breaking změny.

Dokonce jsem to s NextJS jednou rozjel, ale velmi rychle mi došlo, že cena migrace je prostě moc vysoká. Ta složitost za to nestála.

Vike: podobná složitost

Vike (dříve vite-plugin-ssr) měl podobné problémy. I když je pružnější než NextJS, pořád by vyžadoval velké přeuspořádání našeho kódu. Učení a migrace by prostě neodpovídaly tomu, co bychom tím získali.

Astro: špatná architektura pro náš případ

Astro je super pro obsahové weby, ale Foony je složitá multiplayerová herní platforma. Potřebujeme realtime aktualizace, WebSockety a dynamické React komponenty. Architektura Astra se prostě k tomu, co stavíme, nehodí.

Řešení: vlastní SSG na míru

Posílený svým "fake SSG" přístupem, který jsem dal dohromady pár dní nazpět po i18n, jsem se rozhodl pro malé, lehké, vlastní řešení SSG pro Foony.

Můj "fake SSG" přístup spočíval v tom, že jsem z stránek s blogovými příspěvky (routy /posts a herní stránky) vytáhl obsah článků a vložil ho přesně tam, kde by ho vykreslil klient. A to hlavně proto, aby ho vyhledávače a LLM lépe pochopily a dokázaly si Foony zařadit. Zároveň se přidával ld+json schema a pár drobných SEO vychytávek.

Přístup je jednoduchý:

  1. Postavit to na existující React SPA: Není potřeba žádná migrace, jen při buildu přidat generování SSG.
  2. Použít renderToReadableStream: streamovací SSR API v Reactu 18 umí Suspense řešit nativně.
  3. Generovat statické HTML soubory: Routy předrenderovat při buildu a servírovat je jako statické soubory, přičemž seznam rout získáme přes náš SitemapGenerator.
  4. Minimální změny v existujícím kódu: Většina komponent funguje tak, jak jsou.

Jádro implementace je v client/src/generators/GenerateShellSsgFromSitemap.ts. Ten vezme sitemapu, každou routu vyrenderuje pomocí Reactího renderToReadableStream a zapíše HTML do statických souborů. Jednoduché, přesně jak to mám rád!

Navíc je to docela rychlé. Asi 2 800 rout se vyrenderovalo za 10 sekund. Pěkné. To je výrazně rychlejší než NextJS, Gatsby i Astro. <img alt="Výpis z konzole SSG ukazující čas vykreslování" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />

O jednoduchosti bych dokázal mluvit hodiny. I když vám v korporátu asi nevybojuje povýšení kvůli "nedostatku komplexity", jednoduchý kód je krásný, udržitelný a obecně mnohem lepší pro rychlost vývoje. To je něco, co na Zen principech opravdu obdivuju.

Problém se Suspense boundary

Takže, SSG už jsem měl, obsah se v HTML objevil... ale moje stránky byly prázdné! Jak je to možné?! <img alt="Prázdná stránka po 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" }} />

Ukázalo se, že renderToReadableStreampořád Suspense boundary, i když uděláte await stream.allReady. Tipuju, že je to proto, že je to pořád "stream" a je navržený tak, aby se posílal klientovi postupně po bajtech.

Co z Reactu leze

Když použijete renderToReadableStream se Suspense, React vyplivne HTML nějak takhle:

<!--$?-->
<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"> je placeholder, kam má přijít obsah. <div hidden id="S:0"> obsahuje skutečný vyrenderovaný obsah. B:0 odpovídá S:0 podle čísla (indexování od nuly).

Bez JavaScriptu by vyhledávače (dívám se na tebe, Bingu) a LLM viděly skoro prázdnou stránku jen s tímhle placeholderem z template. Což úplně ničí celou pointu SSG!

Neviděl jsem žádný čistý způsob, jak tyhle Suspense boundary odstranit, takže jsem to vyřešil tak, že jsem napsal pár testů a funkci resolveSuspenseBoundaries, která je umí přehodit. Bylo to rychlejší než HTML parsovat a pouštět v něm skripty přes něco jako JSDOM. A hlavně to bylo potřeba kvůli tomu, co jsem měl v plánu: pěkný čitelný web pro vyhledávače a LLM bez JavaScriptu, ale se Suspense boundary a hydratací na klientu.

Testování téhle transformace

Začal jsem tím, že jsem napsal testy pro tuhle transformaci. Vzal jsem si příklady z DOMu toho, co jsem měl (vypnutý JavaScript), a toho, co jsem chtěl (zapnutý JavaScript). Tyhle páry jsem nakrmil LLM a nechal ho vygenerovat testy, v tom je docela dobré. Tyhle testy jsou v client/src/generators/ssr/renderRoute.test.ts a ověřují, že transformace funguje správně. Pokrývají:

  • Jednoduché nahrazení boundary (výpis blogu)
  • Složitější boundary s obsahem mezi template a uzavíracím komentářem
  • Více boundary najednou
  • Boundary bez komentářových značek
  • Různé edge casy

Tahle forma "TDD" je v tomhle případě docela užitečná, protože máte jasně dané vstupy a očekávané výstupy.

To si nepleťte s přístupem "TDD na všechno, protože to řekl Robert C. Martin" (to vaší rychlosti vývoje spíš ublíží). TDD byste rozhodně neměli používat na UI nebo části kódu, které se pořád mění!

Řešení: resolveSuspenseBoundaries

Když už byly testy hotové, nechal jsem LLM napsat samotnou funkci resolveSuspenseBoundaries. Sáhl jsem po cheerio, abych se vyhnul křehkosti regulárních výrazů, i když by použití RegExu SSG zrychlilo asi o 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}; }

Díky tomu místo skoro prázdné stránky uvidí vyhledávače a LLM plně vyrenderovanou stránku.

Teď máme SSG, které funguje skvěle i bez JavaScriptu! <img alt="SSG pro blogy Foony bez JavaScriptu" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blog_ssg.webp" style={{ margin: "8px auto", height: 340, display: "block" }} />

Dlouhodobě je klidně možné, že React formát Suspense nějak změní. Až budu mít lepší řešení pro stránky, které se načítají lazy (a tím pádem potřebují Suspense boundary), možná tenhle kód na řešení Suspense úplně vyhodím.

Strategie hydratace (update: trvalo to 3 dny + 1 extra den)

Hydratace je těžká, to jsem věděl. Ale po chvilce práce se mi ji podařilo rozběhnout!

Celkový čas na hydrataci: 3 dny plus 1 extra den na nahrazení původního "dehydration" přístupu.

Nejtěžší bylo dostat se k tomu úplně prvnímu, minimálnímu, ale funkčnímu "hydrate". Jakmile se mi podařilo vyrenderovat "Hello World" s navigační lištou, získal jsem jistotu, že to možná fakt nezabere celý měsíc!

<img alt='Foony "Hello World" úspěšně hydratovaný s navigací' loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_mvp.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />

U toho prvního minimálního, ale funkčního hydratu jsem měl jeden speciální problém: chtěl jsem hydrataci, ale zároveň dobré SEO pro vyhledávače a LLM, aniž by vývojáři museli vůbec přemýšlet nad Suspense boundary.

V čem byl problém

Hydratace v Reactu je hrozně doslovná: pokud DOM nevypadá při prvním renderu přesně tak, jak React čeká, dostanete v konzoli hezkou, ale skoro k ničemu chybovou hlášku a React všechno zahodí a vyrenderuje stránku znova od nuly. Ani vám neukáže diff, abyste viděli, co se pokazilo!

V našem případě to SSG ještě zhoršovalo několika způsoby:

  1. HTML jsme po renderu ještě upravovali, abychom odstranili nebo vyřešili artefakty streamovacího Suspense v Reactu 18 (což je super pro boty).
  2. Klient neměl v čase (t = 0) vždycky úplně stejná data jako server při renderu (SSG data, metadata blogu atd.).
  3. Naše i18n je ve výchozím stavu "lazy", což znamená, že při prvním renderu můžou chybět překlady, pokud si nezaznamenáte, které se použily při SSG, a nepředhodíte je Reactu ještě před prvním renderem.

Co fungovalo (první přístup: "dehydration")

Nejdřív jsem zkusil něco takového "chytrého a roztomilého": použil jsem command pattern, který si ukládal příkazy použité k vyřešení Suspense boundary v HTML, a vracel opačné transformační příkazy, abych mohl HTML vrátit do podoby, kterou React potřebuje pro hydrataci. Doufal jsem, že tímhle způsobem pošlu v index.html mnohem míň bajtů. Jenže jako u většiny "chytrých" řešení to selhalo, protože prohlížeče HTML nenápadně upravují, třeba někde smažou nebo přidají ; nebo /, a tím úplně rozhodí pozice, na kterých se mají věci nahrazovat. Technicky by se tyhle drobné změny od prohlížečů asi daly nějak ošetřit, ale tak křehký kód jsem rozhodně nechtěl nasazovat. Místo toho, abych se snažil "vracet" Suspense transformaci zpátky do Reactího streamovacího HTML, udělal jsem něco úplně jednoduchého:

Zabalit původní, nevyřešené HTML do <script type="text">.

Tento "dehydration" přístup fungoval, ale strávil jsem ještě jeden den tím, že jsem ho nahradil lepším řešením.

Lepší přístup: nahrazování Suspense boundary na kritické cestě

Po první implementaci jsem pořád narážel na problémy se Suspense boundary. V tu chvíli mi došlo, že existuje čistší, lepší a jednodušší řešení. Místo "dehydration" jsem zavedl nahrazování Suspense boundary na kritické cestě, které:

  • Načte kritickou cestu ještě před hydratací: komponenty, které se přednahrály během SSR, se označí a přednahrávají i na klientovi ještě před tím, než se zavolá hydrateRoot
  • Je jednodušší na údržbu: není potřeba sahat do React interních věcí ani parsovat AST (u "dehydration" přístupu se muselo HTML parsovat a obnovovat)
  • Posílá míň bajtů: už neposíláme původní SSR výstup z Reactu v <script> tagu
  • Zabraňuje možnému "flashi": není potřeba HTML dehydratovat a znovu hydratovat, takže nehrozí vizuální probliknutí

Implementace sleduje, které lazy komponenty se přednahrály během SSR (přes SSRLazyComponentTracker), jejich import cesty přidá do dat pro hydrataci a před hydratací je synchronně načte. Komponenty na kritické cestě se pak renderují přímo bez Suspense boundary a přesně odpovídají výstupu ze SSR.

U všeho ostatního se snažíme, aby se první klientský render choval jako SSR/SSG. To znamená použít stejná vstupní data a zpřístupnit je synchronně ještě před voláním hydrateRoot. Děláme to tak, že je zabalíme do našich "ssg-data".

Konkrétně to znamenalo tyhle úpravy:

  1. Zabalit vstupy pro SSR do jednoho textového skriptu

    • Při SSG vložíme těsně před vstupní Vite modul <script type="text/foony-ssg" id="foony-ssg-data">...</script>.
    • Tenhle skript obsahuje:
      • html: vyřešené HTML, které skutečně posíláme ve statickém souboru
      • ssgData: serializovaná SSGData, kterou používá SSR wrapper. Plánuju to časem předělat třeba na Proxy, aby se tam posílala jen data, která se opravdu používají.
      • translationData: balíčky překladů (key-value), kterých jsme se při SSR dotkli
  2. Vstupy vstříknout těsně před hydratací

    • V main.tsx synchronně:
      • nastavíme #root.innerHTML na serializované vyřešené HTML (aby DOM vypadal přesně tak, jak ho hydratace uvidí)
      • obalíme aplikaci do SSGDataProvider, aby komponenty měly při prvním renderu stejná SSGData
  3. Uděláme i18n okamžité tím, že předem vstříkneme překlady

    • Zaznamenáme si překladové objekty, ke kterým se během SSR přistupovalo, a pošleme je ve SSG skriptu.
    • Na klientovi je rovnou injektujeme do cache LocaleQueryeru přes speciální metodu LocaleQueryer.inject(), takže jsou překlady k dispozici hned.

A tím pádem má první render stejná data, jaká měl SSR!

Hook useIsSSRMode() už je implementovaný v 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;
}

Tenhle hook vrací true během SSR a při prvním klientském renderu (hydrataci) a po namountování se přepne na false. Komponenty jako UserBanner, Navbar nebo Dialog ho už používají, aby se vyhnuly problémům při hydrataci.

  1. Záplata Reactu kvůli lepším diffům

Doufal jsem, že prostě použiju hydration-overlay. Jenže ten se už moc neudržuje, podporuje jen React 18 a nebyl v produkčním stavu. Takže jsem nechal LLM repozitář naklonovat jako inspiraci a pak mi vygenerovalo jednoduchý hydration overlay během pár minut. Nic sofistikovaného jsem nepotřeboval, jen něco, co se objeví při vývoji a ukáže mi, kde se věci rozcházejí.

Tento nový overlay je hodně jednoduchý, takže diffy nejsou úplně dokonalé. React odstraňuje komentáře, přidává ; za style atributy, upravuje whitespace a dělá pár dalších drobností, se kterými náš overlay zatím nepočítá. Navíc náš overlay zahrnuje i HTML komentáře, které React při hydrataci ignoruje.

<img alt="Náš nový 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" }} />

Ale na to, abych zjistil, co je potřeba opravit, to bohatě stačí.

<img alt="diff našeho SSG vs prvního klientského renderu pro hydrataci v Reactu" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />

V číslech

Aby bylo jasné, co všechno tahle implementace obnášela:

  • 2 dny práce (od nuly po funkční SSG). Dohromady něco málo přes 24 hodin během dovolené.
  • 4 dny práce na tom, aby se hydratace chovala pěkně, bez závodů v asynchronních překladech a bez toho, aby nám to rozbíjel useMediaQuery.
  • 1 extra den na nahrazení "dehydration" přístupu tím s kritickou cestou Suspense boundary (jednodušší, míň bajtů, žádný možný flash).
  • ~200 řádků jádra kódu pro generování SSG (GenerateShellSsgFromSitemap.ts)
  • ~120 řádků kódu na řešení Suspense boundary (resolveSuspenseBoundaries v renderRoute.tsx) - Poznámka: Později to nahradil přístup s kritickou cestou
  • ~50 řádků pomocných funkcí pro SSR (isSSRMode.ts)
  • ~100 řádků testů (renderRoute.test.ts)
  • ~150 řádků polyfillů pro SSR (setupSSREnvironment)
  • Minimální změny v existujících komponentách (většinou jen přidání kontrol přes useIsSSRMode())

Řešení je lehké a dobře udržovatelné. Nevyžaduje žádnou migraci na jiný framework a funguje s naší existující React SPA.

Hlavní poznatky

Někdy je řešení na míru lepší

Ne každý problém potřebuje celý framework. Pro Foony bylo malé, vlastní SSG řešení ta správná volba. Je:

  • lehké: žádné těžké závislosti ani overhead frameworku
  • udržovatelné: jednoduchý kód, kterému rozumíme
  • flexibilní: snadno se upravuje a rozšiřuje podle potřeby
  • kompatibilní: funguje s naší stávající React SPA bez nutnosti migrace

Streaming SSR v Reactu má svoje zvláštnosti

Reactí renderToReadableStream je fajn na práci se Suspense, ale má svoje mouchy. I s await stream.allReady pořád dostanete v outputu Suspense boundary. Není to bug, ale záměr kvůli streamování. Jenže pro SSG potřebujeme plně vyřešené HTML. Přijde mi jako promarněná příležitost, že tenhle scénář tým Reactu nevyřešil nějak elegantně.

Moje řešení bylo HTML po renderu upravit a Suspense boundary vyřešit ručně. Není to úplně krásné, ale je to rychlé a pro můj use case dostatečně flexibilní.

TDD může být užitečné ve spojení s LLM

Transformace HTML je náchylná na chyby. Jediný drobný bug může rozbít celý SSG výstup a tím i zážitek pro koncové uživatele. Nechal jsem proto LLM, ať mi (s mými vstupy) vygeneruje sadu testů, které ověřují, že transformace funguje správně.

Závěr

SSG teď ve Foony funguje. Stránky jsou plně vyrenderované pro vyhledávače a LLM a celé řešení je udržovatelné a lehké. Hydratace SSG rout mi zabrala víc času, než jsem čekal (3 dny) a další den jsem strávil nahrazováním původního "dehydration" přístupu variantou s kritickou cestou Suspense boundary. Nový přístup je jednodušší na údržbu, posílá méně bajtů a brání případným vizuálním flashům při dehydrataci/hydrataci HTML.

Pořád mě překvapuje, že mi stačily jen 2 dny na to, abych dal dohromady vlastní řešení SSG. Ale někdy je to správné řešení prostě to nejjednodušší.

Do budoucna mě čeká ještě doladění shody při hydrataci a možná nějaké úpravy Reactu kvůli lepšímu ladění. Ale prozatím má Foony funkční SSG. V následujících týdnech budu sledovat Google Search Console a Bing Webmaster Tools, abych viděl, jaký to bude mít dopad na naše SEO.

8 Ball Pool online multiplayer billiards icon