Skip to main content
العودة إلى المدونة
Next.jsCSSPerformanceTurbopackSCSS

كيف تخلّصت من 94 ملف CSS حاجب للعرض في Next.js 16 بميزة Turbopack موثَّقة بشكل ضعيف

بعد أيام من تجربة كل نهج — من experimental.inlineCss إلى حيل MutationObserver — اكتشفت Turbopack Import Attributes التي تحل مشكلة CSS الحاجب للعرض في Next.js App Router.

نُشر 8 أبريل 202612 دقيقة قراءة

المشكلة

يستخدم تطبيقنا (Promova) إطار Next.js 16 مع Landing Builder — نظام يعتمد على نظام إدارة محتوى (CMS) يجمّع صفحات التسويق من نحو 90 مكوّن قسم مختلفًا (أقسام Hero وأسئلة شائعة وتسعير ومراجعات وغيرها). تعتمد البنية على sectionRegistry.tsx الذي يربط أسماء الأقسام باستدعاءات next/dynamic():

sectionRegistry.tsx
// sectionRegistry.tsx — imports all 90 sections
export const sectionRegistry = {
  HeroSection: dynamic(() => import('./HeroSection/HeroSection')),
  FAQSection: dynamic(() => import('./FAQSection/FAQSection')),
  ReviewsSection: dynamic(() => import('./ReviewsSection/ReviewsSection')),
  // ... 87 more
}

لا تعرض صفحة الهبوط الواحدة سوى 5-8 أقسام. لكن Lighthouse كان يُظهر ما يلي:

Eliminate render-blocking resources
  94 CSS resources (~330 KB)
  Potential savings: 5,440 ms

لماذا؟ يرى Turbopack جميع مسارات import() التسعين قابلةً للوصول، فيولّد <link rel="stylesheet"> لكل وحدة SCSS. حتى الأقسام التي لا تُعرَض أبدًا على الصفحة يُحقَن ملف CSS الخاص بها في <head>. هذا سلوك مؤكَّد ومتوقَّع لموجّه App Router في Next.js. إذ لا يُقسَّم CSS إلى أجزاء لاستيرادات dynamic() القادمة من Server Components. يعدّه المشرفون مقايضةً متعمَّدة لتفادي وميض المحتوى غير المنسَّق (FOUC). ولا يُخطَّط لإصلاحه.

كل ما جرّبته (ولماذا فشل)

قضيت أيامًا أستعرض كل نهج أمكنني إيجاده. إليك القائمة الكاملة:

ApproachWhy it doesn't work
Split sectionRegistry into per-section filesCSS still loaded — #61066
experimental.inlineCss: trueInlines CSS on every SSR request — crashed our CMS under load
experimental.optimizeCss (Critters)Incompatible with streaming in App Router
CSS-in-JS rewriteRewriting 90 sections from SCSS to CSS-in-JS is not realistic
media="print" hackDoesn't work with CSS Modules in Turbopack — <link> tags are managed by the framework
Switch back to WebpackHas experiments.css options, but we're committed to Turbopack
Turbopack pluginsDon't exist — no plugin API
Turbopack CSS loadersNot supported — only JS output
SWC plugins for CSSSWC only processes JavaScript
Client Component wrapper for dynamic()Registry is a global constant — bundler sees all dependencies
Next.js middleware HTML rewriteMiddleware can't modify response body
Suspense + streaming with async importReact Float always pulls CSS into initial <head>
Suspense + 3s delayContent streams later, but CSS is in the initial <head> chunk
experimental.cssChunkingAlready default. Merges chunks but doesn't remove irrelevant CSS
Post-build Beasties/CrittersOnly for static export, not with ISR
npm libraries (next-critical, fg-loadcss)Pages Router only or abandoned (6+ years)
inlineCss per-route exclusionNot supported — all-or-nothing global flag
Turbopack as: 'raw' for SCSSReturns undefined instead of text
Turbopack rule for *.inline.module.scssTurbopack intercepts .module.scss before custom rules
Turbopack rule for *.inline.scssTurbopack intercepts .scss before custom rules
MutationObserver <script>Tested in dev, risky — CSS still loads as separate files, possible FOUC

فخّ inlineCss

يمتلك Next.js خيار experimental.inlineCss تستبدل جميع وسوم <link rel="stylesheet"> بوسوم <style> مضمَّنة. يبدو ذلك مثاليًا، أليس كذلك؟

المشكلة أنه إما الكل أو لا شيء. لا يمكنك تفعيله لكل مسار على حدة. إن كانت لديك صفحات SSR (force-dynamic) فإن كل طلب يعيد بناء كل CSS بشكل مضمَّن. جرّبنا ذلك — فلم يحتمل نظام CMS بلا واجهة (headless) لدينا الحِمل وتعطّل. ولكي يعمل بأمان، يجب أن تكون 100% من صفحاتك بنمط force-static أو ISR. ومع أكثر من 20 صفحة SSR (مصادقة، ولوحات تحكم، وصفحات ديناميكية) فتلك عملية ترحيل ضخمة.

الاكتشاف: Turbopack Import Attributes

أثناء تنقيبي في ملاحظات إصدار Next.js 16.2، عثرت على ميزة موثَّقة بشكل ضعيف: Turbopack Import Attributes. تتيح لك تجاوز خط أنابيب المجمِّع المدمَج لاستيراد محدَّد باستخدام صياغة with {} من TC39:

import { cssText, styles } from './hero.module.scss' with {
  turbopackLoader: '@promova/scss-to-inline-loader',
  turbopackAs: '*.js'
}

يقول هذا لـ Turbopack: «لا تعالج هذا الاستيراد بوصفه ورقة أنماط. مرِّره عبر الـ loader المخصَّص لديّ وتعامل مع الخرج بوصفه JavaScript.»

هذه هي الفكرة الجوهرية. بدلًا من أن يولّد Turbopack وسم <link rel="stylesheet"> يحجب العرض، يقوم الـ loader لدينا بترجمة SCSS وتصديره كسلسلة JavaScript. ثم نحقنه بوصفه وسم <style> مضمَّنًا مباشرة داخل المكوّن. النتيجة: لا يصل إلى HTML الصفحة سوى CSS الأقسام التي تُعرَض فعلًا. تحصل الصفحة ذات الأقسام الخمسة على CSS لخمسة أقسام — لا لـ 94.

الحل

1. Turbopack Loader مخصَّص

نص Node.js بنحو 70 سطرًا كحزمة ضمن مساحة عمل yarn (@promova/scss-to-inline-loader):

@promova/scss-to-inline-loader/index.js
const { createHash } = require('node:crypto')
const sass = require('sass')
const postcss = require('postcss')
const postcssModules = require('postcss-modules')
const path = require('path')

const SHARED_STYLES = path.resolve(__dirname, '../shared/styles')

// Same prependData as sassOptions.prependData in next.config.ts
const PREPEND_DATA = `
@use "sass:math" as math;
@use "sass:list" as list;
@use "${SHARED_STYLES}/_variables.scss" as *;
@use "${SHARED_STYLES}/_breakpoints.scss" as *;
@use "${SHARED_STYLES}/_mixins.scss" as *;
`

module.exports = function scssToInlineLoader(source) {
  const callback = this.async()
  processScss(source, this.resourcePath)
    .then((js) => callback(null, js))
    .catch((err) => callback(err))
}

async function processScss(source, resourcePath) {
  // 1. Compile SCSS → CSS (with global variables, mixins, breakpoints)
  const sassResult = sass.compileString(PREPEND_DATA + '\n' + source, {
    loadPaths: [path.dirname(resourcePath), SHARED_STYLES],
    style: 'compressed',
    sourceMap: false,
    url: new URL('file://' + resourcePath),
  })

  // 2. Scope class names with postcss-modules (CSS Modules compatible)
  let classNames = {}
  const result = await postcss([
    postcssModules({
      generateScopedName(name, filename) {
        const file = path.basename(filename, path.extname(filename))
          .replace('.module', '')
        const hash = createHash('md5')
          .update(filename + name)
          .digest('base64url')
          .slice(0, 6)
        return `${file}__${name}__${hash}`
      },
      getJSON(_, json) { classNames = json },
    }),
  ]).process(sassResult.css, { from: resourcePath })

  // 3. Export as JS module — same interface as CSS Modules
  return [
    `export const cssText = ${JSON.stringify(result.css)};`,
    `export const styles = ${JSON.stringify(classNames)};`,
    `export default styles;`,
  ].join('\n')
}

ما يفعله: styles — خريطة أسماء الأصناف محدَّدة النطاق (scoped) ذاتها الموجودة في CSS Modules القياسية. cssText — ملف CSS المترجَم كسلسلة نصية.

2. مكوّن InlineStyle

يستخدم واجهة <style href precedence> المدمَجة في React 19 لإزالة التكرار تلقائيًا:

InlineStyle.tsx
export function InlineStyle({ css, id }: { css: string; id: string }) {
  return (
    <style href={id} precedence="default">
      {css}
    </style>
  )
}

يضمن React 19: نفس قيمة href → وسم <style> واحد فقط في DOM. فإن عُرِض قسم مرتين على الصفحة، يُحقَن CSS مرة واحدة رغم ذلك.

3. ترحيل لكل مكوّن (نحو 6 أسطر لكل قسم)

// BEFORE: Turbopack → <link rel="stylesheet"> (render-blocking)
import styles from './hero_section.module.scss'
HeroSection.tsx
// AFTER: Custom loader → JS module → inline <style> (non-blocking)
import { cssText, styles } from './hero_section.module.scss' with {
  turbopackLoader: '@promova/scss-to-inline-loader',
  turbopackAs: '*.js'
}
import { InlineStyle } from '@promova/scss-to-inline-loader/InlineStyle'

export function HeroSection() {
  return (
    <section className={styles.hero}>
      <InlineStyle css={cssText} id="hero-section" />
      {/* ... component JSX, className usage is identical */}
    </section>
  )
}

تبقى ملفات .module.scss كما هي تمامًا. لا إعادة كتابة لـ CSS. Stylelint وPrettier ودعم بيئة التطوير (IDE) — كلها محفوظة.

لماذا هذا أفضل من inlineCss: true

هذا هو الفرق الجوهري:

<code>inlineCss: true</code>Import Attributes + Loader
What gets inlinedALL CSS on the page (94 files)Only CSS for rendered sections (5-8 files)
SSR overheadCSS rebuilt on every requestCSS compiled at build time
Per-route controlNo (all-or-nothing)Yes (per-import)
SSR pages safetyRisky (overloads server)Safe (component-level)

عند استخدام inlineCss: true، تحصل الصفحة رغم ذلك على جميع أوراق الأنماط الأربع والتسعين مضمَّنة — لكنها وسوم <style> بدلًا من وسوم <link>. يتضخّم HTML. أما مع نهجنا، فإن ما يصل إلى HTML هو CSS الذي يُعرَض فعلًا لا غير. عُرِضت 5 أقسام → 5 وسوم <style>. هذا كل شيء.

مطبّ Turbopack: لا قواعد عامة لـ .module.scss

أحد المطبّات التي وقعت فيها: قد تظن أن بإمكانك إضافة قاعدة Turbopack في next.config.ts لتطبيق الـ loader عالميًا:

next.config.ts
// ❌ THIS CAUSES A FATAL PANIC
turbopack: {
  rules: {
    '**/components/**/*.module.scss': {
      loaders: ['@promova/scss-to-inline-loader'],
      as: '*.js'
    }
  }
}

لا تفعل هذا. يعترض خط أنابيب وحدات CSS المدمَج في Turbopack ملفات .module.scss قبل تطبيق القواعد المخصَّصة. فيُنشئ أصلًا من نوع EcmascriptCssModule يتعارض مع خرج as: '*.js'، مسبِّبًا:

FATAL PANIC: inner asset should be CSS processable

تعمل سمات with {} لأنها توعز إلى Turbopack عند موضع الاستيراد بتجاوز خط أنابيب وحدات CSS كليًا. ولا سبيل إلى فعل ذلك عالميًا عبر ملف الإعداد.

النتائج

رحّلنا 127 مكوّن قسم عبر Landing Builder. وتم التحقق من بناء الإنتاج.

MetricBeforeAfter
Render-blocking CSS files940 (for landing sections)
CSS in HTML per page~330 KB (all sections)~20-40 KB (rendered sections only)
CSS delivery<link> (network request, blocking)<style> (inline, non-blocking)
Sass featuresFullFull (variables, mixins, nesting, @use)
CSS Modules scopingBuilt-inpostcss-modules (compatible)
Code change per section~6 lines (import + InlineStyle)

القيود

  • with {} لكل استيراد مُسهَب — يحتاج كل استيراد إلى 3 أسطر إضافية. ولا سبيل إلى تطبيقه عالميًا (انظر المطبّ أعلاه).
  • Turbopack فقط — لا يدعم Webpack سمات with {}. وإن احتجت إلى بديل Webpack، تعود الاستيرادات إلى CSS Modules القياسية (وتكون قيمة cssText هي undefined).
  • تجزئة أسماء الأصناف — يستخدم الـ loader لدينا خوارزمية تجزئة مختلفة عن تلك المدمَجة في Turbopack. تبدو أسماء الأصناف مختلفة في DevTools. لكنها متطابقة وظيفيًا.
  • يزداد حجم HTML — يُضمَّن CSS داخل HTML بدلًا من تخزينه مؤقتًا كملفات منفصلة. أما للصفحات الثابتة/ISR المقدَّمة من CDN، فهذه ليست مشكلة.

متى تستخدم هذا

تكون هذه التقنية أكثر فاعلية عندما:

  1. يكون لديك نمط registry/barrel — ملف واحد يستورد مكوّنات كثيرة، لكن قليلًا منها فقط يُعرَض في كل صفحة
  2. تكون على Turbopack — سمات Import Attributes خاصة بـ Turbopack
  3. تريد تحكمًا لكل مكوّن — لا خيار «إما الكل أو لا شيء»
  4. تكون ملفات SCSS لديك معقّدة — متغيّرات وmixins ونقاط توقّف (breakpoints) وتداخل — كلها مدعومة
  5. لا يمكنك استخدام experimental.inlineCss — لأن لديك صفحات SSR أو تريد تحكمًا دقيقًا

إن كنت متأثرًا بمشكلة CSS الحاجب للعرض في Next.js App Router، فأنت لست وحدك:

المشكلة الجوهرية

  • #62485 — Render blocking CSS (maintainers: "expected behavior")
  • #61066 — Dynamic imports from Server Components are not code-split
  • #54935 — Server-side dynamic imports don't split client modules
  • #61574 — JS/CSS code splitting doesn't work as documented
  • #57634 — Add support for critical CSS inlining with App Router
  • #50300 — next/dynamic on server component does not build CSS modules

ميزة inlineCss ومشكلاتها

  • PR #72195 — experimental: css inlining (the implementation)
  • PR #73182 — Don't inline CSS in RSC payload for client navigation
  • #75648 — WhatsApp preview broken with inlineCss + Tailwind
  • #83612 — Turbopack wrong font URL with inline CSS

المجتمع يطلب حلولًا

علل CSS في Turbopack

  • #68412 — Incorrect CSS modules load order with external components
  • #76464 — Turbopack sometimes strips SCSS imports
  • #82497 — SCSS module not loading when not-found.tsx is present
  • #88544 — Exporting Sass variables from CSS modules doesn't work with Turbopack

صُنِع في Promova — منصة لتعلّم اللغات تخدم ملايين المستخدمين. إن كنت تصارع المشكلة ذاتها في CSS، فلا تتردد في الإشارة إلى هذا المقال في مشكلات GitHub أعلاه.