مقارنة INP في React: التقسيم إلى شرائح وstartTransition وscheduler.yield
انتظرت نقرة أثناء عمل ثقيل في React مدة 299 مللي ثانية في النسخة المتزامنة و330 مع startTransition، لكن 12 إلى 13 فقط عند تقسيم العمل إلى شرائح.

ينقر المستخدم زرًّا يبدأ عملًا ثقيلًا: تصفية قائمة كبيرة، أو حساب درجات الصفوف، أو بناء فهرس بحث. وما دام هذا العمل يشغل الخيط الرئيسي، تنتظر النقرة التالية، لأن المتصفّح لا يستطيع معالجة الإدخال أو الرسم حتى ينتهي العمل. يقيس مؤشّر Interaction to Next Paint (INP) هذا الانتظار، وهو في شاشات React الكثيفة البيانات غالبًا أوّل مقاييس Core Web Vitals إخفاقًا.
الحلول الشائعة هي startTransition، أو تقسيم العمل باستخدام setTimeout، أو إعادة التحكم إلى المتصفّح باستدعاء scheduler.yield()، أو مكتبة تتولّى التقسيم عنك. أتولّى صيانة إحدى هذه المكتبات، react-inp-chunker، لذلك أردت أرقامًا لا ادّعاءات. يستعرض هذا المقال اختبارًا صغيرًا يشغّل العمل نفسه بخمس طرق، ويقيس كم تنتظر نقرة تقع في أثناء ذلك العمل.
الإعداد
يحسب عبء العمل درجات 20,000 صف، مع 30 جولة من تجزئة النصوص (hashing) لكل صف، ثم يرتّب النتائج ويعرض أفضل 25 منها. تؤدّي كل النسخ العمل نفسه تمامًا، ولا يختلف بينها سوى الجدولة:
| النسخة | كيف يُجدوَل العمل |
|---|---|
| متزامنة | تحسب درجات كل الصفوف داخل معالج النقرة. |
| شرائح setTimeout | شرائح من 5 مللي ثانية مكتوبة يدويًا، تعيد التحكم باستخدام setTimeout(0). |
| startTransition | تغلّف تحديث الحالة في انتقال (transition)، ويجري حساب الدرجات أثناء التصيير. |
| react-inp-chunker | useChunkedTask(rows, scoreRow): شرائح من 5 مللي ثانية، تعيد التحكم باستخدام scheduler.yield(). |
| scheduler.yield | شرائح من 5 مللي ثانية مكتوبة يدويًا، تعيد التحكم باستخدام scheduler.yield(). |
تنقر كل جولة زرّ Score 20,000 rows، ثم تنقر زرًّا ثانيًا بعد 50 مللي ثانية، بينما لا يزال حساب درجات الصفوف جاريًا. ولكلتا النقرتين، يسجّل التطبيق الزمن من حدث النقر حتى إطار الحركة التالي (animation frame): أبكر لحظة يمكن أن تُرسَم فيها الاستجابة. هذا هو التأخير الذي يقيسه INP، مسجَّلًا دون عتبة الإبلاغ البالغة 16 مللي ثانية في Event Timing API. ويسجّل التطبيق أيضًا أطول إطار خلال الجولة، والزمن الكلّي حتى تظهر النتائج على الشاشة.
الأرقام أدناه من Chromium 141 في وضع headless (دون واجهة)، على آلة افتراضية سحابية بنواتين للمعالج مع إبطاء المعالج 4 أضعاف: 30 جولة لكل نسخة بعد 3 جولات إحماء. القيم المطلقة تعتمد على الجهاز. يحتوي مجلّد الاختبار على الشيفرة والمنهجية وكل العيّنات الخام، لتتمكّن من تشغيله على جهازك.
النتائج
الاستنتاج الرئيسي: انتظرت النقرة التي تقع أثناء تشغيل العمل 299 مللي ثانية بالوسيط في النسخة المتزامنة، و330 مللي ثانية مع startTransition، وكلاهما فوق حدّ 200 مللي ثانية الذي يُعدّ عنده INP جيدًا. أما النسخ الثلاث المقسّمة إلى شرائح فأبقت تلك النقرة بين 12 و13 مللي ثانية، أي قرابة إطار واحد.
لماذا لم يُفِد startTransition هنا
أدّى startTransition ما يَعِد به في النقرة الأولى. فقد وصلت النقرة التي تبدأ العمل إلى الإطار التالي في 7 مللي ثانية بالوسيط، مقابل 339 مللي ثانية في النسخة المتزامنة، لأن React يعامل التحديث على أنه غير عاجل ويترك المتصفّح يرسم أولًا.
المشكلة في النقرة الثانية. في هذه النسخة يجري حساب الدرجات أثناء التصيير، داخل useMemo، وهي الطريقة التي تحسب بها شيفرة حقيقية كثيرة البيانات المشتقّة. يستطيع React مقاطعة الانتقال بين المكوّنات، لكنه لا يستطيع التوقّف في منتصف تصيير مكوّن واحد. والتصيير الذي يحسب كل الدرجات كتلة عمل واحدة طويلة، فتنتظر النقرة التي تصل في أثنائه حتى ينتهي كله: 330 مللي ثانية بالوسيط، و420 مللي ثانية عند المئين 75.
تفيد الانتقالات حين تتوزّع الكلفة على مكوّنات كثيرة، كإعادة تصيير شجرة كبيرة بعد تغيير مرشّح. لكنها لا تقسّم عملية حسابية واحدة مكلفة. لذلك يجب أن تعيد العملية الحسابية نفسها التحكم إلى المتصفّح.
المفاضلة: إعادة التحكم تكلّف زمنًا كلّيًا
إعادة التحكم ليست مجانية. فكل شريحة تعيد الخيط الرئيسي إلى المتصفّح، ولا يُستأنف العمل إلا حين يجدوله المتصفّح من جديد، فينتهي العمل نفسه متأخرًا. عرضت النسخة المتزامنة نتائجها بعد 339 مللي ثانية بالوسيط. أما النسخ المقسّمة فاستغرقت 534 مللي ثانية مع react-inp-chunker، و660 مللي ثانية مع scheduler.yield() المكتوب يدويًا، و714 مللي ثانية مع setTimeout.
نسخة setTimeout هي الأبطأ لأن المتصفّحات تقيّد المؤقّتات المتداخلة: فحين تتداخل المؤقّتات عدّة مستويات، ينتظر setTimeout(fn, 0) مدة 4 مللي ثانية على الأقل. ومع شرائح عمل مدّتها 5 مللي ثانية، يتراكم انتظار أدنى قدره 4 مللي ثانية بعد كل شريحة بسرعة. أما scheduler.yield() فمصمَّم لهذه الحالة: يُستأنف العمل قبل المهام الأخرى المنتظرة في الطابور.
يبدو الفرق بين react-inp-chunker وscheduler.yield() المكتوب يدويًا كبيرًا في مخطّط يعرض الوسيط، لكنه هنا ضمن التذبذب الطبيعي بين الجولات؛ إذ تتداخل الجولات الفردية للنسختين تداخلًا واسعًا (انظر results.json). لا يُظهر هذا الاختبار أن أيًّا منهما ينتهي أسرع. ما يدعمه هو ادّعاء أضيق: تمنح المكتبة الاستجابة نفسها التي تمنحها حلقة scheduler.yield() مكتوبة يدويًا، دون أن تكتب تلك الحلقة أو تتولّى صيانتها بنفسك.
ويكشف أطول إطار حدًّا آخر. فالترتيب النهائي للنتائج، وعددها 20,000، ما زال كتلة عمل واحدة، لذا يبقى أطول إطار عند 121 مللي ثانية بالوسيط مع scheduler.yield()، و127 مللي ثانية مع react-inp-chunker، بينما بلغ الوسيط 65 مللي ثانية في نسخة setTimeout. التقسيم لا يفيد إلا في العمل الذي تقسّمه.
ما الذي تستخدمه ومتى
- العمل الصغير لا يحتاج إلى شيء. إذا استغرق التحديث بضع مللي ثوانٍ، فإن الجدولة تضيف تعقيدًا وزمنًا كلّيًا دون فائدة. قِس أولًا: المهمة التي تتجاوز 50 مللي ثانية مهمة طويلة.
- أخرِج الحسابات الثقيلة من التصيير. في نسخة
startTransitionكان العمل داخلuseMemo، فجرى أثناء التصيير حيث لا يستطيع React تقسيمه. شغّله من معالج حدث أو من تأثير (effect) بدلًا من ذلك، وأبقِ التصيير خفيفًا. - استخدم
startTransitionللتصيير المكلف حين تتوزّع الكلفة على مكوّنات كثيرة. - قسّم الحلقات الطويلة على البيانات. حين يكون العمل دالة واحدة تُطبَّق على عناصر كثيرة، قسّمه إلى شرائح وأعِد التحكم بينها.
تبدو النسخة المكتوبة يدويًا من تلك الحلقة هكذا:
async function scoreInSlices(rows: readonly Row[]) {
const scored: Scored[] = [];
let index = 0;
while (index < rows.length) {
await scheduler.yield();
const start = performance.now();
while (index < rows.length && performance.now() - start < 5) {
scored.push(scoreRow(rows[index]));
index += 1;
}
}
return scored;
}
وما زالت تحتاج إلى بديل للمتصفّحات التي لا تدعم scheduler.yield()، وحالة تقدّم للواجهة، وإلغاء، وتنظيف عند إزالة المكوّن. أما مع react-inp-chunker فالعمل نفسه خطّاف (hook) واحد:
import { useChunkedTask } from 'react-inp-chunker';
function ScoredRows({ rows }: { rows: readonly Row[] }) {
// scoreRow runs once per row, in slices of up to 5 ms.
const task = useChunkedTask(rows, scoreRow);
if (task.isError) return <p role="alert">Couldn't score the rows.</p>;
if (task.status !== 'done') {
return (
<progress
value={task.processed}
max={task.total}
aria-label="Scoring rows"
/>
);
}
return <ResultsTable scores={task.results} />;
}
يعيد الخطّاف التصيير مع التقدّم مرة لكل شريحة لا لكل عنصر، ويلغي العمل عند إزالة المكوّن. ويؤدّي تمرير مرجع جديد للمصفوفة rows إلى إعادة تشغيل المهمة، لذا احفظ المصفوفات المشتقّة بالذاكرة (memoize). يغطّي التوثيق الواجهة البرمجية كاملة، ومتى تلجأ إلى أداة أخرى مثل Web Worker.
حدود هذا الاختبار
- جهاز واحد. آلة افتراضية سحابية بنواتين للمعالج مع إبطاء المعالج 4 أضعاف. ستختلف أرقامك، فتحقّق من المقارنة على جهازك.
- متصفّح واحد، في وضع headless. يدعم Chromium 141 الدالة
scheduler.yield(). أما المتصفّحات التي لا تدعمها فتستخدم بديلًا لم تقِسه هذه الجولة. - عبء عمل اصطناعي. تكلّف تجزئة النصوص القدر نفسه لكل صف، بينما يختلف العمل الحقيقي من عنصر إلى آخر ويتعامل مع الذاكرة بطريقة مختلفة.
- أرقام مختبرية لا INP ميداني. يقيس الاختبار الزمن من النقرة إلى الإطار التالي في تشغيل مضبوط. أما INP الحقيقي فيأتي من مستخدمين حقيقيين على أجهزة حقيقية، بينما تؤدّي بقية الصفحة عملها أيضًا.
لمعرفة ما يختبره مستخدموك فعلًا، اجمع INP من الميدان، مثلًا باستخدام مكتبة web-vitals، وقيِّمه عند المئين 75، وهي الطريقة التي تُقيَّم بها Core Web Vitals. ولتجربة الاختبار نفسه على هاتف حقيقي، افتح نسخة المعاينة (preview) على الجهاز واستخدم التصحيح عن بُعد في DevTools.
شغّله بنفسك
يشرح ملف README الخاص بالاختبار المنهجية كاملة. من نسخة مستنسخة من المستودع:
cd bench/inp-demo
npm install
npm run build
npm run preview # keep running, http://localhost:4173/
في طرفية ثانية:
npx playwright install chromium # once
npm run measure # writes results/results.json and RESULTS.md
npm run report # writes the charts, table and screenshots
تُضبط الإعدادات بمتغيّرات البيئة:
RUNS: عدد الجولات لكل نسخة، والقيمة الافتراضية 30.WARMUP: جولات الإحماء، والقيمة الافتراضية 3.CPU_RATE: معامل إبطاء المعالج، والقيمة الافتراضية 4.WORK: جولات التجزئة لكل صف، والقيمة الافتراضية 30.PING_DELAY_MS: التأخير قبل النقرة الثانية بالمللي ثانية، والقيمة الافتراضية 50.HEADED=1: لمشاهدة الجولات.
تشرح دراسة حالة react-inp-chunker سبب وجود المكتبة وكيف تُختبر.