وبلاگ
مفاهیم Asynchronous در جاوا اسکریپت: Promises و Async/Await
فهرست مطالب
“تسلط به برنامهنویسی پایتون با هوش مصنوعی: آموزش کدنویسی هوشمند با ChatGPT”
"تسلط به برنامهنویسی پایتون با هوش مصنوعی: آموزش کدنویسی هوشمند با ChatGPT"
"با شرکت در این دوره جامع و کاربردی، به راحتی مهارتهای برنامهنویسی پایتون را از سطح مبتدی تا پیشرفته با کمک هوش مصنوعی ChatGPT بیاموزید. این دوره، با بیش از 6 ساعت محتوای آموزشی، شما را قادر میسازد تا به سرعت الگوریتمهای پیچیده را درک کرده و اپلیکیشنهای هوشمند ایجاد کنید. مناسب برای تمامی سطوح با زیرنویس فارسی حرفهای و امکان دانلود و تماشای آنلاین."
ویژگیهای کلیدی:
بدون نیاز به تجربه قبلی برنامهنویسی
زیرنویس فارسی با ترجمه حرفهای
۳۰ ٪ تخفیف ویژه برای دانشجویان و دانش آموزان
0 تا 100 عطرسازی + (30 فرمولاسیون اختصاصی حامی صنعت)
دوره آموزش Flutter و برنامه نویسی Dart [پروژه محور]
دوره جامع آموزش برنامهنویسی پایتون + هک اخلاقی [با همکاری شاهک]
دوره جامع آموزش فرمولاسیون لوازم آرایشی
دوره جامع علم داده، یادگیری ماشین، یادگیری عمیق و NLP
دوره فوق فشرده مکالمه زبان انگلیسی (ویژه بزرگسالان)
شمع سازی و عودسازی با محوریت رایحه درمانی
صابون سازی (دستساز و صنعتی)
صفر تا صد طراحی دارو
متخصص طب سنتی و گیاهان دارویی
متخصص کنترل کیفی شرکت دارویی
برنامهنویسی ناهمگام (Asynchronous Programming) ستون فقرات توسعه وب مدرن، به ویژه در جاوا اسکریپت، محسوب میشود. از زمان معرفی آن، جاوا اسکریپت به عنوان یک زبان تکرشتهای (single-threaded) عمل کرده است، به این معنی که تنها میتواند یک عملیات را در یک زمان اجرا کند. این ماهیت تکرشتهای، اگرچه سادگی را به ارمغان میآورد، اما در مواجهه با عملیاتهای طولانیمدت یا مسدودکننده (blocking operations) مانند درخواستهای شبکه، خواندن فایلها، یا تعاملات با پایگاه داده، چالشهایی را ایجاد میکند.
بدون مکانیزمهای ناهمگام، یک عملیات طولانیمدت میتوانست اجرای کل برنامه را متوقف کند، که منجر به یخزدگی رابط کاربری (UI) و تجربه کاربری بسیار ضعیف میشد. در طول سالها، جاوا اسکریپت تکامل یافته و الگوهای مختلفی را برای مدیریت عملیات ناهمگام ارائه کرده است. از توابع بازخوانی (callbacks) که اولین گام بودند، تا Promises که یک راهحل ساختاریافتهتر را ارائه دادند، و در نهایت به Async/Await که نقطه اوج سادگی و خوانایی در کد ناهمگام است.
این مقاله به بررسی عمیق مفاهیم ناهمگام در جاوا اسکریپت، با تمرکز بر Promises و Async/Await، میپردازد. ما ابتدا چالشهای برنامهنویسی همگام (synchronous) و مشکل “جهنم بازخوانی” (Callback Hell) را بررسی میکنیم. سپس، با جزئیات به سراغ Promises خواهیم رفت و نحوه ایجاد، استفاده، زنجیرهسازی و مدیریت خطاهای آنها را توضیح میدهیم. در ادامه، Async/Await را به عنوان یک گام تکاملی از Promises معرفی کرده و چگونگی سادهسازی کد ناهمگام را با آن نشان میدهیم. همچنین، به بهترین روشها، ملاحظات عملکردی و سناریوهای کاربرد واقعی در دنیای حرفهای خواهیم پرداخت تا درک جامعی از این مفاهیم حیاتی به دست آورید.
ریشههای نیاز به برنامهنویسی ناهمگام: همگام و چالشهای آن
برای درک کامل اهمیت Promises و Async/Await، ضروری است که ابتدا ماهیت برنامهنویسی همگام و محدودیتهای آن در جاوا اسکریپت را بفهمیم. جاوا اسکریپت در هسته خود یک زبان برنامهنویسی تکرشتهای است. این بدان معناست که در یک لحظه، تنها میتواند یک دستورالعمل را اجرا کند. این مدل اجرایی ساده و قابل پیشبینی است، اما در سناریوهایی که نیاز به انجام عملیاتهای زمانبر داریم، به سرعت به یک گلوگاه تبدیل میشود.
مدل اجرایی همگام و رویداد حلقه (Event Loop)
در مدل همگام، هر عملیاتی که اجرا میشود، تا زمان تکمیل شدن، اجرای عملیات بعدی را مسدود میکند. تصور کنید در حال واکشی داده از یک API هستید؛ اگر این درخواست 5 ثانیه طول بکشد، در طول این 5 ثانیه هیچ بخش دیگری از کد جاوا اسکریپت (از جمله بهروزرسانیهای UI) نمیتواند اجرا شود. این منجر به یک تجربه کاربری نامطلوب میشود، جایی که برنامه به نظر میرسد «یخ زده» یا «پاسخگو نیست».
برای غلبه بر این محدودیت، جاوا اسکریپت از مفهوم “رویداد حلقه” (Event Loop) بهره میبرد. رویداد حلقه به جاوا اسکریپت اجازه میدهد تا عملیاتهای زمانبر (مانند درخواستهای شبکه، تایمرها، ورودی/خروجی) را به صورت ناهمگام مدیریت کند، بدون اینکه رشته اصلی (main thread) را مسدود کند. این عملیاتها به اصطلاح به «پشت صحنه» فرستاده میشوند و پس از تکمیل، نتیجه آنها به «صف فراخوانی بازگشتی» (callback queue) یا «صف میکروتسکی» (microtask queue) ارسال میشود. رویداد حلقه به طور مداوم بررسی میکند که آیا پشته فراخوانی (call stack) خالی است یا خیر؛ اگر خالی باشد، وظایف موجود در صفها را به پشته فراخوانی منتقل کرده و اجرا میکند. این مکانیسم بنیادی به جاوا اسکریپت اجازه میدهد تا بدون مسدود کردن، عملیات ناهمگام را مدیریت کند.
آغازینترین راه حل: توابع بازخوانی (Callbacks)
قبل از معرفی Promises و Async/Await، توابع بازخوانی راه حل اصلی برای مدیریت عملیات ناهمگام بودند. یک تابع بازخوانی، تابعی است که به عنوان آرگومان به تابع دیگری پاس داده میشود و پس از تکمیل عملیات تابع اصلی، فراخوانی میشود. این الگو برای عملیاتهای ناهمگام ساده، مانند `setTimeout` یا گوش دادن به رویدادها، بسیار مؤثر است:
function greetAfterDelay(name, callback) {
setTimeout(() => {
console.log(`Hello, ${name}!`);
callback();
}, 2000);
}
function sayGoodbye() {
console.log("Goodbye!");
}
greetAfterDelay("Alice", sayGoodbye);
// خروجی بعد از 2 ثانیه:
// Hello, Alice!
// Goodbye!
فاجعه جهنم بازخوانی (Callback Hell)
هرچند توابع بازخوانی برای سناریوهای ساده کارآمد هستند، اما زمانی که نیاز به اجرای چندین عملیات ناهمگام به صورت متوالی یا با وابستگی به یکدیگر داریم، به سرعت به مشکلی به نام “جهنم بازخوانی” یا “هرم مرگ” (Pyramid of Doom) منجر میشوند. این مشکل زمانی رخ میدهد که توابع بازخوانی به صورت تو در تو و عمیق، کد را غیرقابل خواندن، نگهداری و خطایابی میکنند.
فرض کنید میخواهید سه عملیات ناهمگام را یکی پس از دیگری انجام دهید، که هر کدام به نتیجه عملیات قبلی بستگی دارد. مثلاً، ابتدا کاربر را احراز هویت کنید، سپس دادههای پروفایل او را واکشی کنید، و در نهایت لیستی از سفارشات او را بارگذاری کنید:
function authenticateUser(username, password, callback) {
setTimeout(() => {
console.log(`Authenticating ${username}...`);
// فرض کنید احراز هویت موفق است
const userId = 123;
callback(null, userId); // null برای خطا، userId برای داده
}, 1000);
}
function fetchUserProfile(userId, callback) {
setTimeout(() => {
console.log(`Fetching profile for user ${userId}...`);
// فرض کنید پروفایل واکشی شده
const userProfile = { name: "Alice", email: "alice@example.com" };
callback(null, userProfile);
}, 1500);
}
function fetchUserOrders(userId, callback) {
setTimeout(() => {
console.log(`Fetching orders for user ${userId}...`);
// فرض کنید سفارشات واکشی شده
const userOrders = ["Order 1", "Order 2"];
callback(null, userOrders);
}, 2000);
}
// سناریوی Callback Hell:
authenticateUser("alice", "pass123", (error, userId) => {
if (error) {
console.error("Authentication failed:", error);
return;
}
fetchUserProfile(userId, (error, userProfile) => {
if (error) {
console.error("Failed to fetch profile:", error);
return;
}
fetchUserOrders(userId, (error, userOrders) => {
if (error) {
console.error("Failed to fetch orders:", error);
return;
}
console.log("User Data:", { userProfile, userOrders });
});
});
});
همانطور که مشاهده میکنید، کد به سمت راست متمایل شده و خوانایی آن به شدت کاهش یافته است. مدیریت خطاها (بررسی `error` در هر مرحله) تکراری و پیچیده میشود. افزودن یک مرحله جدید به این زنجیره، مستلزم تغییر عمیق در ساختار کد است. این چالشها بود که جامعه جاوا اسکریپت را به سمت راهحلهای ساختاریافتهتر و قابل نگهداریتر برای برنامهنویسی ناهمگام سوق داد، و اینجاست که Promises وارد میدان میشوند.
Promises: ناجی از جهنم بازخوانی
Promises (قولها) در ES6 (ECMAScript 2015) معرفی شدند تا پاسخی برای معضل Callback Hell باشند. Promise به عنوان یک نگهدارنده (placeholder) برای نتیجه نهایی یک عملیات ناهمگام عمل میکند. به جای اینکه تابع بازخوانی را مستقیماً به عملیات ناهمگام بدهید، عملیات ناهمگام یک Promise را برمیگرداند. این Promise در نهایت یا با یک مقدار حل (resolve) میشود یا با یک خطا رد (reject) میگردد.
حالتهای یک Promise
یک Promise میتواند در یکی از سه حالت زیر باشد:
- Pending (در انتظار): حالت اولیه، نه حل شده و نه رد شده است. عملیات ناهمگام در حال انجام است.
- Fulfilled (برآورده شده / حل شده): به معنای تکمیل موفقیتآمیز عملیات. Promise با یک مقدار حل شده است.
- Rejected (رد شده): به معنای شکست عملیات. Promise با یک دلیل خطا (error) رد شده است.
یک Promise که به حالت fulfilled یا rejected رسیده باشد، به عنوان یک Promise “تسویه شده” (settled) شناخته میشود. نکته کلیدی این است که پس از تسویه شدن، یک Promise دیگر نمیتواند حالت خود را تغییر دهد؛ یعنی یا برای همیشه fulfilled میماند یا برای همیشه rejected.
ساخت یک Promise
برای ساخت یک Promise، از سازنده `new Promise()` استفاده میکنیم که یک تابع اجراکننده (executor function) را به عنوان آرگومان میگیرد. این تابع اجراکننده، خود دو تابع به نامهای `resolve` و `reject` را به عنوان آرگومان میپذیرد:
const myPromise = new Promise((resolve, reject) => {
// عملیات ناهمگام را اینجا انجام دهید
const success = true; // فرض کنید عملیات موفقیتآمیز است
if (success) {
setTimeout(() => {
resolve("Data successfully fetched!"); // عملیات موفقیتآمیز
}, 2000);
} else {
setTimeout(() => {
reject("Error: Failed to fetch data."); // عملیات ناموفق
}, 2000);
}
});
console.log(myPromise); // خروجی: Promise { }
مصرف یک Promise: متدهای .then(), .catch(), .finally()
برای دریافت نتیجه یک Promise، از متدهای `.then()`, `.catch()`, و `.finally()` استفاده میکنیم. این متدها، خود Promise را برمیگردانند و به ما امکان میدهند تا آنها را زنجیرهوار فراخوانی کنیم.
- `.then(onFulfilled, onRejected)`: این متد برای مدیریت حالتهای fulfilled و rejected یک Promise استفاده میشود. `onFulfilled` تابعی است که در صورت موفقیت Promise فراخوانی میشود و مقدار حل شده را دریافت میکند. `onRejected` (اختیاری) تابعی است که در صورت رد شدن Promise فراخوانی میشود و دلیل خطا را دریافت میکند.
- `.catch(onRejected)`: این متد در واقع یک میانبر برای `.then(null, onRejected)` است و به طور خاص برای مدیریت خطاها در Promise Chain استفاده میشود.
- `.finally(onSettled)`: این متد در هر دو حالت fulfilled یا rejected، پس از تسویه شدن Promise فراخوانی میشود و هیچ آرگومانی دریافت نمیکند. اغلب برای انجام عملیاتهای پاکسازی (cleanup) مانند پنهان کردن یک لودر استفاده میشود.
myPromise
.then(data => {
console.log("Success:", data); // "Data successfully fetched!"
})
.catch(error => {
console.error("Error:", error); // "Error: Failed to fetch data."
})
.finally(() => {
console.log("Promise settled (completed or rejected).");
});
زنجیرهسازی Promises (Promise Chaining)
قدرت واقعی Promises در قابلیت زنجیرهسازی آنها نهفته است. هر فراخوانی `.then()` یک Promise جدید را برمیگرداند، که به ما اجازه میدهد عملیاتهای ناهمگام متوالی را به صورت خطی و خوانا سازماندهی کنیم، و از Callback Hell جلوگیری کنیم. مقدار برگشتی از یک `.then()` (اگر یک Promise نباشد) به عنوان ورودی `.then()` بعدی عمل میکند. اگر یک `.then()` یک Promise را برگرداند، `.then()` بعدی منتظر میماند تا آن Promise جدید تسویه شود.
بیایید مثال Callback Hell قبلی را با Promises بازنویسی کنیم:
function authenticateUserPromise(username, password) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log(`Authenticating ${username}...`);
const success = true; // فرض کنید احراز هویت موفق است
if (success) {
resolve(123); // userId
} else {
reject("Invalid credentials");
}
}, 1000);
});
}
function fetchUserProfilePromise(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log(`Fetching profile for user ${userId}...`);
const success = true;
if (success) {
resolve({ name: "Alice", email: "alice@example.com" });
} else {
reject("Profile not found");
}
}, 1500);
});
}
function fetchUserOrdersPromise(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log(`Fetching orders for user ${userId}...`);
const success = true;
if (success) {
resolve(["Order 1", "Order 2"]);
} else {
reject("Orders not found");
}
}, 2000);
});
}
// زنجیرهسازی Promises:
authenticateUserPromise("alice", "pass123")
.then(userId => {
console.log("Authenticated user ID:", userId);
return fetchUserProfilePromise(userId); // برگرداندن یک Promise جدید
})
.then(userProfile => {
console.log("User Profile:", userProfile);
return fetchUserOrdersPromise(userProfile.userId); // userId از پروفایل
})
.then(userOrders => {
console.log("User Orders:", userOrders);
console.log("All data fetched successfully!");
})
.catch(error => { // مدیریت خطا برای کل زنجیره در یک مکان
console.error("An error occurred in the chain:", error);
})
.finally(() => {
console.log("Promise chain completed.");
});
همانطور که میبینید، کد بسیار خواناتر و خطیتر شده است. مدیریت خطاها نیز در یک بلوک `.catch()` مرکزی امکانپذیر است، که پیچیدگی را به شدت کاهش میدهد.
متدهای کمکی Promise API
جاوا اسکریپت چندین متد ایستا را بر روی شیء `Promise` برای مدیریت مجموعهای از Promiseها ارائه میدهد:
-
`Promise.all(iterable)`:
یک Promise جدید برمیگرداند که زمانی fulfilled میشود که تمام Promiseهای موجود در `iterable` (مثلاً یک آرایه) fulfilled شوند. مقدار حل شده آن یک آرایه از مقادیر حل شده تمام Promiseها، به همان ترتیب ورودی است. اگر حتی یکی از Promiseها reject شود، `Promise.all` فوراً reject میشود با دلیل اولین Promise که reject شده است.const promise1 = Promise.resolve(3); const promise2 = 42; // یک مقدار معمولی هم قبول است، به Promise تبدیل میشود const promise3 = new Promise((resolve, reject) => { setTimeout(resolve, 100, 'foo'); }); Promise.all([promise1, promise2, promise3]) .then(values => { console.log(values); // [3, 42, "foo"] }) .catch(error => { console.error(error); });
-
`Promise.allSettled(iterable)`:
این متد نیز یک Promise جدید برمیگرداند که زمانی fulfilled میشود که تمام Promiseهای موجود در `iterable` تسویه (settled) شوند، چه fulfilled و چه rejected. مقدار حل شده آن یک آرایه از اشیاء است که وضعیت و مقدار/دلیل هر Promise را نشان میدهد. این متد برای سناریوهایی مفید است که میخواهید همه نتایج را فارغ از موفقیت یا شکستشان داشته باشید.const promiseA = Promise.resolve("Success A"); const promiseB = Promise.reject("Error B"); const promiseC = new Promise(resolve => setTimeout(() => resolve("Success C"), 50)); Promise.allSettled([promiseA, promiseB, promiseC]) .then(results => { console.log(results); // خروجی: // [ // { status: "fulfilled", value: "Success A" }, // { status: "rejected", reason: "Error B" }, // { status: "fulfilled", value: "Success C" } // ] });
-
`Promise.race(iterable)`:
یک Promise جدید برمیگرداند که زمانی تسویه میشود که اولین Promise از `iterable` تسویه شود (چه fulfilled و چه rejected). این برای مسابقهدادن چندین عملیات و گرفتن نتیجه سریعترین آنها مفید است.const promiseFast = new Promise(resolve => setTimeout(() => resolve('Fast Done'), 100)); const promiseSlow = new Promise(resolve => setTimeout(() => resolve('Slow Done'), 500)); Promise.race([promiseFast, promiseSlow]) .then(value => { console.log(value); // "Fast Done" });
-
`Promise.any(iterable)`:
(ES2021) یک Promise جدید برمیگرداند که زمانی fulfilled میشود که اولین Promise از `iterable` به حالت fulfilled برسد. اگر همه Promiseها reject شوند، این Promise با یک `AggregateError` reject میشود. این برای زمانی مناسب است که به اولین نتیجه موفقیتآمیز نیاز دارید.const promiseReject1 = Promise.reject('Error 1'); const promiseResolve = new Promise(resolve => setTimeout(() => resolve('First Success!'), 50)); const promiseReject2 = Promise.reject('Error 2'); Promise.any([promiseReject1, promiseResolve, promiseReject2]) .then(value => { console.log(value); // "First Success!" }) .catch(error => { console.error(error); // اگر همه رد شوند });
Promises یک پیشرفت بزرگ نسبت به Callbackها بودند و خوانایی و مدیریت کد ناهمگام را به شدت بهبود بخشیدند. با این حال، حتی زنجیرههای Promises نیز گاهی اوقات میتوانند کمی پیچیده به نظر برسند، به خصوص برای توسعهدهندگانی که به مدل کدنویسی همگام عادت دارند. اینجاست که Async/Await وارد عمل میشود تا تجربه توسعهدهنده را باز هم سادهتر کند.
Async/Await: اوج سادگی در کد ناهمگام
Async/Await که در ES2017 معرفی شد، به عنوان “Syntactic Sugar” (شکر نحوی) برای Promises عمل میکند. هدف اصلی آن این است که کد ناهمگام را بنویسیم و بخوانیم، گویی که کاملاً همگام است، در حالی که در پسزمینه همچنان از Promises و مکانیسم ناهمگام رویداد حلقه استفاده میشود. این قابلیت، انقلابی در چگونگی نوشتن کد جاوا اسکریپت ناهمگام ایجاد کرده و آن را به مراتب خواناتر و قابل مدیریتتر ساخته است.
کلمه کلیدی `async`
کلمه کلیدی `async` قبل از تعریف یک تابع قرار میگیرد و آن تابع را به یک “تابع ناهمگام” تبدیل میکند. یک تابع `async` همیشه یک Promise را برمیگرداند. اگر تابع `async` به صورت عادی یک مقدار را برگرداند، جاوا اسکریپت به طور خودکار آن را در یک Promise حل شده (fulfilled) قرار میدهد. اگر تابع `async` یک خطا را پرتاب کند، Promise برگشتی به صورت رد شده (rejected) خواهد بود.
async function greet() {
return "Hello Async!";
}
greet().then(message => console.log(message)); // Hello Async!
async function throwErrorExample() {
throw new Error("Something went wrong!");
}
throwErrorExample().catch(error => console.error(error.message)); // Something went wrong!
کلمه کلیدی `await`
کلمه کلیدی `await` تنها میتواند درون یک تابع `async` استفاده شود. این کلمه کلیدی، اجرای تابع `async` را تا زمانی که Promiseای که `await` بر روی آن اعمال شده، تسویه (settled) شود، متوقف میکند. پس از تسویه شدن Promise، `await` مقدار حل شده (resolved value) آن Promise را برمیگرداند. اگر Promise رد (rejected) شود، `await` خطای آن را پرتاب میکند.
توقف اجرای تابع `async` به معنای مسدود کردن رشته اصلی (main thread) نیست. در واقع، زمانی که `await` با یک Promise روبرو میشود، تابع `async` به طور موقت از پشته فراخوانی خارج شده و کنترل به رویداد حلقه برگردانده میشود. پس از تسویه Promise، تابع `async` به صف میکروتسکی اضافه میشود تا پس از خالی شدن پشته فراخوانی، از سر گرفته شود.
بازنویسی مثال قبلی با Async/Await
بیایید مثال احراز هویت، واکشی پروفایل و سفارشات را با استفاده از Async/Await بازنویسی کنیم:
// توابع Promise از قبل تعریف شدهاند
// authenticateUserPromise, fetchUserProfilePromise, fetchUserOrdersPromise
async function getUserData() {
try {
console.log("Starting data fetching process...");
// مرحله 1: احراز هویت
const userId = await authenticateUserPromise("alice", "pass123");
console.log("Successfully authenticated. User ID:", userId);
// مرحله 2: واکشی پروفایل
const userProfile = await fetchUserProfilePromise(userId);
console.log("Successfully fetched profile:", userProfile);
// مرحله 3: واکشی سفارشات
const userOrders = await fetchUserOrdersPromise(userId);
console.log("Successfully fetched orders:", userOrders);
console.log("All data fetched!");
return { userProfile, userOrders };
} catch (error) {
console.error("An error occurred during data fetching:", error);
// میتوانید خطا را مجدداً پرتاب کنید یا مقدار پیشفرض برگردانید
throw error;
} finally {
console.log("getUserData function finished execution.");
}
}
// فراخوانی تابع async
getUserData()
.then(data => {
console.log("Final consolidated data:", data);
})
.catch(err => {
console.error("Caught error from getUserData:", err.message);
});
همانطور که مشاهده میکنید، کد به نظر میرسد که به صورت همگام و خط به خط اجرا میشود. این خوانایی به شدت افزایش یافته و درک جریان منطقی برنامه را آسانتر میکند. بلوک `try…catch` برای مدیریت خطاها در کل تابع `async` به کار میرود، که جایگزین زنجیرههای `.catch()` متعدد میشود و مدیریت خطا را بسیار سادهتر میکند.
اجرای همزمان (Concurrent) عملیاتها با Async/Await و Promise.all
یکی از تصورات غلط رایج این است که `await` همیشه به معنای اجرای عملیاتها به صورت متوالی (sequentially) است. در حالی که این برای عملیاتهای وابسته به هم درست است، میتوانید عملیاتهای ناهمگام مستقل را به صورت همزمان اجرا کرده و سپس منتظر تکمیل همه آنها با استفاده از `Promise.all` در کنار `await` بمانید:
async function fetchMultipleData() {
console.log("Fetching multiple data concurrently...");
try {
const [profile, orders] = await Promise.all([
fetchUserProfilePromise(123), // فرض کنید 123 یک userId است
fetchUserOrdersPromise(123)
]);
console.log("Profile:", profile);
console.log("Orders:", orders);
console.log("All concurrent fetches completed.");
} catch (error) {
console.error("Error fetching data concurrently:", error);
}
}
fetchMultipleData();
در این مثال، `fetchUserProfilePromise` و `fetchUserOrdersPromise` به صورت همزمان شروع به اجرا میکنند. `await Promise.all(…)` منتظر میماند تا هر دو Promise تکمیل شوند، و سپس مقادیر حل شده آنها را به صورت یک آرایه برمیگرداند. این الگو به شما امکان میدهد تا از مزایای خوانایی Async/Await و در عین حال از کارایی اجرای همزمان بهرهمند شوید.
تفاوتهای کلیدی Promises و Async/Await
- خوانایی: Async/Await به طور قابل توجهی خواناتر است و به مدل کدنویسی همگام شباهت بیشتری دارد.
- خطایابی: خطایابی کد Async/Await با استفاده از دیباگرها آسانتر است، زیرا میتوانید به سادگی از `await` به `await` قدم بردارید، در حالی که در Promise Chains، ردیابی پشته (stack trace) ممکن است پیچیدهتر باشد.
- مدیریت خطا: Async/Await از ساختار `try…catch` برای مدیریت خطا استفاده میکند که با مدیریت خطای همگام سازگار است، در حالی که Promises از `.catch()` استفاده میکنند.
- پشتیبانی از مرورگر: Promises از ES6 (2015) پشتیبانی میشوند، در حالی که Async/Await از ES2017 به بعد موجود است. امروزه، هر دو به طور گسترده پشتیبانی میشوند.
- کارایی: در سطح پایه، Async/Await هیچ کارایی اضافی نسبت به Promises خام ارائه نمیدهد، زیرا فقط یک لایه انتزاعی بر روی آنهاست. اما افزایش خوانایی و کاهش پیچیدگی میتواند به طور غیرمستقیم به کدنویسی کارآمدتر منجر شود.
به طور کلی، Async/Await ترجیح داده میشود زیرا کد ناهمگام را بسیار خواناتر و قابل نگهداریتر میکند. با این حال، درک Promises اساسی است زیرا Async/Await بر پایه آنها ساخته شده است و درک عمیقتر به شما کمک میکند تا مشکلات پیچیدهتر ناهمگام را حل کنید.
مفاهیم پیشرفته و بهترین روشها
پس از تسلط بر اصول Promises و Async/Await، زمان آن است که به مفاهیم پیشرفتهتر و بهترین روشها برای استفاده از آنها در پروژههای واقعی بپردازیم تا کد شما مقاومتر، کارآمدتر و قابل نگهداریتر باشد.
مدیریت چندین عملیات ناهمگام: همگام در برابر موازی
همانطور که پیشتر اشاره شد، تصمیمگیری در مورد اجرای عملیاتها به صورت متوالی (sequential) یا موازی (parallel/concurrent) حیاتی است و به وابستگیهای بین عملیاتها بستگی دارد:
-
عملیاتهای متوالی (Sequential): زمانی که یک عملیات به نتیجه عملیات قبلی نیاز دارد.
async function fetchSequentially() { const user = await fetchUser(); // ابتدا کاربر را واکشی کن const posts = await fetchPosts(user.id); // سپس پستهای کاربر را واکشی کن return { user, posts }; }
-
عملیاتهای موازی (Parallel/Concurrent): زمانی که عملیاتها از یکدیگر مستقل هستند و میتوانند همزمان اجرا شوند. `Promise.all()` بهترین گزینه است.
async function fetchConcurrently() { const [user, products] = await Promise.all([ fetchUser(), fetchProducts() ]); return { user, products }; }
انتخاب صحیح بین این دو رویکرد میتواند تأثیر چشمگیری بر عملکرد برنامه شما داشته باشد. همیشه سعی کنید عملیاتهای مستقل را به صورت موازی اجرا کنید تا زمان کلی اجرا کاهش یابد.
مدیریت خطاها به صورت جامع
اگرچه `try…catch` در Async/Await عالی است، اما هنوز هم باید به سناریوهای پیچیدهتر مدیریت خطا توجه کنید:
- خطاهای شبکه/تایماوت: اطمینان حاصل کنید که درخواستهای شبکه شما دارای مکانیزمهای تایماوت هستند تا در صورت عدم پاسخگویی سرور، برنامه شما برای همیشه منتظر نماند. `AbortController` (برای `fetch` API) یک ابزار عالی برای این منظور است.
-
Retry Logic (منطق تلاش مجدد): در برخی موارد، ممکن است بخواهید عملیاتهای ناهمگام را در صورت شکست موقت، چندین بار امتحان کنید. میتوانید یک تابع `retry` کلی بنویسید:
async function retry(fn, retries = 3, delay = 1000) { try { return await fn(); } catch (error) { if (retries > 0) { console.log(`Retrying... ${retries} attempts left.`); await new Promise(res => setTimeout(res, delay)); return retry(fn, retries - 1, delay * 2); // Exponential backoff } throw error; } } // استفاده: // await retry(() => fetchSomeData(), 5);
- مدیریت خطاهای جزئی در `Promise.all`: به یاد داشته باشید که `Promise.all` اگر حتی یک Promise رد شود، فوراً رد میشود. اگر میخواهید حتی در صورت شکست برخی از عملیاتها، نتایج باقیمانده را داشته باشید، از `Promise.allSettled()` استفاده کنید.
الگوی AbortController برای لغو درخواستها
`AbortController` یک API بومی مرورگر (و Node.js) است که به شما امکان میدهد درخواستهای شبکه (با `fetch` API) و سایر عملیاتهای ناهمگام را لغو کنید. این بسیار مفید است برای جلوگیری از “Race Conditions” (شرایط مسابقه) یا وقتی که کاربر از یک صفحه قبل از تکمیل درخواست خارج میشود.
const controller = new AbortController();
const signal = controller.signal;
async function fetchDataWithAbort() {
try {
const response = await fetch('https://api.example.com/data', { signal });
const data = await response.json();
console.log(data);
} catch (error) {
if (error.name === 'AbortError') {
console.log('Fetch aborted!');
} else {
console.error('Fetch error:', error);
}
}
}
fetchDataWithAbort();
// برای لغو درخواست بعد از 100 میلیثانیه:
setTimeout(() => {
controller.abort();
}, 100);
مدیریت وضعیت بارگذاری (Loading States)
در برنامههای UI محور، مدیریت وضعیت بارگذاری (مثل نمایش یک اسپینر) یک تجربه کاربری خوب را فراهم میکند. Async/Await این کار را سادهتر میکند:
async function loadUserDataAndDisplay() {
showSpinner(); // نمایش اسپینر
try {
const data = await getUserData(); // تابع async از مثال قبلی
displayUserData(data); // نمایش داده
} catch (error) {
showErrorMessage(error); // نمایش خطا
} finally {
hideSpinner(); // همیشه اسپینر را پنهان کن
}
}
استفاده از IIFE برای توابع async در سطح بالا
از آنجایی که `await` تنها میتواند درون یک تابع `async` استفاده شود، اگر بخواهید `await` را در سطح بالای یک اسکریپت (Global Scope) بدون نیاز به تعریف یک تابع جداگانه استفاده کنید، میتوانید از یک Immediately Invoked Function Expression (IIFE) ناهمگام استفاده کنید:
(async () => {
try {
const data = await fetchData();
console.log("Data from top level:", data);
} catch (error) {
console.error("Error at top level:", error);
}
})();
توجه: در نسخههای جدیدتر Node.js (از v14.8.0 به بعد) و مرورگرهای مدرن (از ES2022)، `await` در Global Scope مجاز شده است (Top-level await)، اما استفاده از IIFE هنوز یک الگوی رایج و سازگارتر برای محیطهای قدیمیتر است.
توابع ناهمگام به عنوان متدهای شیء
میتوانید توابع ناهمگام را به عنوان متدهای یک شیء یا کلاس نیز تعریف کنید:
class DataFetcher {
constructor(baseUrl) {
this.baseUrl = baseUrl;
}
async fetchResource(path) {
const response = await fetch(`${this.baseUrl}/${path}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
}
async getUserAndPosts(userId) {
const [user, posts] = await Promise.all([
this.fetchResource(`users/${userId}`),
this.fetchResource(`users/${userId}/posts`)
]);
return { user, posts };
}
}
const api = new DataFetcher('https://jsonplaceholder.typicode.com');
api.getUserAndPosts(1)
.then(data => console.log(data))
.catch(error => console.error(error));
پیروی از این بهترین روشها به شما کمک میکند تا کد ناهمگام قویتر، قابل نگهداریتر و کاربرپسندتری بنویسید.
ملاحظات عملکردی و چرخه رویداد (Event Loop) عمیقتر
درک عمیقتر از چرخه رویداد (Event Loop) و نحوه تعامل آن با Promises و Async/Await برای بهینهسازی عملکرد برنامههای جاوا اسکریپت ضروری است. مدل تکرشتهای جاوا اسکریپت به همراه رویداد حلقه، به آن اجازه میدهد بدون مسدود کردن، عملیات ناهمگام را مدیریت کند.
میکروتسکی (Microtasks) و ماکروتسکی (Macrotasks)
چرخه رویداد دارای دو نوع صف اصلی برای مدیریت وظایف ناهمگام است:
-
صف میکروتسکی (Microtask Queue):
شامل Promise callbacks (`.then()`, `.catch()`, `.finally()`), `queueMicrotask`, و `MutationObserver` callbacks است. وظایف در این صف اولویت بالاتری دارند و پس از هر بار اجرای یک اسکریپت کامل یا پس از هر callback از ماکروتسکی، و قبل از render شدن UI یا اجرای ماکروتسکی بعدی، اجرا میشوند. به عبارت دیگر، تا زمانی که صف میکروتسکی خالی نشود، هیچ ماکروتسکی جدیدی یا هیچ رندری اتفاق نمیافتد. -
صف ماکروتسکی (Macrotask Queue / Task Queue):
شامل وظایف مربوط به I/O، تایمرها (`setTimeout`, `setInterval`), و رویدادهای UI (مانند `click`, `load`) است. این وظایف پس از خالی شدن کامل پشته فراخوانی و صف میکروتسکی اجرا میشوند.
console.log('Start'); // 1. Sync
setTimeout(() => {
console.log('setTimeout'); // 4. Macrotask
}, 0);
Promise.resolve().then(() => {
console.log('Promise.then'); // 3. Microtask
});
console.log('End'); // 2. Sync
// ترتیب خروجی:
// Start
// End
// Promise.then
// setTimeout
درک این تفاوت مهم است زیرا عملیاتهای Promise (که Async/Await بر پایه آنهاست) به عنوان میکروتسکی مدیریت میشوند و بنابراین اولویت بالاتری نسبت به تایمرها یا رویدادهای UI دارند. این بدان معناست که اگر تعداد زیادی Promise دارید که پشت سر هم resolve میشوند، ممکن است UI شما برای مدت کوتاهی مسدود شود، زیرا تمام میکروتسکیها باید قبل از اینکه مرورگر فرصتی برای رندر مجدد یا اجرای ماکروتسکی بعدی داشته باشد، اجرا شوند.
تأثیر بر پاسخگویی UI
همانطور که ذکر شد، اجرای بیش از حد عملیاتهای ناهمگام (حتی میکروتسکیها) در یک چرخه رویداد میتواند منجر به “فریز شدن” UI شود. برای جلوگیری از این، به خصوص در عملیاتهای سنگین که نیاز به محاسبات زیادی دارند:
- تقسیم وظایف سنگین: وظایف محاسباتی سنگین را به قطعات کوچکتر تقسیم کنید و بین آنها `await new Promise(resolve => setTimeout(resolve, 0))` قرار دهید. این کار به رویداد حلقه فرصت میدهد تا در بین محاسبات، رندر UI یا مدیریت رویدادهای دیگر را انجام دهد.
- استفاده از Web Workers: برای کارهای واقعاً CPU-Intensive (محاسبات فشرده)، از Web Workers استفاده کنید. Web Workers در یک رشته جداگانه از رشته اصلی مرورگر اجرا میشوند و میتوانند عملیاتهای سنگین را بدون مسدود کردن UI انجام دهند. آنها نتایج را از طریق ارسال پیام (message passing) با رشته اصلی مبادله میکنند.
محدود کردن همزمانی (Concurrency Limiting)
در حالی که `Promise.all` برای اجرای موازی مفید است، در برخی موارد ممکن است نخواهید تعداد نامحدودی از درخواستها را به صورت همزمان ارسال کنید (مثلاً برای جلوگیری از بارگذاری بیش از حد سرور یا محدودیتهای API). در این حالت، میتوانید یک الگوی محدود کننده همزمانی پیادهسازی کنید:
async function fetchWithConcurrencyLimit(urls, limit = 5) {
const results = [];
const running = [];
for (const url of urls) {
const p = fetch(url).then(response => response.json());
results.push(p);
if (urls.length > limit) { // فقط اگر نیاز به محدودیت باشد
const e = p.then(() => running.splice(running.indexOf(e), 1));
running.push(e);
if (running.length >= limit) {
await Promise.race(running);
}
}
}
return Promise.all(results);
}
// مثال استفاده:
const urlsToFetch = Array.from({ length: 20 }, (_, i) => `https://api.example.com/data/${i}`);
fetchWithConcurrencyLimit(urlsToFetch, 5)
.then(data => console.log("All data fetched with concurrency limit:", data))
.catch(error => console.error("Error fetching data:", error));
این الگو تضمین میکند که هرگز بیش از `limit` تعداد درخواست به صورت همزمان در حال اجرا نخواهند بود، که میتواند به بهبود ثبات و عملکرد سیستم کمک کند.
با درک عمیق از چرخه رویداد، میکروتسکیها، ماکروتسکیها، و تکنیکهای مدیریت همزمانی، میتوانید برنامههای جاوا اسکریپتی بنویسید که نه تنها عملکردی قوی دارند، بلکه تجربه کاربری روانی را نیز ارائه میدهند.
سناریوهای کاربرد واقعی Promises و Async/Await
Promises و Async/Await به ستونهای فقرات توسعه جاوا اسکریپت مدرن تبدیل شدهاند و در طیف وسیعی از سناریوهای کاربردی، چه در سمت کلاینت (مرورگر) و چه در سمت سرور (Node.js)، به کار میروند. در اینجا به برخی از رایجترین و مهمترین موارد استفاده آنها میپردازیم:
1. واکشی داده از API (Client-side & Server-side)
این رایجترین کاربرد است. تقریباً هر برنامه وب مدرن نیاز به واکشی داده از سرورهای راه دور دارد. `fetch` API به طور بومی Promises را برمیگرداند، که آن را به یک کاندید عالی برای Async/Await تبدیل میکند:
async function getUserProfile(userId) {
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
// پرتاب خطا برای وضعیتهای HTTP غیر 2xx
throw new Error(`HTTP error! status: ${response.status}`);
}
const userData = await response.json();
return userData;
} catch (error) {
console.error("Failed to fetch user profile:", error);
// میتوانید خطا را مجدداً پرتاب کنید یا یک مقدار پیشفرض برگردانید
throw error;
}
}
// استفاده:
getUserProfile(123)
.then(profile => console.log("User Profile:", profile))
.catch(err => console.error("Error in fetching:", err.message));
در Node.js نیز میتوانید از ماژولهای Promise-based مانند `axios` یا `node-fetch` استفاده کنید تا به همین روش دادهها را از APIها واکشی کنید.
2. عملیات فایل I/O در Node.js
Node.js به طور گسترده برای عملیاتهای سمت سرور و اسکریپتهای سیستمی استفاده میشود. بسیاری از ماژولهای بومی Node.js (مانند `fs` برای File System) دارای نسخههای Promise-based هستند که استفاده از آنها را با Async/Await سادهتر میکند:
const fs = require('fs').promises; // استفاده از نسخه Promise-based
async function processFile(filePath) {
try {
const data = await fs.readFile(filePath, 'utf8');
console.log("File content:", data);
const modifiedData = data.toUpperCase();
await fs.writeFile(filePath + '.upper.txt', modifiedData);
console.log("File successfully written.");
} catch (error) {
console.error("Error processing file:", error);
}
}
// مثال: ایجاد یک فایل موقت برای تست
fs.writeFile('test.txt', 'Hello, World from Node.js!')
.then(() => processFile('test.txt'))
.catch(err => console.error(err));
3. تعامل با پایگاه داده (Database Interactions)
اکثر درایورهای پایگاه داده مدرن برای جاوا اسکریپت (مانند `mongoose` برای MongoDB، `sequelize` برای SQL، یا `pg` برای PostgreSQL) از Promises پشتیبانی میکنند، که Async/Await را به انتخاب طبیعی برای انجام Queryها تبدیل میکند:
// فرض کنید یک ORM/ODM مبتنی بر Promise دارید
// import { User, Product } from './models';
async function retrieveUserDataAndProducts(userId) {
try {
// اجرای همزمان دو کوئری
const [user, products] = await Promise.all([
User.findById(userId),
Product.find({ userId: userId })
]);
if (!user) {
throw new Error("User not found.");
}
return { user, products };
} catch (error) {
console.error("Database operation failed:", error);
throw error;
}
}
// مثال استفاده (با فرض وجود مدلها و اتصال به دیتابیس)
// retrieveUserDataAndProducts('someUserId')
// .then(data => console.log(data))
// .catch(error => console.error(error));
4. زمانبندی و انیمیشنها (Client-side)
Promises میتوانند برای ایجاد تأخیرها یا هماهنگسازی انیمیشنها استفاده شوند:
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function runAnimationSequence() {
console.log("Animation Phase 1: Start");
// انیمیشن 1
await delay(1000);
console.log("Animation Phase 2: Middle");
// انیمیشن 2
await delay(1500);
console.log("Animation Phase 3: End");
}
runAnimationSequence();
5. مدیریت رویدادهای کاربر (User Event Handling)
اگرچه اغلب از توابع بازخوانی مستقیم برای رویدادها استفاده میشود، اما در سناریوهایی که یک رویداد کاربر منجر به یک زنجیره عملیات ناهمگام میشود، Async/Await بسیار مفید است:
document.getElementById('saveButton').addEventListener('click', async () => {
try {
showLoadingIndicator();
const userData = collectFormData(); // تابع همگام
await saveUserData(userData); // تابع async
await showSuccessMessage("Data saved successfully!"); // تابع async
} catch (error) {
showErrorMessage("Failed to save data: " + error.message);
} finally {
hideLoadingIndicator();
}
});
async function saveUserData(data) {
// فرض کنید این تابع یک درخواست API برای ذخیره داده ارسال میکند
console.log("Saving data...", data);
await delay(2000); // شبیهسازی درخواست شبکه
// if (Math.random() < 0.5) throw new Error("Network error!"); // شبیهسازی خطا
console.log("Data saved.");
}
async function showSuccessMessage(msg) {
console.log(msg);
// نمایش یک پیام موفقیت برای 1 ثانیه
await delay(1000);
}
function showLoadingIndicator() { console.log("Loading..."); }
function hideLoadingIndicator() { console.log("Load complete."); }
function showErrorMessage(msg) { console.error(msg); }
function collectFormData() { return { name: "Test User" }; }
6. پیادهسازی Web Workers (موازیسازی محاسبات سنگین)
Web Workers به شما امکان میدهند کد جاوا اسکریپت را در یک رشته جداگانه از رشته اصلی اجرا کنید و از این طریق UI را در حین انجام محاسبات سنگین مسدود نکنید. ارتباط بین رشته اصلی و Web Worker از طریق `postMessage` و `onmessage` انجام میشود، که میتوانند به صورت Promises/Async-Await کپسوله شوند:
// Main Thread
const worker = new Worker('worker.js');
function runHeavyComputation(data) {
return new Promise((resolve, reject) => {
worker.postMessage(data);
worker.onmessage = (event) => {
resolve(event.data);
};
worker.onerror = (error) => {
reject(error);
};
});
}
async function startHeavyProcess() {
console.log("Starting heavy computation...");
try {
const result = await runHeavyComputation({ start: 1, end: 100000000 });
console.log("Heavy computation result:", result);
} catch (error) {
console.error("Error during heavy computation:", error);
}
}
// startHeavyProcess();
// worker.js (فایل مجزا برای Web Worker)
/*
self.onmessage = function(event) {
const { start, end } = event.data;
let sum = 0;
for (let i = start; i <= end; i++) {
sum += i;
}
self.postMessage(sum);
};
*/
این سناریوها نشان میدهند که چگونه Promises و Async/Await به ابزارهای ضروری برای ساخت برنامههای وب مدرن، پاسخگو و کارآمد تبدیل شدهاند. آنها نه تنها پیچیدگی مدیریت ناهمگامی را کاهش میدهند، بلکه امکان نوشتن کدی را فراهم میکنند که هم قدرتمند است و هم به راحتی قابل درک و نگهداری.
نتیجهگیری: آیندهای ناهمگام و قدرتمند
در طول این مقاله، ما سفری را از چالشهای برنامهنویسی همگام در جاوا اسکریپت، از جمله کابوس "جهنم بازخوانی"، آغاز کردیم و به سمت راهحلهای مدرن و قدرتمند برای مدیریت عملیاتهای ناهمگام حرکت کردیم. دیدیم که چگونه Promises با ارائه یک مدل ساختاریافته برای مدیریت نتایج آینده عملیاتها، نقطه عطفی در توسعه ناهمگام ایجاد کردند، با قابلیتهایی مانند زنجیرهسازی و مدیریت خطای متمرکز.
سپس، با Async/Await آشنا شدیم، که به عنوان یک لایه انتزاعی بر روی Promises، کد ناهمگام را به قدری خوانا و شهودی ساخت که تقریباً با کد همگام قابل مقایسه شد. این قابلیت، همراه با مدیریت خطای `try...catch`، تجربه توسعهدهنده را به طور چشمگیری بهبود بخشید.
همچنین، به مفاهیم پیشرفتهتر مانند `Promise.all()`, `Promise.allSettled()`, `Promise.race()`, و `Promise.any()` پرداختیم که به ما امکان میدهند چندین عملیات ناهمگام را به صورت موازی مدیریت کنیم و کارایی برنامه را افزایش دهیم. اهمیت درک عمیق از چرخه رویداد جاوا اسکریپت، میکروتسکیها و ماکروتسکیها برای بهینهسازی عملکرد و پاسخگویی UI نیز مورد تأکید قرار گرفت.
در نهایت، با بررسی سناریوهای کاربرد واقعی از واکشی داده از API و عملیات فایل I/O در Node.js گرفته تا تعامل با پایگاه داده و پیادهسازی Web Workers، نشان دادیم که Promises و Async/Await چگونه در قلب توسعه برنامههای کاربردی مدرن قرار دارند.
امروزه، Promises و Async/Await نه تنها بهترین روش برای نوشتن کد ناهمگام در جاوا اسکریپت هستند، بلکه تقریباً به یک استاندارد صنعتی تبدیل شدهاند. تسلط بر این مفاهیم برای هر توسعهدهنده جاوا اسکریپت که به دنبال ساخت برنامههای کاربردی مقیاسپذیر، کارآمد و قابل نگهداری است، ضروری است. با ادامه تکامل جاوا اسکریپت، میتوان انتظار داشت که این الگوها همچنان نقش محوری در مدیریت پیچیدگیهای دنیای ناهمگام ایفا کنند و راه را برای نوآوریهای بیشتر هموار سازند.
“تسلط به برنامهنویسی پایتون با هوش مصنوعی: آموزش کدنویسی هوشمند با ChatGPT”
"تسلط به برنامهنویسی پایتون با هوش مصنوعی: آموزش کدنویسی هوشمند با ChatGPT"
"با شرکت در این دوره جامع و کاربردی، به راحتی مهارتهای برنامهنویسی پایتون را از سطح مبتدی تا پیشرفته با کمک هوش مصنوعی ChatGPT بیاموزید. این دوره، با بیش از 6 ساعت محتوای آموزشی، شما را قادر میسازد تا به سرعت الگوریتمهای پیچیده را درک کرده و اپلیکیشنهای هوشمند ایجاد کنید. مناسب برای تمامی سطوح با زیرنویس فارسی حرفهای و امکان دانلود و تماشای آنلاین."
ویژگیهای کلیدی:
بدون نیاز به تجربه قبلی برنامهنویسی
زیرنویس فارسی با ترجمه حرفهای
۳۰ ٪ تخفیف ویژه برای دانشجویان و دانش آموزان