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

كيفية تنفيذ الدوال داخليا ضمن Node.js


Ola Abbas

سنتعرّف في هذا المقال على مفهوم حلقة الأحداث وكيفية سير عملية تنفيذ الدوال تنفيذًا غير متزامن ضمن نود، كما سنوضِّح كيفية التعامل مع الأحداث المخصَّصة من خلال الصنف EventEmitter الذي يُستخدَم لمعالجة الأحداث.

حلقة الأحداث event loop

تُعَدّ حلقة الأحداث Event Loop أحد أهم جوانب جافاسكربت التي يجب فهمها.

مدخل إلى حلقة الأحداث

اقتباس

توضيح: قد تكون مبرمج جافاسكربت منذ سنوات، ولكنك لم تفهم تمامًا كيفية سير الأمور الداخلية، إذ لا يُعَدّ عدم معرفتك بهذا المفهوم بالتفصيل عيبًا، ولكن معرفتك بذلك أمر جيد.

سنشرح التفاصيل الداخلية لكيفية عمل جافاسكربت باستخدام خيط thread واحد، وسنوضّح كيفية معالجة الدوال غير المتزامنة.

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

توجد حلقة أحداث لكل تبويب في معظم المتصفحات لعزل العمليات عن بعضها البعض وتجنب صفحة الويب ذات الحلقات اللانهائية أو ذات المعالجة الكبيرة التي تؤدي إلى توقّف المتصفح بأكمله، كما تدير البيئة حلقات أحداث متزامنة متعددة لمعالجة استدعاءات واجهة API مثلًا، كما تُشغَّل عمَّال الويب Web Workers في حلقة الأحداث الخاصة بها أيضًا، إذ يجب عليك الاهتمام فقط بتشغيل شيفرتك ضمن حلقة أحداث واحدة، وكتابة شيفرتك مع وضع ذلك في الحسبان لتجنب توقفها.

إيقاف حلقة الأحداث

ستوقِف شيفرة جافاسكربت التي تستغرق وقتًا طويلًا لإعادة التحكم إلى حلقة الأحداث مرةً أخرى تنفيذَ أيّ شيفرة جافاسكربت في الصفحة، إذ يمكن أن توقِف خيط واجهة المستخدِم، وبالتالي لا يمكن للمستخدِم تمرير الصفحة أو النقر عليها وغير ذلك، كما تُعَدّ جميع عناصر الدخل/الخرج الأولية في جافاسكربت غير قابلة للإيقاف non-blocking تقريبًا مثل طلبات الشبكة وعمليات نظام ملفات Node.js وما إلى ذلك، ولكن الاستثناء هو توقّفها، وهذا هو سبب اعتماد جافاسكربت الكبير على دوال رد النداء callbacks واعتمادها مؤخرًا على الوعود promises وصيغة عدم التزامن/الانتظار async/await.

مكدس الاستدعاءات call stack

مكدس الاستدعاءات هو طابور LIFO أي القادم أخيرًا يخرج أولًا Last In First Out، حيث تتحقّق حلقة الأحداث باستمرار من مكدس الاستدعاءات للتأكد من وجود دالة يجب تشغيلها، حيث تضيف حلقة الأحداث عندها أي استدعاء دالة تجده إلى مكدس الاستدعاءات وتنفّذ كل استدعاء بالترتيب.

قد تكون على دراية بتعقّب مكدس الأخطاء في منقِّح الأخطاء debugger أو في وحدة تحكم المتصفح، حيث يبحث المتصفح عن أسماء الدوال في مكدس الاستدعاءات لإعلامك بالدالة التي تنشئ الاستدعاء الحالي:

01_ErrorStackTrace.png

شرح بسيط لحلقة الأحداث

افترض المثال التالي:

const bar = () => console.log('bar')

const baz = () => console.log('baz')

const foo = () => {
 console.log('foo')
 bar()
 baz()
}
foo()

الذي يطبع ما يلي:

foo
bar
baz

تُستدعَى الدالة foo()‎ أولًا عند تشغيل الشيفرة السابقة، ثم نستدعي الدالة bar()‎ أولًا ضمن الدالة foo()‎، ثم نستدعي الدالة baz()‎، ويبدو مكدس الاستدعاءات في هذه المرحلة كما يلي:

02_CallStackExample.png

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

03_EventLoopIteration.png

تنفيذ طابور الدوال

لا يوجد شيء مميز في المثال السابق، حيث تعثر شيفرة جافاسكربت على الدوال لتنفيذها وتشغيلها بالترتيب، ولنشاهد كيفية تأجيل تنفيذ دالة إلى أن يصبح المكدس فارغًا، حيث تُستخدَم حالة الاستخدام setTimeout(() => {}), 0)‎ لاستدعاء دالة، ولكنها تُنفَّذ عند كل تنفيذ لدالة أخرى في الشيفرة، وإليك المثال التالي:

const bar = () => console.log('bar')

const baz = () => console.log('baz')

const foo = () => {
 console.log('foo')
 setTimeout(bar, 0)
 baz()
}

foo()

تطبع الشيفرة السابقة ما يلي:

foo
baz
bar

تُستدعَى الدالة foo()‎ أولًا عند تشغيل الشيفرة، ثم نستدعي setTimeout أولًا ضمن الدالة foo()‎، ونمرّر bar على أساس وسيط، ونطلب منه العمل على الفور بأسرع ما يمكنه، ونمرر القيمة 0 على أساس مؤقت timer، ثم نستدعي الدالة baz()‎، حيث يبدو مكدس الاستدعاءات في هذه المرحلة كما يلي:

04_setTimeoutCallStack.png

يوضِّح الشكل التالي ترتيب تنفيذ جميع الدوال في البرنامج:

05_ExecutionOrderForAllFunctions.png

طابور الرسائل Message Queue

يبدأ المتصفح أو Node.js المؤقت timer عند استدعاء الدالة setTimeout()‎، ثم توضَع دالة رد النداء callback function في طابور الرسائل Message Queue بمجرد انتهاء صلاحية المؤقت عالفور مثل حالة وضع القيمة 0 على أساس مهلة زمنية timeout.

يُعَدّ طابور الرسائل المكان الذي توضَع الأحداث التي بدأها المستخدِم مثل أحداث النقر أو أحداث لوحة المفاتيح أو جلب الاستجابات الموجودة في طابور قبل أن تتاح لشيفرتك فرصة الرد عليها أو أحداث DOM مثل onLoad.

اقتباس

ملاحظة: تعطِي الحلقة الأولوية لمكدّس الاستدعاءات، وتعالج كل شيء تجده فيه أولًا، ثم تنتقل لالتقاط الأشياء الموجودة في طابور الأحداث عند عدم وجود أي شيء في مكدّس الاستدعاءات.

لا يتعيّن علينا انتظار دوال مثل الدالة setTimeout أو انتظار جلب أو تنفيذ أشياء أخرى لهذه الدوال لأن المتصفح يوفّرها وتتقيّد بخيوطها الخاصة، فإذا ضبطتَ مهلة setTimeout الزمنية على 2 ثانية مثلًا، فلن تضطر إلى الانتظار لمدة 2 ثانية، بل يحدث الانتظار في مكان آخر.

طابور العمل Job Queue الخاص بالإصدار ES6

قدّم المعيار ECMAScript 2015 مفهوم طابور العمل Job Queue الذي تستخدمه الوعود Promises التي قُدِّمت أيضًا ضمن الإصدار ES6/ES2015، ويُعَدّ هذا المفهوم طريقةً لتنفيذ نتيجة دالة غير متزامنة بأسرع ما يمكن بدلًا من وضعها في نهاية مكدس الاستدعاءات.

ستُنفَّذ الوعود المؤكَّدة قبل انتهاء الدالة الحالية بعدها مباشرةً، حيث يشبه ذلك ركوب الأفعوانية في مدينة ملاهي، إذ يضعك طابور الرسائل بعد جميع الأشخاص الآخرين الموجودين في هذا الطابور، بينما طابور العمل هو مثل تذكرة Fastpass تتيح لك الركوب في رحلة أخرى في الأفعوانية بعد الانتهاء من الرحلة السابقة مباشرةً، وإليك المثال التالي:

const bar = () => console.log('bar')
const baz = () => console.log('baz')
const foo = () => {
 console.log('foo')
 setTimeout(bar, 0)
 new Promise((resolve, reject) =>
   resolve('should be right after baz, before bar')
 ).then(resolve => console.log(resolve))
 baz()
}
foo()

تطبع الشيفرة السابقة ما يلي:

foo
baz
should be right after baz, before bar
bar

يشكّل ذلك فرقًا كبيرًا بين الوعود Promises وصيغة Async/await المبنيّة على الوعود والدوال القديمة غير المتزامنة من خلال الدالة setTimeout()‎ أو واجهات API للمنصات الأخرى.

المؤقتات Timers: التنفيذ غير المتزامن في أقرب وقت ممكن

06_Timers.png

تُعَدّ الدالة process.nextTick()‎ جزءًا مهمًا من حلقة أحداث Node.js، حيث نسمّي كل دورة كاملة تدورها حلقة الأحداث بالاسم نبضة tick، كما يؤدي تمرير دالة إلى process.nextTick()‎ إلى استدعاء هذه الدالة في نهاية العملية الحالية وقبل بدء نبضة حلقة الأحداث التالية.

process.nextTick(() => {
 //افعل شيئًا ما
})

حلقة الأحداث مشغولة بمعالجة شيفرة الدالة الحالية، كما يشغّل محرك JS عند انتهاء هذه العملية جميع الدوال المُمرَّرة إلى استدعاءات nextTick خلال تلك العملية، وهي الطريقة التي يمكننا من خلالها إخبار محرك JS بمعالجة دالة بطريقة غير متزامنة بعد الدالة الحالية في أقرب وقت ممكن دون وضعها في طابور، كما سيؤدي استدعاء setTimeout(() => {}, 0)‎ إلى تنفيذ الدالة في النبضة التالية بعد وقت أطول من استخدام الدالة nextTick()‎، واستخدم الدالة nextTick()‎ عندما تريد التأكد من تنفيذ الشيفرة في تكرار حلقة الأحداث التالي.

الدالة setTimeout()‎

قد ترغب في تأخير تنفيذ دالة عند كتابة شيفرة جافاسكربت، وهذه هي مهمة الدالة setTimeout، حيث تحدِّد دالة رد نداء لتنفيذها لاحقًا مع قيمة تعبِّر عن مقدار التأخير لتشغيلها لاحقًا مقدَّرةً بالميلي ثانية:

setTimeout(() => {
 // تشغيل بعد 2 ثانية
}, 2000)
setTimeout(() => {
 // تشغيل بعد 50 ميلي ثانية
}, 50)

تحدِّد هذه الصيغة دالةً جديدةً، حيث يمكنك استدعاء أيّ دالة أخرى تريدها هناك، أو يمكنك تمرير اسم دالة موجودة مسبقًا مع مجموعة من المعاملات كما يلي:

const myFunction = (firstParam, secondParam) => {
 // افعل شيئًا ما
}
// تشغيل بعد 2 ثانية
setTimeout(myFunction, 2000, firstParam, secondParam)

تعيد الدالة setTimeout معرِّف المؤقت timer id، وهذا المعرِّف غير مُستخدَم، ولكن يمكنك تخزينه ومسحه إذا أردت حذف تنفيذ الدوال المجدولة:

const id = setTimeout(() => {
 // يجب تشغيله بعد 2 ثانية
}, 2000)
//غيّرنا رأينا
clearTimeout(id)

الدالة setImmediate

إذا أردت تنفيذ جزء من الشيفرة بطريقة غير متزامنة ولكن في أقرب وقت ممكن، فإنّ أحد الخيارات هو استخدام الدالة setImmediate()‎ التي يوفّرها Node.js:

setImmediate(() => {
 //شغّل شيئًا ما
})

تمثِّل الدالة المُرَّرة على أساس وسيط للدالة setImmediate()‎ دالةَ رد نداء تُنفَّذ في تكرار حلقة الأحداث التالي، كما تختلف setImmediate()‎ عن setTimeout(() => {}, 0)‎ مع تمرير مهلة زمنية مقدارها 0 ميلي ثانية وعن process.nextTick()‎، إذ تُنفَّذ الدالة المُمرَّرة إلى process.nextTick()‎ في تكرار حلقة الأحداث الحالي بعد انتهاء العملية الحالية، وهذا يعني أنها ستُنفَّذ دائمًا قبل setTimeout وsetImmediate، كما تشبه دالةُ رد النداء setTimeout()‎ مع تأخير 0 ميلي ثانية الدالةَ setImmediate()‎، في حين يعتمد ترتيب التنفيذ على عوامل مختلفة، ولكنهما ستُشغَّلان في تكرار حلقة الأحداث التالي.

التأخير الصفري Zero delay

إذا حدّدت تأخير المهلة الزمنية بالقيمة 0، فستُنفَّذ دالة رد النداء في أقرب وقت ممكن ولكن بعد تنفيذ الدالة الحالية:

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

ستطبع الدالة السابقة before after.

يُعَدّ هذا مفيدًا لتجنب إيقاف وحدة المعالجة المركزية CPU في المهام المكثفة والسماح بتنفيذ الدوال الأخرى أثناء إجراء عملية حسابية ثقيلة عن طريق وضع الدوال ضمن طابور في المجدوِل scheduler.

اقتباس

توضيح: تطبّق بعض المتصفحات مثل متصفح IE ومتصفح Edge دالة setImmediate()‎ التي تؤدي العملية نفسها، ولكنها ليست معيارًا وغير متوفرة في المتصفحات الأخرى، وإنما هي دالة معيارية في Node.js.

الدالة setInterval()‎

تُعَدّ setInterval دالةً مشابهةً للدالة setTimeout، مع اختلاف أنّ الدالة setInterval ستشغِّل دالةَ رد النداء إلى الأبد ضمن الفاصل الزمني الذي تحدِّده مقدَّرًا بالميلي ثانية بدلًا من تشغيلها مرةً واحدةً:

setInterval(() => {
 // تشغيل كل 2 ثانية
}, 2000)

تُشغَّل الدالة السابقة كل 2 ثانية ما لم تخبرها بالتوقف باستخدام clearInterval من خلال تمرير معرِّف id الفاصل الزمني الذي تعيده الدالة setInterval:

const id = setInterval(() => {
 // تشغيل كل 2 ثانية
}, 2000)
clearInterval(id)

يشيع استدعاء clearInterval ضمن دالة رد نداء الدالة setInterval، للسماح لها بالتحديد التلقائي إذا وجب تشغيلها مرةً أخرى أو إيقافها، حيث تشغّل الشيفرة التالية شيئًا على سبيل المثال إذا لم تكن قيمة App.somethingIWait هي arrived:

const interval = setInterval(() => {
 if (App.somethingIWait === 'arrived') {
   clearInterval(interval)
   return
 }
 // وإلّا افعل شيئًا ما
}, 100)

دالة setTimeout العودية

تبدأ setInterval دالةً كل n ميلي ثانية، دون الأخذ في الحسبان موعد انتهاء تنفيذ هذه الدالة، فإذا استغرقت الدالة القدر نفسه من الوقت دائمًا، فلا بأس بذلك:

07_FunctionTakesAlwaysTheSameAmountOfTime.png

قد تستغرق الدالة أوقات تنفيذ مختلفة اعتمادًا على ظروف الشبكة مثلًا:

08_FunctionTakesDifferentExecutionTimes.png

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

09_LongExecutionOverlapsTheNextOne.png

يمكن تجنب ذلك من خلال جدولة دالة setTimeout العودية لتُستدعَى عند انتهاء دالة رد النداء:

const myFunction = () => {
 // افعل شيئًا ما
 setTimeout(myFunction, 1000)
}
setTimeout(
 myFunction()
}, 1000)

بهدف تحقيق السيناريو التالي:

10_RecursivesetTimeout.png

يتوفَّر كل من setTimeout وsetInterval في Node.js من خلال وحدة المؤقتات Timers module، كما يوفِّر Node.js أيضًا الدالة setImmediate()‎ التي تعادل استخدام setTimeout(() => {}, 0)‎ المستخدَمة للعمل مع حلقة أحداث Node.js في أغلب الأحيان.

مطلق الأحداث Event Emitter الخاص بنود Node

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

يوفِّر نود على جانب الواجهة الخلفية خيارًا لإنشاء نظام مماثل باستخدام وحدة الأحداث events module، إذ تقدّم هذه الوحدة الصنف EventEmitter الذي يُستخدَم لمعالجة الأحداث، كما يمكنك تهيئته كما يلي:

const eventEmitter = require('events').EventEmitter()

يُظهِر هذا الكائن التابعين on وemit من بين أشياء متعددة.

  • emit الذي يُستخدَم لبدء حدث.
  • on الذي يُستخدَم لإضافة دالة رد نداء والتي ستُنفَّذ عند بدء الحدث.

لننشئ حدث start مثلًا ثم نتفاعل معه من خلال تسجيل الدخول إلى الطرفية:

eventEmitter.on('start', () => {
 console.log('started')
})

فإذا شغّلنا ما يلي:

eventEmitter.emit('start')

فستُشغَّل دالة معالج الأحداث، وسنحصل على سجل طرفية.

يمكنك تمرير الوسائط إلى معالج الأحداث من خلال تمريرها على أساس وسائط إضافية إلى التابع emit()‎ كما يلي:

eventEmitter.on('start', (number) => {
 console.log(`started ${number}`)
})

eventEmitter.emit('start', 23)

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

eventEmitter.on('start', (start, end) => {
 console.log(`started from ${start} to ${end}`)
})

eventEmitter.emit('start', 1, 100)

يظهِر كائن EventEmitter توابعًا متعددةً أخرى للتفاعل مع الأحداث مثل:

  • once()‎: يضيف مستمعًا لمرة واحدة.
  • removeListener()‎ أو off()‎: يزيل مستمع حدث من الحدث.
  • removeAllListeners()‎: يزيل جميع المستمعين لحدث ما.

يمكنك قراءة جميع التفاصيل الخاصة بهذه التوابع في صفحة وحدة الأحداث events module على Node.js.

ترجمة -وبتصرّف- للفصل Working with the event loop من كتاب The Node.js handbook لصاحبه Flavio Copes.

اقرأ أيضًا


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

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

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



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

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

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

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   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.


×
×
  • أضف...