

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