إذا كتبت JavaScript لفترة فلا بد أنك مررت بإحدى هاتين اللحظتين. الأولى أن تضع console.log بعد setTimeout(fn, 0) وبعد Promise.resolve().then(...) فتظهر الأسطر بترتيب لم تتوقعه، فتقول لنفسك إن صفراً من الملي ثانية يعني «الآن»، فلماذا جاء المؤقت بعد الوعد؟ والثانية أشد إزعاجاً، فلنفرض أنك أضفت إلى صفحة المنتجات في موقعك كوداً يبني فهرساً للبحث داخل المتصفح حتى تظهر نتائج البحث فوراً دون الرجوع إلى السيرفر، وكل شيء بدا ممتازاً على جهازك، ثم بدأت تصلك رسائل من المستخدمين بأن زر «أضف إلى السلة» لا يستجيب أحياناً، وأن بعضهم ضغطه مرتين فوجد منتجين في السلة. وعندما تفتح الصفحة لا تجد أي خطأ في الـ Console، ولا يوجد طلب شبكة بطيء، والكود الذي يضيف المنتج إلى السلة سطر واحد.
والسبب في الحالتين واحد، وهو الطريقة التي ينفذ بها المتصفح كودك، فالصفحة كلها تعمل على خيط رئيسي واحد Main Thread، وعليه يعمل كود JavaScript ومعالجات النقرات ورسم الشاشة، وما يقرر من يعمل الآن ومن ينتظر هو حلقة الأحداث Event Loop. وعندما تفهم هذه الحلقة سوف تعرف لماذا جاء المؤقت بعد الوعد، ولماذا انتظرت نقرة المستخدم ثلث ثانية خلف كود الفهرسة، وكيف تقسم هذا العمل حتى تبقى الصفحة سريعة الاستجابة. وهذا المقال من مرحلة هندسة الواجهات وتطبيقات الويب Frontend، وقد شرحنا في مقال الخيوط وasync ترتيب الطوابير في Node.js باختصار، وهنا سوف ننزل إلى التفاصيل في المتصفح أولاً ثم نعود إلى Node.js.
وسوف نناقش في هذا المقال ما يلي:
- لماذا يعمل كود الصفحة على خيط واحد، وما هو مكدس الاستدعاءات Call Stack وقاعدة «التنفيذ حتى النهاية» Run to Completion.
- المهام Tasks والمهام الصغيرة Microtasks: من أين تأتي كل واحدة، ومتى تنفذ، ولماذا يسبق الوعد المؤقت دائماً.
- خطوة الرسم Rendering في الحلقة، وأين يقع
requestAnimationFrameبين المهام، مع تجربة حقيقية في Chrome. - المؤقتات: لماذا
setTimeout(fn, 0)ليس صفراً، والحد الأدنى 4 ms بعد التداخل، وتبطيء المؤقتات في الألواح المخفية. - المهام الطويلة Long Tasks ومقياس INP بأجزائه الثلاثة، وتجربة تقيس كم تنتظر النقرة خلف مهمة فهرسة طويلة.
- تقسيم العمل بـ
setTimeoutوبـscheduler.yield()وبالـ Web Workers، مع أرقام من 15 تشغيلاً لكل طريقة. - حلقة الأحداث في Node.js ومراحلها، و
setImmediateوprocess.nextTick، وتجويع الحلقة بالوعود. - أخطاء شائعة، وجدول قرار، وتدريب عملي.
لماذا يعمل كود الصفحة على خيط Thread واحد؟
عندما تفتح صفحة في المتصفح فإن الصفحة تحصل على خيط رئيسي واحد، وعلى هذا الخيط يحدث كل ما له علاقة بالصفحة نفسها: تحليل HTML، وتنفيذ JavaScript، واستدعاء معالجات الأحداث Event Handlers، وحساب الأنماط Styles والتخطيط Layout والرسم Paint. ويوجد في المتصفح طبعاً خيوط أخرى كثيرة للشبكة وفك الصور وتركيب الطبقات، ولكن كودك الذي يلمس الـ DOM لا يعمل إلا على الخيط الرئيسي، والسبب أن الـ DOM نفسه لم يصمم ليتحمل خيطين يعدلان نفس العنصر في نفس اللحظة. وتخيل لو كان معالج نقرة يحذف عنصراً من القائمة بينما معالج آخر على خيط ثان يقرأ طول نفس القائمة، فسوف تحتاج أقفالاً Locks في كل سطر من كود الواجهة، وسوف تظهر مشكلات حالة السباق Race Condition التي شرحناها في مقال الخيوط في كل صفحة على الإنترنت. لذلك اختار الويب نموذجاً أبسط: خيط واحد للصفحة، وطوابير ينتظر فيها العمل دوره، وحلقة تأخذ من هذه الطوابير بترتيب محدد.
وهذا لا يعني أنك لا تستطيع تشغيل كود على خيط آخر، فهناك الـ Web Workers، وكل Worker له خيطه وحلقة أحداثه الخاصة، ولكنه لا يرى الـ DOM إطلاقاً، ويتواصل مع الصفحة بالرسائل فقط. وسوف نعود إلى الـ Workers في قسم تقسيم العمل، لأنها الحل الصحيح لبعض أنواع العمل وليست الحل لكل شيء.
والنموذج الذي يحكم هذا كله مكتوب في معيار HTML، في قسم حلقات الأحداث Event Loops، وهو نفس النموذج الذي تتبعه Chrome وFirefox وSafari، ولذلك فإن ما سوف تتعلمه هنا ليس تفصيلاً من تفاصيل متصفح واحد، وإنما قواعد مكتوبة في المعيار، مع بعض الحرية التي يتركها المعيار لكل متصفح كما سوف نرى.
مكدس الاستدعاءات Call Stack: التنفيذ حتى النهاية
مكدس الاستدعاءات هو المكان الذي يتتبع فيه محرك JavaScript الدالة التي تعمل الآن ومن استدعاها، فعندما تستدعي دالة يضعها المحرك فوق المكدس، وعندما تنتهي يزيلها، وعندما تستدعي هي دالة أخرى توضع الأخرى فوقها، وهكذا. وهذا هو نفس المكدس Stack الذي يظهر لك في رسالة الخطأ عندما يطبع المتصفح سلسلة الدوال التي أوصلت إلى السطر الذي فشل.
والقاعدة الأهم هنا هي التنفيذ حتى النهاية Run to Completion، أي أن المتصفح عندما يبدأ تنفيذ مهمة فإنه لا يقاطعها في منتصفها ليعالج نقرة أو يرسم الشاشة، وإنما ينتظر حتى يفرغ المكدس تماماً. وهذا يعطيك ضماناً جميلاً، وهو أن المتغيرات التي تقرؤها في دالتك لن يغيرها كود آخر في منتصف التنفيذ، ولذلك لا تحتاج أقفالاً في JavaScript. ولكن نفس القاعدة لها وجه آخر، وهو أن الدالة التي تستغرق 400 ms تحجز الخيط 400 ms كاملة، ولا يستطيع المتصفح خلالها أن يستجيب لأي شيء، ولا حتى أن يحرك مؤشر التحميل المرسوم بـ JavaScript.
وقد يتساءل البعض: إذا كان المكدس لا يقاطع، فكيف تعمل fetch مثلاً وهي تنتظر رد السيرفر ثانية كاملة؟ والإجابة أن الانتظار نفسه لا يحدث على الخيط الرئيسي، فعندما تستدعي fetch يرسل المتصفح الطلب على خيوط الشبكة الخاصة به كما شرحنا في مقال HTTP، وتنتهي دالتك فوراً وتعود إليك بوعد Promise، ويفرغ المكدس. وعندما يصل الرد يضع المتصفح مهمة في الطابور، ثم تأتي الحلقة إلى هذه المهمة في دورها، فيحل الوعد ويعمل الكود الذي بعد await. أي أن JavaScript لا تنتظر أبداً على الخيط الرئيسي، وإنما تسجل ما تريد تنفيذه لاحقاً ثم تخرج، وهذا كل سر البرمجة غير المتزامنة Asynchronous في المتصفح.
المهام Tasks: من أين تأتي؟
المهمة Task، وتسميها بعض المراجع المهمة الكبيرة Macrotask، هي وحدة عمل كاملة يضعها المتصفح في طابور وينفذها لاحقاً من أولها إلى آخرها. وأشهر مصادر المهام كما يلي:
- تحليل الصفحة: تنفيذ كل وسم
<script>عند تحليل HTML هو مهمة. - أحداث المستخدم: النقرة والضغط على المفاتيح والكتابة في حقل، فكل حدث من هذه يصل كمهمة تنفذ معالجاته.
- المؤقتات Timers: عندما يحين وقت
setTimeoutأوsetIntervalيضع المتصفح ردها في طابور المهام، ولا ينفذها فوراً. - الشبكة: وصول رد
fetchأو بيانات WebSocket. - الرسائل:
postMessageبين النوافذ أو مع Worker، وMessageChannel.
ولاحظ أن المعيار لا يتحدث عن طابور مهام واحد، وإنما عن عدة طوابير، ويترك للمتصفح أن يختار في كل دورة من أي طابور يأخذ، بشرط أن تبقى المهام من نفس المصدر بترتيب دخولها. وهذه الحرية هي التي تسمح للمتصفح أن يعطي أحداث المستخدم أولوية أعلى من المؤقتات، فالنقرة التي تنتظر في الطابور قد تسبق مؤقتاً جاهزاً، ولكنها لا تستطيع أن تسبق المهمة التي تعمل الآن مهما كانت أولويتها، لأن التنفيذ حتى النهاية لا استثناء فيه.
والقاعدة الثانية المهمة أن الحلقة تأخذ مهمة واحدة فقط في كل دورة، ثم تعطي الفرصة للمهام الصغيرة وللرسم قبل أن تأخذ المهمة التالية. وهذا بالضبط ما يجعل تقسيم العمل الطويل إلى مهام صغيرة مفيداً، كما سوف نرى في التجربة، فبين كل مهمتين توجد فرصة للنقرة أن تعالج وللشاشة أن ترسم.
المهام الصغيرة Microtasks: لماذا يسبق الوعد Promise المؤقت؟
المهمة الصغيرة Microtask هي عمل قصير تريد تنفيذه بعد أن ينتهي الكود الحالي مباشرة، وقبل أن يعود المتصفح إلى أي مهمة أخرى أو إلى الرسم. ومصادرها في المتصفح كما يلي:
- ردود الوعود:
.thenو.catchو.finally، والجزء الذي بعدawaitداخل الدالةasync. - الدالة queueMicrotask التي تضع دالة في هذا الطابور مباشرة.
- ردود MutationObserver التي تراقب تغييرات الـ DOM.
والفرق الجوهري بين الطابورين هو متى يفرغ كل منهما، فبعد كل مهمة ينفذ المتصفح ما يسميه المعيار نقطة فحص المهام الصغيرة Microtask Checkpoint، أي أنه ينفذ كل ما في طابور المهام الصغيرة، وإذا أضافت مهمة صغيرة مهمة صغيرة أخرى أثناء التنفيذ فإنها تنفذ في نفس النقطة أيضاً، ولا يخرج المتصفح منها حتى يفرغ الطابور تماماً. أما طابور المهام فيأخذ منه مهمة واحدة فقط ثم يعود إلى المهام الصغيرة والرسم. ولهذا السبب يأتي .then قبل setTimeout(fn, 0) دائماً، فالوعد ينفذ في نقطة الفحص التي بعد المهمة الحالية مباشرة، والمؤقت يحتاج أن ينتظر دوره كمهمة جديدة في دورة قادمة.
وقد يتساءل البعض: لماذا وجد طابوران أصلاً؟ لماذا لا يوضع رد الوعد في طابور المهام مثل المؤقت؟ والإجابة أن ردود الوعود عادة تكمل عملاً بدأ للتو، فإذا كتبت const data = await res.json() ثم حدثت الواجهة، فأنت تريد أن يكتمل هذا التحديث قبل أن يرسم المتصفح الشاشة، وإلا ظهرت للمستخدم حالة نصف محدثة. فالمهام الصغيرة تعطيك ضماناً بأن سلسلة الوعود كلها تكتمل قبل الرسم وقبل أن يعالج أي حدث جديد، كما يشرح دليل المهام الصغيرة في MDN.
ولهذا الضمان ثمن، فبما أن المتصفح لا يخرج من نقطة الفحص حتى يفرغ الطابور، فإن سلسلة وعود تضيف نفسها إلى الطابور بلا نهاية تجمد الصفحة تماماً كما تجمدها حلقة while(true)، لأن المتصفح لن يصل إلى الرسم ولا إلى النقرات أبداً. وهذا ما يسمى تجويع الحلقة Starvation، وسوف نقيسه بالأرقام في تجربة Node.js لاحقاً.
والصورة التالية تجمع الخطوات في دورة واحدة من الحلقة: مهمة واحدة، ثم كل المهام الصغيرة، ثم الرسم إذا حان وقته، ثم العودة إلى البداية:

أين يقع الرسم Rendering في الحلقة؟
الخطوة الرابعة في الصورة هي التي ينساها كثير من المطورين، وهي تحديث الشاشة Update the Rendering. فالمتصفح لا يرسم بعد كل مهمة، وإنما عندما تأتي فرصة للرسم Rendering Opportunity، وهذه تأتي عادة مع معدل تحديث الشاشة، أي قرابة 60 مرة في الثانية في أغلب الشاشات، أو مرة كل 16.7 ms تقريباً، وقد تكون أسرع في شاشات 120Hz. وإذا لم يتغير شيء في الصفحة فقد يتخطى المتصفح الرسم كله.
وعندما يقرر المتصفح أن يرسم فإنه يمر بعدة خطوات بالترتيب: أولاً ينفذ ردود requestAnimationFrame التي سجلتها الصفحة، ثم يحسب الأنماط Style، ثم التخطيط Layout، ثم الرسم Paint، ثم تركيب الطبقات Composite. وكيف تعمل كل خطوة من هذه من الداخل، ولماذا يكلف تغيير خاصية أكثر من أخرى، هو موضوع مقال مستقل في نفس المرحلة عن عمل المتصفح من الداخل، والذي يهمنا هنا هو موقع الرسم في الحلقة. فالرسم يحتاج نفس الخيط الرئيسي الذي يعمل عليه كودك، وبالتالي إذا كانت مهمة طويلة تعمل الآن فلن يرسم المتصفح أي إطار Frame حتى تنتهي، ولن يتحرك شيء على الشاشة مهما كان معدل الشاشة.
وهذا يفسر سلوكاً قد يحيرك، فلو كتبت كوداً يضيف عنصر «جار التحميل» إلى الصفحة ثم يبدأ مباشرة حساباً طويلاً في نفس الدالة، فلن يرى المستخدم عنصر التحميل أبداً، لأن المتصفح لم يحصل على فرصة للرسم بين السطرين، وعندما انتهى الحساب كان العنصر قد حذف أو تغير. والحل أن تعطي المتصفح فرصة للرسم قبل الحساب، وهذا ما سوف نتعلمه في قسم تقسيم العمل.
أما requestAnimationFrame فهي المكان الصحيح لأي تعديل بصري يتكرر مع كل إطار، مثل حركة عنصر أو تحديث عداد، لأنها تعمل مرة واحدة قبل كل رسم بالضبط، فلا تضيع حسابات على إطارات لن تظهر كما يحدث مع setInterval(fn, 16) الذي لا يعرف شيئاً عن موعد الإطار. ولاحظ أن المهام الصغيرة التي تضيفها داخل رد requestAnimationFrame تنفذ هي أيضاً قبل أن يكمل المتصفح إلى حساب الأنماط، كما سوف تظهر التجربة التالية.
التجربة الأولى: ترتيب التنفيذ في Chrome
الآن سوف نختبر كل ما سبق في متصفح حقيقي، فالصفحة التالية تسجل في مصفوفة كل خطوة مع الوقت منذ بداية السكربت، وتجمع كل الأنواع التي ذكرناها: كود متزامن، ومؤقت بصفر، ورسالة MessageChannel، ورد requestAnimationFrame، ووعود وqueueMicrotask ودالة async. احفظها باسم order.html:
<pre id="out"></pre>
<script>
// order.html: in which order does the browser run tasks, microtasks and rendering callbacks?
const log = [];
const t0 = performance.now();
const note = (msg) => log.push(`${(performance.now() - t0).toFixed(2).padStart(7)} ms ${msg}`);
note('1 sync: start of the script');
setTimeout(() => note('task: setTimeout 0'), 0);
const ch = new MessageChannel();
ch.port1.onmessage = () => note('task: MessageChannel message');
ch.port2.postMessage(null);
requestAnimationFrame(() => {
note('render step: requestAnimationFrame (before style, layout, paint)');
Promise.resolve().then(() => note('microtask queued inside rAF'));
});
Promise.resolve().then(() => {
note('microtask: promise .then');
queueMicrotask(() => note('microtask: queued by another microtask'));
});
queueMicrotask(() => note('microtask: queueMicrotask'));
(async () => {
note('2 sync: async function before await');
await null;
note('microtask: async function after await');
})();
note('3 sync: end of the script');
// print once everything above has had its turn
setTimeout(() => { document.getElementById('out').textContent = log.join('\n'); window.__order = log; }, 200);
</script>ولكي تكون النتيجة قابلة للتكرار لم نفتح الصفحة باليد، وإنما شغلنا Chrome بدون واجهة Headless وتحكمنا فيه عبر بروتوكول DevTools Protocol، وهو نفس البروتوكول الذي تستخدمه أدوات الاختبار مثل Playwright التي شرحناها في دليل اختبارات E2E. والسكربت run.mjs يشغل سيرفراً صغيراً على المنفذ 19345 يقدم ملفات التجربة، ثم يفتح الصفحة خمس مرات ويقرأ المصفوفة window.__order في كل مرة. والأمر كما يلي:
node content/articles/js-event-loop/lab/run.mjsوالمخرج للتحميل الأول سوف يكون كما يلي:
Browser: Chrome/156.0.8078.49
== order.html, first load
0.10 ms 1 sync: start of the script
0.40 ms 2 sync: async function before await
0.40 ms 3 sync: end of the script
0.50 ms microtask: promise .then
0.50 ms microtask: queueMicrotask
0.50 ms microtask: async function after await
0.50 ms microtask: queued by another microtask
2.30 ms task: setTimeout 0
2.50 ms task: MessageChannel message
11.60 ms render step: requestAnimationFrame (before style, layout, paint)
11.60 ms microtask queued inside rAFفي المخرج أعلاه لاحظ التالي:
- الأسطر الثلاثة الأولى كود متزامن، بما فيه السطر الذي داخل الدالة
asyncقبلawait، فالدالةasyncتعمل متزامنة حتى تصل إلى أولawait، وعندها فقط يتحول باقيها إلى مهمة صغيرة. - بعد أن انتهى السكربت، وهو نفسه مهمة، فرغ المكدس فبدأت نقطة فحص المهام الصغيرة، فنفذت الأربعة كلها عند 0.50 ms، بالترتيب الذي دخلت به الطابور.
- السطر
queued by another microtaskأضافه رد.thenأثناء تنفيذه، ومع ذلك نفذ في نفس نقطة الفحص قبل المؤقت، وهذا هو المعنى العملي لعبارة «حتى يفرغ الطابور». - المؤقت جاء عند 2.30 ms مع أن مدته صفر، ثم رسالة MessageChannel بعده، فكلاهما مهمة جديدة تنتظر دورها في دورة قادمة.
- رد
requestAnimationFrameجاء عند 11.60 ms، أي بعد قرابة تسعة ملي ثانية لم يحدث فيها شيء، لأن المتصفح كان ينتظر موعد الإطار التالي، والمهمة الصغيرة التي أضيفت داخله نفذت مباشرة بعده في نفس الوقت.
والآن السؤال الأهم: هل هذا الترتيب ثابت؟ والمخرج للتحميلات الخمسة يجيب، وهو كما طبعه السكربت:
== position of rAF vs setTimeout in 5 loads
0.50 ms microtask: promise .then -> 0.50 ms microtask: queueMicrotask -> 0.50 ms microtask: async function after await -> 0.50 ms microtask: queued by another microtask -> setTimeout (2.30 ms) -> MessageChannel (2.50 ms) -> requestAnimationFrame (11.60 ms)
0.20 ms microtask: promise .then -> 0.30 ms microtask: queueMicrotask -> 0.30 ms microtask: async function after await -> 0.30 ms microtask: queued by another microtask -> requestAnimationFrame (0.40 ms) -> setTimeout (1.20 ms) -> MessageChannel (1.40 ms)
0.40 ms microtask: promise .then -> 0.40 ms microtask: queueMicrotask -> 0.40 ms microtask: async function after await -> 0.40 ms microtask: queued by another microtask -> requestAnimationFrame (0.80 ms) -> setTimeout (2.70 ms) -> MessageChannel (2.90 ms)
0.20 ms microtask: promise .then -> 0.20 ms microtask: queueMicrotask -> 0.20 ms microtask: async function after await -> 0.20 ms microtask: queued by another microtask -> setTimeout (0.30 ms) -> MessageChannel (0.30 ms) -> requestAnimationFrame (4.40 ms)
0.20 ms microtask: promise .then -> 0.30 ms microtask: queueMicrotask -> 0.30 ms microtask: async function after await -> 0.30 ms microtask: queued by another microtask -> setTimeout (1.30 ms) -> MessageChannel (1.40 ms) -> requestAnimationFrame (11.60 ms)وهنا مشكلة صغيرة واجهناها في السكربت نفسه، فقد أردنا أن نطبع في كل سطر المهام وخطوة الرسم فقط، فكتبنا فلتراً يبحث عن النص task: ، ولكن الكلمة microtask: تحتوي هي أيضاً على task: ، فظهرت المهام الصغيرة في السطر مع المهام. وقد تركنا المخرج كما هو لأنه لا يغير النتيجة، بل يؤكدها، والدرس هنا أن المطابقة بالنص الجزئي في السجلات تخدعك بسهولة، فطابق بالكلمة الكاملة أو ببداية السطر.
وأهم ما في هذه الأسطر الخمسة ما يلي:
- الجزء الثابت في كل التحميلات: الكود المتزامن ثم المهام الصغيرة الأربع ثم المهام، و
setTimeoutقبل MessageChannel في كل مرة لأن كلاهما سجل بهذا الترتيب. - الجزء المتغير:
requestAnimationFrameجاء بعد المؤقت في ثلاثة تحميلات، وقبله في تحميلين عند 0.40 ms و0.80 ms، والسبب أن موعد الإطار التالي يعتمد على اللحظة التي بدأ فيها السكربت بالنسبة لساعة الشاشة، فإذا صادف أن الإطار كان مستحقاً مباشرة بعد السكربت رسم المتصفح قبل أن يأخذ مهمة المؤقت.
والقاعدة العملية من هذه التجربة: ترتيب المهام الصغيرة مضمون، وترتيب المهام من نفس المصدر مضمون، أما ترتيب المؤقت بالنسبة للرسم فغير مضمون، فلا تكتب كوداً يفترض أن setTimeout(fn, 0) يعمل قبل الإطار التالي أو بعده. والصورة التالية تبين ترتيب التحميل الأول والتحميلات الخمسة:

وهذا السؤال عن ترتيب الأسطر من أشهر أسئلة مقابلات الواجهات، وقد جمعنا أسئلة أخرى من نفس النوع في أسئلة مقابلة مطور Frontend، ولكن الهدف هنا أبعد من الإجابة الصحيحة في المقابلة، فالهدف أن تعرف أين يقع كل سطر من كودك في الحلقة عندما تبطئ الصفحة.
المؤقتات Timers: لماذا setTimeout(fn, 0) ليس صفراً؟
الرقم الذي تمرره إلى setTimeout هو أقل مدة قبل أن يضع المتصفح الرد في طابور المهام، وليس الموعد الذي ينفذ فيه الرد، فبعد أن تمر المدة يجب أن ينتظر الرد دوره خلف المهام التي في الطابور وخلف المهمة التي تعمل الآن. فإذا طلبت 10 ms وكانت هناك مهمة طويلة مدتها 300 ms فسوف ينفذ ردك بعد 300 ms على الأقل. وفوق ذلك هناك حالتان يرفع فيهما المتصفح المدة بنفسه.
الحد الأدنى بعد التداخل Nesting
يقول قسم المؤقتات في معيار HTML إن المؤقت الذي ينشأ من داخل مؤقت آخر يحمل مستوى تداخل Nesting Level، وإذا زاد هذا المستوى على 5 وكانت المدة أقل من 4 ms فإن المتصفح يجعلها 4 ms. والسبب تاريخي وعملي في نفس الوقت، فبعض الصفحات القديمة كانت تكتب حلقات من setTimeout(fn, 0) المتداخلة تستهلك المعالج بلا فائدة، فوضع المعيار هذا الحد ليحمي الجهاز من هذه الحلقات. والصفحة التالية تقيس ذلك، فكل مؤقت ينشئ التالي من داخله، ونسجل الزمن بين كل مؤقت والذي يليه:
<pre id="out"></pre>
<script>
// clamp.html: setTimeout(fn, 0) nested inside itself; how long is each hop really?
const gaps = [];
let last = performance.now();
function hop(level) {
const now = performance.now();
gaps.push({ level, gap: +(now - last).toFixed(2) });
last = now;
if (level < 12) setTimeout(() => hop(level + 1), 0);
else { window.__clamp = gaps; document.getElementById('out').textContent = gaps.map(g => `level ${String(g.level).padStart(2)}: ${g.gap} ms`).join('\n'); }
}
setTimeout(() => hop(1), 0);
</script>والمخرج من نفس التشغيل سوف يكون كما يلي:
== clamp.html
level 1: 2.6 ms
level 2: 0.4 ms
level 3: 0 ms
level 4: 0.1 ms
level 5: 0.1 ms
level 6: 0 ms
level 7: 4.4 ms
level 8: 4.4 ms
level 9: 4.4 ms
level 10: 4.5 ms
level 11: 4.1 ms
level 12: 4.9 msفي المخرج أعلاه لاحظ التالي:
- المؤقتات من الأول إلى السادس عملت بلا تأخير يذكر، وأرقام مثل 0 و0.1 ms تعني أن المؤقت نفذ في الدورة التالية مباشرة، والأول أخذ 2.6 ms لأنه بدأ من سكربت الصفحة أثناء تحميلها.
- من المؤقت السابع أصبح كل انتظار 4 ms أو أكثر قليلاً، فالمؤقت الأول أنشأه السكربت ومستوى تداخله صفر، والسابع أنشأه مؤقت مستواه 6، أي أكبر من 5، فطبق المتصفح الحد. وهذا يطابق ما يذكره توثيق setTimeout في MDN من أن الحد يبدأ بعد خمسة مؤقتات متداخلة.
- الأرقام فوق 4 ms مثل 4.9 هي الحد نفسه مع زمن انتظار الدور في الطابور.

وهذا الحد سوف يظهر لنا مرة أخرى بشكل غير متوقع في تجربة تقسيم العمل، فمن يقسم عمله بـ setTimeout(fn, 0) في سلسلة يدفع هذه الملي ثانيات الأربع مع كل جزء بعد الجزء الخامس.
المؤقتات في الألواح Tabs المخفية
الحالة الثانية أشد أثراً، فعندما ينتقل المستخدم إلى لوح Tab آخر يبطئ المتصفح مؤقتات اللوح المخفي ليوفر البطارية. وحسب نفس صفحة MDN فإن Chrome يفحص المؤقتات في اللوح المخفي مرة كل ثانية، وإذا بقي اللوح مخفياً أكثر من خمس دقائق، وكانت المؤقتات متداخلة خمس مرات أو أكثر، والصفحة صامتة، فإنه يفحصها مرة كل دقيقة فقط، وهذا ما يسميه التبطيء المكثف Intensive Throttling. ولهذا أثر مباشر على كود تعتمد عليه كثير من التطبيقات، مثل مؤقت يجدد التوكن قبل انتهائه، كما شرحنا في مقال الجلسات وJWT، فإذا كان المؤقت مضبوطاً قبل الانتهاء بثلاثين ثانية واللوح مخفي منذ عشر دقائق فقد يعمل بعد انتهاء التوكن فعلاً. والحل ألا تعتمد على دقة المؤقت في هذه الحالات، وإنما تفحص صلاحية التوكن قبل كل طلب، أو تستمع إلى الحدث visibilitychange وتجدد عندما يعود المستخدم إلى اللوح.
المهام الطويلة Long Tasks ومقياس INP
نعود الآن إلى صفحة المنتجات التي بدأنا بها. المهمة الطويلة Long Task حسب واجهة Long Tasks في MDN هي أي مهمة تحجز الخيط الرئيسي 50 ms أو أكثر، والرقم 50 ليس عشوائياً، فحسب نموذج RAIL من فريق Chrome يجب أن يرى المستخدم استجابة لنقرته خلال 100 ms، وإذا أراد المتصفح أن يستجيب لنقرة المستخدم في حدود 100 ms فإن أي مهمة أطول من 50 ms تأكل نصف هذه الميزانية قبل أن تبدأ معالجة النقرة أصلاً.
ولقياس أثر هذه المهام على المستخدم يوجد اليوم مقياس اسمه زمن التفاعل حتى الرسم التالي Interaction to Next Paint، واختصاراً INP، وقد أصبح أحد مقاييس Core Web Vitals بدلاً من FID في 12 مارس 2024 كما أعلن فريق Chrome في web.dev. ويقيس INP الزمن من لحظة تفاعل المستخدم مع الصفحة بالنقر أو اللمس أو الكتابة حتى يرسم المتصفح الإطار التالي الذي يظهر نتيجة هذا التفاعل، ويأخذ تقريباً أسوأ تفاعل في زيارة الصفحة. وحسب صفحة INP في web.dev فإن القيمة 200 ms أو أقل جيدة، وما فوق 500 ms ضعيف، وما بينهما يحتاج تحسيناً.
وأهم ما في INP أنه يقسم زمن التفاعل إلى ثلاثة أجزاء، وكل جزء له سبب مختلف وعلاج مختلف:
- تأخير الإدخال Input Delay: من لحظة النقرة حتى يبدأ أول معالج للحدث، وسببه غالباً مهمة أخرى كانت تعمل عندما نقر المستخدم، مثل الفهرسة في مثالنا، فالنقرة تنتظر في الطابور.
- زمن المعالجة Processing: زمن تنفيذ معالجات الحدث نفسها، وسببه كود ثقيل داخل المعالج.
- تأخير العرض Presentation Delay: من انتهاء المعالجات حتى يظهر الإطار، ويشمل حساب الأنماط والتخطيط والرسم لما غيرته المعالجات.
ويقيس المتصفح هذه الأجزاء بنفسه عبر واجهة Event Timing، فكل حدث تزيد مدته على حد معين يظهر كمدخل من النوع PerformanceEventTiming فيه startTime وهو لحظة الحدث، وprocessingStart وprocessingEnd حول المعالجات، وduration حتى الرسم التالي. ويذكر MDN أن الحد الافتراضي 104 ms وأن أقل حد يمكنك طلبه هو 16 ms، ويذكر معيار Event Timing أن duration تقرب إلى مضاعفات 8 ms لأسباب أمنية، ولذلك سوف ترى في التجربة أرقاماً مثل 16 و432 بالضبط.
والقاعدة التي تجمع هذا القسم: كل مهمة تزيد على 50 ms في صفحتك هي تأخير محتمل لنقرة مستخدم، سواء كانت مهمتك أنت أو سكربت تحليلات أو إعلانات أضيف إلى الصفحة، فالخيط واحد والطابور واحد للجميع.
التجربة الثانية: نقرة تنتظر خلف مهمة فهرسة
الآن سوف نبني صفحة المنتجات نفسها بشكل مصغر، ونقيس ما يحدث للنقرة. في الصفحة longtask.html 120,000 منتج بأسماء تجريبية، وزر «أضف إلى السلة»، وكود يبني فهرساً للبحث، والفهرسة عمل حقيقي وليس انتظاراً مصطنعاً: لكل منتج نوحد شكل الاسم بـ normalize ونحوله إلى أحرف صغيرة ونقسمه إلى كلمات، ثم نضيف رقم المنتج إلى قائمة كل كلمة في Map. والجزء المهم من الصفحة كما يلي:
// longtask.html?mode=block|chunk|handler (the full file is in lab/longtask.html)
const index = new Map();
function indexOne(name, id) { // the real work: normalise the name, split it, add each token to the index
for (const tok of name.normalize('NFKD').toLowerCase().split(/[\s-]+/)) {
let ids = index.get(tok); if (!ids) index.set(tok, ids = []); ids.push(id);
}
}
const useTimeout = new URLSearchParams(location.search).get('yield') === 'timeout';
const yieldToMain = () => (globalThis.scheduler?.yield && !useTimeout ? scheduler.yield() : new Promise(r => setTimeout(r, 0)));
async function runJob() {
jobStart = performance.now(); document.getElementById('status').textContent = 'تعمل';
if (mode === 'chunk') {
let deadline = performance.now() + 10;
for (let i = 0; i < N; i++) {
indexOne(products[i], i);
if (performance.now() >= deadline) { slices++; await yieldToMain(); deadline = performance.now() + 10; }
}
slices++;
} else {
for (let i = 0; i < N; i++) indexOne(products[i], i);
slices = 1;
}
jobEnd = performance.now(); document.getElementById('status').textContent = 'انتهت';
}
document.getElementById('add').addEventListener('click', () => {
const p0 = performance.now();
if (mode === 'handler') { for (let i = 0; i < N; i++) indexOne(products[i], i); }
document.getElementById('cart').textContent = `السلة: ${++cart}`;
clickAt = { handlerStart: p0, handlerEnd: performance.now() };
});
// what the browser itself measured
new PerformanceObserver(l => { for (const e of l.getEntries()) if (['pointerdown', 'pointerup', 'click', 'mousedown', 'mouseup'].includes(e.name))
result.events.push({ name: e.name, startTime: +e.startTime.toFixed(1), inputDelay: +(e.processingStart - e.startTime).toFixed(1),
processing: +(e.processingEnd - e.processingStart).toFixed(1), duration: e.duration, interactionId: e.interactionId }); })
.observe({ type: 'event', durationThreshold: 16, buffered: true });
new PerformanceObserver(l => { for (const e of l.getEntries()) result.longtasks.push({ startTime: +e.startTime.toFixed(1), duration: +e.duration.toFixed(1) }); })
.observe({ type: 'longtask', buffered: true });
let lastFrame = performance.now();
(function frame(t) { result.frames.push(+(t - lastFrame).toFixed(1)); lastFrame = t; requestAnimationFrame(frame); })(performance.now());في الكود أعلاه لاحظ التالي:
- للصفحة ثلاثة أوضاع:
blockتبني الفهرس في مهمة واحدة، وchunkتبنيه في أجزاء مدة كل منها قرابة 10 ms ثم تعطي الخيط للمتصفح، وhandlerلا تبني الفهرس في الخلفية، وإنما يبنيه معالج النقرة نفسه قبل تحديث السلة. - الدالة
yieldToMainتستخدمscheduler.yield()إذا كانت موجودة في المتصفح، وإلا تعود إلى وعد يحل فيsetTimeout، والمعاملyield=timeoutفي الرابط يجبرها علىsetTimeoutلنقارن الطريقتين. - التقسيم بالوقت وليس بعدد المنتجات، أي «اعمل حتى تمر 10 ms ثم توقف»، والسبب أن نفس عدد المنتجات يستغرق وقتاً مختلفاً على هاتف ضعيف وعلى حاسوب سريع، والوقت هو ما يشعر به المستخدم.
- ثلاثة مراقبين: Event Timing للنقرة، وLong Tasks للمهام فوق 50 ms، وحلقة
requestAnimationFrameتسجل الزمن بين كل إطارين، فإذا تجمدت الشاشة ظهرت فجوة كبيرة.
والسكربت run.mjs يفتح الصفحة في كل وضع، ويبدأ الفهرسة، وبعد 60 ms يرسل نقرة حقيقية على الزر عبر الأمر Input.dispatchMouseEvent، أي نفس مسار الإدخال الذي تمر به نقرة الفأرة، ويسجل في نفس الوقت تتبع أداء Performance Trace عبر نطاق Tracing في البروتوكول، وهو نفس التتبع الذي تراه في لوح Performance في DevTools. والجزء الخاص بالنقرة كما يلي:
// read the button position BEFORE the job: Runtime.evaluate also runs on the page's main thread and would wait for the long task
const box = await b.evaluate(`(() => { const r = document.getElementById('add').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; })()`);
await b.evaluate('startJob()');
await sleep(60); // the user clicks 60 ms after the job started
const sent = Date.now();
for (const type of ['mouseMoved', 'mousePressed', 'mouseReleased']) await b.send('Input.dispatchMouseEvent', { type, x: box.x, y: box.y, button: 'left', clickCount: 1, timestamp: Date.now() / 1000 });والمخرج للوضع block سوف يكون كما يلي:
== longtask.html?mode=block (n=120000, yield via scheduler.yield)
job: 362.9 ms in 1 slice(s); longest task 363 ms; long tasks seen: 2; max frame gap 350 ms
pointerdown inputDelay 2.5 ms, processing 0.1 ms, duration 16 ms, interactionId 7329
click inputDelay 2.3 ms, processing 1.6 ms, duration 16 ms, interactionId 7329
page clock: job 1295.7 -> 1658.6 ms; click handler ran at 1665.8 ms; frame gaps over 50 ms: 350
CDP mouse events acknowledged after 299 ms; from sending the click to the handler starting: 298.7 ms
trace: 1 main-thread tasks >= 50 ms: 363.2 ms; tasks 1-50 ms: 3; EventDispatch: mousedown 0 ms, click 1.5 msوهذا المخرج يحتاج قراءة سطراً سطراً:
- السطر
job: بناء الفهرس استغرق 362.9 ms في مهمة واحدة، وواجهة Long Tasks رأتها مهمة طويلة بطول 363 ms، وأطول فجوة بين إطارين 350 ms، أي أن الشاشة تجمدت ثلث ثانية لم يرسم فيها المتصفح أي إطار. أماlong tasks seen: 2فالمهمة الثانية من تحميل الصفحة نفسها قبل أن نبدأ. - سطرا
pointerdownوclick: هذا ما سجلته Event Timing، وفيه تأخير إدخال 2.5 ms فقط. وهذا الرقم غير صحيح لما حدث فعلاً، وسوف نشرح السبب بعد قليل. - السطر
page clock: بدأت المهمة عند 1295.7 ms من ساعة الصفحة وانتهت عند 1658.6 ms، وبدأ معالج النقرة عند 1665.8 ms، أي بعد انتهاء المهمة بسبعة ملي ثانية، مع أن النقرة أرسلت بعد بدايتها بقرابة 71 ms. - السطر
CDP: قسنا الزمن من خارج الصفحة، من لحظة إرسال النقرة في Node.js حتى بدأ المعالج، بنفس ساعة الجهاز، فكان 298.7 ms. وهذا هو تأخير الإدخال الحقيقي الذي شعر به «المستخدم». - السطر
trace: التتبع من DevTools رأى مهمة واحدة فوق 50 ms على الخيط الرئيسي بطول 363.2 ms، وهي الفهرسة، ورأى أن إرسال حدثclickنفسه استغرق 1.5 ms فقط، أي أن المعالج سريع، والمشكلة كلها في الانتظار قبله.

مشكلتان واجهناهما في القياس
المشكلة الأولى ظهرت في أول تشغيل للتجربة، فقد كان السكربت يبدأ الفهرسة، ثم ينتظر 60 ms، ثم يقرأ موقع الزر بالأمر Runtime.evaluate، ثم يرسل النقرة. والنتيجة أن معالج النقرة بدأ بعد نهاية الفهرسة في كل مرة تقريباً، وأن الصفحة بدت سريعة. والسبب أن Runtime.evaluate ينفذ كوداً داخل الصفحة، والكود داخل الصفحة يحتاج الخيط الرئيسي، فانتظر أمر القراءة نفسه حتى انتهت الفهرسة، وبالتالي أرسلت النقرة بعد أن فرغ الخيط. والحل كان أن نقرأ موقع الزر قبل أن نبدأ الفهرسة، كما ترى في التعليق في الكود أعلاه. والدرس هنا أوسع من التجربة، فأي أداة تقيس صفحة عبر تنفيذ كود داخلها، سواء كانت أداة اختبار أو سكربت مراقبة، تعيش هي أيضاً في نفس الحلقة، وقد تقيس الصفحة بعد أن تنتهي المشكلة وليس أثناءها.
والمشكلة الثانية أن Event Timing سجلت تأخير إدخال بين 2 و20 ms تقريباً في التشغيلات المختلفة للوضع block، بينما القياس من الخارج كان قرابة 300 ms، بل إن أحد التشغيلات لم يسجل أي مدخل للنقرة أصلاً لأن المدة المحسوبة كانت أقل من حد 16 ms. وعندما مررنا وقت الإرسال في المعامل timestamp مع الأمر لم يتغير شيء. والذي نستنتجه من الأرقام أن الحدث المرسل عبر البروتوكول يأخذ وقته عندما يصل إلى الصفحة وليس عندما أرسلناه، فلا ترى Event Timing الانتظار الذي حدث قبل ذلك. أما النقرة الحقيقية من فأرة المستخدم فيأخذ وقتها من نظام التشغيل، ولذلك تظهر في INP عند المستخدمين الحقيقيين. والقاعدة العملية: في اختبارات المختبر الآلية قس الزمن من خارج الصفحة كما فعلنا، وفي الإنتاج اعتمد على قياس المستخدمين الحقيقيين عبر مكتبة web-vitals.
ماذا يحدث إذا وضعنا العمل داخل معالج النقرة Event Handler نفسه؟
في الوضع handler لا توجد مهمة في الخلفية، فعندما ينقر المستخدم يبدأ المعالج فوراً، ولكنه يبني الفهرس كاملاً قبل أن يحدث السلة، وهذا يحدث كثيراً في الواقع عندما يقرر المطور أن «يجهز البيانات عند أول نقرة». والمخرج كما يلي:
== longtask.html?mode=handler (n=120000, yield via scheduler.yield)
job: 0 ms in 0 slice(s); longest task 422 ms; long tasks seen: 1; max frame gap 416.7 ms
pointerdown inputDelay 2.6 ms, processing 0 ms, duration 432 ms, interactionId 6862
click inputDelay 4 ms, processing 421.8 ms, duration 432 ms, interactionId 6862
page clock: job 0 -> 0 ms; click handler ran at 1360.9 ms; frame gaps over 50 ms: 416.7
CDP mouse events acknowledged after 464 ms; from sending the click to the handler starting: 32.2 ms
trace: 1 main-thread tasks >= 50 ms: 422.4 ms; tasks 1-50 ms: 1; EventDispatch: mousedown 0 ms, click 421.8 msفي المخرج أعلاه لاحظ التالي:
- الآن تأخير الإدخال صغير، 4 ms في Event Timing و32.2 ms من الخارج، لأن الخيط كان فارغاً عندما نقر المستخدم.
- ولكن زمن المعالجة
processingأصبح 421.8 ms، ومدة التفاعل كلهاduration432 ms، وهي من مضاعفات 8 كما ذكرنا، وهذه القيمة تقع في منطقة «يحتاج تحسيناً» في INP وقريبة من حد «ضعيف». - التتبع رأى نفس الشيء من زاوية أخرى: إرسال الحدث
clickوحده استغرق 421.8 ms، والمهمة الطويلة 422.4 ms هي النقرة نفسها. - هنا لم تخدعنا Event Timing، لأن المشكلة في المعالجة وليست في الانتظار قبل الحدث، فالمقياس يرى ما يحدث داخل المعالج بدقة.
والنتيجة النهائية للمستخدم واحدة في الحالتين: نقر الزر ولم يتغير رقم السلة إلا بعد ثلث ثانية أو أكثر، ولكن العلاج مختلف، ففي الحالة الأولى تقسم المهمة التي في الخلفية، وفي الثانية تخرج العمل الثقيل من المعالج، فتحدث السلة أولاً ثم تبني الفهرس بعد أن يرى المستخدم النتيجة. والصورة التالية تبين الحالتين على محور زمني واحد بأرقام هذا التشغيل:


كيف تقسم العمل الطويل؟
إذا كان عندك عمل يحتاج 400 ms من المعالج فلا توجد طريقة لتجعله يحتاج أقل بمجرد إعادة ترتيبه، فالعمل هو العمل. والذي تستطيع فعله هو أن تقرر متى يعمل وعلى أي خيط، وهنا عدة طرق، وكل واحدة تحل جزءاً من المشكلة وتترك جزءاً، كما يلي:
الطريقة البسيطة: setTimeout بين الأجزاء
تقسم العمل إلى أجزاء، وبعد كل جزء تنتظر وعداً يحل في setTimeout(r, 0)، فيصبح باقي العمل مهمة جديدة في آخر الطابور، وبين المهمتين يحصل المتصفح على فرصة لمعالجة النقرة ورسم إطار. وهذه الطريقة تعمل في كل المتصفحات منذ زمن بعيد، ولكن لها عيبان. الأول أن باقي عملك يذهب إلى آخر الطابور، فإذا كانت هناك سكربتات أخرى في الصفحة تضع مهامها في الطابور فسوف تعمل قبل أن يكمل عملك، فيطول زمن عملك الكلي. والثاني هو الحد الأدنى 4 ms الذي قسناه، فالسلسلة المتداخلة من المؤقتات تدفعه مع كل جزء بعد الخامس.
scheduler.yield(): أعط الدور ثم عد إلى مكانك
الدالة scheduler.yield() جزء من واجهة جدولة المهام بالأولوية، وهي تعيد وعداً تكمل بعده عملك، والفرق عن setTimeout كما يشرح مقال فريق Chrome أن باقي العمل يجدول قبل المهام الجديدة الأخرى من نفس الأولوية، فأنت تعطي المتصفح فرصة ليستجيب للمستخدم، ثم تعود إلى مكانك قبل أن تبدأ سكربتات أخرى عملها. ولاحظ أنها غير موجودة في كل المتصفحات، فتحقق من جدول التوافق في صفحة MDN، ولهذا استخدمنا في التجربة الفحص globalThis.scheduler?.yield مع setTimeout كبديل.
ماذا قالت الأرقام؟
في التشغيل الواحد للتجربة كانت النتائج كما يلي للوضعين المقسمين:
== longtask.html?mode=chunk (n=120000, yield via scheduler.yield)
job: 279.7 ms in 27 slice(s); longest task 0 ms; long tasks seen: 0; max frame gap 66.6 ms
pointerdown inputDelay 12.7 ms, processing 0 ms, duration 16 ms, interactionId 4104
click inputDelay 11.6 ms, processing 0.5 ms, duration 16 ms, interactionId 4104
page clock: job 1196.7 -> 1476.4 ms; click handler ran at 1303.2 ms; frame gaps over 50 ms: 66.6, 66.6
CDP mouse events acknowledged after 31 ms; from sending the click to the handler starting: 30.9 ms
trace: 0 main-thread tasks >= 50 ms: none; tasks 1-50 ms: 32; EventDispatch: mousedown 0 ms, click 0.5 ms
== longtask.html?mode=chunk (n=120000, yield via setTimeout)
job: 522.9 ms in 36 slice(s); longest task 0 ms; long tasks seen: 0; max frame gap 16.8 ms
pointerdown inputDelay 2.4 ms, processing 0 ms, duration 16 ms, interactionId 1080
page clock: job 1172.1 -> 1695 ms; click handler ran at 1266.9 ms; frame gaps over 50 ms: none
CDP mouse events acknowledged after 22 ms; from sending the click to the handler starting: 21.6 ms
trace: 0 main-thread tasks >= 50 ms: none; tasks 1-50 ms: 40; EventDispatch: mousedown 0 ms, click 0.5 msفي المخرج أعلاه لاحظ التالي:
- في الطريقتين اختفت المهام الطويلة تماماً،
long tasks seen: 0، والتتبع لم ير أي مهمة فوق 50 ms، وإنما 32 و40 مهمة صغيرة. - بدأ معالج النقرة أثناء الفهرسة وليس بعدها: عند 1303.2 ms والفهرسة تعمل من 1196.7 إلى 1476.4 في الأولى، وعند 1266.9 ms في الثانية، فانخفض الانتظار من 298.7 ms إلى 30.9 ms و21.6 ms.
- الفهرسة مع
scheduler.yield()انتهت في 279.7 ms، وهذا أسرع حتى من المهمة الواحدة في هذا التشغيل، ومعsetTimeoutأخذت 522.9 ms، والفرق قرابة 36 جزءاً يدفع كل منها حد 4 ms وانتظار دوره في آخر الطابور. - والمفاجأة في
max frame gap: معsetTimeoutلم تتجاوز أي فجوة بين إطارين 16.8 ms، أي إطار كل 1/60 ثانية بالضبط، ومعscheduler.yield()ظهرت فجوات 66.6 ms، أي أن الشاشة رسمت إطارات أقل أثناء الفهرسة.
وعندما كررنا التشغيل لاحظنا أن الأرقام تتغير كثيراً من مرة لأخرى، ففي أحد التشغيلات انتظرت النقرة مع scheduler.yield() 309.5 ms، وهذا أسوأ من بعض تشغيلات المهمة الواحدة، والسبب أن الجهاز كان يشغل أعمالاً أخرى في نفس الوقت. وتشغيل واحد على جهاز مشغول قد يخدعك في أي اتجاه، لذلك كتبنا السكربت bench.mjs الذي يكرر تجربة النقرة 15 مرة لكل طريقة ويطبع القيمة الوسيطة Median وأصغر قيمة وأكبرها:
node content/articles/js-event-loop/lab/bench.mjs 15block (15 runs) click wait: median 244.9, min 178.2, max 392.8 ms | job: median 304.6, min 248.3, max 395.0 ms | longest frame gap: median 300.0, min 233.4, max 383.4 ms
chunk (15 runs) click wait: median 34.8, min 20.3, max 77.6 ms | job: median 343.1, min 227.4, max 515.4 ms | longest frame gap: median 83.3, min 50.0, max 100.1 ms
chunk&yield=timeout (15 runs) click wait: median 20.4, min 7.9, max 118.9 ms | job: median 423.8, min 332.7, max 573.2 ms | longest frame gap: median 16.8, min 16.7, max 16.8 msوهذه الأرقام أوضح من أي تشغيل واحد:
- انتظار النقرة: القيمة الوسيطة 244.9 ms في المهمة الواحدة، و34.8 ms مع
scheduler.yield()، و20.4 ms معsetTimeout، أي أن أي تقسيم خفض الانتظار سبع مرات أو أكثر، والفرق بين طريقتي التقسيم صغير مقارنة بالفرق بينهما وبين المهمة الواحدة. - زمن العمل كله: 343.1 ms مع
scheduler.yield()مقابل 423.8 ms معsetTimeout، فالعودة السريعة إلى العمل وفرت قرابة 80 ms في الوسيط، وهذه هي الميزة التي صممت من أجلها. - الإطارات: مع
setTimeoutكانت أطول فجوة 16.8 ms في كل التشغيلات تقريباً، ومعscheduler.yield()كانت بين 50 و100.1 ms. وهذا منطقي مع طريقة عملscheduler.yield()، فبما أن باقي العمل يجدول قبل المهام الأخرى فإن المتصفح يعود إلى الفهرسة بسرعة ويؤجل بعض الإطارات، ويبدو أن Chrome في تجربتنا كان يرسم إطاراً كل 66 إلى 100 ms أثناء العمل. فإذا كانت الصفحة تعرض حركة مستمرة أثناء العمل، مثل شريط تقدم متحرك، فسوف تلاحظ هذا الفرق.

والخلاصة من هذه الأرقام أن السؤال ليس «أي الطريقتين أفضل؟» وإنما «ما الذي يهمك في هذه الصفحة؟»، فإذا كان عملك هو الأهم ويجب أن ينتهي بسرعة مع بقاء النقرات سريعة فاستخدم scheduler.yield()، وإذا كانت الحركة على الشاشة أثناء العمل مهمة فقد يكون setTimeout أنسب، أو اجمع بينهما بأن تنتظر requestAnimationFrame ثم setTimeout قبل الأجزاء التي تريد أن ترى بعدها إطاراً. وإليك اللقطتين من Chrome للوضعين المقسمين:


طرق أخرى ومتى تختارها
بالإضافة إلى الطريقتين السابقتين هناك ثلاث أدوات أخرى ستراها في الكود الحقيقي:
- requestIdleCallback: ينفذ ردك عندما يكون الخيط فارغاً، ويعطيك مهلة تعرف منها كم بقي لك من الوقت. وهو مناسب للعمل الذي لا يستعجله أحد، مثل إرسال بيانات التحليلات أو تجهيز بيانات قد يحتاجها المستخدم لاحقاً، ولكنه قد لا يعمل أبداً إذا كانت الصفحة مشغولة دائماً، فلا تضع فيه شيئاً يجب أن يحدث.
- الـ Web Workers: إذا كان العمل حساباً خالصاً لا يحتاج الـ DOM، مثل فهرسة 120,000 منتج أو تحويل ملف كبير إلى Base64 كما شرحنا في مقال الترميز، فمكانه الصحيح خيط آخر، فلا يأخذ من الخيط الرئيسي شيئاً إلا نقل النتيجة. وثمن ذلك أن البيانات تنسخ بين الخيطين في الرسائل، فإذا كانت النتيجة ضخمة فقد يصبح النسخ نفسه مهمة طويلة، ولذلك استخدم الكائنات القابلة للنقل Transferable مثل
ArrayBufferعندما تستطيع. - تقليل العمل نفسه: وهو ما فعلته redBus، فأحياناً لا تحتاج أن تقسم العمل وإنما أن تتوقف عن تكراره، مثل إعادة رسم القائمة كلها مع كل حرف بدلاً من انتظار توقف المستخدم عن الكتابة.
وللوصول إلى سبب بطء تفاعل معين في الإنتاج يقدم Chrome واجهة Long Animation Frames، واختصاراً LoAF، وهي تخبرك بالإطارات التي تأخرت وبالسكربتات التي عملت خلالها، أي أنها تجيب عن سؤال «من الذي حجز الخيط؟» الذي لا تجيب عنه Long Tasks. وقد استخدمتها شركة Taboola لتعرف أي سكربتاتها يؤخر تفاعلات صفحات الناشرين، ثم قسمت عملها بحيث يعطي الخيط للمتصفح بانتظام، فانخفض INP عند أحد الناشرين من 75 إلى 48 ms، كما في دراسة الحالة على web.dev. ولمن يريد قائمة كاملة بأساليب التحسين فإن دليل تحسين المهام الطويلة ودليل تحسين INP من فريق Chrome هما المرجعان الأساسيان.
حلقة الأحداث Event Loop في Node.js
حتى الآن تكلمنا عن المتصفح، وفي Node.js نفس الفكرة الأساسية: خيط واحد ينفذ كودك، وطوابير، وحلقة. ولكن التفاصيل مختلفة، فلا توجد خطوة رسم ولا requestAnimationFrame، والحلقة نفسها من مكتبة libuv، وهي مقسمة إلى مراحل Phases بترتيب ثابت كما يشرح دليل حلقة الأحداث في Node.js:
- timers: ردود
setTimeoutوsetIntervalالتي حان وقتها. - pending callbacks: ردود بعض عمليات النظام المؤجلة من الدورة السابقة، مثل بعض أخطاء TCP.
- poll: انتظار أحداث الإدخال والإخراج I/O وتنفيذ ردودها، مثل وصول بيانات من الشبكة أو انتهاء قراءة ملف، وهنا ينتظر Node إذا لم يكن هناك عمل آخر.
- check: ردود
setImmediate. - close callbacks: أحداث الإغلاق مثل
socket.on('close').
وبعد كل رد في أي مرحلة ينفذ Node طابور process.nextTick ثم طابور الوعود، وهذا يعادل نقطة فحص المهام الصغيرة في المتصفح. وقد رأينا في مقال الخيوط أن ترتيب nextTick مقابل الوعود يختلف بين ملف CommonJS وملف ES Module، ولن نعيد ذلك هنا، وإنما سوف نختبر المراحل نفسها.

التجربة الثالثة: المراحل Phases والتجويع Starvation وتأخير الحلقة
الملف التالي يختبر أربعة أشياء بالترتيب، ولا يستخدم إلا مكتبات Node.js القياسية. احفظه باسم node-loop.mjs:
// node-loop.mjs: the Node.js side of the lab (standard library only).
// docker run --rm --name sfe-eventloop -v "$PWD:/lab" -w /lab node:22-alpine node node-loop.mjs
import { readFile } from 'node:fs';
import { monitorEventLoopDelay } from 'node:perf_hooks';
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const t0 = performance.now();
const at = () => `${(performance.now() - t0).toFixed(1).padStart(6)} ms`;
console.log(`Node.js ${process.version} on ${process.platform}/${process.arch}`);
// 1) inside an I/O callback (poll phase): setImmediate (check phase) always comes before setTimeout 0 (next timers phase)
await new Promise(done => readFile(new URL(import.meta.url), () => {
console.log('\n== 1) inside an fs.readFile callback');
setTimeout(() => { console.log('timers phase: setTimeout 0'); done(); }, 0);
setImmediate(() => {
console.log('check phase: setImmediate');
process.nextTick(() => console.log(' nextTick queued inside setImmediate'));
Promise.resolve().then(() => console.log(' promise queued inside setImmediate'));
});
process.nextTick(() => console.log('nextTick (right after the callback)'));
Promise.resolve().then(() => console.log('promise .then (right after nextTick)'));
}));
// 2) immediates queued together run in one check phase; one queued from inside them waits for the next turn of the loop
await sleep(20);
console.log('\n== 2) 3 setImmediate calls queued together, and one more queued from inside them');
await new Promise(done => {
setImmediate(() => { console.log('immediate A'); setImmediate(() => { console.log('immediate D (queued by A: next loop turn)'); done(); }); });
setImmediate(() => console.log('immediate B'));
setImmediate(() => console.log('immediate C'));
setTimeout(() => console.log('setTimeout 0 queued after the immediates'), 0);
});
// 3) microtask starvation: a promise chain that keeps queueing itself holds back a timer that was due long ago
await sleep(20);
console.log('\n== 3) a timer due in 0 ms vs. 2,000,000 chained microtasks');
await new Promise(done => {
const start = performance.now();
setTimeout(() => { console.log(`timer ran after ${(performance.now() - start).toFixed(1)} ms`); done(); }, 0);
let n = 0;
const step = () => { if (++n < 2_000_000) queueMicrotask(step); else console.log(`microtasks finished after ${(performance.now() - start).toFixed(1)} ms`); };
queueMicrotask(step);
});
await sleep(20);
console.log(' the same 2,000,000 steps, but each one queued with setImmediate');
await new Promise(done => {
const start = performance.now();
let timerAt = null;
setTimeout(() => { timerAt = performance.now() - start; console.log(`timer ran after ${timerAt.toFixed(1)} ms`); }, 0);
let n = 0;
const step = () => { if (++n < 2_000_000) setImmediate(step); else { console.log(`immediates finished after ${(performance.now() - start).toFixed(1)} ms`); done(); } };
setImmediate(step);
});
// 4) event loop delay: one 300 ms synchronous job vs. the same work in slices of ~10 ms
function work(ms) { const end = performance.now() + ms; let x = 0; while (performance.now() < end) x += Math.sqrt(x + 1); return x; }
async function measure(label, job) {
const h = monitorEventLoopDelay({ resolution: 10 });
h.enable(); await sleep(100);
const start = performance.now(); await job(); const took = performance.now() - start;
await sleep(100); h.disable();
const ms = (v) => (v / 1e6).toFixed(1);
console.log(`${label.padEnd(24)} job ${took.toFixed(0)} ms | loop delay p50 ${ms(h.percentile(50))} ms, p99 ${ms(h.percentile(99))} ms, max ${ms(h.max)} ms`);
}
console.log('\n== 4) event loop delay (perf_hooks.monitorEventLoopDelay, resolution 10 ms)');
await measure('one 300 ms task', async () => work(300));
await measure('30 slices of 10 ms', async () => { for (let i = 0; i < 30; i++) { work(10); await new Promise(r => setImmediate(r)); } });
console.log(`\ndone at ${at()}`);ومعه ملف صغير اسمه race.mjs يسجل مؤقتاً وsetImmediate من الملف الرئيسي مباشرة:
// race.mjs: setTimeout 0 vs setImmediate from the main module; the order depends on how fast the process starts
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));ونشغل الاثنين في Container من Docker Image الرسمي node:22-alpine، والملف race.mjs خمسين مرة ونعد أي السطرين ظهر أولاً، ثم يحذف Docker الـ Container تلقائياً بسبب الخيار --rm:
docker run --rm --name sfe-eventloop -v "$PWD:/lab" -w /lab node:22-alpine sh -c 'node node-loop.mjs; echo; echo "== race.mjs, 50 runs"; for i in $(seq 50); do node race.mjs | head -1; done | sort | uniq -c'والمخرج سوف يكون كما يلي:
Node.js v22.23.4 on linux/x64
== 1) inside an fs.readFile callback
nextTick (right after the callback)
promise .then (right after nextTick)
check phase: setImmediate
nextTick queued inside setImmediate
promise queued inside setImmediate
timers phase: setTimeout 0
== 2) 3 setImmediate calls queued together, and one more queued from inside them
immediate A
immediate B
immediate C
setTimeout 0 queued after the immediates
immediate D (queued by A: next loop turn)
== 3) a timer due in 0 ms vs. 2,000,000 chained microtasks
microtasks finished after 1103.6 ms
timer ran after 1104.1 ms
the same 2,000,000 steps, but each one queued with setImmediate
timer ran after 0.9 ms
immediates finished after 7388.1 ms
== 4) event loop delay (perf_hooks.monitorEventLoopDelay, resolution 10 ms)
one 300 ms task job 300 ms | loop delay p50 10.1 ms, p99 302.8 ms, max 302.8 ms
30 slices of 10 ms job 304 ms | loop delay p50 10.1 ms, p99 29.7 ms, max 29.7 ms
done at 9587.7 ms
== race.mjs, 50 runs
39 immediate
11 timeoutوهذا المخرج يحتاج قراءة قسماً قسماً:
- القسم 1: داخل رد
readFileنحن في مرحلة poll، فبعد الرد مباشرة عملnextTickثم الوعد، ثم انتقلت الحلقة إلى المرحلة التالية وهي check فعملsetImmediate، ثم دارت الحلقة إلى timers في الدورة التالية فعمل المؤقت. وهذا الترتيب مضمون في كل مرة داخل ردود الإدخال والإخراج. ولاحظ أنnextTickوالوعد اللذين أضيفا داخلsetImmediateنفذا بعده مباشرة وقبل المؤقت، لأن Node يفرغ الطابورين بعد كل رد. - القسم 2: الثلاثة A وB وC نفذت معاً في نفس مرحلة check، أما D التي أضافتها A أثناء المرحلة فلم تنفذ في نفس المرحلة، وإنما في الدورة التالية بعد المؤقت. وهذا عكس طابور المهام الصغيرة تماماً، فطابور
setImmediateيأخذ ما كان فيه عند بداية المرحلة فقط، ولذلك لا يمكن أن يجوع الحلقة. - القسم 3: هذا هو التجويع بالأرقام، فمؤقت مدته صفر انتظر 1104.1 ms لأن سلسلة من مليوني مهمة صغيرة كانت تضيف نفسها إلى الطابور، ولم يخرج Node من الطابور حتى فرغ. ثم عندما نفذنا نفس الخطوات بـ
setImmediateعمل المؤقت بعد 0.9 ms، ولكن الخطوات نفسها أخذت 7388.1 ms بدلاً من 1103.6 ms، لأن كل خطوة أصبحت تدور دورة كاملة في الحلقة. وهذا هو المقابل الدقيق لما رأيناه في المتصفح بين المهمة الواحدة والتقسيم: إعطاء الدور للآخرين يكلف وقتاً، فلا تعط الدور بعد كل خطوة صغيرة وإنما بعد كل جزء بحجم معقول. - القسم 4: الدالة
monitorEventLoopDelayمن وحدة perf_hooks تقيس كم تأخرت الحلقة عن موعدها، وهي في السيرفر ما يقابل الفجوة بين الإطارين في المتصفح. مع المهمة الواحدة كانت p99 وأكبر قيمة 302.8 ms، أي أن أي طلب HTTP وصل في هذه الفترة انتظر حتى 300 ms قبل أن يبدأ، ومع الأجزاء انخفضت إلى 29.7 ms مع نفس زمن العمل تقريباً، 304 ms مقابل 300 ms. والقيمة الوسيطة 10.1 ms في الحالتين هي دقة القياس التي اخترناهاresolution: 10. - race.mjs: من الملف الرئيسي ظهر
immediateأولاً 39 مرة وtimeoutأولاً 11 مرة من 50، والسبب أن توثيق المؤقتات يجعل المدة 0 تساوي 1 ms، فإذا بدأت الحلقة قبل أن يمر 1 ms لم يكن المؤقت جاهزاً في مرحلة timers فتسبقه check، وإذا تأخرت قليلاً كان جاهزاً فيسبق. فلا تعتمد على هذا الترتيب خارج ردود الإدخال والإخراج.
والدرس العملي للسيرفر واضح من القسم الرابع، فكل طلب يعالجه Node.js على نفس الخيط، ومعالج واحد يحسب شيئاً ثقيلاً 300 ms يؤخر كل الطلبات الأخرى التي وصلت في نفس الوقت، كما يشرح دليل لا تحجز حلقة الأحداث في توثيق Node.js. ولهذا السبب تظهر المشكلة في اختبار الضغط Load Testing كما شرحنا في مقال اختبار الضغط قبل أن تظهر في اختبار طلب واحد، وإذا كنت تراقب سيرفرات Node.js فأضف تأخير الحلقة إلى مقاييسك بجانب زمن الاستجابة.
process.nextTick «أسرع» من الوعد فتستخدمه في كل مكان، ولكن توثيق process نفسه ينصح باستخدام queueMicrotask() في أغلب الحالات، لأن nextTick طابور خاص بـ Node.js، وتكراره بلا نهاية يجوع الحلقة كما رأينا مع الوعود تماماً.أخطاء شائعة
- اعتبار
asyncطريقة لتشغيل الكود في الخلفية: الدالةasyncتعمل على نفس الخيط، وحلقةforثقيلة داخلها تحجز الخيط تماماً كما لو كانت دالة عادية، وawaitلا يعطي الدور للمتصفح إلا إذا كان ما تنتظره ينتهي في مهمة أخرى. فـawaitعلى وعد محلول مسبقاً لا يسمح بالرسم ولا بالنقرة. - تقسيم العمل بـ
Promise.resolve().thenأوqueueMicrotask: هذه مهام صغيرة تنفذ في نفس نقطة الفحص، فالعمل المقسم بها لا يزال مهمة واحدة طويلة من وجهة نظر المتصفح، والتقسيم الصحيح يكون بمهمة جديدة:setTimeoutأوscheduler.yield()أو MessageChannel. - الاعتماد على دقة
setTimeout: المدة حد أدنى وليست موعداً، وتصبح 4 ms بعد التداخل، وثانية أو دقيقة في الألواح المخفية، فلا تبن عداداً تنازلياً بزيادة رقم كل ثانية، وإنما احسب الوقت المتبقي منDate.now()عند كل تحديث. - حركة بـ
setIntervalبدلاً منrequestAnimationFrame: المؤقت لا يعرف موعد الإطار، فيحسب أحياناً مرتين في إطار واحد ويتخطى إطاراً آخر، فتتقطع الحركة. - إضافة عنصر «جار التحميل» ثم بدء الحساب في نفس المهمة: لن يرسم المتصفح العنصر قبل الحساب، فأعط الدور للمتصفح بعد إضافة العنصر بانتظار إطار ثم مهمة جديدة.
- قياس الأداء على حاسوب المطور فقط: المهمة التي تأخذ 40 ms على حاسوب سريع قد تأخذ أضعاف ذلك على هاتف متوسط، فقس بأجهزة المستخدمين الحقيقيين عبر web-vitals، أو على الأقل بتبطيء المعالج في DevTools.
- الوثوق بتشغيل واحد للقياس: كما رأينا تغيرت النتيجة من تشغيل لآخر حتى انقلب الترتيب بين الطريقتين، فكرر القياس وانظر إلى القيمة الوسيطة وأكبر قيمة.
- أداة قياس تعمل داخل الصفحة: سكربت المراقبة أو الاختبار الذي ينفذ كوداً في الصفحة ينتظر الخيط مثل أي كود آخر، فقد يقيس بعد انتهاء المشكلة، وهذا ما حدث لنا مع
Runtime.evaluate. - معالجة الأخطاء في الوعود وحدها: الوعد المرفوض دون
catchلا يوقف الصفحة، فقد يفشل طلبfetchبسبب سياسة CORS مثلاً ويبقى الزر في حالة «جار الإرسال» إلى الأبد، فأضفtry/catchحول كلawaitيغير حالة الواجهة.
أين تضع هذا العمل؟ جدول قرار Decision Table
الجدول التالي يجمع الأدوات التي رأيناها، ومتى تختار كل واحدة:
| ما تريده | الأداة | لماذا | انتبه إلى |
|---|---|---|---|
| إكمال عمل بدأ للتو قبل أي حدث أو رسم | await أو queueMicrotask | ينفذ في نفس نقطة الفحص فلا يرى المستخدم حالة نصف محدثة | لا تكرره بلا نهاية، فهو يجوع الحلقة |
| تعديل بصري مع كل إطار | requestAnimationFrame | يعمل مرة واحدة قبل كل رسم | لا يعمل في الألواح المخفية |
| عمل طويل يجب أن ينتهي بسرعة مع بقاء النقرات سريعة | scheduler.yield() كل 10 ms تقريباً | يعود إلى عملك قبل المهام الأخرى | غير موجود في كل المتصفحات، وقد يقلل الإطارات أثناء العمل |
| عمل طويل مع حركة مستمرة على الشاشة | setTimeout(r, 0) بين الأجزاء | يعطي المتصفح كل إطار في موعده | حد 4 ms بعد التداخل، وزمن العمل الكلي أطول |
| عمل غير مستعجل | requestIdleCallback | يعمل فقط عندما يفرغ الخيط | قد لا يعمل أبداً في صفحة مشغولة |
| حساب ثقيل لا يحتاج الـ DOM | Web Worker | يعمل على خيط آخر تماماً | نسخ البيانات في الرسائل له ثمن |
| عمل ثقيل في سيرفر Node.js | worker_threads أو أجزاء مع setImmediate | لا يؤخر الطلبات الأخرى | راقب تأخير الحلقة بـ monitorEventLoopDelay |
وقبل أن تنشر صفحة فيها JavaScript ثقيلة راجع النقاط التالية:
- هل سجلت تتبع أداء في DevTools أثناء التفاعلات المهمة، وهل فيه مهام فوق 50 ms؟
- هل معالجات الأحداث تحدث الواجهة أولاً ثم تؤجل العمل الثقيل؟
- هل العمل الذي يعمل في الخلفية مقسم بالوقت، ويعطي الدور للمتصفح كل 10 ms تقريباً؟
- هل الحسابات التي لا تحتاج الـ DOM في Worker؟
- هل تجمع INP من المستخدمين الحقيقيين، وتنظر إلى أسوأ الصفحات وليس إلى المتوسط فقط؟
- هل تعتمد المؤقتات المهمة، مثل تجديد التوكن، على فحص الوقت الفعلي وليس على دقة
setTimeout؟
تدريب عملي
- قبل أن تشغل
order.htmlعلى جهازك اكتب الترتيب الذي تتوقعه، ثم أضفMutationObserverيراقب عنصراً تغيره في السكربت، وتوقع أين سيظهر رده بين الأسطر. - غير مدة الجزء في
longtask.htmlمن 10 ms إلى 50 ms ثم إلى 2 ms، وشغلbench.mjsفي كل مرة، وارسم كيف يتغير انتظار النقرة وزمن العمل كله. - انقل الفهرسة إلى Web Worker، وقس كم يستغرق إرسال النتيجة إلى الصفحة، ثم قارن انتظار النقرة بالأرقام التي في هذا المقال.
- افتح صفحة من موقعك في DevTools، وفعل تبطيء المعالج أربع مرات في لوح Performance، ثم سجل تتبعاً أثناء أهم تفاعل فيها، وابحث عن أطول مهمة واعرف من الذي سببها.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- كود الصفحة يعمل على خيط رئيسي واحد مع معالجات الأحداث والرسم، وكل مهمة تعمل حتى النهاية دون مقاطعة، فالمهمة الطويلة تؤخر كل شيء بعدها.
- في كل دورة تأخذ الحلقة مهمة واحدة، ثم تفرغ طابور المهام الصغيرة كله، ثم ترسم إذا حان وقت إطار، ولذلك يسبق
.thenالمؤقت دائماً، أما موقعrequestAnimationFrameمن المؤقت فيتغير مع موعد الإطار كما رأينا في خمسة تحميلات. setTimeoutحد أدنى وليس موعداً، ويصبح 4 ms على الأقل من المؤقت السابع في السلسلة المتداخلة، ويبطأ كثيراً في الألواح المخفية.- INP يقسم زمن التفاعل إلى تأخير الإدخال وزمن المعالجة وتأخير العرض، وفي تجربتنا انتظرت النقرة 298.7 ms خلف مهمة فهرسة، وأخذت 421.8 ms عندما كانت الفهرسة داخل المعالج.
- تقسيم العمل خفض انتظار النقرة من 244.9 ms إلى 34.8 ms مع
scheduler.yield()وإلى 20.4 ms معsetTimeoutفي القيمة الوسيطة لخمسة عشر تشغيلاً، والأولى أنهت العمل أسرع، والثانية حافظت على الإطارات. - في Node.js تأتي check بعد poll، فيسبق
setImmediateالمؤقت داخل ردود الإدخال والإخراج، والمهام الصغيرة التي تضيف نفسها تجوع الحلقة، وتأخير الحلقة هو المقياس الذي يكشف المهام الطويلة في السيرفر.
وفي مرحلة هندسة الواجهات وتطبيقات الويب تجد المقالات التي تكمل هذا المقال، من طريقة رسم المتصفح للصفحة إلى بناء واجهة عربية سليمة:
دليلكيف يرسم المتصفح صفحتك؟ من HTML إلى البكسلاتماذا يفعل المتصفح بين وصول HTML وظهور الصفحة، ولماذا تتجمد صفحة فيها ألفا صف، وكيف تقيس كل مرحلة في Chrome وتصلحها.
دليلCSS للعربية واتجاه RTL: الخصائص المنطقية Logical Propertiesكيف تكتب ملف CSS واحداً يعمل بالعربية والإنجليزية، ولماذا تخطئ margin-left في الصفحة العربية، مع قياسات حقيقية من Chrome.
دليلالتعريب i18n للمطورين: لماذا ينقلب النص العربي في الواجهة؟لماذا ينقلب النص المختلط في الواجهة العربية وكيف تعزله، وكيف تنسق الأرقام والتواريخ وصيغ الجمع العربية بأدوات المتصفح نفسها.
دليلThreads وasync/await: كود لا يتجمد ولا يضيع بياناتهكيف تكتب كوداً يعمل في أكثر من خيط Thread دون أن تضيع بياناته أو يتجمد، ولماذا تظهر هذه الأخطاء على السيرفر ولا تظهر على جهازك.
دليلأسئلة مقابلة مطور Frontend من المبتدئ إلى الخبيربنك أسئلة لمقابلة مطور Frontend، لكل سؤال إجابة المبتدئ وإجابة الخبير وأسئلة متابعة، مع تمارين عملية شغلناها فعلاً.
دليلاختبارات E2E مع Playwright: من أول اختبار إلى CIكيف تكتب اختبارات متصفح ثابتة لموقع عربي مع Playwright، وتشخص الاختبار المتقلب، وتقسمها على أكثر من جهاز في خط CI.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ تجارب المتصفح في Chrome 156.0.8078.49 بدون واجهة عبر DevTools Protocol، وتجربة Node.js في Container من
node:22-alpineمع Node.js v22.23.4.