Розбираємо Async/Await у JavaScript на прикладах

Автор статті розбирає на прикладах Async/Await JavaScript. Загалом Async/Await — зручний спосіб написання асинхронного коду. До появи цієї можливості подібний код писали з використанням колббеків та промісів. Автор оригінальної статті розкриває переваги Async/Await, розбираючи різноманітні приклади.

Нагадуємо: для всіх читачів "Хабра" - знижка 10 000 рублів при записі на будь-який курс Skillbox за промокодом "Хабр".

Skillbox рекомендує: Освітній онлайн-курс «Java-розробник».

Callback

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.

Недоліки такого способу очевидні:

  • Цей код важко читати.
  • У ньому також складно обробляти помилки, що найчастіше призводить до погіршення якості коду.

Щоб вирішити цю проблему, в JavaScript були додані проміси. Вони дозволяють замінити глибоку вкладеність коллбеків словом .then.

Позитивним моментом промісів стало те, що з ними код читається набагато краще, причому зверху донизу, а не зліва направо. Проте проміси теж мають свої проблеми:

  • Потрібно додавати велику кількість .then.
  • Замість try/catch використовується .catch для обробки всіх помилок.
  • Робота з кількома промісами в рамках одного циклу далеко не завжди зручна, у деяких випадках вони ускладнюють код.

Ось завдання, яке покаже значення останнього пункту.

Припустимо, що є цикл for, що виводить послідовність чисел від 0 до 10 із випадковим інтервалом (0-n секунд). Використовуючи проміси, потрібно змінити цей цикл таким чином, щоб числа виводилися в послідовності від 0 до 10. Так, якщо виведення нуля займає 6 секунд, а одиниці — 2 секунди, спочатку має бути виведено нуль, а потім почнеться відлік виведення одиниці.

І, звичайно, для вирішення цього завдання ми не використовуємо Async/Await або .sort. Приклад рішення – наприкінці.

Async-функції

Додавання async-функцій до ES2017 (ES8) спростило завдання роботи з промісами. Зазначу, що async-функції працюють «поверх» промісів. Ці функції не є якісно іншими концепціями. Async функції замислювалися як альтернатива коду, який використовує проміси.

Async/Await дає можливість організувати роботу з асинхронним кодом у синхронному стилі.

Таким чином, знання промісів полегшує розуміння принципів Async/Await.

Синтаксис

У звичайній ситуації він складається з двох ключових слів: async і await. Перше слово і перетворює функцію на асинхронну. У таких функціях дозволяється використання 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.resolve повернеться Promise.reject, що містить помилку.

Async функції на виході завжди дають проміс, незалежно від того, що повертається.

Асинхронні функції призупиняються при кожному await.

Await впливає вирази. Так, якщо вираз є промісом, 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 виконання функції призупиняється, поки не отримує своє значення (у поточній ситуації це 9).
  • delayAndGetRandom(1000) призупиняє виконання fn-функції, доки не завершиться сама (після 1 секунди). Це фактично є зупинкою fn функції на 1 секунду.
  • delayAndGetRandom(1000) через resolve повертає випадкове значення, яке потім присвоюється змінною b.
  • Ну а випадок зі змінною з аналогічним випадком зі змінною а. Після цього все зупиняється на секунду, але тепер 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);
  }
}

Обробка помилок

Необроблені помилки обертаються у rejected проміс. Проте в async-функціях можна використовувати конструкцію 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() — це асинхронна функція, яка або вдало виконується (perfect number), або невдало завершується з помилкою (Sorry, number too big).

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

Оскільки у прикладі вище очікується виконання canRejectOrReturn, то власне невдале завершення спричинить виконання блоку catch. В результаті функція foo завершиться або з undefined (коли в блоці try нічого не повертається), або з error caught. У результаті ця функція нічого очікувати невдалого завершення, оскільки try/catch займеться обробкою самої функції foo.

Ось ще приклад:

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

Варто звернути увагу на те, що у прикладі з foo повертається canRejectOrReturn. Foo у разі завершується або perfect number, або повертається помилка Error (“Sorry, number too big”). Блок catch ніколи не буде виконуватись.

Проблема в тому, що foo повертає проміс, переданий від canRejectOrReturn. Тому рішення функції foo стає рішенням для canRejectOrReturn. У цьому випадку код складатиметься всього з двох рядків:

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

А ось що буде, якщо використовувати разом await та return:

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

У коді вище foo вдало завершиться як із perfect number, так і з error caught. Тут відмов не буде. Але foo завершиться з canRejectOrReturn, а не з undefined. Давайте переконаємося в цьому, прибравши рядок return await canRejectOrReturn():

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

Поширені помилки та підводні камені

У деяких випадках використання Async/Await може призвести до помилок.

Забутий await

Таке трапляється досить часто – перед промісом забувається ключове слово await:

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

У коді, очевидно, немає ні await, ні return. Тому foo завжди завершується з undefined без затримки в 1 секунду. Але проміс виконуватиметься. Якщо ж він видає помилку або реджект, то в цьому випадку викликатиметься UnhandledPromiseRejectionWarning.

Async-функції у зворотних викликах

Async-функції досить часто використовуються в .map або .filter як коллбеки. Як приклад можна навести функцію fetchPublicReposCount(username), яка повертає кількість відкритих на 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. Тут counts — масив промісів, а .map — анонімний зворотний виклик для кожного вказаного користувача.

Надмірно послідовне використання await

Як приклад візьмемо такий код:

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;
}

Тут змінну count міститься число репо, потім це число додається масив counts. Проблема коду в тому, що поки з сервера не надійдуть дані першого користувача, всі наступні користувачі будуть у режимі очікування. Таким чином, в один момент обробляється лише один користувач.

Якщо, наприклад, на обробку одного користувача йде близько 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-функцій варто скористатися Async Iterators. JavaScript-розробник повинен добре розумітися на цьому.

Skillbox рекомендує:

Джерело: habr.com

Додати коментар або відгук