رياكتالرسوم المتحركة

Core Web Vitals: ما الذي يقيسه INP فعليًا، وكيف ينقذ scheduler.yield() الخيط الرئيسي

Core Web Vitals: ما الذي يقيسه INP فعليًا، وكيف ينقذ scheduler.yield() الخيط الرئيسي

في مقال التفاعلات الدقيقة أوضحتُ لماذا تتحرّك transform وopacity بسلاسة حتى على هاتف ضعيف – لأنّ المتصفح يتعامل معهما حصرًا في مرحلة التركيب، على خيط GPU منفصل، دون لمس التخطيط. هذا لا يزال صحيحًا، لكن يكمن خلفه افتراض يستحقّ الآن أن يُكشَف: لكي تبدأ هذه الحركة أصلًا، يجب على المتصفح أولًا أن يعالج حدث النقرة على الخيط الرئيسي – الخيط نفسه الذي تُنفَّذ عليه كل جافاسكريبت لديك. فإن كان الخيط الرئيسي، لحظة النقرة، مشغولًا بتنفيذ مهمّة متزامنة طويلة – تصفية مصفوفة كبيرة، أو تحليل استجابة من API، أو عرض شجرة مكوّنات معقّدة – فإنّ حدث النقرة لن يُعالَج قبل أن تنتهي تلك المهمّة ولو بلحظة. قد تكون الحركة نفسها رخيصة إلى أقصى حدّ. لا يهمّ ذلك إن لم يتمكّن أحد بعد من تشغيلها أصلًا.

Interaction to Next Paint (INP) هو مقياس من Core Web Vitals يقيس هذه الفجوة بالضبط – ليس الزمن حتى أوّل بايت، ولا زمن تحميل الصفحة، بل الزمن الفاصل بين ضغط المستخدم لأيّ شيء واللحظة التي يرسم فيها المتصفح فعليًا أثر ذلك التفاعل على الشاشة. حلّ محلّ المقياس الأقدم FID (First Input Delay) في مارس 2024 لسبب مهمّ واحد: كان FID يقيس فقط التفاعل الأول على الصفحة، فقد تحصل صفحة على نتيجة ممتازة حتى لو تقطّعت كل نقرة لاحقة بعد الأولى لثانية كاملة. أمّا INP فيأخذ بعين الاعتبار كل التفاعلات طوال دورة حياة الصفحة، ويُبلّغ عمليًّا عن أسوأ حالة متكرّرة – لا يمكن «إصلاحه» بانطباع أوّل جيّد.

لماذا لا يمكن مقاطعة جافاسكريبت في منتصف الطريق

الآلية الأساسية التي بدونها لا معنى لـINP: تعمل جافاسكريبت في المتصفح وفق نموذج التشغيل حتى الاكتمال (run-to-completion) – بمجرّد بدء تشغيل مهمّة، تُنفَّذ من البداية إلى النهاية دون مقاطعة، بصرف النظر عمّا يحاول المستخدم فعله في الأثناء. النقرة أثناء تنفيذ مهمّة كهذه لا تُتجاهَل – بل تدخل الطابور وتنتظر تحرّر الخيط. تُصنَّف المهمّة التي تستغرق أكثر من 50 مللي ثانية رسميًّا بأنها مهمّة طويلة (long task) وفق Long Tasks API – وهذا ليس رقمًا اعتباطيًّا، بل تقريب للحدّ الذي يبدأ بعده المستخدم يشعر ذاتيًّا بأنّ شيئًا ما «عالق»، قريب في جوهره من عتبة دوهرتي (Doherty threshold) من مقال التفاعلات الدقيقة، إلا أنه يُقاس هنا من زاوية الكلفة الحسابية، لا الشبكية.

ينقسم 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(): لا تُعِد الخيط إن لم يكن أحد ينتظره

لإعادة زمام التحكّم بعد كلّ عنصر في الحلقة كلفتها الخاصة – فكل مرور عبر حلقة الأحداث له نفقات إضافية (overhead). إن لم يضغط أحد أيّ شيء في تلك اللحظة، فإنّ مقاطعة العمل كلّ 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 تكرارًا – وعند نقرة فعلية أثناء المعالجة ستُعيد التحكّم فورًا، بالضبط حيث يهمّ ذلك.

الفخاخ والممارسات الجيدة

  • المهام الدقيقة لا تُحتسَب كتنازل عن الخيط. هذا هو الخطأ الأكثر شيوعًا عند محاولة «إصلاح» INP – يبدو الكود غير متزامن لأنه يحتوي await، لكن إن كان خلف هذا الـawait مجرّد Promise.resolve()، فسيظلّ المتصفح يتعامل مع الكلّ كمهمّة واحدة غير قابلة للمقاطعة.
  • scheduler.yield() لا تزال تحتاج إلى بديل احتياطي. الدعم مقتصر أساسًا على المتصفحات المبنية على Chromium – عاملها كتحسين تدريجي مع setTimeout كخيار احتياطي آمن، تمامًا كما مع Paint API في مقال CSS Houdini.
  • المهام الطويلة من سكربتات طرف ثالث تُحتسَب أيضًا في نتيجة INP لديك. أدوات التحليلات، والإعلانات، وأدوات الدردشة – تعيش على الخيط الرئيسي نفسه وتحجبه تمامًا مثل كودك الخاص. مراقب الأداء PerformanceObserver المستمِع لإدخالات من نوع longtask سيُظهرها جميعًا، بصرف النظر عن مصدرها – يستحقّ الأمر قياس حركة الإنتاج الفعلية (RUM)، لا الاختبارات المحلّية فقط على بيئة تطوير فارغة ومخزَّنة مؤقتًا.
  • يقيس INP أسوأ حالة متكرّرة طوال دورة حياة الصفحة، لا الانطباع الأوّل. التفاعل الذي يتقطّع فقط بعد عشر دقائق من استخدام التطبيق، حين تتراكم بيانات كثيرة في حالة العميل، لا يزال يُحتسَب في النتيجة – واختبار النقرات الأولى فقط على صفحة حُمِّلَت للتوّ يُقلِّل بشكل منهجي من حجم المشكلة الفعلية.

الخلاصة

أظهر مقال التفاعلات الدقيقة كيف تجعل الحركة نفسها لا تُكلِّف الخيط الرئيسي شيئًا. أمّا INP فيُظهر النصف الآخر من المعادلة نفسها: حتى أرخص حركة في العالم لن تفيد إن كان الخيط الرئيسي مشغولًا بمهمّة جافاسكريبت طويلة وغير قابلة للمقاطعة لحظة نقرة المستخدم. نموذج التشغيل حتى الاكتمال يجعل الطريقة الوحيدة لتحقيق الاستجابة هي تقسيم عملك الثقيل بوعي إلى أجزاء، وتسليم التحكّم للمتصفح بينها – عبر مهمّة كبيرة حقيقية، لا مهمّة دقيقة، ويُفضَّل أن يكون ذلك عندما تؤكّد isInputPending() فعليًّا أن أحدًا ينتظر. سلسلة الأداء المُدرَك بأكملها، من عتبة دوهرتي إلى INP، لا تكون أقوى من أضعف حلقاتها – وتلك الحلقة تتبيّن بشكل متزايد أنها ليست الشبكة ولا الخادم، بل جافاسكريبت الخاص بك حين يبقى طويلًا دون تقسيم.