اجازه دهید Async/Await در جاوا اسکریپت را با استفاده از مثال‌ها بررسی کنیم

نویسنده مقاله به بررسی نمونه هایی از Async/Await در جاوا اسکریپت می پردازد. به طور کلی، Async/Await یک راه راحت برای نوشتن کد ناهمزمان است. قبل از اینکه این ویژگی ظاهر شود، چنین کدهایی با استفاده از تماس ها و وعده ها نوشته می شد. نویسنده مقاله اصلی با تجزیه و تحلیل مثال های مختلف مزایای Async/Await را آشکار می کند.

یادآوری می کنیم: برای همه خوانندگان "Habr" - تخفیف 10 روبل هنگام ثبت نام در هر دوره Skillbox با استفاده از کد تبلیغاتی "Habr".

Skillbox توصیه می کند: دوره آموزشی آنلاین "توسعه دهنده جاوا".

تماس لطفا

Callback تابعی است که تماس آن به طور نامحدود به تاخیر می افتد. پیش از این، تماس‌های برگشتی در مناطقی از کد استفاده می‌شد که نتیجه را نمی‌توان فوراً به دست آورد.

در اینجا مثالی از خواندن ناهمزمان یک فایل در Node.js آمده است:

fs.readFile(__filename, 'utf-8', (err, data) => {
  if (err) {
    throw err;
  }
  console.log(data);
});

مشکلات زمانی به وجود می آیند که شما نیاز دارید چندین عملیات ناهمزمان را همزمان انجام دهید. بیایید این سناریو را تصور کنیم: یک درخواست از پایگاه داده کاربران Arfat ارسال می شود، شما باید قسمت profile_img_url آن را بخوانید و یک تصویر را از سرور someserver.com دانلود کنید.
پس از دانلود، تصویر را به فرمت دیگری مثلا از PNG به JPEG تبدیل می کنیم. در صورت موفقیت آمیز بودن تبدیل، نامه ای به ایمیل کاربر ارسال می شود. در مرحله بعد، اطلاعات مربوط به رویداد با نشان دادن تاریخ در فایل transformations.log وارد می شود.

توجه به همپوشانی کال بک ها و تعداد زیاد }) در قسمت پایانی کد خالی از لطف نیست. نام آن Callback Hell یا Pyramid of Doom است.

معایب این روش واضح است:

  • خواندن این کد دشوار است.
  • همچنین رسیدگی به خطاها دشوار است که اغلب منجر به کیفیت پایین کد می شود.

برای حل این مشکل، وعده هایی به جاوا اسکریپت اضافه شد. آنها به شما این امکان را می دهند که لانه سازی عمیق callback ها را با کلمه .tn جایگزین کنید.

جنبه مثبت وعده ها این است که کد را بسیار خواناتر می کنند، از بالا به پایین به جای از چپ به راست. با این حال، وعده ها نیز مشکلات خود را دارند:

  • باید مقدار زیادی .سپس اضافه کنید.
  • به جای try/catch، .catch برای رسیدگی به همه خطاها استفاده می شود.
  • کار با چندین وعده در یک حلقه همیشه راحت نیست؛ در برخی موارد، کد را پیچیده می کنند.

در اینجا یک مشکل وجود دارد که معنای آخرین نکته را نشان می دهد.

فرض کنید ما یک حلقه for داریم که دنباله ای از اعداد از 0 تا 10 را در فواصل تصادفی (0 تا n ثانیه) چاپ می کند. با استفاده از وعده ها باید این حلقه را تغییر دهید تا اعداد به ترتیب از 0 تا 10 چاپ شوند. بنابراین، اگر چاپ صفر 6 ثانیه و چاپ یک 2 ثانیه طول می کشد، ابتدا باید صفر چاپ شود و سپس چاپ شود. شمارش معکوس برای چاپ یکی آغاز خواهد شد.

و البته برای حل این مشکل از Async/Await یا .sort استفاده نمی کنیم. یک مثال راه حل در پایان آمده است.

توابع ناهمگام

اضافه شدن توابع async در ES2017 (ES8) کار با وعده ها را ساده کرد. توجه داشته باشم که توابع async "در بالای" وعده ها کار می کنند. این توابع مفاهیم کیفی متفاوتی را نشان نمی دهند. توابع Async به عنوان جایگزینی برای کدهایی هستند که از وعده ها استفاده می کنند.

Async/Await سازماندهی کار با کد ناهمزمان را به سبک همزمان امکان پذیر می کند.

بنابراین، دانستن وعده‌ها درک اصول Async/Await را آسان‌تر می‌کند.

نحو

به طور معمول از دو کلمه کلیدی تشکیل شده است: async و await. کلمه اول تابع را به ناهمزمان تبدیل می کند. چنین توابعی امکان استفاده از انتظار را فراهم می کند. در هر صورت استفاده از این تابع باعث ایجاد خطا می شود.

// With function declaration
 
async function myFn() {
  // await ...
}
 
// With arrow function
 
const myFn = async () => {
  // await ...
}
 
function myFn() {
  // await fn(); (Syntax Error since no async)
}
 

Async در همان ابتدای اعلان تابع و در مورد تابع فلش بین علامت "=" و پرانتز درج می شود.

این توابع را می توان در یک شی به عنوان متد قرار داد یا در یک اعلان کلاس استفاده کرد.

// As an object's method
 
const obj = {
  async getName() {
    return fetch('https://www.example.com');
  }
}
 
// In a class
 
class Obj {
  async getResource() {
    return fetch('https://www.example.com');
  }
}

NB! شایان ذکر است که سازنده‌های کلاس و دریافت‌کننده/تنظیم‌کننده‌ها نمی‌توانند ناهمزمان باشند.

معناشناسی و قواعد اجرا

توابع Async اساساً مشابه توابع استاندارد JS هستند، اما استثناهایی نیز وجود دارد.

بنابراین، توابع async همیشه وعده‌ها را برمی‌گردانند:

async function fn() {
  return 'hello';
}
fn().then(console.log)
// hello

به طور خاص، fn رشته hello را برمی گرداند. خوب، از آنجایی که این یک تابع ناهمزمان است، مقدار رشته در یک وعده با استفاده از یک سازنده پیچیده می شود.

در اینجا یک طراحی جایگزین بدون Async آمده است:

function fn() {
  return Promise.resolve('hello');
}
 
fn().then(console.log);
// hello

در این حالت، قول به صورت دستی برگردانده می شود. یک تابع ناهمزمان همیشه در یک وعده جدید پیچیده می شود.

اگر مقدار بازگشتی یک مقدار اولیه باشد، تابع async با قرار دادن آن در یک وعده، مقدار را برمی‌گرداند. اگر مقدار بازگشتی یک شی وعده باشد، وضوح آن در یک وعده جدید برگردانده می شود.

const p = Promise.resolve('hello')
p instanceof Promise;
// true
 
Promise.resolve(p) === p;
// true
 

اما اگر در یک تابع ناهمزمان خطایی وجود داشته باشد چه اتفاقی می افتد؟

async function foo() {
  throw Error('bar');
}
 
foo().catch(console.log);

اگر پردازش نشود، foo() یک وعده را با رد برمی گرداند. در این شرایط، Promise.reject حاوی خطا به جای Promise.resolve برگردانده می شود.

توابع Async همیشه یک وعده خروجی می دهند، صرف نظر از اینکه چه چیزی برگردانده می شود.

توابع ناهمزمان در هر انتظار مکث می کنند.

انتظار بر عبارات تأثیر می گذارد. بنابراین، اگر عبارت یک وعده باشد، تابع async تا زمانی که وعده محقق شود، تعلیق می شود. اگر عبارت وعده ای نباشد، از طریق Promise.resolve به قول تبدیل می شود و سپس تکمیل می شود.

// utility function to cause delay
// and get random value
 
const delayAndGetRandom = (ms) => {
  return new Promise(resolve => setTimeout(
    () => {
      const val = Math.trunc(Math.random() * 100);
      resolve(val);
    }, ms
  ));
};
 
async function fn() {
  const a = await 9;
  const b = await delayAndGetRandom(1000);
  const c = await 5;
  await delayAndGetRandom(1000);
 
  return a + b * c;
}
 
// Execute fn
fn().then(console.log);

و در اینجا توضیحی در مورد نحوه عملکرد تابع fn ارائه شده است.

  • پس از فراخوانی آن، خط اول از const a = await 9 تبدیل می شود. در const a = await Promise.resolve(9);.
  • پس از استفاده از Await، اجرای تابع تا زمانی که a مقدار خود را به دست آورد (در وضعیت فعلی 9 است) به حالت تعلیق در می آید.
  • delayAndGetRandom(1000) اجرای تابع fn را تا زمانی که خودش کامل شود (پس از 1 ثانیه) متوقف می کند. این به طور موثر عملکرد fn را برای 1 ثانیه متوقف می کند.
  • delayAndGetRandom(1000) via solve مقدار تصادفی را برمی گرداند که سپس به متغیر b اختصاص داده می شود.
  • خوب، مورد متغیر c مشابه حالت متغیر a است. پس از آن، همه چیز برای یک ثانیه متوقف می شود، اما اکنون delayAndGetRandom(1000) چیزی را برمی گرداند زیرا نیازی به آن نیست.
  • در نتیجه، مقادیر با استفاده از فرمول a + b * c محاسبه می شوند. نتیجه با استفاده از Promise.resolve در یک وعده پیچیده شده و توسط تابع برگردانده می شود.

این مکث ها ممکن است یادآور ژنراتورهای ES6 باشد، اما چیزی در آن وجود دارد دلایل شما.

حل مشکل

خوب، حالا بیایید راه حل مشکل ذکر شده در بالا را بررسی کنیم.

تابع finishMyTask از Await برای منتظر ماندن برای نتایج عملیاتی مانند queryDatabase، sendEmail، logTaskInFile و غیره استفاده می کند. اگر این راه حل را با راه حلی که در آن از وعده ها استفاده شده است مقایسه کنید، شباهت ها آشکار می شود. با این حال، نسخه Async/Await تمام پیچیدگی های نحوی را بسیار ساده می کند. در این حالت تعداد کال بک و زنجیره ای مانند .then/.catch زیاد نیست.

در اینجا یک راه حل با خروجی اعداد وجود دارد، دو گزینه وجود دارد.

const wait = (i, ms) => new Promise(resolve => setTimeout(() => resolve(i), ms));
 
// Implementation One (Using for-loop)
const printNumbers = () => new Promise((resolve) => {
  let pr = Promise.resolve(0);
  for (let i = 1; i <= 10; i += 1) {
    pr = pr.then((val) => {
      console.log(val);
      return wait(i, Math.random() * 1000);
    });
  }
  resolve(pr);
});
 
// Implementation Two (Using Recursion)
 
const printNumbersRecursive = () => {
  return Promise.resolve(0).then(function processNextPromise(i) {
 
    if (i === 10) {
      return undefined;
    }
 
    return wait(i, Math.random() * 1000).then((val) => {
      console.log(val);
      return processNextPromise(i + 1);
    });
  });
};

و در اینجا یک راه حل با استفاده از توابع async وجود دارد.

async function printNumbersUsingAsync() {
  for (let i = 0; i < 10; i++) {
    await wait(i, Math.random() * 1000);
    console.log(i);
  }
}

خطا در پردازش

خطاهای کنترل نشده در یک وعده رد شده پیچیده می شوند. با این حال، توابع غیر همگام می توانند از try/catch برای رسیدگی همزمان خطاها استفاده کنند.

async function canRejectOrReturn() {
  // wait one second
  await new Promise(res => setTimeout(res, 1000));
 
// Reject with ~50% probability
  if (Math.random() > 0.5) {
    throw new Error('Sorry, number too big.')
  }
 
return 'perfect number';
}

canRejectOrReturn() یک تابع ناهمزمان است که یا موفق می شود ("تعداد کامل") یا با خطا ("متاسفم، عدد خیلی بزرگ") شکست می خورد.

async function foo() {
  try {
    await canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}

از آنجایی که مثال بالا انتظار دارد canRejectOrReturn اجرا شود، شکست خود منجر به اجرای بلوک catch می شود. در نتیجه، تابع foo یا با تعریف نشده (زمانی که چیزی در بلوک try برگردانده نمی شود) یا با یک خطا به پایان می رسد. در نتیجه، این تابع شکست نخواهد خورد زیرا try/catch خود تابع foo را مدیریت می کند.

این هم یک مثال دیگر:

async function foo() {
  try {
    return canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}

شایان توجه است که در مثال canRejectOrReturn از foo برگردانده شده است. Foo در این مورد یا با یک عدد کامل خاتمه می‌یابد یا یک خطا برمی‌گرداند («متاسفم، عدد خیلی بزرگ»). بلوک catch هرگز اجرا نخواهد شد.

مشکل این است که foo وعده داده شده از canRejectOrReturn را برمی گرداند. بنابراین راه حل foo به راه حل canRejectOrReturn تبدیل می شود. در این حالت، کد فقط از دو خط تشکیل خواهد شد:

try {
    const promise = canRejectOrReturn();
    return promise;
}

اگر با هم از انتظار و بازگشت استفاده کنید چه اتفاقی می‌افتد:

async function foo() {
  try {
    return await canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}

در کد بالا، foo با موفقیت هر دو با یک عدد کامل و یک خطا خارج می شود. در اینجا هیچ ردی وجود نخواهد داشت. اما foo با canRejectOrReturn بازخواهد گشت، نه با undefined. بیایید با حذف خط بازگشت await canRejectOrReturn() از این مطمئن شویم:

try {
    const value = await canRejectOrReturn();
    return value;
}
// …

اشتباهات و مشکلات رایج

در برخی موارد، استفاده از Async/Await می تواند منجر به خطا شود.

انتظار فراموش شده

این اغلب اتفاق می افتد - کلمه کلیدی انتظار قبل از وعده فراموش می شود:

async function foo() {
  try {
    canRejectOrReturn();
  } catch (e) {
    return 'caught';
  }
}

همانطور که می بینید، هیچ انتظار یا بازگشتی در کد وجود ندارد. بنابراین foo همیشه با undefined بدون 1 ثانیه تاخیر خارج می شود. اما وعده عملی خواهد شد. اگر خطا یا ردی ایجاد کند، UnhandledPromiseRejectionWarning فراخوانی می شود.

توابع ناهمگام در پاسخ به تماس ها

توابع Async اغلب در .map یا .filter به عنوان پاسخ به تماس استفاده می شوند. یک مثال تابع fetchPublicReposCount (نام کاربری) است که تعداد مخازن باز را در GitHub برمی‌گرداند. فرض کنید سه کاربر وجود دارد که به معیارهای آنها نیاز داریم. در اینجا کد این کار وجود دارد:

const url = 'https://api.github.com/users';
 
// Utility fn to fetch repo counts
const fetchPublicReposCount = async (username) => {
  const response = await fetch(`${url}/${username}`);
  const json = await response.json();
  return json['public_repos'];
}

به حساب های ArfatSalman، octocat، norvig نیازمندیم. در این مورد ما انجام می دهیم:

const users = [
  'ArfatSalman',
  'octocat',
  'norvig'
];
 
const counts = users.map(async username => {
  const count = await fetchPublicReposCount(username);
  return count;
});

ارزش توجه به Await را در .map callback دارد. در اینجا count آرایه‌ای از وعده‌ها است و .map یک تماس ناشناس برای هر کاربر مشخص است.

استفاده بیش از حد مداوم از انتظار

بیایید این کد را به عنوان مثال در نظر بگیریم:

async function fetchAllCounts(users) {
  const counts = [];
  for (let i = 0; i < users.length; i++) {
    const username = users[i];
    const count = await fetchPublicReposCount(username);
    counts.push(count);
  }
  return counts;
}

در اینجا شماره repo در متغیر count قرار می گیرد، سپس این عدد به آرایه count اضافه می شود. مشکل کد این است که تا زمانی که اطلاعات کاربر اول از سرور برسد، همه کاربران بعدی در حالت آماده به کار خواهند بود. بنابراین، تنها یک کاربر در یک زمان پردازش می شود.

برای مثال، اگر برای پردازش یک کاربر حدود 300 میلی‌ثانیه طول می‌کشد، برای همه کاربران از قبل یک ثانیه است؛ زمان صرف شده به صورت خطی به تعداد کاربران بستگی دارد. اما از آنجایی که به دست آوردن تعداد مخزن به یکدیگر بستگی ندارد، فرآیندها را می توان موازی کرد. برای این کار باید با .map و Promise.all کار کنید:

async function fetchAllCounts(users) {
  const promises = users.map(async username => {
    const count = await fetchPublicReposCount(username);
    return count;
  });
  return Promise.all(promises);
}

Promise.all آرایه ای از وعده ها را به عنوان ورودی دریافت می کند و یک وعده را برمی گرداند. دومی، پس از اتمام تمام وعده‌های آرایه یا در اولین رد، تکمیل می‌شود. ممکن است اتفاق بیفتد که همه آنها به طور همزمان شروع نشوند - برای اطمینان از شروع همزمان، می توانید از p-map استفاده کنید.

نتیجه

توابع Async به طور فزاینده ای برای توسعه مهم می شوند. خوب، برای استفاده تطبیقی ​​از توابع async، باید استفاده کنید تکرار کننده های غیر همگام. یک توسعه دهنده جاوا اسکریپت باید به خوبی در این زمینه مسلط باشد.

Skillbox توصیه می کند:

منبع: www.habr.com

اضافه کردن نظر