background blurbackground mobile blur

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-правок.

Подход максимально простой:

  1. Строиться поверх существующего React SPA: миграция не нужна, просто добавляем генерацию SSG во время билда.
  2. Использовать renderToReadableStream: стриминговый SSR API из React 18 сам по себе умеет работать с Suspense.
  3. Генерировать статические HTML-файлы: заранее рендерим роуты во время билда и отдаем их как статику, а список роутов берем из нашего SitemapGenerator.
  4. Минимум правок в существующем коде: почти все компоненты работают как есть.

Основная реализация живет в client/src/generators/GenerateShellSsgFromSitemap.ts. Она читает sitemap, рендерит каждый роут через React renderToReadableStream и записывает HTML в статические файлы. Все просто, как я люблю!

И по скорости все вышло довольно шустро. Около 2800 роутов рендерятся за 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 и проверяют, что трансформация работает как надо. Они покрывают:

  • Простую замену границ (список постов в блоге)
  • Сложные границы, где между <template> и закрывающим комментарием есть контент
  • Несколько границ на странице
  • Границы без комментариев-маркеров
  • Разные крайние случаи

Такой легкий «TDD» тут реально полезен, когда у тебя есть четкий вход и ожидаемый выход.

Это не то же самое, что «покрыть TDD вообще все, потому что так сказал Robert C. Martin» (так вы только замедлите всю команду). Не стоит использовать TDD для UI и тех мест кода, которые постоянно меняются!

Решение: resolveSuspenseBoundaries

Когда тесты были готовы, я попросил LLM написать саму функцию resolveSuspenseBoundaries. Для парсинга я выбрал cheerio, чтобы не зависеть от хрупких RegExp, хотя с регекспами время 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.

Стратегия гидратации (апдейт: ушло 3 дня + еще 1 день сверху)

Гидратация это непростая штука, я это знал заранее. Но после небольшой возни мне все же удалось ее запустить!

Итого на гидратацию ушло: 3 дня плюс еще 1 день, чтобы заменить подход с "dehydration".

Самым сложным было добиться самого первого минимального рабочего варианта. Как только мне удалось отрендерить Hello World вместе с навбаром, я окончательно поверил, что все получится и это не растянется на целый месяц!

<img alt="Hello World в Foony успешно гидратится вместе с навбаром" 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 только усугублял ситуацию несколькими способами:

  1. Мы постобрабатывали HTML, чтобы убрать/разрулить артефакты стримингового Suspense из React 18 (ботам от этого только лучше).
  2. На клиенте в момент времени (t = 0) не всегда были те же данные, что и при серверном рендере (SSG data, метаданные блога и так далее).
  3. Наш i18n по умолчанию "ленивый", то есть переводы могут отсутствовать при первом рендере, если заранее не записать, какие именно строки использовались при SSG, и не подложить их до того, как React начнет рендерить.

Что сработало (первый подход: dehydration)

Сначала я попробовал сделать что-то хитрое и красивое: использовал паттерн команд, чтобы записывать операции, которыми я развязывал Suspense-гранички в HTML, а потом возвращал обратные команды, чтобы восстановить HTML в том виде, который нужен React для гидратации.

Я надеялся, что таким способом смогу отправлять в index.html гораздо меньше байтов. Но, как это часто бывает с хитрыми решениями, все развалилось, потому что браузеры незаметно меняют HTML: где-то убирают или добавляют ; или /, и все индексы для замен съезжают.

Теоретически можно было бы как-то учитывать эти мелкие правки браузера, но мне совсем не хотелось выкатывать настолько хрупкое решение.

Вместо того чтобы пытаться «откатить» трансформацию Suspense обратно в стриминговую разметку React, я сделал что-то предельно простое:

Класть оригинальный HTML с исходными границами Suspense в <script type="text">.

Этот подход с "dehydration" в целом работал, но потом я потратил еще один день, чтобы заменить его на более удачное решение.

Более удачный подход: замена Suspense на критическом пути

После первого варианта реализации у меня все равно продолжали всплывать проблемы с границами Suspense. В какой-то момент я понял, что есть вариант чище, проще и лучше. Я заменил подход с dehydration на замену границ Suspense по критическому пути, которая:

  • Загружает критический путь до гидратации: компоненты, которые были предзагружены во время SSR, помечаются и предзагружаются на клиенте еще до вызова hydrateRoot.
  • Проще в поддержке: не нужно лезть во внутренности React или парсить AST (старый подход с dehydration заставлял парсить и восстанавливать HTML).
  • Шлет меньше байтов: мы больше не упаковываем оригинальный SSR-ответ от React в тег script.
  • Убирает возможный "миг": не нужно дегидратировать/регидратировать HTML, поэтому нет риска визуальной вспышки.

Реализация помечает, какие ленивые компоненты были предзагружены во время SSR (через SSRLazyComponentTracker), добавляет их пути импорта в данные для гидратации и синхронно предзагружает их перед гидратацией. Компоненты на критическом пути рендерятся сразу, без границ Suspense, и в точности совпадают с SSR-выводом.

Для всего остального мы делаем так, чтобы первый клиентский рендер вел себя как SSR/SSG. То есть используем те же входные данные и делаем их доступными синхронно до вызова hydrateRoot. Это реализовано через наш бандл с данными "ssg-data".

Конкретно правки были такими:

  1. Собрать входные данные SSR в один текстовый скрипт

    • Во время SSG мы вставляем <script type="text/foony-ssg" id="foony-ssg-data">...</script> прямо перед входной точкой Vite.
    • В этом скрипте лежит:
      • html: уже разобранный HTML, который мы реально отдаем в статическом файле
      • ssgData: сериализованный SSGData, который использует SSR-обертка. Я хочу потом сделать здесь Proxy или что-то похожее, чтобы включать только реально использованные данные.
      • translationData: наборы ключ-значение для переводов, к которым мы обращались во время SSR
  2. Подложить эти данные прямо перед гидратацией

    • В main.tsx мы синхронно:
      • ставим #root.innerHTML равным сериализованному готовому HTML (чтобы DOM выглядел ровно так, как его увидит гидратация)
      • оборачиваем приложение в SSGDataProvider, чтобы компоненты получили тот же SSGData на первом рендере
  3. Сделать 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, уже используют его, чтобы избежать расхождений при гидратации.

  1. Пропатчить React, чтобы было проще смотреть диффы

Я надеялся, что смогу просто взять hydration-overlay. Но проект там особо не поддерживается, официально работает только до React 18 и для продакшена явно не готов. Поэтому я попросил LLM склонировать репозиторий чисто ради вдохновения, и она за пару минут собрала минимальный оверлей для гидратации. Мне и не нужно было ничего сложного, просто инструмент, который во время разработки показывает, где все поехало.

Новый оверлей совсем простой, так что диффы там далеки от идеала. React выкидывает комментарии, добавляет ; после стилей, меняет пробелы и делает еще пару мелких вещей, которые наш оверлей пока не учитывает. Плюс он показывает HTML-комментарии, которые React при гидратации просто игнорирует.

<img alt="Наш новый оверлей для гидратации" 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 день на замену подхода с dehydration на критический путь для 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 дня), и еще один день ушел на замену первого подхода с dehydration на вариант с критическим путем для Suspense. Зато новый подход проще поддерживать, он отправляет меньше байтов и не дает странице мигать из-за дегидратации/регидратации HTML.

Меня до сих пор немного шокирует, что на кастомное SSG-решение ушло всего 2 дня. Но иногда самое правильное решение как раз самое простое.

Дальше в планах полностью добить совпадение при гидратации и, возможно, слегка пропатчить React ради более удобного дебага. Но прямо сейчас у Foony уже есть рабочий SSG. В ближайшие недели я буду поглядывать на Google Search Console и Bing Webmaster Tools, чтобы понять, как все это повлияло на наш SEO.

8 Ball Pool online multiplayer billiards icon