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 хвилин. У мене немає точних цифр, тільки це 5-річної давності обговорення на GitHub)
  • Уся команда має вивчати щось нове (NextJS), а швидкість розробки падає назавжди
  • Переміщувати код кожного разу, коли NextJS вирішить зробити breaking changes.

Я навіть пробував стартувати з NextJS, але дуже швидко стало ясно, що вартість міграції надто висока. Ця складність того не варта.

Vike: майже та сама історія

Vike (раніше vite-plugin-ssr) мав подібні проблеми. Хоч він і гнучкіший за NextJS, усе одно вимагав би серйозної перебудови нашого коду. Крива навчання і зусилля на міграцію просто не окуповували б вигоди.

Astro: не та архітектура

Astro чудово підходить для сайтів із купою контенту, але Foony - це складна мультиплеєрна платформа ігор. Нам потрібні оновлення в реальному часі, WebSocket-з'єднання і динамічні React-компоненти. Архітектура Astro просто не підходить під те, що ми будуємо.

Рішення: власний SSG

Натхненний своїм "фейковим SSG" підходом, який я прикрутив кілька днів тому після i18n, я зупинився на невеликому, легкому, кастомному рішенні для SSG у Foony.

Мій "фейковий SSG" полягав у тому, щоб витягувати контент блогу зі сторінок із постами (/posts роутами і сторінками ігор) і ставити його саме туди, де клієнт потім його відрендерить, конкретно для пошукових систем і LLM, щоб їм було легше зрозуміти Foony. Там же додавався ld+json schema і трохи 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 у статичні файли. Простіше нікуди, саме так, як я люблю!

І це виявилося ще й досить швидким. Приблизно 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" }} />

Я можу дуже довго говорити про простоту. Навіть якщо це і не принесе підвищення в великих компаніях через "нестачу складності", простий код - це красиво, зручно в підтримці і взагалі набагато краще для швидкості розробки. Це те, що мені дуже подобається в принципах Zen.

Проблема з межами 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. Моя здогадка, що це тому, що це саме "stream" і його задумано передавати клієнту по мірі того, як приходять байти.

Що виводить 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 за номером (0-based індекс).

Без JavaScript пошукові системи (привіт, Bing) і LLM побачать майже порожню сторінку тільки з цим плейсхолдером template. Це повністю вбиває сенс 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, щоб уникнути крихкості 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, коли матиму краще рішення для сторінок, що lazy-loaded (і тому вимагають меж Suspense).

Стратегія гідратації (Оновлення: це зайняло 3 дні + 1 додатковий день)

Гідратація - це складно. Я це знав. Але після певної кількості танців з бубном мені вдалося все завести!

Загальний час на гідратацію: 3 дні, плюс 1 додатковий день, щоб прибрати підхід із "дегідратацією".

Найскладнішою частиною було домогтися першої мінімальної, але робочої гідратації. Як тільки мені вдалося відрендерити "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" }} />

Для цього першого MVP-гідратації у мене була особлива вимога: я хотів гідратацію, але також хотів хороший SEO для пошукових систем і LLM без того, щоб розробникам доводилося думати про межі Suspense.

Проблема

Гідратація React надзвичайно буквальна: якщо DOM не виглядає так, як React очікує для першого рендера, ви отримуєте цей милий, майже марний ерор у консолі, і React все викидає та рендерить з нуля. Навіть без дифа, щоб показати, що саме пішло не так!

У нашому випадку SSG ускладнював ситуацію щонайменше у двох аспектах:

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

Що спрацювало (початковий підхід: дегідратація)

Спочатку я спробував щось кумедне: використав патерн команд, щоб записувати команди, які використовувалися для розв'язання меж Suspense в HTML, і повертав зворотні команди, щоб потім можна було відновити HTML до того вигляду, який потрібен React для гідратації. Я сподівався, що з таким підходом можна буде шипити помітно менше байтів в index.html. Але, як це часто буває з "розумними" рішеннями, усе зламалося через те, що браузери трохи змінюють HTML, наприклад прибирають або додають ; чи /, і це ламало всі індекси замін. Технічно можна було б якось врахувати ці дрібні зміни браузера, але я не збирався шипити настільки крихке рішення. Замість того щоб намагатися "розкрутити" трансформацію меж Suspense назад у стрімінгову розмітку React, я зробив суперпросту річ:

Запакувати оригінальний, нерозв'язаний HTML у <script type="text">.

Цей підхід з "дегідратацією" працював, але я витратив ще один день, щоб замінити його кращим рішенням.

Краще рішення: заміна меж Suspense по критичному шляху

Після першої реалізації я все ще ловив проблеми з межами Suspense. І тоді нарешті дійшло, що є чистіше, краще й простіше рішення. Я викинув підхід із дегідратацією і замінив його на заміни меж Suspense по критичному шляху, який:

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

Реалізація відстежує, які lazy-компоненти були preload під час SSR (через SSRLazyComponentTracker), включає їхні шляхи імпорту в дані для гідратації і синхронно preload-ить їх перед гідратацією. Компоненти критичного шляху рендеряться без меж Suspense, і вихід повністю збігається з SSR.

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

По факту, я зробив таке:

  1. Запакувати вхідні дані SSR в один текстовий скрипт

    • Під час SSG ми вставляємо <script type="text/foony-ssg" id="foony-ssg-data">...</script> прямо перед Vite entrypoint модулем.
    • Цей скрипт містить:
      • html: розв'язаний HTML, який ми й шипимо в статиці
      • ssgData: серіалізований SSGData, що використовує SSR-обгортка. Я планую оновити це до Proxy чи чогось подібного, щоб включати тільки реально використані дані.
      • translationData: об'єкти перекладів (key-value), до яких ми зверталися під час 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 після mount. Такі компоненти як UserBanner, Navbar і Dialog уже це використовують, щоб уникнути розсинхрону при гідратації.

  1. Патч 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="diff між 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.

Я досі трохи в шоці, що запровадити власний SSG вийшло всього за 2 дні. Але іноді найкраще рішення - це найпростіше.

Далі в планах - допиляти повний збіг гідратації і, можливо, трохи пропатчити React для кращого дебагу. Але зараз у Foony є робочий SSG. У найближчі тижні я буду поглядати на Google Search Console і Bing Webmaster Tools, щоб подивитися, як це вплине на наш SEO.

8 Ball Pool online multiplayer billiards icon