Skip to main content
العودة إلى المدونة
Next.jsPerformanceCode SplittingTurbopackReact Server Components

next/dynamic لن ينقذك: لماذا تُرسل صفحات Next.js المدارة عبر CMS كل حزمة (chunk)؟

كانت صفحات Next.js لدينا، المدارة عبر CMS، تُرسل نحو 100 مكوّن قسم على كل مسار — رغم استخدام next/dynamic بشكل مثالي وفق الكتاب. استنفدنا كل إصلاح على مستوى المُجمِّع (import() المباشر، وتقسيم الملفات، وإعداد Turbopack، و splitChunks في webpack) قبل أن نكتشف السبب الحقيقي: إمكانية الوصول (reachability)، لا التقطيع إلى حزم (chunking). أدى توليد الكود في وقت البناء مع rewrites إلى خفض JavaScript التحميل الأول بنسبة 53%.

نُشر 18 يوليو 202614 دقيقة قراءة

كان لدينا موقع إنتاجي على Next.js 16 يضم نحو 700 صفحة هبوط مُدارة عبر CMS. تُبنى كل صفحة من «أقسام» — hero و FAQ والمراجعات والتسعير وغيرها — أي قرابة 100 مكوّن React موزَّعة على 38 نوعًا من الأقسام. تعرض الصفحة النموذجية بين 10 و15 منها. كانت الصفحة الرئيسية تُرسل 5.3 MB من JavaScript التحميل الأول (first-load). وحين نبشنا لنعرف أين يذهب كل ذلك، وجدنا حزمة (chunk) واحدة بحجم 1.2 MB تحوي شيفرة 108 أقسام — على صفحة لا تعرض سوى 15.

كان كل قسم مُغلَّفًا بالفعل داخل next/dynamic. بدا تقسيم الشيفرة صحيحًا كما في الكتاب المدرسي. ومع ذلك كان المُجمِّع يُرسل كل شيء في كل مكان. أمضينا أيامًا نجرّب كل رافعة موثَّقة — الاستيرادات الديناميكية، و import() المباشر، وإعادة تنظيم الملفات، وإعداد المُجمِّع، بل حتى تبديل المُجمِّع نفسه — وأخفق كلٌّ منها للسبب غير البديهي ذاته. تمرّ هذه المقالة على كل طريق مسدود بأرقام حقيقية، وتشرح السبب الجذري الفعلي، وتُظهر الحل الذي قصّ 53% من JavaScript التحميل الأول من دون المساس بأي مكوّن قسم واحد.

الخلاصة السريعة: على المسارات الديناميكية المدارة عبر CMS، ليس تقسيم الشيفرة مشكلة تقطيع إلى حزم — بل مشكلة إمكانية وصول (reachability). لا يُصلحها أي علَم (flag) في المُجمِّع. ما يُصلحها هو نقل معرفة «أي المكوّنات تستخدمها هذه الصفحة» من وقت التشغيل إلى وقت البناء.

البنية (التي على الأرجح لديك أنت أيضًا)

الإعداد هو المعياري لأي أداة بناء صفحات: يخزّن الـ CMS الصفحة كقائمة مرتَّبة من مراجع الأقسام، ولدى التطبيق سجلّ مركزي يربط أسماء الأقسام بالمكوّنات. كل مدخل مُغلَّف بـ next/dynamic، تمامًا كما توصي الوثائق:

sectionRegistry.tsx
// One central registry: every section variant wrapped in next/dynamic
export const sectionRegistry = {
  'sections.hero': {
    concept_1: dynamic(() => import('./sections/Hero/HeroV1')),
    concept_2: dynamic(() => import('./sections/Hero/HeroV2')),
    // ...10 more hero variants
  },
  'sections.faq': {
    concept_1: dynamic(() => import('./sections/Faq/FaqV1')),
  },
  // ...38 section types, ~100 variants total
}

يحلّ مكوّنٌ خادمي (server component) كل قسم بالاسم وقت العرض ثم يعرضه. الصفحات هي SSG مع ISR، لذا يجري هذا كله على الخادم — لا يتلقّى العميل سوى HTML إضافةً إلى الحزم اللازمة للترطيب (hydration):

SectionRenderer.tsx
// Server component: picks ONE section by name from CMS data
export async function SectionRenderer({ name, concept, id }) {
  const data = await fetchSectionData(name, id)
  const Component = sectionRegistry[name].concepts[concept]
  return <Component {...data} />
}

هذا التصميم جيد فعلًا: يؤلّف المسوّقون الصفحات في الـ CMS دون عمليات نشر، ويخدم سجلٌّ واحد 700 صفحة، وكل صيغة قسم كسولة (lazy) على نحو مستقل. على الورق. أما الحزمة فقد روت قصة مختلفة.

القياس بأمانة (سيكذب عليك DevTools)

قبل أي إصلاح، احتجنا إلى قياس نثق به. ثلاثة أمور تُفسِد قياسات Network الساذجة في DevTools:

  • المجاميع في الشريط السفلي تراكمية طوال مدة تسجيل اللوحة. بعد ثوانٍ قليلة من التحميل، يبدأ الجلب المسبق (prefetch) للروابط في إطار العمل بسحب حزم مساراتٍ أخرى في الخلفية — وعلى تبويب خامل تتقارب المجاميع نحو «كل شيء في نهاية المطاف»، فتُخفي أي مكسب في التحميل الأول.
  • تحقن إضافات المتصفح ميغابايتات من سكربتاتها الخاصة داخل قياسك — حتى في وضع التصفّح المتخفّي إن سُمح لها هناك. رأينا إضافةً واحدة تضيف 2 MB من «JS الصفحة».
  • الصفوف المُخدَّمة من الذاكرة المؤقتة ((disk cache) / (memory cache)) تنقل صفر بايت عبر الشبكة، لذا فإن إعادة تحميل دافئة (warm reload) لا تقيس شيئًا على الإطلاق.

الرقم الذي يهم فعلًا — وهو الذي تستجيب له Core Web Vitals — هو مجموعة السكربتات الأولية (initial script set): وسوم <script> في الـ HTML المعروض من الخادم. هذا ما يحجب الترطيب. وهو أيضًا قابل للأتمتة بالسكربت ببساطة:

// Count the REAL first-load JS: the <script> set of the prerendered HTML.
// (DevTools totals lie — more on that below.)
const html = fs.readFileSync('.next/server/app/en.html', 'utf8')
const scripts = [...new Set(
  [...html.matchAll(/<script src="([^"?]+\.js)[^"]*"/g)].map(m => m[1])
)]
let bytes = 0
for (const src of scripts) bytes += fs.statSync('.next' + src).size
console.log(scripts.length, 'chunks,', (bytes / 1024).toFixed(0), 'KB')

من أجل الإسناد لكل وحدة (per-module)، فعّلنا مؤقتًا productionBrowserSourceMaps ونسبنا كل بايت مُولَّد إلى وحدته المصدرية عبر مُحلِّل VLQ صغير. مطبٌّ واحد يجدر معرفته: يُسمّي Turbopack ملف .map لكل حزمة بهاش مختلف عن هاش .js — اقرأ تعليق sourceMappingURL من ذيل الحزمة بدلًا من تخمين js + '.map'.

خمسة طرق مسدودة (كي لا تكرّرها)

تحقّقنا من كل مما يلي عبر بناء إنتاجي كامل والقياس أعلاه. لم يُزحزح أيٌّ منها الرقم. هذا التكرار هو المغزى — فوضع الفشل ينجو من كل أداة ترميها عليه، لأن كل هذه الأدوات تحلّ مشكلة مختلفة.

الطريق المسدود رقم 1: «استخدم next/dynamic ببساطة»

كان موجودًا بالفعل. كان كل واحد من الـ ~100 صيغة مُغلَّفًا داخل dynamic(). ومع ذلك بلغ JavaScript التحميل الأول 5.3 MB. مهما وعد به dynamic()، فإنه لم يكن يفي به هنا — احتفظ بهذه الفكرة، إذ يأتي السبب بعد لحظات.

الطريق المسدود رقم 2: await import() المباشر في المكوّن الخادمي

ربما يكون الغلاف الذي يوفّره next/dynamic هو المشكلة؟ في App Router، يستطيع مكوّن خادمي أن ينتظر (await) استيرادًا ديناميكيًا مباشرةً — وهو النمط الرسمي للتحميل الكسول للمكتبات. استبدلنا سجلّ أغلفة dynamic() بخريطة من دوال import() الخام (thunks):

// Dead end #2: replace next/dynamic with a direct server-side import()
const loaders = {
  'sections.hero': { concept_1: () => import('./sections/Hero/HeroV1') },
  // ...
}
export async function SectionRenderer({ name, concept, id }) {
  const data = await fetchSectionData(name, id)
  const { default: Component } = await loaders[name][concept]()
  return <Component {...data} />
}
// Result: byte-for-byte the same megachunk. Nothing changed.

الطريق المسدود رقم 3: تقسيم السجلّ عبر 38 ملفًا

الفرضية التالية: يدمج المُجمِّع الحزم لأن كل استدعاءات import() الـ ~100 تقيم في وحدة واحدة. فولّدنا ملف مُحمِّل (loader) واحدًا لكل نوع قسم — 38 ملفًا، يحمل كلٌّ منها دوال import() الخاصة بصيغه فقط، إضافةً إلى برميل (barrel) رفيع للبحث عنها بالاسم.

النتيجة: مخرجات مطابقة بايتًا ببايت. الحزمة الضخمة (megachunk) نفسها، والحجم المجاور للهاش نفسه. كانت هذه أول نتيجة سلبية مفيدة فعلًا: تُجمِّع المُجمِّعات الحزم وفق رسم بيان الوحدات (module graph)، لا وفق التوزيع الملفّي لاستدعاءات import() لديك. نقل التعليمات بين الملفات غير مرئي للرسم البياني — تبقى مجموعة الوحدات نفسها قابلة للوصول من المُحلِّل (resolver) نفسه.

الطريق المسدود رقم 4: إعداد المُجمِّع

تقطيع Turbopack غير قابل للإعداد عن قصد: لا يوجد مكافئ لـ splitChunks، وتُتجاهَل تعليقات webpack السحرية (webpackChunkName ورفاقه). أما العَلَم التجريبي الوحيد ذو الصلة بالتقطيع اللامتزامن المتداخل فهو مُفعَّل أصلًا افتراضيًا في بنى الإنتاج. لم يبقَ حرفيًا أي مقبض لإدارته.

الطريق المسدود رقم 5: التحوّل إلى webpack (والتجربة التي فسّرت كل شيء)

webpack قابل للإعداد فعلًا، لذا مرّرنا الموقع عبر next build --webpack. الإعداد الافتراضي: الحزمة الضخمة نفسها، ~1 MB، كل الأقسام. ثم فرضنا المسألة عبر cacheGroup لكل قسم — حزمة واحدة لكل مجلد قسم، مع enforce: true:

next.config.js (webpack experiment)
config.optimization.splitChunks.cacheGroups.sections = {
  test: /[\\/]components[\\/]sections[\\/]/,
  chunks: 'all',
  minSize: 0,
  enforce: true,
  priority: 50,
  name: (module) => 'section-' + dirNameOf(module), // one chunk per section
}
// Result: the 1 MB megachunk split into 34 neat per-section files...
// ...and the page loaded ALL 34 of them. Same total bytes. Zero win.

انقسمت الحزم انقسامًا رائعًا — 34 ملفًا مرتَّبًا لكل قسم. وحمّلت الصفحة الـ 34 كلها. البايتات الإجمالية نفسها، والترطيب المحجوب نفسه، لكن الآن بمزيد من طلبات HTTP. هذه هي اللحظة التي أصبحت فيها المشكلة الحقيقية لا تُنكَر: كنا نُحسِّن كيفية تغليف الشيفرة، بينما كانت المشكلة في أي شيفرة يشير إليها المسار.

السبب الجذري الحقيقي: إمكانية الوصول، لا التقطيع إلى حزم

إليك الآلية. مكوّنات الأقسام هي مكوّنات عميل ('use client') — فيها معالجات، ومنزلقات، وتحليلات. حين تشير شجرة مكوّنات خادمية إلى مكوّن عميل، يجب على المُجمِّع أن يُضمِّن حزمة ذلك المكوّن في حزمة العميل الخاصة بالمسار كي يتمكّن من الترطيب. فأي مكوّنات عميل يشير إليها مسارٌ مُدار عبر CMS؟ يحلّ العارض الأقسام عبر سلسلة نصية تُقرأ وقت التشغيل من الـ CMS — لذا فمن الناحية الساكنة (statically)، يكون كل قسم في السجلّ قابلًا للوصول من كل صفحة تستخدم العارض. لا يمكن للمُجمِّع أن يعرف أن /pricing لا يعرض سوى 12 منها أبدًا. عليه أن يُجهّز الـ ~100 كلها.

next/dynamic لا يساعد، لأن الكسل الذي يوفّره كسلٌ على جانب العميل: فهو يؤجّل متى تُنزَّل الحزمة نسبةً إلى العرض، لكن الخادم قد قرّر بالفعل ما الذي سيُعرَض قبل أن ينفّذ العميل بايتًا واحدًا. كل ما عرضه الخادم يجب أن يُرطَّب؛ وكل ما قد يُعرَض يجب أن يُرسَل أو يكون قابلًا للوصول. ومع سجلّ يُحَلّ وقت التشغيل، فإن «ما قد يُعرَض» يساوي «كل شيء».

المُجمِّع يُجمِّع ما هو قابل للوصول. إذا استطاع مُحلِّلك أن يصل إلى 100 مكوّن، فإن مسارك يُرسل 100 مكوّن — مقسّمة في حزمة واحدة أو في أربع وثلاثين، لكنها مُرسَلة في الحالتين. الرافعة الحقيقية الوحيدة هي تقليص إمكانية الوصول نفسها.

الحل: نقل المعرفة إلى وقت البناء (توليد الكود + rewrites)

الميزة المنقِذة لصفحات SSG المدارة عبر CMS: في وقت البناء، يعرف الخادم بالفعل وبدقّة أي الأقسام تستخدمها كل صفحة — فهو يجلبها من الـ CMS لعرض الـ HTML مسبقًا. المعرفة موجودة؛ لكنها محبوسة في وقت التشغيل حيث لا يراها المُجمِّع. لذا نُجسّدها في شيفرة قبل أن يعمل المُجمِّع.

يعمل سكربت توليد الكود بوصفه الخطوة الأولى في البناء (مربوطًا سلسليًا في سكربت build الخاص بالحزمة — بحيث يحصل عليه كل خط أنابيب مجانًا: المحلي، و CI، وكلا مسارَي النشر):

scripts/generate-landing-routes.mjs
// Runs BEFORE next build (chained in the "build" script).
// 1. Find the routes that render CMS pages (scan for the renderer import).
// 2. Ask the CMS which sections each page actually uses —
//    union across every locale, and across A/B variants.
// 3. Stamp a physical page file per route whose import map contains
//    ONLY those sections. The bundler does the rest.
const pages = await fetchAllCmsPages()           // slug -> sections[]
for (const page of inScope(pages)) {
  const concepts = unionAcrossLocalesAndVariants(page)
  writeFileSync(
    `app/[lang]/(generated)/g/${page.key}/page.tsx`,
    stampTemplate({ page, concepts })            // inline import() per concept
  )
}
writeFileSync('generated-routes.manifest.json', { rewrites })
// Any failure => write nothing => the site behaves exactly as before.

لكل صفحة ضمن النطاق، يختم ملف مسار ماديًا (physical route file) تحوي خريطة استيراداته أقسام تلك الصفحة فقط — أي الاتحاد عبر كل اللغات المحلية (locales) (قد تختلف مجموعات الأقسام باختلاف اللغة المحلية) وعبر صيغ A/B. بالنسبة للمُجمِّع، هذا الملف المُولَّد شيفرة مصدرية عادية بإمكانية وصول ساكنة ضيّقة، فيُنتج حزمة صغيرة لكل مسار دون أي إعداد:

app/[lang]/(generated)/g/home/page.tsx (auto-generated)
// AUTO-GENERATED on every build — the "manifest" is literal code.
const LOADERS: SectionLoaders = {
  'sections.hero': {
    concept_5: () => import('@/components/sections/Hero/HeroV5'),
  },
  'sections.faq': {
    concept_1: () => import('@/components/sections/Faq/FaqV1'),
  },
  // ...exactly the 13 section types this page renders. Nothing else.
}

export default async function GeneratedHome({ params }) {
  const { lang } = await params
  return <CmsPage loaders={LOADERS} params={{ lang, slug: 'home' }} />
}

تعيش المسارات المُولَّدة تحت مسار داخلي قبيح — لا يراه المستخدمون أبدًا. تقرأ كتلة rewrites() في next.config الملف التعريفي (manifest) المنبعث وتربط عناوين URL الحقيقية بالمسارات المُولَّدة في beforeFiles. الخاصية الحاسمة: إن كان الملف التعريفي مفقودًا أو معطوبًا، فببساطة لا توجد rewrites، وتُخدَّم كل صفحة عبر المسار الشامل غير الممسوس:

next.config.ts
async rewrites() {
  // Missing/broken manifest => zero rewrites => yesterday's behavior.
  // The codegen can never take the site down.
  let generated: Array<{ source: string; destination: string }> = []
  try {
    const manifest = JSON.parse(
      fs.readFileSync(path.join(__dirname, 'generated-routes.manifest.json'), 'utf8')
    )
    if (Array.isArray(manifest?.rewrites)) generated = manifest.rewrites
  } catch { /* fall back to universal routes */ }

  return {
    beforeFiles: [
      ...generated,
      // e.g. { source: '/:lang(en|de|fr|...)', destination: '/:lang/g/home' }
    ],
  }
}

سلسلة العرض مرآة للأصلية — الجلب نفسه للبيانات، و SEO نفسه، والتحليلات نفسها — مع فارق واحد هو جوهر الأمر برمّته: تصل خريطة المكوّنات بوصفها خاصية (prop) بدلًا من استيرادها من السجلّ العام. يجب أن يصمد ثابتٌ واحد وإلا تبخّر المكسب كله: لا يجوز لأي شيء في رسم بيان وحدات المسار المُولَّد أن يستورد السجلّ العام. ويشمل ذلك المسارات غير المباشرة — إذ كان مساعد جلب البيانات لدينا يستورد السجلّ لمجرد قراءة قوالب الاستعلام، وهو ما كان سيجعل كل قسم قابلًا للوصول من جديد؛ فتعيّن فصل البيانات الوصفية (metadata) في وحدتها الخاصة الخالية من السجلّ.

SectionRendererGen.tsx
// Same rendering logic as before — but the component map arrives
// as a PROP from the generated page. Nothing in this chain may import
// the global registry, or every section becomes reachable again.
export async function SectionRendererGen({ loaders, name, concept, id }) {
  const data = await fetchSectionData(name, id)
  const loader = loaders[name]?.[concept] ?? loaders[name]?.['concept_1']
  if (!data || !loader) return null
  const { default: Component } = await loader()
  return <Component {...data} />
}

النجاة من اختبار A/B (الجزء الذي يسأل عنه الجميع)

تُجري صفحات الـ CMS تجارب A/B: يقيّم الـ middleware علَمَ ميزة (feature flag) لكل طلب ويُعيد كتابة (rewrites) مسار المستخدمين في مجموعة صيغة إلى صفحة مختلفة تحت العنوان URL نفسه. هذا بالضبط سبب وجود الحلّ وقت التشغيل في المقام الأول — فكيف يتعامل نظامٌ يعمل وقت البناء مع ذلك؟ عبر احترام ترتيب المعالجة. في Next.js، يعمل الـ middleware دائمًا قبل rewrites الخاصة بـ beforeFiles، لذا تتراكب الطبقتان بدلًا من أن تتصارعا:

request /
  │
  ▼
proxy (middleware) — A/B decision, ALWAYS runs first
  ├─ no experiment        → pass through as /en
  ├─ user in CONTROL      → set cookie, pass through as /en
  └─ user in VARIANT      → rewrite to /en/ab/<variant-slug>
  │
  ▼
beforeFiles rewrites — OUR manifest, runs on whatever path proxy chose
  ├─ /en                  → /en/g/home            (generated, small)
  ├─ /en/ab/<variant>     → /en/g/ab-<variant>    (generated, small)
  └─ anything unmatched   → untouched → universal route (fallback)

يقرأ مولّد الكود إعدادات التجارب من المصدر نفسه الذي يستخدمه الـ middleware (ثابتٌ متعمَّد — انعدام احتمال الانحراف)، ويختم صفحة مُولَّدة لكل صيغة، إضافةً إلى rewrite دقيق للعنوان URL الداخلي للصيغة. يحصل مستخدمو المجموعة الضابطة على الصفحة المُولَّدة السريعة؛ ويحصل مستخدمو الصيغة على صفحتهم المُولَّدة السريعة الخاصة؛ ولا يتغيّر العنوان URL المرئي للمستخدم أبدًا. أما التجربة التي تُنشَأ بعد آخر بناء فتسقط ببساطة إلى المسار الشامل حتى النشر التالي — متدنّية إلى أداء الأمس، لا معطوبة أبدًا.

عقد الفشل المفتوح (fail-open)

النظام بأكمله مصمَّم لكي يتدنّى إلى الوضع الراهن، ولا يهبط دونه أبدًا. أُتيح لنا أن نرى هذا يعمل في الإنتاج بمحض الصدفة: عند أول نشر، كشفت بيئة الـ CI عنوان URL الخاص بالـ CMS تحت اسم متغيّر مختلف عن البناءات المحلية. لم يجد مولّد الكود CMS، فلم يُنتج شيئًا — وخدَّم الموقع كل صفحة عبر المسار الشامل: البناء أخضر، وصفر أثر على المستخدمين. وبعد إصلاح من سطرين ظهرت المسارات المُولَّدة. العقد:

  • الـ CMS غير قابل للوصول أو صفحة غير قابلة للتحليل ← تُتخطّى تلك الصفحة (أو كل شيء)؛ ولا يفشل البناء أبدًا بسبب مولّد الكود.
  • لا ملف تعريفي ← لا rewrites ← يخدم المسار الشامل كل شيء، تمامًا كما قبل المشروع.
  • تغيّرت بنية الأقسام في الـ CMS بعد البناء ← تعرض الصفحة المجموعة القديمة حتى النشر التالي (خطّاف نشر (publish-webhook) يُطلق عمليات إعادة النشر يجعل هذه النافذة بطول دقائق). تعديلات المحتوى غير متأثرة — إذ ما تزال البيانات تُجلب حيًّا عبر ISR.
  • الملفات المُولَّدة مُدرَجة في gitignore وتُعاد توليدها في كل بناء؛ يراجع المراجعون المولّد، لا 700 ملف مختوم.

النتائج

المقياس (الصفحة الرئيسية)قبلبعد
JavaScript التحميل الأول (خام، مجموعة السكربتات الأولية)5,127 KB (57 chunks)2,411 KB (39 chunks)
مكوّنات الأقسام في الحزمة13716
الحزمة الضخمة لكل الأقسام~1,200 KB0 KB

−53% من JavaScript التحميل الأول، مؤكَّدة بثلاث طرق مستقلة: قياس مجموعة السكربتات الأولية، والإسناد لكل وحدة عبر خرائط المصدر (source-map) (تحديدًا مكوّنات الصفحة الـ 16 الخاصة بها — 13 قسمًا مُخرَّطًا إضافةً إلى مكوّناتها الأبناء)، و grep لعلامات مميِّزة عبر حزم النشر الحي:

// Verify on the LIVE deployment without source maps: CSS class names
// are embedded in the JS chunks, so grep for section markers.
let all = ''
for (const src of initialScriptsOf('/en')) all += await fetchText(src)

// must be there:  the sections the page renders
console.log(all.includes('HeroV5'))          // true ✅
// must NOT be there: everything else
console.log(all.includes('fortune_wheel'))   // false ✅
console.log(all.includes('holiday_offer'))   // false ✅

تكرّرت الأرقام نفسها على معاينة الإنتاج حتى الكيلوبايت. توقّعٌ واحد ينبغي ضبطه: سيبدو المجموع الكلي (TOTAL) في DevTools بلا تغيير تقريبًا، لأن الموجّه (router) يجلب مسبقًا حزم المسارات الأخرى (التي ما تزال شاملة) في الخلفية بعد التحميل. لا بأس بذلك — فالجلب المسبق في الخلفية لا يحجب شيئًا. الأداء يتعلق بما يُحمَّل قبل التفاعلية، وهذا هو الرقم الذي انخفض إلى النصف. ويُظهره Lighthouse مع مخطّطه الشجري (treemap) بلا لبس إن أردت دليلًا مناسبًا للقطة شاشة.

المقايضات، بصراحة

  • حداثة البنية مرتبطة بالنشر: إضافة/إزالة قسم في الـ CMS تحتاج إعادة بناء كي تصل إلى المسار المُحسَّن (عمليات النشر المُطلَقة بخطّاف تقلّص النافذة إلى دقائق؛ والاحتياطي (fallback) يغطّي الفجوة).
  • يحلّل مولّد الكود الـ CMS الخاص بك واصطلاحات مساراتك — إنها ~400 سطر من الشيفرة تملكها ويجب أن تصونها، بما في ذلك تحليلها للسجلّ ولخصائص المسار (route props).
  • التوسّع: مئات صفحات الـ CMS ← مئات التركيبات الفريدة من الأقسام (صفحاتنا الـ ~700 أعطت ~380 مجموعة فريدة، ذيل طويل). ابدأ بالصفحات الأعلى حركةً خلف ثابت نطاق (scope) صريح؛ والاحتياطي يخدم الذيل.
  • هذا لا يقلّص إجمالي JS الموقع — فالمكتبات الثقيلة بالصيغ والأقسام الغنية ما تزال موجودة. إنه يقلّص ما يُرسله كل مسار. أما الرافعة التالية (تحويل الأقسام العرضية إلى مكوّنات خادمية) فمشروع منفصل وأكبر.

الخلاصات

  1. next/dynamic يُنشئ نقاط تقسيم، لا ضمانات. على الصفحات المدفوعة من الخادم، يتحدّد الكسل بما يستطيع المسار الوصول إليه، لا بكيفية كتابة الاستيرادات.
  2. إعداد التقطيع إلى حزم لا يُصلح إمكانية الوصول. أثبتنا ذلك إثباتًا شاملًا: تقسيم الحزمة الضخمة إلى 34 حزمة لكل قسم لم يغيّر شيئًا — حُمِّلت الـ 34 كلها.
  3. إذا كانت صفحاتك SSG/ISR من CMS، فالمعلومات التي تحتاجها موجودة أصلًا في وقت البناء. مولّد الكود هو الجسر: حوّل عمليات البحث وقت التشغيل إلى استيرادات ساكنة لكل مسار.
  4. تراكب مع الـ middleware، لا تحاربه: يقرّر الـ middleware أيَّ صفحة (A/B)، وتقرّر rewrites أيَّ تنفيذ (implementation) لتلك الصفحة. والترتيب يضمن تراكمهما.
  5. ابنِ أنظمة فشل مفتوح (fail-open): غياب الملف التعريفي = موقع الأمس. مارس أول نشر إنتاجي لدينا الاحتياطي بمحض الصدفة، ولم يلاحظ المستخدمون شيئًا.

يتعمّم النمط على أي «سجلّ مكوّنات يُحَلّ عبر بيانات CMS وقت التشغيل» — أدوات بناء الصفحات، وأنظمة الودجات (widgets)، وواجهات المتاجر القابلة للتنسيق (themeable). إذا كان مسارك قادرًا على عرض أي شيء، فسوف يُرسل كل شيء. اجعل البناء يعرف ما هي كل صفحة حقًا، وعندها سيفعل المُجمِّع أخيرًا ما افترضت دائمًا أنه يفعله.