

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 إطار قوي، لكنه كان سيتطلّب عمليّة ترحيل هائلة لتطبيق React SPA الموجود حاليًا في Foony. لدينا آلاف الملفات، ومنطق تمرير مسارات (routing) معقّد، والكثير من البنية التحتيّة المخصّصة. الترحيل إلى NextJS كان سيعني:
- إعادة كتابة نظام التوجيه (routing) بالكامل
- إعادة تنظيم طريقة تحميل الألعاب والمكوّنات
- شهور من العمل فقط لنعود إلى نفس مستوى الميزات الموجودة حاليًا
- تغييرات خطِرة قد تكسر تجربة المستخدمين
- تغيير الطريقة التي نتعامل بها مع الصور
- زمن بناء أبطأ بكثير (ربما بين 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وصفحات الألعاب)، ووضعه بالضبط في المكان الذي سيقوم العميل بعرضه فيه، خصيصًا لمحركات البحث ونماذج اللغة الكبيرة (LLMs) حتى تفهم Foony بشكل أفضل. كما كانت تضيف مخطّط ld+json وبعض تعديلات SEO الصغيرة.
الطريقة بسيطة:
- البناء فوق تطبيق React SPA الحالي: لا نحتاج أي ترحيل، فقط نضيف توليد SSG وقت البناء.
- استخدام
renderToReadableStream: واجهة React 18 للبثّ في SSR تتعامل مع Suspense تلقائيًا. - توليد ملفات HTML ثابتة: نقوم بتوليد المسارات مسبقًا وقت البناء وتقديمها كملفات ثابتة، باستخدام
SitemapGeneratorعندنا للحصول على قائمة المسارات. - تغييرات قليلة جدًا على الشيفرة الحالية: أغلب المكوّنات تعمل كما هي.
التنفيذ الأساسي يعيش في client/src/generators/GenerateShellSsgFromSitemap.ts. يقرأ ملف خريطة الموقع، ويولِّد كل مسار باستخدام renderToReadableStream من React، ثم يكتب الـ 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. أظن أن السبب هو أنه تدفّق (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).
من دون JavaScript ستشاهد محركات البحث (أقصدك أنت يا Bing) ونماذج اللغة الكبيرة صفحة شبه فارغة فيها فقط مكان القالب الفارغ. بهذا نكون قد دمّرنا الهدف بأكمله من SSG!
لم أجد طريقة أنيقة لحذف حدود Suspense هذه، فقررت أن أكتب بعض الاختبارات ودالّة اسمها resolveSuspenseBoundaries تستبدلها مباشرة. كان هذا أسرع من تحليل الـ HTML وتشغيل السكربت باستخدام شيء مثل JSDOM. والأهم من ذلك أنه كان ضروريًا للخطة التي أردتها: موقع لطيف وسهل القراءة لمحركات البحث / نماذج اللغة الكبيرة من دون JavaScript، مع المحافظة في الوقت نفسه على دعم حدود Suspense وعمليّة hydration على جهة العميل.
اختبار عملية التحويل
بدأت بكتابة اختبارات للتحويل، عن طريق التقاط أمثلة من DOM لما هو موجود فعليًا (مع تعطيل JavaScript)، وما أريده أن يكون (مع تفعيل JavaScript). أدخلت هذه الأمثلة في نموذج لغة كبير ودعوتُه يتولّى توليد الاختبارات، وهو مجال يجيده نوعًا ما.
تعيش هذه الاختبارات في client/src/generators/ssr/renderRoute.test.ts، وتضمن أن عملية التحويل تعمل كما ينبغي. تغطي هذه الاختبارات الحالات التالية:
- استبدال بسيط للحدود (قائمة التدوينات)
- حدود معقّدة مع وجود محتوى بين عنصر القالب والتعليق الختامي
- وجود حدود متعددة
- حدود من دون تعليقات تحدّد البداية والنهاية
- الحالات الطرفية (edge cases)
هذا النوع من تطبيق "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 لتدوينات 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 (وبالتالي تحتاج إلى حدود Suspense).
استراتيجية الـ Hydration (تحديث: استغرقت 3 أيام + يوم إضافي)
عملية hydration ليست سهلة، وكنت أعرف ذلك. لكن بعد بعض العمل تمكّنت في النهاية من تشغيلها!
إجمالي الوقت الذي استغرقته الـ hydration: 3 أيام، مع يوم إضافي لاستبدال أسلوب الـ dehydration.
أصعب جزء كان الوصول إلى أول نسخة مصغّرة تعمل من الـ hydration. بمجرد أن نجحت في عرض "Hello World" مع شريط التنقل (navbar)، شعرت بالثقة أن الأمر لن يستغرق شهرًا كاملًا بعد الآن!
<img alt="شاشة Hello World في Foony تعمل مع الـ hydration وشريط التنقل" 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 كان لدي تحدٍّ من نوع خاص: أردت أن أحصل على hydration، لكنني في الوقت نفسه أردت SEO جيّدًا لمحركات البحث ونماذج اللغة الكبيرة من دون أن يضطر المطوّرون للتفكير في حدود Suspense على الإطلاق.
التحدّي
عملية hydration في React حرفيّة جدًا: إذا لم يكن الـ DOM مطابقًا تمامًا لما يتوقّعه React في أول عرض، ستحصل على رسالة خطأ لطيفة ولكن شبه عديمة الفائدة في وحدة التحكم، ثم يقوم React برمي كل شيء وإعادة البناء من الصفر. حتى إنه لا يعطيك diff يخبرك بما الذي تعطل!
في حالتنا، جعل SSG هذا أسوأ بطريقتين أو ثلاث:
- كنّا نعالج الـ HTML بعد توليده لإزالة / حلّ بقايا Suspense الخاصة ببث React 18 (وهذا رائع للروبوتات).
- جهة العميل لم يكن لديها دائمًا نفس البيانات الدقيقة المتاحة في الزمن (t = 0) كما حصل أثناء توليد الخادم (بيانات SSG، بيانات الميتا للتدوينات، إلى آخره).
- نظام i18n عندنا يعمل بطريقة "lazy" بشكل افتراضي، وهذا يعني أن الترجمات قد تكون غير جاهزة في أول عرض، ما لم تسجّل مسبقًا الترجمات التي استُستخدمت لـ SSG وتحقنها قبل أن يبدأ React بالرسم.
ما الذي نجح (المقاربة الأولى: Dehydration)
في البداية جرّبت حيلة ذكيّة ولطيفة: استخدمت نمط الأوامر (command pattern) لتسجيل الأوامر التي أستخدمها لحلّ حدود Suspense في الـ HTML، ثم أرجعت أوامر تحويل عكسيّة حتى أتمكّن من إعادة الـ HTML إلى الشكل الذي يحتاجه React لعملية hydration.
كنت آمل أن أستطيع بهذا الأسلوب أن أرسل عدد بايتات أقل بكثير في ملف index.html. لكن، مثل أغلب الحلول "الذكيّة"، فشل هذا لأن المتصفّحات تعدّل الـ HTML بطرق بسيطة لكن مزعجة، مثل إزالة أو إضافة ; أو /، وهذا كان يفسد تمامًا مواقع الاستبدال التي أعتمد عليها.
من الناحية النظرية يمكنك ربما التعامل مع هذه التغييرات الطفيفة التي يقوم بها المتصفّح، لكنني لم أكن مستعدًا إطلاقًا لطرح حل هش إلى هذا الحد في الإنتاج.
بدلًا من محاولة "عكس" تحويل حدود Suspense وإعادته إلى صيغة البث الخاصة بـ React، فعلت شيئًا بسيطًا جدًا:
تجميع نسخة الـ HTML الأصلية (من دون حلّ لحدود Suspense) داخل عنصر <script type="text">.
هذه المقاربة التي أسميتها "dehydration" عملت بالفعل، لكنني قضيت يومًا إضافيًا في استبدالها بحل أفضل.
المقاربة الأفضل: استبدال حدود Suspense في المسار الحرج
بعد التنفيذ الأوّل ما زلت أواجه بعض المشاكل مع حدود Suspense. عندها أدركت أن هناك حلًا أنظف وأبسط وأفضل. استبدلت أسلوب الـ dehydration بـ استبدال حدود Suspense في المسار الحرج (critical path)، والذي:
- يحمّل المسار الحرج قبل الـ hydration: المكوّنات التي تمّ تحميلها مسبقًا أثناء SSR يُعاد تحديدها وتحميلها على جهة العميل قبل استدعاء
hydrateRoot.
- أسهل في الصيانة: لا حاجة للتعامل مع تفاصيل React الداخلية أو تحليل AST (بينما كان أسلوب الـ dehydration يحتاج لتحليل الـ HTML وإعادته).
- يرسل عددًا أقل من البايتات: لم نعد نحتاج إلى تضمين الاستجابة الأصلية لـ SSR من React داخل وسم script.
- يمنع الوميض المحتمل: لا حاجة لتجفيف (dehydrate) / إعادة ترطيب الـ HTML، وبذلك نتجنب أي وميض بصري محتمل.
التنفيذ يقوم بتتبع المكوّنات الكسولة (lazy) التي جرى تحميلها مسبقًا أثناء SSR (عن طريق SSRLazyComponentTracker)، ويضمّن مسارات استيرادها داخل بيانات الـ hydration، ثم يقوم بتحميلها بشكل متزامن قبل بدء الـ hydration. مكوّنات المسار الحرج تُعرَض مباشرة من دون حدود Suspense، لتطابق ناتج SSR حرفيًا.
أما باقي الأجزاء، فنحن نجعل أول عرض على جهة العميل يعمل كما لو كان SSR/SSG. هذا يعني استخدام نفس المدخلات، وجعل هذه المدخلات متاحة بشكل متزامن قبل استدعاء hydrateRoot. نحقق هذا عن طريق تجميعها في ما نسمّيه "ssg-data".
عمليًا، التعديلات كانت كالتالي:
تجميع مدخلات SSR في سكربت نصي واحد
- أثناء SSG نُدرِج عنصر
<script type="text/foony-ssg" id="foony-ssg-data">...</script> مباشرة قبل نقطة دخول وحدات Vite.
- يحتوي هذا السكربت على:
html: كود الـ HTML بعد حلّه الذي نُرسله فعليًا في الملف الثابت
ssgData: نسخة مسلسلة من SSGData التي يستخدمها غلاف SSR. أخطّط لاحقًا لتحويلها إلى Proxy أو شيء مشابه حتى نضمّن البيانات التي تم الوصول إليها فقط.
translationData: كتل من أزواج المفتاح/القيمة للترجمات التي لمسناها أثناء SSR
حقن هذه المدخلات مباشرة قبل الـ hydration
- في
main.tsx نقوم بشكل متزامن بـ:
- ضبط
#root.innerHTML على نسخة الـ HTML المحلولة والمسلسلة (حتى يكون الـ DOM مطابقًا تمامًا لما ستراه عملية hydration)
- تغليف التطبيق داخل
SSGDataProvider حتى تحصل المكوّنات على نفس SSGData في أول عرض
جعل i18n فوريًا عن طريق حقن قيَم الترجمات
- نسجّل كائنات الترجمة التي تم الوصول إليها فعليًا أثناء SSR ونرسلها داخل سكربت SSG.
- على جهة العميل نحقن هذه البيانات مباشرة في كاش
LocaleQueryer عبر دالّة مخصّصة LocaleQueryer.inject(), حتى تكون الترجمات متاحة فورًا.
وبهذا يصبح لدى أول عرض على جهة العميل نفس البيانات التي كانت لدى SSR!
useIsSSRMode() hook موجود مسبقًا في 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;
}
هذا الـ hook يعيد القيمة true أثناء SSR وفي أول عرض على جهة العميل (أثناء الـ hydration)، ثم يتحوّل إلى false بعد إتمام التركيب (mount). مكوّنات مثل UserBanner وNavbar وDialog تستخدمه بالفعل لمنع عدم التطابق أثناء الـ hydration.
- ترقيع React للحصول على فروقات أفضل
كنت آمل أن أستطيع ببساطة استخدام hydration-overlay. لكنه لم يعد مُدارًا بنشاط، ويدعم فقط حتى React 18، ولم يكن جاهزًا للإنتاج. لذلك جعلت نموذجًا لغويًا ينسخ المستودع للاستلهام، ثم أنشأ طبقة بسيطة جدًا لـ hydration خلال بضع دقائق. لم أكن بحاجة لشيء متقدّم، فقط أداة تظهر أثناء التطوير وتخبرني أين حدث الخطأ.
هذه الطبقة الجديدة بسيطة للغاية، لذلك الفروقات ليست مثالية تمامًا. React يزيل التعليقات، ويضيف ; بعد خصائص style، ويعدّل المسافات، ويقوم ببعض التغييرات الصغيرة الأخرى التي لا تأخذها طبقتنا في الحسبان (حتى الآن). كما أن طبقتنا تشمل تعليقات HTML التي يتجاهلها React أثناء الـ hydration.
لكنها كافية تمامًا لمعرفة ما الذي يحتاج إلى إصلاح.
<img alt="طبقة الـ 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 وأول عرض على جهة العميل في React hydration" 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 أيام من العمل لجعل الـ hydration يعمل بسلاسة من دون مشاكل سباق (race) في الترجمات غير المتزامنة أو أن يعبث
useMediaQuery بالنتيجة.
- يوم إضافي واحد لاستبدال أسلوب الـ dehydration باستبدال حدود 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. الصفحات تُولَّد بالكامل لمحركات البحث ونماذج اللغة الكبيرة، والحل نفسه سهل الصيانة وخفيف. عملية الـ hydration لمسارات SSG استغرقت وقتًا أطول مما توقّعت (3 أيام)، ثم قضيت يومًا إضافيًا في استبدال أسلوب الـ dehydration الأوّل باستبدال حدود Suspense في المسار الحرج. الأسلوب الجديد أبسط في الصيانة، ويرسل عددًا أقل من البايتات، ويمنع أي ومضات بصرية محتملة ناتجة عن تجفيف / إعادة ترطيب الـ HTML.
ما زلت مندهشًا أن تنفيذ حل مخصّص لـ SSG استغرق يومين فقط. لكن أحيانًا يكون الحل الصحيح هو ببساطة الحل الأبسط.
العمل المستقبلي يشمل إكمال مطابقة الـ hydration وربما ترقيع React للحصول على تجربة تصحيح (debugging) أفضل. لكن في الوقت الحالي لدى Foony نظام SSG يعمل بالفعل. سأتابع Google Search Console وأدوات مشرفي المواقع من Bing خلال الأسابيع المقبلة لأرى تأثير كل هذا على الـ SEO عندنا.