أساسيات JavaScript

حلقة الاحداث والجافاسكريبت غير المتزامن

50 دقيقة الدرس 47 من 60

الجافاسكريبت احادية الخيط

الجافاسكريبت هي في جوهرها لغة برمجة احادية الخيط. هذا يعني انها تملك مكدس استدعاء واحد فقط ويمكنها تنفيذ قطعة واحدة فقط من الكود في كل مرة. بخلاف لغات مثل Java او C++ التي تدعم تعدد الخيوط الحقيقي، تعالج الجافاسكريبت التعليمات بشكل تسلسلي على خيط واحد. كان هذا التصميم اختيارا مقصودا عندما انشا بريندان ايك الجافاسكريبت في عام 1995 -- النموذج احادي الخيط يتجنب تعقيدات الوصول المتزامن لشجرة DOM وحالات السباق والتوقف التام التي تصيب البيئات متعددة الخيوط. لكن اذا كانت الجافاسكريبت تستطيع فعل شيء واحد فقط في كل مرة، فكيف تتعامل مع طلبات الشبكة والمؤقتات وتفاعلات المستخدم دون تجميد المتصفح؟ الاجابة تكمن في حلقة الاحداث وبيئة التشغيل غير المتزامنة التي تحيط بمحرك الجافاسكريبت.

هندسة بيئة تشغيل الجافاسكريبت

لفهم الجافاسكريبت غير المتزامنة، يجب ان تفهم مكونات بيئة تشغيل الجافاسكريبت. تتكون بيئة التشغيل من عدة اجزاء متعاونة تعمل معا لخلق وهم التزامن ضمن نموذج احادي الخيط.

  • مكدس الاستدعاء (Call Stack) -- مكدس الاستدعاء هو بنية بيانات LIFO (الاخير يدخل، الاول يخرج) تتتبع الدالة التي يتم تنفيذها حاليا. عند استدعاء دالة، يتم دفع اطار جديد الى المكدس. عندما تعود الدالة، يتم ازالة اطارها. يمكن للجافاسكريبت فقط تنفيذ الدالة الموجودة في اعلى المكدس.
  • الكومة (Heap) -- الكومة هي منطقة غير منظمة من الذاكرة حيث يتم تخصيص الكائنات. عندما تنشئ كائنات او مصفوفات او دوال، يتم تخزينها في الكومة. يحتفظ مكدس الاستدعاء بمراجع (مؤشرات) لهذه الكائنات المخصصة في الكومة.
  • واجهات الويب البرمجية (Web APIs) -- هذه واجهات برمجة توفرها بيئة الاستضافة، وليس محرك الجافاسكريبت نفسه. تشمل setTimeout وsetInterval وfetch ومستمعي احداث DOM وXMLHttpRequest وتحديد الموقع الجغرافي والمزيد. عند استدعاء هذه الواجهات، تتعامل بيئة الاستضافة مع العمل على خيوط منفصلة خارج محرك الجافاسكريبت.
  • طابور الاستدعاءات (Callback Queue / Macrotask Queue) -- عندما تكمل واجهة ويب عملها (مثلا انتهاء مؤقت او وصول استجابة HTTP)، تضع دالة الاستدعاء المرتبطة في طابور الاستدعاءات. يسمى هذا ايضا طابور المهام او طابور المهام الكبرى.
  • طابور المهام الصغرى (Microtask Queue) -- طابور منفصل ذو اولوية اعلى مخصص للمهام الصغرى. استدعاءات Promise (مثل .then() و.catch() و.finally()) وqueueMicrotask() واستدعاءات MutationObserver كلها تذهب الى هذا الطابور.
  • حلقة الاحداث (Event Loop) -- حلقة الاحداث هي المنسق. تتحقق باستمرار مما اذا كان مكدس الاستدعاء فارغا. اذا كان كذلك، تفرغ حلقة الاحداث اولا جميع المهام الصغرى من طابور المهام الصغرى، ثم تلتقط المهمة الكبرى التالية من طابور الاستدعاءات وتدفعها الى مكدس الاستدعاء للتنفيذ.
ملاحظة: محرك الجافاسكريبت (مثل V8 في Chrome و Node.js او SpiderMonkey في Firefox) يوفر فقط مكدس الاستدعاء والكومة. كل شيء اخر -- واجهات الويب البرمجية والطوابير وحلقة الاحداث -- توفره بيئة الاستضافة (المتصفح او Node.js).

مكدس الاستدعاء بالتفصيل

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

مثال: تتبع مكدس الاستدعاء

function multiply(a, b) {
    return a * b;
}

function square(n) {
    return multiply(n, n);
}

function printSquare(n) {
    const result = square(n);
    console.log(result);
}

printSquare(4);

اليك تتبع مكدس الاستدعاء خطوة بخطوة:

  1. يتم استدعاء printSquare(4) -- يُدفع الى المكدس.
  2. داخل printSquare، يتم استدعاء square(4) -- يُدفع الى المكدس.
  3. داخل square، يتم استدعاء multiply(4, 4) -- يُدفع الى المكدس.
  4. multiply يعود بـ 16 -- يُزال من المكدس.
  5. square يعود بـ 16 -- يُزال من المكدس.
  6. يتم استدعاء console.log(16) داخل printSquare -- يُدفع ويُزال.
  7. printSquare ينتهي -- يُزال من المكدس. المكدس الان فارغ.
تحذير: اذا نما مكدس الاستدعاء بشكل كبير جدا (مثلا التكرار اللانهائي)، تحصل على خطا "Maximum call stack size exceeded" المعروف بتجاوز سعة المكدس. يفرض المتصفح او Node.js حدا على عمق مكدس الاستدعاء للحماية من الحلقات اللانهائية التي تستهلك كل الذاكرة المتاحة.

كيف تمكّن واجهات الويب البرمجية السلوك غير المتزامن

عند استدعاء دالة غير متزامنة مثل setTimeout، لا ينتظر محرك الجافاسكريبت انتهاء المؤقت. بدلا من ذلك، يفوض المؤقت الى طبقة واجهات الويب البرمجية (التي يوفرها المتصفح) وينتقل فورا الى السطر التالي من الكود. تشغل واجهة الويب البرمجية المؤقت على خيط منفصل. عند انتهاء المؤقت، تضع واجهة الويب البرمجية دالة الاستدعاء في طابور الاستدعاءات. ثم تلتقطها حلقة الاحداث عندما يكون مكدس الاستدعاء فارغا.

مثال: setTimeout وواجهة الويب البرمجية

console.log('Start');

setTimeout(function() {
    console.log('Timer callback');
}, 2000);

console.log('End');

// المخرجات:
// Start
// End
// Timer callback (بعد ~2 ثانية)

اليك ما يحدث بالضبط:

  1. console.log('Start') يُنفذ فورا على مكدس الاستدعاء. المخرج: Start.
  2. setTimeout يُستدعى. يسلم محرك الجافاسكريبت الاستدعاء والتاخير 2000 مللي ثانية لواجهة الويب البرمجية. setTimeout نفسه يعود فورا ويُزال من المكدس.
  3. console.log('End') يُنفذ فورا. المخرج: End.
  4. مكدس الاستدعاء الان فارغ. حلقة الاحداث تنتظر.
  5. بعد حوالي 2000 مللي ثانية، تنقل واجهة الويب البرمجية الاستدعاء الى طابور الاستدعاءات.
  6. ترى حلقة الاحداث ان المكدس فارغ، تلتقط الاستدعاء من الطابور، تدفعه الى المكدس، ويُنفذ. المخرج: Timer callback.

طابور الاستدعاءات (طابور المهام الكبرى)

طابور الاستدعاءات، المعروف ايضا بطابور المهام او طابور المهام الكبرى، يحتفظ بالاستدعاءات من واجهات الويب البرمجية الجاهزة للتنفيذ. تشمل هذه الاستدعاءات من setTimeout وsetInterval وsetImmediate (في Node.js) وعمليات الادخال/الاخراج واحداث عرض واجهة المستخدم. تعالج حلقة الاحداث مهمة كبرى واحدة في كل مرة. بعد معالجة كل مهمة كبرى، تتحقق حلقة الاحداث من طابور المهام الصغرى وتفرغه بالكامل قبل التقاط المهمة الكبرى التالية.

مثال: مؤقتات متعددة في طابور الاستدعاءات

setTimeout(() => console.log('Timer 1'), 0);
setTimeout(() => console.log('Timer 2'), 0);
setTimeout(() => console.log('Timer 3'), 0);

console.log('Synchronous');

// المخرجات:
// Synchronous
// Timer 1
// Timer 2
// Timer 3

على الرغم من ان جميع المؤقتات الثلاثة لها تاخير 0 مللي ثانية، الا انها لا تُنفذ فورا. يتم تسجيلها مع واجهة الويب البرمجية التي تضعها في طابور الاستدعاءات. يعمل الكود المتزامن اولا، ثم تعالج حلقة الاحداث كل استدعاء في الطابور بالترتيب.

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

طابور المهام الصغرى هو طابور منفصل ذو اولوية اعلى من طابور المهام الكبرى. تشمل المهام الصغرى استدعاءات Promise (مثل .then() و.catch() و.finally()) واستدعاءات queueMicrotask() واستدعاءات MutationObserver. الفرق الحاسم هو ان حلقة الاحداث تفرغ طابور المهام الصغرى بالكامل قبل معالجة المهمة الكبرى التالية. هذا يعني انه اذا استمرت المهام الصغرى في اضافة مهام صغرى جديدة، فستتم معالجتها جميعا قبل ان تحصل اي مهمة كبرى على فرصة للتشغيل.

مثال: المهام الصغرى مقابل المهام الكبرى

console.log('Script start');

setTimeout(() => {
    console.log('setTimeout');
}, 0);

Promise.resolve()
    .then(() => {
        console.log('Promise 1');
    })
    .then(() => {
        console.log('Promise 2');
    });

queueMicrotask(() => {
    console.log('queueMicrotask');
});

console.log('Script end');

// المخرجات:
// Script start
// Script end
// Promise 1
// queueMicrotask
// Promise 2
// setTimeout

دعنا نتتبع التنفيذ خطوة بخطوة:

  1. console.log('Script start') -- يُنفذ بشكل متزامن. المخرج: Script start.
  2. setTimeout -- يُرسل الاستدعاء الى واجهة الويب البرمجية، ثم يُوضع في طابور المهام الكبرى.
  3. Promise.resolve().then(...) -- يُوضع استدعاء .then() الاول في طابور المهام الصغرى.
  4. queueMicrotask(...) -- يُوضع الاستدعاء في طابور المهام الصغرى.
  5. console.log('Script end') -- يُنفذ بشكل متزامن. المخرج: Script end.
  6. مكدس الاستدعاء فارغ. حلقة الاحداث تفرغ طابور المهام الصغرى:
  7. المهمة الصغرى الاولى: يُطبع Promise 1. هذا .then() يعود، فيُضاف استدعاء .then() الثاني الى طابور المهام الصغرى.
  8. المهمة الصغرى التالية: يُطبع queueMicrotask.
  9. المهمة الصغرى التالية: يُطبع Promise 2 (اُضيف في الخطوة 7).
  10. طابور المهام الصغرى فارغ. الان تلتقط حلقة الاحداث المهمة الكبرى التالية: يعمل استدعاء setTimeout. المخرج: setTimeout.
نصيحة احترافية: ترتيب التنفيذ دائما هو: الكود المتزامن اولا، ثم جميع المهام الصغرى (تفريغ الطابور بالكامل بما في ذلك المهام الصغرى المضافة حديثا)، ثم مهمة كبرى واحدة، ثم جميع المهام الصغرى مرة اخرى، وهكذا. تذكر هذا كالتالي: متزامن -> مهام صغرى -> مهمة كبرى -> مهام صغرى -> مهمة كبرى -> ...

خوارزمية حلقة الاحداث

تتبع حلقة الاحداث خوارزمية دقيقة في كل تكرار (يسمى "نبضة"):

  1. نفذ اقدم مهمة كبرى من طابور المهام الكبرى (او النص البرمجي الاولي نفسه).
  2. بعد اكتمال المهمة الكبرى وفراغ مكدس الاستدعاء، عالج جميع المهام الصغرى في طابور المهام الصغرى. اذا اضافت مهمة صغرى مهاما صغرى جديدة، عالجها ايضا -- افرغ الطابور بالكامل.
  3. اذا كانت هناك فرص للعرض (يحتاج المتصفح لاعادة الرسم)، نفذ استدعاءات requestAnimationFrame، واعد حساب الانماط، وحدث التخطيط، وارسم.
  4. ارجع الى الخطوة 1 والتقط المهمة الكبرى التالية.

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

setTimeout(fn, 0) -- ليس تاخيرا صفريا حقا

من المفاهيم الخاطئة الشائعة ان setTimeout(fn, 0) ينفذ الاستدعاء فورا. هذا غير صحيح. التاخير 0 مللي ثانية يعني "اضف هذا الى طابور المهام الكبرى في اقرب وقت ممكن"، لكن الاستدعاء لا يزال يتعين عليه الانتظار حتى ينتهي الكود المتزامن الحالي، وتُفرغ جميع المهام الصغرى، وتكتمل اي مهام كبرى سابقة. عمليا، تفرض المتصفحات تاخيرا ادنى يبلغ حوالي 4 مللي ثانية لاستدعاءات setTimeout المتداخلة (بعد مستوى التداخل الخامس) كما هو محدد في مواصفات HTML.

مثال: setTimeout(0) لا يعني فوري

const start = performance.now();

setTimeout(() => {
    const elapsed = performance.now() - start;
    console.log('setTimeout(0) ran after: ' + elapsed.toFixed(2) + 'ms');
}, 0);

// محاكاة عمل متزامن ثقيل
let sum = 0;
for (let i = 0; i < 100000000; i++) {
    sum += i;
}

console.log('Heavy work done. Sum: ' + sum);

// المخرجات:
// Heavy work done. Sum: 4999999950000000
// setTimeout(0) ran after: ~150ms (يختلف)

على الرغم من ان المؤقت ضُبط على 0 مللي ثانية، انتظر الاستدعاء اكثر من 150 مللي ثانية لان الحلقة المتزامنة حجبت مكدس الاستدعاء. لم تتمكن حلقة الاحداث من معالجة طابور الاستدعاءات حتى اصبح المكدس فارغا.

توقيت حل الوعود (Promise Resolution)

عندما يُحل وعد (Promise)، تُجدول استدعاءات .then() كمهام صغرى. هذا يعني انها تُنفذ قبل اي مهام كبرى، حتى لو سُجلت تلك المهام الكبرى في وقت سابق. فهم هذا الترتيب ضروري لكتابة كود غير متزامن يمكن التنبؤ بسلوكه وهو موضوع شائع جدا في مقابلات الجافاسكريبت.

مثال: ترتيب Promise مقابل setTimeout

setTimeout(() => console.log('1 - setTimeout'), 0);

new Promise((resolve) => {
    console.log('2 - Promise constructor');
    resolve();
}).then(() => {
    console.log('3 - Promise then');
});

console.log('4 - Synchronous');

// المخرجات:
// 2 - Promise constructor
// 4 - Synchronous
// 3 - Promise then
// 1 - setTimeout
ملاحظة: استدعاء منشئ Promise (الدالة الممررة الى new Promise()) يُنفذ بشكل متزامن. فقط استدعاءات .then() و.catch() و.finally() تُجدول كمهام صغرى. هذا مصدر شائع للارتباك.

توقيت requestAnimationFrame

واجهة requestAnimationFrame (rAF) تجدول استدعاءا ليعمل قبل اعادة رسم المتصفح التالية، عادة بمعدل 60 اطارا في الثانية (كل ~16.7 مللي ثانية). في نموذج حلقة الاحداث، تعمل استدعاءات rAF بعد تفريغ المهام الصغرى وقبل ان يرسم المتصفح، لكنها ليست مهاما صغرى ولا مهاما كبرى -- تشغل مرحلة منفصلة في خط انابيب العرض. هذا يجعل rAF مثاليا للرسوم المتحركة المرئية السلسة.

مثال: requestAnimationFrame في حلقة الاحداث

console.log('Sync start');

requestAnimationFrame(() => {
    console.log('requestAnimationFrame');
});

setTimeout(() => {
    console.log('setTimeout');
}, 0);

Promise.resolve().then(() => {
    console.log('Promise microtask');
});

console.log('Sync end');

// المخرجات النموذجية:
// Sync start
// Sync end
// Promise microtask
// requestAnimationFrame  (قد يختلف بالنسبة لـ setTimeout)
// setTimeout

الترتيب الدقيق لـ requestAnimationFrame بالنسبة لـ setTimeout(fn, 0) قد يختلف بين المتصفحات ويعتمد على وقت حدوث دورة العرض. ومع ذلك، المهام الصغرى تعمل دائما قبل كليهما. بشكل عام، يعمل rAF قبل العرض لكن التوقيت بالنسبة للمهام الكبرى يعتمد على تنفيذ المتصفح.

تجويع المهام الصغرى

لان حلقة الاحداث تفرغ طابور المهام الصغرى بالكامل قبل الانتقال الى المهمة الكبرى التالية، من الممكن ان "تجوّع" المهام الصغرى المهام الكبرى. اذا استمرت مهمة صغرى في جدولة مهام صغرى جديدة، فلن تتم معالجة طابور المهام الكبرى ابدا، وسيبدو المتصفح مجمدا لان العرض محظور ايضا.

مثال: تجويع المهام الصغرى (خطير -- لا تشغله في الانتاج)

// تحذير: سيجمد هذا تبويب المتصفح!
function recursiveMicrotask() {
    Promise.resolve().then(() => {
        console.log('microtask');
        recursiveMicrotask(); // يجدول مهمة صغرى اخرى
    });
}

recursiveMicrotask();

// setTimeout ادناه لن يُنفذ ابدا
setTimeout(() => {
    console.log('This will never print');
}, 0);
تحذير: لا تنشئ ابدا حلقات مهام صغرى لا نهائية في كود الانتاج. بخلاف الحلقة المتزامنة اللانهائية التي تثير خطا بتجاوز سعة المكدس، حلقة المهام الصغرى اللانهائية ستجمد تبويب المتصفح بصمت بدون رسالة خطا. يجب على المستخدم اغلاق التبويب بالقوة. تاكد دائما من ان سلاسل المهام الصغرى لديها شرط انهاء.

حجب حلقة الاحداث

العمليات المتزامنة طويلة التشغيل تحجب حلقة الاحداث بالكامل. بينما يكون مكدس الاستدعاء مشغولا، لا يمكن معالجة اي استدعاءات، ولا يمكن التعامل مع اي احداث، ولا يمكن للمتصفح اعادة رسم الشاشة. لهذا السبب يتسبب الكود المتزامن المكلف حسابيا في عدم استجابة الصفحة -- تجربة "التبويب المجمد" الشهيرة.

مثال: الحجب مقابل عدم الحجب

// حاجب: يجمد واجهة المستخدم لحوالي 5 ثوان
function blockingOperation() {
    const end = Date.now() + 5000;
    while (Date.now() < end) {
        // انتظار مشغول -- يحجب كل شيء
    }
    console.log('Blocking done');
}

// غير حاجب: يسمح لحلقة الاحداث بالتنفس
function nonBlockingOperation(data, index = 0) {
    if (index >= data.length) {
        console.log('Non-blocking done');
        return;
    }

    // معالجة جزء واحد
    processChunk(data[index]);

    // التنازل لحلقة الاحداث، ثم المتابعة
    setTimeout(() => {
        nonBlockingOperation(data, index + 1);
    }, 0);
}

النسخة غير الحاجبة تعالج البيانات في اجزاء، باستخدام setTimeout(fn, 0) لاعادة التحكم الى حلقة الاحداث بين كل جزء. هذا يسمح للمتصفح بالتعامل مع احداث المستخدم واعادة رسم الشاشة ومعالجة الاستدعاءات الاخرى بين الاجزاء. للحساب الثقيل، فكر في استخدام Web Workers التي تعمل على خيط منفصل تماما.

تصور حلقة الاحداث بمثال شامل

دعنا نمر عبر مثال شامل يجمع كل المفاهيم. هذا النوع من التمارين شائع للغاية في المقابلات التقنية للجافاسكريبت.

مثال: تصور كامل لحلقة الاحداث

console.log('1');

setTimeout(() => {
    console.log('2');
    Promise.resolve().then(() => {
        console.log('3');
    });
}, 0);

Promise.resolve().then(() => {
    console.log('4');
    setTimeout(() => {
        console.log('5');
    }, 0);
});

setTimeout(() => {
    console.log('6');
}, 0);

Promise.resolve().then(() => {
    console.log('7');
});

console.log('8');

// المخرجات: 1, 8, 4, 7, 2, 3, 6, 5

دعنا نتتبع كل خطوة بعناية:

  1. المرحلة المتزامنة: console.log('1') يعمل. المخرج: 1.
  2. استدعاء setTimeout الاول يذهب الى طابور المهام الكبرى. نسميه كبرى-أ.
  3. استدعاء Promise.resolve().then() الاول يذهب الى طابور المهام الصغرى. نسميه صغرى-1.
  4. استدعاء setTimeout الثاني يذهب الى طابور المهام الكبرى. نسميه كبرى-ب.
  5. استدعاء Promise.resolve().then() الثاني يذهب الى طابور المهام الصغرى. نسميه صغرى-2.
  6. console.log('8') يعمل. المخرج: 8.
  7. تفريغ طابور المهام الصغرى:
  8. صغرى-1 يعمل: يطبع 4، يجدول setTimeout (نسميه كبرى-ج) في طابور المهام الكبرى.
  9. صغرى-2 يعمل: يطبع 7.
  10. طابور المهام الصغرى فارغ. التقاط المهمة الكبرى التالية.
  11. كبرى-أ يعمل: يطبع 2، يجدول Promise .then() (نسميه صغرى-3) في طابور المهام الصغرى.
  12. تفريغ طابور المهام الصغرى: صغرى-3 يعمل: يطبع 3.
  13. التقاط المهمة الكبرى التالية: كبرى-ب يعمل: يطبع 6.
  14. تفريغ طابور المهام الصغرى (فارغ). التقاط المهمة الكبرى التالية: كبرى-ج يعمل: يطبع 5.

الغاز ترتيب التنفيذ بنمط المقابلات

تختبر مقابلات الجافاسكريبت بشكل متكرر فهمك لحلقة الاحداث. اليك لغزين صعبين مع شرح مفصل.

اللغز 1: async/await وحلقة الاحداث

async function asyncFunc() {
    console.log('A');
    const result = await Promise.resolve('B');
    console.log(result);
    console.log('C');
}

console.log('D');
asyncFunc();
console.log('E');

// المخرجات: D, A, E, B, C

اليك السبب:

  1. console.log('D') يعمل بشكل متزامن. المخرج: D.
  2. يتم استدعاء asyncFunc(). داخلها، console.log('A') يعمل بشكل متزامن. المخرج: A.
  3. await Promise.resolve('B') يوقف الدالة غير المتزامنة مؤقتا. كل شيء بعد await يُجدول كمهمة صغرى. يعود التحكم الى المستدعي.
  4. console.log('E') يعمل بشكل متزامن. المخرج: E.
  5. مكدس الاستدعاء فارغ. المهمة الصغرى من await تعمل: result هو 'B'، فيطبع console.log(result) قيمة B، ثم يطبع console.log('C') قيمة C.

اللغز 2: الوعود والمؤقتات المتداخلة

console.log('Start');

setTimeout(() => {
    console.log('Timeout 1');
    queueMicrotask(() => {
        console.log('Microtask inside Timeout 1');
    });
}, 0);

queueMicrotask(() => {
    console.log('Microtask 1');
    queueMicrotask(() => {
        console.log('Nested Microtask');
    });
});

Promise.resolve()
    .then(() => console.log('Promise 1'))
    .then(() => console.log('Promise 2'))
    .then(() => console.log('Promise 3'));

setTimeout(() => {
    console.log('Timeout 2');
}, 0);

console.log('End');

// المخرجات:
// Start
// End
// Microtask 1
// Promise 1
// Nested Microtask
// Promise 2
// Promise 3
// Timeout 1
// Microtask inside Timeout 1
// Timeout 2

الفكرة الرئيسية هي انه بعد كل مهمة صغرى، تتم ايضا معالجة اي مهام صغرى مضافة حديثا قبل الانتقال الى المهام الكبرى. Microtask 1 يعمل ويضيف Nested Microtask الى الطابور. Promise 1 يعمل ويضيف Promise 2 الى الطابور. ثم يعمل Nested Microtask وPromise 2، وPromise 2 يضيف Promise 3. تُفرغ جميع المهام الصغرى بالكامل قبل ان يعمل اول استدعاء setTimeout.

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

التطبيقات العملية للكود الحقيقي

فهم حلقة الاحداث ليس مجرد معرفة اكاديمية او موضوعا نظريا للمقابلات فقط. انه يؤثر مباشرة وبشكل جوهري على كيفية كتابة كود الانتاج وتصميم التطبيقات التفاعلية عالية الاداء. المطورون الذين يفهمون حلقة الاحداث بعمق يكتبون تطبيقات اكثر استجابة ويتجنبون الاخطاء الشائعة المتعلقة بالتزامن والتوقيت. اليك النقاط العملية الرئيسية التي يجب ان تطبقها في عملك اليومي:

  • لا تحجب الخيط الرئيسي ابدا بحساب متزامن ثقيل. استخدم Web Workers للمهام المكثفة على المعالج او قسم العمل الى اجزاء اصغر باستخدام setTimeout او requestIdleCallback.
  • سلاسل Promise تُنفذ قبل المؤقتات. اذا احتجت لحدوث شيء بعد المهام الصغرى ولكن قبل العرض التالي، استخدم requestAnimationFrame.
  • استخدم queueMicrotask() عندما تحتاج لجدولة شيء بعد الكود المتزامن الحالي ولكن قبل اي مهام كبرى. هذا مفيد لتجميع قراءات وكتابات DOM لتجنب اضطراب التخطيط.
  • async/await لا يجعل الكود متزامنا. انه يوفر سكر نحوي فوق الوعود. الكود بعد await لا يزال مهمة صغرى تُنفذ بشكل غير متزامن.
  • معالجات الاحداث هي مهام كبرى. نقرات المستخدم واحداث لوحة المفاتيح واستجابات الشبكة كلها تُعالج كمهام كبرى. لهذا السبب قد يبدو معالج النقر بطيئا اذا استغرقت المهمة الكبرى السابقة وقتا طويلا.

مثال: التنازل لحلقة الاحداث لواجهة مستخدم متجاوبة

async function processLargeArray(items) {
    const CHUNK_SIZE = 1000;

    for (let i = 0; i < items.length; i += CHUNK_SIZE) {
        const chunk = items.slice(i, i + CHUNK_SIZE);

        // معالجة هذا الجزء
        chunk.forEach(item => heavyComputation(item));

        // التنازل لحلقة الاحداث حتى يتمكن المتصفح من اعادة الرسم
        // والتعامل مع احداث المستخدم
        await new Promise(resolve => setTimeout(resolve, 0));

        // تحديث شريط التقدم
        updateProgressBar((i + CHUNK_SIZE) / items.length * 100);
    }

    console.log('All items processed!');
}

يستخدم هذا النمط await new Promise(resolve => setTimeout(resolve, 0)) لاعادة التحكم الى حلقة الاحداث بين الاجزاء. هذا يسمح للمتصفح باعادة رسم شريط التقدم والتعامل مع تفاعلات المستخدم ويمنع التبويب من عدم الاستجابة اثناء العمليات طويلة التشغيل. هذا النمط شائع جدا في تطبيقات الويب الحديثة التي تعالج كميات كبيرة من البيانات على جانب العميل مثل تصفية الجداول الكبيرة او معالجة الصور او تحليل ملفات CSV. بدون هذا التنازل لحلقة الاحداث سيشعر المستخدم بتجمد الواجهة تماما مما يخلق تجربة مستخدم سيئة. تذكر دائما ان المستخدم يتوقع استجابة فورية من واجهة التطبيق حتى لو كانت العمليات الخلفية تستغرق وقتا طويلا.

تمرين عملي

توقع مخرجات الكود التالي بدون تشغيله. اكتب اجابتك، ثم تحقق منها في وحدة تحكم المتصفح. تتبع كل خطوة باستخدام نهج الاعمدة الثلاثة (مكدس الاستدعاء، طابور المهام الصغرى، طابور المهام الكبرى).

console.log('A');

setTimeout(() => console.log('B'), 0);

Promise.resolve()
    .then(() => {
        console.log('C');
        setTimeout(() => console.log('D'), 0);
        return Promise.resolve();
    })
    .then(() => console.log('E'));

queueMicrotask(() => {
    console.log('F');
    queueMicrotask(() => console.log('G'));
});

setTimeout(() => {
    console.log('H');
    Promise.resolve().then(() => console.log('I'));
}, 0);

console.log('J');

بعد ان تحصل على توقعك، شغل الكود وقارن. اذا اختلفت اجابتك، ارجع الى خوارزمية حلقة الاحداث وتتبع كل خطوة بعناية. انتبه بشكل خاص لوقت اضافة مهام صغرى جديدة اثناء معالجة المهام الصغرى وكيف تنشئ سلاسل Promise مهاما صغرى متسلسلة.