JavaScript المتقدم (ES6+)

فهم عميق لحلقة الأحداث

13 دقيقة الدرس 20 من 40

فهم عميق لحلقة الأحداث

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

نموذج وقت تشغيل JavaScript

يعمل JavaScript في بيئة أحادية الخيط مع عدة مكونات رئيسية:

مكونات وقت تشغيل JavaScript: 1. مكدس الاستدعاء (Call Stack) - حيث يحدث تنفيذ الدوال - بنية LIFO (آخر داخل، أول خارج) - شيء واحد في كل مرة 2. Web APIs / واجهات برمجة المتصفح - setTimeout, fetch, أحداث DOM - يوفرها المتصفح/Node.js - تعمل خارج الخيط الرئيسي 3. قائمة انتظار الاستدعاءات (Callback Queue) - حيث تنتظر الاستدعاءات من Web APIs - FIFO (أول داخل، أول خارج) - تُعالج بعد فراغ مكدس الاستدعاء 4. قائمة انتظار المهام الصغيرة (Microtask Queue) - أولوية أعلى من قائمة الاستدعاءات - Promises و MutationObserver - تُعالج قبل قائمة الاستدعاءات 5. حلقة الأحداث (Event Loop) - تفحص باستمرار مكدس الاستدعاء والقوائم - تنقل المهام من القوائم إلى مكدس الاستدعاء
مفهوم أساسي: JavaScript أحادي الخيط، مما يعني أنه يمكن تنفيذ جزء واحد فقط من الكود في كل مرة. حلقة الأحداث هي ما يجعل البرمجة غير المتزامنة ممكنة في هذه البيئة أحادية الخيط.

كيف تعمل حلقة الأحداث

لنتتبع مثالاً لفهم ترتيب التنفيذ:

console.log("1: Start"); setTimeout(() => { console.log("2: setTimeout"); }, 0); Promise.resolve().then(() => { console.log("3: Promise"); }); console.log("4: End"); // المخرجات: // 1: Start // 4: End // 3: Promise // 2: setTimeout // لماذا هذا الترتيب؟ // 1. "Start" - متزامن، يُنفذ فوراً // 2. استدعاء setTimeout يذهب إلى قائمة الاستدعاءات // 3. استدعاء Promise يذهب إلى قائمة المهام الصغيرة // 4. "End" - متزامن، يُنفذ فوراً // 5. مكدس الاستدعاء فارغ، حلقة الأحداث تفحص القوائم // 6. قائمة المهام الصغيرة لها أولوية أعلى // 7. "Promise" يُنفذ (من قائمة المهام الصغيرة) // 8. "setTimeout" يُنفذ (من قائمة الاستدعاءات)

تصور مكدس الاستدعاء

فهم مكدس الاستدعاء يساعدك على تصحيح الأخطاء والتفكير في تنفيذ الكود:

function first() { console.log("First function"); second(); console.log("First function end"); } function second() { console.log("Second function"); third(); console.log("Second function end"); } function third() { console.log("Third function"); } first(); // تقدم مكدس الاستدعاء: // الخطوة 1: [first] // الخطوة 2: [first, second] // الخطوة 3: [first, second, third] // الخطوة 4: [first, second] (third اكتمل) // الخطوة 5: [first] (second اكتمل) // الخطوة 6: [] (first اكتمل) // المخرجات: // First function // Second function // Third function // Second function end // First function end

قائمة المهام الصغيرة مقابل قائمة الاستدعاءات

فرق الأولوية بين هذه القوائم حاسم لفهم ترتيب التنفيذ:

console.log("Script start"); setTimeout(() => { console.log("setTimeout 1"); }, 0); Promise.resolve() .then(() => { console.log("Promise 1"); }) .then(() => { console.log("Promise 2"); }); setTimeout(() => { console.log("setTimeout 2"); }, 0); Promise.resolve().then(() => { console.log("Promise 3"); }); console.log("Script end"); // المخرجات: // Script start // Script end // Promise 1 // Promise 3 // Promise 2 // setTimeout 1 // setTimeout 2 // ترتيب التنفيذ: // 1. الكود المتزامن يعمل أولاً (Script start, Script end) // 2. قائمة المهام الصغيرة تُعالج بالكامل (جميع Promises) // 3. قائمة الاستدعاءات تُعالج (جميع setTimeouts)
قاعدة الأولوية: المهام الصغيرة (Promises) تعمل دائماً قبل المهام الكبيرة (setTimeout، setInterval). جميع المهام الصغيرة في القائمة تُعالج قبل الانتقال إلى المهمة الكبيرة التالية.

سلوك setTimeout و setInterval

فهم سلوك المؤقت في حلقة الأحداث:

// الحد الأدنى للتأخير في setTimeout console.log("Start"); setTimeout(() => { console.log("Timeout"); }, 0); // حتى مع تأخير 0ms، setTimeout يذهب إلى قائمة الاستدعاءات // سيعمل بعد كل الكود المتزامن والمهام الصغيرة // المخرجات: // Start // Timeout // مشكلة setInterval let count = 0; const intervalId = setInterval(() => { console.log("Interval:", ++count); // مهمة طويلة المدى const start = Date.now(); while (Date.now() - start < 2000) {} // حجب لمدة ثانيتين if (count === 3) { clearInterval(intervalId); } }, 1000); // إذا استغرق interval أطول من التأخير، تتراكم الاستدعاءات! // هذا يمكن أن يسبب مشاكل في الأداء // نهج أفضل: استخدم setTimeout بشكل متكرر function recursiveTimeout(count = 0) { console.log("Recursive:", count); if (count < 3) { setTimeout(() => recursiveTimeout(count + 1), 1000); } } recursiveTimeout(); // هذا يضمن انتهاء كل استدعاء قبل جدولة التالي

فهم عميق لمهام Promise الصغيرة

تنشئ Promises مهاماً صغيرة يمكن أن تؤدي إلى أنماط تنفيذ مثيرة للاهتمام:

console.log("1"); setTimeout(() => console.log("2"), 0); Promise.resolve() .then(() => console.log("3")) .then(() => console.log("4")) .then(() => { console.log("5"); setTimeout(() => console.log("6"), 0); }) .then(() => console.log("7")); Promise.resolve().then(() => { console.log("8"); Promise.resolve() .then(() => console.log("9")) .then(() => console.log("10")); }); setTimeout(() => console.log("11"), 0); console.log("12"); // المخرجات: // 1, 12, 3, 8, 4, 9, 5, 10, 7, 2, 11, 6 // لماذا هذا الترتيب؟ // - متزامن: 1, 12 // - دفعة المهام الصغيرة الأولى: 3, 8, 4, 9, 5, 10, 7 // - المهام الكبيرة: 2, 11, 6
تحذير: إنشاء مهام صغيرة لا نهائية سيجوع قائمة الاستدعاءات! لن تنتقل حلقة الأحداث أبداً إلى المهام الكبيرة إذا استمرت المهام الصغيرة في إضافة المزيد من المهام الصغيرة.

RequestAnimationFrame

requestAnimationFrame له توقيت خاص في حلقة الأحداث:

console.log("Start"); setTimeout(() => { console.log("setTimeout"); }, 0); requestAnimationFrame(() => { console.log("requestAnimationFrame"); }); Promise.resolve().then(() => { console.log("Promise"); }); console.log("End"); // المخرجات النموذجية (المتصفح): // Start // End // Promise // requestAnimationFrame // setTimeout // ترتيب التنفيذ: // 1. الكود المتزامن // 2. المهام الصغيرة (Promises) // 3. requestAnimationFrame (قبل الرسم التالي) // 4. المهام الكبيرة (setTimeout) // requestAnimationFrame مثالي للرسوم المتحركة // يعمل قبل إعادة رسم المتصفح function animate() { // تحديث حالة الرسوم المتحركة requestAnimationFrame(animate); } requestAnimationFrame(animate);

الآثار على الأداء

فهم حلقة الأحداث يساعدك على كتابة كود أكثر أداءً:

// سيء: حجب حلقة الأحداث function heavyComputation() { const start = Date.now(); while (Date.now() - start < 5000) { // يحجب لمدة 5 ثواني // واجهة المستخدم تتجمد، لا يمكن معالجة أحداث } } // جيد: قسم العمل إلى أجزاء async function heavyComputationAsync() { for (let i = 0; i < 1000; i++) { // قم ببعض العمل processChunk(i); // امنح حلقة الأحداث فرصة لمعالجة أحداث أخرى if (i % 100 === 0) { await new Promise(resolve => setTimeout(resolve, 0)); } } } // أفضل: استخدم Web Workers للمهام كثيفة المعالجة const worker = new Worker("worker.js"); worker.postMessage({ task: "heavy-computation", data: largeDataset }); worker.onmessage = (event) => { console.log("النتيجة من worker:", event.data); }; // Worker يعمل في خيط منفصل، لا يحجب الخيط الرئيسي

تصحيح الكود غير المتزامن

أدوات وتقنيات لتصحيح مشاكل حلقة الأحداث:

// 1. استخدم console.trace() لرؤية مكدس الاستدعاء function debugFunction() { console.trace("مكدس الاستدعاء الحالي"); } setTimeout(() => { debugFunction(); }, 0); // 2. استخدم performance.now() لقياس التوقيت console.time("operation"); await someAsyncOperation(); console.timeEnd("operation"); // 3. تصور مهمة صغيرة مقابل مهمة كبيرة function logWithType(message, type) { console.log(`[${type}] ${message}`); } logWithType("Sync code", "SYNC"); setTimeout(() => { logWithType("setTimeout", "MACRO"); }, 0); Promise.resolve().then(() => { logWithType("Promise", "MICRO"); }); queueMicrotask(() => { logWithType("queueMicrotask", "MICRO"); }); // 4. استخدم تبويب الأداء في DevTools للمتصفح // سجل ملف تعريف الأداء لرؤية توقيت المهام // 5. تحقق من المهام الطويلة (المهام > 50ms) const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { console.warn(`تم اكتشاف مهمة طويلة: ${entry.duration}ms`); } }); observer.observe({ entryTypes: ["longtask"] });

الأخطاء الشائعة في حلقة الأحداث

// خطأ 1: افتراض أن setTimeout(fn, 0) يعمل فوراً console.log("1"); setTimeout(() => console.log("2"), 0); console.log("3"); // المخرجات: 1, 3, 2 (وليس 1, 2, 3) // خطأ 2: حلقة مهام صغيرة لا نهائية (تجوع المهام الكبيرة) function infiniteMicrotasks() { Promise.resolve().then(() => { console.log("Microtask"); infiniteMicrotasks(); // ينشئ مهام صغيرة لا نهائية }); } // infiniteMicrotasks(); // لا تشغل هذا! ستتجمد واجهة المستخدم // خطأ 3: خلط المتزامن وغير المتزامن في الحلقات const urls = ["/api/1", "/api/2", "/api/3"]; // خطأ: forEach لا ينتظر async urls.forEach(async (url) => { const data = await fetch(url); console.log(data); // جميعها تبدأ مرة واحدة }); // صحيح: استخدم for...of for (const url of urls) { const data = await fetch(url); console.log(data); // انتظر كل واحد } // خطأ 4: عدم فهم توقيت Promise.resolve console.log("1"); Promise.resolve().then(() => { console.log("2"); }); console.log("3"); // المخرجات: 1, 3, 2 (استدعاء Promise غير متزامن)

تمرين تطبيقي:

المهمة: توقع ترتيب مخرجات سيناريو حلقة الأحداث المعقد هذا:

console.log("A"); setTimeout(() => console.log("B"), 0); Promise.resolve() .then(() => { console.log("C"); setTimeout(() => console.log("D"), 0); }) .then(() => console.log("E")); setTimeout(() => { console.log("F"); Promise.resolve().then(() => console.log("G")); }, 0); Promise.resolve().then(() => console.log("H")); console.log("I"); // ما هو ترتيب المخرجات؟

الحل:

// المخرجات: A, I, C, H, E, B, F, G, D // الشرح: // 1. متزامن: A, I // 2. قائمة المهام الصغيرة (الدفعة الأولى): C, H, E // - C يُنفذ، يجدول setTimeout D // - H يُنفذ // - E يُنفذ (متسلسل .then) // 3. قائمة الاستدعاءات: B, F // - B يُنفذ (أول setTimeout) // - F يُنفذ (ثاني setTimeout) // 4. مهمة صغيرة أنشأها F: G // 5. قائمة الاستدعاءات: D (جدوله C) // خطوة بخطوة: // متزامن: A, I // دفعة صغيرة 1: C (يجدول D في كبير), H, E // كبير: B // كبير: F (يجدول G في صغير) // دفعة صغيرة 2: G // كبير: D

أفضل الممارسات

// 1. لا تحجب حلقة الأحداث // استخدم async/await أو Web Workers للحسابات الثقيلة // 2. افهم أولوية المهمة الصغيرة مقابل الكبيرة // Promises تعمل قبل setTimeout // 3. قسم المهام الطويلة إلى أجزاء async function processLargeArray(items) { for (let i = 0; i < items.length; i++) { processItem(items[i]); // دع حلقة الأحداث تتنفس كل 100 عنصر if (i % 100 === 0) { await new Promise(resolve => setTimeout(resolve, 0)); } } } // 4. استخدم requestAnimationFrame للرسوم المتحركة function animate() { updateAnimation(); requestAnimationFrame(animate); } // 5. تجنب setTimeout المتداخلة في الحلقات // استخدم setTimeout المتكررة بدلاً من ذلك // 6. راقب المهام الطويلة في الإنتاج const longTaskObserver = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (entry.duration > 50) { console.warn(`مهمة طويلة: ${entry.duration}ms`); } } }); longTaskObserver.observe({ entryTypes: ["longtask"] });

الملخص

في هذا الدرس، تعلمت:

  • وقت تشغيل JavaScript لديه مكدس استدعاء وقوائم وحلقة أحداث
  • حلقة الأحداث تمكن من async في بيئة أحادية الخيط
  • قائمة المهام الصغيرة (Promises) لها أولوية أعلى من قائمة الاستدعاءات
  • setTimeout و setInterval هي مهام كبيرة في قائمة الاستدعاءات
  • requestAnimationFrame يعمل قبل رسم المتصفح
  • الكود المحجوب يجمد واجهة المستخدم ويمنع معالجة الأحداث
  • قسم المهام الثقيلة إلى أجزاء للحفاظ على استجابة واجهة المستخدم
  • فهم ترتيب التنفيذ يساعد في تصحيح الكود غير المتزامن
تهانينا! لقد أكملت الوحدة 3: JavaScript غير المتزامن! لديك الآن فهم عميق لـ Promises و async/await و Fetch API ومعالجة JSON والأنماط غير المتزامنة المتقدمة وحلقة الأحداث. في الوحدة التالية، سنستكشف هياكل بيانات ES6+ مثل Sets و Maps و Symbols!