

1/1/1970
Jak jsem za 2 dny implementoval SSG
Nazdar! Před rokem jsem si myslel, že je to nemožné. Ale zrovna jsem za 2 dny dokončil implementaci Static Site Generation (SSG) pro Foony a docela mě to nadchlo. Tohle navíc není můj první pokus vyřešit SSG pro Foony. V minulosti jsem se díval na NextJS, Vike, Astro, Gatsby a několik dalších řešení. Měl jsem dokonce falešný start s NextJS, ale narazil jsem na potíže kvůli složitosti Foony SPA a tisícům souborů. Migrace by byla noční můrou a zabrala by měsíce. Přidala by také další složitost pro všechny ostatní pracující na webu, protože by se museli učit NextJS a jeho zvláštnosti.
Chtěl jsem něco lehkého a snadno implementovatelného. Něco, co by nám umožnilo dál psát kód stejným způsobem, jakým jsme ho psali doteď, aniž bychom museli myslet na SSG (s výjimkou useMediaQuery, kolem toho se prostě nedá obejít). Níže rozeberu, proč jsem zvolil řešení na míru, na jaké konkrétní výzvy jsem narazil (především s React Suspense boundaries) 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 mocný, ale vyžadoval by masivní migraci stávající React SPA Foony. Máme tisíce souborů, složitou logiku směrování a spoustu vlastní infrastruktury. Migrace na NextJS by znamenala:
- Přepsat celý systém směrování
- Restrukturalizovat způsob načítání her a komponent
- Měsíce práce jen na to, abychom se vrátili na úroveň současné funkčnosti
- Potenciální nekompatibilní změny pro uživatele
- Změnit způsob práce s obrázky
- Výrazně pomalejší build časy (potenciálně 5–30 minut. Nemám konkrétní čísla, která by to podpořila, kromě této pět let staré diskuse na GitHubu)
- Celý tým by se musel učit něco nového (NextJS) a vývojářská rychlost by se navždy zpomalila
- Migrace kódu pokaždé, když se NextJS rozhodne provést nekompatibilní změny
Dokonce jsem zkusil falešný start s NextJS, ale rychle jsem si uvědomil, že migrace bude příliš drahá. Složitost za to nestála.
Vike: Podobná složitost
Vike (dříve vite-plugin-ssr) měl podobné problémy. Sice je flexibilnější než NextJS, ale stále by vyžadoval významnou restrukturalizaci našeho kódu. Křivka učení a úsilí na migraci neospravedlňovaly přínosy.
Astro: Špatná architektura
Astro je skvělé pro weby zaměřené na obsah, ale Foony je složitá multiplayerová herní platforma. Potřebujeme aktualizace v reálném čase, WebSocket spojení a dynamické React komponenty. Architektura Astra prostě neodpovídá tomu, co stavíme.
Řešení: SSG na míru
Povzbuzen svým přístupem „falešného SSG", který jsem implementoval před pár dny po i18n, jsem se rozhodl pro malé, lehké řešení SSG pro Foony na míru.
Můj přístup „falešného SSG" spočíval v tom, že jsem stahoval obsah blogových příspěvků ze stránek s blogovými příspěvky (cesty
/postsa stránky her) a umisťoval je přesně tam, kde by je klient vykreslil, konkrétně pro vyhledávače a LLM, aby pomohly pochopit Foony. Aplikoval také schéma ld+json a několik drobností okolo SEO.
Přístup je jednoduchý:
- Stavět na stávající React SPA: Žádná migrace nepotřebná, jen přidat generování SSG při buildu.
- Použít
renderToReadableStream: Streaming SSR API z Reactu 18 zvládá Suspense nativně. - Generovat statické HTML soubory: Předrenderovat cesty při buildu a servírovat je jako statické soubory pomocí našeho SitemapGenerator pro získání seznamu cest.
- Minimální změny ve stávajícím kódu: Většina komponent funguje tak, jak je.
Hlavní implementace žije v client/src/generators/GenerateShellSsgFromSitemap.ts. Načte sitemapu, vyrenderuje každou cestu pomocí renderToReadableStream z Reactu a zapíše HTML do statických souborů. Jednoduché, přesně jak to mám rád!
A ukázalo se to být docela rychlé. Kolem 2 800 cest vyrenderováno za 10 sekund. Pěkné. To je výrazně rychlejší než NextJS, Gatsby a Astro. <img alt="SSG konzolový log ukazující čas" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />
Mohl bych mluvit o jednoduchosti donekonečna. I když vám ve velkých firmách nepomůže k povýšení kvůli „nedostatečné složitosti", jednoduchý kód je krásný, udržovatelný a obecně mnohem lepší pro vývojářskou rychlost. Tohle je něco, co opravdu obdivuji na Zen principech.
Problém Suspense boundaries
Tak jsem teď měl SSG a obsah se objevoval v HTML... ale moje stránky byly prázdné! Jak to?! <img alt="Prázdná SSG stránka" 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 stále obsahuje Suspense boundaries, i když uděláte await stream.allReady. Můj odhad je, že je to proto, že je to „stream" navržený k předávání klientům po bytech.
Co React vypisuje
Když použijete renderToReadableStream se Suspense, React vypíše HTML jako tohle:
<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
<!-- Skutečný obsah zde -->
</div>
...
<script>/*Skript, který nahrazuje suspense boundaries*/</script>
<template id="B:0"> je placeholder, kam má jít obsah. <div hidden id="S:0"> obsahuje skutečně vyrenderovaný obsah. B:0 se shoduje s S:0 podle čísla (index od 0).
Bez JavaScriptu by vyhledávače (na tebe se dívám, Bing) a LLM viděly téměř prázdnou stránku jen s placeholderem šablony. To maří celý smysl SSG!
Neviděl jsem žádný čistý způsob, jak tyto Suspense boundaries odstranit, takže mým řešením bylo napsat nějaké testy a funkci resolveSuspenseBoundaries, která je vymění. Tohle bylo rychlejší než parsovat HTML a spouštět skript něčím jako JSDOM. A co je důležitější, byl to požadavek pro to, co jsem plánoval: hezký, čitelný web pro vyhledávače / LLM bez JavaScriptu, ale s podporou Suspense boundaries a hydratací na klientu.
Testování transformace
Začal jsem psaním testů pro transformaci tak, že jsem si vzal několik příkladů z DOM, které jsem měl (s vypnutým JavaScriptem), a co jsem chtěl (se zapnutým JavaScriptem). Tohle jsem nakrmil do LLM a nechal ho vygenerovat testy, v čemž je docela dobré.
Tyto testy žijí v client/src/generators/ssr/renderRoute.test.ts a zajišťují, že transformace funguje správně. Testy pokrývají:
- Jednoduché nahrazení boundary (výpis blogu)
- Složité boundaries s obsahem mezi šablonou a uzavírajícím komentářem
- Více boundaries
- Boundaries bez markerů komentářů
- Okrajové případy
Tento typ „TDD" je vlastně docela užitečný pro tento případ, kde máte očekávané vstupy a výstupy.
Tohle se nemá zaměňovat s „TDD na všechno, protože to řekl Robert C. Martin" (což zpomalí vývojářskou rychlost vašeho týmu). NEMĚLI byste používat TDD pro UI nebo oblasti kódu, které se neustále mění!
Řešení: resolveSuspenseBoundaries
Teď, když byly testy na místě, jsem nechal LLM napsat funkci resolveSuspenseBoundaries. Šel jsem s cheerio, abych se vyhnul křehkosti RegEx, i když použití RegEx by zde 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};
}
Tohle zajišťuje, že místo téměř prázdné stránky vyhledávače a LLM uvidí plně vyrenderovanou stránku.
Teď máme SSG fungující dobře 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" }} />
Z dlouhodobého hlediska je možné, že React změní formát Suspense. Možná kód řešící Suspense odstraním, jakmile budu mít lepší řešení pro stránky, které jsou lazy-loaded (a tím pádem vyžadují Suspense boundaries).
Strategie hydratace (Update: Tohle trvalo 3 dny + 1 extra den)
Hydratace je výzva. To jsem věděl. Ale po troše práce se mi to podařilo zprovoznit!
Celkový čas na hydrataci: 3 dny plus 1 extra den na nahrazení přístupu dehydratace.
Nejtěžší část byla prostě dostat tu první minimální, fungující hydrataci. Jakmile se mi povedlo vyrenderovat „Hello World" s navbarem, získal jsem důvěru, že ano, tohle nezabere celý měsíc!
<img alt="Hello World od Foony se úspěšně hydratuje s navbarem" 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 tu první minimální, fungující hydrataci jsem měl jedinečnou výzvu: chtěl jsem hydrataci, ale zároveň jsem chtěl dobré SEO pro vyhledávače a LLM, aniž by vývojáři museli myslet na Suspense boundaries.
Výzva
React hydratace je extrémně doslovná: pokud DOM nevypadá tak, jak React očekává pro první render, dostanete tuhle hezkou, téměř k ničemu chybovou zprávu v konzoli a React všechno zahodí a vyrenderuje znovu od nuly. Ani diff vám nepoví, co se pokazilo!
V našem případě to SSG zhoršilo několika způsoby:
- Zpracovávali jsme HTML, abychom odstranili / vyřešili artefakty streaming Suspense z Reactu 18 (což je skvělé pro boty).
- Klient neměl vždy úplně stejná data k dispozici v čase (t = 0) jako server render (SSG data, metadata blogu atd.).
- Naše i18n je výchozím nastavením „lazy", což znamená, že překlady mohou pro první render chybět, pokud nezaznamenáte, které překlady byly použity pro SSG, a neinjektujete je před tím, než React renderuje.
Co fungovalo (Počáteční přístup: Dehydratace)
Nejprve jsem zkusil něco chytrého a roztomilého: použil jsem command pattern k zaznamenání příkazů použitých k vyřešení Suspense boundaries v HTML a vrátil reverzní transformační příkazy, abych mohl HTML obnovit do podoby, kterou React potřebuje pro hydrataci.
Doufal jsem, že touto metodou příkazů budu moct posílat výrazně méně bytů v index.html. Ale jak to bývá s většinou chytrých řešení, tohle selhalo, protože prohlížeče modifikují HTML jemnými způsoby, jako třeba odstraněním nebo přidáním ; nebo /, což rozhodilo indexy nahrazení.
Technicky byste pravděpodobně mohli tyto jemné změny prohlížeče zohlednit, ale nehodlal jsem posílat něco tak křehkého.
Místo snahy „obrátit" transformaci Suspense boundary zpět do streaming markupu Reactu jsem udělal něco super jednoduchého:
Zabalit původní, nevyřešené HTML do <script type="text">.
Tento přístup „dehydratace" fungoval, ale strávil jsem extra den jeho nahrazením lepším řešením.
Lepší přístup: Nahrazení Suspense boundary kritické cesty
Po počáteční implementaci jsem stále narážel na nějaké problémy se Suspense boundaries. Tehdy jsem si uvědomil, že existuje čistší, lepší, jednodušší řešení. Přístup dehydratace jsem nahradil nahrazením Suspense boundary kritické cesty, který:
- Načte kritickou cestu před hydratací: Komponenty, které byly preloadovány během SSR, jsou identifikovány a preloadovány na klientu před voláním
hydrateRoot
- Je jednodušší na údržbu: Žádné React internals nebo AST parsování nepotřebné (přístup dehydratace musel parsovat a obnovovat HTML)
- Posílá méně bytů: Už nezabalujeme původní SSR odpověď z Reactu do script tagu
- Předchází potenciálnímu blikání: Není potřeba dehydratovat / rehydratovat HTML, eliminuje se potenciální vizuální blikání
Implementace sleduje, které lazy komponenty byly preloadovány během SSR (přes SSRLazyComponentTracker), zahrnuje jejich import cesty do hydratačních dat a synchronně je preloaduje před hydratací. Komponenty kritické cesty se renderují přímo bez Suspense boundaries, přesně se shodují s SSR výstupem.
Pro všechno ostatní uděláme to, že první klientský render se chová jako SSR/SSG. To znamená použít stejné vstupy a zpřístupnit tyto vstupy synchronně před hydrateRoot. Toho dosahujeme zabalením přes naše „ssg-data".
Konkrétně, úpravy byly:
Zabalit SSR vstupy do jednoho textového skriptu
- Během SSG injektujeme
<script type="text/foony-ssg" id="foony-ssg-data">...</script> přímo před entrypoint Vite modulu.
- Tento skript obsahuje:
html: vyřešené HTML, které jsme skutečně poslali ve statickém souboru
ssgData: serializovaná SSGData použitá SSR wrapperem. Plánuji to aktualizovat na Proxy nebo něco podobného, aby byla zahrnuta jen přístupná data.
translationData: bloby překladových klíč-hodnota, kterých jsme se dotkli během SSR
Injektovat tyto vstupy těsně před hydratací
- V
main.tsx synchronně:
- nastavíme
#root.innerHTML na serializované vyřešené HTML (takže DOM je přesně to, co hydratace vidí)
- obalíme aplikaci do
SSGDataProvider, aby komponenty měly stejná SSGData při prvním renderu
Udělat i18n okamžité injektováním překladových hodnot
- Zaznamenáváme skutečné překladové objekty přístupné během SSR a posíláme je v SSG skriptu.
- Na klientu je injektujeme přímo do cache
LocaleQueryer přes specializovanou metodu LocaleQueryer.inject(), takže překlady jsou dostupné okamžitě.
A s tím má první render stejná data jako SSR!
Hook useIsSSRMode() je už implementován 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;
}
Tento hook vrací true během SSR a při prvním klientském renderu (hydratace), pak se po mountu přepne na false. Komponenty jako UserBanner, Navbar a Dialog to už používají k prevenci hydration mismatchů.
- Patchnout React pro lepší diffy
Doufal jsem, že prostě budu moct použít hydration-overlay. Ale není aktivně udržovaný, podporuje jen do Reactu 18 a nebyl produkčně připravený. Tak jsem nechal LLM naklonovat repo pro inspiraci a pak za pár minut vytvořil minimální hydration overlay. Nepotřeboval jsem nic fancy, jen něco, co se objeví během vývoje, abych zjistil, kde se věci pokazily.
Tento nový overlay je super základní, takže diffy nejsou úplně dokonalé. React odstraňuje komentáře, přidává ; za style atributy, modifikuje bílé znaky a několik dalších drobností, které náš overlay (zatím) nezohledňuje. Náš overlay také zahrnuje HTML komentáře, které React pro 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 je dost dobrý na to, abych zjistil, co je potřeba opravit.
<img alt="diff našeho SSG vs první render klientské stránky pro React hydrataci" 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
Abyste získali představu, co tato implementace zahrnovala:
- 2 dny práce (od začátku po fungující SSG). Bylo to jen něco přes 24 hodin během dovolené.
- 4 dny práce na to, aby se hydratace chovala hezky bez async překladových závodů nebo aby
useMediaQuery nedělal neplechu.
- 1 extra den na nahrazení přístupu dehydratace nahrazením Suspense boundary kritické cesty (jednodušší, méně bytů, žádné potenciální blikání).
- ~200 řádků hlavního kódu generování SSG (
GenerateShellSsgFromSitemap.ts)
- ~120 řádků řešení Suspense boundaries (
resolveSuspenseBoundaries v renderRoute.tsx) - Poznámka: Tohle bylo později nahrazeno přístupem kritické cesty
- ~50 řádků SSR utilit (
isSSRMode.ts)
- ~100 řádků testů (
renderRoute.test.ts)
- ~150 řádků polyfillů pro SSR (
setupSSREnvironment)
- Minimální změny ve stávajících komponentách (převážně přidání kontrol
useIsSSRMode())
Řešení je lehké a udržovatelné. Nevyžaduje migraci frameworku a funguje s naší stávající React SPA.
Klíčové poznatky
Někdy je řešení na míru lepší
Ne každý problém potřebuje framework. Pro Foony bylo malé SSG řešení na míru tou správnou volbou. Je:
- Lehké: Žádné těžké závislosti nebo režie frameworku
- Udržovatelné: Jednoduchý kód, kterému rozumíme
- Flexibilní: Snadno modifikovatelné a rozšiřitelné podle potřeby
- Kompatibilní: Funguje s naší stávající React SPA bez migrace
Streaming SSR od Reactu má své zvláštnosti
renderToReadableStream od Reactu je hezké pro práci se Suspense, ale má své zvláštnosti. I s await stream.allReady ve výstupu pořád dostáváte Suspense boundaries. To není bug, je to navržené pro streamování. Ale pro SSG potřebujeme plně vyřešené HTML. Připadá mi to jako selhání týmu Reactu, že tento scénář neřeší čistým způsobem.
Mým řešením bylo HTML zpracovat a boundaries vyřešit. Není to hezké, ale je to rychlé a dostatečně flexibilní pro můj případ použití.
TDD může být užitečné pro LLM
HTML transformace je náchylná k chybám. Jeden malý bug a můžete rozbít celý SSG výstup a zničit zážitek koncového uživatele. Nechal jsem LLM napsat komplexní testy (s mým vstupem), aby zajistily, že transformace funguje správně.
Závěr
SSG teď pro Foony funguje. Stránky jsou plně vyrenderované pro vyhledávače a LLM a řešení je udržovatelné a lehké. Hydratace pro SSG cesty trvala déle, než jsem čekal (3 dny), a strávil jsem extra den nahrazením počátečního přístupu dehydratace nahrazením Suspense boundary kritické cesty. Nový přístup je jednodušší na údržbu, posílá méně bytů a předchází potenciálnímu vizuálnímu blikání z dehydratace / rehydratace HTML.
Pořád mě šokuje, že implementace SSG řešení na míru zabrala jen 2 dny. Ale někdy je správné řešení to nejjednodušší.
Budoucí práce zahrnuje dokončení shody hydratace a potenciálně patchnutí Reactu pro lepší debugování. Ale pro teď má Foony fungující SSG. Budu sledovat Google Search Console a Bing Webmaster Tools v nadcházejících týdnech, abych viděl, jaký to bude mít efekt na naše SEO.