Core Web Vitals: ما الذي يقيسه INP فعليًا، وكيف ينقذ scheduler.yield() الخيط الرئيسي
Core Web Vitals: ما الذي يقيسه INP فعليًا، وكيف ينقذ scheduler.yield() الخيط الرئيسي
أوضحتُ في مقال المؤثرات الدقيقة لماذا تتحرك transform وopacity بسلاسة حتى على هاتف ضعيف – لأن المتصفح يتعامل معهما حصريًا في مرحلة التركيب (compositing)، على خيط GPU منفصل، دون لمس التخطيط. هذا لا يزال صحيحًا، لكن يكمن خلفه افتراض يستحق الكشف عنه الآن: لكي تبدأ هذه الحركة أصلًا، يجب على المتصفح أولًا أن يعالج حدث النقر على الخيط الرئيسي – الخيط نفسه الذي يُنفَّذ عليه كل جافاسكريبت لديك. فإذا كان الخيط الرئيسي، لحظة النقر، مشغولًا بتنفيذ مهمة متزامنة طويلة – تصفية مصفوفة كبيرة، أو تحليل استجابة من واجهة برمجية، أو عرض شجرة مكوّنات معقّدة – فلن تتم معالجة حدث النقر قبل أن تنتهي تلك المهمة ولو بلحظة واحدة. قد تكون الحركة نفسها زهيدة التكلفة إلى أقصى حد. لكن هذا لا يهم إن لم يتمكن أحد بعد من تشغيلها.
التفاعل إلى الرسم التالي (Interaction to Next Paint – INP) هو مقياس من مقاييس Core Web Vitals يرصد هذه الفجوة بالتحديد – ليس الوقت حتى أول بايت، ولا وقت تحميل الصفحة، بل الوقت الفاصل بين ضغط المستخدم على أي شيء واللحظة التي يرسم فيها المتصفح فعليًا أثر ذلك التفاعل على الشاشة. حلّ محلّ مقياس FID (تأخير الإدخال الأول) الأقدم في مارس 2024 لسبب مهم واحد: كان FID يقيس فقط التفاعل الأول في الصفحة، لذا يمكن أن تحصل الصفحة على نتيجة ممتازة حتى لو كانت كل نقرة بعد الأولى تتعثر لثانية كاملة. يأخذ INP في الحسبان جميع التفاعلات طوال دورة حياة الصفحة، ويُبلغ عمليًا عن أسوأ حالة متكررة – ولا يمكن «إصلاحه» بانطباع أول جيد.
لماذا لا يمكن مقاطعة جافاسكريبت في منتصف الطريق
الآلية الأساسية التي بدونها لا معنى لمقياس INP: تعمل جافاسكريبت في المتصفح وفق نموذج التشغيل حتى الاكتمال (run-to-completion) – فالمهمة، بمجرد بدء تشغيلها، تُنفَّذ من البداية إلى النهاية دون مقاطعة، بصرف النظر عمّا يحاول المستخدم فعله في الأثناء. النقرة أثناء تنفيذ مهمة كهذه لا تُتجاهَل – بل تدخل الطابور وتنتظر تحرّر الخيط. تُصنَّف المهمة التي تستغرق أكثر من 50 مللي ثانية رسميًا بأنها مهمة طويلة (long task، عبر Long Tasks API) – وهذا ليس رقمًا اعتباطيًا، بل تقريب للحد الذي يبدأ بعده المستخدم بالشعور ذاتيًا بأن شيئًا ما «معلّق»، وهو قريب من حيث الجوهر من عتبة دوهرتي (Doherty) الواردة في مقال المؤثرات الدقيقة، إلا أنه يُقاس هنا من جانب التكلفة الحسابية، لا الشبكية.
ينقسم INP إلى ثلاثة مكوّنات، وغالبًا ما تُضخِّم المهام الطويلة المكوّن الأول منها:
- تأخير الإدخال (input delay) – الوقت من النقرة حتى اللحظة التي يبدأ فيها الخيط الرئيسي أصلًا في معالجة الحدث. هنا بالضبط تحجب المهمة الطويلة كل شيء.
- وقت المعالجة (processing time) – المدة التي يستغرقها فعليًا تنفيذ معالج الحدث لديك.
- تأخير العرض (presentation delay) – الوقت من انتهاء المعالج حتى الرسم الفعلي للإطار الذي يحمل الأثر.
التنازل عن الخيط (Yielding): كيف تُعيد الخيط إلى المتصفح في منتصف العمل
بما أنه لا يمكن مقاطعة المهام من الخارج، فالمخرج الوحيد هو مقاطعتها بوعي من الداخل – بتقسيم العمل المتزامن الطويل إلى أجزاء أصغر، وتسليم التحكم إلى المتصفح بينها، لكي يتمكن من معالجة الإدخال المتراكم ورسم الإطار، قبل أن تستأنف الجزء التالي من العمل.
javascript
async function processInChunks(items, processItem) {const results = [];for (let i = 0; i < items.length; i++) {results.push(processItem(items[i]));if (i % 50 === 0) {if ("scheduler" in window && "yield" in scheduler) {await scheduler.yield();} else {// حل احتياطي: setTimeout(fn, 0)، وليس Promise.resolve().then()await new Promise((resolve) => setTimeout(resolve, 0));}}}return results;}
تفصيل يسهل إغفاله ويُفسد فعليًا أثر هذه التقنية: إن await Promise.resolve() أو queueMicrotask() لا يُسلّمان التحكم إلى المتصفح. فهذه مهام دقيقة (microtasks) – ويُفرَّغ طابور المهام الدقيقة بأكمله قبل أن يفكّر المتصفح أصلًا في معالجة إدخال متراكم أو رسم إطار. فإذا كنت تستخدم في حلقة غير متزامنة المهام الدقيقة فقط لـ«تقسيم» العمل، فإن الأمر من منظور المتصفح لا يزال مهمة واحدة متواصلة – إلا أنها مقسّمة إلى استدعاءات then() بدلًا من أسطر كود بسيطة. لكي تُسلّم الخيط فعليًا، تحتاج إلى حد فاصل من نوع المهمة الكبيرة (macrotask) – مثل setTimeout، أو MessageChannel (أسرع من setTimeout(0) لأنه يتجاوز الحد الأدنى للتأخير الذي تفرضه المتصفحات على المؤقتات المتداخلة)، أو الدالة الجديدة المخصصة لهذا الغرض scheduler.yield() من Scheduler API، والتي تُرتّب أيضًا بذكاء أولوية الاستئناف مقارنة بالمهام الأخرى المنتظرة، بدلًا من الذهاب إلى نهاية الطابور العادي كما يفعل setTimeout.
isInputPending(): لا تُسلّم الخيط إن لم يكن أحد ينتظره
لتسليم التحكم بعد كل عنصر في الحلقة تكلفته الخاصة – فكل مرور عبر حلقة الأحداث يحمل نفقات إضافية. فإن لم ينقر أحد أي شيء في تلك اللحظة، يصبح إيقاف العمل كل 50 تكرارًا مضيعة صرفة للوقت، تُطيل وقت المعالجة الإجمالي دون أي فائدة للمستخدم. تحلّ navigator.scheduling.isInputPending() هذا الأمر بطرح سؤال مباشر: هل ينتظر فعليًا في الطابور أي حدث إدخال لم تتم معالجته بعد، قبل أن تقرر إيقاف العمل.
javascript
function processQueue(tasks) {while (tasks.length > 0) {if (navigator.scheduling?.isInputPending()) {break; // شخص ما ينتظر فعليًا – سلّم الخيط الآن}doExpensiveWork(tasks.shift());}if (tasks.length > 0) {setTimeout(() => processQueue(tasks), 0);}}
هذا يعكس المنطق مقارنة بقاعدة «كل 50 عنصرًا» الجامدة: فبدلًا من التخمين مسبقًا كم من العمل «آمن» بين تسليم وآخر، تسأل المتصفح لحظيًا إن كانت هناك حاجة فعلية لذلك. عند خلوّ طابور الإدخال، يمكن للحلقة أن تستمر دون توقف لعدد تكرارات أكبر بكثير من 50 – وعند حدوث نقرة حقيقية أثناء المعالجة، ستُسلّم التحكم فورًا، تمامًا حيث يكون لذلك معنى.
المزالق والممارسات الجيدة
- المهام الدقيقة لا تُحتسب تسليمًا (yield). هذا هو الخطأ الأكثر شيوعًا عند محاولة «إصلاح» INP – يبدو الكود غير متزامن لاحتوائه على
await، لكن إن كان خلف هذا الـawaitمجردPromise.resolve()، فسيظل المتصفح يعامل الكل كمهمة واحدة غير قابلة للمقاطعة. scheduler.yield()لا تزال بحاجة إلى حل احتياطي. الدعم محدود بشكل رئيسي بالمتصفحات المبنية على Chromium – تعامل معها كتحسين تدريجي معsetTimeoutكبديل آمن، تمامًا مثل Paint API في مقال CSS Houdini.- المهام الطويلة القادمة من نصوص برمجية خارجية (third-party) تُحتسب أيضًا في نتيجة INP لديك. أدوات التحليلات، والإعلانات، وودجات الدردشة – تعيش كلها على الخيط الرئيسي نفسه وتحجبه تمامًا كما يفعل كودك الخاص. سيُظهرها كلها
PerformanceObserverالذي ينصت لمدخلات من نوعlongtask، بصرف النظر عمّن ولّدها – ومن المفيد قياس حركة المرور الحقيقية على الإنتاج (RUM)، لا الاختبارات المحلية فقط على بيئة تطوير فارغة ومخبأة. - يقيس INP أسوأ حالة متكررة طوال دورة حياة الصفحة، لا الانطباع الأول. التفاعل الذي يتعثر فقط بعد عشر دقائق من استخدام التطبيق، حين تكون كمية كبيرة من البيانات قد تراكمت في حالة العميل، لا يزال يُحتسب في النتيجة – واختبار أول بضع نقرات فقط على صفحة محمَّلة حديثًا يقلل بشكل ممنهج من حجم المشكلة الحقيقية.
خلاصة
أوضح مقال المؤثرات الدقيقة كيف تجعل الحركة نفسها لا تكلّف الخيط الرئيسي شيئًا. أما INP فيُظهر النصف الآخر من المعادلة نفسها: حتى أرخص حركة في العالم لن تفيد إن كان الخيط الرئيسي مشغولًا بمهمة جافاسكريبت طويلة وغير قابلة للمقاطعة في اللحظة التي نقر فيها المستخدم. نموذج التشغيل حتى الاكتمال يجعل الوسيلة الوحيدة لتحقيق الاستجابة هي التقسيم الواعي لعملك الثقيل إلى أجزاء، وتسليم التحكم إلى المتصفح بينها – عبر مهمة كبيرة حقيقية، لا مهمة دقيقة، ويُفضَّل أن يكون ذلك عندما تؤكد isInputPending() فعليًا أن أحدًا ينتظر. سلسلة الأداء المُدرَك بأكملها، من عتبة دوهرتي إلى INP، لا تكون أقوى من أضعف حلقاتها – وتلك الحلقة تتضح أكثر فأكثر أنها ليست الشبكة ولا الخادم، بل جافاسكريبت الخاص بك الذي طال بقاؤه دون تقسيم.