

1/1/1970
Как я внедрил SSG за 2 дня
Привет! Год назад я думал, что это невозможно. Но я только что закончил внедрение Static Site Generation (SSG) для Foony за 2 дня, и я очень этому рад. Это уже не первая моя попытка решить задачу SSG для Foony. В прошлом я смотрел на NextJS, Vike, Astro, Gatsby и ещё пару решений. У меня даже был фальстарт с NextJS, но я столкнулся со сложностями из-за объёмного SPA Foony и тысяч файлов. Миграция превратилась бы в кошмар и заняла бы месяцы. Она также добавила бы сложности всем остальным, кто работает над сайтом, потому что им пришлось бы изучать NextJS со всеми его особенностями.
Мне хотелось чего-то лёгкого и простого в реализации. Чего-то, что позволило бы нам и дальше писать код так, как мы привыкли, не задумываясь об SSG (за исключением useMediaQuery, тут уж никуда не деться). Ниже я расскажу, почему выбрал собственное решение, с какими конкретными проблемами столкнулся (особенно с границами Suspense в React), и как их решил.
Почему не стандартные решения?
Когда я впервые задумался о добавлении SSG в Foony, я, естественно, рассмотрел NextJS (отраслевой стандарт), Vike и Astro.
NextJS: слишком большая миграция
NextJS мощный, но потребовал бы массивной миграции существующего React SPA Foony. У нас тысячи файлов, сложная логика маршрутизации и куча кастомной инфраструктуры. Переход на NextJS означал бы:
- Переписывание всей нашей системы маршрутизации
- Реструктуризацию загрузки игр и компонентов
- Месяцы работы только чтобы вернуться к текущему функционалу
- Потенциальные ломающие изменения для пользователей
- Изменение способа работы с изображениями
- Значительно более долгие сборки (потенциально 5-30 минут. У меня нет конкретных цифр в подтверждение, кроме этого пятилетнего обсуждения на GitHub)
- Всей команде пришлось бы изучить нечто новое (NextJS), и скорость разработки замедлилась бы навсегда
- Миграцию кода каждый раз, когда NextJS решит внести ломающие изменения.
Я даже попробовал фальстарт с NextJS, но быстро понял, что цена миграции слишком высока. Сложность того не стоила.
Vike: похожая сложность
У Vike (ранее vite-plugin-ssr) были схожие проблемы. Он гибче, чем NextJS, но всё равно потребовал бы значительной реструктуризации нашей кодовой базы. Кривая обучения и трудозатраты на миграцию не оправдывали выгод.
Astro: неподходящая архитектура
Astro отлично подходит для контентных сайтов, но Foony это сложная многопользовательская игровая платформа. Нам нужны обновления в реальном времени, WebSocket-соединения и динамические React-компоненты. Архитектура Astro просто не подходит для того, что мы создаём.
Решение: собственный SSG
Воодушевлённый своим подходом «фейкового SSG», который я внедрил пару дней назад после i18n, я остановился на маленьком, легковесном, собственном решении для SSG в Foony.
Мой подход с «фейковым SSG» заключался в том, чтобы вытащить контент блог-постов со страниц с блогами (маршруты
/postsи страницы игр) и разместить его именно там, где его рендерил бы клиент, специально для поисковых систем и LLM, чтобы помочь им понять Foony. Также применялась схема ld+json и немного мелкого SEO.
Подход прост:
- Строим поверх существующего React SPA: миграция не нужна, просто добавляем генерацию SSG на этапе сборки.
- Используем
renderToReadableStream: API стримингового SSR из React 18 нативно обрабатывает Suspense. - Генерируем статические HTML-файлы: пред-рендерим маршруты на этапе сборки и отдаём их как статические файлы, используя наш SitemapGenerator для получения списка маршрутов.
- Минимальные изменения в существующей кодовой базе: большинство компонентов работают как есть.
Основная реализация живёт в client/src/generators/GenerateShellSsgFromSitemap.ts. Она читает sitemap, рендерит каждый маршрут через React-овский renderToReadableStream и пишет HTML в статические файлы. Просто, как я люблю!
Получилось ещё и довольно быстро. Около 2 800 маршрутов отрендерены за 10 секунд. Красота. Это значительно быстрее, чем NextJS, Gatsby и Astro. <img alt="Лог консоли SSG, показывающий затраченное время" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />
Я могу бесконечно говорить о простоте. Даже если в крупных компаниях за неё не повысят из-за «недостатка сложности», простой код красив, поддерживаем и в целом гораздо лучше для скорости разработки. Это то, что я очень ценю в принципах Дзен.
Проблема границ Suspense
Итак, у меня появился SSG, контент отображался в HTML... но мои страницы были пустыми! Как так?! <img alt="Пустая страница 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" }} />
Оказалось, что renderToReadableStream всё равно содержит границы Suspense, даже если вы делаете await stream.allReady. Я предполагаю, это из-за того, что это «поток», и он спроектирован для передачи клиентам по мере получения байтов.
Что выводит React
Когда вы используете renderToReadableStream с Suspense, React выводит HTML вроде такого:
<!--$?-->
<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"> это плейсхолдер, куда должен попасть контент. <div hidden id="S:0"> содержит реально отрендеренный контент. B:0 соответствует S:0 по номеру (индекс с нуля).
Без JavaScript поисковые системы (на тебя смотрю, Bing) и LLM увидели бы почти пустую страницу с одним только плейсхолдером шаблона. Это полностью убивает смысл SSG!
Я не нашёл чистого способа удалить эти границы Suspense, поэтому моё решение было написать тесты и функцию resolveSuspenseBoundaries, чтобы их подменить. Это оказалось быстрее, чем парсить HTML и выполнять скрипт через что-то вроде JSDOM. И, что важнее, это было необходимо для того, что я задумал: красивый, читаемый сайт для поисковых систем / LLM без JavaScript, но с поддержкой границ Suspense и гидратации на клиенте.
Тестирование преобразования
Я начал с написания тестов для преобразования, взяв несколько примеров DOM из того, что у меня было (с отключённым JavaScript), и того, что я хотел получить (с включённым JavaScript). Я скормил их LLM и попросил сгенерировать тесты, в этом она довольно неплоха.
Эти тесты живут в client/src/generators/ssr/renderRoute.test.ts и обеспечивают корректность преобразования. Тесты покрывают:
- Простую замену границ (список блогов)
- Сложные границы с контентом между шаблоном и закрывающим комментарием
- Множественные границы
- Границы без маркеров-комментариев
- Граничные случаи
Такой «TDD» на самом деле очень полезен для подобных случаев, когда у вас есть ожидаемые входы и выходы.
Это не следует путать с «TDD везде, потому что Роберт С. Мартин так сказал» (что замедлит скорость разработки вашей команды). Вам НЕ стоит использовать TDD для UI или участков кода, которые постоянно меняются!
Решение: resolveSuspenseBoundaries
Когда тесты были на месте, я попросил LLM написать функцию resolveSuspenseBoundaries. Я выбрал cheerio, чтобы избежать хрупкости RegEx, хотя использование RegEx сократило бы время SSG примерно на 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};
}
Это гарантирует, что вместо почти пустой страницы поисковики и LLM видят полностью отрендеренную страницу.
Теперь у нас работает SSG без JavaScript!
<img alt="SSG для блогов Foony без JavaScript" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blog_ssg.webp" style={{ margin: "8px auto", height: 340, display: "block" }} />
В долгосрочной перспективе возможно, что React изменит формат Suspense. Я могу убрать код разрешения Suspense, как только у меня появится лучшее решение для страниц, которые загружаются лениво (и поэтому требуют границ Suspense).
Стратегия гидратации (Обновление: это заняло 3 дня + 1 дополнительный)
Гидратация это сложно. Я это знал. Но после некоторой работы мне удалось заставить её работать!
Общее время на гидратацию: 3 дня плюс 1 дополнительный день, чтобы заменить подход с дегидратацией.
Самой каверзной частью было заставить заработать первый минимальный гидрат. Как только мне удалось отрендерить «Hello World» с навигационной панелью, я обрёл уверенность: да, это, возможно, не займёт целый месяц!
<img alt="Foony Hello World успешно гидратируется с навигационной панелью" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_mvp.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
Для этого первого минимального рабочего гидрата у меня была уникальная задача: я хотел гидратацию, но также хотел хорошее SEO для поисковых систем и LLM, при этом разработчикам не нужно было думать о границах Suspense.
Сложность
Гидратация React предельно буквальна: если DOM не выглядит так, как React ожидает для первого рендера, вы получаете эту красивую, почти бесполезную ошибку в консоли, и React выбрасывает всё и рендерит с нуля. Даже не диффа, чтобы понять, что пошло не так!
В нашем случае SSG делал это хуже несколькими способами:
- Мы пост-обрабатывали HTML, чтобы удалить/разрешить артефакты стримингового Suspense React 18 (что отлично для ботов).
- У клиента не всегда были те же данные в момент времени (t = 0), что и при серверном рендере (данные SSG, метаданные блогов и т.д.).
- Наша i18n по умолчанию «ленивая», что означает, что переводы могут отсутствовать при первом рендере, если только вы не записываете, какие переводы использовались для SSG, и не инъектируете их перед рендером React.
Что сработало (первоначальный подход: дегидратация)
Сначала я попробовал нечто умное и милое: я использовал паттерн команд, чтобы записать команды, использованные для разрешения границ Suspense в HTML, и возвращал команды обратного преобразования, чтобы восстановить HTML до того состояния, которое нужно React для гидратации.
Я надеялся, что смогу отгружать значительно меньше байтов в index.html с этим методом команд. Но, как и большинство умных решений, это провалилось, потому что браузеры модифицируют HTML тонкими способами, например, удаляя или добавляя ; или /, что сбивало индексы замены.
Технически вы, наверное, могли бы учесть эти тонкие изменения браузеров, но я не собирался отгружать что-то настолько хрупкое.
Вместо того чтобы пытаться «обратить» преобразование границ Suspense обратно в стриминговую разметку React, я сделал нечто супер простое:
Поместил оригинальный, неразрешённый HTML в <script type="text">.
Этот подход «дегидратации» работал, но я потратил дополнительный день, чтобы заменить его лучшим решением.
Лучший подход: замена границ Suspense на критическом пути
После первоначальной реализации я всё ещё сталкивался с некоторыми проблемами с границами Suspense. Тогда я понял, что есть более чистое, лучшее, более простое решение. Я заменил подход с дегидратацией на замену границ Suspense на критическом пути, которая:
- Загружает критический путь до гидратации: компоненты, предзагруженные во время SSR, идентифицируются и предзагружаются на клиенте до вызова
hydrateRoot
- Проще в поддержке: не требуется парсинг внутренностей React или AST (подход с дегидратацией требовал парсить и восстанавливать HTML)
- Отгружает меньше байтов: мы больше не упаковываем оригинальный SSR-ответ React в тег скрипта
- Предотвращает потенциальное мерцание: не нужно дегидратировать/регидратировать HTML, устраняя потенциальное визуальное мерцание
Реализация отслеживает, какие ленивые компоненты были предзагружены во время SSR (через SSRLazyComponentTracker), включает их пути импорта в данные гидратации и предзагружает их синхронно до гидратации. Компоненты критического пути рендерятся напрямую без границ Suspense, точно соответствуя выводу SSR.
Для всего остального мы делаем так, чтобы первый клиентский рендер действовал как SSR/SSG. Это означает использование тех же входных данных и обеспечение синхронного доступа к ним до hydrateRoot. Это делается путём упаковки через наши «ssg-data».
Конкретно корректировки были такими:
Упаковка SSR-входов в один текстовый скрипт
- Во время SSG мы инъектируем
<script type="text/foony-ssg" id="foony-ssg-data">...</script> прямо перед точкой входа модуля Vite.
- Этот скрипт содержит:
html: разрешённый HTML, который мы фактически отгрузили в статическом файле
ssgData: сериализованный SSGData, используемый обёрткой SSR. Я планирую обновить это до Proxy или чего-то подобного, чтобы включались только используемые данные.
translationData: блобы ключ-значение переводов, которые мы трогали во время SSR
Инъекция этих входов прямо перед гидратацией
- В
main.tsx мы синхронно:
- устанавливаем
#root.innerHTML в сериализованный разрешённый HTML (чтобы DOM был именно тем, что видит гидратация)
- оборачиваем приложение в
SSGDataProvider, чтобы у компонентов был тот же SSGData при первом рендере
Делаем i18n мгновенной через инъекцию значений переводов
- Мы записываем фактические объекты переводов, к которым обращались во время SSR, и отгружаем их в SSG-скрипте.
- На клиенте мы инъектируем их прямо в кэш
LocaleQueryer через специальный метод LocaleQueryer.inject(), чтобы переводы были доступны немедленно.
И вот, при первом рендере у нас те же данные, что были у SSR!
Хук useIsSSRMode() уже реализован в 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;
}
Этот хук возвращает true во время SSR и при первом клиентском рендере (гидратации), а затем переключается на false после монтирования. Компоненты вроде UserBanner, Navbar и Dialog уже используют это, чтобы предотвратить расхождения гидратации.
- Патчим React для лучших диффов
Я надеялся, что смогу просто использовать hydration-overlay. Но он активно не поддерживается, поддерживает только до React 18 и не готов к продакшену. Поэтому я попросил LLM клонировать репо для вдохновения, и за пару минут она создала минимальный hydration overlay. Мне не нужно было ничего навороченного, просто что-то, что показывало бы во время разработки, где что-то пошло не так.
Этот новый overlay супер базовый, поэтому диффы не совсем идеальны. React убирает комментарии, добавляет ; после атрибутов style, модифицирует пробелы и ещё несколько мелочей, которые наш overlay (пока) не учитывает. Наш overlay также включает HTML-комментарии, которые React игнорирует при гидратации.
<img alt="Наш новый 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" }} />
Но этого достаточно, чтобы понять, что нужно чинить.
<img alt="дифф нашего SSG против первого клиентского рендера для гидратации React" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />
В цифрах
Чтобы дать вам представление о том, что включала эта реализация:
- 2 дня работы (от старта до работающего SSG). Это было чуть больше 24 часов во время отпуска.
- 4 дня работы, чтобы гидратация вела себя хорошо без асинхронных гонок переводов или того, что
useMediaQuery всё портит.
- 1 дополнительный день, чтобы заменить подход с дегидратацией на замену границ Suspense на критическом пути (проще, меньше байтов, нет потенциального мерцания).
- ~200 строк основного кода генерации SSG (
GenerateShellSsgFromSitemap.ts)
- ~120 строк разрешения границ Suspense (
resolveSuspenseBoundaries в renderRoute.tsx). Примечание: позже это было заменено подходом критического пути
- ~50 строк SSR-утилит (
isSSRMode.ts)
- ~100 строк тестов (
renderRoute.test.ts)
- ~150 строк полифилов для SSR (
setupSSREnvironment)
- Минимальные изменения в существующих компонентах (в основном добавление проверок
useIsSSRMode())
Решение легковесное и поддерживаемое. Оно не требует миграции фреймворка и работает с нашим существующим React SPA.
Ключевые выводы
Иногда собственное решение лучше
Не каждой проблеме нужен фреймворк. Для Foony маленькое, собственное решение SSG было правильным выбором. Оно:
- Легковесно: никаких тяжёлых зависимостей или накладных расходов фреймворка
- Поддерживаемо: простой код, который мы понимаем
- Гибко: легко модифицировать и расширять по необходимости
- Совместимо: работает с нашим существующим React SPA без миграции
У стримингового SSR в React есть особенности
renderToReadableStream в React хорош для работы с Suspense, но у него есть особенности. Даже с await stream.allReady вы всё равно получаете границы Suspense в выводе. Это не баг, это сделано специально для стриминга. Но для SSG нам нужен полностью разрешённый HTML. Это ощущается как недоработка команды React, что они не обработали этот сценарий чисто.
Моим решением была пост-обработка HTML и разрешение границ. Это не красиво, но быстро и достаточно гибко для моего случая.
TDD может быть полезен для LLM
Преобразование HTML подвержено ошибкам. Один маленький баг и вы можете сломать весь вывод SSG и сломать пользовательский опыт. Я попросил LLM написать всеобъемлющие тесты (с моим вкладом), чтобы убедиться, что преобразование работает корректно.
Заключение
SSG теперь работает для Foony. Страницы полностью отрендерены для поисковиков и LLM, а решение поддерживаемо и легковесно. Гидратация для маршрутов SSG заняла дольше, чем я ожидал (3 дня), и я потратил дополнительный день, чтобы заменить первоначальный подход с дегидратацией на замену границ Suspense на критическом пути. Новый подход проще в поддержке, отгружает меньше байтов и предотвращает потенциальные визуальные мерцания от дегидратации/регидратации HTML.
Я до сих пор поражён, что потребовалось всего 2 дня, чтобы внедрить собственное решение для SSG. Но иногда правильное решение это самое простое.
Будущая работа включает завершение сопоставления гидратации и потенциально патчинг React для лучшей отладки. Но пока что у Foony работает SSG. В ближайшие недели я буду следить за Google Search Console и Bing Webmaster Tools, чтобы увидеть, какой эффект это окажет на наше SEO.