

1/1/1970
Jak jsem za 2 dny implementoval SSG
Ahoj! Před rokem bych si myslel, že je to nemožné. Ale právě jsem během 2 dnů dokončil implementaci Static Site Generation (SSG) pro Foony a mám z toho fakt radost. Není to ani moje první pokus o SSG pro Foony. Už dřív jsem koukal na NextJS, Vike, Astro, Gatsby a pár dalších řešení. Dokonce jsem měl i falešný start s NextJS, ale narazil jsem na problémy s komplexitou SPA Foony a tisíci souborů. Migrace by byla noční můra a trvala by měsíce. A navíc by přidala další složitost pro všechny ostatní na projektu, protože by se museli naučit NextJS a jeho zvláštnosti.
Chtěl jsem něco lehkého a jednoduchého na implementaci. Něco, co nám umožní dál psát kód stejným způsobem jako doteď, aniž bychom museli na SSG skoro myslet (až na useMediaQuery – tomu se vážně vyhnout nedá). Níž rozepíšu, proč jsem skončil u vlastního řešení, na jaké konkrétní problémy jsem narazil (hlavně kolem Suspense boundary v Reactu) a jak jsem je vyřešil.
Proč ne standardní řešení?
Když jsem se poprvé díval na přidání 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 znamenal by obří migraci existující React SPA Foony. Máme tisíce souborů, komplexní routování a spoustu custom infrastruktury. Migrace na NextJS by znamenala:
- Přepsat celý náš routing systém
- Předělat způsob, jak načítáme hry a komponenty
- Měsíce práce jen proto, abychom se dostali na stejnou funkcionalitu jako teď
- Potenciální breaking změny pro uživatele
- Změnit způsob, jak pracujeme s obrázky
- Výrazně pomalejší buildy (klidně 5–30 minut; nemám na to moc konkrétních čísel, jen tuhle 5 let starou diskuzi na GitHubu)
- Celý tým by se musel učit něco nového (NextJS) a vývoj by byl navždy pomalejší
- A pokaždé, když by NextJS udělal breaking změny, bychom zase museli migrovat kód
Zkusil jsem si i jeden falešný start s NextJS, ale rychle jsem si uvědomil, že cena té migrace je prostě moc vysoká. Ta složitost za to nestála.
Vike: Podobná složitost
Vike (dřív vite-plugin-ssr) měl podobné problémy. Je sice flexibilnější než NextJS, ale pořád by vyžadoval výrazné překopání našeho kódu. Křivka učení a migrační úsilí prostě neodpovídaly tomu, co bychom tím získali.
Astro: Špatná architektura pro náš use case
Astro je skvělé pro obsahové weby, ale Foony je komplexní multiplayerová herní platforma. Potřebujeme real-time updaty, WebSockety a dynamické React komponenty. Architektura Astra se prostě nehodí k tomu, co stavíme.
Řešení: Vlastní SSG na míru
Posílený svým „fake SSG“ přístupem, který jsem udělal pár dní 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 zabalil obsah blogpostu ze stránek s blogy (routy
/postsa stránky her) a umístil ho přesně tam, kde by ho vykreslil klient, čistě kvůli vyhledávačům a LLM, aby lépe pochopily Foony. Taky jsem tam přidal ld+json schéma a pár drobností pro SEO.
Přístup je jednoduchý:
- Postavit to nad existující React SPA: Žádná migrace, jen přidání generování SSG při buildu.
- Použít
renderToReadableStream: Streaming SSR API v Reactu 18 nativně pracuje se Suspense. - Generovat statické HTML soubory: Před-renderovat routy při buildu a servírovat je jako statické soubory, pomocí našeho SitemapGeneratoru pro získání seznamu rout.
- Minimální změny v existující codebase: Většina komponent funguje beze změn.
Jádro implementace je v client/src/generators/GenerateShellSsgFromSitemap.ts. Ten načte sitemapu, každou routu vyrenderuje pomocí renderToReadableStream a HTML zapíše do statických souborů. Jednoduché, přesně jak to mám rád!
Nakonec je to i dost rychlé. Asi 2 800 rout se vyrenderovalo za 10 sekund. Pěkné. To je výrazně rychlejší než NextJS, Gatsby a Astro. <img alt="Výpis z konzole pro SSG ukazující dobu vykreslení" 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 mohl mluvit hodiny. I když vám to v korporátu asi nevyrazí dveře k povýšení kvůli „nedostatku komplexity“, jednoduchý kód je krásný, udržitelný a obecně mnohem lepší pro rychlost vývoje. Tohle je něco, co fakt obdivuju na Zen principech.
Problém se Suspense boundary
Takže, SSG 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 renderToReadableStream pořád vytváří Suspense boundary, i když použijete await stream.allReady. Tipuju, že je to proto, že je to pořád „stream“ a je navržený tak, aby se posílal klientovi už během přijímání bytů.
Co React vypisuje
Když použijete renderToReadableStream se Suspense, React vypíše HTML podobně jako:
<!--$?-->
<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á obsah přijít. <div hidden id="S:0"> obsahuje skutečně vykreslený obsah. B:0 odpovídá S:0 podle čísla (index od nuly).
Bez JavaScriptu uvidí vyhledávače (koukám na tebe, Bingu) a LLM téměř prázdnou stránku jen s template placeholderem. Což úplně zabíjí smysl SSG!
Neviděl jsem žádný čistý způsob, jak tyhle Suspense boundary odstranit, tak jsem si napsal pár testů a funkci resolveSuspenseBoundaries, která je přehází. Bylo to rychlejší než parsovat HTML a pouštět skripty přes něco jako JSDOM. A hlavně to byla nutnost pro to, co jsem měl v plánu: pěkný, čitelný web pro vyhledávače a LLM bez JavaScriptu, ale se supportem pro Suspense boundary a hydrataci na klientovi.
Testování transformace
Začal jsem psaním testů pro tuhle transformaci tak, že jsem si z DOMu vzal příklady toho, co mám (s vypnutým JavaScriptem), a toho, co chci mít (s povoleným JavaScriptem). Tyhle příklady jsem nakrmil LLM a nechal ho vygenerovat testy, což mu docela jde.
Tyhle testy jsou v client/src/generators/ssr/renderRoute.test.ts a hlídají, že transformace funguje správně. Pokrývají:
- Jednoduchou náhradu jedné boundary (seznam blogů)
- Komplexní boundary s obsahem mezi template a zavíracím komentářem
- Více boundary najednou
- Boundary bez komentářových značek
- Různé okrajové případy
Tenhle typ „TDD“ je vážně užitečný pro situace, kdy máte jasně dané vstupy a výstupy.
To se neplete s přístupem „TDD na všechno, protože to řekl Robert C. Martin“ (který vašemu týmu spíš uškodí a zpomalí ho). TDD byste rozhodně NEMĚLI používat pro UI nebo části kódu, které se pořád mění!
Řešení: resolveSuspenseBoundaries
Když už byly testy hotové, nechal jsem LLM napsat funkci resolveSuspenseBoundaries. Sáhl jsem po cheerio, abych se vyhnul křehkosti regulárních výrazů, i když by použití RegEx tady zkrátilo čas SSG 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ď nám SSG funguje pěkně i bez JavaScriptu!
<img alt="SSG bez JavaScriptu pro blogy 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" }} />
Do budoucna je možné, že React změní formát pro Suspense. Možná ten kód na řešení Suspense boundary časem odstraním, až budu mít lepší řešení pro stránky, které se načítají lazy (a tím pádem potřebují Suspense boundary).
Hydration strategie (Update: Trvalo to 3 dny + 1 extra den)
Hydration je náročná. To jsem věděl. Ale po chvilce práce se mi ji podařilo rozběhnout!
Celkový čas na hydration: 3 dny, plus 1 extra den na nahrazení přístupu s „dehydratací“.
Nejzáludnější bylo získat ten první úplně minimální, ale funkční hydrate. Jakmile se mi podařilo vyrenderovat „Hello World“ s navigační lištou, bylo jasno, že to asi nebude práce na celý měsíc!
<img alt="Hello World ve Foony se úspěšně hydratoval i s 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" }} />
Pro ten první minimální funkční hydrate jsem měl jeden speciální problém: chtěl jsem hydration, ale zároveň i dobré SEO pro vyhledávače a LLM, a nechtěl jsem, aby vývojáři museli řešit Suspense boundary.
Výzva
Hydration v Reactu je extrémně doslovná: pokud DOM nevypadá přesně tak, jak React čeká pro ten první render, dostanete v konzoli pěknou, ale skoro k ničemu hlášku a React všechno zahodí a vyrenderuje stránku znova od nuly. Žádný diff, žádná nápověda, co se pokazilo!
V našem případě SSG situaci ještě zhoršilo:
- Post-processingem HTML jsme odstranili / vyřešili artefakty React 18 streaming Suspense (což je skvělé pro boty).
- Klient ne vždy měl ve chvíli (t = 0) úplně stejná data jako server při renderu (SSG data, metadata blogů atd.).
- Náš i18n je ve výchozím stavu „líný“, což znamená, že překlady můžou při prvním renderu chybět, pokud si nezaznamenáte, které překlady se při SSG použily, a nedodáte je ještě před tím, než začne React renderovat.
Co fungovalo (první přístup: Dehydratace)
Nejdřív jsem zkusil něco chytrého a trochu roztomilého: použil jsem command pattern na zaznamenání příkazů, které řešily Suspense boundary v HTML, a vrátil jsem opačné transformační příkazy, abych mohl HTML zase vrátit do stavu, jaký React potřebuje pro hydration.
Doufal jsem, že tímhle command přístupem odešlu v index.html mnohem míň bytů. Ale jak to u chytrých řešení bývá, skončilo to špatně, protože prohlížeče HTML nenápadně upravují – třeba přidají nebo odeberou ; nebo /, což mi rozhodilo všechny indexy pro nahrazování.
Teoreticky by šlo tyhle drobné změny prohlížeče ošetřit, ale rozhodně jsem nechtěl nasadit něco tak křehkého.
Místo snahy „vracet“ Suspense boundary zpátky do Reactího streaming markup jsem udělal něco super jednoduchého:
Původní, nevyřešené HTML zabalit do <script type="text">.
Tenhle „dehydration“ přístup fungoval, ale pak jsem strávil ještě jeden den tím, že jsem ho nahradil lepším řešením.
Lepší přístup: Náhrada Suspense boundary na kritické cestě
Po prvním nasazení jsem měl se Suspense boundary pořád nějaké problémy. V tu chvíli mi došlo, že existuje čistší, lepší a jednodušší řešení. Dehydrataci jsem nahradil přístupem critical path Suspense boundary replacement, který:
- Načte kritickou cestu ještě před hydration: Komponenty, které se pre-loadovaly během SSR, se na klientu identifikují a pre-loadnou ještě před voláním
hydrateRoot
- Je jednodušší na údržbu: Není potřeba sahat do React internals nebo parsovat AST (dehydration přístup potřeboval HTML znovu parsovat a vracet)
- Posílá míň bytů: Už nemusíme v bundlu mít původní SSR odpověď z Reactu ve script tagu
- Zabraňuje potenciálnímu „flashnutí“: Není potřeba HTML dehydratovat / rehydratovat, takže odpadá vizuální probliknutí
Implementace sleduje, které lazy komponenty se během SSR pre-loadly (přes SSRLazyComponentTracker), jejich import cesty zahrne do hydration dat a na klientovi je synchronně pre-loadne před hydration. Komponenty na kritické cestě se pak vykreslí přímo bez Suspense boundary a přesně odpovídají SSR výstupu.
U všeho ostatního se snažíme, aby se první render na klientu choval jako SSR/SSG. To znamená použít stejná vstupní data a zpřístupnit je synchronně ještě před hydrateRoot. To řešíme bundlováním přes naše „ssg-data“.
Konkrétně šlo o tyhle úpravy:
Zabalení SSR vstupů do jednoho textového scriptu
- Během SSG injektujeme těsně před Vite module entrypoint
<script type="text/foony-ssg" id="foony-ssg-data">...</script>.
- Ten script obsahuje:
html: vyřešené HTML, které skutečně posíláme jako statický soubor
ssgData: serializovaná SSGData, kterou používá SSR wrapper. Plánuju to časem změnit třeba na Proxy, aby se posílala jen data, ke kterým se skutečně přistoupilo.
translationData: blob klíč-hodnota s překlady, na které jsme během SSR sáhli
Vstřiknutí těchto vstupů těsně před hydration
- V
main.tsx synchronně:
- nastavíme
#root.innerHTML na serializované vyřešené HTML (aby DOM vypadal přesně tak, jak ho hydration uvidí)
- obalíme aplikaci do
SSGDataProvider, aby měly komponenty hned při prvním renderu stejnou SSGData jako při SSR
Okamžité i18n díky injektování překladů
- Zaznamenáme si konkrétní překladové objekty, ke kterým se během SSR přistupovalo, a pošleme je v SSG scriptu.
- Na klientu je přímo injectneme do cache
LocaleQueryer přes speciální metodu LocaleQueryer.inject(), aby překlady byly k dispozici okamžitě.
A tím pádem má první render k dispozici úplně stejná data jako 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 renderu na klientu (hydration), pak po mountu přepne na false. Komponenty jako UserBanner, Navbar a Dialog ho už používají, aby předešly mismatchům při hydrataci.
- Patchnutí Reactu pro lepší diffy
Doufal jsem, že prostě použiju hydration-overlay. Ale není aktivně udržovaný, podporuje jen React 18 a nebyl připravený do produkce. Tak jsem nechal LLM naklonovat repozitář jen jako inspiraci a ono pak během pár minut vytvořilo minimální hydration overlay. Nepotřeboval jsem nic extra chytrého – jen něco, co se ukáže během vývoje a řekne mi, kde je problém.
Ten nový overlay je hodně jednoduchý, takže diffy nejsou úplně dokonalé. React odstraňuje komentáře, přidává ; za style atributy, mění whitespace a dělá ještě pár dalších drobných věcí, se kterými náš overlay zatím nepočítá. Overlay taky ukazuje 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 i tak je to úplně dostačující k tomu, aby se dalo zjistit, co je potřeba opravit.
<img alt="diff mezi SSG a prvním client-renderem pro React hydration" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />
Čísla a statistiky
Aby bylo jasnější, co všechno tahle implementace obnášela:
- 2 dny práce (od začátku po funkční SSG). Ve skutečnosti něco přes 24 hodin, a to jsem byl na dovolené.
- 4 dny práce, aby se hydration chovala hezky, bez async závodů v překladech nebo problémů s
useMediaQuery.
- 1 extra den na nahrazení přístupu s dehydratací kritickou náhradou Suspense boundary (jednodušší, méně bytů, žádné potenciální probliknutí).
- ~200 řádků core kódu pro generování SSG (
GenerateShellSsgFromSitemap.ts)
- ~120 řádků kódu pro řešení Suspense boundary (
resolveSuspenseBoundaries v renderRoute.tsx) - Poznámka: Později to bylo nahrazeno přístupem s kritickou cestou
- ~50 řádků utilit 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
useIsSSRMode())
Řešení je lehké a udržitelné. Nevyžaduje žádnou frameworkovou migraci a funguje s naším stávajícím React SPA.
Hlavní poznatky
Někdy je vlastní řešení lepší
Ne každý problém potřebuje framework. Pro Foony bylo malé, vlastní SSG řešení nejlepší volba. Je:
- Lehké: Žádné těžké dependency ani framework overhead
- Udržitelné: Jednoduchý kód, kterému rozumíme
- Flexibilní: Snadno se upravuje a rozšiřuje podle potřeby
- Kompatibilní: Funguje s naším existujícím React SPA bez migrace
Reactí streaming SSR má svoje zvláštnosti
Reactí renderToReadableStream je fajn na práci se Suspense, ale má svoje quirky. I s await stream.allReady pořád dostanete v outputu Suspense boundary. Není to bug, je to tak navržené pro streaming. Ale pro SSG potřebujeme plně vyřešené HTML. Působí to trochu jako selhání React týmu, že tenhle scénář nevyřešil nějak hezky zabudovaně.
Moje řešení bylo HTML po renderu zpracovat a boundary vyřešit. Není to úplně líbivá cesta, ale je rychlá a pro můj use case dostatečně flexibilní.
TDD může být užitečné ve spolupráci s LLM
Transformace HTML jsou náchylné na chyby. Jedna malá chyba a rozbijete celé SSG a zážitek koncového uživatele. Nechal jsem LLM napsat (s mým vstupem) sadu testů, které hlídají, ž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 přitom lehké a udržitelné. Hydration pro SSG routy mi zabrala víc času, než jsem čekal (3 dny), a ještě jsem strávil 1 další den nahrazením původního přístupu s dehydratací kritickou náhradou Suspense boundary. Nový přístup je jednodušší na údržbu, posílá méně bytů a brání vizuálnímu probliknutí při de-/re-hydrataci HTML.
Pořád mě trochu šokuje, že mi trvalo jen 2 dny implementovat vlastní SSG řešení na míru. Ale někdy je to správné řešení prostě to nejjednodušší.
Do budoucna mě čeká doladění hydration matching a možná i patchnutí Reactu kvůli lepšímu debugování. Ale pro teď má Foony funkční SSG. V následujících týdnech budu sledovat Google Search Console a Bing Webmaster Tools, abych zjistil, jaký to má dopad na naše SEO.