لماذا تسبب الخطوط انزياح التخطيط: ملاحظات ميدانية حول next/font و size-adjust ومقاييس الخط الاحتياطي العربي
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗انزياح التخطيط الناتج عن الخطوط هو عدم تطابق في المقاييس، وليس مشكلة في سرعة التحميل. الخط الاحتياطي الذي يرسمه المتصفح أولاً يشغل مساحة مختلفة عن خط الويب الذي يحل محله. يعالج next/font هذا التفاوت تلقائياً للنصوص اللاتينية عبر توليد قاعدة @font-face احتياطية معدَّلة بـ size-adjust، لكنه لا يعالجه للعربية، حيث يجب كتابة محدِّدات التعديل يدوياً.
أبرز النقاط
انزياح الخطوط عدم تطابق في المقاييس بين الخط الاحتياطي وخط الويب، وليس بطئاً في التحميل — شبكة توزيع أسرع لن تزيله.
يستضيف next/font ملفات الخطوط ذاتياً، ويحمّلها مسبقاً لكل مسار، ويولّد عبر adjustFontFallback قاعدة @font-face احتياطية تحمل size-adjust و ascent-override و descent-override و line-gap-override.
تُشتق تلك القاعدة الاحتياطية من خط نظام لاتيني، لذا تظل النصوص العربية والسيريلية والصينية اليابانية الكورية تتحرك وتحتاج قاعدة @font-face مكتوبة يدوياً ومقيّدة بـ unicode-range.
تزيل القيمة font-display: optional الانزياح الناتج عن التبديل تماماً: فترة حجب تقارب 100 مللي ثانية وفترة تبديل تساوي صفراً.
أعد إنتاج التحميل البارد مع تعطيل ذاكرة التخزين المؤقت وتفعيل خنق الشبكة — ذاكرة الخطوط الدافئة تخفي هذه العلة في كل اختبار محلي بينما يصطدم بها المستخدمون الحقيقيون.
ما الذي يسبب انزياح التخطيط المرتبط بالخطوط فعلياً؟
يحدث انزياح الخطوط عندما يشغل الخط الاحتياطي الذي يرسمه المتصفح أثناء التنزيل مساحة مختلفة عن خط الويب الذي يستبدله. ومقياس Cumulative Layout Shift أو CLS — وهو مؤشر Core Web Vitals الذي يجمع الحركة غير المتوقعة للعناصر المرئية، وحده الجيد 0.1 أو أقل — يسجل ذلك التبديل انزياحاً كلما حرّك المحتوى المجاور. سرعة التنزيل لا تحل المشكلة: في الذاكرة الباردة يرسم المتصفح إطاراً واحداً على الأقل بالخط الاحتياطي.
رقمان يحددان حجم القفزة. الأول هو متوسط عرض المحرف: إذا كان الخط الاحتياطي أعرض، يلتف النص إلى سطور أكثر وتنزل كل الكتل أسفله. والثاني هو نسبة الصعود إلى الهبوط، وهي التي تحدد ارتفاع صندوق السطر. خط احتياطي أعرض بأربعة بالمئة فقط قد يدفع فقرة إلى سطر إضافي ويحرك صورة رئيسية.
القيمة font-display: swap، وهي الافتراضية في next/font، ترسم النص بالخط الاحتياطي فوراً ثم تبدّله بخط الويب فور وصوله. التبديل يضمن قابلية القراءة، ويضمن كذلك حدوث انزياح ما لم تتطابق مقاييس الخطين.
ماذا يفعل next/font فعلياً وما الذي لا يفعله؟
next/font هو محمّل الخطوط المدمج في Next.js منذ الإصدار 13.2، ويُستورد عبر next/font/google أو next/font/local. ينفذ أربعة أمور وقت البناء: ينزّل ملفات الخطوط ويستضيفها من نطاقك فلا يبقى أي طلب زمن تشغيل إلى fonts.gstatic.com، ويضيف وسم preload لملفات الخطوط التي يستخدمها المسار، ويضبط font-display: swap، ويولّد عبر adjustFontFallback (قيمتها الافتراضية true) قاعدة @font-face ثانية لخط احتياطي محلي تحمل size-adjust و ascent-override و descent-override و line-gap-override مشتقة من مقاييس خط الويب نفسه.
وثلاثة أمور لا يفعلها. لا يختار خطاً احتياطياً مناسباً لنظام كتابة غير لاتيني، لأن الاحتياطي المولَّد مبني على Arial أو Times New Roman. ولا يحمّل مسبقاً خطاً مستورداً داخل مكوّن لا يعرضه المسار. ولا يحميك حين تعيد كتابة font-family في CSS باسم العائلة، فذلك يُسقط الاحتياطي المولَّد من السلسلة بالكامل. هذا تحديداً هو الخطأ الذي أطلقناه: ثيم Tailwind يشير بـ --font-sans إلى اسم العائلة بدل متغير CSS الذي يعيده المحمّل.
القاعدة التي نفرضها الآن في المراجعة بسيطة: عرّف الخط مرة واحدة في app/fonts.ts مع متغير صريح، ثم أشر إلى ذلك المتغير وحده في CSS — font-family: var(--font-sans), system-ui, sans-serif.
كيف تزيل size-adjust ومحدِّدات التعديل الانزياح؟
تعيد المحدِّدات الأربعة قياس الخط الاحتياطي ليشغل الصندوق نفسه الذي يشغله خط الويب قبل حدوث التبديل. المحدِّد size-adjust يضرب عرض تقدم كل محرف وحجم الـ em الفعلي بنسبة مئوية. أما ascent-override و descent-override و line-gap-override فتستبدل المقاييس الرأسية للخط بحيث يتوقف ارتفاع صندوق السطر عن الاعتماد على الخط النشط حالياً. يدعم Chrome و Firefox المحدِّدات الأربعة منذ 2021، وأضافها Safari في الإصدار 17. والمتصفحات التي لا تفهمها تتجاهلها، فهذا تحسين تدريجي لا مخاطرة توافقية.
النسبة التي يستخدمها Next.js — ويستخدمها fontaine، الإضافة المستقلة عن إطار العمل التي تؤدي المهمة نفسها في Vite و Nuxt — مباشرة: طبّع متوسط عرض المحرف لكل خط على وحدات الـ em الخاصة به، اقسم قيمة خط الويب على قيمة الاحتياطي، ثم عبّر عن المقاييس الرأسية لخط الويب نسبةً إلى الـ em المعدَّل. سكربت قصير بـ fontkit يقرأ جدولي OS/2 و hhea ويطبع النسب الأربع مرة واحدة، ثم تنتقل المخرجات مباشرة إلى CSS.
متى نستخدم font-display: optional بدل swap؟
استخدم font-display: optional حين يكون CLS في المسار أهم من إظهار خط الهوية للزائر لأول مرة. تمنح optional الخط فترة حجب تقارب 100 مللي ثانية وفترة تبديل تساوي صفراً: إذا لم يجهز الملف في الوقت المناسب يبقي المتصفح على الخط الاحتياطي طوال عرض الصفحة، ويستخدم خط الويب في التنقل التالي بعد أن يستقر في ذاكرة HTTP. ولأن التبديل لا يحدث، لا يحدث انزياح ناتج عنه مهما بلغ تفاوت المقاييس.
القيمة swap لها فترة حجب نحو 100 مللي ثانية وفترة تبديل لا نهائية، فخطر الانزياح معها مرتفع ما لم تتطابق مقاييس الاحتياطي، وهي مناسبة لنص المتن. والقيمة optional فترة تبديلها صفر ولا ينتج عنها انزياح، وهي مناسبة للخطوط العرضية والمسارات الحساسة لـ CLS. والقيمة fallback بينهما بنافذة تبديل نحو ثلاث ثوانٍ. أما block فتنتظر نحو ثلاث ثوانٍ قبل رسم أي نص، فتستبدل الانزياح بنص غير مرئي وبقياس Largest Contentful Paint أسوأ.
لماذا يظل النص العربي يتحرك رغم كل ذلك؟
تظل العربية تتحرك لأن adjustFontFallback يولّد تعديله بالقياس إلى خط نظام لاتيني، بينما الخط الذي يرسم المحارف العربية فعلياً ملف آخر بمقاييس مختلفة: Geeza Pro على macOS و iOS، و Noto Naskh Arabic أو Noto Sans Arabic على Android، و Segoe UI أو Tahoma على Windows. كما أن الخطوط العربية الاحتياطية تميل إلى صناديق سطور أعلى من نظيرتها اللاتينية، فيظهر التفاوت حركةً رأسية في عمود كامل من اليمين إلى اليسار لا مجرد سطر أعيد التفافه.
الحل قاعدة @font-face احتياطية مكتوبة يدوياً لكل نظام كتابة، مقيّدة بـ unicode-range بالنطاقات U+0600-06FF و U+0750-077F و U+08A0-08FF و U+FB50-FDFF و U+FE70-FEFF حتى لا تطالب إلا بنقاط الترميز العربية، مع حساب size-adjust والمحدِّدات الرأسية بالقياس إلى خط النظام الحقيقي بدل Arial.
تفصيلان كلّفانا وقتاً هنا. الدالة fontkit.openSync() على ملف .ttc في macOS تعيد المجموعة لا الوجه الخطي، فتقرأ المقاييس undefined حتى تمرر اسم PostScript كوسيط ثانٍ. و unicode-range ليست زينة: بدونها يطالب الاحتياطي العربي بنقاط الترميز اللاتينية أيضاً، فترث العناوين الإنجليزية تعديلاً محسوباً لنظام كتابة خاطئ.
أي ملفات خطوط تُحمَّل مسبقاً فعلاً؟
يحمّل next/font/google مسبقاً المجموعات الفرعية التي تصرّح بها فقط، وعلى المسارات التي يُستورد فيها موديول الخط فقط. وهذا يجعل subsets إعداد صحة لا إعداد تحسين — إن أغفلته يفشل البناء برسالة خطأ عن مجموعات فرعية مفقودة ما لم تضبط قيمة افتراضية في next.config. كما يعني أن استيراد خط عربي في التخطيط الجذري يحمّل ملفات عربية مسبقاً في كل مسار إنجليزي.
فصل استيراد الخطوط حسب مقطع اللغة أزال هذا الهدر: التخطيط الجذري المشترك يملك الخط اللاتيني، ومقطع التخطيط من اليمين إلى اليسار يستورد الخط العربي. وللأوزان التي تظهر في صفحات قليلة، تُبقي preload: false على قاعدة @font-face دون إنفاق نطاق ترددي مبكر عليها. والخط المتغير خيار افتراضي أفضل عموماً — ملف واحد يغطي محور الوزن كاملاً خير من أربعة أوزان ثابتة لكل منها وسم تحميل مسبق.
كيف نتحقق من الإصلاح بدل التخمين؟
أعد إنتاج التحميل البارد أولاً: افتح أدوات المطور، فعّل Disable cache، واخنق الشبكة. مع ذاكرة خطوط دافئة يُحل الملف من القرص قبل أول رسم فيختفي الانزياح، وهذا بالضبط سبب نجاة هذا النوع من العلل من الاختبار المحلي وظهوره في بيانات الميدان.
ثم انسب الانزياح إلى مصدره بدل تقديره بالعين. واجهة Layout Instability API، عبر PerformanceObserver بنوع 'layout-shift' مع buffered: true، تبلّغ عن كل انزياح مع العُقد المسؤولة عنه. والمسار entry.sources[0].node يشير إلى العنصر الذي تحرك فعلاً، وهو ما يحوّل رقم CLS إلى مكوّن محدد بالاسم. ولوحة Rendering في Chrome DevTools فيها مفتاح Layout Shift Regions يلوّن المناطق المتحركة لحظياً.
يؤكد Lighthouse الإصلاح في المختبر. وحدها بيانات الميدان من CrUX أو من نظام مراقبة المستخدمين الحقيقيين لديك تؤكده على الأجهزة التي تُحتسب في Core Web Vitals، لذا نتعامل مع تشغيل المختبر كنقطة تفتيش ومع بيانات الميدان كحكم نهائي.
الأسئلة الشائعة
س: هل يزيل next/font مقياس CLS وحده؟
ج: بالنسبة للنص اللاتيني مع إبقاء adjustFontFallback على قيمتها الافتراضية true، يزيل next/font معظم انزياح التخطيط الناتج عن الخطوط تلقائياً. لكنه لا يغطي أنظمة الكتابة غير اللاتينية، ويتوقف عن العمل لحظة إشارة CSS إلى اسم عائلة الخط مباشرة بدل متغير CSS الذي يعيده المحمّل.
س: هل font-display: optional أفضل دائماً من swap؟
ج: optional أفضل لـ CLS لأنها لا تبدّل الخط بعد فترة الحجب البالغة نحو 100 مللي ثانية، لكن زائر المرة الأولى قد يرى الخط الاحتياطي طوال عرض الصفحة. استخدم optional للخطوط العرضية و swap مع مقاييس احتياطية مطابقة لنص المتن.
س: هل ما زلنا بحاجة إلى preconnect نحو fonts.gstatic.com مع next/font/google؟
ج: لا. ينزّل next/font/google ملفات الخطوط وقت البناء ويقدمها من نطاقك، فلا يوجد اتصال زمن تشغيل بشبكة Google لتسخينه.
س: من أين نحصل على قيم ascent و descent و xAvgCharWidth لخط ما؟
ج: اقرأها من ملف الخط بواسطة fontkit أو opentype.js من جدولي hhea و OS/2، أو استخدم القيم المنشورة في @capsizecss/metrics التي توفر مقاييس الخطوط الشائعة على الويب وفي الأنظمة.
س: هل ينطبق أي من هذا خارج Next.js؟
ج: نعم. تولّد أداة fontaine قاعدة @font-face الاحتياطية المعدَّلة نفسها لمشاريع Vite و Nuxt و webpack، والمحدِّدات الأربعة هي CSS خالص يمكن كتابته يدوياً في أي بيئة.
قراءات إضافية
هندسة الواجهات
الوضع الداكن دون وميض في Next.js 16: كيف نستخدم سكربت سمة حاجبًا وcolor-scheme وزر تبديل آمنًا مع الـ Hydration
وميض الوضع الداكن عند إعادة تحميل الصفحة مشكلة توقيت: الخادم لا يستطيع قراءة localStorage. نشرح سكربت السمة الحاجب الذي نستخدمه في Next.js 16، ولماذا لا نضع السمة في cookie حفاظًا على الرسم الثابت، وكيف نبني زر تبديل يمر بالـ hydration دون أي عدم تطابق.
هندسة الواجهات
صور 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 قبل مشاركة أي رابط.