انتقل إلى المحتوى
Dazvix
المدوّنة
الأداء5 دقيقة قراءة

Core Web Vitals كميزانية هندسية، لا كفكرة لاحقة

العمل على الأداء بعد الإطلاق هو معالجة للأخطاء. أما العمل عليه داخل CI فهو هندسة.

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

الحل ليس تدقيقًا أفضل، بل نقل القياس إلى مرحلة أبكر ومنحه أسنانًا. نتعامل مع Core Web Vitals كميزانية — رقم لا يُسمح للبناء بتجاوزه — تمامًا كما يتعامل الفريق مع اختبار وحدة فاشل.

ما الذي تقيسه المقاييس الثلاثة فعلًا

من المهم أن نكون دقيقين هنا، لأن كثيرًا من نصائح الأداء تُحسّن الشيء الخطأ.

  • LCP (Largest Contentful Paint) — اللحظة التي ينتهي فيها رسم أكبر عنصر في منفذ العرض. القيمة الجيدة ≤ 2.5s. وهذا العنصر في الغالب صورة أو عنوان أو فقرة نصية، ويتحكم فيه أساسًا مدى سرعة استجابة الخادم، وما إذا كان المورد قابلًا للاكتشاف في HTML الأولي.
  • INP (Interaction to Next Paint) — زمن استجابة التفاعلات على امتداد زيارة الصفحة كلها، ويُسجَّل قريبًا من أسوأها. القيمة الجيدة ≤ 200ms. حلّ INP محل FID كأحد مقاييس Core Web Vitals في مارس 2024، واجتيازه أصعب بكثير، لأن FID كان يقيس تأخر الإدخال فقط، بينما يقيس INP التفاعل كاملًا حتى الإطار التالي.
  • CLS (Cumulative Layout Shift) — مقدار حركة المحتوى المرئي من تلقاء نفسه دون أي إجراء من المستخدم. القيمة الجيدة ≤ 0.1. وأسبابه صور بلا أبعاد، ولافتات تُحقن في الصفحة، وخطوط ويب تعيد تدفق النص.

مكان الميزانيات هو CI، لا التقارير

التقرير يُقرأ مرة واحدة ثم يُحفظ في الأرشيف. أما مسار البناء الفاشل فيُصلَح بعد ظهر اليوم نفسه. لذلك فإن أول ما نضيفه إلى أي مشروع هو خطوة Lighthouse CI تعمل على نسخة إنتاجية مع كل طلب دمج (pull request)، بشروط تُفشل المهمة بدلًا من الاكتفاء بالتحذير.

// lighthouserc.js
module.exports = {
  ci: {
    collect: {
      startServerCommand: 'npm run start',
      url: [
        'http://localhost:3000/',
        'http://localhost:3000/work',
        'http://localhost:3000/services/wordpress',
      ],
      // Median of 3 runs — a single run is too noisy to gate a merge on.
      numberOfRuns: 3,
    },
    assert: {
      assertions: {
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
        'cumulative-layout-shift':  ['error', { maxNumericValue: 0.1 }],
        'total-blocking-time':      ['error', { maxNumericValue: 200 }],

        // Weight budgets stop regressions before they become timing problems.
        'resource-summary:script:size':     ['error', { maxNumericValue: 170000 }],
        'resource-summary:image:size':      ['error', { maxNumericValue: 400000 }],
        'resource-summary:third-party:count': ['warn', { maxNumericValue: 5 }],
      },
    },
  },
};

ثمة تفصيلان مهمان هنا. الأول هو numberOfRuns — فمشغّلات CI بيئات مشتركة وكثيرة التقلب، وتشغيل Lighthouse مرة واحدة يتذبذب بما يكفي ليتعلم الفريق تجاهل المهمة، وهذا أسوأ من ألا تكون موجودة أصلًا. والثاني أننا نضع الشرط على Total Blocking Time بدلًا من INP، لأن INP يحتاج إلى تفاعل حقيقي. فـ TBT هو أفضل بديل مخبري متاح لدينا، والصفحة ذات TBT المنخفض تجتاز INP في الميدان في الغالب.

ميزانيات الحجم هي التي تصمد

ميزانيات الزمن تخبرك أن شيئًا ما انكسر، أما ميزانيات الحجم فتخبرك ما الذي انكسر، وهي أكثر استقرارًا بكثير بين بيئات CI المختلفة. وعمليًا فإن ميزانية السكربتات هي التي تؤدي العمل: سقف 170KB من JavaScript المضغوط يعني ألا يستطيع أحد أن يضيف بهدوء مكتبة تواريخ وشريطًا دوّارًا وحزمة تحليلات في السبرنت نفسه دون نقاش.

هذا النقاش هو لبّ الموضوع. الميزانية لا تمنع الفريق من إضافة الاعتماد البرمجي؛ بل تُظهر كلفته لحظة اتخاذ القرار، حين يكون التراجع عنه ما زال رخيصًا.

قرارات وقت البناء التي تحدد LCP

متى وُضعت الميزانيات، فإن معظم العمل على LCP لا يكون تحسينًا بالمعنى الدقيق، بل إزالة للرحلات الإضافية بين وصول HTML واكتشاف المتصفح لما يحتاجه.

  1. 01اعرض عنصر LCP على الخادم. إذا لم تظهر الصورة الرئيسية إلا بعد hydration، فلا يستطيع المتصفح البدء بجلبها حتى تُنفَّذ JavaScript. وهذا التغيير الواحد يفوق في الغالب كل تحسينات الصور مجتمعة.
  2. 02امنحها `fetchpriority="high"` ولا تؤجّل تحميلها أبدًا. وضع loading="lazy" على صورة الواجهة الرئيسية من أكثر الأخطاء التي نجدها وتضر بـ LCP بأيدي أصحابها.
  3. 03نفّذ preconnect إلى كل مصدر على المسار الحرج، وحمّل مسبقًا (preload) ملفات الخطوط المستخدمة فعلًا في الجزء الظاهر أولًا من الصفحة — لا العائلة كلها.
  4. 04قدّم الخطوط بـ `font-display: swap` مع خط بديل مطابق في المقاييس، ليُرسم النص فورًا ولا يُحدث التبديل إزاحة في التخطيط. ويجعل size-adjust على الخط البديل في @font-face هذا شبه غير مرئي.
  5. 05حدّد `width` و`height` صريحين (أو `aspect-ratio`) لكل صورة. هذا إصلاح لـ CLS، لكنه يمنع أيضًا إعادة حساب التخطيط أثناء الرسم الحرج.

INP مشكلة في الخيط الرئيسي

هنا تفشل معظم مواقع React الحديثة، ونادرًا ما يظهر ذلك في درجة المختبر. والسبب في الغالب واحد من ثلاثة: معالج حدث يؤدي عملًا متزامنًا، أو تحديث حالة يعيد رسم شجرة أكبر بكثير من الجزء الذي تغيّر، أو سكربت من طرف ثالث يحتكر الخيط الرئيسي في أسوأ لحظة ممكنة.

الحل العملي هو تقسيم المهام الطويلة ليتمكن المتصفح من الرسم بينها. أي مهمة تتجاوز 50ms تُعدّ مهمة طويلة، وأي مهمة تتجاوز 200ms هي إخفاق في INP ينتظر فقط أن يكتشفه مستخدم.

// Yield to the browser so it can paint the visual response
// before the expensive work runs.
async function onFilterChange(value: string) {
  setActiveFilter(value);        // cheap: paints immediately

  await yieldToMain();           // let the browser render the new state

  const results = expensiveFilter(value);  // now do the costly part
  setResults(results);
}

function yieldToMain(): Promise<void> {
  // scheduler.yield() is the purpose-built API; fall back where unsupported.
  if ('scheduler' in globalThis && 'yield' in scheduler) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

النمط يصلح للتعميم: ارسم الاستجابة المرئية أولًا، ثم نفّذ العمل. مرشّح يُظهر التحديد فورًا ويملأ النتائج بعد 80ms يبدو سريعًا. ومرشّح يفعل الأمرين بعد 300ms من الحجب يبدو معطّلًا، مع أنه انتهى أبكر.

أغلق الحلقة ببيانات الميدان

ميزانيات المختبر تمنع التراجعات، لكنها لا تخبرك بما يعيشه مستخدموك الفعليون على هاتف Android متوسط الفئة وعلى شبكة مزدحمة. لذلك تحتاج إلى بيانات الميدان، وهي بضعة أسطر من الشيفرة باستخدام مكتبة web-vitals.

import { onLCP, onINP, onCLS } from 'web-vitals';

function report(metric: { name: string; value: number; rating: string }) {
  // sendBeacon survives the page being unloaded mid-flight.
  navigator.sendBeacon('/api/vitals', JSON.stringify(metric));
}

onLCP(report);
onINP(report);
onCLS(report);

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

ما الذي يتغيّر عمليًا

التحول صغير وأثره كبير. يتوقف الأداء عن أن يكون مرحلة في آخر المشروع يتولاها من يجد وقتًا، ويصبح قيدًا يُبنى التصميم والمعمارية داخل حدوده. ولا يجادل أحد في الشهر السادس حول جدوى الشريط الدوّار، لأن الميزانية أجابت عن ذلك في الأسبوع الثاني.

ميزانية الأداء ليست هدفًا تأمل بلوغه، بل حدٌّ لا يُسمح للبناء بتجاوزه.