ترکیب async/await با پیمایش ناهمزمان: کار عملی با for await...of

در فصل ۱۹ با پروتکل Async Iterator و حلقه‌ی for await...of آشنا شدید. اکنون که async/await را نیز به‌طور کامل بررسی کرده‌اید، وقت آن است که این دو مفهوم را در کنار هم و در سناریوهای عملی‌تر ببینید: چگونه می‌توان داده‌های صفحه‌به‌صفحه از یک API را با async/await پردازش کرد، و تفاوت مهمی که میان پردازش متوالی و پردازش موازی یک منبع ناهمزمان وجود دارد.

یادآوری کوتاه: for await...of فقط داخل async

همان‌طور که پیش‌تر دیدید، حلقه‌ی for await...of فقط داخل توابعی که با async تعریف شده‌اند قابل استفاده است. این حلقه پیش از هر تکرار، منتظر resolve شدن Promise برگشتی از Async Iterator می‌ماند، دقیقاً به همان شکلی که await منتظر یک Promise تکی می‌ماند:

async function* fetchPages(baseUrl, totalPages) {
  for (let page = 1; page <= totalPages; page++) {
    const response = await fetch(`${baseUrl}?page=${page}`);
    yield await response.json();
  }
}

پردازش متوالی داده‌های صفحه‌به‌صفحه

با ترکیب for await...of و try/catch، می‌توان داده‌های یک Async Generator را به همراه مدیریت کامل خطا پردازش کرد:

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

نکته‌ی مهم در این الگو این است که اگر خطایی در میانه‌ی پردازش (مثلاً در صفحه‌ی سوم از پنج صفحه) رخ دهد، حلقه بلافاصله متوقف می‌شود و کنترل به بلوک catch منتقل می‌شود؛ صفحاتی که پیش از آن با موفقیت دریافت شده‌اند، همچنان در allProducts باقی می‌مانند.

تفاوت کلیدی: پردازش متوالی در برابر موازی

استفاده از for await...of برای پردازش یک Async Iterable، به‌طور ذاتی متوالی است: صفحه‌ی دوم فقط پس از پایان کامل پردازش صفحه‌ی اول درخواست می‌شود. اگر هدف، ارسال هم‌زمان چند درخواست مستقل باشد (نه پیمایش یک جریان واحد)، ترکیب Promise.all با آرایه‌ای از توابع async مناسب‌تر است:

ویژگی for await...of Promise.all با چند تابع async
ترتیب اجرا متوالی؛ هر مرحله منتظر پایان مرحله‌ی قبل موازی؛ همه‌ی درخواست‌ها هم‌زمان ارسال می‌شوند
مناسب برای جریانی از داده که تعداد یا محتوای آن به مرحله‌ی قبل وابسته است چند منبع کاملاً مستقل که ترتیب دریافت آن‌ها اهمیتی ندارد
سرعت کلی برابر مجموع زمان تمام مراحل برابر زمان کندترین درخواست

به همین دلیل، اگر صفحاتی که قرار است دریافت شوند کاملاً مستقل از یکدیگر باشند (مثلاً شماره‌ی صفحات از قبل مشخص است و به نتیجه‌ی صفحه‌ی قبلی وابسته نیست)، اجرای موازی با Promise.all معمولاً سریع‌تر از for await...of خواهد بود:

async function loadAllProductsParallel(baseUrl, totalPages) {
  const pagePromises = Array.from({ length: totalPages }, (_, i) =>
    fetch(`${baseUrl}?page=${i + 1}`).then((r) => r.json())
  );

  const allPages = await Promise.all(pagePromises);
  return allPages.flatMap((page) => page.items);
}

نکات کلیدی

  • for await...of به‌طور ذاتی مراحل را متوالی پردازش می‌کند؛ هر مرحله منتظر پایان مرحله‌ی قبل می‌ماند.
  • ترکیب try/catch با for await...of امکان می‌دهد خطای هر مرحله از پیمایش، بدون از دست رفتن نتایج قبلی، مدیریت شود.
  • وقتی منابع داده کاملاً مستقل از یکدیگرند، Promise.all معمولاً سریع‌تر از پیمایش متوالی با for await...of است.
  • انتخاب میان این دو الگو باید بر اساس وابستگی واقعی داده‌ها به یکدیگر انجام شود، نه صرفاً بر اساس ترجیح نوشتاری.

جمع‌بندی

ترکیب async/await با for await...of ابزاری قدرتمند برای پردازش خوانا و مقاوم در برابر خطای منابع داده‌ی ناهمزمان است، اما باید به ماهیت متوالی این حلقه توجه داشت. وقتی داده‌ها به یکدیگر وابسته نیستند، Promise.all انتخاب بهتری از نظر سرعت است؛ در حالی که وقتی هر مرحله واقعاً به نتیجه‌ی مرحله‌ی قبل نیاز دارد، for await...of ساختاری طبیعی‌تر و خواناتر ارائه می‌دهد.