Skip to main content
العودة إلى المدونة
SolidJSSolidStartPerformanceSSGCode Splitting

التحميل الكسول لمحتوى المدونة في SolidStart دون فقدان الـ SSG

على الأرجح تشحن مدونتك متن كل مقالة في حزمة (bundle) واحدة — وقد بلغ حجم مدونتي 2.88 MB، وكان يُحمَّل حتى في صفحة الفهرس حيث لا تحتاج سوى العناوين. إليك كيف قسّمت نص كل مقالة إلى chunk كسول يُحمَّل عند الطلب، مع بقاء كل صفحة مُصيَّرة بالكامل على الخادم من أجل الـ SEO.

نُشر 5 أغسطس 20268 دقيقة قراءة

الاحتفاظ بالمحتوى داخل كود مُنمَّط أمر مريح — إلى أن تكبر الكومة بما يكفي لتضخيم ما يُنزّله كل زائر. إليك كيف قسّمت مدونة بحجم 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 — يحتفظ فقط بالبيانات الوصفية إضافةً إلى دالة تستورد النصف الثقيل:

posts/<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 — يحوي النص، ولا شيء يستورده استيرادًا ساكنًا:

posts/<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 / زيارة مباشرة / F5HTML مُصيَّر مسبقًا بالكامل، يُرطَّب من الحمولة المُسلسَلةلا يُحمَّل
زائر ينقر داخل التطبيق (تنقّل 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 وتحميل مسبق حسب النية يجعلانه يختفي.