التحميل الكسول لمحتوى المدونة في SolidStart دون فقدان الـ SSG
على الأرجح تشحن مدونتك متن كل مقالة في حزمة (bundle) واحدة — وقد بلغ حجم مدونتي 2.88 MB، وكان يُحمَّل حتى في صفحة الفهرس حيث لا تحتاج سوى العناوين. إليك كيف قسّمت نص كل مقالة إلى chunk كسول يُحمَّل عند الطلب، مع بقاء كل صفحة مُصيَّرة بالكامل على الخادم من أجل الـ SEO.
الاحتفاظ بالمحتوى داخل كود مُنمَّط أمر مريح — إلى أن تكبر الكومة بما يكفي لتضخيم ما يُنزّله كل زائر. إليك كيف قسّمت مدونة بحجم 2.88 MB إلى chunk منفصل لكل مقالة في SolidStart، دون التخلي عن صفحة واحدة مُصيَّرة على الخادم.
chunk واحد ليحكمها جميعًا
كان سِجِل مدونتي يفعل الأمر البديهي: يستورد كل مقالة استيرادًا ساكنًا (static)، ويضعها في مصفوفة، ويصدّر بعض الدوال المساعدة.
import { postA } from "./posts/a";
import { postB } from "./posts/b";
// …19 of these
const allPosts: BlogPost[] = [postA, postB, /* … */];كان كل BlogPost يحمل متنه الكامل — ContentBlock[] — لجميع اللغات الـ 13. الاستيرادات الساكنة تعني أن المُجمِّع (bundler) يسحب كل ذلك إلى chunk واحد. والنتيجة:
blog-C81oI990.js 2,880 kB │ gzip: 942 kBكانت هذه الـ 2.88 MB تُشحَن في كل صفحة من المدونة — بما في ذلك الفهرس، حيث لا يُصيَّر سوى قائمة بالعناوين والأوصاف. كان كل زائر يُنزّل نصوص المقالات الـ 19 كلها، بـ 13 لغة، ليقرأ واحدة.
التقسيم: البيانات الوصفية في الحزمة، والنص على حدة
الحل هو فصل ما تحتاجه القائمة (رخيص) عمّا تحتاجه مقالة واحدة (مكلف). صار كل ملف مقالة ملفين.
النصف الخفيف — <slug>.ts — يحتفظ فقط بالبيانات الوصفية إضافةً إلى دالة تستورد النصف الثقيل:
import type { BlogMeta } from "../types";
export const fitText: BlogMeta = {
slug: "fit-text-to-container-pure-css",
date: "2026-07-24",
readingTime: 11,
tags: ["CSS", "Container Queries", "Typography"],
translations: {
en: { title: "Fit text to its container…", description: "…" },
// …12 more locales, title + description only
},
loadContent: () => import("./fit-text-to-container-pure-css.content"),
};النصف الثقيل — <slug>.content.ts — يحوي النص، ولا شيء يستورده استيرادًا ساكنًا:
import type { ContentBlock } from "../types";
import type { Language } from "~/i18n/languages";
export const content: Record<Language, ContentBlock[]> = {
en: [ /* the whole article, as blocks */ ],
// …12 more locales
};النوع BlogMeta هو العقد الذي يلصقهما معًا:
export interface BlogMeta {
slug: string;
date: string;
readingTime: number;
tags: string[];
translations: Record<Language, { title: string; description: string }>;
loadContent: () => Promise<{ content: Record<Language, ContentBlock[]> }>;
}بما أن loadContent عبارة عن import() ديناميكي، يمنح المُجمِّع متن كل مقالة chunk خاصًا بها، لا يُحمَّل إلا عندما يستدعيه أحد.
السِّجِل: ملخصات متزامنة، ومحتوى غير متزامن
الآن لا يستورد السِّجِل سوى الأنصاف الخفيفة، ويوفّر نوعين من الوصول — بيانات وصفية متزامنة، ومتن غير متزامن:
export function getPostSummary(slug: string, lang: Language) {
const meta = metas.find((p) => p.slug === slug);
return meta && { ...meta, localized: meta.translations[lang] };
}
export async function getPostContent(slug: string, lang: Language) {
const meta = metas.find((p) => p.slug === slug);
if (!meta) return undefined;
const mod = await meta.loadContent(); // ← the lazy chunk loads here
return mod.content[lang] ?? mod.content.en;
}صفحة الفهرس والبطاقات تستدعي getPostSummary/getAllPosts ولا تلمس متنًا أبدًا. صفحة المقالة وحدها تلجأ إلى getPostContent.
الصفحة: الـ SEO من الملخص، والمتن من createAsync
مسار المقالة يُصيِّر بسرعتين. الترويسة والعنوان والوسوم وكل وسم <meta>/JSON-LD تأتي من الملخص — متزامنة، حاضرة دائمًا. أما المتن فيأتي من createAsync الذي يُحمِّل الـ chunk:
const post = () => getPostSummary(params.slug, lang());
const content = createAsync(() => getPostContent(params.slug, lang()));
return (
<Show when={post()} keyed>
{(p) => (
<article>
<PageSeo customTitle={p.localized.title} /* …sync SEO… */ />
<header>{/* title, tags, date — sync */}</header>
<Suspense fallback={<Skeleton minutes={p.readingTime} />}>
<Show when={content()}>
{(blocks) => <BlogPostRenderer content={blocks()} />}
</Show>
</Suspense>
</article>
)}
</Show>
);الجزء الذي يخطئ فيه الجميع: هل ما زال هذا SSG؟
إليك السؤال الذي يوقف الناس: هناك fallback خاص بـ Suspense — أفلن يفهرس Google الهيكل (skeleton) بدل محتواي؟
لا. والسبب يستحق الفهم، لأنه العارضة الحاملة كلها.
Suspense لا يُظهر الـ fallback الخاص به إلا ما دام غير المتزامن معلَّقًا (pending) — والتعليق لا يحدث إلا حيث لا تكون البيانات جاهزة بعد. وأثناء التصيير المسبق (prerender)، لا يكون ذلك صحيحًا أبدًا:
- على الخادم، ينتظر SolidStart المورد قبل أن يُصيِّر. وحين يُنتَج الـ HTML يكون
content()قد حُلّ (resolved) بالفعل، فلا يُسلَك فرع الـ fallback أبدًا. الملف الساكن يحتوي المقالة كاملة. - ثم يُسلسِل SolidStart القيمة المحلولة داخل حمولة الترطيب (hydration payload). وعند زيارة مباشرة يستأنف العميل المورد من تلك الحمولة مباشرةً — فهو لا يُعيد تشغيل
getPostContent، ومن ثمّ لا يُنزّل حتى chunk المحتوى.
يمكنك إثبات ذلك على ناتج البناء. نفّذ grep على مقالة مُصيَّرة مسبقًا:
skeleton markers (aria-busy, animate-pulse, "Loading"): 0
article code blocks (<pre>/<code>): 11
reference to the .content chunk in the HTML: noneصفر هيكل. متن كامل. chunk المحتوى ليس مرتبطًا أصلًا — النص يسافر داخل الـ DOM وداخل سكربت الترطيب المُسلسَل بحجم ~20 KB.
الهيكل مجرد مؤشر تحميل (spinner). الخادم لا يشحن مؤشر تحميل أبدًا — بل يحتجز الاستجابة حتى تجهز البيانات، ثم يشحن الصفحة المكتملة. والزواحف (crawlers) تحصل على الصفحة المكتملة.
مساران، وواحد فقط غير متزامن
التقسيم لا يُضعف الـ SSG؛ بل يضيف طبقة code-splitting فوقه. هناك مساران متمايزان، والـ chunk الكسول لا يهم إلا لأحدهما:
| مَن | ماذا يحصل عليه | chunk المحتوى؟ |
|---|---|---|
| Google / زيارة مباشرة / F5 | HTML مُصيَّر مسبقًا بالكامل، يُرطَّب من الحمولة المُسلسَلة | لا يُحمَّل |
| زائر ينقر داخل التطبيق (تنقّل SPA) | المسار يُصيَّر على العميل، وcreateAsync يُطلق import() | يُحمَّل عند الطلب |
الكلفة غير المتزامنة يدفعها فقط من هو داخل التطبيق المُحمَّل أصلًا ويتنقل بين الصفحات — أي بالضبط من يستفيد من حزمة الـ 108 KB.
جعل غير المتزامن غير مرئي
لهذا المسار الوحيد من تنقّل الـ SPA، لمستان تزيلان الحدّ الفاصل.
اجعل حجم الهيكل على قدر المقالة. لحظة ظهور الهيكل لا يكون النص محمَّلًا (وهذا هو بيت القصيد)، فإشارة الطول الوحيدة المتاحة لك بشكل متزامن هي readingTime. صيِّر مجموعة فقرات لكل دقيقة كي تحجز المقالة القصيرة قليلًا والطويلة أكثر — فيتوقف التذييل (footer) عن القفز:
<For each={Array.from({ length: Math.min(Math.max(minutes, 3), 12) })}>
{() => <ParagraphGroup />}
</For>حمِّل مسبقًا حسب النية. سخِّن الـ chunk في اللحظة التي يحطّ فيها المؤشر أو لوحة المفاتيح على بطاقة، كي تلتقي النقرة باستيراد في الجو (أو من الكاش) بدل استيراد بارد:
const preload = () => preloadPostContent(props.post.slug);
<A href={href} onMouseEnter={preload} onFocus={preload} onTouchStart={preload}>كاش «وعد واحد لكل slug» يجعل التحميل المسبق والنقرة التي تليه يتشاركان الـ import() نفسه، فلا يُحمَّل الـ chunk مرتين أبدًا. ومع التحميل المسبق يصير الهيكل شبكة أمان لحالة فشل الكاش النادرة (شبكة بطيئة، لمسة دون hover) لا الحالة المعتادة.
النتائج
before after
main blog chunk 2,880 kB 108 kB (-96%)
post body in the chunk 19 lazy chunks, ~100-280 kB each
prerendered pages 247 247 (unchanged)الفهرس وكل صفحة ليست مقالة تشحن الآن 108 KB بدل 2.88 MB. افتح مقالة مباشرةً وستحصل على HTML مُصيَّر بالكامل على الخادم، دون أن يُطلَب chunk المحتوى إطلاقًا. تنقّل إليها داخل التطبيق فيُحمَّل متنها — وغالبًا يكون محمَّلًا مسبقًا سلفًا.
الخلاصة
- قسِّم المحتوى وفق ما يحتاجه كل عرض فعلًا: القوائم تريد البيانات الوصفية، والمقالة تريد متنها. لا تجعل الفهرس يدفع ثمن 19 مقالة.
import()ديناميكي خلفloadContent()مُنمَّط هو كل ما يلزم لمنح كل مقالة chunk خاصًا بها.- التحميل الكسول والـ SSG ليسا في تعارض.
createAsyncيُحَلّ على الخادم ويُسلسَل في حمولة الترطيب، فيبقى الـ HTML المُصيَّر مسبقًا كاملًا ولا يُشحن الـ fallback أبدًا. - الحدّ الفاصل المتبقي — التنقّل على العميل — مسألة UX لا SEO: هيكل بحجم
readingTimeوتحميل مسبق حسب النية يجعلانه يختفي.