حلقه Event یکی از مهم‌ترین جنبه‌های جاوا اسکریپت است که باید درک شود. در این بخش از مقالات آموزش Node.js جزییات دقیق طرز کار جاوا اسکریپت با «نخ» (Thread) منفرد و شیوه مدیریت تابع‌های ناهمگام را مورد بررسی قرار می‌دهیم.

به طور کلی، در اغلب مرورگرها یک حلقه Event روی هر برگه مرورگر وجود دارد که موجب می‌شود هر پردازشی مجزا باشد و از مسدودسازی کل مرورگر در نتیجه یک پردازش سنگین یا حلقه بی‌نهایت جلوگیری شود. این محیط چندین حلقه Event همزمان را مدیریت می‌کند تا برای نمونه فراخوانی‌های API را مدیریت کند. بدین ترتیب Web Workers در حلقه رویداد خودشان اجرا می‌شوند. شما به صورت عمده باید در مورد این نگران باشید که کد روی یک حلقه رویداد منفرد اجرا خواهد شد و در هنگام نوشتن کد این ذهنیت جلوگیری از مسدودسازی را مداوماً داشته باشید.

مسدودسازی حلقه Event

هر کد جاوا اسکریپتی که بازگرداندن کنترل به حلقه event را بیش از حد طول بدهد، موجب مسدود شدن اجرای کد جاوا اسکریپت در صفحه خواهد شد و حتی ممکن است نخ UI را مسدود کند. در این حالت کاربر دیگر نمی‌تواند روی چیزی کلیک کند و یا کارهایی مانند اسکرول و نظایر آن انجام دهد.

تقریباً همه کارهای ابتدایی I/O در جاوا اسکریپت غیر مسدودکننده هستند. بنابراین درخواست‌های شبکه، عملیات فایل سیستم Node.js و مواردی از این دست همگی غیر مسدودکننده هستند. مسدودکننده بودن یک استثنا است و به همین دلیل جاوا اسکریپت به طور عمده بر مبنای callback-ها کار می‌کند. البته در نسخه‌های اخیر تمرکز بیشتر روی promises و async/await انتقال یافته است.

پشته فراخوانی

پشته فراخوانی یک صف LIFO به معنی «ورودی آخر، خروجی اول» (Last In ،First Out) است. حلقه Event به طور پیوسته پشته فراخوانی را بررسی می‌کند تا ببیند آیا هیچ تابعی نیاز به اجرا دارد یا نه. در زمانی که چنین نیازی باشد، هر فراخوانی تابعی را که می‌یابد به پشته فراخوانی اضافه می‌کند تا به ترتیب اجرا شوند. همه شما «رد پشته خطا» (Error Stack Trace) را می‌شناسید و آن را در دیباگر یا در کنسول مرورگر دیده‌اید.

مرورگر نام‌های تابع‌ها را در پشته فراخوانی بررسی می‌کند تا مطلع شود که کدام تابع فراوانی جاری را آغاز کرده است.

توضیح ساده حلقه Event

به مثال زیر توجه کنید:

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

//----------
Foobarbaz

 

که مطابق انتظار است. زمانی که این کد اجرا می‌شود ابتدا ()foo فراخوانی می‌شود. درون ()foo ابتدا ()bar را فراخوانی می‌کنیم و سپس ()baz فراخوانی می‌شود. در این مرحله پشته فراخوانی مانند زیر است:

 

حلقه Event در هر تکرار بررسی می‌کند که آیا چیزی در پشته فراخوانی وجود دارد یا نه و آن را اجرا می‌کند:

تا این که پشته فراخوانی خالی شود.

صف‌بندی اجرای تابع

مثال فوق معمولی به نظر می‌رسد و نکته خاصی ندارد: جاوا اسکریپت مواردی که باید اجرا شوند را می‌یابد و آن‌ها را به ترتیب اجرا می‌کند. در ادامه با روش به تعویض انداختن (defer) یک تابع تا زمان خالی شدن پشته آشنا می‌شویم. کاربرد دستور زیر برای فراخوانی یک تابع است:

setTimeout(() => {})، 0)

اما هر بار که تابع دیگری در کد اجرا شود، این دستور نیز اجرا خواهد شد. مثال زیر را در نظر بگیرید:

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

شاید شگفت‌زده شوید که کد فوق عبارت زیر را در خروجی نمایش می‌دهد:

Foobazbar

زمانی که این کد اجرا شود، ابتدا ()foo فراخوانی می‌شود. درون ()foo ابتدا setTimeout فراخوانی می‌شود و bar به عنوان یک آرگومان ارسال می‌شود. ما آن را طوری تنظیم می‌کنیم تا حد امکان به سرعت اجرا شود و مقدار 0 به عنوان تایمر ارسال می‌شود سپس ()baz را فراخوانی می‌کنیم. در این نقطه پشته فراخوانی مانند زیر خواهد بود:

ترتیب اجرای تابع‌ها

در ادامه ترتیب اجرای همه تابع‌ها را در برنامه مشاهده می‌کنید:

صف پیام

زمانی که ()setTimeout فراخوانی می‌شود، مرورگر یا Node.js تایمر را آغاز می‌کند. زمانی که تایمر منقضی شود، در این حالت از آنجا که مقدار 0 به عنوان timeout تعیین شده است، تابع callback در «صف پیام» (Message Queue) قرار می‌گیرد.

صف پیام جایی است که رویدادهای آغاز شده از سمت کاربر مانند رویدادهای کلیک و ضربه‌های کیبورد و یا واکشی پاسخ‌ها از صف پیش از آن که کد فرصت واکنش به آن‌ها را داشته باشد صف‌بندی می‌شوند. رویدادهای DOM مانند onLoad نیز چنین خصوصیتی دارند.

این حلقه به پشته فراخوانی اولویت می‌دهد. این حلقه ابتدا همه چیز که در پشته فراخوانی بیاید پردازش می‌کند و زمانی که چیزی باقی نماند، اقدام به انتخاب موارد موجود در صف پیام می‌کند.

بدین ترتیب لازم نیست برای تابع‌هایی مانند setTimeout منتظر بمانیم یا این که صبر کنیم واکشی یا دیگر امور به پایان برسند، زیرا از سوی مرورگر ارائه شده‌اند و روی نخ‌های خود زنده هستند. برای نمونه اگر مقدار timeout را با استفاده از دستور setTimeout روی 2 ثانیه تنظیم کرده باشید، لازم نیست 2 ثانیه منتظر بمانید چون انتظار در هر جایی رخ می‌دهد.

صف کار در ES6

استاندارد ECMAScript 2015 مفهوم «صف کار» (Job Queue) را معرفی کرده است که از سوی Pomise-ها مورد استفاده قرار می‌گیرد و روشی برای اجرای نتیجه یک تابع async به محض امکان است و دیگر آن را در انتهای پشته فراخوانی قرار نمی‌دهیم. بدین ترتیب Promise-هایی که پیش از اتمام تابع جاری خاتمه یابند، درست پس از تابع جاری اجرا خواهند شد.

برای مقایسه می‌توانید یک قطار هوایی شهر بازی را در نظر بگیرید. صف پیام شما را در پشت همه افرادی که قبل از شما در صف جای گرفته‌اند قرار می‌دهد، در حالی که صف کار یک بلیت سریع‌السیر است که اجازه می‌دهد درست پس از اتمام یک دور، مجدداً بی‌درنگ سوار قطار هوایی شوید.

مثال:

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()

کد فوق عبارت زیر را نمایش می‌دهد:

foobazshould be right after foo، before barbar

این تفاوت بزرگی است که بین Promise-ها (و البته async/await که بر مبنای Promise ساخته شده) با تابع‌های ساده قدیمی ناهمگام که از طریق ()setTimeout یا دیگر API-های پلتفرم اجرا می‌شدند وجود دارد.

درک ()process.nextTick

زمانی که تلاش می‌کنید حلقه رویداد Node.js را درک کنید، یک بخش مهم آن ()process.nextTick است. این بخش با حلقه رویداد به روشی خاص تعامل پیدا می‌کند. هر بار که حلقه رویداد یک دور کامل می‌زند آن را یک tick می‌نامیم.

زمانی که یک تابع را به ()process.nextTick ارسال می‌کنیم به موتور مرورگر دستور می‌دهیم که تابع را در انتهای عملیات جاری و پیش از آغاز تیک بعدی حلقه رویداد احضار کند:

process.nextTick(() => {//do something})

حلقه رویداد مشغول پردازش کردن کد تابع جاری است. زمانی که این عملیات پایان گیرد، موتور جاوا اسکریپت همه تابع‌های ارسالی در فراخوانی‌های nextTick که در طی اجرای این عملیات ارسال شده‌اند را اجرا می‌کند. به این ترتیب به موتور جاوا اسکریپت اعلام می‌کنیم که یک تابع را به روشی ناهمگام (پس از اجرای تابع جاری) اما به سرعت و بدون صف‌بندی پردازش کند. فراخوانی کردن (setTimeout(() => {}، 0 موجب می‌شود که تابع در تیک بعدی و بسیار بعدتر از زمانی که از ()nextTick استفاده می‌کنیم اجرا شود. از ()nextTick زمانی استفاده کنید که می‌خواهید مطمئن شوید در تکرار بعدی حلقه رویداد، کد حتماً اجرا خواهد شد.

درک ()setImmediate

هنگامی که می‌خواهیم بخشی از کد را به صورت ناهمگام اجرا کنیم، اما این کار در سریع‌ترین زمان ممکن صورت گیرد، یک گزینه استفاده از تابع ()setImmediate است که از سوی Node.js ارائه شده است:

setImmediate(() => {//run something})

هر تابعی که به صورت آرگومان ()setImmediate ارسال شود یک callback است که در تکرار بعدی حلقه رویداد اجرا خواهد شد. اینک شاید از خود بپرسید ()setImmediate چه تفاوتی با (setTimeout(() => {}، 0 و یا ()process.nextTick دارد؟ تابعی که به ()process.nextTick ارسال شود در تکرار بعدی حلقه رویداد و پس از پایان یافتن عملیات اجرا خواهد شد. این بدان معنی است که همواره پیش از ()setTimeout و ()setImmediate اجرا می‌شود. یک callback به نام ()setTimeout با تأخیر 0 میلی‌ثانیه بسیار به ()setImmediate شباهت دارد. ترتیب اجرا به عوامل مختلفی وابسته خواهد بود، اما هر دوی آن‌ها در تکرار بعدی حلقه رویداد اجرا خواهند شد.

تایمرها

زمانی که کد جاوا اسکریپت می‌نویسیم ممکن است بخواهیم اجرای یک تابع را به تأخیر بیندازیم. به این منظور می‌توان از ()setTimeout و ()setInterval برای زمان‌بندی اجرای تابع در آینده استفاده کرد.

()setTimeout

هنگامی که کد جاوا اسکریپت می‌نویسیم، می‌توانیم با استفاده از دستور ()setTimeout اجرای یک تابع را به تأخیر بیندازیم. می‌توان یک تابع callback تعیین کرد که بعدتر اجرا شود و مقداری برای میزان این تأخیر در اجرا بر مبنای میلی‌ثانیه تعیین کرد:

setTimeout(() => {// runs after 2 seconds}، 2000)
setTimeout(() => {// runs after 50 milliseconds}، 50)

این ساختار یک تابع جدید تعریف می‌کند. شما می‌توانید تابع دیگر را درون آن فراخوانی کنید یا این که نام یک تابع موجود را به آن ارسال و پارامترهای آن را تعیین کنید:

const myFunction = (firstParam، secondParam) => {// do something}
// runs after 2 secondssetTimeout(myFunction، 2000، firstParam، secondParam)

()setTimeout یک شناسه تایمر بازگشت می‌دهد. این شناسه عموماً استفاده‌ای ندارد، اما می‌توانید آن را ذخیره کنید و در صورتی که بخواهید اجرای این تابع زمان‌بندی‌شده را حذف کنید آن را پاک کنید:

const id = setTimeout(() => {// should run after 2 seconds}، 2000)
// I changed my mindclearTimeout(id)

تأخیر صفر

اگر میزان تأخیر را برابر با 0 تعیین کنید، تابع callback در اولین فرصت ممکن، اما پس از اجرای تابع جاری اجرا خواهد شد:

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

کد فوق عبارت زیر را نمایش می‌دهد:

before after

این حالت به طور خاص در مواردی که می‌خواهیم از مسدود شدن CPI روی وظایف سنگین جلوگیری کنیم و اجازه دهیم تابع‌های دیگر نیز در زمان اجرای یک محاسبه سنگین اجرا شوند مفید خواهد بود. این کار از طریق صف‌بندی تابع‌ها در یک جدول زمان‌بندی ممکن خواهد بود. برخی مرورگرها (مانند IE و Edge) یک متد ()setImmediate را پیاده‌سازی کرده‌اند که دقیقاً همین کارکرد را انجام می‌دهد، اما استاندارد نیست و روی مرورگرهای دیگر وجود ندارد. اما این تابع که معرفی کردیم در Node.js یک استاندارد محسوب می‌شود.

()setInterval

()setInterval یک تابع مشابه ()setTimeout است و تنها یک تفاوت دارد. به جای اجرا کردن یک‌باره تابع callback این تابع آن را برای همیشه و در بازه‌های زمانی معین شده (بر حسب میلی‌ثانیه) اجرا می‌کند:

setInterval(() => {// runs every 2 seconds}، 2000)

تابع فوق هر 2 ثانیه یک بار اجرا می‌شود، مگر اینکه با استفاده از clearInterval و ارسال شناسه بازه‌ای که در پی اجرای setInterval بازگشت می‌یابد از آن بخواهیم متوقف شود:

const id = setInterval(() => {// runs every 2 seconds}، 2000)
clearInterval(id)

فراخوانی clearInterval درون تابع callback setInterval رویه‌ای رایج است و بدین ترتیب می‌توان در مورد اجرای مجدد یا توقف آن یک تصمیم‌گیری خودکار داشت. برای نمونه کد زیر کار دیگری را اجرا می‌کند، مگر اینکه App.somethingIWait دارای مقدار arrived باشد:

const interval = setInterval(function() { if (App.somethingIWait === 'arrived') { clearInterval(interval)
// otherwise do things }}، 100

setTimeout بازگشتی

setTimeout یک تابع را هر n میلی‌ثانیه یک بار اجرا می‌کند و هیچ ملاحظه‌ای در مورد زمان پایان اجرای تابع ندارد. اگر یک تابع همواره زمان مشابهی را نیاز داشته باشد، این روش مناسب خواهد بود:

اما ممکن است تابع برای نمونه بسته به شرایط شبکه، برای اجرا به زمان متفاوتی نیاز داشته باشد:

و حتی ممکن است این زمان طولانی اجرا با تابع بعدی همپوشانی پیدا کند:

تعیین setTimout بازگشتی

برای جلوگیری از این وضعیت می‌توان یک setTimeout بازگشتی زمان‌بندی کرد تا زمانی که تابع callback به پایان می‌رسد فراخوانی شود:

const myFunction = () => {// do something
setTimeout(myFunction، 1000)}
setTimeout(myFunction()}، 1000)

برای دستیابی به این سناریو:

 

setTimeout و setInterval هر دو در Node.js از طریق ماژول Timers در اختیار ما قرار گرفته‌اند. ()Node.js تابع setImmediate را نیز ارائه کرده است که معادل استفاده از دستور زیر است:

setTimeout(() => {}، 0)ا