background blurbackground mobile blur

1/1/1970

Як я впровадив SSG за 2 дні

Привіт! Рік тому я думав, що це неможливо. Але я щойно завершив впровадження статичної генерації сайту (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 означала б:

  • Переписування всієї системи маршрутизації
  • Реструктуризацію того, як ми завантажуємо ігри та компоненти
  • Місяці роботи лише для того, щоб повернутися до того ж функціоналу
  • Потенційні breaking changes для користувачів
  • Зміну того, як ми обробляємо зображення
  • Значно повільніший час збірки (потенційно 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 та деякі дрібні SEO-речі.

Підхід простий:

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

Основна реалізація живе в client/src/generators/GenerateShellSsgFromSitemap.ts. Вона читає мапу сайту, рендерить кожен маршрут через 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" }} />

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

Що видає 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).

Без JavaScript пошукові системи (привіт тобі, Bing) і LLM бачили б майже порожню сторінку лише з заповнювачем-шаблоном. Це повністю нівелює сенс SSG!

Я не побачив жодного чистого способу прибрати ці межі Suspense, тож моїм рішенням було написати кілька тестів і функцію resolveSuspenseBoundaries, щоб їх замінити. Це було швидше, ніж парсити HTML і виконувати скрипт чимось на кшталт JSDOM. І, що важливіше, це була вимога для того, що я планував: гарний, читабельний сайт для пошукових систем / LLM без JavaScript, але з підтримкою меж Suspense та гідратації на клієнті.

Тестування трансформації

Я почав із написання тестів для трансформації, взявши кілька прикладів із DOM з того, що я мав (JavaScript вимкнено), і того, що хотів (JavaScript увімкнено). Я згодував їх LLM, і вона згенерувала тести, у чому вона досить непогана. Ці тести живуть у client/src/generators/ssr/renderRoute.test.ts і гарантують, що трансформація працює правильно. Тести охоплюють:

  • Просту заміну меж (список блогу)
  • Складні межі з вмістом між шаблоном і закриваючим коментарем
  • Кілька меж
  • Межі без коментарів-маркерів
  • Граничні випадки

Цей тип "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, коли матиму краще рішення для сторінок, які завантажуються лінькувато (і тому потребують меж 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" }} />

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

Виклик

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

У нашому випадку SSG погіршив це кількома способами:

  1. Ми постобробляли HTML, щоб видалити/розв'язати артефакти потокового Suspense React 18 (що чудово для ботів).
  2. Клієнт не завжди мав однакові дані в момент (t = 0), як серверний рендер (дані SSG, метадані блогу тощо).
  3. Наш 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".

Конкретно, налаштування були такі:

  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 для кращих diff'ів

Я сподівався, що зможу просто використати hydration-overlay. Але він не активно підтримується, працює лише до React 18 і не був готовий для продакшену. Тож я попросив LLM клонувати репозиторій для натхнення, і він створив мінімальний оверлей гідратації за кілька хвилин. Мені не потрібно було нічого вигадливого, просто щось, що з'являлося б під час розробки, щоб я міг зрозуміти, де щось пішло не так.

Цей новий оверлей дуже базовий, тож diff'и не зовсім ідеальні. 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="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.

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

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

8 Ball Pool online multiplayer billiards icon