تتصرف حلقة الأحداث (event loop) في JavaScript التي تشغل صفحتك الويب بشكل مختلف تمامًا عن تلك التي تشغل خادم Node.js، ويمكن لهذا الاختلاف أن يؤدي إلى تجميد واجهة المستخدم أو خنق عمليات الإدخال والإخراج (I/O) إذا لم تكن حذرًا. إن معرفة نقاط الاختلاف بين الاثنين أمر ضروري لأي شخص يكتب كودًا غير متزامن (async code) يعمل في كلا البيئتين.

لماذا يهم هذا التمييز

حلقة الأحداث ليست محددة في مواصفات ECMAScript؛ بل هي جزء من المضيف (host). يجب على المتصفحات الحفاظ على استجابة الصفحة أثناء عملية التصيير (rendering)، بينما بُني Node.js حول عمليات الإدخال والإخراج غير المعطلة (non-blocking I/O). إن خلط الأنماط التي تعمل في مضيف واحد مع الآخر قد يؤدي إلى أخطاء يصعب إعادة إنتاجها: فسلسلة طويلة من الوعود (promises) قد تعطل عملية إعادة الرسم (repaint) في المتصفح، بينما يمكن لحلقة process.nextTick غير منضبطة أن تمنع Node من الوصول إلى مراحل الإدخال والإخراج الخاصة به تمامًا.

حلقة المتصفح القائمة على الأدوار

في المتصفح، تقوم الحلقة بتشغيل دورة واحدة تتداخل فيها عملية تنفيذ المهام، وإفراغ المهام الصغرى (microtask draining)، وعملية التصيير:

  1. تشغيل مهمة كبرى واحدة (macrotask) (مثل معالج النقر، أو setTimeout وما إلى ذلك).
  2. إفراغ جميع المهام الصغرى (microtasks) (مثل الوعود promises أو queueMicrotask).
  3. إذا حان وقت إطار جديد، يتم الرسم والتركيب (paint and composite) للوصول إلى معدل 60 إطارًا في الثانية المستهدف.
  4. التكرار.

توفر واجهتان برمjointتان (APIs) للمطورين نقاط ربط صريحة في هذه الدورة:

  • requestAnimationFrame – تُستدعى قبل قيام المتصفح بالرسم مباشرة. إنها المكان المناسب لأعمال الرسوم المتحركة لأن الاستدعاء (callback) يعمل بعد المهام الصغرى الحالية ولكن قبل الإطار التالي.
  • requestIdleCallback – تُستدعى عندما لا يكون لدى المتصفح أي عمل عالي الأولوية. وهي مفيدة للمهام ذات التأثير المنخفض مثل التحليلات أو التحميل المسبق للبيانات.

فخ: تجويع المهام الصغرى (microtask starvation)

بما أن المتصفح يفرغ طابور المهام الصغرى قبل عملية التصيير، فإن سلسلة طويلة من الوعود يمكن أن تمنع واجهة المستخدم من الرسم تمامًا. لا يتم حظر مكدس الاستدعاءات (call stack)؛ بل ببساطة لا تصل الصفحة أبدًا إلى خطوة التصيير، مما يعطي شعورًا بالتجمد للمستخدم.

حلقة Node المدفوعة بـ libuv

يفوض Node.js حلقته إلى libuv، وهي مكتبة بلغة C تقسم العمل إلى مراحل متميزة، لكل منها طابورها الخاص:

  1. المؤقتات (Timers) – استدعاءات setTimeout و setInterval.
  2. استدعاءات الانتظار (Pending callbacks) – استدعاءات الإدخال والإخراج المؤجلة التي اكتملت بالفعل على مستوى نظام التشغيل.
  3. الاستطلاع (Poll) – جلب أحداث الإدخال والإخراج الجديدة (قراءة الملفات، بيانات الشبكة).
  4. التحقق (Check) – تشغيل استدعاءات setImmediate.
  5. استدعاءات الإغلاق (Close callbacks) – تعمل عند إغلاق مقبس (socket) أو مقبض (handle).

هناك بنيتان تقعان خارج ترتيب هذه المراحل:

  • process.nextTick – تعمل قبل طابور المهام الصغرى، مباشرة بعد انتهاء العملية الحالية.

فخ: تجويع الإدخال والإخراج (I/O starvation)

إذا قامت دالة بجدولة process.nextTick بشكل متكرر دون إفساح المجال، فلن يتقدم Node أبدًا لما بعد خطوة "next-tick". ستظل طلبات الشبكة، وقراءات الملفات، والمؤقتات في حالة خمول، مما يتسبب في ارتفاع مفاجئ في زمن الاستجابة في جانب الخادم أو توقف تام.

setImmediate مقابل setTimeout في الممارسة العملية

كلاهما يقوم بجدولة استدعاءات للدورة التالية، ولكن ترتيبهما النسبي يعتمد على مكان استدعائهما:

  • الكود في المستوى الأعلى (Top-level code) – الترتيب غير مضمون؛ فهو يعتمد على سرعة بدء تشغيل العملية.
  • داخل استدعاء إدخال/إخراج (I/O callback) – الترتيب حتمي: يعمل setImmediate قبل setTimeout(fn, 0). فبعد انتهاء مرحلة الاستطلاع (Poll)، تنتقل libuv إلى مرحلة التحقق (Check) (حيث يوجد setImmediate) قبل أن تعود مرة أخرى إلى مرحلة المؤقتات (Timers) من أجل مهلة زمنية قدرها صفر.

هذا التفصيل الدقيق يهم عندما تعتمد على تسلسل دقيق، مثل تنظيف مورد ما مباشرة بعد اكتمال عملية القراءة.

الفروق الجوهرية في لمحة سريعة

  • الهدف: المتصفحات تعطي الأولوية للتحديثات المرئية؛ Node يعطي الأولوية لجهوزية الإدخال والإخراج (I/O).
  • رابط التصيير: requestAnimationFrame (للمتصفح فقط).
  • رابط خاص بمرحلة معينة: setImmediate (لـ Node فقط، يعمل في مرحلة Check).
  • طابور عالي الأولوية: process.nextTick (لـ Node فقط، يعمل قبل المهام الصغرى).
  • خطر التجويع: سلاسل الوعود الطويلة في المتصفحات؛ process.nextTick غير المحدود في Node.

ما يجب مراقبته لاحقًا

إذا كنت تدير قاعدة كود تعمل في كلا البيئتين (مثل المكتبات المتماثلة/isomorphic)، فقم بمراجعة أي مكان تقوم فيه بـ:

  • ربط العديد من الوعود دون إفساح المجال لحلقة الأحداث. استخدم await new Promise(r => setTimeout(r, 0)) أو استخدم requestIdleCallback في المتصفح لمنح المصيّر فرصة للعمل.
  • استخدام process.nextTick لأعمال يمكن تأجيلها. فضل استخدام setImmediate أو وعد (promise) عادي عندما لا تحتاج إلى استعجال "next-tick".
  • افتراض أن setTimeout(fn, 0) و setImmediate قابلان للتبادل. اختبر الترتيب داخل استدعاءات الإدخال والإخراج إذا كان التسلسل مهمًا.

الخلاصة

حلقة الأحداث (event loop) هي مجدول خاص بالمضيف (host-specific scheduler)، وليست ميزة عامة في JavaScript. تقوم المتصفحات بدمج عملية التصيير (rendering) ضمن الحلقة؛ بينما يقوم Node بعزل عمليات الإدخال والإخراج (I/O) في مراحل libuv. إن إساءة استخدام آليات الأولوية — مثل المهام الدقيقة (microtasks) في المتصفح، أو process.nextTick في Node — قد تؤدي إلى حرمان الجزء الذي صُممت كل بيئة لخدمته من الموارد. قم بمواءمة أنماط البرمجة غير المتزامنة (async patterns) الخاصة بك مع نموذج الحلقة الخاص بالمضيف، وبذلك ستتجنب تجميد الصفحات وتعطل الخوادم على حد سواء.