background blurbackground mobile blur

1/1/1970

كيف نفّذتُ SSG في يومين

أهلاً! قبل سنة، كنتُ أظنّ أن هذا مستحيل. لكنني للتوّ انتهيتُ من تنفيذ توليد المواقع الثابتة (SSG) لـ Foony في يومين فقط، وأنا متحمّس جداً لذلك. هذه ليست محاولتي الأولى لحلّ مشكلة 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 دقيقة. ليس لديّ أرقام ملموسة لدعم ذلك سوى هذه المناقشة على GitHub منذ 5 سنوات)
  • الفريق بأكمله سيتعلّم شيئاً جديداً (NextJS)، وسرعة تطوير أبطأ إلى الأبد
  • نقل الشيفرة في كل مرة يقرّر فيها NextJS إجراء تغييرات تكسر التوافق.

حتى أنني جرّبتُ بداية خاطئة مع NextJS، لكنني أدركتُ بسرعة أن تكلفة الانتقال مرتفعة جداً. لم يكن التعقيد يستحقّ العناء.

Vike: تعقيد مماثل

Vike (المعروف سابقاً بـ vite-plugin-ssr) كان لديه مشاكل مماثلة. ورغم أنه أكثر مرونة من NextJS، إلا أنه كان سيتطلّب إعادة هيكلة كبيرة لقاعدة الشيفرة. منحنى التعلّم وجهد الانتقال لم يبرّرا الفوائد.

Astro: بنية خاطئة

Astro رائع للمواقع التي تركّز على المحتوى، لكن Foony منصّة ألعاب متعدّدة اللاعبين معقّدة. نحتاج إلى تحديثات في الوقت الفعلي، واتصالات WebSocket، ومكوّنات React ديناميكية. بنية Astro ببساطة لا تناسب ما نبنيه.

الحل: SSG مخصّص

بعد أن تشجّعتُ بنهج "SSG الزائف" الذي نفّذتُه قبل بضعة أيام بعد i18n، استقرّيتُ على حلّ صغير وخفيف ومخصّص لـ SSG في Foony.

نهجي في "SSG الزائف" تضمّن سحب محتوى المنشورات من الصفحات التي تحتوي منشورات (مسارات /posts وصفحات الألعاب)، ووضعها بالضبط حيث سيعرضها العميل، خصيصاً لمحرّكات البحث ونماذج اللغة الكبيرة لمساعدتها على فهم Foony. كما طبّق مخطّط ld+json وبعض الأمور البسيطة المتعلّقة بـ SEO.

النهج بسيط:

  1. البناء فوق React SPA الحالي: لا حاجة للانتقال، فقط أضف توليد SSG وقت البناء.
  2. استخدام renderToReadableStream: واجهة برمجة SSR التدفّقية في React 18 تتعامل مع Suspense بشكل أصلي.
  3. توليد ملفات HTML ثابتة: عرض المسارات مسبقاً وقت البناء وتقديمها كملفات ثابتة، باستخدام SitemapGenerator للحصول على قائمة المسارات.
  4. تغييرات بسيطة على قاعدة الشيفرة الحالية: معظم المكوّنات تعمل كما هي.

التنفيذ الأساسي موجود في client/src/generators/GenerateShellSsgFromSitemap.ts. يقرأ خريطة الموقع، ويعرض كل مسار باستخدام renderToReadableStream من React، ويكتب 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. تخميني هو أن السبب هو كونه "تدفّقاً"، ومُصمَّماً ليُمرَّر إلى العملاء أثناء استلام البايتات.

ما يُخرجه 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) ونماذج اللغة الكبيرة صفحة شبه فارغة بها مجرّد العنصر النائب. هذا يُلغي الغرض الكامل من SSG!

لم أرَ أي طريقة نظيفة لإزالة حدود Suspense هذه، لذا كان حلّي هو كتابة بعض الاختبارات ودالة resolveSuspenseBoundaries لاستبدالها. كان هذا أسرع من تحليل HTML وتنفيذ السكربت بشيء مثل JSDOM. والأهم من ذلك، كان متطلّباً لما خطّطتُ له: موقع لطيف وقابل للقراءة لمحرّكات البحث ونماذج اللغة الكبيرة بدون JavaScript، مع دعم لحدود Suspense والترطيب على العميل.

اختبار التحويل

بدأتُ بكتابة اختبارات للتحويل عن طريق الحصول على بعض الأمثلة في DOM ممّا كان لديّ (JavaScript معطّل)، وما أردتُه (JavaScript مُفعّل). أدخلتُها في نموذج لغة كبير وجعلتُه يتعامل مع توليد الاختبارات، وهو شيء يُجيده. هذه الاختبارات موجودة في client/src/generators/ssr/renderRoute.test.ts وتضمن أن التحويل يعمل بشكل صحيح. تُغطّي الاختبارات:

  • استبدال الحدود البسيط (قائمة المنشورات)
  • الحدود المعقّدة بمحتوى بين القالب والتعليق الختامي
  • حدود متعدّدة
  • حدود بدون علامات تعليق
  • الحالات الحدّية

هذا النوع من "TDD" مفيد جداً في حالة الاستخدام هذه حيث لديك مدخلات ومخرجات متوقّعة.

هذا يجب ألّا يُخلَط مع "TDD لكل شيء لأن Robert C. Martin قال ذلك" (والذي سيُبطّئ سرعة تطوير فريقك). يجب ألّا تستخدم TDD لواجهة المستخدم أو المناطق التي تتغيّر باستمرار في شيفرتك!

الحل: resolveSuspenseBoundaries

الآن بعد أن أصبحت الاختبارات في مكانها، جعلتُ نموذج اللغة الكبير يكتب دالة 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}; }

هذا يضمن أنه بدلاً من رؤية صفحة شبه فارغة، ترى محرّكات البحث ونماذج اللغة الكبيرة صفحة معروضة بالكامل.

والآن أصبح 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. قد أزيل شيفرة حلّ Suspense بمجرّد أن يكون لديّ حلّ أفضل للصفحات التي تُحمَّل بشكل كسول (وبالتالي تتطلّب حدود Suspense).

استراتيجية الترطيب (تحديث: استغرق هذا 3 أيام + يوم إضافي)

الترطيب صعب. كنتُ أعرف ذلك. لكن، بعد بعض العمل، تمكّنتُ من جعله يعمل!

إجمالي الوقت المستغرَق للترطيب: 3 أيام، بالإضافة إلى يوم إضافي لاستبدال نهج إزالة الترطيب.

كان الجزء الأصعب هو الحصول على أول ترطيب بسيط يعمل. بمجرّد أن تمكّنتُ من عرض "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 جيداً لمحرّكات البحث ونماذج اللغة الكبيرة دون أن يحتاج المطوّرون للتفكير في حدود 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 للحصول على مقارنات أفضل

كنتُ آمل أن أتمكّن من استخدام hydration-overlay فقط. لكنه ليس مُصاناً بنشاط، ومدعوم فقط حتى React 18، ولم يكن جاهزاً للإنتاج. لذا جعلتُ نموذج لغة كبيراً يُستنسخ المستودع للإلهام، ثم أنشأ تراكب ترطيب بسيطاً في بضع دقائق. لم أكن بحاجة إلى أي شيء فاخر، فقط شيء يظهر أثناء التطوير حتى أتمكّن من معرفة أين حدث الخطأ.

هذا التراكب الجديد بسيط جداً، لذا فإن المقارنات ليست مثالية تماماً. 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="مقارنة بين 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" }} />

بالأرقام

لإعطائك فكرة عمّا تضمّنه هذا التنفيذ:

  • يومان من العمل (من البداية إلى SSG يعمل). كان هذا بالكاد يزيد عن 24 ساعة أثناء العطلة.
  • 4 أيام من العمل لجعل الترطيب يعمل بشكل جيّد بدون سباقات ترجمة غير متزامنة أو useMediaQuery يُفسد الأمور.
  • يوم إضافي لاستبدال نهج إزالة الترطيب باستبدال حدود Suspense للمسار الحرج (أبسط، بايتات أقل، لا يوجد وميض محتمل).
  • ~200 سطر من شيفرة توليد SSG الأساسية (GenerateShellSsgFromSitemap.ts)
  • ~120 سطر من حلّ حدود Suspense (resolveSuspenseBoundaries في renderRoute.tsx). ملاحظة: تمّ استبدال هذا لاحقاً بنهج المسار الحرج
  • ~50 سطراً من أدوات SSR (isSSRMode.ts)
  • ~100 سطر من الاختبارات (renderRoute.test.ts)
  • ~150 سطراً من polyfills لـ SSR (setupSSREnvironment)
  • تغييرات بسيطة على المكوّنات الحالية (في الغالب إضافة فحوصات useIsSSRMode())

الحلّ خفيف وقابل للصيانة. لا يتطلّب انتقال إطار عمل، ويعمل مع تطبيق React SPA الحالي.

النقاط الرئيسية

أحياناً يكون الحلّ المخصّص أفضل

ليست كل مشكلة تحتاج إلى إطار عمل. بالنسبة لـ Foony، كان حلّ SSG الصغير المخصّص هو الخيار الصحيح. إنه:

  • خفيف: لا توجد تبعيّات ثقيلة أو حمل إطار عمل
  • قابل للصيانة: شيفرة بسيطة نفهمها
  • مرن: سهل التعديل والتوسيع حسب الحاجة
  • متوافق: يعمل مع تطبيق React SPA الحالي بدون انتقال

SSR التدفّقي في React له غرائبه

renderToReadableStream في React لطيف للتعامل مع Suspense، لكن له غرائبه. حتى مع await stream.allReady، لا تزال تحصل على حدود Suspense في المخرجات. هذه ليست علّة، بل هي بالتصميم للتدفّق. لكن بالنسبة لـ SSG، نحتاج إلى HTML محلول بالكامل. يبدو أنه إخفاق من فريق React في عدم التعامل مع هذا السيناريو بطريقة نظيفة.

كان حلّي هو معالجة HTML لاحقاً وحلّ الحدود. ليست جميلة، لكنها سريعة ومرنة بما يكفي لحالة الاستخدام لديّ.

TDD يمكن أن يكون مفيداً لنماذج اللغة الكبيرة

تحويل HTML عرضة للأخطاء. علّة صغيرة واحدة ويمكن أن تكسر مخرجات SSG بأكملها وتكسر تجربة المستخدم النهائي. جعلتُ نموذج لغة كبيراً يكتب اختبارات شاملة (بمساعدتي) لضمان أن التحويل يعمل بشكل صحيح.

الخاتمة

SSG يعمل الآن لـ Foony. الصفحات معروضة بالكامل لمحرّكات البحث ونماذج اللغة الكبيرة، والحلّ قابل للصيانة وخفيف. استغرق ترطيب مسارات SSG وقتاً أطول مما توقّعتُ (3 أيام)، وقضيتُ يوماً إضافياً لاستبدال نهج إزالة الترطيب الأوّلي باستبدال حدود Suspense للمسار الحرج. النهج الجديد أبسط في الصيانة، ويشحن بايتات أقل، ويمنع الوميض البصري المحتمل من إزالة/إعادة ترطيب HTML.

لا أزال مصدوماً من أن الأمر استغرق يومين فقط لتنفيذ حلّ مخصّص لـ SSG. لكن أحياناً يكون الحلّ الصحيح هو الأبسط.

العمل المستقبلي يشمل إكمال مطابقة الترطيب وربما تصحيح React لتصحيح أخطاء أفضل. لكن في الوقت الحالي، لدى Foony SSG يعمل. سأُراقب Google Search Console وBing Webmaster Tools خلال الأسابيع القادمة لأرى التأثير الذي سيُحدثه هذا على SEO لدينا.

8 Ball Pool online multiplayer billiards icon