شاید تا به حال حین انجام کاری با کامپیوتر، لحظاتی با صحنههایی شبیه به لود شدن چیزی در صفحه مواجه شده باشید. این تجربهی ناخوشایند که احتمالا در گذشته و با سیستمهای قدیمیتر بیشتر اتفاق میافتاد دلیل موجهی دارد.
طراحی بسیاری از زبانهای برنامه نویسی به صورت تک رشتهای (single threaded) است و این به معنی آن است که کد یک برنامه به صورت خطی و مستقیم اجرا میشود و در هر لحظه تنها یک کار انجام میدهد. برای مثال اگر ما تابعی داشته باشیم که برای اجرا به نتیجهی تابعی دیگر نیازمند باشد باید صبر کنیم تا تابع اولیه نتیجه را بازگشت دهد و تا زمان اتمام این کار عملاً برنامه متوقف میشود و سرعت برنامه به حداقل میرسد.
برای حل این مشکل باید عملیات را بین هستههای پردازشی تقسیم کرد و کارها را به صورت همزمان پیش برد. سوال اینجاست که ما چگونه میتوانیم بدون مسدود کردن یک رشته در حال اجرا، یک عملیات طولانی را انجام دهیم؟ بسیار خب، به «برنامهنویسی ناهمگام» (asynchronous programming) خوش آمدید. شما با توجه به محیط برنامهای که استفاده میکنید و با ارائه APIهای مختلف، میتوانید امکان اجرای نامتقارن وظایف را فراهم سازید. در زمینه برنامهنویسی وب این محیط مرورگر وب است.
برنامه نویسی ناهمگام در جاوااسکریپت یک روش عالی برای کنترل عملیات ارائه میدهد. در ادامه، با توضیح دقیقتر توابع callback، متد promise و async/await با مفاهیم جاوااسکریپت ناهمگام (async JavaScript) و نحوهی کار آن بیشتر آشنا میشوید.
[button class=”github-btn” href=”http://frontcast.ir/procedural-oop-functional”]ویدیوی آموزشی: برنامه نویسی رویهای، شیگرا و تابعی[/button]
ما میدانیم که جاوااسکریپت به صورت تک رشتهای و اجرای توابع سراسری میباشد. یعنی طبیعت ساختار جاوااسکریپت، همگام (synchronous) و با یک Call stack واحد است. بنابراین کد شما به همان ترتیبی که فراخوانی شده، اجرا میشود که معمولاً به عنوان متد LIFO یا (last-in, first-out) شناخته میشود.
به عنوان مثال اگر ما بخواهیم دو تابع A و B را اجرا کنیم، با فرض اینکه نتیجهی B به خروجی تابع A بستگی دارد. حال اگر تابع A مدت زمانی را برای ارائهی نتیجه نیاز داشته باشد، در پایان این اتفاق سبب کندی برنامه میشود که برای تجربهی کاربر مضر است.
بیایید نمونهای از یک عملیات همگام و مسدود کننده در جاوااسکریپت را بررسی کنیم.
const fs = require('fs') const A = (filePath) => { const data = fs.readFileSync(filePath) return data.toString() } const B = () => { const result = A('./file.md') if (result) { for (i=0; i < result.length; i++) { console.log(i) } } console.log('Result is back from function A') } B() // output is shown below ۰ ۱ ۲ ۳ ۴ ۵ ۶ ۷ ۸ ۹ ۱۰ Result is back from function A
در مثال بالا، برنامه پیش از اجرای منطق کد در تابع B منتظر خروجی تابع A در خط ۹ میباشد. این موضوع تا زمانی که مجبور به خواندن فایلهای بسیار بزرگتر نباشیم، مشکلساز نیست. اما در غیر این صورت این نوع کد نویسی توصیه نمیشود.
نکته: تابع readFileSync یک متد داخلی در ماژول fs در Node.js است که یک ورودی فایل با مسیری مشخص را به صورت همگام می خواند.
بنابراین، برای یک فراخوانی همگام یا عملی همزمان، چرخهی رویداد قادر به اجرای سایر کدها تا زمان اتمام کامل آن نیست.
این نوع برنامه نویسی انجام عملیات های ورودی/خروجی را ممکن میکند. در جاوااسکریپت این عمل توسط event loop، call stack و APIهای ناهمگام مانند callback انجام میشود.
برای درک بهتر عملیات ناهمگام به مثال زیر توجه کنید.
const fs = require('fs') const A = (filePath, callback) => { return fs.readFile(filePath, (error, result) => { if (error) { return callback(error, null) } return callback(null, result) }) } const B = () => { // a callback function attached A('./file.md', (error, result) => { if (result) { for (i=0; i < result.length; i++) { console.log(i) } } }) console.log('Result is not yet back from function A') } B() // output is shown below Result is not yet back from function A ۰ ۱ ۲ ۳ ۴ ۵ ۶ ۷ ۸ ۹ ۱۰
در این لینک میتوانید کد بالا را اجرا کنید. همانطور که مشاهده میکنید، در این کد یک callback ناهمگام تعریف شده. بنابراین، هنگامی که تابع B فراخوانی شد، A بلافاصله بعد از آن اجرا نمیشود. بلکه پس از اتمام ماژول readFile و تجزیه و خواندن مطالب داخل فایل انجام میشود.
callback API اولین API ناهمگام در جاوااسکریپت برای مرورگر و Node.js میباشد. در این قسمت، ترتیب اجرای کد از طریق چرخهی رویداد، call stack و callback API را توضیح میدهیم.
برای نشان دادن دقیق نحوهی عملکرد چرخهی رویداد از مثال قبلی استفاده کرده و مراحل کار را شرح میدهیم:
چرخهی رویداد مثل یک پل بین call stack و callback queue عمل میکند، که دائما در حال ردیابی call stack است. این عضو (event loop) در هر لحظه خالی بودن call stack را چک میکند و در صورت خالی بودن، اگر چیزی در صف وجود داشت، آن را به کال استک منتقل میکند تا به اجرا درآید.
کال استک که از نوع دادهای پشته (Stack data structure) است، کمک میکند که تابع در حال اجرا در برنامه را ردیابی کنیم، در نوع دادهای پشته آخرین آیتمی که وارد میشود، اولین آیتمی است که خارج میشود.
توجه داشته باشید که اگر چه callbackها بخشی از پیادهسازی موتور جاوااسکریپت نیستند، آنها APIهایی در دسترس، هم برای مرورگر و هم برای Node هستند. این APIها کدی که باید اجرا شود را مستقیماً روی call stack قرار نمیدهند ، زیرا این امر میتواند با کدی که از قبل در حال اجراست تداخل داشته باشد، از این رو event loop آنها را هندل میکند.
Callbackها یکی از اولین عضوها برای مدیریت رفتار async در جاوااسکریپت میباشد. همانطور که قبلاً در مثال async مشاهده کردیم، کالبک تابعی است که به عنوان آرگمان به تابع دیگری منتقل می شود، سپس بعداً همراه با نتیجه اجرا میشود.
در حقیقت، بعد از اتمام عملیات ناهمگام، ارورها یا پاسخهای برگشتی توسط با callbackها یا سایر APIهای ناهمگام مشابه مانند promiseها یا async/await هندل میشوند.
توجه: به صورت قراردادی، اولین آرگمان ارسال شده به یک callback، ارور محسوب میشود. دلیل آن این است که خطا روی داده است، در حالی که آرگمان دوم دادههای پاسخ یا نتیجه است.
با همهی اینها، ایجاد یک callback میتواند به سادگی مثال زیر باشد. در اینجا میتوانید کد زیر را به اجرا درآورید.
const callbackExample = (asyncPattern, callback) => { console.log(`This is an example, with a ${asyncPattern} passed an an argument`) callback() } const testCallbackFunc = () => { console.log('Again, this is just a simple callback example') } // call our function and pass the testCallbackFunction as an argument callbackExample('callback', testCallbackFunc)
از آنجایی که نتیجهی هر عمل ناهمگام در call stack خود اتفاق میافتد، ممکن است در زمان وجود یک استثنا، کنترل کنندههای خطا روی call stack نباشند. این ممکن است منجر به عدم انتشار خطا در فراخوانی توابع شود.
همچنین، مسئلهای وحشتناک بهنام “جهنم کالبک” وجود دارد که در آن بسیاری از کالبکهای تودرتو مانند اسپاگتی درهم آمیخته میشوند. هنگامی که این اتفاق می افتد، خطاها به کالبک درست گزارش نمی شوند، که ممکن است فراموش کنیم همهی ارورهای یک پاسخ را هندل کنیم. این اتفاق به خصوص برای توسعه دهندگان جدید بسیار گیج کننده است.
const fs = require('fs') const callbackHell = () => { return fs.readFile(filePath, (err, res)=> { if(res) { firstCallback(args, (err, res1) => { if(res1) { secondCallback(args, (err, res2) => { if(res2) { thirdCallback(args, (err, res3) => { // and so on... } } } } } } }) }
در مثال بالا یک جهنم کالبک معمولی نشان داده شده است. یک روش برای هندلکردن این مشکل تقسیم کالبک به توابع کوچکتر است، همانطور که در مثال قبلی انجام دادیم. علاوه بر این، promise و async/awaitها هم میتوانند برخی از چالشهای مرتبط را حل کنند.
در ادامه، کد مثال قبلی را با استفاده از promiseها به صورت سادهتری بازنویسی میکنیم:
const fs = require('fs') const A = (filePath) => { const promise = new Promise((resolve, reject) => { return fs.readFile(filePath, (error, result) => { if (error) { reject(error) } resolve(result) }) }) return promise } const B = () => { A('./file.md').then((data)=>{ if(data) { for (i=0; i < data.length; i++) { console.log(i) } } }).catch((error)=>{ // handle errors console.log(error) }) console.log('Result is not yet back from function A') } B() // output as above Result is not yet back from function A ۰ ۱ ۲ ۳ ۴ ۵ ۶ ۷ ۸ ۹ ۱۰
همانطور که در مثال بالا مشاهده کردید، ما با استفاده از سازندهی Promise() توانستیم مثال قبلی را از callback به promise تغییر دهیم. در بخش بعدی به طور عمیق promiseها را بررسی خواهیم کرد.
از زمانی که پشتیبانی برای promiseها توسط APIیی مانند inbuilt util.promisify() در Node پیشرفت کردهاست، این تبدیل بسیار آسانتر شده است. در این لینک میتوانید کد بالا را به اجرا درآورید.
Promise در واقع یک شی در جاوااسکریپت است که تکمیل یا شکست یک عملیات ناهمگام را نمایش میدهد. درست مانند callbackها، پرامیسها به ما کمک میکنند که از ارور یا پاسخ موفق عملکردهایی که بلافاصله اجرا نمیشوند، به روش بهتر و تمیزتر استفاده کنیم.
در استاندارد ES2015، پرامیس یک تابع نگهدارنده در میان تابعهای کالبک عادی است. برای ساختن یک پرامیس، از سازندهی Promise() استفاده میکنیم، همانطور که در مثال قبلی برای تبدیل کالبک به پرامیس مشاهده کردید.
سازنده Promise() دو پارامتر را در نظر میگیرد: resolve و reject، که هر دو برگشتی هستند. ما میتوانیم یک عمل async را در درون کالبک اجرا کنیم و سپس در صورت موفقیت آن را resolve کنیم یا در صورت عدم موفقیت آن را reject کنیم. در اینجا نحوهی ساخت پرامیس آورده شده است:
const promiseExample = new Promise((resolve, reject) => { // run an async action and check for the success or failure if (success) { resolve('success value of async operation') } else { reject(throw new Error('Something happened while executing async action')) } })
تابع بالا یک پرامیس جدید را باز میگرداند که در ابتدا در مرحلهی «انتظار» قرار دارد. در اینجا دو پارامتر resolve و reject مانند callbackها عمل میکنند. هنگامی که یک پرامیس با ارزش موفقیت، resolve میشود میگوییم که در وضعیت «تحقق یافته» قرار دارد. از طرف دیگر هنگامی که ارور میدهد یا reject میشود، میگوییم که پرامیس در حالت «رد شده» میباشد.
به منظور استفاده از پرامیس بالا به صورت زیر عمل میکنیم:
promiseExample.then((data) => { console.log(data) // 'success value of async operation' }).catch((error) => { console.log(error) // 'Something happened while executing async action' }).finally(() => { console.log('I will always run when the promise must have settled') })
در مثال فوق، هنگامی که عملیات به پایان برسد، آبلاک کد finally کمک میکند که چیزهایی مانند پاکسازی استدلال هندل کنیم. که این به معنای پردازش نتیجهی پرامیس نیست، بلکه به منظور پردازش کدهای پاکسازی شدهی دیگری است.
علاوه بر این، ما میتوانیم به صورت دستی یک مقدار را به یک پرامیس تبدیل کنیم، به این شکل:
const value = 100 const promisifiedValue = Promise.resolve(value) console.log(promisifiedValue) promisifiedValue.then(val => console.log(val)).catch(err => console.log(err)) //output below Promise { 100 } Promise { <pending> } ۱۰۰
توجه: این موضوع همچنین در رد کردن پرامیسها با استفاده از Promise.reject(new Error(‘Rejected’)) اعمال میشود.
Promise.all در واقع یک پرامیس است که برای بازگرداندن نتیجهی نهایی، منتظر پاسخ تمامی پرامیسهای موجود در یک آرایه میماند و سپس آرایهای به همان ترتیب اصلی از مقادیر پرامیسها بازمیگرداند. اگر هر پرامیسی در آرایه رد شود، نتیجهی خود Promise.all رد میشود.
Promise.all([promise1, promise2]).then(([res1, res2]) => console.log('Results', res1, res2))
در مثال بالا، promise1 و promise2، هردو توابع بازگردانندهی پرامیس هستند. برای اطلاعات بیشتر درمورد Promise.all میتوانید مطالب سایت MDN در این مورد را مطالعه کنید.
یکی از قسمتهای جالب کار با پرامیسها، زنجیر کردن پرامیس (Promise chaining) میباشد. ما میتوانیم چندین پرامیس را با هم زنجیر کنیم، به این صورت که یک مقدار بازگشتی را از پرامیس قبلی تغییر دهد و یا سایر عملیات ناهمگام را یکی پس از دیگری اجرا کند.
با استفاده از مثال قبلی، به صورت زیر میتوان یک زنجیره از پرامیس ها ساخت:
const value = 100 const promisifiedValue = Promise.resolve(value) promisifiedValue.then( (val) => { console.log(val) // 100 return val + 100 }).then( (val) => { console.log(val) // 200 }) // and so on
پرتکرارترین anti-patternهای موجود در مبحث پرامیسها:
اطلاعات بیشتر دربارهی این ضد الگوها را میتوانید از اینجا مطالعه کنید.
[button class=”github-btn” href=”http://frontcast.ir/scope-in-javascript”]ویدیوی آموزشی: درک بهتر Scope در جاوااسکریپت[/button]
با گذشت سالها، جاوااسکریپت از استاندارد کالبک به پرامیسها، سپس به استاندارد async در ES2017 روی آورد. توابع Async به ما اجازه میدهد تا یک برنامه ناهمگام را شبیه به همگام بنویسیم. در پشت صحنه، asyncها از پرامیسها استفاده میکنند. بنابراین، درک درست چگونگی عملکرد پرامیسها برای بهتر فهمیدن async/await بسیار مهم است.
یک تابع ناهمگام توسط کلمهی async قبل از کلمه کلیدی تابع مشخص میشود. همچنین، میتوان متدها را با نوشتن async قبل از نام آنها ناهمگام کرد. وقتی چنین تابع یا متدی فراخوانی میشود، یک پرامیس را برمیگرداند. به محض بازگشت، پرامیس resolve میشود یا اگر استثنایی وجود داشته باشد، پرامیس reject میشود.
هر تابع ناهمگامی در واقع یک آبجکت AsyncFunction است. به عنوان مثال، بگذارید بگوییم ما یک تابع async داریم که یک پرامیس را برمیگرداند:
const asyncFun = () => { return new Promise( resolve => { // simulate a promise by waiting for 3 seconds before resolving or returning with a value setTimeout(() => resolve('Promise value returned'), 3000) }) }
اکنون میتوانیم پرامیس بالا را با یک تابع async بسته بندی کرده و نتیجهی پرامیس داخل تابع را await کنیم:
// add async before the func name async function asyncAwaitExample() { // await the result of the promise here const result = await asyncFun() console.log(result) // 'Promise value returned' after 3 seconds }
توجه داشته باشید که در مثال فوق، await اجرای پرامیس را موقتاً متوقف خواهد کرد تا وقتی که حل شود. جزئیات بیشتر دربارهی Async/await در MDN مطالعه کنید.
Async/await یک سینتکس بسیار تمیزتری برای استفاده از برنامه نویسی ناهمگام ارائه میدهد. در حالی که پرامیسها بارها در قسمتهای مختلف برنامه تکرار شده و حجم برنامه را افزایش میدهند. بنابراین، توابع async در واقع فقط یک syntactic sugar برای پرامیسها محسوب میشوند. به طور خلاصه میتوان گفت:
کار با ارور بسیار ساده تر است زیرا این امر مانند هر کد همگام دیگری به try… catch متکی است.
Await سطح بالا، که در حال حاضر در مرحلهی سوم از ECMAScript است، به توسعه دهندگان اجازه میدهد تا از کلمه کلیدی await خارج از یک تابع async استفاده کنند.
بنابراین، در مثال قبلی async/await، اگر این کار را کرده بودیم:
// here the returned `asyncFun()`promise is not wrapped in an async const result = await asyncFun() console.log(result) // this would throw a SyntaxError: await is only valid in async function
پیش از این، برای شبیه سازی این نوع رفتار، ما بلافاصله از عبارات فانکشن فراخوانی کردیم:
const fetch = require("node-fetch") (async function() { const data = await fetch(url) console.log(data.json()) }())
در حقیقت، از آنجا که ما در کد خود به async/await عادت کردهایم، اکنون میتوان از کلمه کلیدی await به تنهایی استفاده کرد، با این تصور که یک ماژول میتواند به عنوان یک تابع async بزرگ در پس زمینه عمل کند.
با استفاده از این ویژگی جدید await سطح بالا، قطعه زیر به روشی پیش میرود که انتظار عملکرد تابع async/await را دارید. در این حالت، ماژولهای ES را قادر میسازد تا به عنوان توابع async سراسری عمل کنند.
const result = await asyncFun() console.log(result) // 'Promise value returned'
برای کسب اطلاعات بیشتر در مورد await سطح بالا، میتوانید مطالب V8 را در اینجا مطالعه کنید.
همانطور که قبلاً گفته شد، جاوااسکریپت یک مدل concurrency مبتنی بر چرخهی رویداد و APIهای async دارد. در parallelism، چند تسک به صورت موازی و دقیقاً در یک لحظه پردازش میشوند. در واقع concurrency مفهوم کلیتری نسبت به parallelism است و میتوانیم بدون پردازش parallel هم، concurrency داشته باشیم. از سوی دیگر، web workerها توسط مرورگرهای جدید پشتیبانی میشوند و در یک رشتهی موازی در پس زمینه و جدا از رشتهی اصلی، اجرای عملیات را ممکن میسازند.
توابع Async با محدودیت هایی همراه است. همانطور که قبلاً یاد گرفتیم، میتوانیم با استفاده از کالبک، پرامیس یا async/await کد خود را ناهمگام کنیم. وقتی میخواهیم عملیات طولانی مدت را برنامه ریزی و مدیریت کنیم، این APIهای مرورگر و Node واقعاً مفید هستند.
اما برای یک کار کاملاً محاسباتی که مدت زمان زیادی برای حل آن لازم است (برای مثال یک حلقهی for بسیار بزرگ) چه باید کرد؟ در این حالت، ممکن است به یک رشتهی اختصاصی دیگر برای انجام این عملیات نیاز داشته باشیم و رشتهی اصلی را برای انجام کارهای دیگر آزاد کنیم. اینجاست که Web Worker API وارد عمل می شود و امکان اجرای موازی کد را ایجاد میکند.
توابع Async فقط بخش کوچکی از مسائل مرتبط با موضوع اجرای تک رشتهای جاوااسکریپت را حل کنید. web workerها با معرفی یک رشتهی جداگانه برای برنامه ما، کدهای جاوااسکریپت را بدون مسدود کردن چرخهی رویداد، اجرا میکنند تا کد را به طور موازی (parallel) اجرا کند.
Web workerها به صورت زیر ایجاد میشوند:
const worker = new Worker('file.js')
با توجه به موارد فوق، ما یک worker جدید با سازنده (کانستراکتور) ایجاد کردهایم. ما همچنین مسیر اسکریپت برای اجرا در رشتهی worker را مشخص کردیم. از آنجا که آنها در یک رشتهی جداگانه در پس زمینه اجرا میشوند، کدی که باید اجرا شود در یک فایل جاوااسکریپت جداگانه وجود دارد.
برای ارسال پیام به یک worker اختصاصی، می توانیم از ایپیآی postMessage و کنترل کننده رویداد Worker.onmessage استفاده کنیم. برای خاتمه دادن به یک worker، میتوانیم متد terminate() را فراخوانی کنیم. برای کسب اطلاعات بیشتر، این بخش و این بخش از وبسایت MDN را بررسی کنید.
Web Workerها به دلایل زیر دارای محدودیت هستند:
در این مقاله، ما سیر تکامل برنامهنویسی ناهمگام در جاوااسکریپت را از callback تا promise و async/await بررسی کردیم. ما همچنین Web Worker API را توضیح دادیم.
مشاهده کردیم که callbackها توابع سادهای هستند که به توابع دیگر منتقل میشوند و تنها زمانی اجرا میشوند که یک رویداد کامل شده باشد.
علاوه بر این، ما دیدیم که توابع async به طور مستقل در پسزمینه اجرا میشوند، بدون اینکه در رشته اصلی برنامهی ما تداخلی ایجاد شود. با توجه به این ماهیت آنها، هر زمان که آماده باشند، همراه با پاسخ (دیتا یا ارور) بازمیگردند و در نتیجه در سایر فرایندهای اجرایی در برنامه دخالت نمیکنند.
همچنین یاد گرفتیم که Web Workerها چگونه یک رشته جدید جدا از رشته اصلی در حال اجرا، تشکیل میدهند.
برای کسب اطلاعات بیشتر در مورد این مفاهیم، مطالب MDN در مورد جاوااسکریپت ناهمگام و سایر موضوعاتی که در اینجا گفته شد را به صورت دقیقتری مطالعه کنید.
[button class=”github-btn” href=”http://frontcast.ir/course/javascript-advanced”]دوره جامع و پیشرفته جاوااسکریپت[/button]