توسعه فرانت‌اند

Promise و Async/Await: مدیریت کد ناهمزمان

تفاوت ترتیب و هم‌زمانی، Promise.all و allSettled و race، خطا و AbortSignal و نمونه کامل Node بدون package یا درخواست شبکه.

1404/11/07 2 دقیقه مطالعه 2,211 بازدید

اگر دریافت مشخصات کاربر و فهرست اعلان‌ها مستقل‌اند، لازم نیست پایان یکی را برای شروع دیگری منتظر بمانید. اگر درخواست دوم به شناسه حاصل از اولی نیاز دارد، ترتیب باید حفظ شود. مدیریت کد ناهمزمان از شناخت همین وابستگی شروع می‌شود. Promise و async/await ابزار بیان این قراردادند؛ انتخاب syntax به تنهایی درباره ترتیب، خطا یا لغو تصمیم نمی‌گیرد.

async/await کد را ظاهراً خطی می‌کند، اما اجرای عملیات را هم‌زمان و قطعی نمی‌کند. تابع async یک Promise برمی‌گرداند و await ادامه همان تابع را تا آماده شدن نتیجه متوقف می‌کند. کارهای دیگر محیط می‌توانند ادامه یابند. اگر داخل تابع محاسبه سنگین هم‌زمان انجام دهید، صرف افزودن async آن را از Thread اجرا خارج نمی‌کند.

Promise نتیجه آینده است، نه خود عملیات قابل لغو

Promise می‌تواند pending، fulfilled یا rejected باشد. نتیجه نهایی فقط یک‌بار تعیین می‌شود. handlerهای then و catch به واکنش به همان نتیجه مربوط‌اند. تابع داخل Promise constructor هم‌زمان اجرا می‌شود؛ بنابراین ساختن Promise همیشه به معنی تعویق تمام کار نیست. اگر آنجا کار سنگین انجام دهید، قبل از رسیدن به await می‌تواند مسیر فعلی را مشغول کند.

خطای تابع async به rejected Promise تبدیل می‌شود. راهنمای async function در MDN رابطه return و await را توضیح می‌دهد. فراخواننده باید نتیجه یا خطا را مدیریت کند. نوشتن try/catch اطراف فراخوانی بدون await، لزوماً rejection بعدی را نمی‌گیرد؛ محدوده انتظار باید در همان مسیر کنترل خطا حضور داشته باشد.

Promise به‌طور عمومی cancel ندارد. لغو به عملیات underlying مربوط است؛ مثلاً API می‌تواند AbortSignal را بپذیرد. رد شدن یک Promise دیگر، خودکار آن عملیات را متوقف نمی‌کند. این تفاوت در requestهای هم‌زمان مهم است. اگر صفحه تغییر کرده و نتیجه دیگر لازم نیست، باید روش لغو یا کنار گذاشتن پاسخ دیررس را در قرارداد برنامه داشته باشید.

ترتیب را بر اساس وابستگی انتخاب کنید

در مسیر ترتیبی، هر کار پس از دریافت نتیجه قبلی آغاز می‌شود. این روش برای کارهای وابسته مناسب است و تعداد عملیات هم‌زمان را محدود می‌کند. اگر کارها مستقل‌اند، ترتیب اجباری می‌تواند انتظار اضافه بسازد. قبل از بهینه‌سازی، dependency واقعی را بنویسید؛ ممکن است دو تابع ظاهراً مستقل روی یک منبع مشترک اثر بگذارند.

برای شروع چند عملیات مستقل، ابتدا Promiseهایشان را ایجاد و سپس به نتیجه گروهی منتظر شوید. فراخوانی function آغاز کار را تعیین می‌کند، نه نام Promise.all. اگر قبل از ساخت آرایه، هر کار را await کرده‌اید، all دیگر آن‌ها را از ابتدا هم‌زمان نمی‌کند. این خطای رایج از مخلوط کردن شروع عملیات و جمع‌آوری نتیجه می‌آید.

هم‌زمانی نامحدود هم همیشه مناسب نیست. هزار درخواست می‌توانند محدودیت سرویس یا حافظه را تحت فشار قرار دهند. Promise.all concurrency limit ندارد. برای کارهای زیاد، صف یا batch با سقف مشخص لازم است. اندازه سقف باید با قرارداد سرویس و منابع هماهنگ باشد؛ یک عدد ثابت برای تمام APIها معرفی نکنید.

Promise.all برای نتیجه وابسته گروهی است

Promise.all وقتی همه ورودی‌ها fulfilled شوند، نتیجه را به ترتیب ورودی می‌دهد. ترتیب پایان عملیات الزاماً همان ترتیب نیست. اگر یکی reject شود، نتیجه گروهی reject می‌شود. این رفتار برای جایی مناسب است که بدون همه بخش‌ها نمی‌توانید نتیجه معتبر بسازید.

Reject زودهنگام گروه به معنی توقف سایر کارها نیست. آن‌ها ممکن است همچنان اجرا و حتی اثر سروری ایجاد کنند. اگر نیاز به لغو دارید، signal و روش توقف باید جدا به عملیات داده شوند. برای مشاهده وضعیت باقی‌مانده‌ها نیز می‌توانید آن‌ها را در مسیر مناسب جمع‌آوری کنید. پیام رابط نباید از شکست گروه، انجام نشدن تمام کارها را استنباط کند.

نتیجه partial را بی‌توضیح جای نتیجه کامل نگذارید. اگر مشخصات اصلی و بخش فرعی قراردادهای متفاوت دارند، شاید بهتر باشد آن‌ها را جدا مدیریت کنید. انتخاب ابزار گروهی باید با تعریف موفقیت صفحه هماهنگ باشد. مثال این مقاله عملیات محلی شبیه‌سازی‌شده دارد و درباره موفقیت یک API واقعی ادعا نمی‌کند.

allSettled و race پاسخ پرسش‌های دیگری هستند

Promise.allSettled پس از تعیین نتیجه همه ورودی‌ها، برای هر کدام وضعیت fulfilled یا rejected می‌دهد. خود این روش به معنی موفقیت همه نیست. باید نتیجه هر مورد را بررسی کنید و درباره نمایش partial تصمیم بگیرید. نادیده گرفتن reason و نمایش یک پیام موفقیت کلی، قرارداد خطا را حذف می‌کند.

Promise.race نتیجه اولین Promise تعیین‌شده را می‌گیرد؛ اولین rejection هم می‌تواند برنده باشد. آن را با «اولین پاسخ موفق» یکی نگیرید. برای اولین fulfillment، Promise.any قرارداد دیگری دارد و در صورت شکست همه، خطای گروهی مناسب خود را می‌دهد. نام ابزار باید با هدف انتخاب هماهنگ باشد.

race بقیه عملیات را cancel نمی‌کند. ساختن timeout Promise و race کردن، ممکن است فقط انتظار فراخواننده را پایان دهد و درخواست اصلی ادامه یابد. برای توقف واقعی، API باید لغو را پشتیبانی کند و cleanup انجام شود. timerهای باقی‌مانده نیز باید پاک شوند. این تصمیم‌ها با یک catch عمومی خودکار حل نمی‌شوند.

نمونه کامل Node بدون package و شبکه

کد زیر را در demo.mjs ذخیره و با Node.js نسخه 22 یا جدیدتر اجرا کنید: node demo.mjs. از ماژول داخلی timers/promises استفاده می‌کند و package جدا ندارد. تأخیرها برای آموزش ترتیب و نتیجه‌اند، نه اندازه‌گیری عملکرد واقعی. نام کارها فرضی است و هیچ request یا داده مشتری دریافت نمی‌شود.

import { setTimeout as delay } from 'node:timers/promises';

async function task(name, milliseconds, { fail = false, signal } = {}) {
  await delay(milliseconds, undefined, { signal });
  if (fail) throw new Error(`خطای آموزشی ${name}`);
  return `نتیجه ${name}`;
}

async function main() {
  console.log('ترتیبی:');
  for (const name of ['A', 'B']) console.log(await task(name, 150));

  console.log('گروهی با نتیجه به ترتیب ورودی:');
  const first = task('C', 250);
  const second = task('D', 100);
  console.log(await Promise.all([first, second]));

  console.log('نتیجه جداگانه هر کار:');
  const results = await Promise.allSettled([
    task('E', 100),
    task('F', 120, { fail: true })
  ]);
  results.forEach((result, index) => {
    if (result.status === 'fulfilled') console.log(index, result.value);
    else console.log(index, result.reason.message);
  });

  console.log('اولین نتیجه و لغو صریح باقی‌مانده:');
  const controller = new AbortController();
  const jobs = [
    task('سریع', 100, { signal: controller.signal }),
    task('کند', 500, { signal: controller.signal })
  ];
  try {
    console.log(await Promise.race(jobs));
  } finally {
    controller.abort();
    const cleanup = await Promise.allSettled(jobs);
    console.log(cleanup.map(result => result.status));
  }
}

main().catch(error => {
  console.error('نمونه ناموفق شد:', error.message);
  process.exitCode = 1;
});

بخش اول ترتیب صریح را نشان می‌دهد و بخش دوم شروع مستقل پیش از await گروهی را. در allSettled، یکی از کارها عمداً reject می‌شود تا بررسی تک‌تک نتیجه‌ها دیده شود. در بخش race، پس از پایان سریع‌تر، signal مشترک بقیه عملیات محلی را متوقف می‌کند و allSettled نتیجه cleanup را جمع می‌کند. این رفتار توقف را خود race فراهم نکرده است.

این مثال خروجی کنسول دارد و state رابط را پیاده نمی‌کند. اگر آن را به برنامه وصل می‌کنید، loading، خطا و لغو باید از نتیجه واقعی بیایند. مقاله Loading States این قراردادهای دیداری را بررسی می‌کند. نگه داشتن spinner بعد از rejection، خطای Promise را به انتظار بی‌پایان کاربر تبدیل می‌کند.

حلقه‌ها و کنترل خطا را روشن نگه دارید

forEach نتیجه callback async را به عنوان Promise گروهی منتظر نمی‌ماند. اگر باید کارها به ترتیب انجام شوند، for...of با await مناسب است. اگر مستقل‌اند، map می‌تواند Promiseها را بسازد و all یا allSettled نتیجه را جمع کند. انتخاب باید صریح باشد تا ادامه تابع پیش از کارهای مورد انتظار اتفاق نیفتد.

catch بدون اقدام مناسب می‌تواند شکست را پنهان کند. اگر خطا را log و سپس مقدار undefined برگردانید، فراخواننده ممکن است آن را نتیجه موفق ببیند. قرارداد تابع باید معلوم کند خطا throw می‌شود یا نوع نتیجه شامل وضعیت است. این دو روش قابل استفاده‌اند، ولی ترکیب بی‌قاعده آن‌ها خواندن مسیر شکست را سخت می‌کند.

finally برای cleanup مناسب است، اما return یا throw جدید در آن می‌تواند نتیجه قبلی را عوض کند. آزادسازی resource را از تولید نتیجه مستقل نگه دارید. همچنین خطای cleanup باید در سیاست خطا دیده شود. برای پیام کاربر، Error Handling کمک می‌کند علت معلوم و وضعیت نامعلوم را از هم جدا کنید.

پاسخ شبکه و داده معتبر را جدا بررسی کنید

طبق راهنمای Fetch در MDN، fulfilled شدن fetch الزاماً به معنی HTTP موفق نیست؛ وضعیت پاسخ و سپس داده باید بررسی شوند. دریافت JSON نیز Schema مناسب محصول را تضمین نمی‌کند. لایه async فقط پایان انتظار را مدیریت می‌کند. اعتبارسنجی محتوا، دسترسی و نتیجه عملیات همچنان قراردادهای جدا هستند و نباید به syntax await واگذار شوند.

در مسیرهای دارای اثر، retry را با idempotency و وضعیت سرور هماهنگ کنید. Timeout به تنهایی انجام نشدن کار را ثابت نمی‌کند. مهاجرت App Router نمونه دیگری از مرز دریافت داده و نمایش است. در مستند هر تابع، ورودی، شروع عملیات، نوع نتیجه و روش لغو را بنویسید تا فراخواننده بتواند ترتیب و خطا را بدون حدس مدیریت کند.

برای کارهایی که آگاهانه در پس‌زمینه آغاز می‌کنید، مالک رسیدگی به خطا را مشخص کنید. رها کردن Promise بدون handler می‌تواند rejection مدیریت‌نشده بسازد. اگر نتیجه به رابط مربوط نیست، هنوز ثبت و cleanup آن نیاز به مسیر روشن دارند. نامی مانند fire and forget نباید به معنی فراموش کردن مسئولیت خطا باشد. فراخواننده باید بداند آن عملیات در کجا پیگیری می‌شود و چه زمانی پایانش اهمیت دارد.

اگر کاربر چند بار جست‌وجو می‌کند، هر پاسخ async متعلق به درخواست خودش است. نتیجه قدیمی ممکن است پس از نتیجه تازه برسد. در این حالت، شناسه درخواست جاری یا signal لغو به حفظ ترتیب نمایش کمک می‌کند. Promise.all برای درخواست‌های مستقل یک گروه، این مسئله را برای همه گروه‌های بعدی حل نمی‌کند؛ قرارداد اعتبار پاسخ باید در state برنامه حضور داشته باشد.

واکنش به این مقاله

اشتراک‌گذاری این مقاله:
دیدگاه‌ها 0
۰ / ۲۰۰۰

هنوز دیدگاهی ثبت نشده

اولین نفری باشید که نظر می‌دهد!