اذهب إلى المحتوى

حلقة اﻷحداث event loop: طريقك لفهم كيفية تنفيذ جافاسكربت في المتصفح


محمد أمين بوقرة

يعتمد مجرى تنفيذ جافاسكربت في المتصفّح، وكذلك في Node.js، على حلقة اﻷحداث event loop.

يُعدّ الفهم الجيّد لكيفيّة عمل حلقة اﻷحداث مهمّا عند تطبيق التحسينات optimizations وأحيانا من أجل هندسة صحيحة لما نبنيه.

في هذا المقال، سنتناول أوّلا التفاصيل النظريّة لكيفيّة عمل اﻷمور، ثمّ سنرى تطبيقات عمليّة لتلك المعرفة.

حلقة اﻷحداث

مفهوم حلقة اﻷحداث بسيط للغاية. توجد هناك حلقة لا نهاية لها، حيث ينتظر محرّك جافاسكربت المهامّ، وينفّذها ثم ينام، منتظرا المزيد من المهامّ.

الخوارزميّة العامّة للمحرّك:

  1. مادام أنّ هناك مهامّا:
    • ينفّذها، بدءًا بالمهمّة الأقدم.
  2. ينام إلى أن تظهر مهمّة ما، فينتقل عند ذلك إلى 1.

هذا ما يحصل عندما نتصفّح صفحة ما، إذ لا يقوم محرّك جافاسكربت بأيّ شيء في غالب اﻷحيان، بل يشتغل فقط إذا لتنفيذ سكربت/معالج/حدث ما. أمثلة للمهام:

  • عندما يُحمَّل سكربت خارجيّ <script src="..."‎>، تكون المهمّة هي تنفيذه.
  • عندما يحرّك المستخدم فأرته، تكون الهمّة هي إرسال الحدث mousemove وتنفيذ المعالجات.
  • عندما يحين وقت الدالة setTimeout المُنتظَر، تكون المهمة هي تنفيذ رد النداء الخاص بها.
  • وما إلى ذلك.

تُضبط المهام ويعالجها المحرّك ثمّ ينتظر المزيد من المهامّ (بينما ينام ولا يستهلك أيّ شيئ تقريبا من وحدة المعالجة المركزيّة).

قد يحصل وتأتي مهمّة ما بينما المحرّك مشغول، فتضاف تلك المهمّة إلى رتل آنذاك، إذ تشكّل المهام رتلًا queue، يُطلق عليه "رتل المهامّ الكبرى" macrotasks (مصطلح v8) أو رتل المهام ببساطة (بحسب مواصفة WHATWG):

eventLoop.png

على سبيل المثال، بينما المحرّك مشغول بتنفيذ السكربت script، قد يحرّك المستخدم فأرته متسبّبا في حدوث الحدث mousemove، كما قد يحين وقت setTimeout إلى غير ذلك، تُشكّل هذه المهامّ رتلا، كما هو موضّح في الصورة أعلاه.

تُعالج المهامّ في الرتل حسب ترتيب "من يأتي أوّلا - يُخدَم أوّلا". وعندما يفرغ محرّك المتصفّح من script، يعالج حدث mousemove، ثمّ يعالج setTimeout، وهكذا.

إلى حدّ الآن، اﻷمر في غاية البساطة، صحيح؟

اثنان من التفاصيل الإضافيّة:

  1. لا يحدث التصيير أو الإخراج rendering أبدًا بينما ينفّذ المحرّك مهمّة ما. لا يهمّ إن كانت المهمّة تستغرق وقتا طويلا. لا تظهر التغييرات في DOM إلّا بعد تمام المهمّة.
  2. إذا كانت المهمّة تستغرق الكثير من الوقت، فلا يمكن للمتصفّح القيام بمهامّ أخرى، كمعالجة أفعال المستخدم. وبذلك بعد وقت معيّن، سيرفع تنبيها من نحو "لا تستجيب الصفحة"، مقترحا إنهاء المهمّة مع كامل الصفحة. يحصل هذا عندما يكون هناك الكثير من الحسابات المعقّدة أو خطأ برمجيّ يؤدّي إلى حلقة لا متناهية.

كان هذا هو النظريّ. لنرى الآن كيف يمكننا تطبيق هذه المعرفة.

حالة الاستعمال 1: تقسيم المهام الشرهة لوحدة المعالجة المركزية

لنفترض أنّ لدينا مهمّة شرهة لوحدة المعالجة المركزيّة. على سبيل المثال، إبراز الصيغة (التي استُخدمت في تلوين أمثلة الشيفرات في هذه الصفحة) ثقيلة جدّا على وحدة المعالجة المركزيّة. لإبراز الشيفرة، يكون هناك تحليل، وتُنشئ العديد من العناصر الملوّنة، وتضاف إلى الصفحة لمقدار هائل من النصّ، سيأخذ ذلك الكثير من الوقت.

مادام المحرّك مشغولا بإبراز الصيغة، لا يمكنه فعل أشياء أخرى متعلّقة بـ DOM، أو معالجة أحداث المستخدمين أو غير ذلك. بل قد يؤدّي ذلك بالمتصفّح إلى "التلكّئ"، أو حتى "التوقّف" قليلا، وهو ما لا يُعدّ مقبولا.

يمكننا تجنّب هذه المشاكل بواسطة تقسيم المهامّ الكبيرة إلى أجزاء. كإبراز المئة سطر اﻷولى، ثمّ برمجة setTimeout (بتأخير منعدم) للمئة سطر التالية، وهكذا.

لعرض هذه الطريقة، وطلبا للبساطة، بدل إبراز النص، لنأخذ دالّة تعمل على التعداد من 1 إلى 1000000000.

لو نفّذت الشيفرة في الأسفل، سوف "يتوقّف" المحرّك لبعض الوقت. أما في جافاسكربت المشتغلة في جانب الخادم سيكون ذلك ظاهرا بوضوح، وإذا كنت تشغّلها في المتصفّح، جرّب النقر على الأزرار اﻷخرى في الصفحة وسترى أنّه لن تُعالج أي من اﻷحداث اﻷخرى إلى حين انتهاء العد.

let i = 0;

let start = Date.now();

function count() {

  // do a heavy job
  for (let j = 0; j < 1e9; j++) {
    i++;
  }

  alert("Done in " + (Date.now() - start) + 'ms');
}

count();

 

بل إنّ المتصفّح قد يظهر التحذير "استغرق هذا السكربت طويلا".

لنقسّم هذه المهمّة باستخدام استدعاءات متداخلة لـ setTimeout:

let i = 0;

let start = Date.now();

function count() {

  // القيام بجزء من المهمّة الثقيلة (*)
  do {
    i++;
  } while (i % 1e6 != 0);

  if (i == 1e9) {
    alert("Done in " + (Date.now() - start) + 'ms');
  } else {
    setTimeout(count); // برمجة الاستدعاء الجديد (**)
  }

}

count();

تعمل واجهة المستخدم الآن كليّا خلال عمليّة "العدّ".

يؤدّي إجراءٌ واحد للدالة count جزءًا من المهمّة (*)، ثمّ يعيد برمجة نفسه (**) عند الحاجة:

  1. يعدُّ الإجراء اﻷوّل: i=1...1000000.
  2. يعدُّ الإجراء الثاني: i=1000001..2000000.
  3. وهكذا.

لو ظهرت الآن مهمّة جانبيّة جديدة (حدث onclick مثلا) بينما المحرّك مشغول بتنفيذ الجزء الأوّل، فإنّها تُصَفّ في الرتل ثمّ تُنفّذ عند الانتهاء من الجزء اﻷوّل، قبل الجزء التالي. يعطي الرجوع الدوريّ لحلقة الأحداث ما يكفي من "النفَس" لمحرّك جافاسكربت للقيام بشيء آخر، للاستجابة لأفعال المستخدم اﻷخرى.

الشيء الملاحظ هو أنّ كلا النسختين - مع أو دون تقسيم المهمّة بواسطة setTimeout - متقاربتان في السرعة. ليس هناك فرق كبير في الوقت الكلّيّ للعدّ. لتقريبهما أكثر، لنقم بشيء من التحسين.

سننقل برمجة setTimeout إلى بداية العدّ count()‎:

let i = 0;

let start = Date.now();

function count() {

  // نقل البرمجة إلى البداية
  if (i < 1e9 - 1e6) {
    setTimeout(count); // برمجة الاستدعاء التالي
  }

  do {
    i++;
  } while (i % 1e6 != 0);

  if (i == 1e9) {
    alert("Done in " + (Date.now() - start) + 'ms');
  }

}

count();

حاليّا، عندما نبدأ العدّ count()‎ ونرى أنّنا سنحتاج المزيد من العدّ، نبرمج ذلك مباشرة، قبل أداء المهمّة. إذا فعلت ذلك، من السهل أن تلاحظ أنّه يأخذ وقتا أقلّ بكثير. تسأل لماذا؟

هذا بسيط: كما تذكر، يوجد في المتصفّح تأخير صغير بمقدار 4ms عند تعدّد الاستدعاءات المتداخلة للدالة setTimeout. حتى لو وضعنا 0، فسيكون 4ms (أو أكثر بقليل). فكلّما برمجناه أبكر، جرى بشكل أسرع.

في النهاية، لقد قسّمنا مهمّة شرهة لوحدة المعالجة المركزيّة لأجزاء ولا تحبس الآن واجهة المستخدم كما أنّ الوقت الكليّ لتنفيذها ليس أطول بكثير.

حالة الاستخدام 2: الإشارة إلى المعالجة

من الفوائد اﻷخرى لتقسيم المهامّ الثقيلة لسكربتات المتصفّح هو أنّه يمكّننا من إظهار إشارة إلى المعالجة.

كما ذكرنا آنفا، لا تُرسم التغييرات في DOM حتى تتمّ المهمّة الجارية حاليّا، بغضّ النظر عن طول استغراقها. من جهة، هذا جيّد، لأنّ دالّتنا قد تنشئ عدّة عناصر، وتضيفها الواحد تلو الآخر إلى الصفحة وتغيّر تنسيقاتها ولن يرى المستخدمّ أيّة حالة "بينيّة" غير تامة. هذا أمر مهمّ، صحيح؟

في هذا المثال، لن تظهر التغيّرات في i حتى تنتهي الدالّة، وبذلك لن نرى سوى القيمة النهائيّة:

<div id="progress"></div>

<script>

  function count() {
    for (let i = 0; i < 1e6; i++) {
      i++;
      progress.innerHTML = i;
    }
  }

  count();
</script>

لكن قد نودّ أيضا إظهار شيء ما خلال المهمّة، كشريط تقدّم مثلا.

إذا قسّمنا المهمّة الثقيلة إلى أجزاء باستخدام setTimeout، فسوف تُرسم التغييرات في ما بينها.

تبدو هذه أحسن:

<script>
  let i = 0;

  function count() {

    // القيام بجزء من المهمّة الثقيلة (*)
    do {
      i++;
      progress.innerHTML = i;
    } while (i % 1e3 != 0);

    if (i < 1e7) {
      setTimeout(count);
    }

  }

  count();
</script>

يُظهر <div> الآن قيما تصاعديّة لـ i، كنوع من شريط التقدّم.

حالة الاستخدام 3: فعل شيء ما بعد الحدث

في معالج حدثٍ ما، قد نقرّر تأجيل بعض اﻷفعال إلى حين انتشار الحدث نحو الأعلى ومعالجته في جميع المستويات. يمكننا فعل ذلك من خلال إحاطة الشيفرة بالدالة setTimeout بتأخير منعدم.

قد رأينا في مقال إرسال اﻷحداث المخصّصة مثالًا لذلك: اُرسل الحدث المخصّص must-open داخل setTimeout، لكي يقع بعد المعالجة الكليّة لحدث "النقر".

menu.onclick = function() {
  // ...

  // إنشاء حدث مخصّص ببيانات عنصر القائمة الذي نُقر
  let customEvent = new CustomEvent("menu-open", {
    bubbles: true
  });

  // إرسال الحدث المخصّص بشكل لا متزامن
  setTimeout(() => menu.dispatchEvent(customEvent));
};

المهام الكبرى والمهام الصغرى

إلى جانب المهامّ الكبرى macrotasks، التي بُيّنت في هذا المقال، هناك المهامّ الصغرى microtasks، المذكورة في مقال المهامّ الصغرى.

لا تأتي المهامّ الصغرى إلّا من شيفرتنا. تُنشأ عادة بواسطة الوعود promises، حيث يصير تنفيذ معالج ‎then/catch/finally مهمّة صغرى. تُستخدم المهامّ الصغرى تحت غطاء await كذلك، إذ يُعدّ ذلك صورة أخرى لمعالجة الوعود.

هناك أيضا الدالّة الخاصّة queueMicrotask(func)‎ التي تصفّ الدالة func الممررة إليها لكي تُنفَّذ في رتل المهامّ الصغرى.

بعد كلّ مهمّة كبرى مباشرة، ينفّذ المحرّك جميع المهامّ الموجودة في رتل المهام الصغرى، قبل إجراء أيّ مهامّ كبرى أخرى أو التصيير أو أيّ شيء آخر.

على سبيل المثال، ألق نظرةً على:

setTimeout(() => alert("timeout"));

Promise.resolve()
  .then(() => alert("promise"));

alert("code");

كيف سيكون الترتيب هنا؟

  1. يظهر code أوّلا، ﻷنّه استدعاء لا متزامن عاديّ.
  2. يظهر promise ثانيا، لأنّ ‎.then تمرّ عبر رتل المهامّ الصغرى، وتجري بعد الشيفرة الحاليّة.
  3. يظهر timeout أخيرا، لأنّه مهمّة كبرى.

هذا ما تبدو عليه الصورة الثريّة أكثر لحلقة الأحداث (الترتيب هو من اﻷعلى إلى اﻷسفل، بمعنى: السكربت أوّلا، ثمّ المهامّ الصغرى، ثمّ التصيير وهكذا):

eventLoop-full.png

تتمّ جميع المهامّ الصغرى قبل أيّ معالج حدث آخر أو تصيير أو أيّ مهمّة كبرى أخرى.

هذا مهمّ، ﻷنّه يضمن أنّ بيئة التطبيق هي نفسها أساسا (لا تغيّر في إحداثيات الفأرة، لا بيانات جديدة من الشبكة، إلى غير ذلك) بين المهام الكبرى.

لو أردنا تنفيذ دالّة بشكل لا متزامن (بعد الشيفرة الحاليّة)، لكن قبل تصيير التغييرات أو معالجة اﻷحداث الجديدة، يمكننا برمجة ذلك بواسطة queueMicrotask.

هذا مثال مع "شريط تقدّم العدّ"، كما في الذي عرضناه سابقا، لكن باستخدام queueMicrotask بدل setTimeout. يمكنك رؤية أنّ التصيير يحصل في آخر المطاف. تماما كالشيفرة المتزامنة:

<div id="progress"></div>

<script>
  let i = 0;

  function count() {

    // القيام بجزء من مهمّة ثقيلة (*)
    do {
      i++;
      progress.innerHTML = i;
    } while (i % 1e3 != 0);

    if (i < 1e6) {
      queueMicrotask(count);
    }

  }

  count();
</script>

الملخص

خوارزميّة أكثر تفصيلا لحلقة اﻷحداث (لكن تبقى مبسّطة مقارنة بالمواصفة):

  1. خذ المهّمة اﻷقدم من رتل المهام الكبرى ونفذّها ("سكربت" مثلا).
  2. نفّذ جميع المهامّ الصغرى:
    • مادام أنّ رتل المهامّ الصغرى ليس فارغا:
      • خذ المهمّة اﻷقدم من الرتل ونفّذها.
  3. صيّر التغييرات إن وُجدت.
  4. إذا كان رتل المهامّ الكبرى فارغا، انتظر قدوم مهمّة كبرى.
  5. اذهب إلى الخطوة 1.

لبرمجة مهمّة كبرى جديدة:

  • استخدم setTimeout(f)‎ منعدمة التأخير.

يمكن أن يُستخدم ذلك لتقسيم مهمّة كبيرة ومثقلة بالحسابات إلى أجزاء، حتى يتسنّى للمتصفّح الاستجابة لأحداث المستخدم وإظهار التقدّم بينها.

تُستخدم كذلك، في معالجات اﻷحداث لبرمجة فعل ما بعد معالجة الحدث كليّة (انتهاء الانتشار نحو اﻷعلى).

لبرمجة مهمّة صغرى جديدة:

  • استخدم queueMicrotask(f)‎.
  • تمرّ معالجات الوعود أيضا عبر رتل المهامّ الصغرى.

ليست هناك معالجة لأحداث واجهة المستخدم أو الشبكة بين المهامّ الصغرى: تجري الواحدة تلو اﻷخرى مباشرة.

وبذلك، قد يودّ أحدهم استخدام queueMicrotask لتنفيذ دالّة بشكل لا متزامن، لكن ضمن نفس حالة البيئة.


ملاحظة: عاملات الويب web workers

للحسابات الطويلة والثقيلة التي لا ينبغي أن تحبس حلقة اﻷحداث، يمكننا استخدام عاملات الويب فهذه طريقة لإجراء الشيفرة في عملية أو خيط thread آخر متوازي.

يمكن أن تتبادل عاملات الويب الرسائل مع العمليّة اﻷساسيّة، لكن لديها متغيّراتها الخاصّة، ولديها حلقة أحداث خاصّة.

لا يمكن لعاملات الويب الوصول إلى DOM، فهي مفيدة، بشكل أساسيّ، في الحسابات، لاستخدام عدّة أنوية لوحدة المعالجة المركزيّة في نفس الوقت.


ترجمة -وبتصرف- للمقال Event loop: microtasks and macrotasks من سلسلة Browser: Document, Events, Interfaces لصاحبها Ilya Kantor.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...