Menu
العربية

حلقة الأحداث في JavaScript: كيف يعمل الكود غير المتزامن

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

مسار وحيد، لكن التنفيذ ليس تسلسليًا

تعمل جافا سكريبت على Thread واحد فقط. لديك call stack واحد، وفي أي لحظة هناك دالة واحدة بالضبط قيد التنفيذ. لن يحدث أبدًا أن يعمل سطران من كودك بالتوازي داخل نفس الـ realm.

قد يبدو هذا قيدًا مزعجًا، لكن تذكّر طبيعة عمل جافا سكريبت فعليًا: جلب بيانات، انتظار نقرات المستخدم، قراءة ملفات. معظم "الشغل" في الحقيقة مجرد انتظار. وهنا تأتي حلقة الأحداث (event loop) كحيلة ذكية تجعل الانتظار بلا تكلفة تقريبًا — يسلّم كودك المهمة للمتصفح أو لـ Node، ثم يعود لينجز أمورًا أخرى، وحين تجهز النتيجة يصله إشعار بذلك.

index.js
Output
Click Run to see the output here.

الأول و الثاني تُطبعان بالترتيب، أما الثالث فتظهر بعدهما رغم أن المؤقّت مضبوط على 0. هذه الفجوة هي حلقة الأحداث (event loop) أثناء العمل، وفهم سببها هو جوهر هذا الدرس.

مكدّس الاستدعاءات (Call Stack)

كل استدعاء دالة يضع إطاراً جديداً فوق مكدّس الاستدعاءات، وعندما تنتهي الدالة يُزال إطارها. المكدّس ببساطة يعمل بمبدأ "آخر داخل، أول خارج" (LIFO).

index.js
Output
Click Run to see the output here.

عندما يعمل outer()، يضع Node الدالة outer على المكدس، ثم inner، ثم يرفع inner بعد أن تُرجع "تم"، وأخيراً يرفع outer. يصبح المكدس فارغاً من جديد. هذه اللحظة — لحظة "المكدس الفارغ" — هي بالضبط ما ترصده حلقة الأحداث في جافا سكريبت.

الكود المتزامن يعمل على call stack جافا سكريبت من البداية إلى النهاية دفعة واحدة، ولا شيء غير متزامن يستطيع مقاطعته. فلو كتبت حلقة while (true)، لن يُفرَّغ المكدس أبداً وستتجمد الصفحة — لا نقرات، ولا مؤقتات، ولا ردود نداء الوعود. حلقة الأحداث هنا عاجزة تماماً لأنها ببساطة لا تحصل على دورها.

أين تجري المهام غير المتزامنة فعلياً؟

جافا سكريبت بحد ذاتها لا تعرف كيف تُرسل طلباً عبر الشبكة ولا كيف تنتظر 100 ملّي ثانية. هذه الواجهات تنتمي إلى البيئة المضيفة — المتصفح أو Node. عندما تستدعي setTimeout(fn, 100)، إليك ما يحدث خطوة بخطوة:

  1. يُسجَّل المؤقت لدى البيئة المضيفة.
  2. تعود setTimeout فوراً، ويواصل المكدس عمله دون توقف.
  3. بعد مرور 100 ملّي ثانية، تضع البيئة المضيفة fn في طابور الانتظار.
  4. حين يصبح المكدس فارغاً، تسحب حلقة الأحداث fn من الطابور وتنفّذها.
index.js
Output
Click Run to see the output here.

لا يمكن لدالة رد النداء الخاصة بالمؤقّت أن تعمل قبل أن تنتهي حلقة for واستدعاء console.log("النهاية") — ببساطة لأنّ الـ call stack لم يُفرَّغ بعد. المؤقّتات في جافا سكريبت تمثّل حدًّا أدنى للتأخير فقط، وليست ضمانًا بالتنفيذ في الوقت المحدّد.

طابورا المهام: Tasks و Microtasks

لا يوجد طابور واحد في حلقة الأحداث javascript، بل طابوران اثنان، والتمييز بينهما يفسّر معظم المفاجآت التي تصادفك مع الـ event loop.

  • طابور المهام (ويُسمّى أحيانًا macrotask queue): يضمّ setTimeout و setInterval وردود نداء الإدخال/الإخراج وأحداث الواجهة.
  • طابور المهام الدقيقة (microtask queue): يضمّ ردود نداء الـ Promise (.then و .catch و .finally)، واستكمالات await، وأي شيء تجدوله بواسطة queueMicrotask.

أمّا القاعدة التي تتّبعها حلقة الأحداث فهي:

  1. نفِّذ مهمّة واحدة من طابور المهام.
  2. فرِّغ طابور الـ microtasks بالكامل — كل مهمّة دقيقة فيه، بما في ذلك تلك التي تُجدوَل أثناء التفريغ نفسه.
  3. نفِّذ عملية الرسم (Rendering) إن لزم الأمر (في المتصفّحات).
  4. عُد إلى الخطوة 1.

الـ microtasks تُنفَّذ دائمًا قبل المهمّة التالية من طابور الـ tasks. ولهذا السبب تحديدًا يُفاجأ الكثيرون بسلوك الكود التالي:

index.js
Output
Click Run to see the output here.

ترتيب الطباعة سيكون: متزامن ١, متزامن ٢, وعد, مؤقت. الكود المتزامن ينفّذ أولاً، ثم تُفرَّغ الـ call stack، وبعدها تبدأ حلقة الأحداث بتصريف طابور المهام الدقيقة (microtasks) فتطبع وعد. وفي الأخير فقط تلتقط مهمة المؤقّت (مؤقت).

كيف تتسبّب المهام الدقيقة في تجويع المهام العادية؟

بما أن طابور الـ microtasks يُفرَّغ بالكامل قبل الانتقال إلى المهمة التالية، فإن أي microtask تستمر في جدولة مهام دقيقة جديدة ستُعطّل طابور المهام (task queue) إلى الأبد:

index.js
Output
Click Run to see the output here.

لن ينطلق المؤقّت أبدًا، لأن كل microtask يُضيف microtask جديدة إلى الطابور، فلا تتاح الفرصة للحلقة بأن تُفرِغه. سلاسل الـ Promise آمنة لأن كل .then يُجدوِل متابعة واحدة فقط، أما حلقات الـ microtask المكتوبة يدويًا فهي فخّ حقيقي يستحق الانتباه.

الـ await ما هو إلا غلاف لطيف حول microtask

حين تستخدم await مع وعد ما، تتوقف الدالة مؤقّتًا ويُجدوَل ما تبقى منها كـ microtask يُنفَّذ بمجرد أن يُحسم الوعد. لا سحر في الأمر؛ إنها .then نفسها خلف الكواليس.

index.js
Output
Click Run to see the output here.

الناتج: أ, ج, ب. الـ await يُعيد التحكّم إلى المُستدعي، فيُنفَّذ console.log("ج") ضمن الـ call stack الحالي. بعدها يُفرَّغ microtask queue فيُستأنف باقي دالّة demo ويطبع ب.

خَلِّ هذه النقطة في بالك وأنت تقرأ كود غير متزامن: await لا يُجمِّد التنفيذ، بل يتنازل عنه.

مثال تطبيقي: ترتيب كل شيء معًا

لنجمع كل القطع في مثال واحد:

index.js
Output
Click Run to see the output here.

الترتيب:

  1. 1: متزامن — يُنفَّذ على الـ stack.
  2. 6: متزامن — لا يزال على الـ stack.
  3. يفرغ الـ stack. يبدأ تفريغ microtask queue: 3: وعد، ثم 5: مهمة دقيقة، ثم 4: مهمة دقيقة متداخلة (الذي جُدوِل أثناء عملية التفريغ، ومع ذلك يُلتقط في نفس الجولة).
  4. تأتي المهمة التالية: 2: مؤقت.

الناتج النهائي: 1, 6, 3, 5, 4, 2. إن استطعت تتبّع هذا المثال، فأنت تفهم event loop فعلًا.

مراحل إضافية في Node

حلقة الأحداث في Node نسخة موسَّعة من نموذج المتصفح. لديها مراحل مستقلة — timers، وpending I/O callbacks، وpoll، وcheck، وclose — وتُفرَّغ الـ microtasks بين كل مرحلة وأخرى. دالة setImmediate تعمل في مرحلة الـ check، أما process.nextTick فتعمل قبل microtasks الاعتيادية (لها قائمة خاصة ذات أولوية أعلى).

لست مضطرًا لحفظ مخطط المراحل من أول يوم. الخلاصة هي نفسها كما في المتصفح: الشيفرة المتزامنة تُنفَّذ حتى النهاية، ثم تُفرَّغ الـ microtasks، ثم تلتقط الحلقة الـ callback التالي في الطابور.

لماذا كل هذا مهم؟

حين تستوعب هذا النموذج، يتوقّف كثير من الكود غير المتزامن عن كونه لغزًا:

  • حلقة for طويلة تُجمِّد الواجهة لأن event loop لا تجد فرصة للعمل.
  • setTimeout(fn, 0) وسيلة لتأجيل عمل ما إلى ما بعد انتهاء المهمة الحالية وكل microtasks.
  • callback داخل .then يعمل "فورًا" بعد promise محلول مسبقًا، لكنه يظل ينتظر انتهاء الشيفرة المتزامنة الحالية.
  • استخدام await داخل حلقة يجعل العمل تسلسليًا، لأن كل دورة تتنازل للـ microtask queue قبل المتابعة.

تصحيح أخطاء الكود غير المتزامن يتلخّص غالبًا في سؤال واحد: "ما الذي يقف الآن على الـ stack، وما الذي ينتظر في الطابور؟" والإجابة دائمًا عند event loop.

التالي: Callbacks

قبل ظهور الـ promises وasync/await، لم يكن لدى JavaScript سوى الـ callback للتعامل مع العمليات غير المتزامنة — دالة تسلّمها لواجهة ما لتستدعيها لاحقًا. الـ callbacks لا تزال موجودة في كل مكان (مستمعات الأحداث، وواجهات Node الأساسية)، وفهمها هو الأساس الذي يُبنى عليه بقية هذا الفصل.

الأسئلة الشائعة

ما هي حلقة الأحداث (Event Loop) في JavaScript؟

هي الآلية التي تسمح للغة JavaScript ذات الخيط الواحد بتشغيل الأعمال غير المتزامنة دون أن يتوقف البرنامج. الحلقة تراقب مكدس الاستدعاءات (call stack)، وحين يصبح فارغًا تسحب الـ callback التالية من الطابور وتشغّلها. المؤقتات وعمليات الإدخال/الإخراج وتتمّات الـ promises كلها تنتهي في طوابير تُفرغها حلقة الأحداث عنصرًا واحدًا في كل مرة.

لماذا JavaScript لغة أحادية الخيط؟

مواصفات اللغة تُعرّف مكدس استدعاءات واحدًا لكل realm، أي أن كودك يعمل على خيط واحد فقط. التزامن يأتي من البيئة المستضيفة (المتصفح أو Node) التي تُسلّم العمل لواجهات خلفية — المؤقتات، الشبكة، ملفات الـ I/O — ثم تضع الـ callback في الطابور عند انتهائها. لن تجد أبدًا قطعتين من JS تعملان في نفس اللحظة داخل نفس السياق.

ما الفرق بين الـ microtasks والـ macrotasks؟

الـ microtasks تأتي من الـ promises (.then، await) ومن queueMicrotask. أما الـ macrotasks فتأتي من setTimeout وsetInterval وعمليات I/O وأحداث الواجهة. بعد انتهاء كل macrotask، تُفرغ حلقة الأحداث كامل طابور الـ microtasks قبل تشغيل الـ macrotask التالية — ولهذا فإن Promise.resolve().then(...) تُنفَّذ دائمًا قبل setTimeout(..., 0) حتى لو جُدولا في نفس اللحظة.

لماذا لا تُنفَّذ setTimeout بقيمة 0ms فورًا؟

setTimeout(fn, 0) لا تعني «شغّل الآن»، بل تعني «ضع fn في طابور الـ macrotasks على أن يبدأ بعد 0ms كحدّ أدنى». يجب أولًا أن ينتهي الكود المتزامن الحالي، ثم يُفرَّغ طابور الـ microtasks بالكامل، وبعدها فقط تلتقط حلقة الأحداث callback المؤقت. إذًا الصفر هنا حدّ أدنى، وليس وعدًا بالتنفيذ الفوري.

تعلّم البرمجة مع Coddy

ابدأ الآن