

1/1/1970
Jak wdrożyłem SSG w 2 dni
Cześć! Rok temu myślałem, że to niemożliwe. Ale właśnie skończyłem wdrażać Static Site Generation (SSG) dla Foony w 2 dni i jestem z tego dość podekscytowany. To nie pierwszy raz, gdy próbuję rozwiązać kwestię SSG dla Foony. Wcześniej przyglądałem się NextJS, Vike, Astro, Gatsby i kilku innym rozwiązaniom. Miałem nawet falstart z NextJS, ale natknąłem się na trudności wynikające ze złożoności SPA Foony i tysięcy plików. Migracja byłaby koszmarem i zajęłaby miesiące. Dodałaby też dodatkowej złożoności wszystkim innym pracującym nad stroną, bo musieliby nauczyć się NextJS i jego dziwactw.
Chciałem czegoś lekkiego i łatwego do wdrożenia. Czegoś, co pozwoli nam dalej pisać kod tak, jak to robiliśmy, bez konieczności myślenia o SSG (z wyjątkiem useMediaQuery, na to nie ma sposobu). Poniżej wyjaśnię, dlaczego wybrałem dedykowane rozwiązanie, jakie konkretne wyzwania napotkałem (zwłaszcza z granicami Suspense w React) i jak je rozwiązałem.
Dlaczego nie standardowe rozwiązania?
Kiedy po raz pierwszy zastanawiałem się nad dodaniem SSG do Foony, naturalnie rozważałem NextJS (standard branżowy), Vike i Astro.
NextJS: za dużo migracji
NextJS jest potężny, ale wymagałby ogromnej migracji istniejącego React SPA Foony. Mamy tysiące plików, złożoną logikę routingu i sporo niestandardowej infrastruktury. Migracja do NextJS oznaczałaby:
- Przepisanie całego systemu routingu
- Przebudowanie sposobu, w jaki ładujemy gry i komponenty
- Miesiące pracy tylko po to, by wrócić do parytetu funkcjonalnego
- Potencjalne breaking changes dla użytkowników
- Zmiana sposobu obsługi obrazów
- Znacznie wolniejsze czasy buildów (potencjalnie 5-30 minut. Nie mam konkretnych liczb na poparcie tego, oprócz tej 5-letniej dyskusji na GitHubie)
- Cały zespół uczący się czegoś nowego (NextJS) i wolniejsza prędkość developerska na zawsze
- Migrację kodu za każdym razem, gdy NextJS zdecyduje się wprowadzić breaking changes.
Próbowałem nawet falstartu z NextJS, ale szybko zorientowałem się, że koszt migracji jest zbyt wysoki. Złożoność nie była warta korzyści.
Vike: podobna złożoność
Vike (dawniej vite-plugin-ssr) miał podobne problemy. Choć jest bardziej elastyczny niż NextJS, nadal wymagałby znacznej restrukturyzacji naszego kodu. Krzywa uczenia się i wysiłek migracyjny nie uzasadniały korzyści.
Astro: zła architektura
Astro świetnie sprawdza się przy stronach bogatych w treść, ale Foony to złożona platforma gier wieloosobowych. Potrzebujemy aktualizacji w czasie rzeczywistym, połączeń WebSocket i dynamicznych komponentów React. Architektura Astro po prostu nie pasuje do tego, co budujemy.
Rozwiązanie: dedykowane SSG
Ośmielony moim podejściem „fake SSG”, które wdrożyłem kilka dni temu po i18n, zdecydowałem się na małe, lekkie, dedykowane rozwiązanie dla SSG Foony.
Moje podejście „fake SSG” polegało na wyciąganiu treści wpisów blogowych ze stron z postami (trasy
/postsi strony gier) oraz umieszczaniu ich dokładnie tam, gdzie klient by je wyrenderował, specjalnie dla wyszukiwarek i LLM-ów, aby pomóc im zrozumieć Foony. Stosowało także schemat ld+json oraz drobne sprawy SEO.
Podejście jest proste:
- Buduj na istniejącym React SPA: Bez migracji, po prostu dodaj generowanie SSG w czasie buildu.
- Użyj
renderToReadableStream: API streamingowego SSR z React 18 natywnie obsługuje Suspense. - Generuj statyczne pliki HTML: Pre-renderuj trasy w czasie buildu i serwuj je jako pliki statyczne, używając naszego SitemapGenerator do uzyskania listy tras.
- Minimalne zmiany w istniejącym kodzie: Większość komponentów działa bez zmian.
Główna implementacja znajduje się w client/src/generators/GenerateShellSsgFromSitemap.ts. Czyta sitemapę, renderuje każdą trasę za pomocą renderToReadableStream z Reacta i zapisuje HTML do plików statycznych. Proste, dokładnie tak, jak lubię!
To okazało się też dość szybkie. Około 2800 tras renderowanych w 10 sekund. Super. To znacznie szybciej niż NextJS, Gatsby i Astro. <img alt="Log konsoli SSG pokazujący czas wykonania" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />
Mógłbym mówić o prostocie bez końca. Nawet jeśli nie zapewni ci awansu w dużych firmach z powodu „braku złożoności”, prosty kod jest piękny, łatwy w utrzymaniu i ogólnie znacznie lepszy dla prędkości developerskiej. To coś, co naprawdę podziwiam w zasadach Zen.
Problem granic Suspense
Więc miałem już SSG, a treść pojawiała się w HTML... ale moje strony były puste! Jak?! <img alt="Pusta strona 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" }} />
Okazuje się, że renderToReadableStream nadal ma granice Suspense, nawet jeśli wykonasz await stream.allReady. Domyślam się, że wynika to z tego, że jest to „strumień” zaprojektowany do przekazywania klientom w miarę otrzymywania bajtów.
Co wypluwa React
Kiedy używasz renderToReadableStream z Suspense, React generuje HTML taki jak ten:
<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
<!-- Faktyczna treść tutaj -->
</div>
...
<script>/*Skrypt zastępujący granice suspense*/</script>
<template id="B:0"> to placeholder, w którym powinna pojawić się treść. <div hidden id="S:0"> zawiera faktycznie wyrenderowaną treść. B:0 odpowiada S:0 po numerze (indeks od zera).
Bez JavaScriptu wyszukiwarki (patrzę na ciebie, Bing) i LLM-y zobaczyłyby niemal pustą stronę z samym placeholderem szablonu. To niweczy cały sens SSG!
Nie znalazłem czystego sposobu na usunięcie tych granic Suspense, więc moim rozwiązaniem było napisanie kilku testów i funkcji resolveSuspenseBoundaries, która je podmienia. Było to szybsze niż parsowanie HTML i wykonywanie skryptu czymś takim jak JSDOM. I, co ważniejsze, było to wymóg dla tego, co planowałem: ładnej, czytelnej strony dla wyszukiwarek / LLM-ów bez JavaScriptu, ale ze wsparciem dla granic Suspense i hydracji po stronie klienta.
Testowanie transformacji
Zacząłem od napisania testów dla transformacji, biorąc kilka przykładów z DOM tego, co miałem (JavaScript wyłączony) i tego, co chciałem mieć (JavaScript włączony). Wrzuciłem to do LLM-a i pozwoliłem mu zająć się generowaniem testów, w czym jest dość dobry.
Te testy znajdują się w client/src/generators/ssr/renderRoute.test.ts i zapewniają, że transformacja działa poprawnie. Testy obejmują:
- Proste zastąpienie granicy (lista wpisów blogowych)
- Złożone granice z treścią między szablonem a komentarzem zamykającym
- Wiele granic
- Granice bez znaczników komentarzy
- Przypadki brzegowe
Ten typ „TDD” jest faktycznie bardzo użyteczny w przypadku, gdy masz oczekiwane wejścia i wyjścia.
Nie należy tego mylić z „TDD wszystkiego, bo Robert C. Martin tak powiedział” (co spowolni prędkość developerską twojego zespołu). NIE powinieneś używać TDD dla UI ani obszarów kodu, które ciągle się zmieniają!
Rozwiązanie: resolveSuspenseBoundaries
Teraz, gdy testy były gotowe, kazałem LLM-owi napisać funkcję resolveSuspenseBoundaries. Wybrałem cheerio, aby uniknąć kruchości RegEx, mimo że użycie RegEx skróciłoby czas SSG o okoł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};
}
To zapewnia, że zamiast widzieć niemal pustą stronę, wyszukiwarki i LLM-y widzą w pełni wyrenderowaną stronę.
Teraz mamy działające SSG bez JavaScriptu!
<img alt="SSG bez JavaScriptu dla blogów 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" }} />
Długoterminowo możliwe, że React zmieni format Suspense. Mogę usunąć kod rozwiązywania Suspense, kiedy będę miał lepsze rozwiązanie dla stron ładowanych leniwie (i wymagających granic Suspense).
Strategia hydracji (aktualizacja: zajęło to 3 dni + 1 dodatkowy dzień)
Hydracja jest wymagająca. Wiedziałem to. Ale po pewnej pracy udało mi się to uruchomić!
Łączny czas hydracji: 3 dni plus 1 dodatkowy dzień na zastąpienie podejścia dehydracji.
Najtrudniejszą częścią było zwyczajne uruchomienie pierwszej minimalnej, działającej hydracji. Kiedy udało mi się wyrenderować „Hello World” z paskiem nawigacji, nabrałem pewności, że tak, to może nie zająć całego miesiąca!
<img alt="Hello World Foony hydratuje się pomyślnie z paskiem nawigacji" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_mvp.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
Przy tej pierwszej minimalnej, działającej hydracji miałem wyjątkowe wyzwanie: chciałem hydracji, ale chciałem też dobrego SEO dla wyszukiwarek i LLM-ów, bez konieczności myślenia developerów o granicach Suspense.
Wyzwanie
Hydracja Reacta jest niezwykle dosłowna: jeśli DOM nie wygląda tak, jak React oczekuje przy pierwszym renderze, dostajesz tę miłą, prawie bezużyteczną wiadomość błędu w konsoli, a React wyrzuca wszystko i renderuje od nowa. Nawet bez diffu informującego, co poszło nie tak!
W naszym przypadku SSG pogarszało to na kilka sposobów:
- Post-przetwarzaliśmy HTML, aby usunąć/rozwiązać artefakty streamingowego Suspense z React 18 (co jest świetne dla botów).
- Klient nie zawsze miał dokładnie te same dane dostępne w czasie (t = 0), co render serwerowy (dane SSG, metadane bloga itp.).
- Nasze i18n jest „leniwe” domyślnie, co oznacza, że tłumaczenia mogą brakować przy pierwszym renderze, chyba że zarejestrujesz, które tłumaczenia zostały użyte dla SSG i wstrzykniesz je przed renderem React.
Co zadziałało (początkowe podejście: dehydracja)
Najpierw spróbowałem czegoś sprytnego i ślicznego: użyłem wzorca polecenia, aby zarejestrować polecenia używane do rozwiązywania granic Suspense w HTML, i zwracałem polecenia odwrotnej transformacji, aby móc przywrócić HTML do tego, czego potrzebuje React do hydracji.
Miałem nadzieję, że dzięki tej metodzie poleceń wyślę znacznie mniej bajtów w index.html. Ale, jak w przypadku większości sprytnych rozwiązań, to zawiodło, ponieważ przeglądarki modyfikują HTML w subtelny sposób, np. usuwając lub dodając ; lub /, co zaburzało indeksy zamian.
Technicznie można by uwzględnić te subtelne zmiany przeglądarki, ale nie zamierzałem wysyłać czegoś tak kruchego.
Zamiast próbować „odwrócić” transformację granic Suspense z powrotem do streamingowego znacznika Reacta, zrobiłem coś super prostego:
Załączyć oryginalny, nierozwiązany HTML w <script type="text">.
To podejście „dehydracji” zadziałało, ale spędziłem dodatkowy dzień na zastąpieniu go lepszym rozwiązaniem.
Lepsze podejście: zastąpienie granic Suspense ścieżki krytycznej
Po początkowej implementacji nadal napotykałem pewne problemy z granicami Suspense. Wtedy zdałem sobie sprawę, że istnieje czystsze, lepsze, prostsze rozwiązanie. Zastąpiłem podejście dehydracji zastępowaniem granic Suspense ścieżki krytycznej, które:
- Ładuje ścieżkę krytyczną przed hydracją: Komponenty preładowane podczas SSR są identyfikowane i preładowane na kliencie przed wywołaniem
hydrateRoot
- Jest prostsze w utrzymaniu: Nie wymaga elementów wewnętrznych Reacta ani parsowania AST (podejście dehydracji wymagało parsowania i przywracania HTML)
- Wysyła mniej bajtów: Nie pakujemy już oryginalnej odpowiedzi SSR z Reacta w tagu skryptu
- Zapobiega potencjalnemu mignięciu: Brak konieczności dehydracji/rehydracji HTML, eliminuje potencjalne migotanie wizualne
Implementacja śledzi, które komponenty leniwe były preładowane podczas SSR (poprzez SSRLazyComponentTracker), zawiera ich ścieżki importu w danych hydracji i preładowuje je synchronicznie przed hydracją. Komponenty ścieżki krytycznej renderują się bezpośrednio bez granic Suspense, dokładnie pasując do wyjścia SSR.
Dla wszystkiego innego sprawiamy, że pierwszy render kliencki zachowuje się jak SSR/SSG. To znaczy używamy tych samych wejść i udostępniamy te wejścia synchronicznie przed hydrateRoot. Robimy to poprzez pakowanie przez nasze „ssg-data”.
Konkretnie, dostosowania były następujące:
Spakowanie wejść SSR w jeden tekstowy skrypt
- Podczas SSG wstrzykujemy
<script type="text/foony-ssg" id="foony-ssg-data">...</script> tuż przed punktem wejścia modułu Vite.
- Ten skrypt zawiera:
html: rozwiązany HTML, który faktycznie wysłaliśmy w pliku statycznym
ssgData: zserializowane SSGData używane przez wrapper SSR. Planuję zaktualizować to do Proxy lub czegoś podobnego, aby uwzględniane były tylko dane, do których jest dostęp.
translationData: bloby klucz-wartość tłumaczeń, których dotknęliśmy podczas SSR
Wstrzyknięcie tych wejść tuż przed hydracją
- W
main.tsx synchronicznie:
- ustawiamy
#root.innerHTML na zserializowany rozwiązany HTML (więc DOM jest dokładnie tym, co widzi hydracja)
- opakowujemy aplikację w
SSGDataProvider, aby komponenty miały te same SSGData przy pierwszym renderze
Sprawienie, że i18n jest natychmiastowy poprzez wstrzykiwanie wartości tłumaczeń
- Rejestrujemy faktyczne obiekty tłumaczeń, do których uzyskano dostęp podczas SSR i wysyłamy je w skrypcie SSG.
- Po stronie klienta wstrzykujemy je bezpośrednio do cache
LocaleQueryer poprzez dedykowaną metodę LocaleQueryer.inject(), więc tłumaczenia są dostępne natychmiast.
I dzięki temu pierwszy render ma te same dane, co SSR!
Hook useIsSSRMode() jest już zaimplementowany w 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;
}
Ten hook zwraca true podczas SSR i przy pierwszym renderze klienckim (hydracja), a następnie przełącza się na false po zamontowaniu. Komponenty takie jak UserBanner, Navbar i Dialog już używają go, aby zapobiec niedopasowaniom hydracji.
- Łatka Reacta dla lepszych diffów
Miałem nadzieję, że będę mógł po prostu użyć hydration-overlay. Ale nie jest aktywnie utrzymywany, wspierany tylko do React 18 i nie był gotowy do produkcji. Więc kazałem LLM-owi sklonować repo dla inspiracji, a potem stworzył minimalną nakładkę hydracji w kilka minut. Nie potrzebowałem niczego wymyślnego, po prostu czegoś, co pojawiałoby się podczas developmentu, abym mógł zorientować się, gdzie coś poszło nie tak.
Ta nowa nakładka jest bardzo podstawowa, więc diffy nie są do końca idealne. React usuwa komentarze, dodaje ; po atrybutach stylu, modyfikuje białe znaki i kilka innych drobnych rzeczy, których nasza nakładka (jeszcze) nie uwzględnia. Nasza nakładka zawiera również komentarze HTML, które React ignoruje przy hydracji.
<img alt="Nasza nowa nakładka hydracji" 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 jest wystarczająco dobra, aby zorientować się, co wymaga naprawy.
<img alt="diff naszego SSG vs renderu pierwszej strony klienta dla hydracji Reacta" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />
W liczbach
Aby dać ci poczucie, co obejmowała ta implementacja:
- 2 dni pracy (od początku do działającego SSG). To było nieco ponad 24 godziny podczas urlopu.
- 4 dni pracy, aby hydracja zachowywała się dobrze bez wyścigów asynchronicznych tłumaczeń lub
useMediaQuery psującego sprawy.
- 1 dodatkowy dzień, aby zastąpić podejście dehydracji zastępowaniem granic Suspense ścieżki krytycznej (prostsze, mniej bajtów, brak potencjalnego mignięcia).
- ~200 linii głównego kodu generowania SSG (
GenerateShellSsgFromSitemap.ts)
- ~120 linii rozwiązywania granic Suspense (
resolveSuspenseBoundaries w renderRoute.tsx) - Uwaga: To zostało później zastąpione podejściem ścieżki krytycznej
- ~50 linii narzędzi SSR (
isSSRMode.ts)
- ~100 linii testów (
renderRoute.test.ts)
- ~150 linii polyfilli dla SSR (
setupSSREnvironment)
- Minimalne zmiany w istniejących komponentach (głównie dodanie sprawdzeń
useIsSSRMode())
Rozwiązanie jest lekkie i łatwe w utrzymaniu. Nie wymaga migracji frameworka i działa z naszym istniejącym React SPA.
Kluczowe wnioski
Czasem dedykowane rozwiązanie jest lepsze
Nie każdy problem wymaga frameworka. Dla Foony małe, dedykowane rozwiązanie SSG było właściwym wyborem. Jest:
- Lekkie: Bez ciężkich zależności ani narzutu frameworka
- Łatwe w utrzymaniu: Prosty kod, który rozumiemy
- Elastyczne: Łatwe do modyfikacji i rozszerzenia w razie potrzeby
- Kompatybilne: Działa z naszym istniejącym React SPA bez migracji
Streamingowy SSR Reacta ma swoje dziwactwa
renderToReadableStream Reacta jest miły do radzenia sobie z Suspense, ale ma swoje dziwactwa. Nawet z await stream.allReady nadal masz granice Suspense w wyjściu. To nie błąd, to z założenia tak działa dla streamingu. Ale dla SSG potrzebujemy w pełni rozwiązanego HTML. Wydaje się, że to porażka zespołu Reacta, że nie obsługują tego scenariusza w czysty sposób.
Moim rozwiązaniem było post-przetwarzanie HTML i rozwiązywanie granic. Nie jest to ładne, ale jest szybkie i wystarczająco elastyczne dla mojego zastosowania.
TDD może być użyteczne dla LLM-ów
Transformacja HTML jest podatna na błędy. Jeden mały błąd i możesz zepsuć całe wyjście SSG i zepsuć doświadczenie użytkownika końcowego. Kazałem LLM-owi napisać kompleksowe testy (z moim wkładem), aby zapewnić, że transformacja działa poprawnie.
Podsumowanie
SSG działa teraz dla Foony. Strony są w pełni renderowane dla wyszukiwarek i LLM-ów, a rozwiązanie jest łatwe w utrzymaniu i lekkie. Hydracja dla tras SSG zajęła dłużej, niż się spodziewałem (3 dni), a dodatkowy dzień spędziłem na zastąpieniu początkowego podejścia dehydracji zastępowaniem granic Suspense ścieżki krytycznej. Nowe podejście jest prostsze w utrzymaniu, wysyła mniej bajtów i zapobiega potencjalnym mignięciom wizualnym z dehydracji/rehydracji HTML.
Nadal jestem zszokowany, że wdrożenie dedykowanego rozwiązania dla SSG zajęło tylko 2 dni. Ale czasem właściwym rozwiązaniem jest to najprostsze.
Przyszłe prace obejmują dokończenie dopasowania hydracji oraz potencjalnie łatanie Reacta dla lepszego debugowania. Ale na razie Foony ma działające SSG. Będę miał oko na Google Search Console i Bing Webmaster Tools w nadchodzących tygodniach, aby zobaczyć, jaki to wpływ na nasze SEO.