background blurbackground mobile blur

1/1/1970

چطور تو ۲ روز SSG رو پیاده کردم

سلام! حدود یه سال پیش فکر می‌کردم این کار غیرممکنه. ولی الان تازه تو فقط ۲ روز Static Site Generation (SSG) یا همون تولید استاتیک سایت رو برای Foony پیاده کردم و حسابی سر ذوقم. این اولین باری هم نیست که سعی می‌کنم مسئله‌ی SSG رو برای Foony حل کنم. قبلاً سراغ NextJS، Vike، Astro، Gatsby و چند تا راه‌حل دیگه هم رفتم. حتی یه شروع اشتباه با NextJS داشتم، ولی خیلی زود خوردم به دیوار پیچیدگی SPA فعلی Foony و چند هزار تا فایل. مهاجرت به اون طرف تبدیل می‌شد به یه کابوس واقعی و احتمالاً چند ماه طول می‌کشید. تازه برای بقیه‌ی تیم هم کلی پیچیدگی اضافه می‌کرد، چون باید NextJS و ریزه‌کاری‌هاش رو یاد می‌گرفتن.

من یه چیزی می‌خواستم که هم سبک باشه هم ساده برای پیاده‌سازی. یه چیزی که اجازه بده همون‌طوری که تا حالا کد می‌نوشتیم، ادامه بدیم و لازم نباشه مدام به SSG فکر کنیم (به جز useMediaQuery--واقعاً راه فراری براش نبود). پایین‌تر توضیح می‌دم چرا رفتم سراغ یه راه‌حل سفارشی، دقیقاً چه چالش‌هایی مخصوصاً با Suspense boundaryهای React داشتم و چطور حلشون کردم.

چرا از راه‌حل‌های استاندارد استفاده نکردم؟

وقتی اولین بار به اضافه کردن SSG به Foony فکر کردم، طبیعتاً سراغ NextJS (استاندارد صنعت)، Vike و Astro رفتم.

NextJS: مهاجرت بیش از حد سنگین

NextJS قدرتمنده، اما برای Foony یعنی یه مهاجرت عظیم از React SPA فعلی‌مون. ما چند هزار تا فایل داریم، منطق روتینگ پیچیده، و کلی زیرساخت سفارشی. مهاجرت به NextJS یعنی:

  • بازنویسی کل سیستم روتینگ
  • عوض کردن ساختار لود شدن بازی‌ها و کامپوننت‌ها
  • چند ماه کار فقط برای این که برگردیم به همون سطح قابلیت‌های فعلی
  • ریسک خراب شدن تجربه‌ی کاربر
  • تغییر تو روشی که با تصویرها کار می‌کنیم
  • زمان بیلد به‌مراتب کندتر (احتمالاً بین ۵ تا ۳۰ دقیقه. عدد دقیقی ندارم، فقط یه بحث ۵ ساله تو 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 رو بفهمن. علاوه بر اون، schemaهای ld+json و چند تا ریزه‌کاری کوچیک SEO هم اعمال می‌شد.

رویکرد کلی خیلی ساده‌ست:

  1. ساختن روی همون React SPA فعلی: نیازی به مهاجرت نیست، فقط تو زمان بیلد خروجی SSG رو تولید می‌کنیم.
  2. استفاده از renderToReadableStream: API استریمینگ SSR تو React 18 خودش به‌صورت پیش‌فرض با Suspense کنار میاد.
  3. تولید فایل‌های HTML استاتیک: روت‌ها رو موقع بیلد از قبل رندر می‌کنیم و به صورت فایل استاتیک سرو می‌کنیم، لیست روت‌ها رو هم از SitemapGenerator خودمون می‌گیریم.
  4. حداقل تغییر در کدهای فعلی: اکثر کامپوننت‌ها همون‌طوری که هستن کار می‌کنن.

هسته‌ی اصلی پیاده‌سازی تو فایل client/src/generators/GenerateShellSsgFromSitemap.ts زندگی می‌کنه. این فایل sitemap رو می‌خونه، هر روت رو با استفاده از renderToReadableStream خود React رندر می‌کنه و خروجی HTML رو به صورت فایل استاتیک می‌نویسه. ساده و سرراست، دقیقاً همون‌جوری که دوست دارم!

سرعتش هم آخر سر خیلی خوب از آب دراومد. حدود ۲۸۰۰ تا روت تو ۱۰ ثانیه رندر می‌شن. عالیه. این از نظر سرعت به‌طور محسوسی از 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 Boundary

خب، حالا 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 boundary تولید می‌کنه، حتی اگه await stream.allReady رو صدا بزنید. حدسم اینه که چون حالت استریم داره و طراحی شده که همون‌طور که بایت‌ها می‌رسن مستقیم برن سمت کلاینت، این رفتار طبیعیشه.

خروجی React چه شکلیه

وقتی با Suspense از renderToReadableStream استفاده می‌کنید، 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"> یه placeholderه که قراره محتوا اونجا قرار بگیره. <div hidden id="S:0"> در واقع محتوای رندرشده‌ی واقعی رو نگه می‌داره. B:0 و S:0 هم از نظر شماره با هم مچ می‌شن (ایندکس از ۰ شروع می‌شه).

اگه JavaScript نداشته باشیم، موتورهای جست‌وجو (به‌خصوص شما، Bing) و LLMها تقریباً یه صفحه‌ی خالی می‌بینن که فقط یه template توش هست. این دقیقاً برعکس هدفیه که برای SSG داشتیم!

هیچ راه تمیز و مستقیمی برای حذف این Suspense boundaryها پیدا نکردم، برای همین رفتم سراغ این که چند تا تست بنویسم و یه تابع به اسم resolveSuspenseBoundaries درست کنم که جاشون رو عوض کنه. این رویکرد از این که HTML رو پارس کنم و اسکریپت React رو با چیزی مثل JSDOM اجرا کنم خیلی سریع‌تر بود. و مهم‌تر از همه این که دقیقاً همون چیزی بود که دنبالش بودم: یه سایت خوش‌خوان و قابل فهم برای موتورهای جست‌وجو و LLMها بدون JavaScript، ولی با پشتیبانی از Suspense boundaryها و hydration سمت کلاینت.

تست کردن این تبدیل

اول از همه برای این تبدیل تست نوشتم. چند تا نمونه از DOM تو دو حالت مختلف برداشتم: یکی وقتی JavaScript خاموش بود (و چیزی که واقعاً تو HTML داشتیم) و یکی وقتی JavaScript روشن بود (و چیزی که React بعد از اجرا می‌ساخت). این ورودی‌ها رو دادم به یه LLM و گذاشتم خودش تست‌ها رو بسازه، کاری که واقعاً توش خوبه. این تست‌ها تو فایل client/src/generators/ssr/renderRoute.test.ts هستن و مطمئن می‌شن که تبدیل درست انجام می‌شه. سناریوهایی که تست‌ها پوشش می‌دن ایناست:

  • جایگزینی ساده‌ی boundary (لیست پست‌های وبلاگ)
  • boundaryهای پیچیده با محتوایی بین template و کامنت پایانی
  • چند تا boundary هم‌زمان
  • boundaryهایی که کامنت مارکر ندارن
  • حالت‌های لبه‌ای و عجیب

این نوع «TDD» برای چنین سناریویی که ورودی و خروجی مورد انتظار رو می‌دونی، واقعاً به درد می‌خوره.

این رو با «همه‌چی رو TDD کن چون Robert C. Martin گفته» قاطی نکنید (که فقط سرعت توسعه‌ی تیمتون رو می‌آره پایین). برای UI یا جاهایی از کد که مدام تغییر می‌کنن اصلاً نباید از TDD استفاده کنید!

راه‌حل: resolveSuspenseBoundaries

حالا که تست‌ها آماده بود، از همون LLM خواستم تابع resolveSuspenseBoundaries رو برام بنویسه. برای این کار رفتم سراغ cheerio تا درگیر شکنندگی RegEx نشم، با این که استفاده از RegEx احتمالاً زمان SSG رو حدود ۴۰٪ کمتر می‌کرد.

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 بدون JavaScript برای بلاگ‌های Foony" 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 خودش رو عوض کنه. احتمالاً وقتی برای صفحه‌هایی که lazy-load می‌شن (و در نتیجه به Suspense boundary نیاز دارن) یه راه‌حل بهتر پیدا کنم، این کدهای مربوط به حل کردن Suspense رو کامل پاک می‌کنم.

استراتژی Hydration (آپدیت: شد ۳ روز + ۱ روز اضافه)

Hydration کار راحتی نیست، این رو از قبل می‌دونستم. ولی بعد از یه کم کلنجار رفتن تونستم راهش بندازم!

کل زمانی که برای hydration صرف شد: ۳ روز، به‌علاوه‌ی ۱ روز اضافه برای عوض کردن رویکرد dehydration.

سخت‌ترین بخشش این بود که همون نسخه‌ی خیلی مینیمالِ کارکردن hydration رو دربیارم. همین که تونستم یه «Hello World» رو با navbar رندر کنم، مطمئن شدم که اوکی، احتمالاً لازم نیست یه ماه کامل وقتم رو پای این بذارم!

<img alt="Hello World Foony که با موفقیت همراه با navbar هیدریت شده" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_mvp.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />

برای اون نسخه‌ی مینیمالِ اولیه که واقعاً کار می‌کرد، یه چالش خاص داشتم: هم hydration می‌خواستم، هم SEO خوب برای موتورهای جست‌وجو و LLMها، بدون این که دولوپرها مجبور بشن مدام حواسشون به Suspense boundaryها باشه.

چالش کار

Hydration تو React فوق‌العاده حساس و مو به موئه. اگه DOM تو اون اولین رندر دقیقاً شبیه چیزی نباشه که React انتظار داره، یه پیام خطای خوشگل ولی تقریباً بی‌فایده تو کنسول می‌گیرید و React هم کل چیزهایی که هست رو ول می‌کنه و از صفر دوباره رندر می‌کنه. حتی یه diff درست‌وحسابی هم بهتون نمی‌ده که بفهمید مشکل از کجاست!

توی مورد ما، SSG اوضاع رو از چند جهت بدتر هم می‌کرد:

  1. ما بعد از رندر سرور، روی HTML یه پردازش اضافه انجام می‌دادیم تا آرتیفکت‌های Suspense استریمینگ React 18 رو حذف یا حل کنیم (که برای بات‌ها عالیه).
  2. سمت کلاینت همیشه دقیقاً همون دیتایی رو که سرور تو لحظه‌ی (t = 0) داشته، در دسترس نداشت (دیتای SSG، متادیتای بلاگ و غیره).
  3. i18n ما به صورت پیش‌فرض «lazy»ـه، یعنی ترجمه‌ها ممکنه تو اولین رندر وجود نداشته باشن، مگر این که دقیقاً ثبت کنی تو SSG چه ترجمه‌هایی استفاده شده و قبل از این که React رندر کنه، همون‌ها رو تزریق کنی.

چیزی که اول جواب داد (رویکرد اولیه: Dehydration)

اولش رفتم سراغ یه راه بامزه و به‌ظاهر باهوش: یه الگوی command نوشتم که دستورهایی رو که برای حل کردن Suspense boundaryهای HTML استفاده می‌کردیم، ثبت می‌کرد و بعد برعکس همون دستورها رو برمی‌گردوند تا بتونم HTML رو دوباره به همون شکلی که React برای hydration لازم داره، برگردونم. امیدم این بود که با این روشِ مبتنی بر command، حجم بایتی که تو index.html می‌فرستیم خیلی کمتر بشه. ولی مثل خیلی از راه‌حل‌های بیش از حد باهوش، این یکی هم خراب شد، چون مرورگرها HTML رو به شکل‌های خیلی ریز دست‌کاری می‌کنن؛ مثلاً یه ; یا / رو حذف یا اضافه می‌کنن و همین باعث می‌شه همه‌ی ایندکس‌هایی که برای جایگزینی داشتیم به‌هم بریزه. از نظر تئوری می‌تونستم برای این تغییرات ریز مرورگرها هم یه سری جبران‌کننده بنویسم، اما واقعاً دلم نمی‌خواست همچین سیستم شکننده‌ای رو بفرستم روی خط اصلی. به‌جای این که سعی کنم اون تبدیل Suspense boundary رو برعکس کنم و دوباره به markup استریمینگ React برگردونم، رفتم سراغ یه کار فوق‌العاده ساده:

همون HTML اولیه و حل‌نشده رو توی یه <script type="text"> باندل کردم.

این رویکرد «dehydration» جواب داد، ولی یه روز اضافه گذاشتم تا عوضش کنم و یه راه‌حل بهتر جاش بذارم.

رویکرد بهتر: جایگزینی Suspense Boundary فقط روی مسیر بحرانی (Critical Path)

بعد از پیاده‌سازی اولیه، هنوز هم با یه‌سری مشکل تو Suspense boundaryها روبه‌رو می‌شدم. اون‌جا بود که فهمیدم یه راه‌حل تمیزتر، بهتر و ساده‌تر هم وجود داره. رویکرد dehydration رو با جایگزینی Suspense boundary فقط روی مسیر بحرانی (critical path) عوض کردم که:

  • مسیر بحرانی رو قبل از hydration لود می‌کنه: کامپوننت‌هایی که حین SSR preload شده بودن شناسایی می‌شن و قبل از صدا زدن hydrateRoot سمت کلاینت هم preload می‌شن
  • نگه‌داریش ساده‌تره: نیازی به دست بردن تو دل React یا پارس کردن AST نیست (تو رویکرد dehydration مجبور بودیم HTML رو پارس و برگردونیم)
  • بایت‌های کمتری می‌فرسته: دیگه لازم نیست خروجی SSR اصلی React رو تو یه تگ script باندل کنیم
  • جلوگیری از فلش احتمالی: چون نیازی به dehydrate/rehydrate کردن HTML نیست، فلش بصری احتمالی هم از بین می‌ره

تو این پیاده‌سازی، ما ردیابی می‌کنیم کدوم lazy componentها حین SSR preload شده بودن (با کمک SSRLazyComponentTracker)، بعد مسیر importشون رو تو دیتای hydration قرار می‌دیم و قبل از شروع hydration به صورت همگام preloadشون می‌کنیم. کامپوننت‌های مسیر بحرانی بدون Suspense boundary رندر می‌شن و خروجی‌شون دقیقاً با خروجی SSR یکی می‌شه.

برای بقیه‌ی بخش‌ها، کاری می‌کنیم که اولین رندر سمت کلاینت عملاً مثل SSR/SSG رفتار کنه. یعنی از همون ورودی‌ها استفاده می‌کنیم و اون ورودی‌ها رو قبل از hydrateRoot به صورت همگام در دسترس قرار می‌دیم. این کار با باندل کردن داده‌ها تو چیزی که اسمش رو گذاشتیم «ssg-data» انجام می‌شه.

به شکل مشخص، تغییرها این‌ها بودن:

  1. باندل کردن ورودی‌های SSR داخل یه اسکریپت متنی واحد

    • حین SSG، درست قبل از entrypoint ماژول Vite، یه <script type="text/foony-ssg" id="foony-ssg-data">...</script> تزریق می‌کنیم.
    • داخل این اسکریپت این چیزها هست:
      • html: همون HTML حل‌شده‌ای که واقعاً تو فایل استاتیک فرستادیم
      • ssgData: نسخه‌ی serialize شده‌ی SSGData که تو wrapper سمت SSR استفاده شده. احتمالاً بعداً این رو به یه Proxy یا چیزی شبیه اون تبدیل می‌کنم تا فقط دیتای واقعاً استفاده‌شده داخلش بمونه.
      • translationData: همون آبجکت‌های کلیدـمقدارِ ترجمه که حین SSR لمسشون کردیم
  2. تزریق همین ورودی‌ها درست قبل از hydration

    • تو main.tsx، به صورت همگام:
      • مقدار #root.innerHTML رو می‌ذاریم روی HTML حل‌شده‌ای که serialize شده (تا DOM دقیقاً همونی باشه که hydration می‌بینه)
      • کل اپ رو داخل SSGDataProvider می‌پیچونیم تا کامپوننت‌ها تو اولین رندر به همون SSGData دسترسی داشته باشن
  3. یعنی i18n بلافاصله آماده باشه با تزریق مقدار ترجمه‌ها

    • آبجکت‌های ترجمه‌ای رو که حین SSR واقعاً استفاده شدن ثبت می‌کنیم و تو اسکریپت SSG می‌فرستیم.
    • سمت کلاینت، این‌ها رو مستقیم با متد مخصوص LocaleQueryer.inject() تو کش LocaleQueryer می‌ریزیم تا ترجمه‌ها همون لحظه در دسترس باشن.

و با این کار، اولین رندر سمت کلاینت دقیقاً همون دیتایی رو داره که 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;
}

این هوک حین SSR و تو اولین رندر سمت کلاینت (یعنی موقع hydration) مقدار true برمی‌گردونه و بعد از mount شدن، می‌شه false. کامپوننت‌هایی مثل UserBanner، Navbar و Dialog همین الان ازش استفاده می‌کنن تا از mismatch تو hydration جلوگیری کنن.

  1. Patch کردن React برای diffهای بهتر

امیدوار بودم بتونم مستقیم از hydration-overlay استفاده کنم. ولی این پروژه دیگه فعال نگه‌داری نمی‌شه، فقط تا React 18 رو پشتیبانی می‌کنه و برای محیط production هم آماده نبود. برای همین از یه LLM خواستم ریپو رو کلون کنه، ایده بگیره و تو چند دقیقه یه نسخه‌ی مینیمال از یه hydration overlay بسازه. چیز خاص و فانتزی‌ای نمی‌خواستم، فقط یه چیزی که حین توسعه ظاهر بشه تا بفهمم کجاها اوضاع به هم می‌ریزه.

این overlay جدید خیلی ساده‌ست، برای همین diffها کاملاً بی‌نقص نیستن. React کامنت‌ها رو برمی‌داره، بعد از style attributeها ; اضافه می‌کنه، فاصله‌ها رو عوض می‌کنه و چند تا ریزه‌کاری دیگه انجام می‌ده که overlay ما فعلاً حواسش بهشون نیست. ضمن این که overlay ما کامنت‌های HTML رو هم نشون می‌ده، در حالی که React موقع hydration کلاً نادیده‌شون می‌گیره.

<img alt="overlay جدید hydration ما" 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 و اولین رندر سمت کلاینت برای hydration 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" }} />

چند تا عدد و آمار

برای این که یه حسی از ابعاد این پیاده‌سازی بگیرید:

  • ۲ روز کار (از صفر تا SSG قابل استفاده). عملاً کمی بیشتر از ۲۴ ساعت، اون هم وسط تعطیلات.
  • ۴ روز کار برای این که hydration بدون race شرطی ترجمه‌های async یا خراب‌کاری useMediaQuery درست رفتار کنه.
  • ۱ روز اضافه برای این که رویکرد dehydration رو با جایگزینی Suspense boundary روی مسیر بحرانی عوض کنم (ساده‌تر، بایت کمتر، بدون فلش احتمالی).
  • حدود ۲۰۰ خط کد هسته‌ای برای تولید SSG (GenerateShellSsgFromSitemap.ts)
  • حدود ۱۲۰ خط کد برای حل Suspense boundaryها (resolveSuspenseBoundaries تو renderRoute.tsx) - نکته: بعداً این بخش رو با رویکرد critical path جایگزین کردم
  • حدود ۵۰ خط utility برای SSR (isSSRMode.ts)
  • حدود ۱۰۰ خط تست (renderRoute.test.ts)
  • حدود ۱۵۰ خط polyfill برای SSR (setupSSREnvironment)
  • تغییر خیلی کم روی کامپوننت‌های موجود (بیشتر اضافه کردن چک useIsSSRMode())

این راه‌حل هم سبک و جمع‌وجوره، هم نگه‌داریش راحته. نه نیاز به مهاجرت به یه فریم‌ورک جدید داره، نه سازگاریش با React SPA فعلی‌مون مشکلی داره.

چیزهایی که از این تجربه می‌شه برداشت کرد

بعضی وقت‌ها یه راه‌حل سفارشی واقعاً بهتره

هر مشکلی فریم‌ورک نمی‌خواد. برای Foony، یه راه‌حل کوچیک و سفارشی برای SSG انتخاب درست‌تری بود. چون:

  • سبک: بدون وابستگی‌های سنگین یا سربار فریم‌ورکی
  • قابل نگه‌داری: کد ساده‌ای که خودمون کامل می‌فهمیمش
  • منعطف: به‌راحتی می‌شه تغییرش داد و هر وقت خواستیم گسترش بدیم
  • سازگار: بدون هیچ مهاجرتی کنار React SPA فعلی‌مون کار می‌کنه

SSR استریمینگ React یه‌سری رفتار عجیب هم داره

renderToReadableStream تو React برای کار با Suspense چیز خوبیه، ولی یه‌سری رفتار عجیبه هم داره. حتی وقتی await stream.allReady رو صدا می‌زنید، باز هم تو خروجی Suspense boundary دارید. این باگ نیست، طراحی‌ش این‌طوریه چون برای استریم ساخته شده. ولی برای SSG ما به HTML کاملاً حل‌شده نیاز داریم. این که React یه راه تمیز برای این سناریو فراهم نکرده، یه کم ناامیدکننده‌ست.

راه‌حلی که من رفتم این بود که روی HTML یه پردازش اضافه انجام بدم و boundaryها رو خودم حل کنم. خیلی شیک و مجلسی نیست، اما برای کاری که من می‌خواستم هم سریع بود، هم به اندازه‌ی کافی انعطاف‌پذیر.

TDD می‌تونه برای کار با LLMها خیلی به درد بخوره

تبدیل HTML خیلی مستعد خطاست. یه باگ کوچیک می‌تونه کل خروجی SSG رو خراب کنه و تجربه‌ی کاربر نهایی رو بترکونه. برای همین از یه LLM خواستم (با ورودی و راهنمایی خودم) یه سری تست جامع بنویسه تا مطمئن بشم این تبدیل درست کار می‌کنه.

جمع‌بندی

الان SSG برای Foony راه افتاده. صفحه‌ها برای موتورهای جست‌وجو و LLMها کامل رندر می‌شن و خود راه‌حل هم سبک و قابل نگه‌داریه. Hydration برای روت‌های SSG بیشتر از چیزی که فکر می‌کردم طول کشید (۳ روز)، و یه روز اضافه هم صرف این شد که رویکرد اولیه‌ی dehydration رو با جایگزینی Suspense boundary روی مسیر بحرانی عوض کنم. رویکرد جدید هم نگه‌داریش ساده‌تره، هم بایت کمتری می‌فرسته، هم جلوی فلش‌های بصری ناشی از dehydrate/rehydrate کردن HTML رو می‌گیره.

هنوز هم برام جالبه که فقط ۲ روز طول کشید تا یه راه‌حل سفارشی برای SSG پیاده کنم. ولی خب، بعضی وقت‌ها واقعاً درست‌ترین راه‌حل همون ساده‌ترینیشه.

برای ادامه‌ی مسیر، احتمالاً باید تطبیق hydration رو کامل‌تر کنم و شاید React رو هم برای دیباگینگ بهتر یه کم patch کنم. ولی فعلاً Foony یه SSG کاملاً کارا داره. تو هفته‌های آینده حواسم به Google Search Console و Bing Webmaster Tools هست تا ببینم این تغییر چه تأثیری روی SEO ما می‌ذاره.

8 Ball Pool online multiplayer billiards icon