آموزش جاوا اسکریپت – جلسه ۱۷ – مفهوم Promise

در جلسه قبل درباره دیزاین پترن ها و اهمیت استفاده از آن در برنامه نویسی صحبت کردیم. در این جلسه می خواهیم درباره مفهوم بسیار مهم Promise و اجرای ناهمگام (async) دستورات جاوا اسکریپت صحبت کنیم.
زبان جاوا اسکریپت single-thread می باشد یعنی فقط یک تسک می تواند در یک لحظه اجرا شود. اگرچه زبان هایی مانند javascript که single thread هستند کار برنامه نویس را راحت می کند زیرا نیازی نیست نگران تسک های همزمان و نحوه اجرای آنها باشند اما این بدین معنی نیست که نمی توان تسک های زمانبری مانند خواندن یک فایل از IO یا ارسال درخواست به network را بدون بلاک کردن thread اصلی انجام داد.
کاربرد مفهوم Promise در جاوا اسکریپت:
تصور کنید شما در اسکریپت خود یک درخواست به یک سرور ارسال کرده اید و زمان زیادی طول می کشد تا این درخواست پاسخ داده شود. مطمئنا اگر بخواهید منتظر باشید تا سرور به request شما پاسخ دهد و کاربر را معطل بگذارید اپلیکیشن برای مدتی قفل می کند و غیر قابل استفاده خواهد شد.
اینجاست که جاوا اسکریپت ناهمگام یا asynchronous JavaScript پا به میدان می گذارد. با استفاده از قابلیت async در جاوا اسکریپت (مانند callbacks, promises و async/await) دیگر نیازی نیست کاربر منتظر اجرای یک تسک زمانبر باشد و thread اصلی مشغول باشد.
روند اجرای اسکریپت های ناهمگام یا async:
قبل از اینکه به سراغ جاوااسکریپت ناهمگام برویم بهتر است ابتدا مفهوم اجرای ناهمگام تسک ها را دریابیم. به مثال زیر توجه کنید:
const second = () => { console.log('Hello there!'); }const first = () => { console.log('Hi there!'); second(); console.log('The End'); }first();
برای اینکه بفهمیم کد بالا چگونه در موتور جاوا اسکریپت اجرا می شود بهتر است با مفهوم اجرای دستورات در پشته یا call stack آشنا شویم.
زمینه اجرا (Execution Context):
یک Execution Context محیطی است انتزاعی که در آن اجرای دستورات جاوا اسکریپت صورت می گیرد. کدهای توابع در محیط Execution Context اجرا می شوند. هر تابع Execution Context مختص خود را دارد.
پشته فراخوانی (Call Stack):
همانطور که از نام call stack پیداست بصورت پشته کار می کند یعنی LiFo یا Last in First out که شامل تمام فراخوانی های nested داخل دستورات توابع می باشد. javascript فقط یک پشته واحد دارد زیرا یک زبان برنامه نویسی single thread می باشد. Call Stack با ساختار پشته کار می کند یعنی تسک ها به بالای آن اضافه می شود و از بالای آن برداشته می شوند و اجرا می شوند.
حالا سراغ کد بالا می رویم و بررسی می کنیم که چگونه در موتور جاوا اسکریپت اجرا می شود.
ترتیب اجرای دستورات واقع در پشته (stack):
وقتی دستورات فوق اجرا می شوند ابتدا یک execution context گلوبال ساخته می شود و main() به آن افزوده می شود. وقتی اجرای دستورات به first() می رسد این تابع در بالای stack و بالای main درج می شود.
در گام بعدی دستور console.log(‘Hi there!’) در بالای پشته قرار می گیرد. بلافاصله اجرا می شود و از بالای پشته برداشته (pop) می شود. سپس تابع second() در بالای پشته قرار خواهد گرفت.
در مرحله بعدی دستور console.log(‘Hello there!’) به بالای پشته درج می شود و اجرا شده و از پشته حذف می شود. الان second اجرا شده است. پس از پشته حذف می شود.
سپس در نهایت دستور console.log(‘The End’) در بالای پشته قرار می گیرد و اجرا می شود و حذف می شود. اکنون که first کاملا اجرا شده از پشته حذف می شود.
در مرحله آخر هم main که کاملا اجرا شده از پشته برداشته می شود.
روند اجرای دستورات ناهمگام یا async در جاوا اسکریپت
اکنون که مفهوم call stack را متوجه شده اید می توانیم به سراغ روند اجرای دستورات ناهمگام یا async در جاوا اسکریپت برویم.
بلاک شدن به چه معناست؟
تصور کنید ما می خواهیم در اسکریپت خود یک پردازش تصویر (image processing) یا درخواست از شبکه را به صورت همگام یا synchronous انجام دهیم.
بعنوان مثال:
const processImage = (image) => { /** * doing some operations on image **/ console.log('Image processed'); } const networkRequest = (url) => { /** * requesting network resource **/ return someData; } const greeting = () => { console.log('Hello World'); } processImage(logo.jpg); networkRequest('www.somerandomurl.com'); greeting();
می دانیم که عملیات پردازش تصویر زمانبر است و با اجرای تابع processImage ممکن است برای مدتی (بستگی به سایز تصویر دارد) اپلیکیشن ما درگیر اجرای این درخواست باشد. وقتی تابع processImage بطور کامل انجام شد از پشته یا call stack خارج می شود.
در مرحله بعد networkRequest صدا زده می شود و در بالای پشته قرار می گیرد. اجرای این تابع نیز احتمالا زمانبر خواهد بود. در مرحله بعدی به greeting می رسیم که فقط یک console log دارد که اصلا زمانبر نیست و سریع اجرا می شود و از پشته حذف می شود.
مشاهده کردید که ما باید منتظر می شدیم تا اجرای توابع processImage() یا networkRequest() بطور کامل انجام شود. این به این معنی است که اجرای این دستورات باعث بلاک شدن اجرای call stack یا main thread خواهد شد. بنابراین تا زمان پایان یافتن اجرای آنها قادر نخواهیم بود هیچ دستور دیگری اجرا کنیم که اصلا ایده آل نیست!
خب راهکار چیست؟
ساده ترین راه حل برای جلوگیری از بلاک شدن اجرای دستورات در call stack استفاده از asynchronous callbacks می باشد.
بعنوان مثال:
const networkRequest = () => { setTimeout(() => { console.log('Async Code'); }, 2000); }; console.log('Hello World'); networkRequest();
در این مثال از تابع setTimeout برای شبیه سازی درخواست از شبکه (network request) استفاده کردیم.
برای اینکه نحوه اجرای دستورات فوق را بخوبی درک کنیم باید با مفاهیم دیگری از قبیل event loop و callback queue (که با نام های دیگر مانند task queue یا message queue) آشنا شویم.
دقت کنید که مفاهیم event loop و web API و task queue/ message queue جزئی از موتور زبان جاوا اسکریپت نمی باشد بلکه مربوط به محیط زمان اجرای جاوا اسکریپت در مرورگر (browser’s JavaScript runtime environment) می باشد.
اکنون به کد فوق بازگردیم و ببینیم چطور بصورت ناهمگام یا asynchronous اجرا می شود.
const networkRequest = () => { setTimeout(() => { console.log('Async Code'); }, 2000); }; console.log('Hello World'); networkRequest(); console.log('The End');
وقتی کد بالا اجرا می شود ابتدا console.log(‘Hello World’) به بالای پشته درج شده، اجرا می شود و بلافاصله از بالای پشته خارج می شود. سپس تابع networkRequest صدا زده می شود، به بالای پشته درج می شود.
در مرحله بعد setTimeout فراخوانی می شود و در بالای call stack قرار می گیرد. تابع setTimeout دو آرگومان ورودی می گیرد. یک callback و دیگری تعداد میلی ثانیه.
تابع setTimeout یک تایمر 2 ثانیه ای را در محیط web API ایجاد می کند. پس از پایان اجرای این تابع از پشته خارج می شود. سپس console.log(‘The End’) به بالای استک push می شود و پس از اجرای کامل از استک حذف می شود.
در همین حال timer منقضی شده و تابع کال بک آن وارد صف پیام یا message queue می شود. اما تابع callback بلافاصله اجرا نمی شود و اینجاست که event loop وارد میدان می شود.
حلقه رویداد (Event Loop):
کار event loop اینست که چک کند آیا call stack خالی است یا خیر؟ اگر خالی بود به message queue نگاه می کند که آیا دستوری منتظر اجراست یا خیر؟ در این مثال، message queue فقط شامل یک callback است و call stack نیز خالی است. بنابراین event loop تسک موجود در صف message را در بالای کال استک درج می کند. سپس console.log(‘Async Code’) به بالای کال استک درج (push) می شود. بلافاصله اجرا می شود و از پشته خارج می شود.
در این لحظه تابع callback بطور کامل اجرا شده است و از call stack خارج می شود و اجرای برنامه به پایان می رسد.
DOM Events:
صف پیام یا message queue همچنین شامل کال بک های مربوط به رویدادهای DOM می باشد. مانند رویدادهای ماوس و صفحه کلید و…
بعنوان مثال:
document.querySelector('.btn').addEventListener('click',(event) => { console.log('Button Clicked'); });
در مورد DOM Events ، event listener منتظر رویدادهای DOM (در این مثال onClick) می ماند. پس از اینکه رویداد موردنظر اتفاق افتاد تابع callback آن در صف پیام یا message queue درج می شود و منتظر اجرا شدن می ماند.
سپس مانند حالت قبل event loop وارد کار شده و call stack را چک می کند. اگر خالی باشد تابع کال بک موجود در message queue را برای اجرا شدن به پشته درج می کند.
تا اینجا متوجه شدیم کال بک های ناهمگام (asynchronous callbacks) یا رویدادهای DOM چگونه اجرا می شوند. همچنین نحوه استفاده از صف پیام جهت ایجاد صف انتظار برای درج در call stack و اجرا شدن را یاد گرفتیم.
مفهوم صف های Job Queue/ Micro-Task در ES6:
اکما اسکریپت ورژن 6 مفهوم Job Queue/ Micro-Task را در مبحث promise ارائه کرد. تفاوت بین message queue با job queue/ micro-task queue اینست که اولویت اجرای تسک های واقع در job queue بالاتر است. در واقع اجرای دستورات مربوط به promise قبل از دستورات DOM events و callbacks انجام می شود.
بعنوان مثال:
console.log("Script start"); setTimeout(() => { console.log("setTimeout"); }, 0); new Promise((resolve, reject) => { resolve("Promise resolved"); }) .then((res) => console.log(res)) .catch((err) => console.log(err)); console.log("Script End");
خروجی:
Script start Script End Promise resolved setTimeout
مشاهده می شود promise زودتر از setTimeout اجرا می شود. زیرا تسک promise در صف job ذخیره می شود که اولویت بالاتری نسبت به صف message دارد.
در مثال زیر از دو timeout و دو promise استفاده کرده ایم:
console.log('Script start');setTimeout(() => { console.log('setTimeout 1'); }, 0);setTimeout(() => { console.log('setTimeout 2'); }, 0);new Promise((resolve, reject) => { resolve('Promise 1 resolved'); }).then(res => console.log(res)) .catch(err => console.log(err));new Promise((resolve, reject) => { resolve('Promise 2 resolved'); }).then(res => console.log(res)) .catch(err => console.log(err));console.log('Script End');
خروجی کد فوق:
Script start Script End Promise 1 resolved Promise 2 resolved setTimeout 1 setTimeout 2
همانطور که انتظار می رود ابتدا توابع promise واقع در صف micro-task به بالای call stack درج می شوند و اجرا می شوند و سپس تسک های setTimeout اجرا می شوند.
نکته: تسکی که در job queue قرار دارد به شکل promise تعریف شده است و اولویت اجرایی بالاتری نسبت به message queue دارد. اهمیتی ندارد تسک موجود در message queue چقدر منتظر اجرا باشد.
بعنوان مثال:
console.log("Script start"); setTimeout(() => { console.log("setTimeout 1"); }, 0); setTimeout(() => { console.log("setTimeout 2"); }, 0); new Promise((resolve, reject) => { resolve("Promise 1 resolved"); }) .then((res) => console.log(res)) .catch((err) => console.log(err)); new Promise((resolve, reject) => { resolve("Promise 2 resolved"); }) .then((res) => console.log(res)) .catch((err) => console.log(err)); console.log("Script End");
خروجی:
Script start Script End Promise 1 resolved Promise 2 resolved Promise 3 resolved setTimeout
در این مقاله، با مفاهیم call stack و message queue و job/micro-task queue و event loop آشنا شدیم.
در مقاله بعد درباره اجرای دستورات جاوا اسکریپت به دو روش async و defer صحبت خواهیم کرد.
دیدگاهتان را بنویسید