async و await در جاوااسکریپت
async/await سینتکسی است که Promise را به کدی خوانا شبیه همگام تبدیل میکند — ولی زیر پوسته، همان مدل آسنکرون مرورگر است. از اجرای سری و موازی تا مدیری
یادم میآید اولین بار که async/await را در یک پروژهی واقعی دیدم، با خودم گفتم «پس چرا از اول اینطور نمینوشتیم؟». کد Promise-محور که شش خط زنجیرهی `.then` بود، به سه خط خطی تبدیل شده بود و هر تازهکاری میتوانست بخواندش. ولی چند ماه بعد، همان سینتکس ظاهراً ساده، در یک پروژهی پُربار، به من درس دیگری داد: وقتی یک حلقهی async را بدون توجه نوشتم، تمام درخواستها بهجای سری، موازی رفتند و سرور مقصد ده دقیقهای نیمهبسته شد. آن روز برایم روشن شد که async و await در جاوااسکریپت فقط یک سینتکس راحتتر برای Promise نیست؛ یک قرارداد است که اگر آن را درست نفهمید، هم میتواند کد شما را زیبا کند و هم میتواند یک فاجعهی پنهان بسازد. در این مقاله، همان مسیری را میروم که امروز با تازهکارها طی میکنم: از سینتکس پایه تا الگوهای سری و موازی، مدیریت خطا، توپلِوِل await و تلههایی که در پروژههای واقعی دیدهام.
async/await دقیقاً چیست؟
اگر تازه با جاوااسکریپت آشنا میشوید، اول آموزش جاوااسکریپت از صفر را بخوانید و بعد مفاهیم پایه جاوااسکریپت را مرور کنید. برای درک async/await، باید با Promise در جاوااسکریپت راحت باشید — چون async/await، در زیر پوسته، دقیقاً همان Promise است، فقط با یک قالب خواناتر. دو کلمهی کلیدی که باید بشناسید:
async: یک تابع را به یک تابع آسنکرون تبدیل میکند. هر تابعی که با این کلمه تعریف شود، همیشه یک Promise برمیگرداند — حتی اگر داخلش مقدار ساده برگردانید.await: اجرای تابع را تا زمانی که Promise مورد نظر resolve یا reject شود، متوقف میکند. فقط درون تابعasyncقابل استفاده است.
ترکیب این دو، کدی میسازد که شبیه کد همگام به نظر میرسد، ولی واقعاً آسنکرون است. سه نکتهی مهم در مدل ذهنی که در پروژههای واقعی به آنها رسیدهام:
- await اجرای تابع را متوقف نمیکند، فقط ادامهی آن را به تعویق میاندازد: تابع شما در نقطهی
awaitمتوقف میشود، ولی بقیهی کد برنامه (مثل رویدادهای DOM) به کار خودش ادامه میدهد. این تفاوت ظریف، دلیل اینکه async/await باعث فریز شدن صفحه نمیشود است. - await فقط روی Promise معنی دارد: اگر روی یک مقدار ساده
awaitبزنید، بلافاصله همان مقدار برگردانده میشود. این ویژگی، ترکیب کد همگام و آسنکرون را آسان میکند. - async/await، Promise را حذف نمیکند: هنوز در زیر پوسته Promise است. یعنی میتوانید در جاهایی که به Promise نیاز دارید، با خیال راحت از همان تابع
asyncاستفاده کنید.
async/await لباس جدید Promise است، نه بدنی جدید؛ اگر Promise را نفهمید، این سینتکس فقط یک ماسک زیبا روی یک مفهوم عمیق است که روزی کنار میرود.
چرا این سینتکس به وجود آمد؟
برای درک ارزش async/await، کافی است یک زنجیرهی Promise را با نسخهی async/await مقایسه کنید:
// با Promise
function loadUser(userId) {
return fetchUser(userId)
.then((user) => fetchOrders(user.id))
.then((orders) => fetchOrderDetails(orders[0].id))
.then((details) => ({ details }));
}
// با async/await
async function loadUser(userId) {
const user = await fetchUser(userId);
const orders = await fetchOrders(user.id);
const details = await fetchOrderDetails(orders[0].id);
return { details };
}
دو تفاوت مهم در همین مقایسه:
- خوانایی: نسخهی دوم، دقیقاً مثل یک کد همگام خوانده میشود. ترتیب اجرا، خطبهخط، بدون پرشهای ذهنی بین
.thenها. - دیباگ: در نسخهی اول، اگر خطایی رخ دهد، پیدا کردن منبع دقیق خطا سختتر است چون همهی زنجیره در یک شکل تودرتو است. در نسخهی دوم، میتوانید در هر خط نقطهی توقف بگذارید و مقادیر را ببینید — مثل کد همگام.
علاوه بر این، در پروژههای واقعی، زمانی که منطق کسبوکار پیچیده شود — با شرطها، حلقهها و مقادیر میانی — async/await خوانایی را چند برابر بهتر از زنجیرهی Promise میکند. تجربهی من: بعد از تسلط بر async/await، سرعت نوشتن کد آسنکرون و کیفیت دیباگ هر دو بهطور محسوس بالا رفت.
سینتکس پایه: async و await
سینتکس پایه، بهقدری ساده است که ممکن است فریبنده باشد:
async function fetchUserData(userId) {
const response = await fetch(`/api/users/${userId}`);
const user = await response.json();
return user;
}
سه نکتهی مهم در سینتکس پایه که در پروژههای واقعی به آنها رسیدهام:
۱) async برای هر شکل تابعی کار میکند
// تابع نامدار
async function fetchData() { ... }
// Arrow function
const fetchData = async () => { ... };
// متد آبجکت
const api = {
async fetchUser() { ... },
};
// کلاس
class UserService {
async getUser() { ... }
}
همهی این اشکال، معتبرند و در پروژههای واقعی هرکدام جای خودشان را دارند. برای callbackهای کوتاه، arrow function انتخاب بهتری است؛ برای متدهای کلاس، سینتکس متد سادهتر است.
۲) await فقط درون async
// خطا — SyntaxError
function broken() {
const data = await fetchData();
}
// درست
async function fixed() {
const data = await fetchData();
}
۳) به async بهعنوان یک قرارداد نگاه کنید
هر تابع async، یک قرارداد است با فراخوان: «هرچه برگردانم، بهشکل Promise تحویل میدهم». این یعنی حتی اگر خطای داخلی داشته باشید، بهشکل Reject Promise بیرون میآید و میتوانید مدیریتش کنید.
اگر با آموزش ES6 در جاوااسکریپت آشنا هستید، async/await یکی از ویژگیهای ES2017 است که بعد از ES6 اضافه شد — بخشی از همان تکامل مستمر زبان که از سال ۲۰۱۵ شروع شد.
async همیشه Promise برمیگرداند
یکی از مهمترین ویژگیهای async که در پروژههای واقعی زیاد به آن برخوردهام: هر تابع async، بدون استثنا، یک Promise برمیگرداند:
async function getNumber() {
return 42;
}
// این دو معادلاند:
getNumber().then((n) => console.log(n)); // 42
Promise.resolve(42).then((n) => console.log(n)); // 42
این ویژگی سه نتیجهی عملی در پروژهها دارد:
- میتوانید async و Promise را ترکیب کنید: تابع
asyncشما میتواند در زنجیرهی Promiseای که خودتان ساختهاید، بهراحتی استفاده شود. - خطاها در خروجی هم بهشکل Reject بیرون میآیند: اگر تابع
asyncخطایthrowکند یا درونش Promise reject شود، خروجی تابع یک Promise Rejected است. - برای Promise.all، همیشه بهعنوان Promise بهکار میرود: بدون هیچ تبدیل اضافهای، میتوانید تابع
asyncرا در آرایهی ورودیPromise.allبگذارید.
async function fetchAll(urls) {
return Promise.all(urls.map(async (url) => {
const res = await fetch(url);
return res.json();
}));
}
در این مثال، هر عنصر آرایه، یک تابع async است که بهطور خودکار به Promise تبدیل میشود و Promise.all همه را موازی اجرا میکند. این الگو در پروژههای واقعی، یکی از پرکاربردترین ترکیبهای async/await با Promise است.
مدیریت خطا با try/catch
یکی از بزرگترین مزایای async/await، مدیریت خطا با try/catch است — روشی که برای توسعهدهندگانی که از زبانهای دیگر آمدهاند، طبیعیتر است:
async function loadData(userId) {
try {
const user = await fetchUser(userId);
const orders = await fetchOrders(user.id);
return { user, orders };
} catch (error) {
console.error("Failed to load data:", error);
throw error; // یا مقدار جایگزین برگردانید
}
}
سه الگوی رایج که در پروژههای واقعی بهکار میبرم:
۱) catch مرکزی در سطح تابع
async function processOrder(order) {
try {
await validateOrder(order);
await saveOrder(order);
await notifyCustomer(order);
return { success: true };
} catch (error) {
logError(error);
return { success: false, error: error.message };
}
}
۲) try/catch در سطح هر عملیات
async function processOrder(order) {
let validated;
try {
validated = await validateOrder(order);
} catch (error) {
return { success: false, stage: "validation" };
}
try {
await saveOrder(validated);
} catch (error) {
return { success: false, stage: "save" };
}
return { success: true };
}
این الگو در پروژههای واقعی، اطلاعات دقیقتری از مرحلهی خطا میدهد. خصوصاً در عملیات چندمرحلهای مثل ثبت سفارش یا پرداخت، این تفکیک اهمیت زیادی دارد.
۳) استفاده از finally
async function fetchData() {
showLoading();
try {
const response = await fetch("/api/data");
return await response.json();
} catch (error) {
showError(error);
throw error;
} finally {
hideLoading(); // در هر حالت اجرا میشود
}
}
الگوی finally، در پروژههای واقعی برای بستن لودرها بسیار پرکاربرد است. اگر با پایتون هم کار میکنید، همین مفهوم در مدیریت خطا در پایتون با مثالهای بیشتری بررسی شده است.
try/catch در async، دقیقاً همان مفهومی است که در کد همگام داشتیم؛ ولی اینبار روی Promise کار میکند — یعنی خوانایی Promise با همان ابزارهایی که میشناختیم.
اجرای سری در برابر موازی
یکی از مهمترین تفاوتهایی که در پروژههای واقعی به آن برخوردم، تفاوت بین اجرای سری و موازی است. این تفاوت در کد بالا بهسادگی دیده نمیشود، ولی در سرعت، تفاوت چند برابری میسازد:
// سری — مجموع زمان اجرا
async function loadSequential() {
const user = await fetchUser(1); // 1s
const orders = await fetchOrders(1); // 1s
const notifs = await fetchNotifications(1); // 1s
return { user, orders, notifs };
}
// زمان کل: ~3 ثانیه
// موازی — حداکثر زمان
async function loadParallel() {
const [user, orders, notifs] = await Promise.all([
fetchUser(1),
fetchOrders(1),
fetchNotifications(1),
]);
return { user, orders, notifs };
}
// زمان کل: ~1 ثانیه
در پروژههای واقعی، این تفاوت چند برابری، بین یک صفحهی روان و یک صفحهی کند، تعیینکننده است. سه اصل که در این انتخاب رعایت میکنم:
- اگر عملیاتها مستقل هستند، موازی: اگر نتیجهی یکی به دیگری وابسته نیست، از
Promise.allاستفاده کنید. - اگر نتیجهی یکی به دیگری وابسته است، سری: اگر برای گرفتن سفارشها به شناسهی کاربر نیاز دارید، نمیتوانید موازی بروید.
- در شک، اول سری، بعد موازی: اگر مطمئن نیستید، با سری شروع کنید و بعد از پروفایلینگ، اگر گلوگاه بود، به موازی تغییر دهید. بهینهسازی زودهنگام، منبع پیچیدگی بیدلیل است.
async/await در حلقهها
یکی از دامهای کلاسیک در پروژههای واقعی، استفاده از async/await در حلقهها است. سه الگو وجود دارد که هرکدام رفتار متفاوتی دارند:
۱) for...of: اجرای سری
async function processSequentially(items) {
const results = [];
for (const item of items) {
results.push(await processItem(item)); // هر iteration منتظر میماند
}
return results;
}
در for...of، هر iteration منتظر await میماند. مناسب وقتی ترتیب مهم است یا میخواهید سرور را از فشار زیاد موازی نجات دهید.
۲) map + Promise.all: اجرای موازی
async function processParallel(items) {
const promises = items.map((item) => processItem(item));
return Promise.all(promises);
}
در این الگو، همهی عملیاتها همزمان شروع میشوند. مناسب وقتی تعداد آیتمها محدود است و میخواهید زمان کل را کم کنید.
۳) forEach: تلهی رایج
// اشتباه — منتظر نمیماند
async function broken(items) {
items.forEach(async (item) => {
await processItem(item); // forEach منتظر await نمیماند
});
// اینجا همهی عملیات احتمالاً هنوز تمام نشدهاند
}
forEach از await پشتیبانی نمیکند و هیچوقت منتظر نمیماند. این باگ در پروژههای واقعی زیاد دیده میشود — چون کد، بدون خطا اجرا میشود ولی نتیجه، اشتباه است. راهحل: از for...of یا Promise.all با map استفاده کنید.
| الگو | رفتار | کاربرد |
|---|---|---|
for...of | سری | ترتیب مهم یا فشار کمتر روی سرور |
map + Promise.all | موازی | سرعت بیشتر برای عملیات مستقل |
forEach | بیاثر (باگ) | استفاده نکنید |
Top-Level Await و ماژولها
در ES2022، ویژگی top-level await معرفی شد که به شما اجازه میدهد در سطح یک ماژول، بدون تابع async، از await استفاده کنید:
// در یک فایل ماژول
import { loadConfig } from "./config.js";
const config = await loadConfig();
export default config;
این ویژگی، در پروژههای واقعی مخصوصاً برای بارگذاری تنظیمات یا دادههای اولیه، بسیار مفید است. ولی دو محدودیت مهم دارد:
- فقط در ماژولها: باید اسکریپت شما از نوع
type="module"باشد یا در Node.js با پسوند.mjsتعریف شده باشد. - ماژول وابسته، منتظر میماند: اگر یک ماژول از شما import کند، آن import هم تا resolve شدن Promise شما معلق میماند. این رفتار گاهی مفید و گاهی منبع تأخیر در بارگذاری است.
در پروژههای واقعی، top-level await را در نقاط محدود استفاده میکنم — نه بهعنوان الگوی پیشفرض. تجربهی من: در ماژولهای پیکربندی و بارگذاری اولیه، این ویژگی کد را خیلی تمیز میکند؛ ولی در ماژولهای معمولی، همان async/await درون توابع، خواناتر و قابل پیشبینیتر است.
الگوهای واقعی در پروژهها
چند الگویی که با async/await در پروژههای واقعی به ذهنیت ثابت من تبدیل شدهاند:
الگوی اول: Retry با تأخیر نمایی
async function fetchWithRetry(url, retries = 3) {
for (let attempt = 0; attempt < retries; attempt++) {
try {
return await fetch(url);
} catch (error) {
if (attempt === retries - 1) throw error;
await new Promise((resolve) =>
setTimeout(resolve, 1000 * Math.pow(2, attempt))
);
}
}
}
این الگو در پروژههای پربازدید، تفاوت بین یک اپلیکیشن پایدار و یک اپلیکیشن لرزان را میسازد. اگر با APIهای خارجی کار میکنید، اصول مشابه این استراتژی در امنیت API نیز توصیه شده است.
الگوی دوم: بارگذاری مرحلهای داده
async function loadDashboard(userId) {
// مرحله اول: دادههای حیاتی — کاربر منتظر میماند
const user = await fetchUser(userId);
renderUserInfo(user);
// مرحله دوم: دادههای ثانویه — موازی
const [orders, stats, notifs] = await Promise.all([
fetchOrders(userId),
fetchStats(userId),
fetchNotifications(userId),
]);
renderDashboard({ orders, stats, notifs });
}
این الگو، تجربهی کاربری را بهشدت بهبود میدهد چون دادههای حیاتی سریع نمایش داده میشوند و بقیه، در پسزمینه بارگذاری میشود.
الگوی سوم: صف درخواستها با محدودیت همزمانی
async function processWithLimit(items, limit = 3) {
const results = [];
const executing = [];
for (const item of items) {
const promise = processItem(item).then((result) => {
executing.splice(executing.indexOf(promise), 1);
return result;
});
results.push(promise);
executing.push(promise);
if (executing.length >= limit) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
این الگو در پروژههای وباسکرپینگ یا پردازش دستهای، از فشار آوردن بیشازحد به سرور جلوگیری میکند. اصول مشابه این کنترل همزمانی، در وب اسکرپینگ با پایتون نیز وجود دارد.
کارایی و نکات عملی
در پروژههای واقعی، چند نکتهی کارایی و عملی در کار با async/await به آنها رسیدهام:
۱) از await در حلقهی بزرگ پرهیز کنید
// کند — هر iteration منتظر میماند
for (const id of ids) {
const data = await fetchItem(id);
results.push(data);
}
// سریع — موازی
const results = await Promise.all(ids.map((id) => fetchItem(id)));
البته اگر تعداد idها خیلی زیاد است (مثلاً بیش از ۱۰۰)، اجرای موازی میتواند سرور را تحت فشار بگذارد. آنجا الگوی صف با محدودیت همزمانی، گزینهی درستی است.
۲) از async برای عملیات همگام استفاده نکنید
// بیدلیل async
async function add(a, b) {
return a + b;
}
// درست
function add(a, b) {
return a + b;
}
async برای عملیات همگام، فقط پیچیدگی اضافه میکند — چون خروجی همیشه Promise است و کد مصرفکننده باید await بزند.
۳) از await روی Promiseهای از پیش ساخته شده استفاده کنید
// خوب — شروع موازی، انتظار سری
const userPromise = fetchUser(1);
const ordersPromise = fetchOrders(1);
const user = await userPromise;
const orders = await ordersPromise;
این الگو، Promiseها را بلافاصله شروع میکند ولی در نقطهی استفاده، منتظر میماند. مفید وقتی میخواهید بین شروع و استفاده، کار دیگری انجام دهید.
۴) در شک، سری را انتخاب کنید
در پروژههای واقعی، بارها دیدهام که توسعهدهندگان برای «سرعت بیشتر» عملیات را موازی میکنند، بعد با مشکل فشار روی سرور یا تجاوز از rate limit روبرو میشوند. اگر مطمئن نیستید، با سری شروع کنید و فقط زمانی که واقعاً نیاز شد، به موازی تغییر دهید.
تلههایی که در پروژههای واقعی دیدهام
در بازبینی کد پروژههای مختلف، این تلهها را زیاد دیدهام:
- استفاده از async در حلقهی forEach:
forEachمنتظرawaitنمیماند. این باگ بیصدا است و گاهی روزها طول میکشد تا کشف شود. راهحل: ازfor...ofیاmap + Promise.allاستفاده کنید. - فراموش کردن catch:
awaitروی Promise Rejected، خطا را به بالاتر پرتاب میکند. اگرtry/catchنداشته باشید، برنامه ممکن است کرش کند یا خطا بیصدا در کنسول بماند. - فراموش کردن await: اگر تابع
asyncرا صدا بزنید ولیawaitنگذارید، خروجی یک Promise است که ممکن است هنوز تمام نشده باشد. نتیجه: کد شما با مقادیر ناقص کار میکند. - استفاده از await در سطح بالای کد بدون ماژول: top-level await فقط در ماژولها کار میکند. در اسکریپتهای معمولی، این خطا میدهد یا کار نمیکند.
- async بودن بیدلیل: هر تابع که
asyncمیشود، یک Promise برمیگرداند که کد مصرفکننده باید باawaitیا.thenمدیریت کند. اگر عملیات همگام است،asyncفقط پیچیدگی است. - await درون حلقه بدون توجه به ترتیب: در
for...of، هر iteration منتظر میماند. اگر هدف سرعت است، این الگو اشتباه است. ولی اگر ترتیب مهم است (مثلاً ارسال ایمیل به ترتیب)، این الگو درست است. - عدم استفاده از Promise.all در موارد مستقل: سه درخواست مستقل را سری اجرا کردن، یعنی سه برابر زمان انتظار. اگر مستقل هستند، همیشه موازی.
- نادیدهگرفتن خطا در Promise.all: اگر یکی از Promiseها reject شود،
Promise.allبلافاصله Reject میشود و بقیهی نتایج، حتی اگر موفق شده باشند، نادیده گرفته میشوند. اگر میخواهید همه اجرا شوند، ازPromise.allSettledاستفاده کنید. - مصرف حافظه در Promiseهای بیپایان: اگر در حلقهی بیپایان، Promise میسازید و آرایه را پاک نمیکنید، حافظه پر میشود. این تله در پروژههای طولانیمدت، به کرش منجر میشود.
- ترکیب نادرست async با callback: بعضی تیمها یک API callback-محور را درون
asyncمیپیچند بدون اینکه واقعاً به Promise تبدیلش کنند. نتیجه:awaitبیاثر میشود. - خطاهای بیمعنی در throw:
throw "error"بهجایthrow new Error("error"). بدونError،stack traceوجود ندارد و دیباگ سخت میشود. - نادیدهگرفتن top-level await در ماژولها: اگر از top-level await استفاده میکنید، بدانید که همهی ماژولهای وابسته، تا resolve شدن Promise شما معلق میمانند. این تأخیر در بعضی سناریوها مشکلساز است.
یک توصیهی عملی از تجربه: در پروژههای واقعی، عادت کنید هر تابع async را با یک try/catch کامل بنویسید، مگر اینکه صریحاً بخواهید خطا به لایهی بالاتر پرتاب شود. این انضباط کوچک، جلوی بسیاری از خطاهای بیصدا را میگیرد. اگر با fetch برای ارتباط با سرور کار میکنید، اصول کامل آن در fetch API در جاوااسکریپت آمده است — و اگر با مدیریت خطا در بسترهای دیگر هم درگیر هستید، مدیریت خطا در جاوااسکریپت و مدیریت خطا در پایتون لایههای مکمل را نشان میدهند.
سخن آخر
async/await سینتکسی است که Promise را به کدی خوانا شبیه همگام تبدیل میکند — ولی زیر پوسته، همان مدل آسنکرون مرورگر است. سه نکتهی اصلی که در این مقاله به آنها رسیدیم: اول، async همیشه Promise برمیگرداند — این ویژگی به شما اجازه میدهد async/await را با زنجیرههای Promise یا Promise.all ترکیب کنید؛ دوم، مدیریت خطا با try/catch انجام میشود — روشی که برای توسعهدهندگان جاوااسکریپت آشنا است و خوانایی را بالا میبرد؛ سوم، بین اجرای سری و موازی، آگاهانه انتخاب کنید — for...of برای سری، map + Promise.all برای موازی، و forEach برای هیچکدام.
اگر امروز میخواهید در async/await ماهر شوید، سه کار کوچک پیشنهاد میکنم: یک زنجیرهی Promise از پروژهی خودتان را با async/await بازنویسی کنید و تفاوت خوانایی را ببینید؛ سه درخواست API مستقل را با Promise.all موازی اجرا کنید و زمان کل را با حالت سری مقایسه کنید؛ و یک تابع async با try/catch و finally بنویسید که لودر را در هر حالت ببندد. همین سه تمرین، ۹۰٪ مهارتهای عملی async/await را در ذهن شما زنده میکند. مسیر طبیعی بعدی، fetch API در جاوااسکریپت و مدیریت خطا در جاوااسکریپت است — چون اپلیکیشن واقعی، ترکیبی از ارتباط با سرور و مدیریت خطاست. اگر تجربهای از کار با async/await در پروژههای خودتان دارید — مخصوصاً اگر با باگ forEach یا مشکل کارایی روبرو شدهاید — در دیدگاهها بنویسید؛ همین نکتههای میدانی، برای خوانندهی بعدی از هر مستند رسمی ارزشمندتر است. ⏱️