الوضع الداكن دون وميض في Next.js 16: كيف نستخدم سكربت سمة حاجبًا وcolor-scheme وزر تبديل آمنًا مع الـ Hydration
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗الخلاصة: يظهر وميض الوضع الداكن في Next.js App Router لأن الخادم يرسم HTML قبل أن يتمكن من قراءة السمة المحفوظة في localStorage، ولأن useEffect لا يطبّق تلك السمة إلا بعد أول رسم للصفحة. سكربت صغير مضمَّن داخل عنصر head في الـ root layout يضبط صنف عنصر html وخاصية color-scheme قبل تحليل body، فيختفي الوميض وتبقى كل الصفحات ثابتة.
أهم النقاط
الوميض مشكلة توقيت: الخادم لا يستطيع قراءة localStorage، لذلك يستخدم أول رسم السمة الافتراضية إلى أن يصححها JavaScript على العميل.
سكربت حاجب مضمَّن داخل عنصر head يحل المشكلة، لأن المتصفح ينفّذه قبل تحليل body، فيظهر أول رسم بالصنف الصحيح.
قراءة cookie السمة عبر cookies() في الـ root layout تمنع الوميض أيضًا، لكنها تجعل كل مسار يُرسم مع كل طلب.
خاصية CSS المسماة color-scheme، وليس الصنف dark، هي ما يحوّل أشرطة التمرير وعناصر النماذج الأصلية ودالة light-dark() إلى الوضع الداكن.
يجب أن يأخذ زر تبديل السمة أيقونته من CSS وحالته من useSyncExternalStore، حتى يتطابق ترميز الخادم والعميل أثناء الـ hydration.
لماذا يظهر وميض الوضع الداكن عند تحميل الصفحة في Next.js؟
يظهر الوميض في Next.js لأن تفضيل السمة محفوظ في المتصفح بينما يُرسم HTML من دونه. الصفحة المرسومة بشكل ثابت تُبنى مرة واحدة دون أي وصول إلى localStorage الخاص بالزائر. يرسم المتصفح هذا HTML فورًا، ثم يُجري React عملية الـ hydration، وبعدها فقط يقرأ useEffect السمة المحفوظة ويضيف الصنف dark. كل إطار بين أول رسم وذلك الـ effect يظهر بالسمة الافتراضية.
نقل المنطق إلى useLayoutEffect لا يحل المشكلة، لأن الـ hydration نفسها تبدأ بعد رسم HTML القادم من الخادم. في المشاريع التي نبنيها في Devya يجب أن يعمل الحل أثناء تحليل المتصفح لـ HTML، قبل أن يصل React إلى الصفحة.
هل نخزّن السمة في localStorage أم في cookie؟
في المواقع الثابتة في معظمها يخزّن فريقنا السمة في localStorage ويطبّقها بسكربت مضمَّن. يتيح الـ cookie للخادم رسم السمة الصحيحة، لكن قراءته في الـ root layout تُخرج كل المسارات من الرسم الثابت. هذه هي الخيارات التي قارنّاها:
سكربت مضمَّن مع localStorage: لا وميض، والصفحات تبقى ثابتة، والخادم لا يعرف السمة، والتكلفة بضع مئات من البايتات من JavaScript مضمَّن يحجب الرسم.
cookie يُقرأ عبر cookies() في الـ root layout: لا وميض والخادم يعرف السمة، لكن كل مسار يُرسم مع كل طلب ويفقد HTML التخزين المؤقت على الـ CDN.
useEffect داخل client provider: الصفحات تبقى ثابتة لكن الوميض يبقى.
الاعتماد على prefers-color-scheme في CSS فقط: لا وميض ولا JavaScript، لكن لا يوجد زر تبديل للمستخدم، ونظام التشغيل هو من يقرر.
لطريق الـ cookie عقبة إضافية في Next.js 16: عند تفعيل cacheComponents تُعد cookies() بيانات وقت تشغيل يتوقعها Next.js داخل حدود Suspense، ولا يمكن لأي حدود أن تحيط بعنصر html الذي سيضبط الـ cookie صنفه. مكتبة next-themes تستخدم أسلوب السكربت المضمَّن داخليًا، لذا تنطبق عليها مقايضات الخيار الأول.
كيف نكتب سكربت سمة حاجبًا في App Router؟
سكربت السمة الحاجب دالة مستقلة تُرسم كوسم script مضمَّن داخل عنصر head في الـ root layout، وينفّذها المتصفح قبل تحليل body. يقرأ سكربتنا القيمة المحفوظة عبر localStorage.getItem('theme')، ولا يقبل إلا 'light' أو 'dark'، ويستدعي document.documentElement.classList.toggle('dark', ...)، ويضبط document.documentElement.style.colorScheme، ويلتقط الأخطاء لأن localStorage يرمي خطأ عندما يكون التخزين معطّلًا.
يحتاج عنصر html إلى suppressHydrationWarning لأن السكربت يغيّر صنفه ونمطه قبل أن يُجري React الـ hydration. تعمل suppressHydrationWarning في React على مستوى واحد فقط، لذا لا تُخفي أي عدم تطابق حقيقي في بقية الشجرة.
نرسم السكربت من الـ root layout بصفته Server Component عبر dangerouslySetInnerHTML، وليس من client provider، فيصل كـ HTML عادي ولا يحتاج إلى حزمة JavaScript على العميل. أما السمة الاحتياطية عند عدم وجود قيمة محفوظة فهي قرار يخص المنتج: نحن نوفّر خيارين فقط، فاتح وداكن، ونعود إلى السمة الافتراضية للتصميم. المنتج الذي يجب أن يتبع نظام التشغيل يمكنه قراءة matchMedia('(prefers-color-scheme: dark)').matches في السكربت نفسه.
المواقع التي تستخدم سياسة أمان المحتوى (CSP) يجب أن تسمح بالسكربت المضمَّن. سياسة CSP المعتمدة على nonce تفرض الرسم الديناميكي في Next.js، لذلك نسمح بالسكربت الثابت عبر بصمة sha256 في script-src ونعيد توليد البصمة كلما تغيّر نص السكربت.
كيف نبدّل السمة دون أن يتحرك كل لون على حدة؟
العناصر التي تملك انتقالات CSS على color أو background-color تتحرك بسرعات مختلفة عند تبديل الصنف، فتبدو الصفحة وكأنها يُعاد رسمها على شكل شرائط. دالة setTheme لدينا تضيف عنصر style يضبط transition: none على كل العناصر، ثم تبدّل الصنف dark وcolor-scheme، وتحفظ الاختيار في localStorage داخل try/catch، وتفرض إعادة حساب الأنماط بقراءة getComputedStyle(document.body).opacity، ثم تزيل عنصر style عبر setTimeout بعد ميلي ثانية واحدة.
خيار disableTransitionOnChange في next-themes يستخدم التقنية نفسها، لذا يمكن للفرق التي تعتمد هذه المكتبة تفعيله بدل كتابة دالة خاصة بها.
كيف نرسم زر التبديل دون عدم تطابق في الـ hydration؟
زر التبديل الذي يختار أيقونة الشمس أو القمر بناءً على قيمة السمة يسبب عدم تطابق في الـ hydration، لأن الخادم يرسم دائمًا السمة الافتراضية. نحن نرسم الأيقونتين معًا ونترك CSS يُظهر إحداهما، باستخدام أصناف Tailwind مثل dark:hidden وhidden dark:block، فيتطابق الترميز على الخادم والعميل.
أما الحالة التي يحتاجها الزر، مثل aria-pressed، فتأتي من useSyncExternalStore، وهو hook في React يربط المكوّن بمخزن خارجي ويقبل لقطة منفصلة للخادم. المخزن لدينا هو قائمة أصناف عنصر html، ونراقبها عبر MutationObserver. أثناء الـ hydration يستخدم React لقطة الخادم، وهي null، ثم يعيد الرسم بالقيمة الحقيقية، فلا يحدث عدم تطابق، وتقرأ كل أزرار التبديل في الصفحة من مصدر الحقيقة نفسه.
تبقى علامات التبويب الأخرى متزامنة عبر الحدث storage، الذي يُطلق في كل علامة تبويب من الأصل نفسه باستثناء العلامة التي كتبت القيمة. كتابة قيمة لم تتغيّر لا تطلق أي حدث، لذا لا يمكن لعلامتي تبويب أن تدخلا في حلقة.
أين يأتي دور Tailwind CSS v4 ودالة light-dark()؟
متغيّر dark في Tailwind CSS v4 يتبع prefers-color-scheme افتراضيًا. زر التبديل المعتمد على الصنف يحتاج إلى سطر واحد في ملف الأنماط الرئيسي، هو @custom-variant dark (&:where(.dark, .dark *)); الذي يوجّه المتغيّر إلى الصنف dark.
دالة light-dark() في CSS تُرجع لونها الأول عندما يكون نظام الألوان المستخدم للعنصر فاتحًا، والثاني عندما يكون داكنًا. أصبحت ضمن Baseline منذ 2024، ولا تتبدّل إلا عند ضبط color-scheme، ولهذا يضبط سكربتنا colorScheme إلى جانب الصنف. ضبط color-scheme هو أيضًا ما يحوّل أشرطة التمرير وقوائم select ومنتقيات التاريخ إلى الوضع الداكن.
الأسئلة الشائعة
س: هل تُخفي suppressHydrationWarning أخطاء hydration أخرى؟
ج: لا. تُخفي suppressHydrationWarning في React عدم التطابق في سمات العنصر الذي وُضعت عليه ونصه فقط، على مستوى واحد، لذا يظل أي عدم تطابق تحت عنصر html ظاهرًا.
س: هل ما زلنا بحاجة إلى next-themes في Next.js 16؟
ج: لا، فهي اختيارية. تغلّف next-themes أسلوب السكربت المضمَّن نفسه بـ provider وhook باسم useTheme، والنسخة المكتوبة يدويًا لا تتجاوز بضع عشرات من الأسطر وتتجنب وضع client provider حول التطبيق كله.
س: هل يمكن منع وميض الوضع الداكن دون JavaScript؟
ج: فقط إذا كانت السمة تتبع نظام التشغيل. لا يحتاج CSS المعتمد على prefers-color-scheme أو light-dark() إلى سكربت، لكن اختيار المستخدم المحفوظ يجب أن يطبّقه سكربت مضمَّن أو ترميز يرسمه الخادم من cookie.
س: هل يُبطئ سكربت السمة المضمَّن الصفحة؟
ج: يحجب السكربت تحليل HTML فقط أثناء قراءة مفتاح واحد من localStorage وضبط خاصيتين على عنصر html، ولا يضيف أي طلب شبكة، على عكس ملف سكربت خارجي.
س: كيف نجعل شريط أدوات متصفح الجوال يطابق السمة المختارة؟
ج: يتحكم وسم meta المسمى theme-color في لون شريط الأدوات. يقبل تصدير viewport في Next.js قيم themeColor مع استعلام media لـ prefers-color-scheme، لكن اختيار المستخدم المختلف عن نظام التشغيل يحتاج إلى أن تحدّث setTheme وسم theme-color أيضًا.
قراءات إضافية
هندسة الواجهات
صور Open Graph الديناميكية في Next.js 16: كيف نبني opengraph-image.tsx ونتحقق من إصدار Satori الذي تضمّنه next/og ونحافظ على صحة معاينات الروابط
واجهت بطاقات المشاركة المولّدة لدينا ثلاث مفاجآت: خط WOFF2 لم يستطع المحرّك قراءته، وملف README لـ Satori يصف محرّكًا أحدث من المضمّن في Next.js، وصفحة ظلّت تعرض صورة الـ layout. هذه ملاحظاتنا الميدانية حول opengraph-image.tsx في Next.js 16 وأولوية البيانات الوصفية وفحص og:image قبل مشاركة أي رابط.
هندسة الواجهات
sitemap.ts و robots.ts في Next.js 16: كيف نتحكم في زواحف Google وزواحف البحث بالذكاء الاصطناعي
كيف نولّد robots.txt و sitemap.xml عبر robots.ts و sitemap.ts في Next.js 16، وأي زواحف الذكاء الاصطناعي نسمح بها للبحث مقابل التدريب، وقاعدة مجموعات user-agent التي تُسقط أسطر Disallow بصمت، وكيف نضبط lastModified لتثق به الزواحف.