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 در React) و چطور آنها را حل کردم.

چرا راه‌حل‌های استاندارد نه؟

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

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

NextJS قدرتمند است، اما نیاز به مهاجرت عظیمی از SPA موجود React در Foony داشت. ما هزاران فایل، منطق پیچیده مسیریابی و کلی زیرساخت سفارشی داریم. مهاجرت به 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. همچنین اسکیمای ld+json و چند چیز کوچک SEO اعمال می‌کرد.

رویکرد ساده است:

  1. ساخت روی SPA موجود React: بدون نیاز به مهاجرت، فقط تولید 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

خب حالا 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"> یک placeholder است که محتوا باید آنجا قرار گیرد. <div hidden id="S:0"> حاوی محتوای رندرشده واقعی است. B:0 با S:0 بر اساس عدد (شاخص ۰-پایه) منطبق می‌شود.

بدون JavaScript، موتورهای جست‌وجو (با تو هستم Bing) و LLMها فقط یک صفحه تقریباً خالی با placeholder تمپلیت می‌بینند. این کل هدف SSG را زیر سؤال می‌برد!

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

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

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

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

این نوع "TDD" در واقع برای این مورد کاربرد بسیار مفید است که ورودی‌ها و خروجی‌های مورد انتظار را دارید.

این را با "TDD برای همه چیز چون Robert C. Martin گفت" اشتباه نگیرید (که سرعت توسعه تیم را کند می‌کند). شما نباید برای UI یا بخش‌هایی از کد خود که دائماً در حال تغییر است از TDD استفاده کنید!

راه‌حل: resolveSuspenseBoundaries

حالا که تست‌ها سر جای خود بودند، گذاشتم LLM تابع resolveSuspenseBoundaries را بنویسد. برای جلوگیری از شکنندگی RegEx از cheerio استفاده کردم، هرچند استفاده از 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 خود را تغییر دهد. شاید کد resolution مربوط به Suspense را حذف کنم وقتی راه‌حل بهتری برای صفحاتی داشته باشم که lazy-load می‌شوند (و در نتیجه به مرزهای Suspense نیاز دارند).

استراتژی Hydration (به‌روزرسانی: ۳ روز + ۱ روز اضافی طول کشید)

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

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

سخت‌ترین قسمت همین رسیدن به اولین hydrate حداقلی و کارا بود. وقتی موفق شدم یک "Hello World" را با navbar رندر کنم، اعتمادبه‌نفس پیدا کردم که بله، شاید این کار یک ماه کامل طول نکشد!

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

برای آن اولین hydrate حداقلی و کارا، یک چالش منحصربه‌فرد داشتم: می‌خواستم hydration داشته باشم، اما SEO خوبی هم برای موتورهای جست‌وجو و LLMها داشته باشم بدون اینکه توسعه‌دهندگان نیاز به فکر کردن درباره مرزهای Suspense داشته باشند.

چالش

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

در مورد ما، SSG این موضوع را به چند طریق بدتر کرد:

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

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

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

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

این رویکرد "dehydration" کار کرد، اما یک روز اضافی صرف کردم تا با راه‌حل بهتری جایگزینش کنم.

رویکرد بهتر: جایگزینی مرز Suspense در مسیر بحرانی

بعد از پیاده‌سازی اولیه، هنوز با مشکلاتی در مرزهای Suspense برخورد می‌کردم. آنجا بود که فهمیدم یک راه‌حل تمیزتر، بهتر و ساده‌تر وجود دارد. رویکرد dehydration را با جایگزینی مرز Suspense در مسیر بحرانی عوض کردم، که:

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

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

برای بقیه چیزها، اولین رندر کلاینت را وادار می‌کنیم که مانند SSR/SSG رفتار کند. یعنی استفاده از همان ورودی‌ها و در دسترس قرار دادن این ورودی‌ها به‌صورت همزمان قبل از hydrateRoot. این کار از طریق بسته‌بندی با "ssg-data" انجام می‌شود.

به‌طور مشخص، تنظیمات این بود:

  1. بسته‌بندی ورودی‌های SSR در یک text script واحد

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

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

    • شیءهای ترجمه واقعی که در طول SSR دسترسی پیدا کردیم را ثبت می‌کنیم و در اسکریپت SSG ارسال می‌کنیم.
    • در سمت کلاینت، آن‌ها را مستقیماً از طریق متد اختصاصی LocaleQueryer.inject() به cache 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 در حال حاضر از این برای جلوگیری از عدم تطابق hydration استفاده می‌کنند.

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

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

این overlay جدید بسیار پایه است، پس diffها کاملاً بی‌نقص نیستند. React کامنت‌ها را حذف می‌کند، بعد از ویژگی‌های style یک ; اضافه می‌کند، فاصله‌ها را تغییر می‌دهد و چند چیز کوچک دیگر که overlay ما (هنوز) آن‌ها را در نظر نمی‌گیرد. overlay ما همچنین کامنت‌های HTML را شامل می‌شود که React آن‌ها را برای hydration نادیده می‌گیرد.

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

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

نکات کلیدی

گاهی اوقات یک راه‌حل اختصاصی بهتر است

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

  • سبک: بدون وابستگی‌های سنگین یا سربار فریمورک
  • قابل نگهداری: کد ساده‌ای که می‌فهمیم
  • انعطاف‌پذیر: تغییر و توسعه آسان در صورت نیاز
  • سازگار: بدون مهاجرت با SPA موجود React ما کار می‌کند

SSR استریمینگ React نکته‌های خاصی دارد

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

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

TDD می‌تواند برای LLMها مفید باشد

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

نتیجه‌گیری

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

هنوز شوکه‌ام که فقط ۲ روز طول کشید تا یک راه‌حل اختصاصی برای SSG پیاده‌سازی کنم. اما گاهی اوقات راه‌حل درست ساده‌ترین آن‌هاست.

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

8 Ball Pool online multiplayer billiards icon